feat: support workspace-managed SSH repository access
This commit is contained in:
@@ -39,7 +39,7 @@ reqwest = { version = "0.13", optional = true, default-features = false, feature
|
|||||||
ring.workspace = true
|
ring.workspace = true
|
||||||
tar.workspace = true
|
tar.workspace = true
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
tokio = { workspace = true, features = ["net", "rt", "sync", "time"] }
|
tokio = { workspace = true, features = ["net", "process", "rt", "sync", "time"] }
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
tracing-subscriber.workspace = true
|
tracing-subscriber.workspace = true
|
||||||
toml.workspace = true
|
toml.workspace = true
|
||||||
|
|||||||
@@ -119,9 +119,16 @@ impl std::fmt::Debug for SensitiveString {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct RepositorySshMaterializationAccess {
|
pub struct RepositorySshCredentialCandidate {
|
||||||
pub credential_id: String,
|
pub credential_id: String,
|
||||||
pub credential_revision: u64,
|
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_id: String,
|
||||||
pub host_trust_revision: u64,
|
pub host_trust_revision: u64,
|
||||||
pub access: workspace_api::RepositoryAccessMode,
|
pub access: workspace_api::RepositoryAccessMode,
|
||||||
@@ -131,8 +138,6 @@ pub struct RepositorySshMaterializationAccess {
|
|||||||
pub repository_uri: String,
|
pub repository_uri: String,
|
||||||
pub secret_resource: crate::resource::BackendResourceHandle,
|
pub secret_resource: crate::resource::BackendResourceHandle,
|
||||||
#[serde(skip, default)]
|
#[serde(skip, default)]
|
||||||
pub private_key: SensitiveString,
|
|
||||||
#[serde(skip, default)]
|
|
||||||
pub known_hosts_entry: SensitiveString,
|
pub known_hosts_entry: SensitiveString,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ use crate::retention::{
|
|||||||
};
|
};
|
||||||
#[cfg(feature = "ws-server")]
|
#[cfg(feature = "ws-server")]
|
||||||
use crate::runtime::RuntimeSubscriptionRecvError;
|
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::{
|
use crate::workspace_issuer::{
|
||||||
RuntimeVerificationSigner, VerifiedWorkspaceCapability, WORKSPACE_VERIFICATION_ACK_PATH,
|
RuntimeVerificationSigner, VerifiedWorkspaceCapability, WORKSPACE_VERIFICATION_ACK_PATH,
|
||||||
WORKSPACE_VERIFICATION_CHALLENGE_PATH, WORKSPACE_VERIFICATION_OPERATION,
|
WORKSPACE_VERIFICATION_CHALLENGE_PATH, WORKSPACE_VERIFICATION_OPERATION,
|
||||||
@@ -58,7 +63,6 @@ use serde::{Deserialize, Serialize};
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
#[cfg(feature = "fs-store")]
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
@@ -181,12 +185,27 @@ fn runtime_http_router_with_optional_auth(
|
|||||||
runtime: Runtime,
|
runtime: Runtime,
|
||||||
local_token: Option<String>,
|
local_token: Option<String>,
|
||||||
workspace_auth: Option<WorkspaceRuntimeHttpAuth>,
|
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 {
|
) -> Router {
|
||||||
let state = RuntimeHttpState {
|
let state = RuntimeHttpState {
|
||||||
runtime,
|
runtime,
|
||||||
local_token: local_token.map(Arc::<str>::from),
|
local_token: local_token.map(Arc::<str>::from),
|
||||||
workspace_auth: workspace_auth.map(Arc::new),
|
workspace_auth: workspace_auth.map(Arc::new),
|
||||||
workdir_sessions: Arc::new(Mutex::new(HashMap::new())),
|
workdir_sessions: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
ssh_keyscan_program: Arc::new(ssh_keyscan_program),
|
||||||
};
|
};
|
||||||
|
|
||||||
let router = Router::new()
|
let router = Router::new()
|
||||||
@@ -220,6 +239,10 @@ fn runtime_http_router_with_optional_auth(
|
|||||||
"/v1/working-directories/repository-access",
|
"/v1/working-directories/repository-access",
|
||||||
post(authorize_working_directory_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/repository-refs/observe", post(observe_repository_ref))
|
||||||
.route(
|
.route(
|
||||||
"/v1/working-directories/{working_directory_id}/sessions",
|
"/v1/working-directories/{working_directory_id}/sessions",
|
||||||
@@ -293,6 +316,7 @@ struct RuntimeHttpState {
|
|||||||
local_token: Option<Arc<str>>,
|
local_token: Option<Arc<str>>,
|
||||||
workspace_auth: Option<Arc<WorkspaceRuntimeHttpAuth>>,
|
workspace_auth: Option<Arc<WorkspaceRuntimeHttpAuth>>,
|
||||||
workdir_sessions: Arc<Mutex<HashMap<String, RuntimeHttpWorkdirSession>>>,
|
workdir_sessions: Arc<Mutex<HashMap<String, RuntimeHttpWorkdirSession>>>,
|
||||||
|
ssh_keyscan_program: Arc<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[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(
|
async fn observe_repository_ref(
|
||||||
State(state): State<RuntimeHttpState>,
|
State(state): State<RuntimeHttpState>,
|
||||||
Extension(auth): Extension<RuntimeAuthContext>,
|
Extension(auth): Extension<RuntimeAuthContext>,
|
||||||
@@ -2174,10 +2237,11 @@ fn required_runtime_permission(method: &Method, path: &str) -> Option<&'static s
|
|||||||
return Some("workers:create");
|
return Some("workers:create");
|
||||||
}
|
}
|
||||||
if (path == "/v1/working-directories/repository-access"
|
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
|
&& *method == Method::POST
|
||||||
{
|
{
|
||||||
return Some("workdirs:operate");
|
return Some(SSH_HOST_KEY_PROBE_OPERATION);
|
||||||
}
|
}
|
||||||
if path.starts_with("/v1/workdir-sessions")
|
if path.starts_with("/v1/workdir-sessions")
|
||||||
|| (path.starts_with("/v1/working-directories/") && path.ends_with("/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"),
|
required_runtime_permission(&Method::POST, "/v1/repository-refs/observe"),
|
||||||
Some("workdirs:operate")
|
Some("workdirs:operate")
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
required_runtime_permission(&Method::POST, SSH_HOST_KEY_PROBE_PATH),
|
||||||
|
Some(SSH_HOST_KEY_PROBE_OPERATION)
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
required_runtime_permission(&Method::POST, "/v1/working-directories/wd-1/sessions"),
|
required_runtime_permission(&Method::POST, "/v1/working-directories/wd-1/sessions"),
|
||||||
Some("workdirs:operate")
|
Some("workdirs:operate")
|
||||||
@@ -2890,6 +2958,7 @@ mod tests {
|
|||||||
session: session.clone(),
|
session: session.clone(),
|
||||||
},
|
},
|
||||||
)]))),
|
)]))),
|
||||||
|
ssh_keyscan_program: Arc::new(PathBuf::from("ssh-keyscan")),
|
||||||
};
|
};
|
||||||
let auth = RuntimeAuthContext {
|
let auth = RuntimeAuthContext {
|
||||||
server_id: "server-a".to_string(),
|
server_id: "server-a".to_string(),
|
||||||
@@ -3245,6 +3314,112 @@ mod tests {
|
|||||||
assert_eq!(response.status(), StatusCode::OK);
|
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]
|
#[tokio::test]
|
||||||
async fn runtime_errors_use_typed_rest_error_shape() {
|
async fn runtime_errors_use_typed_rest_error_shape() {
|
||||||
let token = "local-token";
|
let token = "local-token";
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ pub mod resource;
|
|||||||
#[cfg(feature = "fs-store")]
|
#[cfg(feature = "fs-store")]
|
||||||
pub mod retention;
|
pub mod retention;
|
||||||
mod runtime;
|
mod runtime;
|
||||||
|
pub mod ssh_host_key_probe;
|
||||||
pub mod worker_backend;
|
pub mod worker_backend;
|
||||||
pub mod worker_source;
|
pub mod worker_source;
|
||||||
pub mod working_directory;
|
pub mod working_directory;
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ use worker_runtime::workspace_issuer::{
|
|||||||
FileWorkspaceClaimReplayProtection, FileWorkspaceRuntimeVerificationAuthority,
|
FileWorkspaceClaimReplayProtection, FileWorkspaceRuntimeVerificationAuthority,
|
||||||
MAX_WORKSPACE_ISSUER_TRUST_RECORDS, RuntimeVerificationSigner, WorkspaceCapabilityVerifier,
|
MAX_WORKSPACE_ISSUER_TRUST_RECORDS, RuntimeVerificationSigner, WorkspaceCapabilityVerifier,
|
||||||
WorkspaceIssuerTrustError, WorkspaceIssuerTrustMutation, WorkspaceIssuerTrustRecord,
|
WorkspaceIssuerTrustError, WorkspaceIssuerTrustMutation, WorkspaceIssuerTrustRecord,
|
||||||
add_workspace_issuer_trust, replace_workspace_issuer_trust, revoke_workspace_issuer_trust,
|
WorkspaceIssuerTrustState, add_workspace_issuer_trust, replace_workspace_issuer_trust,
|
||||||
validate_workspace_issuer_trust_records,
|
revoke_workspace_issuer_trust, validate_workspace_issuer_trust_records,
|
||||||
};
|
};
|
||||||
use worker_runtime::{Runtime, RuntimeOptions};
|
use worker_runtime::{Runtime, RuntimeOptions};
|
||||||
|
|
||||||
@@ -236,6 +236,33 @@ fn build_runtime(config: &ProcessConfig) -> Result<Runtime, ProcessError> {
|
|||||||
factory = factory.with_resource_client(client.clone());
|
factory = factory.with_resource_client(client.clone());
|
||||||
backend_resource_client = Some(client);
|
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(
|
let backend = Arc::new(
|
||||||
WorkerRuntimeExecutionBackend::new(factory)
|
WorkerRuntimeExecutionBackend::new(factory)
|
||||||
.map_err(ProcessError::WorkerAdapter)?
|
.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 {
|
if let Some(client) = backend_resource_client {
|
||||||
runtime
|
runtime
|
||||||
.install_backend_resource_client(client)
|
.install_backend_resource_client(client)
|
||||||
.map_err(ProcessError::Runtime)?;
|
.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)
|
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 {
|
fn runtime_options_from_http(config: &RuntimeHttpServerConfig) -> RuntimeOptions {
|
||||||
RuntimeOptions {
|
RuntimeOptions {
|
||||||
display_name: config.display_name.clone(),
|
display_name: config.display_name.clone(),
|
||||||
@@ -1521,6 +1565,42 @@ mod tests {
|
|||||||
assert_eq!(error, "unknown auth command `trust-server`");
|
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]
|
#[test]
|
||||||
fn no_store_disables_runtime_catalog_persistence() {
|
fn no_store_disables_runtime_catalog_persistence() {
|
||||||
let config = parse_args(["--no-store"]).unwrap().unwrap();
|
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";
|
"application/vnd.yoi.repository-ssh-access+json";
|
||||||
pub const DEFAULT_PROFILE_SOURCE_ARCHIVE_MAX_BYTES: u64 = 2 * 1024 * 1024;
|
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_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)]
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
pub struct RepositorySshAccessSecret {
|
pub struct RepositorySshAccessSecret {
|
||||||
pub private_key: String,
|
pub credential_candidates: Vec<RepositorySshAccessSecretCandidate>,
|
||||||
pub known_hosts_entry: String,
|
pub known_hosts_entry: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for RepositorySshAccessSecret {
|
impl Drop for RepositorySshAccessSecret {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
zeroize::Zeroize::zeroize(&mut self.private_key);
|
|
||||||
zeroize::Zeroize::zeroize(&mut self.known_hosts_entry);
|
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 {
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
formatter
|
formatter
|
||||||
.debug_struct("RepositorySshAccessSecret")
|
.debug_struct("RepositorySshAccessSecret")
|
||||||
.field("private_key", &"[REDACTED]")
|
.field("credential_candidates", &self.credential_candidates)
|
||||||
.field("known_hosts_entry", &"[REDACTED]")
|
.field("known_hosts_entry", &"[REDACTED]")
|
||||||
.finish()
|
.finish()
|
||||||
}
|
}
|
||||||
@@ -142,6 +167,8 @@ pub enum BackendResourceError {
|
|||||||
Oversized { max_bytes: u64, actual_bytes: u64 },
|
Oversized { max_bytes: u64, actual_bytes: u64 },
|
||||||
#[error("backend resource content type mismatch: expected {expected}, got {actual}")]
|
#[error("backend resource content type mismatch: expected {expected}, got {actual}")]
|
||||||
ContentTypeMismatch { expected: String, actual: String },
|
ContentTypeMismatch { expected: String, actual: String },
|
||||||
|
#[error("backend resource fetch timed out")]
|
||||||
|
Timeout,
|
||||||
#[error("backend resource transport failed: {message}")]
|
#[error("backend resource transport failed: {message}")]
|
||||||
Transport { message: String },
|
Transport { message: String },
|
||||||
#[error("backend resource response is invalid: {message}")]
|
#[error("backend resource response is invalid: {message}")]
|
||||||
@@ -163,6 +190,7 @@ pub struct HttpBackendResourceClient {
|
|||||||
bearer_token: Option<String>,
|
bearer_token: Option<String>,
|
||||||
request_source_signer: Option<RuntimeRequestSourceSigner>,
|
request_source_signer: Option<RuntimeRequestSourceSigner>,
|
||||||
request_source_audience: Option<String>,
|
request_source_audience: Option<String>,
|
||||||
|
request_timeout: std::time::Duration,
|
||||||
client: reqwest::Client,
|
client: reqwest::Client,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,10 +202,16 @@ impl HttpBackendResourceClient {
|
|||||||
bearer_token,
|
bearer_token,
|
||||||
request_source_signer: None,
|
request_source_signer: None,
|
||||||
request_source_audience: None,
|
request_source_audience: None,
|
||||||
|
request_timeout: DEFAULT_BACKEND_RESOURCE_FETCH_TIMEOUT,
|
||||||
client: reqwest::Client::new(),
|
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(
|
pub fn with_runtime_request_source(
|
||||||
mut self,
|
mut self,
|
||||||
identity: &RuntimeIdentityMaterial,
|
identity: &RuntimeIdentityMaterial,
|
||||||
@@ -209,6 +243,7 @@ impl BackendResourceClient for HttpBackendResourceClient {
|
|||||||
let mut builder = self
|
let mut builder = self
|
||||||
.client
|
.client
|
||||||
.post(endpoint.clone())
|
.post(endpoint.clone())
|
||||||
|
.timeout(self.request_timeout)
|
||||||
.header(reqwest::header::CONTENT_TYPE, "application/json")
|
.header(reqwest::header::CONTENT_TYPE, "application/json")
|
||||||
.body(body.clone());
|
.body(body.clone());
|
||||||
if let Some(signer) = self.request_source_signer.as_ref() {
|
if let Some(signer) = self.request_source_signer.as_ref() {
|
||||||
@@ -239,11 +274,14 @@ impl BackendResourceClient for HttpBackendResourceClient {
|
|||||||
} else {
|
} else {
|
||||||
builder
|
builder
|
||||||
};
|
};
|
||||||
let response = builder
|
let response = builder.send().await.map_err(|error| {
|
||||||
.send()
|
if error.is_timeout() {
|
||||||
.await
|
BackendResourceError::Timeout
|
||||||
.map_err(|err| BackendResourceError::Transport {
|
} else {
|
||||||
message: err.to_string(),
|
BackendResourceError::Transport {
|
||||||
|
message: error.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
})?;
|
})?;
|
||||||
if response.status().is_success() {
|
if response.status().is_success() {
|
||||||
response
|
response
|
||||||
@@ -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]
|
#[test]
|
||||||
fn response_verification_detects_digest_mismatch() {
|
fn response_verification_detects_digest_mismatch() {
|
||||||
let bytes = b"archive-bytes";
|
let bytes = b"archive-bytes";
|
||||||
|
|||||||
@@ -189,6 +189,23 @@ impl Runtime {
|
|||||||
Ok(())
|
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.
|
/// Create or restore a filesystem-backed Runtime.
|
||||||
///
|
///
|
||||||
/// The store is scoped by `options.root`; if the directory already exists,
|
/// The store is scoped by `options.root`; if the directory already exists,
|
||||||
@@ -439,26 +456,51 @@ impl Runtime {
|
|||||||
&self,
|
&self,
|
||||||
ssh: &mut crate::catalog::RepositorySshMaterializationAccess,
|
ssh: &mut crate::catalog::RepositorySshMaterializationAccess,
|
||||||
) -> Result<(), RuntimeError> {
|
) -> 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(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let (client, runtime_id) = {
|
let (client, runtime_id) = {
|
||||||
let state = self.lock()?;
|
let state = self.lock()?;
|
||||||
let client = state.backend_resource_client.clone().ok_or_else(|| {
|
let client = state
|
||||||
RuntimeError::InvalidRequest(
|
.workspace_backend_resource_clients
|
||||||
"Backend Repository access resource client is unavailable".to_string(),
|
.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(|| {
|
let runtime_id = state.runtime_identity.clone().ok_or_else(|| {
|
||||||
RuntimeError::InvalidRequest("Runtime identity is unavailable".to_string())
|
RuntimeError::InvalidRequest("Runtime identity is unavailable".to_string())
|
||||||
})?;
|
})?;
|
||||||
(client, runtime_id)
|
(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
|
let mut response = client
|
||||||
.0
|
.0
|
||||||
.fetch_resource(BackendResourceFetchRequest {
|
.fetch_resource(BackendResourceFetchRequest {
|
||||||
handle: ssh.secret_resource.clone(),
|
handle: ssh.secret_resource.clone(),
|
||||||
runtime_id,
|
runtime_id: runtime_id.clone(),
|
||||||
worker_id: None,
|
worker_id: None,
|
||||||
audit_correlation_id: ssh.secret_resource.audit_correlation_id.clone(),
|
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(),
|
"Backend Repository SSH access resource payload was invalid".to_string(),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
ssh.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));
|
crate::catalog::SensitiveString::new(std::mem::take(&mut secret.private_key));
|
||||||
|
}
|
||||||
ssh.known_hosts_entry =
|
ssh.known_hosts_entry =
|
||||||
crate::catalog::SensitiveString::new(std::mem::take(&mut secret.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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -492,10 +564,22 @@ impl Runtime {
|
|||||||
&self,
|
&self,
|
||||||
mut request: WorkingDirectoryRepositoryAccessRequest,
|
mut request: WorkingDirectoryRepositoryAccessRequest,
|
||||||
) -> Result<(), RuntimeError> {
|
) -> Result<(), RuntimeError> {
|
||||||
|
let materialization_runtime_id = request.materialization.runtime_id.clone();
|
||||||
let ssh = request.materialization.ssh.as_mut().ok_or_else(|| {
|
let ssh = request.materialization.ssh.as_mut().ok_or_else(|| {
|
||||||
RuntimeError::InvalidRequest("Repository SSH access metadata is missing".to_string())
|
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)
|
self.authorize_working_directory_repository_access(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2428,6 +2512,7 @@ struct RuntimeState {
|
|||||||
status: RuntimeStatus,
|
status: RuntimeStatus,
|
||||||
execution_backend: Option<WorkerExecutionBackendRef>,
|
execution_backend: Option<WorkerExecutionBackendRef>,
|
||||||
backend_resource_client: Option<BackendResourceClientRef>,
|
backend_resource_client: Option<BackendResourceClientRef>,
|
||||||
|
workspace_backend_resource_clients: BTreeMap<String, BackendResourceClientRef>,
|
||||||
#[cfg(feature = "fs-store")]
|
#[cfg(feature = "fs-store")]
|
||||||
next_diagnostic_id: u64,
|
next_diagnostic_id: u64,
|
||||||
workers: BTreeMap<WorkerId, WorkerRecord>,
|
workers: BTreeMap<WorkerId, WorkerRecord>,
|
||||||
@@ -2458,6 +2543,7 @@ impl RuntimeState {
|
|||||||
status: RuntimeStatus::Running,
|
status: RuntimeStatus::Running,
|
||||||
execution_backend: None,
|
execution_backend: None,
|
||||||
backend_resource_client: None,
|
backend_resource_client: None,
|
||||||
|
workspace_backend_resource_clients: BTreeMap::new(),
|
||||||
#[cfg(feature = "fs-store")]
|
#[cfg(feature = "fs-store")]
|
||||||
next_diagnostic_id: 1,
|
next_diagnostic_id: 1,
|
||||||
workers: BTreeMap::new(),
|
workers: BTreeMap::new(),
|
||||||
@@ -2489,6 +2575,7 @@ impl RuntimeState {
|
|||||||
status: RuntimeStatus::Running,
|
status: RuntimeStatus::Running,
|
||||||
execution_backend: None,
|
execution_backend: None,
|
||||||
backend_resource_client: None,
|
backend_resource_client: None,
|
||||||
|
workspace_backend_resource_clients: BTreeMap::new(),
|
||||||
#[cfg(feature = "fs-store")]
|
#[cfg(feature = "fs-store")]
|
||||||
next_diagnostic_id: 1,
|
next_diagnostic_id: 1,
|
||||||
workers: BTreeMap::new(),
|
workers: BTreeMap::new(),
|
||||||
@@ -2552,6 +2639,7 @@ impl RuntimeState {
|
|||||||
status: persisted.status,
|
status: persisted.status,
|
||||||
execution_backend: None,
|
execution_backend: None,
|
||||||
backend_resource_client: None,
|
backend_resource_client: None,
|
||||||
|
workspace_backend_resource_clients: BTreeMap::new(),
|
||||||
next_diagnostic_id,
|
next_diagnostic_id,
|
||||||
workers,
|
workers,
|
||||||
config_bundles: BTreeMap::new(),
|
config_bundles: BTreeMap::new(),
|
||||||
@@ -3344,6 +3432,10 @@ fn repository_resource_error(error: BackendResourceError) -> RuntimeError {
|
|||||||
"repository_access_credential_unavailable",
|
"repository_access_credential_unavailable",
|
||||||
"Repository access credential lease is unavailable or already consumed",
|
"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 { .. } => (
|
BackendResourceError::Transport { .. } => (
|
||||||
"repository_access_provider_unavailable",
|
"repository_access_provider_unavailable",
|
||||||
"Repository access credential provider is unavailable",
|
"Repository access credential provider is unavailable",
|
||||||
@@ -3615,8 +3707,9 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::catalog::{
|
use crate::catalog::{
|
||||||
ConfigBundleRef, MaterializerKind, ProfileSelector, RepositoryMaterializationContext,
|
ConfigBundleRef, MaterializerKind, ProfileSelector, RepositoryMaterializationContext,
|
||||||
RepositorySshMaterializationAccess, SensitiveString, WorkingDirectoryClaim,
|
RepositorySshCredentialCandidate, RepositorySshMaterializationAccess, SensitiveString,
|
||||||
WorkingDirectoryRepository, WorkingDirectoryRequest, WorkspaceApiRef,
|
WorkingDirectoryClaim, WorkingDirectoryRepository, WorkingDirectoryRequest,
|
||||||
|
WorkspaceApiRef,
|
||||||
};
|
};
|
||||||
use crate::config_bundle::{
|
use crate::config_bundle::{
|
||||||
ConfigBundle, ConfigBundleMetadata, ConfigBundleProvenance, ConfigDeclaration,
|
ConfigBundle, ConfigBundleMetadata, ConfigBundleProvenance, ConfigDeclaration,
|
||||||
@@ -3670,6 +3763,10 @@ mod tests {
|
|||||||
BackendResourceError::MissingResource,
|
BackendResourceError::MissingResource,
|
||||||
"repository_access_credential_unavailable",
|
"repository_access_credential_unavailable",
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
BackendResourceError::Timeout,
|
||||||
|
"repository_access_resource_fetch_timeout",
|
||||||
|
),
|
||||||
(
|
(
|
||||||
BackendResourceError::Unauthorized {
|
BackendResourceError::Unauthorized {
|
||||||
message: "denied".to_string(),
|
message: "denied".to_string(),
|
||||||
@@ -3933,8 +4030,11 @@ mod tests {
|
|||||||
config_projection_digest: "sha256:projection".to_string(),
|
config_projection_digest: "sha256:projection".to_string(),
|
||||||
cache_generation: 0,
|
cache_generation: 0,
|
||||||
ssh: Some(RepositorySshMaterializationAccess {
|
ssh: Some(RepositorySshMaterializationAccess {
|
||||||
|
credential_candidates: vec![RepositorySshCredentialCandidate {
|
||||||
credential_id: "credential-1".to_string(),
|
credential_id: "credential-1".to_string(),
|
||||||
credential_revision: 1,
|
credential_revision: 1,
|
||||||
|
private_key: SensitiveString::new("private-key-bytes"),
|
||||||
|
}],
|
||||||
host_trust_id: "host-trust-1".to_string(),
|
host_trust_id: "host-trust-1".to_string(),
|
||||||
host_trust_revision: 1,
|
host_trust_revision: 1,
|
||||||
access: workspace_api::RepositoryAccessMode::ReadOnly,
|
access: workspace_api::RepositoryAccessMode::ReadOnly,
|
||||||
@@ -3943,7 +4043,6 @@ mod tests {
|
|||||||
repository_source_fingerprint: "sha256:source".to_string(),
|
repository_source_fingerprint: "sha256:source".to_string(),
|
||||||
repository_uri: "ssh://git@example.test/repo.git".to_string(),
|
repository_uri: "ssh://git@example.test/repo.git".to_string(),
|
||||||
secret_resource: repository_resource_handle(),
|
secret_resource: repository_resource_handle(),
|
||||||
private_key: SensitiveString::new("private-key-bytes"),
|
|
||||||
known_hosts_entry: SensitiveString::new("known-hosts-entry"),
|
known_hosts_entry: SensitiveString::new("known-hosts-entry"),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
@@ -4003,20 +4102,35 @@ mod tests {
|
|||||||
runtime.bind_runtime_identity("runtime-1").unwrap();
|
runtime.bind_runtime_identity("runtime-1").unwrap();
|
||||||
let handle = repository_resource_handle();
|
let handle = repository_resource_handle();
|
||||||
runtime
|
runtime
|
||||||
.install_backend_resource_client(Arc::new(TestRepositoryResourceClient {
|
.install_workspace_backend_resource_client(
|
||||||
|
"workspace-1",
|
||||||
|
Arc::new(TestRepositoryResourceClient {
|
||||||
response: Mutex::new(Some(crate::resource::BackendResourceFetchResponse {
|
response: Mutex::new(Some(crate::resource::BackendResourceFetchResponse {
|
||||||
kind: crate::resource::BackendResourceKind::RepositorySshAccess,
|
kind: crate::resource::BackendResourceKind::RepositorySshAccess,
|
||||||
resource_id: handle.resource_id.clone(),
|
resource_id: handle.resource_id.clone(),
|
||||||
digest: handle.digest.clone(),
|
digest: handle.digest.clone(),
|
||||||
content_type: crate::resource::REPOSITORY_SSH_ACCESS_CONTENT_TYPE.to_string(),
|
content_type: crate::resource::REPOSITORY_SSH_ACCESS_CONTENT_TYPE
|
||||||
|
.to_string(),
|
||||||
bytes: serde_json::to_vec(&RepositorySshAccessSecret {
|
bytes: serde_json::to_vec(&RepositorySshAccessSecret {
|
||||||
private_key: "private-key-bytes".to_string(),
|
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(),
|
known_hosts_entry: "known-hosts-entry".to_string(),
|
||||||
})
|
})
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
audit_correlation_id: handle.audit_correlation_id.clone(),
|
audit_correlation_id: handle.audit_correlation_id.clone(),
|
||||||
})),
|
})),
|
||||||
}))
|
}),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let request = WorkingDirectoryRepositoryAccessRequest {
|
let request = WorkingDirectoryRepositoryAccessRequest {
|
||||||
working_directory_id: "working-directory-1".to_string(),
|
working_directory_id: "working-directory-1".to_string(),
|
||||||
@@ -4028,8 +4142,18 @@ mod tests {
|
|||||||
config_projection_digest: "sha256:projection".to_string(),
|
config_projection_digest: "sha256:projection".to_string(),
|
||||||
cache_generation: 0,
|
cache_generation: 0,
|
||||||
ssh: Some(RepositorySshMaterializationAccess {
|
ssh: Some(RepositorySshMaterializationAccess {
|
||||||
|
credential_candidates: vec![
|
||||||
|
RepositorySshCredentialCandidate {
|
||||||
credential_id: "credential-1".to_string(),
|
credential_id: "credential-1".to_string(),
|
||||||
credential_revision: 1,
|
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_id: "host-trust-1".to_string(),
|
||||||
host_trust_revision: 1,
|
host_trust_revision: 1,
|
||||||
access: workspace_api::RepositoryAccessMode::ReadOnly,
|
access: workspace_api::RepositoryAccessMode::ReadOnly,
|
||||||
@@ -4038,7 +4162,6 @@ mod tests {
|
|||||||
repository_source_fingerprint: "sha256:source".to_string(),
|
repository_source_fingerprint: "sha256:source".to_string(),
|
||||||
repository_uri: "ssh://git@example.test/repo.git".to_string(),
|
repository_uri: "ssh://git@example.test/repo.git".to_string(),
|
||||||
secret_resource: handle,
|
secret_resource: handle,
|
||||||
private_key: SensitiveString::default(),
|
|
||||||
known_hosts_entry: SensitiveString::default(),
|
known_hosts_entry: SensitiveString::default(),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@@ -4059,7 +4182,23 @@ mod tests {
|
|||||||
let accesses = backend.repository_accesses.lock().unwrap();
|
let accesses = backend.repository_accesses.lock().unwrap();
|
||||||
assert_eq!(accesses.len(), 1);
|
assert_eq!(accesses.len(), 1);
|
||||||
let access = accesses[0].materialization.ssh.as_ref().unwrap();
|
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");
|
assert_eq!(access.known_hosts_entry.expose(), "known-hosts-entry");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4072,20 +4211,35 @@ mod tests {
|
|||||||
runtime.bind_runtime_identity("runtime-1").unwrap();
|
runtime.bind_runtime_identity("runtime-1").unwrap();
|
||||||
let handle = repository_resource_handle();
|
let handle = repository_resource_handle();
|
||||||
runtime
|
runtime
|
||||||
.install_backend_resource_client(Arc::new(TestRepositoryResourceClient {
|
.install_workspace_backend_resource_client(
|
||||||
|
"workspace-1",
|
||||||
|
Arc::new(TestRepositoryResourceClient {
|
||||||
response: Mutex::new(Some(crate::resource::BackendResourceFetchResponse {
|
response: Mutex::new(Some(crate::resource::BackendResourceFetchResponse {
|
||||||
kind: crate::resource::BackendResourceKind::RepositorySshAccess,
|
kind: crate::resource::BackendResourceKind::RepositorySshAccess,
|
||||||
resource_id: handle.resource_id.clone(),
|
resource_id: handle.resource_id.clone(),
|
||||||
digest: handle.digest.clone(),
|
digest: handle.digest.clone(),
|
||||||
content_type: crate::resource::REPOSITORY_SSH_ACCESS_CONTENT_TYPE.to_string(),
|
content_type: crate::resource::REPOSITORY_SSH_ACCESS_CONTENT_TYPE
|
||||||
|
.to_string(),
|
||||||
bytes: serde_json::to_vec(&RepositorySshAccessSecret {
|
bytes: serde_json::to_vec(&RepositorySshAccessSecret {
|
||||||
private_key: "create-private-key-bytes".to_string(),
|
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(),
|
known_hosts_entry: "create-known-hosts-entry".to_string(),
|
||||||
})
|
})
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
audit_correlation_id: handle.audit_correlation_id.clone(),
|
audit_correlation_id: handle.audit_correlation_id.clone(),
|
||||||
})),
|
})),
|
||||||
}))
|
}),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let request = WorkingDirectoryRequest {
|
let request = WorkingDirectoryRequest {
|
||||||
repository: WorkingDirectoryRepository {
|
repository: WorkingDirectoryRepository {
|
||||||
@@ -4109,8 +4263,18 @@ mod tests {
|
|||||||
config_projection_digest: "sha256:projection".to_string(),
|
config_projection_digest: "sha256:projection".to_string(),
|
||||||
cache_generation: 0,
|
cache_generation: 0,
|
||||||
ssh: Some(RepositorySshMaterializationAccess {
|
ssh: Some(RepositorySshMaterializationAccess {
|
||||||
|
credential_candidates: vec![
|
||||||
|
RepositorySshCredentialCandidate {
|
||||||
credential_id: "credential-1".to_string(),
|
credential_id: "credential-1".to_string(),
|
||||||
credential_revision: 1,
|
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_id: "host-trust-1".to_string(),
|
||||||
host_trust_revision: 1,
|
host_trust_revision: 1,
|
||||||
access: workspace_api::RepositoryAccessMode::ReadOnly,
|
access: workspace_api::RepositoryAccessMode::ReadOnly,
|
||||||
@@ -4119,7 +4283,6 @@ mod tests {
|
|||||||
repository_source_fingerprint: "sha256:source".to_string(),
|
repository_source_fingerprint: "sha256:source".to_string(),
|
||||||
repository_uri: "ssh://git@example.test/repo.git".to_string(),
|
repository_uri: "ssh://git@example.test/repo.git".to_string(),
|
||||||
secret_resource: handle,
|
secret_resource: handle,
|
||||||
private_key: SensitiveString::default(),
|
|
||||||
known_hosts_entry: SensitiveString::default(),
|
known_hosts_entry: SensitiveString::default(),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
@@ -4138,7 +4301,14 @@ mod tests {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|materialization| materialization.ssh.as_ref())
|
.and_then(|materialization| materialization.ssh.as_ref())
|
||||||
.unwrap();
|
.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!(
|
assert_eq!(
|
||||||
access.known_hosts_entry.expose(),
|
access.known_hosts_entry.expose(),
|
||||||
"create-known-hosts-entry"
|
"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
@@ -1152,6 +1152,58 @@ pub struct RepositoryDetailResponse {
|
|||||||
pub source: String,
|
pub source: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct RepositorySshConnectionProbeRequest {
|
||||||
|
pub runtime_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct RepositorySshHostKeyCandidate {
|
||||||
|
pub algorithm: String,
|
||||||
|
pub host_key: String,
|
||||||
|
pub fingerprint: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum RepositorySshConnectionTrustState {
|
||||||
|
Untrusted,
|
||||||
|
Verified,
|
||||||
|
Changed,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct RepositorySshConnectionProbeResponse {
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub repository_key: String,
|
||||||
|
pub runtime_id: String,
|
||||||
|
pub hostname: String,
|
||||||
|
pub port: u16,
|
||||||
|
pub trust_state: RepositorySshConnectionTrustState,
|
||||||
|
pub host_trust_id: String,
|
||||||
|
#[cfg_attr(feature = "typescript", ts(type = "number | null"))]
|
||||||
|
pub expected_host_trust_revision: Option<u64>,
|
||||||
|
pub candidates: Vec<RepositorySshHostKeyCandidate>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct ConfirmRepositorySshHostTrustRequest {
|
||||||
|
pub operation_id: String,
|
||||||
|
pub runtime_id: String,
|
||||||
|
pub host_key: String,
|
||||||
|
#[cfg_attr(feature = "typescript", ts(type = "number | null"))]
|
||||||
|
pub expected_host_trust_revision: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
@@ -2455,6 +2507,27 @@ pub struct CreateRepositorySshCredentialRequest {
|
|||||||
pub passphrase: Option<String>,
|
pub passphrase: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct GenerateRepositorySshCredentialRequest {
|
||||||
|
pub operation_id: String,
|
||||||
|
pub credential_id: String,
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct RepositorySshPublicKey {
|
||||||
|
pub credential_id: String,
|
||||||
|
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||||
|
pub current_revision: u64,
|
||||||
|
pub public_key_algorithm: String,
|
||||||
|
pub public_key_fingerprint: String,
|
||||||
|
pub public_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
@@ -2997,6 +3070,11 @@ pub fn catalog_typescript() -> String {
|
|||||||
GitCommitSummary::decl(&config),
|
GitCommitSummary::decl(&config),
|
||||||
RepositoryListResponse::decl(&config),
|
RepositoryListResponse::decl(&config),
|
||||||
RepositoryDetailResponse::decl(&config),
|
RepositoryDetailResponse::decl(&config),
|
||||||
|
RepositorySshConnectionProbeRequest::decl(&config),
|
||||||
|
RepositorySshHostKeyCandidate::decl(&config),
|
||||||
|
RepositorySshConnectionTrustState::decl(&config),
|
||||||
|
RepositorySshConnectionProbeResponse::decl(&config),
|
||||||
|
ConfirmRepositorySshHostTrustRequest::decl(&config),
|
||||||
RepositoryLogResponse::decl(&config),
|
RepositoryLogResponse::decl(&config),
|
||||||
RuntimeSourceKind::decl(&config),
|
RuntimeSourceKind::decl(&config),
|
||||||
RuntimeSourceStatus::decl(&config),
|
RuntimeSourceStatus::decl(&config),
|
||||||
@@ -3041,6 +3119,8 @@ pub fn repository_access_api_typescript() -> String {
|
|||||||
let declarations = [
|
let declarations = [
|
||||||
RepositorySshCredential::decl(&config),
|
RepositorySshCredential::decl(&config),
|
||||||
CreateRepositorySshCredentialRequest::decl(&config),
|
CreateRepositorySshCredentialRequest::decl(&config),
|
||||||
|
GenerateRepositorySshCredentialRequest::decl(&config),
|
||||||
|
RepositorySshPublicKey::decl(&config),
|
||||||
RotateRepositorySshCredentialRequest::decl(&config),
|
RotateRepositorySshCredentialRequest::decl(&config),
|
||||||
DeleteRepositorySshCredentialRequest::decl(&config),
|
DeleteRepositorySshCredentialRequest::decl(&config),
|
||||||
RepositorySshHostTrust::decl(&config),
|
RepositorySshHostTrust::decl(&config),
|
||||||
|
|||||||
@@ -67,6 +67,10 @@ use worker_runtime::profile_archive::ProfileSourceArchive;
|
|||||||
use worker_runtime::retention::{
|
use worker_runtime::retention::{
|
||||||
WorkerRetentionExecutionRequest, WorkerRetentionExecutionResult, WorkerRetentionInventory,
|
WorkerRetentionExecutionRequest, WorkerRetentionExecutionResult, WorkerRetentionInventory,
|
||||||
};
|
};
|
||||||
|
use worker_runtime::ssh_host_key_probe::{
|
||||||
|
SSH_HOST_KEY_PROBE_OPERATION, SSH_HOST_KEY_PROBE_PATH, SshHostKeyProbeRequest,
|
||||||
|
SshHostKeyProbeResponse,
|
||||||
|
};
|
||||||
use worker_runtime::workspace_issuer::{
|
use worker_runtime::workspace_issuer::{
|
||||||
WorkspaceCapabilityClaims, WorkspaceRuntimeVerificationAcknowledgement,
|
WorkspaceCapabilityClaims, WorkspaceRuntimeVerificationAcknowledgement,
|
||||||
WorkspaceRuntimeVerificationChallenge, WorkspaceRuntimeVerificationReceipt,
|
WorkspaceRuntimeVerificationChallenge, WorkspaceRuntimeVerificationReceipt,
|
||||||
@@ -82,6 +86,9 @@ const MAX_REMOTE_RUNTIME_RESPONSE_BYTES: usize = 16 * 1024 * 1024;
|
|||||||
// Runtime creation can spend up to 60s bootstrapping; durable Submit
|
// Runtime creation can spend up to 60s bootstrapping; durable Submit
|
||||||
// acceptance is acknowledged before the potentially long run preparation.
|
// acceptance is acknowledged before the potentially long run preparation.
|
||||||
const REMOTE_WORKER_CREATE_TIMEOUT: Duration = Duration::from_secs(80);
|
const REMOTE_WORKER_CREATE_TIMEOUT: Duration = Duration::from_secs(80);
|
||||||
|
// Repository materialization can spend up to 300s in Git. Keep the HTTP
|
||||||
|
// caller alive long enough for Runtime to return its bounded result.
|
||||||
|
const REMOTE_WORKING_DIRECTORY_CREATE_TIMEOUT: Duration = Duration::from_secs(330);
|
||||||
const MAX_HOST_SCAN: usize = 256;
|
const MAX_HOST_SCAN: usize = 256;
|
||||||
const MAX_IDENTIFIER_LEN: usize = 120;
|
const MAX_IDENTIFIER_LEN: usize = 120;
|
||||||
const ID_DIGEST_HEX_LEN: usize = 16;
|
const ID_DIGEST_HEX_LEN: usize = 16;
|
||||||
@@ -826,6 +833,17 @@ pub trait WorkspaceWorkerRuntime: Send + Sync {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn probe_ssh_host_keys(
|
||||||
|
&self,
|
||||||
|
_request: SshHostKeyProbeRequest,
|
||||||
|
) -> std::result::Result<SshHostKeyProbeResponse, Error> {
|
||||||
|
Err(Error::RuntimeOperationFailed {
|
||||||
|
runtime_id: self.runtime_id().to_string(),
|
||||||
|
code: "ssh_host_key_probe_unsupported".to_string(),
|
||||||
|
message: "Runtime does not support SSH host key probing".to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn activate_workspace_authorization(&self, _binding: crate::store::WorkspaceRuntimeBinding) {}
|
fn activate_workspace_authorization(&self, _binding: crate::store::WorkspaceRuntimeBinding) {}
|
||||||
|
|
||||||
fn send_workspace_verification_challenge(
|
fn send_workspace_verification_challenge(
|
||||||
@@ -1569,6 +1587,31 @@ impl RuntimeRegistry {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn probe_ssh_host_keys(
|
||||||
|
&self,
|
||||||
|
runtime_id: &str,
|
||||||
|
request: SshHostKeyProbeRequest,
|
||||||
|
) -> Result<SshHostKeyProbeResponse, RuntimeRegistryError> {
|
||||||
|
validate_backend_identifier("runtime_id", runtime_id)?;
|
||||||
|
let runtime = self.runtime(runtime_id)?;
|
||||||
|
runtime
|
||||||
|
.probe_ssh_host_keys(request)
|
||||||
|
.map_err(|error| match error {
|
||||||
|
Error::RuntimeOperationFailed { code, message, .. } => {
|
||||||
|
RuntimeRegistryError::RuntimeOperationFailed {
|
||||||
|
runtime_id: runtime_id.to_string(),
|
||||||
|
code,
|
||||||
|
message,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
other => RuntimeRegistryError::RuntimeOperationFailed {
|
||||||
|
runtime_id: runtime_id.to_string(),
|
||||||
|
code: "ssh_host_key_probe_failed".to_string(),
|
||||||
|
message: other.to_string(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub fn observe_repository_ref(
|
pub fn observe_repository_ref(
|
||||||
&self,
|
&self,
|
||||||
runtime_id: &str,
|
runtime_id: &str,
|
||||||
@@ -3404,10 +3447,11 @@ fn workspace_runtime_operation(method: &str, path_and_query: &str) -> &'static s
|
|||||||
return "workers:create";
|
return "workers:create";
|
||||||
}
|
}
|
||||||
if (path == "/v1/working-directories/repository-access"
|
if (path == "/v1/working-directories/repository-access"
|
||||||
|| path == "/v1/repository-refs/observe")
|
|| path == "/v1/repository-refs/observe"
|
||||||
|
|| path == SSH_HOST_KEY_PROBE_PATH)
|
||||||
&& method == "POST"
|
&& method == "POST"
|
||||||
{
|
{
|
||||||
return "workdirs:operate";
|
return SSH_HOST_KEY_PROBE_OPERATION;
|
||||||
}
|
}
|
||||||
if path.starts_with("/v1/workdir-sessions")
|
if path.starts_with("/v1/workdir-sessions")
|
||||||
|| (path.starts_with("/v1/working-directories/") && path.ends_with("/sessions"))
|
|| (path.starts_with("/v1/working-directories/") && path.ends_with("/sessions"))
|
||||||
@@ -3631,6 +3675,19 @@ impl RemoteWorkerRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn post_json<B, T>(&self, path: &str, body: &B) -> Result<T, RuntimeDiagnostic>
|
fn post_json<B, T>(&self, path: &str, body: &B) -> Result<T, RuntimeDiagnostic>
|
||||||
|
where
|
||||||
|
B: Serialize + ?Sized,
|
||||||
|
T: DeserializeOwned + Send + 'static,
|
||||||
|
{
|
||||||
|
self.post_json_with_timeout(path, body, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn post_json_with_timeout<B, T>(
|
||||||
|
&self,
|
||||||
|
path: &str,
|
||||||
|
body: &B,
|
||||||
|
timeout: Option<Duration>,
|
||||||
|
) -> Result<T, RuntimeDiagnostic>
|
||||||
where
|
where
|
||||||
B: Serialize + ?Sized,
|
B: Serialize + ?Sized,
|
||||||
T: DeserializeOwned + Send + 'static,
|
T: DeserializeOwned + Send + 'static,
|
||||||
@@ -3642,15 +3699,15 @@ impl RemoteWorkerRuntime {
|
|||||||
error.to_string(),
|
error.to_string(),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
self.send_json(
|
let mut request = self
|
||||||
path,
|
.http
|
||||||
"POST",
|
|
||||||
&body,
|
|
||||||
self.http
|
|
||||||
.post(self.endpoint(path))
|
.post(self.endpoint(path))
|
||||||
.header(CONTENT_TYPE, "application/json")
|
.header(CONTENT_TYPE, "application/json")
|
||||||
.body(body.clone()),
|
.body(body.clone());
|
||||||
)
|
if let Some(timeout) = timeout {
|
||||||
|
request = request.timeout(timeout);
|
||||||
|
}
|
||||||
|
self.send_json(path, "POST", &body, request)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn post_bytes<T>(&self, path: &str, body: &[u8]) -> Result<T, RuntimeDiagnostic>
|
fn post_bytes<T>(&self, path: &str, body: &[u8]) -> Result<T, RuntimeDiagnostic>
|
||||||
@@ -4140,9 +4197,10 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
|
|||||||
&self,
|
&self,
|
||||||
request: WorkingDirectoryRequest,
|
request: WorkingDirectoryRequest,
|
||||||
) -> RuntimeWorkingDirectoryResult {
|
) -> RuntimeWorkingDirectoryResult {
|
||||||
match self.post_json::<_, RuntimeHttpWorkingDirectoryResponse>(
|
match self.post_json_with_timeout::<_, RuntimeHttpWorkingDirectoryResponse>(
|
||||||
"/v1/working-directories",
|
"/v1/working-directories",
|
||||||
&request,
|
&request,
|
||||||
|
Some(REMOTE_WORKING_DIRECTORY_CREATE_TIMEOUT),
|
||||||
) {
|
) {
|
||||||
Ok(response) => RuntimeWorkingDirectoryResult {
|
Ok(response) => RuntimeWorkingDirectoryResult {
|
||||||
state: WorkerOperationState::Accepted,
|
state: WorkerOperationState::Accepted,
|
||||||
@@ -4169,6 +4227,18 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
|
|||||||
.map_err(|diagnostic| Error::RegistryInconsistency(diagnostic.message))
|
.map_err(|diagnostic| Error::RegistryInconsistency(diagnostic.message))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn probe_ssh_host_keys(
|
||||||
|
&self,
|
||||||
|
request: SshHostKeyProbeRequest,
|
||||||
|
) -> std::result::Result<SshHostKeyProbeResponse, Error> {
|
||||||
|
self.post_json::<_, SshHostKeyProbeResponse>(SSH_HOST_KEY_PROBE_PATH, &request)
|
||||||
|
.map_err(|diagnostic| Error::RuntimeOperationFailed {
|
||||||
|
runtime_id: self.runtime_id.clone(),
|
||||||
|
code: diagnostic.code,
|
||||||
|
message: diagnostic.message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn observe_repository_ref(
|
fn observe_repository_ref(
|
||||||
&self,
|
&self,
|
||||||
request: RepositoryRefObservationRequest,
|
request: RepositoryRefObservationRequest,
|
||||||
@@ -5369,6 +5439,15 @@ mod tests {
|
|||||||
assert!(REMOTE_WORKER_CREATE_TIMEOUT > Duration::from_secs(60 + 10 + 5));
|
assert!(REMOTE_WORKER_CREATE_TIMEOUT > Duration::from_secs(60 + 10 + 5));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remote_working_directory_create_timeout_covers_git_budget() {
|
||||||
|
assert!(REMOTE_WORKING_DIRECTORY_CREATE_TIMEOUT > Duration::from_secs(300));
|
||||||
|
assert!(
|
||||||
|
REMOTE_WORKING_DIRECTORY_CREATE_TIMEOUT
|
||||||
|
> worker_runtime::resource::DEFAULT_BACKEND_RESOURCE_FETCH_TIMEOUT
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn test_create_binding() -> WorkerCreateBinding {
|
fn test_create_binding() -> WorkerCreateBinding {
|
||||||
WorkerCreateBinding {
|
WorkerCreateBinding {
|
||||||
worker_id: EmbeddedWorkerId::now_v7(),
|
worker_id: EmbeddedWorkerId::now_v7(),
|
||||||
@@ -6593,6 +6672,10 @@ mod tests {
|
|||||||
workspace_runtime_operation("GET", &format!("/v1/workers/{worker_id}/protocol/ws")),
|
workspace_runtime_operation("GET", &format!("/v1/workers/{worker_id}/protocol/ws")),
|
||||||
"workers:protocol"
|
"workers:protocol"
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
workspace_runtime_operation("POST", SSH_HOST_KEY_PROBE_PATH),
|
||||||
|
SSH_HOST_KEY_PROBE_OPERATION
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -7,16 +7,19 @@ use std::sync::Arc;
|
|||||||
use chrono::{SecondsFormat, Utc};
|
use chrono::{SecondsFormat, Utc};
|
||||||
use config_source::ConfigSchemaContribution;
|
use config_source::ConfigSchemaContribution;
|
||||||
use ring::aead::{AES_256_GCM, Aad, LessSafeKey, Nonce, UnboundKey};
|
use ring::aead::{AES_256_GCM, Aad, LessSafeKey, Nonce, UnboundKey};
|
||||||
|
use ring::hmac;
|
||||||
use ring::rand::{SecureRandom, SystemRandom};
|
use ring::rand::{SecureRandom, SystemRandom};
|
||||||
use rusqlite::{OptionalExtension, TransactionBehavior, params};
|
use rusqlite::{OptionalExtension, TransactionBehavior, params};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
|
use ssh_key::private::Ed25519Keypair;
|
||||||
use ssh_key::{Algorithm, HashAlg, LineEnding, PrivateKey, PublicKey};
|
use ssh_key::{Algorithm, HashAlg, LineEnding, PrivateKey, PublicKey};
|
||||||
use workspace_api::{
|
use workspace_api::{
|
||||||
CreateRepositorySshCredentialRequest, DeleteRepositorySshCredentialRequest,
|
CreateRepositorySshCredentialRequest, DeleteRepositorySshCredentialRequest,
|
||||||
DeleteRepositorySshHostTrustRequest, PutRepositorySshHostTrustRequest, RepositoryAccessMode,
|
DeleteRepositorySshHostTrustRequest, GenerateRepositorySshCredentialRequest,
|
||||||
RepositoryAccessProjection, RepositorySshAccessBinding, RepositorySshCredential,
|
PutRepositorySshHostTrustRequest, RepositoryAccessMode, RepositoryAccessProjection,
|
||||||
RepositorySshHostTrust, RotateRepositorySshCredentialRequest,
|
RepositorySshAccessBinding, RepositorySshCredential, RepositorySshHostTrust,
|
||||||
|
RepositorySshPublicKey, RotateRepositorySshCredentialRequest,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::config_source::{
|
use crate::config_source::{
|
||||||
@@ -42,6 +45,9 @@ const MAX_NAME_BYTES: usize = 200;
|
|||||||
const MAX_IDENTIFIER_BYTES: usize = 128;
|
const MAX_IDENTIFIER_BYTES: usize = 128;
|
||||||
const MASTER_KEY_BYTES: usize = 32;
|
const MASTER_KEY_BYTES: usize = 32;
|
||||||
const NONCE_BYTES: usize = 12;
|
const NONCE_BYTES: usize = 12;
|
||||||
|
pub const WORKSPACE_DEFAULT_REPOSITORY_SSH_CREDENTIAL_ID: &str = "workspace-default";
|
||||||
|
const WORKSPACE_DEFAULT_REPOSITORY_SSH_OPERATION_ID: &str = "workspace-default-repository-ssh-v1";
|
||||||
|
const WORKSPACE_DEFAULT_REPOSITORY_SSH_NAME: &str = "Workspace default SSH key";
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
pub struct RepositoryAccessConfigSchemaProvider;
|
pub struct RepositoryAccessConfigSchemaProvider;
|
||||||
@@ -130,6 +136,43 @@ pub fn project_repository_access_state(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn repository_ssh_endpoint(
|
||||||
|
repository_key: &str,
|
||||||
|
repository_uri: &str,
|
||||||
|
) -> Result<Option<(String, u16)>> {
|
||||||
|
if !repository_uri.contains("://") {
|
||||||
|
if let Some((identity, path)) = repository_uri.split_once(':')
|
||||||
|
&& !path.is_empty()
|
||||||
|
&& let Some((_, hostname)) = identity.rsplit_once('@')
|
||||||
|
&& !hostname.is_empty()
|
||||||
|
{
|
||||||
|
return Ok(Some((hostname.to_ascii_lowercase(), 22)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let parsed = url::Url::parse(repository_uri).map_err(|error| {
|
||||||
|
Error::InvalidInput(format!(
|
||||||
|
"Repository `{repository_key}` has invalid SSH URI: {error}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
if parsed.scheme() != "ssh" {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
if parsed.username().is_empty() || parsed.password().is_some() {
|
||||||
|
return Err(Error::InvalidInput(format!(
|
||||||
|
"Repository `{repository_key}` must use ssh://user@host[:port]/path without embedded credentials"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let hostname = parsed.host_str().ok_or_else(|| {
|
||||||
|
Error::InvalidInput(format!(
|
||||||
|
"Repository `{repository_key}` SSH URI has no hostname"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
Ok(Some((
|
||||||
|
hostname.to_ascii_lowercase(),
|
||||||
|
parsed.port().unwrap_or(22),
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
fn project_repository_access_evaluation(
|
fn project_repository_access_evaluation(
|
||||||
store: &dyn ControlPlaneStore,
|
store: &dyn ControlPlaneStore,
|
||||||
secrets: &RepositorySecretService,
|
secrets: &RepositorySecretService,
|
||||||
@@ -145,6 +188,9 @@ fn project_repository_access_evaluation(
|
|||||||
.map_err(|error| {
|
.map_err(|error| {
|
||||||
Error::InvalidInput(format!("invalid Repository access config: {error}"))
|
Error::InvalidInput(format!("invalid Repository access config: {error}"))
|
||||||
})?;
|
})?;
|
||||||
|
if !config.repository_access.is_empty() {
|
||||||
|
secrets.ensure_workspace_default_credential(workspace_id)?;
|
||||||
|
}
|
||||||
let mut bindings = Vec::with_capacity(config.repository_access.len());
|
let mut bindings = Vec::with_capacity(config.repository_access.len());
|
||||||
for (repository_key, access) in config.repository_access {
|
for (repository_key, access) in config.repository_access {
|
||||||
workspace_api::validate_repository_key(&repository_key)
|
workspace_api::validate_repository_key(&repository_key)
|
||||||
@@ -181,22 +227,14 @@ fn project_repository_access_evaluation(
|
|||||||
access.ssh.host_trust
|
access.ssh.host_trust
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
let uri = url::Url::parse(&repository.source.uri).map_err(|_| {
|
let (hostname, port) =
|
||||||
|
repository_ssh_endpoint(repository_key.as_str(), &repository.source.uri)?.ok_or_else(
|
||||||
|
|| {
|
||||||
Error::InvalidInput(format!(
|
Error::InvalidInput(format!(
|
||||||
"Repository `{repository_key}` has an invalid SSH URI"
|
"Repository `{repository_key}` must use an SSH source"
|
||||||
))
|
))
|
||||||
})?;
|
},
|
||||||
if uri.scheme() != "ssh" || uri.username().is_empty() || uri.password().is_some() {
|
)?;
|
||||||
return Err(Error::InvalidInput(format!(
|
|
||||||
"Repository `{repository_key}` must use ssh://user@host[:port]/path without credentials"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
let hostname = uri.host_str().ok_or_else(|| {
|
|
||||||
Error::InvalidInput(format!(
|
|
||||||
"Repository `{repository_key}` SSH URI has no hostname"
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
let port = uri.port().unwrap_or(22);
|
|
||||||
if hostname != host_trust.hostname || port != host_trust.port {
|
if hostname != host_trust.hostname || port != host_trust.port {
|
||||||
return Err(Error::InvalidInput(format!(
|
return Err(Error::InvalidInput(format!(
|
||||||
"Repository `{repository_key}` SSH host does not match host trust `{}`",
|
"Repository `{repository_key}` SSH host does not match host trust `{}`",
|
||||||
@@ -245,6 +283,152 @@ impl RepositorySecretService {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn generated_ed25519_private_key(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
operation_id: &str,
|
||||||
|
credential_id: &str,
|
||||||
|
intent: &str,
|
||||||
|
) -> Result<String> {
|
||||||
|
let master_key = self.master_key.as_ref().ok_or_else(|| {
|
||||||
|
Error::Store("Repository secret encryption authority is unavailable".to_string())
|
||||||
|
})?;
|
||||||
|
let key = hmac::Key::new(hmac::HMAC_SHA256, master_key.as_slice());
|
||||||
|
let context = format!(
|
||||||
|
"yoi/repository-ssh-key/v1\0{workspace_id}\0{operation_id}\0{credential_id}\0{intent}"
|
||||||
|
);
|
||||||
|
let seed = hmac::sign(&key, context.as_bytes());
|
||||||
|
PrivateKey::from(Ed25519Keypair::from_seed(
|
||||||
|
seed.as_ref().try_into().map_err(|_| {
|
||||||
|
Error::Store("generated SSH Ed25519 seed had an invalid length".to_string())
|
||||||
|
})?,
|
||||||
|
))
|
||||||
|
.to_openssh(LineEnding::LF)
|
||||||
|
.map(|key| key.to_string())
|
||||||
|
.map_err(|err| Error::Store(format!("failed to encode generated SSH key: {err}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn generate_credential(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
request: GenerateRepositorySshCredentialRequest,
|
||||||
|
actor_account_id: &str,
|
||||||
|
) -> Result<RepositorySshCredential> {
|
||||||
|
let operation_id = validate_identifier("operation_id", &request.operation_id)?;
|
||||||
|
let credential_id = validate_identifier("credential_id", &request.credential_id)?;
|
||||||
|
let name = normalize_name(&request.name)?;
|
||||||
|
let private_key = self.generated_ed25519_private_key(
|
||||||
|
workspace_id,
|
||||||
|
&operation_id,
|
||||||
|
&credential_id,
|
||||||
|
&format!("create\0{name}"),
|
||||||
|
)?;
|
||||||
|
self.create_credential(
|
||||||
|
workspace_id,
|
||||||
|
CreateRepositorySshCredentialRequest {
|
||||||
|
operation_id,
|
||||||
|
credential_id,
|
||||||
|
name,
|
||||||
|
private_key,
|
||||||
|
passphrase: None,
|
||||||
|
},
|
||||||
|
actor_account_id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ensure_workspace_default_credential(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
) -> Result<RepositorySshCredential> {
|
||||||
|
if let Some(credential) = self.store.with_conn(|conn| {
|
||||||
|
read_credential(
|
||||||
|
conn,
|
||||||
|
workspace_id,
|
||||||
|
WORKSPACE_DEFAULT_REPOSITORY_SSH_CREDENTIAL_ID,
|
||||||
|
)
|
||||||
|
})? {
|
||||||
|
return Ok(credential);
|
||||||
|
}
|
||||||
|
self.generate_credential(
|
||||||
|
workspace_id,
|
||||||
|
GenerateRepositorySshCredentialRequest {
|
||||||
|
operation_id: WORKSPACE_DEFAULT_REPOSITORY_SSH_OPERATION_ID.to_string(),
|
||||||
|
credential_id: WORKSPACE_DEFAULT_REPOSITORY_SSH_CREDENTIAL_ID.to_string(),
|
||||||
|
name: WORKSPACE_DEFAULT_REPOSITORY_SSH_NAME.to_string(),
|
||||||
|
},
|
||||||
|
"workspace-system",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn credential_public_key(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
credential_id: &str,
|
||||||
|
) -> Result<Option<RepositorySshPublicKey>> {
|
||||||
|
let credential_id = validate_identifier("credential_id", credential_id)?;
|
||||||
|
let Some((credential, private_secret, passphrase_secret)) =
|
||||||
|
self.store.with_conn(|conn| {
|
||||||
|
let Some(credential) = read_credential(conn, workspace_id, &credential_id)? else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let private_secret = read_sealed_secret(
|
||||||
|
conn,
|
||||||
|
workspace_id,
|
||||||
|
&credential_id,
|
||||||
|
credential.current_revision,
|
||||||
|
"private_key",
|
||||||
|
)?
|
||||||
|
.ok_or_else(|| Error::Store("credential private key is missing".to_string()))?;
|
||||||
|
let passphrase_secret = read_sealed_secret(
|
||||||
|
conn,
|
||||||
|
workspace_id,
|
||||||
|
&credential_id,
|
||||||
|
credential.current_revision,
|
||||||
|
"passphrase",
|
||||||
|
)?;
|
||||||
|
Ok(Some((credential, private_secret, passphrase_secret)))
|
||||||
|
})?
|
||||||
|
else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let private_key = zeroize::Zeroizing::new(self.unseal(
|
||||||
|
workspace_id,
|
||||||
|
&credential_id,
|
||||||
|
credential.current_revision,
|
||||||
|
"private_key",
|
||||||
|
private_secret,
|
||||||
|
)?);
|
||||||
|
let passphrase = passphrase_secret
|
||||||
|
.map(|secret| {
|
||||||
|
self.unseal(
|
||||||
|
workspace_id,
|
||||||
|
&credential_id,
|
||||||
|
credential.current_revision,
|
||||||
|
"passphrase",
|
||||||
|
secret,
|
||||||
|
)
|
||||||
|
.map(zeroize::Zeroizing::new)
|
||||||
|
})
|
||||||
|
.transpose()?;
|
||||||
|
let private_key = std::str::from_utf8(private_key.as_slice())
|
||||||
|
.map_err(|_| Error::Store("credential private key is not UTF-8".to_string()))?;
|
||||||
|
let passphrase = passphrase
|
||||||
|
.as_deref()
|
||||||
|
.map(|value| std::str::from_utf8(value.as_slice()))
|
||||||
|
.transpose()
|
||||||
|
.map_err(|_| Error::Store("credential passphrase is not UTF-8".to_string()))?;
|
||||||
|
let parsed = parse_private_key(private_key, passphrase).map_err(|err| {
|
||||||
|
Error::Store(format!("stored credential private key is invalid: {err}"))
|
||||||
|
})?;
|
||||||
|
Ok(Some(RepositorySshPublicKey {
|
||||||
|
credential_id,
|
||||||
|
current_revision: credential.current_revision,
|
||||||
|
public_key_algorithm: parsed.algorithm,
|
||||||
|
public_key_fingerprint: parsed.fingerprint,
|
||||||
|
public_key: parsed.public_key,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn create_credential(
|
pub fn create_credential(
|
||||||
&self,
|
&self,
|
||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
@@ -384,6 +568,11 @@ impl RepositorySecretService {
|
|||||||
actor_account_id: &str,
|
actor_account_id: &str,
|
||||||
) -> Result<RepositorySshCredential> {
|
) -> Result<RepositorySshCredential> {
|
||||||
let credential_id = validate_identifier("credential_id", credential_id)?;
|
let credential_id = validate_identifier("credential_id", credential_id)?;
|
||||||
|
if credential_id == WORKSPACE_DEFAULT_REPOSITORY_SSH_CREDENTIAL_ID {
|
||||||
|
return Err(Error::WorkspaceConfigConflict(
|
||||||
|
"Workspace default SSH credential is immutable".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
let operation_id = validate_identifier("operation_id", &request.operation_id)?;
|
let operation_id = validate_identifier("operation_id", &request.operation_id)?;
|
||||||
let parsed = parse_private_key(&request.private_key, request.passphrase.as_deref())?;
|
let parsed = parse_private_key(&request.private_key, request.passphrase.as_deref())?;
|
||||||
let next_revision = request
|
let next_revision = request
|
||||||
@@ -529,6 +718,11 @@ impl RepositorySecretService {
|
|||||||
projection: &RepositoryAccessProjection,
|
projection: &RepositoryAccessProjection,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let credential_id = validate_identifier("credential_id", credential_id)?;
|
let credential_id = validate_identifier("credential_id", credential_id)?;
|
||||||
|
if credential_id == WORKSPACE_DEFAULT_REPOSITORY_SSH_CREDENTIAL_ID {
|
||||||
|
return Err(Error::WorkspaceConfigConflict(
|
||||||
|
"Workspace default SSH credential is immutable".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
let operation_id = validate_identifier("operation_id", &request.operation_id)?;
|
let operation_id = validate_identifier("operation_id", &request.operation_id)?;
|
||||||
let references = credential_references(projection, &credential_id);
|
let references = credential_references(projection, &credential_id);
|
||||||
if !references.is_empty() {
|
if !references.is_empty() {
|
||||||
@@ -839,6 +1033,73 @@ impl RepositorySecretService {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn host_trusts_for_endpoint(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
hostname: &str,
|
||||||
|
port: u16,
|
||||||
|
) -> Result<Vec<RepositorySshHostTrust>> {
|
||||||
|
self.store.with_conn(|conn| {
|
||||||
|
let mut statement = conn.prepare(
|
||||||
|
r#"SELECT workspace_id, host_trust_id, hostname, port, key_algorithm,
|
||||||
|
host_key, fingerprint, current_revision, created_at, updated_at
|
||||||
|
FROM repository_ssh_host_trusts
|
||||||
|
WHERE workspace_id = ?1 AND lower(hostname) = lower(?2) AND port = ?3
|
||||||
|
ORDER BY host_trust_id"#,
|
||||||
|
)?;
|
||||||
|
statement
|
||||||
|
.query_map(
|
||||||
|
params![workspace_id, hostname, i64::from(port)],
|
||||||
|
read_host_trust_row,
|
||||||
|
)?
|
||||||
|
.collect::<std::result::Result<Vec<_>, _>>()
|
||||||
|
.map_err(Error::from)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn automatic_host_trust_id(hostname: &str, port: u16) -> String {
|
||||||
|
let normalized = hostname
|
||||||
|
.chars()
|
||||||
|
.map(|character| {
|
||||||
|
if character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '_') {
|
||||||
|
character.to_ascii_lowercase()
|
||||||
|
} else {
|
||||||
|
'-'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.take(96)
|
||||||
|
.collect::<String>();
|
||||||
|
format!("tofu-{normalized}-{port}")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn default_ssh_binding_for_repository(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
repository_key: &str,
|
||||||
|
repository_uri: &str,
|
||||||
|
) -> Result<Option<RepositorySshAccessBinding>> {
|
||||||
|
let Some((hostname, port)) = repository_ssh_endpoint(repository_key, repository_uri)?
|
||||||
|
else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let matches = self.host_trusts_for_endpoint(workspace_id, &hostname, port)?;
|
||||||
|
let Some(host_trust) = matches.first() else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
if matches.len() > 1 {
|
||||||
|
return Err(Error::InvalidInput(format!(
|
||||||
|
"Repository `{repository_key}` matches multiple SSH host trusts for {hostname}:{port}; configure an explicit Repository access binding"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
self.ensure_workspace_default_credential(workspace_id)?;
|
||||||
|
Ok(Some(RepositorySshAccessBinding {
|
||||||
|
repository_key: repository_key.to_string(),
|
||||||
|
credential_id: WORKSPACE_DEFAULT_REPOSITORY_SSH_CREDENTIAL_ID.to_string(),
|
||||||
|
host_trust_id: host_trust.host_trust_id.clone(),
|
||||||
|
access: RepositoryAccessMode::ReadOnly,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn lease_ssh_materialization_access(
|
pub fn lease_ssh_materialization_access(
|
||||||
&self,
|
&self,
|
||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
@@ -1056,6 +1317,7 @@ impl RepositorySecretService {
|
|||||||
struct ParsedKey {
|
struct ParsedKey {
|
||||||
algorithm: String,
|
algorithm: String,
|
||||||
fingerprint: String,
|
fingerprint: String,
|
||||||
|
public_key: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_private_key(private_key: &str, passphrase: Option<&str>) -> Result<ParsedKey> {
|
fn parse_private_key(private_key: &str, passphrase: Option<&str>) -> Result<ParsedKey> {
|
||||||
@@ -1092,6 +1354,9 @@ fn parse_private_key(private_key: &str, passphrase: Option<&str>) -> Result<Pars
|
|||||||
Ok(ParsedKey {
|
Ok(ParsedKey {
|
||||||
algorithm: public_key.algorithm().to_string(),
|
algorithm: public_key.algorithm().to_string(),
|
||||||
fingerprint: public_key.fingerprint(HashAlg::Sha256).to_string(),
|
fingerprint: public_key.fingerprint(HashAlg::Sha256).to_string(),
|
||||||
|
public_key: public_key.to_openssh().map_err(|err| {
|
||||||
|
Error::Store(format!("failed to encode Repository SSH public key: {err}"))
|
||||||
|
})?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1708,6 +1973,102 @@ mod tests {
|
|||||||
assert!(!error.contains(secret));
|
assert!(!error.contains(secret));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workspace_default_credential_is_generated_once_and_immutable() {
|
||||||
|
let (_dir, _store, service) = test_service();
|
||||||
|
|
||||||
|
let created = service
|
||||||
|
.ensure_workspace_default_credential("workspace-a")
|
||||||
|
.unwrap();
|
||||||
|
let replayed = service
|
||||||
|
.ensure_workspace_default_credential("workspace-a")
|
||||||
|
.unwrap();
|
||||||
|
let public_key = service
|
||||||
|
.credential_public_key(
|
||||||
|
"workspace-a",
|
||||||
|
WORKSPACE_DEFAULT_REPOSITORY_SSH_CREDENTIAL_ID,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(created, replayed);
|
||||||
|
assert_eq!(created.current_revision, 1);
|
||||||
|
assert_eq!(
|
||||||
|
public_key.public_key_fingerprint,
|
||||||
|
created.public_key_fingerprint
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
service
|
||||||
|
.rotate_credential(
|
||||||
|
"workspace-a",
|
||||||
|
WORKSPACE_DEFAULT_REPOSITORY_SSH_CREDENTIAL_ID,
|
||||||
|
RotateRepositorySshCredentialRequest {
|
||||||
|
operation_id: "rotate-default".to_string(),
|
||||||
|
expected_revision: 1,
|
||||||
|
private_key: test_private_key(12).0,
|
||||||
|
passphrase: None,
|
||||||
|
},
|
||||||
|
"owner-a",
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
service
|
||||||
|
.delete_credential(
|
||||||
|
"workspace-a",
|
||||||
|
WORKSPACE_DEFAULT_REPOSITORY_SSH_CREDENTIAL_ID,
|
||||||
|
DeleteRepositorySshCredentialRequest {
|
||||||
|
operation_id: "delete-default".to_string(),
|
||||||
|
expected_revision: 1,
|
||||||
|
},
|
||||||
|
"owner-a",
|
||||||
|
&RepositoryAccessProjection {
|
||||||
|
workspace_id: "workspace-a".to_string(),
|
||||||
|
config_revision: 1,
|
||||||
|
projection_digest: "sha256:empty".to_string(),
|
||||||
|
bindings: Vec::new(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generated_credential_is_replayable_and_exposes_only_its_public_key() {
|
||||||
|
let (_dir, _store, service) = test_service();
|
||||||
|
let request = GenerateRepositorySshCredentialRequest {
|
||||||
|
operation_id: "generate-one".to_string(),
|
||||||
|
credential_id: "workspace-key".to_string(),
|
||||||
|
name: "Workspace key".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let created = service
|
||||||
|
.generate_credential("workspace-a", request.clone(), "owner-a")
|
||||||
|
.unwrap();
|
||||||
|
let replayed = service
|
||||||
|
.generate_credential("workspace-a", request, "owner-a")
|
||||||
|
.unwrap();
|
||||||
|
let public_key = service
|
||||||
|
.credential_public_key("workspace-a", "workspace-key")
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(replayed, created);
|
||||||
|
assert_eq!(public_key.current_revision, created.current_revision);
|
||||||
|
assert_eq!(
|
||||||
|
public_key.public_key_fingerprint,
|
||||||
|
created.public_key_fingerprint
|
||||||
|
);
|
||||||
|
assert!(public_key.public_key.starts_with("ssh-ed25519 "));
|
||||||
|
assert!(!public_key.public_key.contains("PRIVATE KEY"));
|
||||||
|
assert!(
|
||||||
|
service
|
||||||
|
.credential_public_key("workspace-b", "workspace-key")
|
||||||
|
.unwrap()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn credential_create_rotate_replay_and_cross_workspace_scope_keep_secrets_write_only() {
|
fn credential_create_rotate_replay_and_cross_workspace_scope_keep_secrets_write_only() {
|
||||||
let (_dir, store, service) = test_service();
|
let (_dir, store, service) = test_service();
|
||||||
@@ -1964,6 +2325,86 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_binding_resolves_unique_host_trust_for_url_and_scp_ssh_sources() {
|
||||||
|
let (_dir, _store, service) = test_service();
|
||||||
|
assert!(
|
||||||
|
service
|
||||||
|
.default_ssh_binding_for_repository(
|
||||||
|
"workspace-a",
|
||||||
|
"main",
|
||||||
|
"git@example.test:org/main.git",
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
let (_, host_key) = test_private_key(10);
|
||||||
|
service
|
||||||
|
.put_host_trust(
|
||||||
|
"workspace-a",
|
||||||
|
PutRepositorySshHostTrustRequest {
|
||||||
|
operation_id: "host-default".to_string(),
|
||||||
|
host_trust_id: "example".to_string(),
|
||||||
|
hostname: "example.test".to_string(),
|
||||||
|
port: 22,
|
||||||
|
host_key,
|
||||||
|
expected_revision: None,
|
||||||
|
},
|
||||||
|
"owner-a",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
for uri in [
|
||||||
|
"ssh://git@example.test/org/main.git",
|
||||||
|
"git@example.test:org/main.git",
|
||||||
|
] {
|
||||||
|
let binding = service
|
||||||
|
.default_ssh_binding_for_repository("workspace-a", "main", uri)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
binding.credential_id,
|
||||||
|
WORKSPACE_DEFAULT_REPOSITORY_SSH_CREDENTIAL_ID
|
||||||
|
);
|
||||||
|
assert_eq!(binding.host_trust_id, "example");
|
||||||
|
assert_eq!(binding.access, RepositoryAccessMode::ReadOnly);
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
service
|
||||||
|
.credential_public_key(
|
||||||
|
"workspace-a",
|
||||||
|
WORKSPACE_DEFAULT_REPOSITORY_SSH_CREDENTIAL_ID,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.is_some()
|
||||||
|
);
|
||||||
|
|
||||||
|
let (_, second_host_key) = test_private_key(11);
|
||||||
|
service
|
||||||
|
.put_host_trust(
|
||||||
|
"workspace-a",
|
||||||
|
PutRepositorySshHostTrustRequest {
|
||||||
|
operation_id: "host-default-second".to_string(),
|
||||||
|
host_trust_id: "example-second".to_string(),
|
||||||
|
hostname: "example.test".to_string(),
|
||||||
|
port: 22,
|
||||||
|
host_key: second_host_key,
|
||||||
|
expected_revision: None,
|
||||||
|
},
|
||||||
|
"owner-a",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
service
|
||||||
|
.default_ssh_binding_for_repository(
|
||||||
|
"workspace-a",
|
||||||
|
"main",
|
||||||
|
"git@example.test:org/main.git",
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn referenced_resources_cannot_be_deleted() {
|
fn referenced_resources_cannot_be_deleted() {
|
||||||
let (_dir, _store, service) = test_service();
|
let (_dir, _store, service) = test_service();
|
||||||
|
|||||||
@@ -397,7 +397,13 @@ mod tests {
|
|||||||
"1",
|
"1",
|
||||||
i64::MAX,
|
i64::MAX,
|
||||||
RepositorySshAccessSecret {
|
RepositorySshAccessSecret {
|
||||||
|
credential_candidates: vec![
|
||||||
|
worker_runtime::resource::RepositorySshAccessSecretCandidate {
|
||||||
|
credential_id: "credential-test".to_string(),
|
||||||
|
credential_revision: 1,
|
||||||
private_key: "private-key-bytes".to_string(),
|
private_key: "private-key-bytes".to_string(),
|
||||||
|
},
|
||||||
|
],
|
||||||
known_hosts_entry: "known-hosts-entry".to_string(),
|
known_hosts_entry: "known-hosts-entry".to_string(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -419,7 +425,10 @@ mod tests {
|
|||||||
assert!(!debug.contains("private-key-bytes"));
|
assert!(!debug.contains("private-key-bytes"));
|
||||||
assert!(debug.contains("REDACTED"));
|
assert!(debug.contains("REDACTED"));
|
||||||
let secret: RepositorySshAccessSecret = serde_json::from_slice(&response.bytes).unwrap();
|
let secret: RepositorySshAccessSecret = serde_json::from_slice(&response.bytes).unwrap();
|
||||||
assert_eq!(secret.private_key, "private-key-bytes");
|
assert_eq!(
|
||||||
|
secret.credential_candidates[0].private_key,
|
||||||
|
"private-key-bytes"
|
||||||
|
);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
broker.fetch_resource(request(handle, "runtime-test", None)),
|
broker.fetch_resource(request(handle, "runtime-test", None)),
|
||||||
Err(BackendResourceError::MissingResource)
|
Err(BackendResourceError::MissingResource)
|
||||||
|
|||||||
@@ -70,28 +70,32 @@ use worker_runtime::workspace_issuer::{
|
|||||||
use workspace_api::{
|
use workspace_api::{
|
||||||
ActorAuthMethod, AuthBootstrapUserRequest, AuthPublicConfig, AuthUserResponse,
|
ActorAuthMethod, AuthBootstrapUserRequest, AuthPublicConfig, AuthUserResponse,
|
||||||
AuthenticatedUser, BrowserCreateWorkerResponse, BrowserWorkspaceOrchestratorResponse,
|
AuthenticatedUser, BrowserCreateWorkerResponse, BrowserWorkspaceOrchestratorResponse,
|
||||||
CreateRemoteRuntimeRequest, CreateRepositorySshCredentialRequest,
|
ConfirmRepositorySshHostTrustRequest, CreateRemoteRuntimeRequest,
|
||||||
CreateWorkspaceRepositoryRequest, CreateWorkspaceRepositoryResponse,
|
CreateRepositorySshCredentialRequest, CreateWorkspaceRepositoryRequest,
|
||||||
CreateWorkspaceWorkerRequest, CreateWorkspaceWorkerTicketAssignmentRequest,
|
CreateWorkspaceRepositoryResponse, CreateWorkspaceWorkerRequest,
|
||||||
DeleteRepositorySshCredentialRequest, DeleteRepositorySshHostTrustRequest,
|
CreateWorkspaceWorkerTicketAssignmentRequest, DeleteRepositorySshCredentialRequest,
|
||||||
DeviceAccessTokenType, DeviceLoginApprovalStatus, DeviceLoginApproveRequest,
|
DeleteRepositorySshHostTrustRequest, DeviceAccessTokenType, DeviceLoginApprovalStatus,
|
||||||
DeviceLoginApproveResponse, DeviceLoginPollRequest, DeviceLoginPollResponse,
|
DeviceLoginApproveRequest, DeviceLoginApproveResponse, DeviceLoginPollRequest,
|
||||||
DeviceLoginPollStatus, DeviceLoginStartRequest, DeviceLoginStartResponse, LogoutResponse,
|
DeviceLoginPollResponse, DeviceLoginPollStatus, DeviceLoginStartRequest,
|
||||||
LogoutStatus, MemoryDocumentResponse, MemoryStagingListResponse, ObjectiveCreateRequest,
|
DeviceLoginStartResponse, GenerateRepositorySshCredentialRequest, LogoutResponse, LogoutStatus,
|
||||||
|
MemoryDocumentResponse, MemoryStagingListResponse, ObjectiveCreateRequest,
|
||||||
ObjectiveEditRequest, ObjectiveLinkTicketRequest, ObjectiveStateRequest,
|
ObjectiveEditRequest, ObjectiveLinkTicketRequest, ObjectiveStateRequest,
|
||||||
PasskeyLoginCompleteRequest, PasskeyLoginOptionsRequest, PasskeyLoginOptionsResponse,
|
PasskeyLoginCompleteRequest, PasskeyLoginOptionsRequest, PasskeyLoginOptionsResponse,
|
||||||
PasskeyRegistrationCompleteRequest, PasskeyRegistrationOptionsRequest,
|
PasskeyRegistrationCompleteRequest, PasskeyRegistrationOptionsRequest,
|
||||||
PasskeyRegistrationOptionsResponse, ProfileSettingsResponse, PutRepositorySshHostTrustRequest,
|
PasskeyRegistrationOptionsResponse, ProfileSettingsResponse, PutRepositorySshHostTrustRequest,
|
||||||
RepositoryAccessProjection, RepositoryDetailResponse, RepositoryListResponse,
|
RepositoryAccessProjection, RepositoryDetailResponse, RepositoryListResponse,
|
||||||
RepositoryLogResponse, RepositorySshCredential, RepositorySshHostTrust, RequestActor,
|
RepositoryLogResponse, RepositorySshConnectionProbeRequest,
|
||||||
RevokeRuntimeTrustKeyRequest, RotateRepositorySshCredentialRequest,
|
RepositorySshConnectionProbeResponse, RepositorySshConnectionTrustState,
|
||||||
RuntimeConnectionDisplayState, RuntimeConnectionTestFailureKind, RuntimeConnectionTestResponse,
|
RepositorySshCredential, RepositorySshHostKeyCandidate, RepositorySshHostTrust,
|
||||||
RuntimeConnectionTestStatus, RuntimeManagementSummary, RuntimeTrustAuditAction,
|
RepositorySshPublicKey, RequestActor, RevokeRuntimeTrustKeyRequest,
|
||||||
RuntimeTrustAuditEntry, RuntimeTrustConflictKind, RuntimeTrustConflictResponse,
|
RotateRepositorySshCredentialRequest, RuntimeConnectionDisplayState,
|
||||||
RuntimeTrustKeyRevealResponse, RuntimeTrustKeyState, RuntimeTrustKeyStatus,
|
RuntimeConnectionTestFailureKind, RuntimeConnectionTestResponse, RuntimeConnectionTestStatus,
|
||||||
TICKET_ORCHESTRATION_PLANS_QUERY_PATH, TICKET_RELATIONS_QUERY_PATH,
|
RuntimeManagementSummary, RuntimeTrustAuditAction, RuntimeTrustAuditEntry,
|
||||||
UpdateWorkspaceMetadataRequest, WhoamiResponse, WorkerLaunchOptionsResponse,
|
RuntimeTrustConflictKind, RuntimeTrustConflictResponse, RuntimeTrustKeyRevealResponse,
|
||||||
WorkerLaunchProfileCandidate, WorkerLaunchRuntimeOption, WorkerLaunchWorkerSummary,
|
RuntimeTrustKeyState, RuntimeTrustKeyStatus, TICKET_ORCHESTRATION_PLANS_QUERY_PATH,
|
||||||
|
TICKET_RELATIONS_QUERY_PATH, UpdateWorkspaceMetadataRequest, WhoamiResponse,
|
||||||
|
WorkerLaunchOptionsResponse, WorkerLaunchProfileCandidate, WorkerLaunchRuntimeOption,
|
||||||
|
WorkerLaunchWorkerSummary,
|
||||||
WorkingDirectoryCreateRequest as BrowserWorkingDirectoryCreateRequest,
|
WorkingDirectoryCreateRequest as BrowserWorkingDirectoryCreateRequest,
|
||||||
WorkingDirectoryCreateResponse as BrowserWorkingDirectoryCreateResponse,
|
WorkingDirectoryCreateResponse as BrowserWorkingDirectoryCreateResponse,
|
||||||
WorkingDirectoryDetailResponse as BrowserWorkingDirectoryDetailResponse,
|
WorkingDirectoryDetailResponse as BrowserWorkingDirectoryDetailResponse,
|
||||||
@@ -188,8 +192,8 @@ use crate::{Error, Result};
|
|||||||
use worker_runtime::catalog::{
|
use worker_runtime::catalog::{
|
||||||
ConfigBundleRef, ProfileSelector, RepositoryMaterializationContext, RepositoryRefObservation,
|
ConfigBundleRef, ProfileSelector, RepositoryMaterializationContext, RepositoryRefObservation,
|
||||||
RepositoryRefObservationRequest, RepositorySelector as RuntimeRepositorySelector,
|
RepositoryRefObservationRequest, RepositorySelector as RuntimeRepositorySelector,
|
||||||
RepositorySshMaterializationAccess, SensitiveString, WorkingDirectoryClaim,
|
RepositorySshCredentialCandidate, RepositorySshMaterializationAccess, SensitiveString,
|
||||||
WorkingDirectoryRepository, WorkingDirectoryRequest, WorkspaceApiRef,
|
WorkingDirectoryClaim, WorkingDirectoryRepository, WorkingDirectoryRequest, WorkspaceApiRef,
|
||||||
};
|
};
|
||||||
use worker_runtime::config_bundle::ConfigBundle;
|
use worker_runtime::config_bundle::ConfigBundle;
|
||||||
use worker_runtime::http_server::MAX_WORKER_FILE_UPLOAD_BYTES;
|
use worker_runtime::http_server::MAX_WORKER_FILE_UPLOAD_BYTES;
|
||||||
@@ -1860,6 +1864,19 @@ async fn enforce_server_cookie_mutation_origin(
|
|||||||
next.run(request).await
|
next.run(request).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn workspace_request_requires_mutation_lock(
|
||||||
|
method: &Method,
|
||||||
|
path: &str,
|
||||||
|
workspace_id: &str,
|
||||||
|
) -> bool {
|
||||||
|
if matches!(*method, Method::GET | Method::HEAD | Method::OPTIONS) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Fetching a one-time Runtime resource consumes only an in-memory broker handle. It is a
|
||||||
|
// callback required to finish an already-gated mutation, not a Workspace state mutation.
|
||||||
|
path != format!("/api/runtime/v1/workspaces/{workspace_id}/resources/fetch")
|
||||||
|
}
|
||||||
|
|
||||||
async fn dispatch_workspace_request(
|
async fn dispatch_workspace_request(
|
||||||
State(api): State<WorkspaceServerApi>,
|
State(api): State<WorkspaceServerApi>,
|
||||||
mut request: Request,
|
mut request: Request,
|
||||||
@@ -1867,10 +1884,8 @@ async fn dispatch_workspace_request(
|
|||||||
let path = request.uri().path().to_owned();
|
let path = request.uri().path().to_owned();
|
||||||
let workspace_id = scoped_workspace_id(&path);
|
let workspace_id = scoped_workspace_id(&path);
|
||||||
let _mutation_guard = if let Some(workspace_id) = workspace_id
|
let _mutation_guard = if let Some(workspace_id) = workspace_id
|
||||||
&& !matches!(
|
&& workspace_request_requires_mutation_lock(request.method(), &path, workspace_id)
|
||||||
*request.method(),
|
{
|
||||||
Method::GET | Method::HEAD | Method::OPTIONS
|
|
||||||
) {
|
|
||||||
Some(api.mutation_lock(workspace_id).await.lock_owned().await)
|
Some(api.mutation_lock(workspace_id).await.lock_owned().await)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
@@ -3053,11 +3068,19 @@ fn build_inner_router(api: WorkspaceApi) -> Router {
|
|||||||
get(scoped_list_repository_ssh_credentials)
|
get(scoped_list_repository_ssh_credentials)
|
||||||
.post(scoped_create_repository_ssh_credential),
|
.post(scoped_create_repository_ssh_credential),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/w/{workspace_id}/settings/repository-access/credentials/generate",
|
||||||
|
post(scoped_generate_repository_ssh_credential),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/w/{workspace_id}/settings/repository-access/credentials/{credential_id}",
|
"/api/w/{workspace_id}/settings/repository-access/credentials/{credential_id}",
|
||||||
get(scoped_get_repository_ssh_credential)
|
get(scoped_get_repository_ssh_credential)
|
||||||
.delete(scoped_delete_repository_ssh_credential),
|
.delete(scoped_delete_repository_ssh_credential),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/w/{workspace_id}/settings/repository-access/credentials/{credential_id}/public-key",
|
||||||
|
get(scoped_get_repository_ssh_public_key),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/w/{workspace_id}/settings/repository-access/credentials/{credential_id}/rotate",
|
"/api/w/{workspace_id}/settings/repository-access/credentials/{credential_id}/rotate",
|
||||||
post(scoped_rotate_repository_ssh_credential),
|
post(scoped_rotate_repository_ssh_credential),
|
||||||
@@ -3342,6 +3365,11 @@ fn build_inner_router(api: WorkspaceApi) -> Router {
|
|||||||
"/api/w/{workspace_id}/repositories/{repository_key}",
|
"/api/w/{workspace_id}/repositories/{repository_key}",
|
||||||
get(scoped_repository_detail),
|
get(scoped_repository_detail),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/w/{workspace_id}/repositories/{repository_key}/ssh-connection-test",
|
||||||
|
post(scoped_probe_repository_ssh_connection)
|
||||||
|
.put(scoped_confirm_repository_ssh_host_trust),
|
||||||
|
)
|
||||||
.route("/api/repositories/{repository_key}/log", get(repository_log))
|
.route("/api/repositories/{repository_key}/log", get(repository_log))
|
||||||
.route(
|
.route(
|
||||||
"/api/w/{workspace_id}/repositories/{repository_key}/log",
|
"/api/w/{workspace_id}/repositories/{repository_key}/log",
|
||||||
@@ -4449,6 +4477,8 @@ async fn scoped_list_repository_ssh_credentials(
|
|||||||
Extension(actor): Extension<RequestActor>,
|
Extension(actor): Extension<RequestActor>,
|
||||||
) -> ApiResult<Json<Vec<RepositorySshCredential>>> {
|
) -> ApiResult<Json<Vec<RepositorySshCredential>>> {
|
||||||
require_manage_repository_secrets(&api, &path.workspace_id, &actor).await?;
|
require_manage_repository_secrets(&api, &path.workspace_id, &actor).await?;
|
||||||
|
api.repository_secrets
|
||||||
|
.ensure_workspace_default_credential(&path.workspace_id)?;
|
||||||
let projection = active_repository_access_projection(&api, &path.workspace_id)?;
|
let projection = active_repository_access_projection(&api, &path.workspace_id)?;
|
||||||
Ok(Json(
|
Ok(Json(
|
||||||
api.repository_secrets
|
api.repository_secrets
|
||||||
@@ -4472,6 +4502,19 @@ async fn scoped_get_repository_ssh_credential(
|
|||||||
Ok(Json(credential))
|
Ok(Json(credential))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn scoped_get_repository_ssh_public_key(
|
||||||
|
State(api): State<WorkspaceApi>,
|
||||||
|
AxumPath(path): AxumPath<ScopedRepositoryCredentialPath>,
|
||||||
|
Extension(actor): Extension<RequestActor>,
|
||||||
|
) -> ApiResult<Json<RepositorySshPublicKey>> {
|
||||||
|
require_manage_repository_secrets(&api, &path.workspace_id, &actor).await?;
|
||||||
|
let public_key = api
|
||||||
|
.repository_secrets
|
||||||
|
.credential_public_key(&path.workspace_id, &path.credential_id)?
|
||||||
|
.ok_or_else(|| Error::InvalidRecordId(path.credential_id.clone()))?;
|
||||||
|
Ok(Json(public_key))
|
||||||
|
}
|
||||||
|
|
||||||
async fn scoped_create_repository_ssh_credential(
|
async fn scoped_create_repository_ssh_credential(
|
||||||
State(api): State<WorkspaceApi>,
|
State(api): State<WorkspaceApi>,
|
||||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||||
@@ -4479,12 +4522,43 @@ async fn scoped_create_repository_ssh_credential(
|
|||||||
Json(request): Json<CreateRepositorySshCredentialRequest>,
|
Json(request): Json<CreateRepositorySshCredentialRequest>,
|
||||||
) -> ApiResult<(StatusCode, Json<RepositorySshCredential>)> {
|
) -> ApiResult<(StatusCode, Json<RepositorySshCredential>)> {
|
||||||
require_manage_repository_secrets(&api, &path.workspace_id, &actor).await?;
|
require_manage_repository_secrets(&api, &path.workspace_id, &actor).await?;
|
||||||
|
if request.credential_id
|
||||||
|
== crate::repository_access::WORKSPACE_DEFAULT_REPOSITORY_SSH_CREDENTIAL_ID
|
||||||
|
{
|
||||||
|
return Err(Error::InvalidInput(
|
||||||
|
"Workspace default SSH credential id is reserved".to_string(),
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
let credential =
|
let credential =
|
||||||
api.repository_secrets
|
api.repository_secrets
|
||||||
.create_credential(&path.workspace_id, request, &actor.account_id)?;
|
.create_credential(&path.workspace_id, request, &actor.account_id)?;
|
||||||
Ok((StatusCode::CREATED, Json(credential)))
|
Ok((StatusCode::CREATED, Json(credential)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn scoped_generate_repository_ssh_credential(
|
||||||
|
State(api): State<WorkspaceApi>,
|
||||||
|
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||||
|
Extension(actor): Extension<RequestActor>,
|
||||||
|
Json(request): Json<GenerateRepositorySshCredentialRequest>,
|
||||||
|
) -> ApiResult<(StatusCode, Json<RepositorySshCredential>)> {
|
||||||
|
require_manage_repository_secrets(&api, &path.workspace_id, &actor).await?;
|
||||||
|
if request.credential_id
|
||||||
|
== crate::repository_access::WORKSPACE_DEFAULT_REPOSITORY_SSH_CREDENTIAL_ID
|
||||||
|
{
|
||||||
|
return Err(Error::InvalidInput(
|
||||||
|
"Workspace default SSH credential id is reserved".to_string(),
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
let credential = api.repository_secrets.generate_credential(
|
||||||
|
&path.workspace_id,
|
||||||
|
request,
|
||||||
|
&actor.account_id,
|
||||||
|
)?;
|
||||||
|
Ok((StatusCode::CREATED, Json(credential)))
|
||||||
|
}
|
||||||
|
|
||||||
async fn scoped_rotate_repository_ssh_credential(
|
async fn scoped_rotate_repository_ssh_credential(
|
||||||
State(api): State<WorkspaceApi>,
|
State(api): State<WorkspaceApi>,
|
||||||
AxumPath(path): AxumPath<ScopedRepositoryCredentialPath>,
|
AxumPath(path): AxumPath<ScopedRepositoryCredentialPath>,
|
||||||
@@ -9270,6 +9344,148 @@ async fn scoped_repository_detail(
|
|||||||
repository_detail(State(api), AxumPath(path.repository_key)).await
|
repository_detail(State(api), AxumPath(path.repository_key)).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn scoped_probe_repository_ssh_connection(
|
||||||
|
State(api): State<WorkspaceApi>,
|
||||||
|
AxumPath(path): AxumPath<ScopedRepositoryPath>,
|
||||||
|
Extension(actor): Extension<RequestActor>,
|
||||||
|
Json(request): Json<RepositorySshConnectionProbeRequest>,
|
||||||
|
) -> ApiResult<Json<RepositorySshConnectionProbeResponse>> {
|
||||||
|
require_manage_repository_secrets(&api, &path.workspace_id, &actor).await?;
|
||||||
|
require_active_workspace_runtime_binding(&api, &request.runtime_id).await?;
|
||||||
|
probe_repository_ssh_connection(&api, &path, &request.runtime_id).map(Json)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn scoped_confirm_repository_ssh_host_trust(
|
||||||
|
State(api): State<WorkspaceApi>,
|
||||||
|
AxumPath(path): AxumPath<ScopedRepositoryPath>,
|
||||||
|
Extension(actor): Extension<RequestActor>,
|
||||||
|
Json(request): Json<ConfirmRepositorySshHostTrustRequest>,
|
||||||
|
) -> ApiResult<Json<RepositorySshHostTrust>> {
|
||||||
|
require_manage_repository_secrets(&api, &path.workspace_id, &actor).await?;
|
||||||
|
require_active_workspace_runtime_binding(&api, &request.runtime_id).await?;
|
||||||
|
let probe = probe_repository_ssh_connection(&api, &path, &request.runtime_id)?;
|
||||||
|
let candidate = probe
|
||||||
|
.candidates
|
||||||
|
.iter()
|
||||||
|
.find(|candidate| candidate.host_key == request.host_key)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
settings_bad_request(
|
||||||
|
"repository_ssh_host_key_not_observed",
|
||||||
|
"Selected SSH host key was not presented by the target Runtime",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if request.expected_host_trust_revision != probe.expected_host_trust_revision {
|
||||||
|
return Err(ApiError::from(Error::WorkspaceConfigConflict(
|
||||||
|
"SSH host trust revision changed after the connection test".to_string(),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let host_trust = api.repository_secrets.put_host_trust(
|
||||||
|
&path.workspace_id,
|
||||||
|
PutRepositorySshHostTrustRequest {
|
||||||
|
operation_id: request.operation_id,
|
||||||
|
host_trust_id: probe.host_trust_id,
|
||||||
|
hostname: probe.hostname,
|
||||||
|
port: probe.port,
|
||||||
|
host_key: candidate.host_key.clone(),
|
||||||
|
expected_revision: probe.expected_host_trust_revision,
|
||||||
|
},
|
||||||
|
&actor.account_id,
|
||||||
|
)?;
|
||||||
|
Ok(Json(host_trust))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn repository_ssh_connection_trust_state(
|
||||||
|
candidates: &[RepositorySshHostKeyCandidate],
|
||||||
|
existing_fingerprint: Option<&str>,
|
||||||
|
) -> RepositorySshConnectionTrustState {
|
||||||
|
match existing_fingerprint {
|
||||||
|
None => RepositorySshConnectionTrustState::Untrusted,
|
||||||
|
Some(fingerprint)
|
||||||
|
if candidates
|
||||||
|
.iter()
|
||||||
|
.any(|candidate| candidate.fingerprint == fingerprint) =>
|
||||||
|
{
|
||||||
|
RepositorySshConnectionTrustState::Verified
|
||||||
|
}
|
||||||
|
Some(_) => RepositorySshConnectionTrustState::Changed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn probe_repository_ssh_connection(
|
||||||
|
api: &WorkspaceApi,
|
||||||
|
path: &ScopedRepositoryPath,
|
||||||
|
runtime_id: &str,
|
||||||
|
) -> ApiResult<RepositorySshConnectionProbeResponse> {
|
||||||
|
validate_workspace_scope(api, &path.workspace_id)?;
|
||||||
|
let repository = api.require_configured_workspace_repository_by_key(&path.repository_key)?;
|
||||||
|
let (hostname, port) = crate::repository_access::repository_ssh_endpoint(
|
||||||
|
&path.repository_key,
|
||||||
|
&repository.source.uri,
|
||||||
|
)?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
settings_bad_request(
|
||||||
|
"repository_ssh_connection_test_not_applicable",
|
||||||
|
"Repository does not use an SSH source",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let observed = api
|
||||||
|
.runtime
|
||||||
|
.probe_ssh_host_keys(
|
||||||
|
runtime_id,
|
||||||
|
worker_runtime::ssh_host_key_probe::SshHostKeyProbeRequest {
|
||||||
|
hostname: hostname.clone(),
|
||||||
|
port,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.map_err(|error| error.into_error())?;
|
||||||
|
let candidates = observed
|
||||||
|
.candidates
|
||||||
|
.into_iter()
|
||||||
|
.map(|candidate| RepositorySshHostKeyCandidate {
|
||||||
|
algorithm: candidate.algorithm,
|
||||||
|
host_key: candidate.public_key,
|
||||||
|
fingerprint: candidate.fingerprint,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if candidates.is_empty() {
|
||||||
|
return Err(settings_bad_request(
|
||||||
|
"repository_ssh_host_key_not_observed",
|
||||||
|
"Target Runtime did not observe an SSH Ed25519 host key",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let matches =
|
||||||
|
api.repository_secrets
|
||||||
|
.host_trusts_for_endpoint(&path.workspace_id, &hostname, port)?;
|
||||||
|
if matches.len() > 1 {
|
||||||
|
return Err(ApiError::from(Error::WorkspaceConfigConflict(
|
||||||
|
"Multiple SSH host trust records match this Repository endpoint".to_string(),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let existing = matches.first();
|
||||||
|
let trust_state = repository_ssh_connection_trust_state(
|
||||||
|
&candidates,
|
||||||
|
existing.map(|host_trust| host_trust.fingerprint.as_str()),
|
||||||
|
);
|
||||||
|
Ok(RepositorySshConnectionProbeResponse {
|
||||||
|
workspace_id: path.workspace_id.clone(),
|
||||||
|
repository_key: path.repository_key.clone(),
|
||||||
|
runtime_id: runtime_id.to_string(),
|
||||||
|
hostname: hostname.clone(),
|
||||||
|
port,
|
||||||
|
trust_state,
|
||||||
|
host_trust_id: existing.map_or_else(
|
||||||
|
|| {
|
||||||
|
crate::repository_access::RepositorySecretService::automatic_host_trust_id(
|
||||||
|
&hostname, port,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|host_trust| host_trust.host_trust_id.clone(),
|
||||||
|
),
|
||||||
|
expected_host_trust_revision: existing.map(|host_trust| host_trust.current_revision),
|
||||||
|
candidates,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async fn scoped_repository_log(
|
async fn scoped_repository_log(
|
||||||
State(api): State<WorkspaceApi>,
|
State(api): State<WorkspaceApi>,
|
||||||
AxumPath(path): AxumPath<ScopedRepositoryPath>,
|
AxumPath(path): AxumPath<ScopedRepositoryPath>,
|
||||||
@@ -10921,12 +11137,31 @@ fn working_directory_detail_for_runtime(
|
|||||||
runtime_id: &str,
|
runtime_id: &str,
|
||||||
working_directory_id: &str,
|
working_directory_id: &str,
|
||||||
) -> ApiResult<Json<BrowserWorkingDirectoryDetailResponse>> {
|
) -> ApiResult<Json<BrowserWorkingDirectoryDetailResponse>> {
|
||||||
|
let existing = api
|
||||||
|
.store
|
||||||
|
.get_workdir_registry(&api.config.workspace_id, working_directory_id)?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
ApiError::with_diagnostics(
|
||||||
|
Error::RuntimeOperationFailed {
|
||||||
|
runtime_id: "workspace-backend".to_string(),
|
||||||
|
code: "working_directory_not_found".to_string(),
|
||||||
|
message: format!("Unknown Workdir `{working_directory_id}`"),
|
||||||
|
},
|
||||||
|
Vec::new(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if existing.runtime_id != runtime_id {
|
||||||
|
return Err(ApiError::from(Error::WorkspacePermissionDenied(
|
||||||
|
"Workdir does not belong to the requested Runtime".to_string(),
|
||||||
|
)));
|
||||||
|
}
|
||||||
let result = api
|
let result = api
|
||||||
.runtime
|
.runtime
|
||||||
.working_directory(runtime_id, working_directory_id)
|
.working_directory(runtime_id, working_directory_id)
|
||||||
.map_err(|err| err.into_error())?;
|
.map_err(|err| err.into_error())?;
|
||||||
if let Some(working_directory) = result.working_directory {
|
if let Some(working_directory) = result.working_directory {
|
||||||
let record = workdir_record_from_summary(&api, runtime_id, &working_directory.summary);
|
let mut record = workdir_record_from_summary(&api, runtime_id, &working_directory.summary);
|
||||||
|
preserve_workdir_identity_for_corrupted_summary(&mut record, Some(&existing));
|
||||||
api.store.upsert_workdir_registry(&record)?;
|
api.store.upsert_workdir_registry(&record)?;
|
||||||
let summary = projected_workdir_summary_from_record(&api, &record)?;
|
let summary = projected_workdir_summary_from_record(&api, &record)?;
|
||||||
return Ok(Json(BrowserWorkingDirectoryDetailResponse {
|
return Ok(Json(BrowserWorkingDirectoryDetailResponse {
|
||||||
@@ -10936,25 +11171,12 @@ fn working_directory_detail_for_runtime(
|
|||||||
diagnostics: working_directory_diagnostics(result.diagnostics),
|
diagnostics: working_directory_diagnostics(result.diagnostics),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
if let Some(record) = api
|
Ok(Json(BrowserWorkingDirectoryDetailResponse {
|
||||||
.store
|
|
||||||
.get_workdir_registry(&api.config.workspace_id, working_directory_id)?
|
|
||||||
{
|
|
||||||
return Ok(Json(BrowserWorkingDirectoryDetailResponse {
|
|
||||||
workspace_id: api.config.workspace_id.clone(),
|
workspace_id: api.config.workspace_id.clone(),
|
||||||
runtime_id: runtime_id.to_string(),
|
runtime_id: runtime_id.to_string(),
|
||||||
item: projected_workdir_summary_from_record(&api, &record)?,
|
item: projected_workdir_summary_from_record(&api, &existing)?,
|
||||||
diagnostics: working_directory_diagnostics(result.diagnostics),
|
diagnostics: working_directory_diagnostics(result.diagnostics),
|
||||||
}));
|
}))
|
||||||
}
|
|
||||||
Err(ApiError::with_diagnostics(
|
|
||||||
Error::RuntimeOperationFailed {
|
|
||||||
runtime_id: runtime_id.to_string(),
|
|
||||||
code: "workspace_working_directory_lookup_failed".to_string(),
|
|
||||||
message: "Runtime did not return working directory".to_string(),
|
|
||||||
},
|
|
||||||
result.diagnostics,
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn workdir_removal_response(
|
fn workdir_removal_response(
|
||||||
@@ -14555,6 +14777,7 @@ fn backend_resource_error_status(error: &BackendResourceError) -> StatusCode {
|
|||||||
| BackendResourceError::Oversized { .. }
|
| BackendResourceError::Oversized { .. }
|
||||||
| BackendResourceError::ContentTypeMismatch { .. }
|
| BackendResourceError::ContentTypeMismatch { .. }
|
||||||
| BackendResourceError::InvalidResponse { .. } => StatusCode::BAD_REQUEST,
|
| BackendResourceError::InvalidResponse { .. } => StatusCode::BAD_REQUEST,
|
||||||
|
BackendResourceError::Timeout => StatusCode::GATEWAY_TIMEOUT,
|
||||||
BackendResourceError::Transport { .. } => StatusCode::BAD_GATEWAY,
|
BackendResourceError::Transport { .. } => StatusCode::BAD_GATEWAY,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -16904,6 +17127,35 @@ fn upsert_pending_backend_workdir(
|
|||||||
Ok(workdir_id)
|
Ok(workdir_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn reconcile_runtime_workdir_observations(
|
||||||
|
api: &WorkspaceApi,
|
||||||
|
runtime_id: &str,
|
||||||
|
items: &[worker_runtime::catalog::WorkingDirectoryStatus],
|
||||||
|
) -> ApiResult<std::collections::BTreeSet<String>> {
|
||||||
|
let mut observed = std::collections::BTreeSet::new();
|
||||||
|
for status in items {
|
||||||
|
let Some(existing) = api.store.get_workdir_registry(
|
||||||
|
&api.config.workspace_id,
|
||||||
|
&status.summary.working_directory_id,
|
||||||
|
)?
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if existing.runtime_id != runtime_id {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
observed.insert(status.summary.working_directory_id.clone());
|
||||||
|
if status.summary.status == WorkingDirectoryStatusKind::NotFound {
|
||||||
|
persist_workdir_not_found(api, existing)?;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut record = workdir_record_from_summary(api, runtime_id, &status.summary);
|
||||||
|
preserve_workdir_identity_for_corrupted_summary(&mut record, Some(&existing));
|
||||||
|
api.store.upsert_workdir_registry(&record)?;
|
||||||
|
}
|
||||||
|
Ok(observed)
|
||||||
|
}
|
||||||
|
|
||||||
fn sync_runtime_workdir_observations(
|
fn sync_runtime_workdir_observations(
|
||||||
api: &WorkspaceApi,
|
api: &WorkspaceApi,
|
||||||
runtime_id: &str,
|
runtime_id: &str,
|
||||||
@@ -16912,26 +17164,7 @@ fn sync_runtime_workdir_observations(
|
|||||||
.runtime
|
.runtime
|
||||||
.list_working_directories(runtime_id)
|
.list_working_directories(runtime_id)
|
||||||
.map_err(|err| err.into_error())?;
|
.map_err(|err| err.into_error())?;
|
||||||
let mut observed = std::collections::BTreeSet::new();
|
let observed = reconcile_runtime_workdir_observations(api, runtime_id, &response.items)?;
|
||||||
for status in &response.items {
|
|
||||||
observed.insert(status.summary.working_directory_id.clone());
|
|
||||||
if status.summary.status == WorkingDirectoryStatusKind::NotFound {
|
|
||||||
if let Some(record) = api.store.get_workdir_registry(
|
|
||||||
&api.config.workspace_id,
|
|
||||||
&status.summary.working_directory_id,
|
|
||||||
)? {
|
|
||||||
persist_workdir_not_found(api, record)?;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let existing = api.store.get_workdir_registry(
|
|
||||||
&api.config.workspace_id,
|
|
||||||
&status.summary.working_directory_id,
|
|
||||||
)?;
|
|
||||||
let mut record = workdir_record_from_summary(api, runtime_id, &status.summary);
|
|
||||||
preserve_workdir_identity_for_corrupted_summary(&mut record, existing.as_ref());
|
|
||||||
api.store.upsert_workdir_registry(&record)?;
|
|
||||||
}
|
|
||||||
for mut record in api
|
for mut record in api
|
||||||
.store
|
.store
|
||||||
.list_workdir_registry(&api.config.workspace_id, 500)?
|
.list_workdir_registry(&api.config.workspace_id, 500)?
|
||||||
@@ -17295,6 +17528,30 @@ fn validate_working_directory_claim_for_browser(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn repository_ssh_lease_candidates(
|
||||||
|
api: &WorkspaceApi,
|
||||||
|
primary: crate::repository_access::LeasedRepositorySshAccess,
|
||||||
|
) -> ApiResult<Vec<crate::repository_access::LeasedRepositorySshAccess>> {
|
||||||
|
let workspace_default = api
|
||||||
|
.repository_secrets
|
||||||
|
.ensure_workspace_default_credential(&api.config.workspace_id)?;
|
||||||
|
if primary.credential_id
|
||||||
|
== crate::repository_access::WORKSPACE_DEFAULT_REPOSITORY_SSH_CREDENTIAL_ID
|
||||||
|
{
|
||||||
|
return Ok(vec![primary]);
|
||||||
|
}
|
||||||
|
let workspace_default = api
|
||||||
|
.repository_secrets
|
||||||
|
.lease_ssh_materialization_access_revision(
|
||||||
|
&api.config.workspace_id,
|
||||||
|
crate::repository_access::WORKSPACE_DEFAULT_REPOSITORY_SSH_CREDENTIAL_ID,
|
||||||
|
workspace_default.current_revision,
|
||||||
|
&primary.host_trust_id,
|
||||||
|
primary.host_trust_revision,
|
||||||
|
)?;
|
||||||
|
Ok(vec![primary, workspace_default])
|
||||||
|
}
|
||||||
|
|
||||||
fn authorize_repository_materialization_operation(
|
fn authorize_repository_materialization_operation(
|
||||||
api: &WorkspaceApi,
|
api: &WorkspaceApi,
|
||||||
operation: &WorkdirCreateOperationRecord,
|
operation: &WorkdirCreateOperationRecord,
|
||||||
@@ -17324,6 +17581,16 @@ fn authorize_repository_materialization_operation(
|
|||||||
host_trust_id,
|
host_trust_id,
|
||||||
host_trust_revision,
|
host_trust_revision,
|
||||||
)?;
|
)?;
|
||||||
|
let leases = repository_ssh_lease_candidates(api, lease)?;
|
||||||
|
let primary_lease = leases.first().ok_or_else(|| {
|
||||||
|
settings_bad_request(
|
||||||
|
"working_directory_repository_access_invalid",
|
||||||
|
"Repository SSH access has no credential candidates",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let primary_host_trust_id = primary_lease.host_trust_id.clone();
|
||||||
|
let primary_host_trust_revision = primary_lease.host_trust_revision;
|
||||||
|
let known_hosts_entry = primary_lease.known_hosts_entry.clone();
|
||||||
let access = match access_mode {
|
let access = match access_mode {
|
||||||
"read_only" => workspace_api::RepositoryAccessMode::ReadOnly,
|
"read_only" => workspace_api::RepositoryAccessMode::ReadOnly,
|
||||||
"read_write" => workspace_api::RepositoryAccessMode::ReadWrite,
|
"read_write" => workspace_api::RepositoryAccessMode::ReadWrite,
|
||||||
@@ -17342,13 +17609,30 @@ fn authorize_repository_materialization_operation(
|
|||||||
&operation.resolved_runtime_id,
|
&operation.resolved_runtime_id,
|
||||||
format!("repository-ssh-access:{}", operation.operation_id),
|
format!("repository-ssh-access:{}", operation.operation_id),
|
||||||
format!(
|
format!(
|
||||||
"credential:{}:host-trust:{}",
|
"credentials:{}:host-trust:{}",
|
||||||
lease.credential_revision, lease.host_trust_revision
|
leases
|
||||||
|
.iter()
|
||||||
|
.map(|lease| format!(
|
||||||
|
"{}:{}",
|
||||||
|
lease.credential_id, lease.credential_revision
|
||||||
|
))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(","),
|
||||||
|
primary_host_trust_revision
|
||||||
),
|
),
|
||||||
i64::try_from(expires_at_epoch_seconds).unwrap_or(i64::MAX),
|
i64::try_from(expires_at_epoch_seconds).unwrap_or(i64::MAX),
|
||||||
worker_runtime::resource::RepositorySshAccessSecret {
|
worker_runtime::resource::RepositorySshAccessSecret {
|
||||||
|
credential_candidates: leases
|
||||||
|
.iter()
|
||||||
|
.map(|lease| {
|
||||||
|
worker_runtime::resource::RepositorySshAccessSecretCandidate {
|
||||||
|
credential_id: lease.credential_id.clone(),
|
||||||
|
credential_revision: lease.credential_revision,
|
||||||
private_key: lease.private_key.as_str().to_string(),
|
private_key: lease.private_key.as_str().to_string(),
|
||||||
known_hosts_entry: lease.known_hosts_entry.clone(),
|
}
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
known_hosts_entry: known_hosts_entry.clone(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.map_err(|_| {
|
.map_err(|_| {
|
||||||
@@ -17365,17 +17649,22 @@ fn authorize_repository_materialization_operation(
|
|||||||
config_projection_digest: operation.config_projection_digest.clone(),
|
config_projection_digest: operation.config_projection_digest.clone(),
|
||||||
cache_generation: operation.cache_generation,
|
cache_generation: operation.cache_generation,
|
||||||
ssh: Some(RepositorySshMaterializationAccess {
|
ssh: Some(RepositorySshMaterializationAccess {
|
||||||
|
credential_candidates: leases
|
||||||
|
.into_iter()
|
||||||
|
.map(|lease| RepositorySshCredentialCandidate {
|
||||||
credential_id: lease.credential_id,
|
credential_id: lease.credential_id,
|
||||||
credential_revision: lease.credential_revision,
|
credential_revision: lease.credential_revision,
|
||||||
host_trust_id: lease.host_trust_id,
|
private_key: SensitiveString::default(),
|
||||||
host_trust_revision: lease.host_trust_revision,
|
})
|
||||||
|
.collect(),
|
||||||
|
host_trust_id: primary_host_trust_id,
|
||||||
|
host_trust_revision: primary_host_trust_revision,
|
||||||
access,
|
access,
|
||||||
expires_at_epoch_seconds,
|
expires_at_epoch_seconds,
|
||||||
repository_id: request.repository.id.clone(),
|
repository_id: request.repository.id.clone(),
|
||||||
repository_source_fingerprint: request.repository.source_fingerprint.clone(),
|
repository_source_fingerprint: request.repository.source_fingerprint.clone(),
|
||||||
repository_uri: request.repository.source.uri.clone(),
|
repository_uri: request.repository.source.uri.clone(),
|
||||||
secret_resource,
|
secret_resource,
|
||||||
private_key: SensitiveString::default(),
|
|
||||||
known_hosts_entry: SensitiveString::default(),
|
known_hosts_entry: SensitiveString::default(),
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
@@ -17408,12 +17697,18 @@ fn authorize_repository_materialization_operation(
|
|||||||
"SSH Repository access authority is unavailable",
|
"SSH Repository access authority is unavailable",
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
let primary_credential = ssh.credential_candidates.first().ok_or_else(|| {
|
||||||
|
settings_bad_request(
|
||||||
|
"working_directory_repository_access_invalid",
|
||||||
|
"Repository SSH access has no credential candidates",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
api.config_store.bind_workdir_create_repository_access(
|
api.config_store.bind_workdir_create_repository_access(
|
||||||
&api.config.workspace_id,
|
&api.config.workspace_id,
|
||||||
&operation.operation_id,
|
&operation.operation_id,
|
||||||
request_fingerprint,
|
request_fingerprint,
|
||||||
&ssh.credential_id,
|
&primary_credential.credential_id,
|
||||||
ssh.credential_revision,
|
primary_credential.credential_revision,
|
||||||
&ssh.host_trust_id,
|
&ssh.host_trust_id,
|
||||||
ssh.host_trust_revision,
|
ssh.host_trust_revision,
|
||||||
match ssh.access {
|
match ssh.access {
|
||||||
@@ -17473,19 +17768,40 @@ fn authorize_repository_materialization(
|
|||||||
.map(|repository| repository.repository_key.as_str())
|
.map(|repository| repository.repository_key.as_str())
|
||||||
.ok_or_else(|| Error::UnknownRepository(request.repository.id.clone()))?;
|
.ok_or_else(|| Error::UnknownRepository(request.repository.id.clone()))?;
|
||||||
let ssh = if request.repository.source.kind == workspace_api::RepositorySourceKind::Ssh {
|
let ssh = if request.repository.source.kind == workspace_api::RepositorySourceKind::Ssh {
|
||||||
let binding = projection
|
let binding = match projection
|
||||||
.bindings
|
.bindings
|
||||||
.iter()
|
.iter()
|
||||||
.find(|binding| binding.repository_key == repository_key)
|
.find(|binding| binding.repository_key == repository_key)
|
||||||
|
.cloned()
|
||||||
|
{
|
||||||
|
Some(binding) => binding,
|
||||||
|
None => api
|
||||||
|
.repository_secrets
|
||||||
|
.default_ssh_binding_for_repository(
|
||||||
|
&api.config.workspace_id,
|
||||||
|
repository_key,
|
||||||
|
&request.repository.source.uri,
|
||||||
|
)?
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
settings_bad_request(
|
settings_bad_request(
|
||||||
"working_directory_remote_repository_access_required",
|
"working_directory_remote_repository_host_trust_required",
|
||||||
"SSH Repository has no active Workspace credential and host-trust binding",
|
"SSH Repository has no pinned host trust matching its URI",
|
||||||
)
|
)
|
||||||
})?;
|
})?,
|
||||||
|
};
|
||||||
let lease = api
|
let lease = api
|
||||||
.repository_secrets
|
.repository_secrets
|
||||||
.lease_ssh_materialization_access(&api.config.workspace_id, binding)?;
|
.lease_ssh_materialization_access(&api.config.workspace_id, &binding)?;
|
||||||
|
let leases = repository_ssh_lease_candidates(api, lease)?;
|
||||||
|
let primary_lease = leases.first().ok_or_else(|| {
|
||||||
|
settings_bad_request(
|
||||||
|
"working_directory_repository_access_invalid",
|
||||||
|
"Repository SSH access has no credential candidates",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let primary_host_trust_id = primary_lease.host_trust_id.clone();
|
||||||
|
let primary_host_trust_revision = primary_lease.host_trust_revision;
|
||||||
|
let known_hosts_entry = primary_lease.known_hosts_entry.clone();
|
||||||
let expires_at_epoch_seconds = repository_access_expiry();
|
let expires_at_epoch_seconds = repository_access_expiry();
|
||||||
let secret_resource = api
|
let secret_resource = api
|
||||||
.resource_broker
|
.resource_broker
|
||||||
@@ -17494,13 +17810,30 @@ fn authorize_repository_materialization(
|
|||||||
runtime_id,
|
runtime_id,
|
||||||
format!("repository-ssh-access:{operation_id}"),
|
format!("repository-ssh-access:{operation_id}"),
|
||||||
format!(
|
format!(
|
||||||
"credential:{}:host-trust:{}",
|
"credentials:{}:host-trust:{}",
|
||||||
lease.credential_revision, lease.host_trust_revision
|
leases
|
||||||
|
.iter()
|
||||||
|
.map(|lease| format!(
|
||||||
|
"{}:{}",
|
||||||
|
lease.credential_id, lease.credential_revision
|
||||||
|
))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(","),
|
||||||
|
primary_host_trust_revision
|
||||||
),
|
),
|
||||||
i64::try_from(expires_at_epoch_seconds).unwrap_or(i64::MAX),
|
i64::try_from(expires_at_epoch_seconds).unwrap_or(i64::MAX),
|
||||||
worker_runtime::resource::RepositorySshAccessSecret {
|
worker_runtime::resource::RepositorySshAccessSecret {
|
||||||
|
credential_candidates: leases
|
||||||
|
.iter()
|
||||||
|
.map(
|
||||||
|
|lease| worker_runtime::resource::RepositorySshAccessSecretCandidate {
|
||||||
|
credential_id: lease.credential_id.clone(),
|
||||||
|
credential_revision: lease.credential_revision,
|
||||||
private_key: lease.private_key.as_str().to_string(),
|
private_key: lease.private_key.as_str().to_string(),
|
||||||
known_hosts_entry: lease.known_hosts_entry.clone(),
|
},
|
||||||
|
)
|
||||||
|
.collect(),
|
||||||
|
known_hosts_entry: known_hosts_entry.clone(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.map_err(|_| {
|
.map_err(|_| {
|
||||||
@@ -17510,17 +17843,22 @@ fn authorize_repository_materialization(
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
Some(RepositorySshMaterializationAccess {
|
Some(RepositorySshMaterializationAccess {
|
||||||
|
credential_candidates: leases
|
||||||
|
.into_iter()
|
||||||
|
.map(|lease| RepositorySshCredentialCandidate {
|
||||||
credential_id: lease.credential_id,
|
credential_id: lease.credential_id,
|
||||||
credential_revision: lease.credential_revision,
|
credential_revision: lease.credential_revision,
|
||||||
host_trust_id: lease.host_trust_id,
|
private_key: SensitiveString::default(),
|
||||||
host_trust_revision: lease.host_trust_revision,
|
})
|
||||||
|
.collect(),
|
||||||
|
host_trust_id: primary_host_trust_id,
|
||||||
|
host_trust_revision: primary_host_trust_revision,
|
||||||
access: binding.access,
|
access: binding.access,
|
||||||
expires_at_epoch_seconds,
|
expires_at_epoch_seconds,
|
||||||
repository_id: request.repository.id.clone(),
|
repository_id: request.repository.id.clone(),
|
||||||
repository_source_fingerprint: request.repository.source_fingerprint.clone(),
|
repository_source_fingerprint: request.repository.source_fingerprint.clone(),
|
||||||
repository_uri: request.repository.source.uri.clone(),
|
repository_uri: request.repository.source.uri.clone(),
|
||||||
secret_resource,
|
secret_resource,
|
||||||
private_key: SensitiveString::default(),
|
|
||||||
known_hosts_entry: SensitiveString::default(),
|
known_hosts_entry: SensitiveString::default(),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
@@ -18151,6 +18489,14 @@ mod tests {
|
|||||||
SqliteWorkspaceStore, UserRecord, WorkspaceRecord, WorkspaceRuntimeBinding,
|
SqliteWorkspaceStore, UserRecord, WorkspaceRecord, WorkspaceRuntimeBinding,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn backend_resource_timeout_maps_to_gateway_timeout() {
|
||||||
|
assert_eq!(
|
||||||
|
backend_resource_error_status(&BackendResourceError::Timeout),
|
||||||
|
StatusCode::GATEWAY_TIMEOUT
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn browser_worker_console_href_uses_logical_worker_route() {
|
fn browser_worker_console_href_uses_logical_worker_route() {
|
||||||
let href = browser_worker_console_href("workspace/one", "W-7");
|
let href = browser_worker_console_href("workspace/one", "W-7");
|
||||||
@@ -18158,6 +18504,25 @@ mod tests {
|
|||||||
assert!(!href.contains("/runtimes/"));
|
assert!(!href.contains("/runtimes/"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_resource_fetch_bypasses_workspace_mutation_lock() {
|
||||||
|
assert!(!workspace_request_requires_mutation_lock(
|
||||||
|
&Method::POST,
|
||||||
|
"/api/runtime/v1/workspaces/workspace-a/resources/fetch",
|
||||||
|
"workspace-a"
|
||||||
|
));
|
||||||
|
assert!(workspace_request_requires_mutation_lock(
|
||||||
|
&Method::POST,
|
||||||
|
"/api/w/workspace-a/working-directories",
|
||||||
|
"workspace-a"
|
||||||
|
));
|
||||||
|
assert!(!workspace_request_requires_mutation_lock(
|
||||||
|
&Method::GET,
|
||||||
|
"/api/w/workspace-a/working-directories",
|
||||||
|
"workspace-a"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn workspace_mutation_gate_serializes_deletion_with_active_mutations() {
|
async fn workspace_mutation_gate_serializes_deletion_with_active_mutations() {
|
||||||
let locks = Arc::new(AsyncMutex::new(HashMap::new()));
|
let locks = Arc::new(AsyncMutex::new(HashMap::new()));
|
||||||
@@ -19161,6 +19526,106 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn repository_ssh_probe_distinguishes_untrusted_verified_and_changed_keys() {
|
||||||
|
let candidates = vec![RepositorySshHostKeyCandidate {
|
||||||
|
algorithm: "ssh-ed25519".to_string(),
|
||||||
|
host_key: "ssh-ed25519 AAAA".to_string(),
|
||||||
|
fingerprint: "SHA256:observed".to_string(),
|
||||||
|
}];
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
repository_ssh_connection_trust_state(&candidates, None),
|
||||||
|
RepositorySshConnectionTrustState::Untrusted
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
repository_ssh_connection_trust_state(&candidates, Some("SHA256:observed")),
|
||||||
|
RepositorySshConnectionTrustState::Verified
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
repository_ssh_connection_trust_state(&candidates, Some("SHA256:old")),
|
||||||
|
RepositorySshConnectionTrustState::Changed
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn repository_ssh_clone_leases_specific_then_workspace_default_credentials() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let api = test_api(dir.path()).await;
|
||||||
|
let specific = api
|
||||||
|
.repository_secrets
|
||||||
|
.generate_credential(
|
||||||
|
&api.config.workspace_id,
|
||||||
|
GenerateRepositorySshCredentialRequest {
|
||||||
|
operation_id: "generate-repository-specific".to_string(),
|
||||||
|
credential_id: "repository-specific".to_string(),
|
||||||
|
name: "Repository specific".to_string(),
|
||||||
|
},
|
||||||
|
"owner-account",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let host_public_key = api
|
||||||
|
.repository_secrets
|
||||||
|
.credential_public_key(&api.config.workspace_id, &specific.credential_id)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.public_key;
|
||||||
|
let host_trust = api
|
||||||
|
.repository_secrets
|
||||||
|
.put_host_trust(
|
||||||
|
&api.config.workspace_id,
|
||||||
|
PutRepositorySshHostTrustRequest {
|
||||||
|
operation_id: "trust-example-host".to_string(),
|
||||||
|
host_trust_id: "example-host".to_string(),
|
||||||
|
hostname: "example.test".to_string(),
|
||||||
|
port: 22,
|
||||||
|
host_key: host_public_key,
|
||||||
|
expected_revision: None,
|
||||||
|
},
|
||||||
|
"owner-account",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let binding = workspace_api::RepositorySshAccessBinding {
|
||||||
|
repository_key: "test-repository".to_string(),
|
||||||
|
credential_id: specific.credential_id.clone(),
|
||||||
|
host_trust_id: host_trust.host_trust_id,
|
||||||
|
access: workspace_api::RepositoryAccessMode::ReadOnly,
|
||||||
|
};
|
||||||
|
let primary = api
|
||||||
|
.repository_secrets
|
||||||
|
.lease_ssh_materialization_access(&api.config.workspace_id, &binding)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let candidates = repository_ssh_lease_candidates(&api, primary).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(candidates.len(), 2);
|
||||||
|
assert_eq!(candidates[0].credential_id, "repository-specific");
|
||||||
|
assert_eq!(
|
||||||
|
candidates[1].credential_id,
|
||||||
|
crate::repository_access::WORKSPACE_DEFAULT_REPOSITORY_SSH_CREDENTIAL_ID
|
||||||
|
);
|
||||||
|
assert_ne!(
|
||||||
|
candidates[0].private_key.as_str(),
|
||||||
|
candidates[1].private_key.as_str()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
candidates[0].known_hosts_entry,
|
||||||
|
candidates[1].known_hosts_entry
|
||||||
|
);
|
||||||
|
|
||||||
|
let default_binding = workspace_api::RepositorySshAccessBinding {
|
||||||
|
credential_id: crate::repository_access::WORKSPACE_DEFAULT_REPOSITORY_SSH_CREDENTIAL_ID
|
||||||
|
.to_string(),
|
||||||
|
..binding
|
||||||
|
};
|
||||||
|
let primary_default = api
|
||||||
|
.repository_secrets
|
||||||
|
.lease_ssh_materialization_access(&api.config.workspace_id, &default_binding)
|
||||||
|
.unwrap();
|
||||||
|
let default_only = repository_ssh_lease_candidates(&api, primary_default).unwrap();
|
||||||
|
assert_eq!(default_only.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn repository_bound_ticket_flow_and_workdir_launches_fail_closed_across_workspaces() {
|
async fn repository_bound_ticket_flow_and_workdir_launches_fail_closed_across_workspaces() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
@@ -19270,7 +19735,13 @@ mod tests {
|
|||||||
"1",
|
"1",
|
||||||
i64::MAX,
|
i64::MAX,
|
||||||
worker_runtime::resource::RepositorySshAccessSecret {
|
worker_runtime::resource::RepositorySshAccessSecret {
|
||||||
|
credential_candidates: vec![
|
||||||
|
worker_runtime::resource::RepositorySshAccessSecretCandidate {
|
||||||
|
credential_id: "credential-1".to_string(),
|
||||||
|
credential_revision: 1,
|
||||||
private_key: "private-key-bytes".to_string(),
|
private_key: "private-key-bytes".to_string(),
|
||||||
|
},
|
||||||
|
],
|
||||||
known_hosts_entry: "known-hosts-entry".to_string(),
|
known_hosts_entry: "known-hosts-entry".to_string(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -19290,8 +19761,13 @@ mod tests {
|
|||||||
cache_generation: 0,
|
cache_generation: 0,
|
||||||
ssh: Some(
|
ssh: Some(
|
||||||
worker_runtime::catalog::RepositorySshMaterializationAccess {
|
worker_runtime::catalog::RepositorySshMaterializationAccess {
|
||||||
|
credential_candidates: vec![
|
||||||
|
worker_runtime::catalog::RepositorySshCredentialCandidate {
|
||||||
credential_id: "credential-1".to_string(),
|
credential_id: "credential-1".to_string(),
|
||||||
credential_revision: 1,
|
credential_revision: 1,
|
||||||
|
private_key: worker_runtime::catalog::SensitiveString::default(),
|
||||||
|
},
|
||||||
|
],
|
||||||
host_trust_id: "host-trust-1".to_string(),
|
host_trust_id: "host-trust-1".to_string(),
|
||||||
host_trust_revision: 1,
|
host_trust_revision: 1,
|
||||||
access: workspace_api::RepositoryAccessMode::ReadOnly,
|
access: workspace_api::RepositoryAccessMode::ReadOnly,
|
||||||
@@ -19303,7 +19779,6 @@ mod tests {
|
|||||||
.clone(),
|
.clone(),
|
||||||
repository_uri: working_directory.repository.source.uri.clone(),
|
repository_uri: working_directory.repository.source.uri.clone(),
|
||||||
secret_resource,
|
secret_resource,
|
||||||
private_key: worker_runtime::catalog::SensitiveString::default(),
|
|
||||||
known_hosts_entry: worker_runtime::catalog::SensitiveString::default(),
|
known_hosts_entry: worker_runtime::catalog::SensitiveString::default(),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -21007,6 +21482,60 @@ mod tests {
|
|||||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn runtime_workdir_inventory_does_not_adopt_unknown_workspace_rows() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let api = test_api(dir.path()).await;
|
||||||
|
let foreign = WorkdirRegistryRecord {
|
||||||
|
workspace_id: "other-workspace".to_string(),
|
||||||
|
workdir_id: "foreign-workdir".to_string(),
|
||||||
|
runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(),
|
||||||
|
repository_id: "foreign-repository".to_string(),
|
||||||
|
creation_selector: None,
|
||||||
|
creation_ref: None,
|
||||||
|
creation_tree: None,
|
||||||
|
current_selector: None,
|
||||||
|
current_ref: None,
|
||||||
|
current_tree: None,
|
||||||
|
observed_at_epoch_seconds: None,
|
||||||
|
materialization_status: "present".to_string(),
|
||||||
|
cleanliness: "clean".to_string(),
|
||||||
|
created_at: "1".to_string(),
|
||||||
|
updated_at: "1".to_string(),
|
||||||
|
};
|
||||||
|
let items = [worker_runtime::catalog::WorkingDirectoryStatus {
|
||||||
|
summary: runtime_workdir_summary_from_record(&foreign),
|
||||||
|
}];
|
||||||
|
|
||||||
|
let observed =
|
||||||
|
reconcile_runtime_workdir_observations(&api, EMBEDDED_WORKER_RUNTIME_ID, &items)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(observed.is_empty());
|
||||||
|
assert!(
|
||||||
|
api.store
|
||||||
|
.get_workdir_registry(TEST_WORKSPACE_ID, "foreign-workdir")
|
||||||
|
.unwrap()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn runtime_workdir_detail_rejects_unknown_workspace_row() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let api = test_api(dir.path()).await;
|
||||||
|
|
||||||
|
let response = working_directory_detail_for_runtime(
|
||||||
|
api,
|
||||||
|
EMBEDDED_WORKER_RUNTIME_ID,
|
||||||
|
"foreign-workdir",
|
||||||
|
)
|
||||||
|
.unwrap_err()
|
||||||
|
.into_response();
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn runtime_binding_summary_omits_stale_verification_revision() {
|
fn runtime_binding_summary_omits_stale_verification_revision() {
|
||||||
let binding = WorkspaceRuntimeBinding {
|
let binding = WorkspaceRuntimeBinding {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"dev": "deno run -A npm:vite@7.2.7 dev",
|
"dev": "deno run -A npm:vite@7.2.7 dev",
|
||||||
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
|
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
|
||||||
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
|
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
|
||||||
"test": "deno test --allow-read=src,test,tests --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts src/lib/workspace/auth/model.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts",
|
"test": "deno test --allow-read=src,test,tests --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts src/lib/workspace/auth/model.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts test/repository-ssh-connection-ui.test.ts",
|
||||||
"build": "deno run -A npm:vite@7.2.7 build",
|
"build": "deno run -A npm:vite@7.2.7 build",
|
||||||
"preview": "deno run -A npm:vite@7.2.7 preview"
|
"preview": "deno run -A npm:vite@7.2.7 preview"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -22,6 +22,20 @@ export type CreateRepositorySshCredentialRequest = {
|
|||||||
passphrase: string | null;
|
passphrase: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type GenerateRepositorySshCredentialRequest = {
|
||||||
|
operation_id: string;
|
||||||
|
credential_id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RepositorySshPublicKey = {
|
||||||
|
credential_id: string;
|
||||||
|
current_revision: number;
|
||||||
|
public_key_algorithm: string;
|
||||||
|
public_key_fingerprint: string;
|
||||||
|
public_key: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type RotateRepositorySshCredentialRequest = {
|
export type RotateRepositorySshCredentialRequest = {
|
||||||
operation_id: string;
|
operation_id: string;
|
||||||
expected_revision: number;
|
expected_revision: number;
|
||||||
|
|||||||
@@ -307,6 +307,38 @@ export type RepositoryDetailResponse = {
|
|||||||
source: string;
|
source: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type RepositorySshConnectionProbeRequest = { runtime_id: string };
|
||||||
|
|
||||||
|
export type RepositorySshHostKeyCandidate = {
|
||||||
|
algorithm: string;
|
||||||
|
host_key: string;
|
||||||
|
fingerprint: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RepositorySshConnectionTrustState =
|
||||||
|
| "untrusted"
|
||||||
|
| "verified"
|
||||||
|
| "changed";
|
||||||
|
|
||||||
|
export type RepositorySshConnectionProbeResponse = {
|
||||||
|
workspace_id: string;
|
||||||
|
repository_key: string;
|
||||||
|
runtime_id: string;
|
||||||
|
hostname: string;
|
||||||
|
port: number;
|
||||||
|
trust_state: RepositorySshConnectionTrustState;
|
||||||
|
host_trust_id: string;
|
||||||
|
expected_host_trust_revision: number | null;
|
||||||
|
candidates: Array<RepositorySshHostKeyCandidate>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ConfirmRepositorySshHostTrustRequest = {
|
||||||
|
operation_id: string;
|
||||||
|
runtime_id: string;
|
||||||
|
host_key: string;
|
||||||
|
expected_host_trust_revision: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type RepositoryLogResponse = {
|
export type RepositoryLogResponse = {
|
||||||
workspace_id: string;
|
workspace_id: string;
|
||||||
repository_key: string;
|
repository_key: string;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type {
|
|||||||
RepositoryAccessProjection,
|
RepositoryAccessProjection,
|
||||||
RepositorySshCredential,
|
RepositorySshCredential,
|
||||||
RepositorySshHostTrust,
|
RepositorySshHostTrust,
|
||||||
|
RepositorySshPublicKey,
|
||||||
} from "../../generated/repository-access-api.ts";
|
} from "../../generated/repository-access-api.ts";
|
||||||
|
|
||||||
export class RepositoryAccessSchemaError extends Error {
|
export class RepositoryAccessSchemaError extends Error {
|
||||||
@@ -50,6 +51,25 @@ export function parseRepositorySshCredential(
|
|||||||
return record as RepositorySshCredential;
|
return record as RepositorySshCredential;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parseRepositorySshPublicKey(
|
||||||
|
value: unknown,
|
||||||
|
path = "public_key",
|
||||||
|
): RepositorySshPublicKey {
|
||||||
|
const record = readRecord(value, path, [
|
||||||
|
"credential_id",
|
||||||
|
"current_revision",
|
||||||
|
"public_key_algorithm",
|
||||||
|
"public_key_fingerprint",
|
||||||
|
"public_key",
|
||||||
|
]);
|
||||||
|
readString(record, "credential_id", path);
|
||||||
|
readRevision(record, "current_revision", path);
|
||||||
|
readString(record, "public_key_algorithm", path);
|
||||||
|
readString(record, "public_key_fingerprint", path);
|
||||||
|
readString(record, "public_key", path);
|
||||||
|
return record as RepositorySshPublicKey;
|
||||||
|
}
|
||||||
|
|
||||||
export function parseRepositorySshHostTrusts(
|
export function parseRepositorySshHostTrusts(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
): RepositorySshHostTrust[] {
|
): RepositorySshHostTrust[] {
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import type {
|
|||||||
RepositoryLogResponse,
|
RepositoryLogResponse,
|
||||||
RepositorySource,
|
RepositorySource,
|
||||||
RepositorySourceKind,
|
RepositorySourceKind,
|
||||||
|
RepositorySshConnectionProbeResponse,
|
||||||
|
RepositorySshConnectionTrustState,
|
||||||
|
RepositorySshHostKeyCandidate,
|
||||||
RepositorySummary,
|
RepositorySummary,
|
||||||
WorkspaceAuthConfig,
|
WorkspaceAuthConfig,
|
||||||
WorkspaceCatalogListResponse,
|
WorkspaceCatalogListResponse,
|
||||||
@@ -35,6 +38,8 @@ export type {
|
|||||||
RepositoryDetailResponse,
|
RepositoryDetailResponse,
|
||||||
RepositoryListResponse,
|
RepositoryListResponse,
|
||||||
RepositoryLogResponse,
|
RepositoryLogResponse,
|
||||||
|
RepositorySshConnectionProbeResponse,
|
||||||
|
RepositorySshHostKeyCandidate,
|
||||||
RepositorySummary,
|
RepositorySummary,
|
||||||
WorkspaceCatalogListResponse,
|
WorkspaceCatalogListResponse,
|
||||||
WorkspaceCreateResponse,
|
WorkspaceCreateResponse,
|
||||||
@@ -583,6 +588,98 @@ export function parseRepositoryDetailResponse(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SSH_CONNECTION_TRUST_STATES = new Set<RepositorySshConnectionTrustState>([
|
||||||
|
"untrusted",
|
||||||
|
"verified",
|
||||||
|
"changed",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function repositorySshHostKeyCandidate(
|
||||||
|
value: unknown,
|
||||||
|
path: string,
|
||||||
|
): RepositorySshHostKeyCandidate {
|
||||||
|
const candidate = object(value, path);
|
||||||
|
exactKeys(candidate, ["algorithm", "host_key", "fingerprint"], path);
|
||||||
|
return {
|
||||||
|
algorithm: string(candidate.algorithm, `${path}.algorithm`),
|
||||||
|
host_key: string(candidate.host_key, `${path}.host_key`),
|
||||||
|
fingerprint: string(candidate.fingerprint, `${path}.fingerprint`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseRepositorySshConnectionProbeResponse(
|
||||||
|
value: unknown,
|
||||||
|
): RepositorySshConnectionProbeResponse {
|
||||||
|
const response = object(value, "repository SSH connection probe response");
|
||||||
|
exactKeys(
|
||||||
|
response,
|
||||||
|
[
|
||||||
|
"workspace_id",
|
||||||
|
"repository_key",
|
||||||
|
"runtime_id",
|
||||||
|
"hostname",
|
||||||
|
"port",
|
||||||
|
"trust_state",
|
||||||
|
"host_trust_id",
|
||||||
|
"expected_host_trust_revision",
|
||||||
|
"candidates",
|
||||||
|
],
|
||||||
|
"repository SSH connection probe response",
|
||||||
|
);
|
||||||
|
const trustState = string(
|
||||||
|
response.trust_state,
|
||||||
|
"repository SSH connection probe response.trust_state",
|
||||||
|
) as RepositorySshConnectionTrustState;
|
||||||
|
if (!SSH_CONNECTION_TRUST_STATES.has(trustState)) {
|
||||||
|
throw new Error(
|
||||||
|
"repository SSH connection probe response.trust_state is invalid",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
workspace_id: string(
|
||||||
|
response.workspace_id,
|
||||||
|
"repository SSH connection probe response.workspace_id",
|
||||||
|
),
|
||||||
|
repository_key: string(
|
||||||
|
response.repository_key,
|
||||||
|
"repository SSH connection probe response.repository_key",
|
||||||
|
),
|
||||||
|
runtime_id: string(
|
||||||
|
response.runtime_id,
|
||||||
|
"repository SSH connection probe response.runtime_id",
|
||||||
|
),
|
||||||
|
hostname: string(
|
||||||
|
response.hostname,
|
||||||
|
"repository SSH connection probe response.hostname",
|
||||||
|
),
|
||||||
|
port: integer(
|
||||||
|
response.port,
|
||||||
|
"repository SSH connection probe response.port",
|
||||||
|
),
|
||||||
|
trust_state: trustState,
|
||||||
|
host_trust_id: string(
|
||||||
|
response.host_trust_id,
|
||||||
|
"repository SSH connection probe response.host_trust_id",
|
||||||
|
),
|
||||||
|
expected_host_trust_revision: response.expected_host_trust_revision === null
|
||||||
|
? null
|
||||||
|
: integer(
|
||||||
|
response.expected_host_trust_revision,
|
||||||
|
"repository SSH connection probe response.expected_host_trust_revision",
|
||||||
|
),
|
||||||
|
candidates: array(
|
||||||
|
response.candidates,
|
||||||
|
"repository SSH connection probe response.candidates",
|
||||||
|
).map(
|
||||||
|
(candidate, index) =>
|
||||||
|
repositorySshHostKeyCandidate(
|
||||||
|
candidate,
|
||||||
|
`repository SSH connection probe response.candidates[${index}]`,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const WORKSPACE_DELETION_MAX_OPERATION_ID_BYTES = 128;
|
const WORKSPACE_DELETION_MAX_OPERATION_ID_BYTES = 128;
|
||||||
const WORKSPACE_DELETION_MAX_REVISION_BYTES = 128;
|
const WORKSPACE_DELETION_MAX_REVISION_BYTES = 128;
|
||||||
const WORKSPACE_DELETION_MAX_BLOCKERS = 1024;
|
const WORKSPACE_DELETION_MAX_BLOCKERS = 1024;
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { WorkspaceRuntimeResource } from "$lib/generated/workspace-api";
|
||||||
|
|
||||||
|
export function repositorySshProbeRuntimes(
|
||||||
|
runtimes: readonly WorkspaceRuntimeResource[],
|
||||||
|
): WorkspaceRuntimeResource[] {
|
||||||
|
return runtimes.filter((runtime) =>
|
||||||
|
runtime.kind === "remote_worker_runtime" &&
|
||||||
|
runtime.management.endpoint_configured &&
|
||||||
|
runtime.management.binding !== undefined &&
|
||||||
|
runtime.management.binding !== null &&
|
||||||
|
runtime.management.binding.state !== "revoked"
|
||||||
|
);
|
||||||
|
}
|
||||||
+122
-1
@@ -1,8 +1,88 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { formatDate } from '$lib/workspace/api/http';
|
import type {
|
||||||
|
ConfirmRepositorySshHostTrustRequest,
|
||||||
|
RepositorySshConnectionProbeRequest,
|
||||||
|
RepositorySshConnectionProbeResponse
|
||||||
|
} from '$lib/generated/workspace-api';
|
||||||
|
import { parseRepositorySshHostTrust } from '$lib/workspace/api/repository-access';
|
||||||
|
import { formatDate, workspaceApiPath } from '$lib/workspace/api/http';
|
||||||
|
import { parseRepositorySshConnectionProbeResponse } from '$lib/workspace/api/workspace-model';
|
||||||
|
import { repositorySshProbeRuntimes } from '$lib/workspace/repositories/ssh-connection';
|
||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
let { data }: PageProps = $props();
|
let { data }: PageProps = $props();
|
||||||
|
let selectedRuntimeId = $state('');
|
||||||
|
let probe = $state<RepositorySshConnectionProbeResponse | null>(null);
|
||||||
|
let selectedHostKey = $state('');
|
||||||
|
let pending = $state(false);
|
||||||
|
let connectionMessage = $state<string | null>(null);
|
||||||
|
const probeRuntimes = $derived(data.runtimes ? repositorySshProbeRuntimes(data.runtimes.items) : []);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!selectedRuntimeId) {
|
||||||
|
selectedRuntimeId = probeRuntimes[0]?.runtime_id ?? '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function requestConnectionTest(method: 'POST' | 'PUT', body: unknown): Promise<unknown> {
|
||||||
|
const response = await fetch(
|
||||||
|
workspaceApiPath(
|
||||||
|
data.repository?.workspace_id ?? '',
|
||||||
|
`/repositories/${encodeURIComponent(data.repositoryKey)}/ssh-connection-test`
|
||||||
|
),
|
||||||
|
{
|
||||||
|
method,
|
||||||
|
headers: { accept: 'application/json', 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const value = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
const record = value && typeof value === 'object' ? value as Record<string, unknown> : null;
|
||||||
|
throw new Error(typeof record?.message === 'string' ? record.message : `SSH connection test failed with status ${response.status}`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runConnectionTest() {
|
||||||
|
pending = true;
|
||||||
|
connectionMessage = null;
|
||||||
|
probe = null;
|
||||||
|
selectedHostKey = '';
|
||||||
|
try {
|
||||||
|
const body: RepositorySshConnectionProbeRequest = { runtime_id: selectedRuntimeId };
|
||||||
|
probe = parseRepositorySshConnectionProbeResponse(await requestConnectionTest('POST', body));
|
||||||
|
selectedHostKey = probe.candidates[0]?.host_key ?? '';
|
||||||
|
connectionMessage = probe.trust_state === 'verified'
|
||||||
|
? 'The observed SSH host key matches the Workspace trust record.'
|
||||||
|
: 'Review the observed fingerprint before trusting this SSH host.';
|
||||||
|
} catch (error) {
|
||||||
|
connectionMessage = error instanceof Error ? error.message : 'SSH connection test failed';
|
||||||
|
} finally {
|
||||||
|
pending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmHostTrust() {
|
||||||
|
if (!probe || !selectedHostKey) return;
|
||||||
|
pending = true;
|
||||||
|
connectionMessage = null;
|
||||||
|
try {
|
||||||
|
const body: ConfirmRepositorySshHostTrustRequest = {
|
||||||
|
operation_id: `repository-ssh-confirm-${crypto.randomUUID()}`,
|
||||||
|
runtime_id: probe.runtime_id,
|
||||||
|
host_key: selectedHostKey,
|
||||||
|
expected_host_trust_revision: probe.expected_host_trust_revision
|
||||||
|
};
|
||||||
|
parseRepositorySshHostTrust(await requestConnectionTest('PUT', body));
|
||||||
|
probe = { ...probe, trust_state: 'verified' };
|
||||||
|
connectionMessage = 'SSH host trust saved. Future connections must present this key.';
|
||||||
|
} catch (error) {
|
||||||
|
connectionMessage = error instanceof Error ? error.message : 'Failed to save SSH host trust';
|
||||||
|
} finally {
|
||||||
|
pending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -78,6 +158,47 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{#if data.repository?.item.source.kind === 'ssh'}
|
||||||
|
<section class="card repository-detail-card">
|
||||||
|
<h2>SSH connection test</h2>
|
||||||
|
<p>Observe the SSH host key from the same Runtime that will clone this Repository. Nothing is trusted until you confirm a fingerprint below.</p>
|
||||||
|
{#if data.runtimesError}
|
||||||
|
<p class="section-state error">{data.runtimesError}</p>
|
||||||
|
{:else if data.runtimes}
|
||||||
|
<label>
|
||||||
|
<span>Runtime</span>
|
||||||
|
<select bind:value={selectedRuntimeId} disabled={pending}>
|
||||||
|
{#each probeRuntimes as runtime}
|
||||||
|
<option value={runtime.runtime_id}>{runtime.label} · {runtime.runtime_id}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{#if probeRuntimes.length === 0}
|
||||||
|
<p class="section-state error">No configured remote Runtime is available for this connection test.</p>
|
||||||
|
{/if}
|
||||||
|
<button type="button" disabled={pending || !selectedRuntimeId} onclick={() => void runConnectionTest()}>
|
||||||
|
{pending ? 'Checking…' : 'Check SSH connection'}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if probe}
|
||||||
|
<p><strong>{probe.hostname}:{probe.port}</strong> · {probe.trust_state}</p>
|
||||||
|
{#each probe.candidates as candidate}
|
||||||
|
<label class="repository-host-key-candidate">
|
||||||
|
<input type="radio" name="repository-host-key" bind:group={selectedHostKey} value={candidate.host_key} />
|
||||||
|
<span><code>{candidate.algorithm}</code> <code>{candidate.fingerprint}</code></span>
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
{#if probe.trust_state !== 'verified'}
|
||||||
|
<button type="button" class="danger" disabled={pending || !selectedHostKey} onclick={() => void confirmHostTrust()}>
|
||||||
|
Confirm and trust selected host key
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{#if connectionMessage}<p class="section-state" class:error={probe === null}>{connectionMessage}</p>{/if}
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<section class="card repository-log-card">
|
<section class="card repository-log-card">
|
||||||
<h2>Recent commits</h2>
|
<h2>Recent commits</h2>
|
||||||
{#if data.repositoryLog}
|
{#if data.repositoryLog}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
||||||
|
import { parseWorkspaceRuntimeList } from "$lib/workspace/api/runtime-management";
|
||||||
import {
|
import {
|
||||||
parseRepositoryDetailResponse,
|
parseRepositoryDetailResponse,
|
||||||
parseRepositoryLogResponse,
|
parseRepositoryLogResponse,
|
||||||
@@ -8,7 +9,7 @@ import type { PageLoad } from "./$types";
|
|||||||
export const load: PageLoad = async ({ fetch, params }) => {
|
export const load: PageLoad = async ({ fetch, params }) => {
|
||||||
const workspaceId = params.workspaceId;
|
const workspaceId = params.workspaceId;
|
||||||
const repositoryKey = params.repositoryKey;
|
const repositoryKey = params.repositoryKey;
|
||||||
const [repositoryResult, logResult] = await Promise.all([
|
const [repositoryResult, logResult, runtimesResult] = await Promise.all([
|
||||||
loadJson<unknown>(
|
loadJson<unknown>(
|
||||||
fetch,
|
fetch,
|
||||||
workspaceApiPath(
|
workspaceApiPath(
|
||||||
@@ -23,6 +24,10 @@ export const load: PageLoad = async ({ fetch, params }) => {
|
|||||||
`/repositories/${encodeURIComponent(repositoryKey)}/log`,
|
`/repositories/${encodeURIComponent(repositoryKey)}/log`,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
loadJson<unknown>(
|
||||||
|
fetch,
|
||||||
|
workspaceApiPath(workspaceId, "/runtimes"),
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let repository = null;
|
let repository = null;
|
||||||
@@ -49,11 +54,25 @@ export const load: PageLoad = async ({ fetch, params }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let runtimes = null;
|
||||||
|
let runtimesError = runtimesResult.error;
|
||||||
|
if (runtimesResult.data !== null) {
|
||||||
|
try {
|
||||||
|
runtimes = parseWorkspaceRuntimeList(runtimesResult.data);
|
||||||
|
} catch (cause) {
|
||||||
|
runtimesError = cause instanceof Error
|
||||||
|
? cause.message
|
||||||
|
: "invalid Runtime summary response";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
repositoryKey,
|
repositoryKey,
|
||||||
repository,
|
repository,
|
||||||
repositoryError,
|
repositoryError,
|
||||||
repositoryLog: log,
|
repositoryLog: log,
|
||||||
repositoryLogError: logError,
|
repositoryLogError: logError,
|
||||||
|
runtimes,
|
||||||
|
runtimesError,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,24 +4,33 @@
|
|||||||
CreateRepositorySshCredentialRequest,
|
CreateRepositorySshCredentialRequest,
|
||||||
DeleteRepositorySshCredentialRequest,
|
DeleteRepositorySshCredentialRequest,
|
||||||
DeleteRepositorySshHostTrustRequest,
|
DeleteRepositorySshHostTrustRequest,
|
||||||
|
GenerateRepositorySshCredentialRequest,
|
||||||
PutRepositorySshHostTrustRequest,
|
PutRepositorySshHostTrustRequest,
|
||||||
RepositorySshCredential,
|
RepositorySshCredential,
|
||||||
RepositorySshHostTrust,
|
RepositorySshHostTrust,
|
||||||
|
RepositorySshPublicKey,
|
||||||
RotateRepositorySshCredentialRequest,
|
RotateRepositorySshCredentialRequest,
|
||||||
} from '$lib/generated/repository-access-api';
|
} from '$lib/generated/repository-access-api';
|
||||||
import {
|
import {
|
||||||
parseRepositorySshCredential,
|
parseRepositorySshCredential,
|
||||||
parseRepositorySshHostTrust,
|
parseRepositorySshHostTrust,
|
||||||
|
parseRepositorySshPublicKey,
|
||||||
} from '$lib/workspace/api/repository-access';
|
} from '$lib/workspace/api/repository-access';
|
||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
let { data }: PageProps = $props();
|
let { data }: PageProps = $props();
|
||||||
let credentials = $state<RepositorySshCredential[]>(untrack(() => data.credentials));
|
let credentials = $state<RepositorySshCredential[]>(untrack(() => data.credentials));
|
||||||
|
let publicKeys = $state<Record<string, RepositorySshPublicKey>>(
|
||||||
|
Object.fromEntries(untrack(() => data.publicKeys).map((key) => [key.credential_id, key]))
|
||||||
|
);
|
||||||
let hostTrusts = $state<RepositorySshHostTrust[]>(untrack(() => data.hostTrusts));
|
let hostTrusts = $state<RepositorySshHostTrust[]>(untrack(() => data.hostTrusts));
|
||||||
const accessProjection = untrack(() => data.accessProjection);
|
const accessProjection = untrack(() => data.accessProjection);
|
||||||
let message = $state<string | null>(null);
|
let message = $state<string | null>(null);
|
||||||
let pending = $state(false);
|
let pending = $state(false);
|
||||||
|
let copiedCredentialId = $state<string | null>(null);
|
||||||
|
|
||||||
|
let generateCredentialId = $state('');
|
||||||
|
let generateCredentialName = $state('');
|
||||||
let credentialId = $state('');
|
let credentialId = $state('');
|
||||||
let credentialName = $state('');
|
let credentialName = $state('');
|
||||||
let privateKey = $state('');
|
let privateKey = $state('');
|
||||||
@@ -37,6 +46,7 @@
|
|||||||
let hostExpectedRevision = $state<number | null>(null);
|
let hostExpectedRevision = $state<number | null>(null);
|
||||||
|
|
||||||
const base = $derived(`/api/w/${encodeURIComponent(data.workspaceId)}/settings/repository-access`);
|
const base = $derived(`/api/w/${encodeURIComponent(data.workspaceId)}/settings/repository-access`);
|
||||||
|
const workspaceDefaultCredentialId = 'workspace-default';
|
||||||
|
|
||||||
function operationId(prefix: string): string {
|
function operationId(prefix: string): string {
|
||||||
return `${prefix}-${crypto.randomUUID()}`;
|
return `${prefix}-${crypto.randomUUID()}`;
|
||||||
@@ -71,6 +81,55 @@
|
|||||||
return parse(payload);
|
return parse(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadPublicKey(credentialId: string): Promise<RepositorySshPublicKey> {
|
||||||
|
const response = await fetch(
|
||||||
|
`${base}/credentials/${encodeURIComponent(credentialId)}/public-key`,
|
||||||
|
{ headers: { accept: 'application/json' } }
|
||||||
|
);
|
||||||
|
const body = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Repository Access request failed with status ${response.status}.`);
|
||||||
|
}
|
||||||
|
return parseRepositorySshPublicKey(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateCredential() {
|
||||||
|
pending = true;
|
||||||
|
message = null;
|
||||||
|
try {
|
||||||
|
const body: GenerateRepositorySshCredentialRequest = {
|
||||||
|
operation_id: operationId('credential-generate'),
|
||||||
|
credential_id: generateCredentialId,
|
||||||
|
name: generateCredentialName
|
||||||
|
};
|
||||||
|
const created = await request('/credentials/generate', 'POST', body, parseRepositorySshCredential);
|
||||||
|
const publicKey = await loadPublicKey(created.credential_id);
|
||||||
|
credentials = [...credentials.filter((item) => item.credential_id !== created.credential_id), created];
|
||||||
|
publicKeys = { ...publicKeys, [created.credential_id]: publicKey };
|
||||||
|
generateCredentialId = '';
|
||||||
|
generateCredentialName = '';
|
||||||
|
message = `Generated SSH credential ${created.credential_id}`;
|
||||||
|
} catch (error) {
|
||||||
|
message = error instanceof Error ? error.message : 'Failed to generate SSH credential';
|
||||||
|
} finally {
|
||||||
|
pending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyPublicKey(credentialId: string) {
|
||||||
|
const publicKey = publicKeys[credentialId]?.public_key;
|
||||||
|
if (!publicKey) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(publicKey);
|
||||||
|
copiedCredentialId = credentialId;
|
||||||
|
window.setTimeout(() => {
|
||||||
|
if (copiedCredentialId === credentialId) copiedCredentialId = null;
|
||||||
|
}, 1500);
|
||||||
|
} catch {
|
||||||
|
message = 'Failed to copy the public key';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function createCredential() {
|
async function createCredential() {
|
||||||
pending = true;
|
pending = true;
|
||||||
message = null;
|
message = null;
|
||||||
@@ -88,7 +147,9 @@
|
|||||||
body,
|
body,
|
||||||
parseRepositorySshCredential
|
parseRepositorySshCredential
|
||||||
);
|
);
|
||||||
|
const publicKey = await loadPublicKey(created.credential_id);
|
||||||
credentials = [...credentials, created].sort((a, b) => a.credential_id.localeCompare(b.credential_id));
|
credentials = [...credentials, created].sort((a, b) => a.credential_id.localeCompare(b.credential_id));
|
||||||
|
publicKeys = { ...publicKeys, [created.credential_id]: publicKey };
|
||||||
credentialId = '';
|
credentialId = '';
|
||||||
credentialName = '';
|
credentialName = '';
|
||||||
message = `Credential ${created.credential_id} created. Pasted secret fields were cleared.`;
|
message = `Credential ${created.credential_id} created. Pasted secret fields were cleared.`;
|
||||||
@@ -117,7 +178,9 @@
|
|||||||
body,
|
body,
|
||||||
parseRepositorySshCredential
|
parseRepositorySshCredential
|
||||||
);
|
);
|
||||||
|
const publicKey = await loadPublicKey(rotated.credential_id);
|
||||||
credentials = credentials.map((entry) => entry.credential_id === rotated.credential_id ? rotated : entry);
|
credentials = credentials.map((entry) => entry.credential_id === rotated.credential_id ? rotated : entry);
|
||||||
|
publicKeys = { ...publicKeys, [rotated.credential_id]: publicKey };
|
||||||
rotateCredentialId = null;
|
rotateCredentialId = null;
|
||||||
message = `Credential ${rotated.credential_id} rotated to revision ${rotated.current_revision}. Pasted secret fields were cleared.`;
|
message = `Credential ${rotated.credential_id} rotated to revision ${rotated.current_revision}. Pasted secret fields were cleared.`;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -145,6 +208,9 @@
|
|||||||
null
|
null
|
||||||
);
|
);
|
||||||
credentials = credentials.filter((entry) => entry.credential_id !== credential.credential_id);
|
credentials = credentials.filter((entry) => entry.credential_id !== credential.credential_id);
|
||||||
|
const remainingPublicKeys = { ...publicKeys };
|
||||||
|
delete remainingPublicKeys[credential.credential_id];
|
||||||
|
publicKeys = remainingPublicKeys;
|
||||||
message = `Credential ${credential.credential_id} deleted.`;
|
message = `Credential ${credential.credential_id} deleted.`;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message = error instanceof Error ? error.message : 'Credential deletion failed';
|
message = error instanceof Error ? error.message : 'Credential deletion failed';
|
||||||
@@ -227,7 +293,7 @@
|
|||||||
<div><p class="eyebrow">owner only</p><h2>Repository Access</h2></div>
|
<div><p class="eyebrow">owner only</p><h2>Repository Access</h2></div>
|
||||||
<span class="badge success">encrypted</span>
|
<span class="badge success">encrypted</span>
|
||||||
</header>
|
</header>
|
||||||
<p>Manage Workspace-scoped SSH credentials and pinned host keys. Private keys and passphrases are write-only and never returned by this page.</p>
|
<p>The Workspace default SSH key is generated separately from the Runtime authentication identity and is always offered during SSH clone. Without an explicit Repository binding, a unique pinned host trust matching the Repository URI is used with this default key. A binding can add one dedicated credential; OpenSSH receives both candidates and tries them through one operation-scoped agent. Private keys and passphrases remain write-only.</p>
|
||||||
{#if message}<p class="status-message">{message}</p>{/if}
|
{#if message}<p class="status-message">{message}</p>{/if}
|
||||||
|
|
||||||
<div class="settings-runtime-list">
|
<div class="settings-runtime-list">
|
||||||
@@ -237,7 +303,7 @@
|
|||||||
{#each accessProjection.bindings as binding (binding.repository_key)}
|
{#each accessProjection.bindings as binding (binding.repository_key)}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<strong>{binding.repository_key}</strong>
|
<strong>{binding.repository_key}</strong>
|
||||||
<p>{binding.access} · credential <code>{binding.credential_id}</code> · host trust <code>{binding.host_trust_id}</code></p>
|
<p>{binding.access} · additional credential <code>{binding.credential_id}</code> · always includes <code>{workspaceDefaultCredentialId}</code> · host trust <code>{binding.host_trust_id}</code></p>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -248,12 +314,19 @@
|
|||||||
{#each credentials as credential (credential.credential_id)}
|
{#each credentials as credential (credential.credential_id)}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<strong>{credential.name}</strong> <code>{credential.credential_id}</code>
|
<strong>{credential.name}</strong> <code>{credential.credential_id}</code>
|
||||||
|
{#if credential.credential_id === workspaceDefaultCredentialId}<span class="badge success">Workspace default</span>{/if}
|
||||||
<p>{credential.public_key_algorithm} · {credential.public_key_fingerprint} · revision {credential.current_revision}</p>
|
<p>{credential.public_key_algorithm} · {credential.public_key_fingerprint} · revision {credential.current_revision}</p>
|
||||||
<p>References: {credential.referenced_repositories.join(', ') || 'none'}</p>
|
{#if publicKeys[credential.credential_id]}
|
||||||
|
<label><span>Public key</span><textarea readonly rows="3" value={publicKeys[credential.credential_id].public_key}></textarea></label>
|
||||||
|
<button type="button" onclick={() => void copyPublicKey(credential.credential_id)}>{copiedCredentialId === credential.credential_id ? 'Copied' : 'Copy public key'}</button>
|
||||||
|
{/if}
|
||||||
|
<p>References: {credential.credential_id === workspaceDefaultCredentialId ? 'all SSH repository operations' : credential.referenced_repositories.join(', ') || 'none'}</p>
|
||||||
|
{#if credential.credential_id !== workspaceDefaultCredentialId}
|
||||||
<div class="settings-action-row">
|
<div class="settings-action-row">
|
||||||
<button type="button" onclick={() => (rotateCredentialId = rotateCredentialId === credential.credential_id ? null : credential.credential_id)}>Rotate</button>
|
<button type="button" onclick={() => (rotateCredentialId = rotateCredentialId === credential.credential_id ? null : credential.credential_id)}>Rotate</button>
|
||||||
<button type="button" class="danger" disabled={pending || credential.referenced_repositories.length > 0} onclick={() => void deleteCredential(credential)}>Delete</button>
|
<button type="button" class="danger" disabled={pending || credential.referenced_repositories.length > 0} onclick={() => void deleteCredential(credential)}>Delete</button>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
{#if rotateCredentialId === credential.credential_id}
|
{#if rotateCredentialId === credential.credential_id}
|
||||||
<form class="settings-runtime-form" onsubmit={(event) => { event.preventDefault(); void rotateCredential(credential); }}>
|
<form class="settings-runtime-form" onsubmit={(event) => { event.preventDefault(); void rotateCredential(credential); }}>
|
||||||
<label><span>New private key</span><textarea bind:value={rotatePrivateKey} required rows="8" autocomplete="off"></textarea></label>
|
<label><span>New private key</span><textarea bind:value={rotatePrivateKey} required rows="8" autocomplete="off"></textarea></label>
|
||||||
@@ -264,8 +337,16 @@
|
|||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
|
<form class="settings-runtime-form" onsubmit={(event) => { event.preventDefault(); void generateCredential(); }}>
|
||||||
|
<h3>Generate Repository SSH credential</h3>
|
||||||
|
<p>Create an additional Ed25519 key for a Repository binding. The Workspace default SSH key is already generated automatically and is included separately.</p>
|
||||||
|
<label><span>Credential id</span><input bind:value={generateCredentialId} placeholder="repository-deploy" required pattern="[A-Za-z0-9_.-]+" maxlength="128" /></label>
|
||||||
|
<label><span>Name</span><input bind:value={generateCredentialName} placeholder="Repository deploy key" required maxlength="200" /></label>
|
||||||
|
<button type="submit" disabled={pending}>Generate credential</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
<form class="settings-runtime-form" onsubmit={(event) => { event.preventDefault(); void createCredential(); }}>
|
<form class="settings-runtime-form" onsubmit={(event) => { event.preventDefault(); void createCredential(); }}>
|
||||||
<h3>Add SSH credential</h3>
|
<h3>Import existing SSH credential</h3>
|
||||||
<label><span>Credential id</span><input bind:value={credentialId} required pattern="[A-Za-z0-9_.-]+" maxlength="128" /></label>
|
<label><span>Credential id</span><input bind:value={credentialId} required pattern="[A-Za-z0-9_.-]+" maxlength="128" /></label>
|
||||||
<label><span>Name</span><input bind:value={credentialName} required maxlength="200" /></label>
|
<label><span>Name</span><input bind:value={credentialName} required maxlength="200" /></label>
|
||||||
<label><span>OpenSSH private key (ssh-ed25519)</span><textarea bind:value={privateKey} required rows="10" autocomplete="off"></textarea></label>
|
<label><span>OpenSSH private key (ssh-ed25519)</span><textarea bind:value={privateKey} required rows="10" autocomplete="off"></textarea></label>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
parseRepositoryAccessProjection,
|
parseRepositoryAccessProjection,
|
||||||
parseRepositorySshCredentials,
|
parseRepositorySshCredentials,
|
||||||
parseRepositorySshHostTrusts,
|
parseRepositorySshHostTrusts,
|
||||||
|
parseRepositorySshPublicKey,
|
||||||
} from "$lib/workspace/api/repository-access";
|
} from "$lib/workspace/api/repository-access";
|
||||||
import { loadRepositoryAccessJson } from "$lib/workspace/api/repository-access-loader";
|
import { loadRepositoryAccessJson } from "$lib/workspace/api/repository-access-loader";
|
||||||
import type { PageLoad } from "./$types";
|
import type { PageLoad } from "./$types";
|
||||||
@@ -27,9 +28,25 @@ export const load: PageLoad = async ({ fetch, params }) => {
|
|||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const publicKeys = await Promise.all(
|
||||||
|
credentials.map((credential) =>
|
||||||
|
loadRepositoryAccessJson(
|
||||||
|
fetch,
|
||||||
|
workspaceApiPath(
|
||||||
|
workspaceId,
|
||||||
|
`/settings/repository-access/credentials/${
|
||||||
|
encodeURIComponent(credential.credential_id)
|
||||||
|
}/public-key`,
|
||||||
|
),
|
||||||
|
parseRepositorySshPublicKey,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
workspaceId,
|
workspaceId,
|
||||||
credentials,
|
credentials,
|
||||||
|
publicKeys,
|
||||||
hostTrusts,
|
hostTrusts,
|
||||||
accessProjection,
|
accessProjection,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
parseRepositoryAccessProjection,
|
parseRepositoryAccessProjection,
|
||||||
parseRepositorySshCredentials,
|
parseRepositorySshCredentials,
|
||||||
parseRepositorySshHostTrusts,
|
parseRepositorySshHostTrusts,
|
||||||
|
parseRepositorySshPublicKey,
|
||||||
RepositoryAccessSchemaError,
|
RepositoryAccessSchemaError,
|
||||||
} from "../../src/lib/workspace/api/repository-access.ts";
|
} from "../../src/lib/workspace/api/repository-access.ts";
|
||||||
|
|
||||||
@@ -59,6 +60,22 @@ const hostTrust = {
|
|||||||
|
|
||||||
Deno.test("Repository Access parsers accept generated response contracts", () => {
|
Deno.test("Repository Access parsers accept generated response contracts", () => {
|
||||||
assertEquals(parseRepositorySshCredentials([credential]), [credential]);
|
assertEquals(parseRepositorySshCredentials([credential]), [credential]);
|
||||||
|
assertEquals(
|
||||||
|
parseRepositorySshPublicKey({
|
||||||
|
credential_id: "deploy-key",
|
||||||
|
current_revision: 2,
|
||||||
|
public_key_algorithm: "ssh-ed25519",
|
||||||
|
public_key_fingerprint: "SHA256:credential",
|
||||||
|
public_key: "ssh-ed25519 AAAA",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
credential_id: "deploy-key",
|
||||||
|
current_revision: 2,
|
||||||
|
public_key_algorithm: "ssh-ed25519",
|
||||||
|
public_key_fingerprint: "SHA256:credential",
|
||||||
|
public_key: "ssh-ed25519 AAAA",
|
||||||
|
},
|
||||||
|
);
|
||||||
assertEquals(parseRepositorySshHostTrusts([hostTrust]), [hostTrust]);
|
assertEquals(parseRepositorySshHostTrusts([hostTrust]), [hostTrust]);
|
||||||
assertEquals(
|
assertEquals(
|
||||||
parseRepositoryAccessProjection({
|
parseRepositoryAccessProjection({
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ test("Repository Access Web code consumes workspace-api generated DTOs", () => {
|
|||||||
assert(
|
assert(
|
||||||
loaderSource.includes("parseRepositorySshCredentials") &&
|
loaderSource.includes("parseRepositorySshCredentials") &&
|
||||||
loaderSource.includes("parseRepositorySshHostTrusts") &&
|
loaderSource.includes("parseRepositorySshHostTrusts") &&
|
||||||
|
loaderSource.includes("parseRepositorySshPublicKey") &&
|
||||||
loaderSource.includes("parseRepositoryAccessProjection"),
|
loaderSource.includes("parseRepositoryAccessProjection"),
|
||||||
"loader should validate unknown JSON before exposing generated DTOs to Svelte",
|
"loader should validate unknown JSON before exposing generated DTOs to Svelte",
|
||||||
);
|
);
|
||||||
@@ -66,6 +67,29 @@ test("Repository Access renders the shared access projection fields", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Repository Access generates and copies selectable public keys", () => {
|
||||||
|
for (
|
||||||
|
const token of [
|
||||||
|
"/credentials/generate",
|
||||||
|
"/public-key",
|
||||||
|
"Generate Repository SSH credential",
|
||||||
|
"navigator.clipboard.writeText",
|
||||||
|
"publicKeys[credential.credential_id]",
|
||||||
|
"workspace-default",
|
||||||
|
"always offered during SSH clone",
|
||||||
|
]
|
||||||
|
) {
|
||||||
|
assert(
|
||||||
|
source.includes(token),
|
||||||
|
`missing generated public key flow ${token}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert(
|
||||||
|
source.includes("binding.credential_id"),
|
||||||
|
"Repository bindings should identify the selected credential",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("Repository credential submissions clear write-only fields in finally blocks", () => {
|
test("Repository credential submissions clear write-only fields in finally blocks", () => {
|
||||||
const createStart = source.indexOf("async function createCredential()");
|
const createStart = source.indexOf("async function createCredential()");
|
||||||
const rotateStart = source.indexOf("async function rotateCredential(");
|
const rotateStart = source.indexOf("async function rotateCredential(");
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { assert, assertEquals } from "jsr:@std/assert";
|
||||||
|
import type { WorkspaceRuntimeResource } from "../src/lib/generated/workspace-api.ts";
|
||||||
|
import { parseRepositorySshConnectionProbeResponse } from "../src/lib/workspace/api/workspace-model.ts";
|
||||||
|
import { repositorySshProbeRuntimes } from "../src/lib/workspace/repositories/ssh-connection.ts";
|
||||||
|
|
||||||
|
const root = new URL("../", import.meta.url);
|
||||||
|
const pageSource = await Deno.readTextFile(
|
||||||
|
new URL(
|
||||||
|
"./src/routes/w/[workspaceId]/repositories/[repositoryKey]/+page.svelte",
|
||||||
|
root,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const loaderSource = await Deno.readTextFile(
|
||||||
|
new URL(
|
||||||
|
"./src/routes/w/[workspaceId]/repositories/[repositoryKey]/+page.ts",
|
||||||
|
root,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
Deno.test("Repository SSH probe parser preserves the confirmation contract", () => {
|
||||||
|
const response = {
|
||||||
|
workspace_id: "workspace-a",
|
||||||
|
repository_key: "main",
|
||||||
|
runtime_id: "runtime-a",
|
||||||
|
hostname: "example.test",
|
||||||
|
port: 22,
|
||||||
|
trust_state: "untrusted" as const,
|
||||||
|
host_trust_id: "tofu-example.test-22",
|
||||||
|
expected_host_trust_revision: null,
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
algorithm: "ssh-ed25519",
|
||||||
|
host_key: "ssh-ed25519 AAAA",
|
||||||
|
fingerprint: "SHA256:host",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
assertEquals(parseRepositorySshConnectionProbeResponse(response), response);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Repository SSH probe offers configured remote Runtimes regardless of worker-style status", () => {
|
||||||
|
const configured = {
|
||||||
|
runtime_id: "arcadia",
|
||||||
|
label: "Arcadia",
|
||||||
|
kind: "remote_worker_runtime",
|
||||||
|
status: "idle",
|
||||||
|
diagnostics: [],
|
||||||
|
management: {
|
||||||
|
endpoint_configured: true,
|
||||||
|
endpoint_display: "https://arcadia.example",
|
||||||
|
binding: {
|
||||||
|
state: "verified",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as WorkspaceRuntimeResource;
|
||||||
|
const embedded = {
|
||||||
|
...configured,
|
||||||
|
runtime_id: "embedded-worker-runtime",
|
||||||
|
kind: "embedded_worker_runtime",
|
||||||
|
} as unknown as WorkspaceRuntimeResource;
|
||||||
|
const revoked = {
|
||||||
|
...configured,
|
||||||
|
runtime_id: "revoked",
|
||||||
|
management: {
|
||||||
|
...configured.management,
|
||||||
|
binding: { state: "revoked" },
|
||||||
|
},
|
||||||
|
} as unknown as WorkspaceRuntimeResource;
|
||||||
|
const unbound = {
|
||||||
|
...configured,
|
||||||
|
runtime_id: "unbound",
|
||||||
|
management: {
|
||||||
|
...configured.management,
|
||||||
|
binding: undefined,
|
||||||
|
},
|
||||||
|
} as unknown as WorkspaceRuntimeResource;
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
repositorySshProbeRuntimes([embedded, configured, revoked, unbound]).map((
|
||||||
|
runtime,
|
||||||
|
) => runtime.runtime_id),
|
||||||
|
["arcadia"],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Repository SSH connection test requires an explicit host-key confirmation", () => {
|
||||||
|
for (
|
||||||
|
const token of [
|
||||||
|
"Check SSH connection",
|
||||||
|
"selectedRuntimeId",
|
||||||
|
"candidate.fingerprint",
|
||||||
|
"Confirm and trust selected host key",
|
||||||
|
"expected_host_trust_revision",
|
||||||
|
"requestConnectionTest('POST'",
|
||||||
|
"requestConnectionTest('PUT'",
|
||||||
|
]
|
||||||
|
) {
|
||||||
|
assert(pageSource.includes(token), `missing SSH connection flow ${token}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Repository detail loads configured Workspace Runtimes for the connection test", () => {
|
||||||
|
assert(loaderSource.includes('workspaceApiPath(workspaceId, "/runtimes")'));
|
||||||
|
assert(loaderSource.includes("parseWorkspaceRuntimeList"));
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user