merge: runtime auth
This commit is contained in:
commit
20dcf429d4
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -5973,12 +5973,14 @@ version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum",
|
"axum",
|
||||||
|
"base64 0.22.1",
|
||||||
"decodal",
|
"decodal",
|
||||||
"futures",
|
"futures",
|
||||||
"llm-engine",
|
"llm-engine",
|
||||||
"manifest",
|
"manifest",
|
||||||
"protocol",
|
"protocol",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
|
"ring",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"session-store",
|
"session-store",
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,7 @@ yoi-workspace-server = { path = "crates/workspace-server" }
|
||||||
# need `default-features = false`, which workspace inheritance cannot override.
|
# need `default-features = false`, which workspace inheritance cannot override.
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
axum = "0.8"
|
axum = "0.8"
|
||||||
|
base64 = "0.22.1"
|
||||||
decodal = "0.1.1"
|
decodal = "0.1.1"
|
||||||
fs4 = "0.13"
|
fs4 = "0.13"
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
|
|
@ -91,6 +92,7 @@ serde_json = "1.0"
|
||||||
serde_yaml = "0.9.34"
|
serde_yaml = "0.9.34"
|
||||||
tar = "0.4"
|
tar = "0.4"
|
||||||
rusqlite = { version = "0.37", features = ["bundled"] }
|
rusqlite = { version = "0.37", features = ["bundled"] }
|
||||||
|
ring = "0.17.14"
|
||||||
sha2 = "0.11"
|
sha2 = "0.11"
|
||||||
tempfile = "3.27"
|
tempfile = "3.27"
|
||||||
thiserror = "2.0"
|
thiserror = "2.0"
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,11 @@ name = "worker-runtime-rest-server"
|
||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
required-features = ["ws-server", "fs-store"]
|
required-features = ["ws-server", "fs-store"]
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "worker-runtime"
|
||||||
|
path = "src/worker_runtime_bin.rs"
|
||||||
|
required-features = ["ws-server", "fs-store"]
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
fs-store = []
|
fs-store = []
|
||||||
|
|
@ -19,6 +24,7 @@ ws-server = ["http-server", "axum/ws", "dep:futures", "tokio/sync"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
async-trait.workspace = true
|
async-trait.workspace = true
|
||||||
|
base64.workspace = true
|
||||||
axum = { workspace = true, optional = true }
|
axum = { workspace = true, optional = true }
|
||||||
futures = { workspace = true, optional = true }
|
futures = { workspace = true, optional = true }
|
||||||
decodal.workspace = true
|
decodal.workspace = true
|
||||||
|
|
@ -29,6 +35,7 @@ session-store.workspace = true
|
||||||
sha2.workspace = true
|
sha2.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
reqwest = { version = "0.13", optional = true, default-features = false, features = ["json", "rustls"] }
|
reqwest = { version = "0.13", optional = true, default-features = false, features = ["json", "rustls"] }
|
||||||
|
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", "rt", "sync", "time"] }
|
||||||
|
|
|
||||||
309
crates/worker-runtime/src/auth.rs
Normal file
309
crates/worker-runtime/src/auth.rs
Normal file
|
|
@ -0,0 +1,309 @@
|
||||||
|
use base64::Engine;
|
||||||
|
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||||
|
use ring::rand::{SecureRandom, SystemRandom};
|
||||||
|
use ring::signature::{ED25519, Ed25519KeyPair, KeyPair, UnparsedPublicKey};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fmt;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
const PUBLIC_KEY_PREFIX: &str = "yoi-ed25519-pub:v1:";
|
||||||
|
const PRIVATE_KEY_PREFIX: &str = "yoi-ed25519-pkcs8:v1:";
|
||||||
|
const TOKEN_PREFIX: &str = "yoi-cap-v1";
|
||||||
|
const SIGNING_INPUT_PREFIX: &str = "yoi-cap-v1.";
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum RuntimeAuthError {
|
||||||
|
#[error("invalid Ed25519 public key format")]
|
||||||
|
InvalidPublicKeyFormat,
|
||||||
|
#[error("invalid Ed25519 private key format")]
|
||||||
|
InvalidPrivateKeyFormat,
|
||||||
|
#[error("invalid base64url key material: {0}")]
|
||||||
|
InvalidBase64(#[from] base64::DecodeError),
|
||||||
|
#[error("invalid Ed25519 private key")]
|
||||||
|
InvalidPrivateKey,
|
||||||
|
#[error("failed to generate Ed25519 keypair")]
|
||||||
|
KeyGeneration,
|
||||||
|
#[error("failed to generate random token id")]
|
||||||
|
Random,
|
||||||
|
#[error("invalid capability token format")]
|
||||||
|
InvalidTokenFormat,
|
||||||
|
#[error("malformed capability token claims: {0}")]
|
||||||
|
MalformedClaims(#[from] serde_json::Error),
|
||||||
|
#[error("unknown token issuer `{0}`")]
|
||||||
|
UnknownIssuer(String),
|
||||||
|
#[error("invalid token signature")]
|
||||||
|
InvalidSignature,
|
||||||
|
#[error("token audience `{actual}` does not match runtime `{expected}`")]
|
||||||
|
WrongAudience { expected: String, actual: String },
|
||||||
|
#[error("capability token is expired")]
|
||||||
|
Expired,
|
||||||
|
#[error("capability token is missing required permission `{0}`")]
|
||||||
|
MissingPermission(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct RuntimeIdentityMaterial {
|
||||||
|
pub identity_id: String,
|
||||||
|
pub public_key: String,
|
||||||
|
pub private_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RuntimeIdentityMaterial {
|
||||||
|
pub fn generate(identity_id: impl Into<String>) -> Result<Self, RuntimeAuthError> {
|
||||||
|
let rng = SystemRandom::new();
|
||||||
|
let pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng)
|
||||||
|
.map_err(|_| RuntimeAuthError::KeyGeneration)?;
|
||||||
|
let pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref())
|
||||||
|
.map_err(|_| RuntimeAuthError::InvalidPrivateKey)?;
|
||||||
|
Ok(Self {
|
||||||
|
identity_id: identity_id.into(),
|
||||||
|
public_key: encode_public_key(pair.public_key().as_ref()),
|
||||||
|
private_key: encode_private_key(pkcs8.as_ref()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn signing_key(&self) -> Result<Ed25519KeyPair, RuntimeAuthError> {
|
||||||
|
let private = decode_private_key(&self.private_key)?;
|
||||||
|
Ed25519KeyPair::from_pkcs8(&private).map_err(|_| RuntimeAuthError::InvalidPrivateKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct TrustedServerKey {
|
||||||
|
pub server_id: String,
|
||||||
|
pub public_key: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub display_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct RuntimeHttpAuthConfig {
|
||||||
|
pub runtime_id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub trusted_servers: Vec<TrustedServerKey>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct RuntimeAuthContext {
|
||||||
|
pub server_id: String,
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub permissions: Vec<String>,
|
||||||
|
pub token_id: String,
|
||||||
|
pub expires_at: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct CapabilityClaims {
|
||||||
|
pub iss: String,
|
||||||
|
pub aud: String,
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub permissions: Vec<String>,
|
||||||
|
pub exp: u64,
|
||||||
|
pub jti: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct CapabilityTokenSigner {
|
||||||
|
server_id: String,
|
||||||
|
private_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CapabilityTokenSigner {
|
||||||
|
pub fn new(server_id: impl Into<String>, private_key: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
server_id: server_id.into(),
|
||||||
|
private_key: private_key.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn server_id(&self) -> &str {
|
||||||
|
&self.server_id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sign(&self, claims: &CapabilityClaims) -> Result<String, RuntimeAuthError> {
|
||||||
|
if claims.iss != self.server_id {
|
||||||
|
return Err(RuntimeAuthError::UnknownIssuer(claims.iss.clone()));
|
||||||
|
}
|
||||||
|
let private = decode_private_key(&self.private_key)?;
|
||||||
|
let pair = Ed25519KeyPair::from_pkcs8(&private)
|
||||||
|
.map_err(|_| RuntimeAuthError::InvalidPrivateKey)?;
|
||||||
|
let payload = serde_json::to_vec(claims)?;
|
||||||
|
let payload = URL_SAFE_NO_PAD.encode(payload);
|
||||||
|
let signing_input = format!("{SIGNING_INPUT_PREFIX}{payload}");
|
||||||
|
let signature = pair.sign(signing_input.as_bytes());
|
||||||
|
Ok(format!("{TOKEN_PREFIX}.{payload}.{}", URL_SAFE_NO_PAD.encode(signature.as_ref())))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn capability_claims(
|
||||||
|
server_id: impl Into<String>,
|
||||||
|
runtime_id: impl Into<String>,
|
||||||
|
workspace_id: impl Into<String>,
|
||||||
|
permissions: Vec<String>,
|
||||||
|
ttl_seconds: u64,
|
||||||
|
) -> Result<CapabilityClaims, RuntimeAuthError> {
|
||||||
|
let exp = unix_now_seconds().saturating_add(ttl_seconds);
|
||||||
|
Ok(CapabilityClaims {
|
||||||
|
iss: server_id.into(),
|
||||||
|
aud: runtime_id.into(),
|
||||||
|
workspace_id: workspace_id.into(),
|
||||||
|
permissions,
|
||||||
|
exp,
|
||||||
|
jti: new_token_id()?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn verify_capability_token(
|
||||||
|
config: &RuntimeHttpAuthConfig,
|
||||||
|
token: &str,
|
||||||
|
required_permission: Option<&str>,
|
||||||
|
now_seconds: u64,
|
||||||
|
) -> Result<RuntimeAuthContext, RuntimeAuthError> {
|
||||||
|
let (payload, signature) = split_token(token)?;
|
||||||
|
let claims_json = URL_SAFE_NO_PAD.decode(payload)?;
|
||||||
|
let claims: CapabilityClaims = serde_json::from_slice(&claims_json)?;
|
||||||
|
let Some(server) = config
|
||||||
|
.trusted_servers
|
||||||
|
.iter()
|
||||||
|
.find(|server| server.server_id == claims.iss)
|
||||||
|
else {
|
||||||
|
return Err(RuntimeAuthError::UnknownIssuer(claims.iss));
|
||||||
|
};
|
||||||
|
let public_key = decode_public_key(&server.public_key)?;
|
||||||
|
let signing_input = format!("{SIGNING_INPUT_PREFIX}{payload}");
|
||||||
|
UnparsedPublicKey::new(&ED25519, public_key)
|
||||||
|
.verify(signing_input.as_bytes(), &signature)
|
||||||
|
.map_err(|_| RuntimeAuthError::InvalidSignature)?;
|
||||||
|
|
||||||
|
if claims.aud != config.runtime_id {
|
||||||
|
return Err(RuntimeAuthError::WrongAudience {
|
||||||
|
expected: config.runtime_id.clone(),
|
||||||
|
actual: claims.aud,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if claims.exp < now_seconds {
|
||||||
|
return Err(RuntimeAuthError::Expired);
|
||||||
|
}
|
||||||
|
if let Some(required) = required_permission {
|
||||||
|
if !claims.permissions.iter().any(|permission| permission == required) {
|
||||||
|
return Err(RuntimeAuthError::MissingPermission(required.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(RuntimeAuthContext {
|
||||||
|
server_id: claims.iss,
|
||||||
|
workspace_id: claims.workspace_id,
|
||||||
|
permissions: claims.permissions,
|
||||||
|
token_id: claims.jti,
|
||||||
|
expires_at: claims.exp,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn split_token(token: &str) -> Result<(&str, Vec<u8>), RuntimeAuthError> {
|
||||||
|
let mut parts = token.split('.');
|
||||||
|
match (parts.next(), parts.next(), parts.next(), parts.next()) {
|
||||||
|
(Some(prefix), Some(payload), Some(signature), None) if prefix == TOKEN_PREFIX => {
|
||||||
|
Ok((payload, URL_SAFE_NO_PAD.decode(signature)?))
|
||||||
|
}
|
||||||
|
_ => Err(RuntimeAuthError::InvalidTokenFormat),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn encode_public_key(bytes: &[u8]) -> String {
|
||||||
|
format!("{PUBLIC_KEY_PREFIX}{}", URL_SAFE_NO_PAD.encode(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decode_public_key(value: &str) -> Result<Vec<u8>, RuntimeAuthError> {
|
||||||
|
let Some(encoded) = value.strip_prefix(PUBLIC_KEY_PREFIX) else {
|
||||||
|
return Err(RuntimeAuthError::InvalidPublicKeyFormat);
|
||||||
|
};
|
||||||
|
let bytes = URL_SAFE_NO_PAD.decode(encoded)?;
|
||||||
|
if bytes.len() != 32 {
|
||||||
|
return Err(RuntimeAuthError::InvalidPublicKeyFormat);
|
||||||
|
}
|
||||||
|
Ok(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn encode_private_key(bytes: &[u8]) -> String {
|
||||||
|
format!("{PRIVATE_KEY_PREFIX}{}", URL_SAFE_NO_PAD.encode(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decode_private_key(value: &str) -> Result<Vec<u8>, RuntimeAuthError> {
|
||||||
|
let Some(encoded) = value.strip_prefix(PRIVATE_KEY_PREFIX) else {
|
||||||
|
return Err(RuntimeAuthError::InvalidPrivateKeyFormat);
|
||||||
|
};
|
||||||
|
Ok(URL_SAFE_NO_PAD.decode(encoded)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unix_now_seconds() -> u64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_token_id() -> Result<String, RuntimeAuthError> {
|
||||||
|
let rng = SystemRandom::new();
|
||||||
|
let mut bytes = [0_u8; 16];
|
||||||
|
rng.fill(&mut bytes).map_err(|_| RuntimeAuthError::Random)?;
|
||||||
|
Ok(URL_SAFE_NO_PAD.encode(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for RuntimeAuthContext {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"server={} workspace={} permissions={} exp={}",
|
||||||
|
self.server_id,
|
||||||
|
self.workspace_id,
|
||||||
|
self.permissions.join(","),
|
||||||
|
self.expires_at
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn capability_token_verifies_signature_audience_expiry_and_permission() {
|
||||||
|
let server = RuntimeIdentityMaterial::generate("server-main").unwrap();
|
||||||
|
let signer = CapabilityTokenSigner::new(&server.identity_id, &server.private_key);
|
||||||
|
let claims = CapabilityClaims {
|
||||||
|
iss: "server-main".to_string(),
|
||||||
|
aud: "runtime-main".to_string(),
|
||||||
|
workspace_id: "workspace-a".to_string(),
|
||||||
|
permissions: vec!["workers:list".to_string()],
|
||||||
|
exp: 100,
|
||||||
|
jti: "token-1".to_string(),
|
||||||
|
};
|
||||||
|
let token = signer.sign(&claims).unwrap();
|
||||||
|
let auth = RuntimeHttpAuthConfig {
|
||||||
|
runtime_id: "runtime-main".to_string(),
|
||||||
|
trusted_servers: vec![TrustedServerKey {
|
||||||
|
server_id: "server-main".to_string(),
|
||||||
|
public_key: server.public_key.clone(),
|
||||||
|
display_name: None,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
let context = verify_capability_token(&auth, &token, Some("workers:list"), 99).unwrap();
|
||||||
|
assert_eq!(context.workspace_id, "workspace-a");
|
||||||
|
assert!(matches!(
|
||||||
|
verify_capability_token(&auth, &token, Some("workers:create"), 99),
|
||||||
|
Err(RuntimeAuthError::MissingPermission(permission)) if permission == "workers:create"
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
verify_capability_token(&auth, &token, Some("workers:list"), 101),
|
||||||
|
Err(RuntimeAuthError::Expired)
|
||||||
|
));
|
||||||
|
let wrong_audience = RuntimeHttpAuthConfig {
|
||||||
|
runtime_id: "other-runtime".to_string(),
|
||||||
|
trusted_servers: auth.trusted_servers.clone(),
|
||||||
|
};
|
||||||
|
assert!(matches!(
|
||||||
|
verify_capability_token(&wrong_audience, &token, Some("workers:list"), 99),
|
||||||
|
Err(RuntimeAuthError::WrongAudience { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
//! Runtime process directly; a backend is expected to own any browser-facing
|
//! Runtime process directly; a backend is expected to own any browser-facing
|
||||||
//! credentials, registration, and policy.
|
//! credentials, registration, and policy.
|
||||||
|
|
||||||
|
use crate::auth::{RuntimeAuthContext, RuntimeHttpAuthConfig, unix_now_seconds, verify_capability_token};
|
||||||
use crate::Runtime;
|
use crate::Runtime;
|
||||||
use crate::catalog::{
|
use crate::catalog::{
|
||||||
ConfigBundleRef, CreateWorkerRequest, WorkerDetail, WorkerLifecycleAck, WorkerSummary,
|
ConfigBundleRef, CreateWorkerRequest, WorkerDetail, WorkerLifecycleAck, WorkerSummary,
|
||||||
|
|
@ -23,7 +24,7 @@ use axum::extract::rejection::{JsonRejection, QueryRejection};
|
||||||
#[cfg(feature = "ws-server")]
|
#[cfg(feature = "ws-server")]
|
||||||
use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade};
|
use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade};
|
||||||
use axum::extract::{Path, Query, State};
|
use axum::extract::{Path, Query, State};
|
||||||
use axum::http::{Request, StatusCode, header};
|
use axum::http::{Method, Request, StatusCode, header};
|
||||||
use axum::middleware::{self, Next};
|
use axum::middleware::{self, Next};
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
use axum::routing::{get, post};
|
use axum::routing::{get, post};
|
||||||
|
|
@ -61,6 +62,8 @@ pub struct RuntimeHttpServerConfig {
|
||||||
/// Minimal local bearer token placeholder for backend-to-Runtime calls.
|
/// Minimal local bearer token placeholder for backend-to-Runtime calls.
|
||||||
/// This is not a browser-facing credential model.
|
/// This is not a browser-facing credential model.
|
||||||
pub local_token: Option<String>,
|
pub local_token: Option<String>,
|
||||||
|
/// Optional signed Server-to-Runtime capability token authority.
|
||||||
|
pub auth: Option<RuntimeHttpAuthConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for RuntimeHttpServerConfig {
|
impl Default for RuntimeHttpServerConfig {
|
||||||
|
|
@ -71,6 +74,7 @@ impl Default for RuntimeHttpServerConfig {
|
||||||
limits: RuntimeLimits::default(),
|
limits: RuntimeLimits::default(),
|
||||||
store: RuntimeHttpStoreSelection::Memory,
|
store: RuntimeHttpStoreSelection::Memory,
|
||||||
local_token: None,
|
local_token: None,
|
||||||
|
auth: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -113,15 +117,36 @@ pub async fn serve_runtime_http(
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Serve an existing Runtime on a pre-bound listener with signed capability-token auth.
|
||||||
|
pub async fn serve_runtime_http_with_auth(
|
||||||
|
runtime: Runtime,
|
||||||
|
listener: TcpListener,
|
||||||
|
local_token: Option<String>,
|
||||||
|
auth: Option<RuntimeHttpAuthConfig>,
|
||||||
|
) -> Result<(), RuntimeHttpServerError> {
|
||||||
|
axum::serve(listener, runtime_http_router_with_auth(runtime, local_token, auth)).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Build the REST router for an existing Runtime.
|
/// Build the REST router for an existing Runtime.
|
||||||
///
|
///
|
||||||
/// Handlers delegate to [`Runtime`] methods and keep Worker authority Runtime-local.
|
/// Handlers delegate to [`Runtime`] methods and keep Worker authority Runtime-local.
|
||||||
/// The path contains only a Runtime-local `worker_id`; backend aliases are not
|
/// The path contains only a Runtime-local `worker_id`; backend aliases are not
|
||||||
/// accepted or forwarded as Runtime authority.
|
/// accepted or forwarded as Runtime authority.
|
||||||
pub fn runtime_http_router(runtime: Runtime, local_token: Option<String>) -> Router {
|
pub fn runtime_http_router(runtime: Runtime, local_token: Option<String>) -> Router {
|
||||||
|
runtime_http_router_with_auth(runtime, local_token, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the REST router for an existing Runtime with signed capability-token auth.
|
||||||
|
pub fn runtime_http_router_with_auth(
|
||||||
|
runtime: Runtime,
|
||||||
|
local_token: Option<String>,
|
||||||
|
auth: Option<RuntimeHttpAuthConfig>,
|
||||||
|
) -> 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),
|
||||||
|
auth: auth.map(Arc::new),
|
||||||
};
|
};
|
||||||
|
|
||||||
let router = Router::new()
|
let router = Router::new()
|
||||||
|
|
@ -163,13 +188,14 @@ pub fn runtime_http_router(runtime: Runtime, local_token: Option<String>) -> Rou
|
||||||
|
|
||||||
router
|
router
|
||||||
.with_state(state.clone())
|
.with_state(state.clone())
|
||||||
.layer(middleware::from_fn_with_state(state, require_local_token))
|
.layer(middleware::from_fn_with_state(state, require_runtime_auth))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct RuntimeHttpState {
|
struct RuntimeHttpState {
|
||||||
runtime: Runtime,
|
runtime: Runtime,
|
||||||
local_token: Option<Arc<str>>,
|
local_token: Option<Arc<str>>,
|
||||||
|
auth: Option<Arc<RuntimeHttpAuthConfig>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET /v1/runtime` response.
|
/// `GET /v1/runtime` response.
|
||||||
|
|
@ -700,17 +726,48 @@ fn parse_optional_lifecycle_request(
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn require_local_token(
|
async fn require_runtime_auth(
|
||||||
State(state): State<RuntimeHttpState>,
|
State(state): State<RuntimeHttpState>,
|
||||||
request: Request<Body>,
|
mut request: Request<Body>,
|
||||||
next: Next,
|
next: Next,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
if let Some(expected) = state.local_token.as_deref() {
|
|
||||||
let supplied = request
|
let supplied = request
|
||||||
.headers()
|
.headers()
|
||||||
.get(header::AUTHORIZATION)
|
.get(header::AUTHORIZATION)
|
||||||
.and_then(|value| value.to_str().ok())
|
.and_then(|value| value.to_str().ok())
|
||||||
.and_then(|value| value.strip_prefix("Bearer "));
|
.and_then(|value| value.strip_prefix("Bearer "));
|
||||||
|
|
||||||
|
if let Some(auth) = state.auth.as_deref() {
|
||||||
|
let Some(token) = supplied else {
|
||||||
|
return RuntimeHttpRestError::new(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"unauthorized",
|
||||||
|
"missing Runtime capability bearer token",
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
};
|
||||||
|
match verify_capability_token(
|
||||||
|
auth,
|
||||||
|
token,
|
||||||
|
required_runtime_permission(request.method(), request.uri().path()),
|
||||||
|
unix_now_seconds(),
|
||||||
|
) {
|
||||||
|
Ok(context) => {
|
||||||
|
request.extensions_mut().insert(context);
|
||||||
|
return next.run(request).await;
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
return RuntimeHttpRestError::new(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"unauthorized",
|
||||||
|
format!("invalid Runtime capability token: {error}"),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(expected) = state.local_token.as_deref() {
|
||||||
if supplied != Some(expected) {
|
if supplied != Some(expected) {
|
||||||
return RuntimeHttpRestError::new(
|
return RuntimeHttpRestError::new(
|
||||||
StatusCode::UNAUTHORIZED,
|
StatusCode::UNAUTHORIZED,
|
||||||
|
|
@ -719,10 +776,51 @@ async fn require_local_token(
|
||||||
)
|
)
|
||||||
.into_response();
|
.into_response();
|
||||||
}
|
}
|
||||||
|
request.extensions_mut().insert(RuntimeAuthContext {
|
||||||
|
server_id: "local-token".to_string(),
|
||||||
|
workspace_id: "local".to_string(),
|
||||||
|
permissions: Vec::new(),
|
||||||
|
token_id: "local-token".to_string(),
|
||||||
|
expires_at: 0,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
next.run(request).await
|
next.run(request).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn required_runtime_permission(method: &Method, path: &str) -> Option<&'static str> {
|
||||||
|
if path == "/v1/runtime" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if path == "/v1/workers" && *method == Method::GET {
|
||||||
|
return Some("workers:list");
|
||||||
|
}
|
||||||
|
if path == "/v1/workers" && *method == Method::POST {
|
||||||
|
return Some("workers:create");
|
||||||
|
}
|
||||||
|
if path.starts_with("/v1/config-bundles") || path.starts_with("/v1/working-directories") {
|
||||||
|
return Some("workers:create");
|
||||||
|
}
|
||||||
|
if path.ends_with("/input") {
|
||||||
|
return Some("workers:input");
|
||||||
|
}
|
||||||
|
if path.ends_with("/stop") || path.ends_with("/cancel") {
|
||||||
|
return Some("workers:stop");
|
||||||
|
}
|
||||||
|
if path.ends_with("/protocol") || path.ends_with("/protocol/ws") {
|
||||||
|
return Some("workers:protocol");
|
||||||
|
}
|
||||||
|
if path.ends_with("/completions") {
|
||||||
|
return Some("workers:read");
|
||||||
|
}
|
||||||
|
if path.starts_with("/v1/workers/") && *method == Method::DELETE {
|
||||||
|
return Some("workers:delete");
|
||||||
|
}
|
||||||
|
if path.starts_with("/v1/workers/") && *method == Method::GET {
|
||||||
|
return Some("workers:read");
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct RuntimeHttpRestError {
|
struct RuntimeHttpRestError {
|
||||||
status: StatusCode,
|
status: StatusCode,
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
//! server is available only through the optional `http-server` / `ws-server`
|
//! server is available only through the optional `http-server` / `ws-server`
|
||||||
//! features.
|
//! features.
|
||||||
|
|
||||||
|
pub mod auth;
|
||||||
pub mod catalog;
|
pub mod catalog;
|
||||||
pub mod config_bundle;
|
pub mod config_bundle;
|
||||||
pub mod diagnostics;
|
pub mod diagnostics;
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,22 @@
|
||||||
//! Worker-backed Runtime REST process wrapper.
|
// Worker-backed Runtime REST process wrapper.
|
||||||
//!
|
//
|
||||||
//! This binary starts a Runtime command API with a real worker execution backend.
|
// This binary starts a Runtime command API with a real worker execution backend.
|
||||||
//! A REST Runtime process that cannot spawn Workers is not a valid Runtime for the
|
// A REST Runtime process that cannot spawn Workers is not a valid Runtime for the
|
||||||
//! Workspace Browser.
|
// Workspace Browser.
|
||||||
|
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
use std::env;
|
use std::env;
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::ExitCode;
|
use std::process::ExitCode;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use worker_runtime::auth::{
|
||||||
|
RuntimeHttpAuthConfig, RuntimeIdentityMaterial, TrustedServerKey, decode_public_key,
|
||||||
|
};
|
||||||
use worker_runtime::error::RuntimeError;
|
use worker_runtime::error::RuntimeError;
|
||||||
use worker_runtime::fs_store::FsRuntimeStoreOptions;
|
use worker_runtime::fs_store::FsRuntimeStoreOptions;
|
||||||
use worker_runtime::http_server::{
|
use worker_runtime::http_server::{
|
||||||
|
|
@ -39,10 +43,15 @@ fn main() -> ExitCode {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run() -> Result<(), ProcessError> {
|
fn run() -> Result<(), ProcessError> {
|
||||||
let Some(config) = parse_args(env::args().skip(1))? else {
|
let args = env::args().skip(1).collect::<Vec<_>>();
|
||||||
|
if matches!(args.first().map(String::as_str), Some("identity" | "trust-server")) {
|
||||||
|
return run_auth_command(args);
|
||||||
|
}
|
||||||
|
let Some(mut config) = parse_args(args)? else {
|
||||||
println!("{}", usage());
|
println!("{}", usage());
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
|
config.http.auth = load_runtime_http_auth(&config)?;
|
||||||
|
|
||||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||||
.enable_all()
|
.enable_all()
|
||||||
|
|
@ -54,10 +63,11 @@ fn run() -> Result<(), ProcessError> {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"worker-runtime REST server listening on {local_addr}; intended client is a trusted backend/proxy, not a browser"
|
"worker-runtime REST server listening on {local_addr}; intended client is a trusted backend/proxy, not a browser"
|
||||||
);
|
);
|
||||||
worker_runtime::http_server::serve_runtime_http(
|
worker_runtime::http_server::serve_runtime_http_with_auth(
|
||||||
worker_runtime,
|
worker_runtime,
|
||||||
listener,
|
listener,
|
||||||
config.http.local_token,
|
config.http.local_token,
|
||||||
|
config.http.auth,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(ProcessError::from)
|
.map_err(ProcessError::from)
|
||||||
|
|
@ -326,6 +336,7 @@ enum ProcessError {
|
||||||
Server(RuntimeHttpServerError),
|
Server(RuntimeHttpServerError),
|
||||||
Runtime(RuntimeError),
|
Runtime(RuntimeError),
|
||||||
WorkerAdapter(String),
|
WorkerAdapter(String),
|
||||||
|
Auth(String),
|
||||||
Io(std::io::Error),
|
Io(std::io::Error),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -342,6 +353,7 @@ impl fmt::Display for ProcessError {
|
||||||
Self::Server(error) => error.fmt(f),
|
Self::Server(error) => error.fmt(f),
|
||||||
Self::Runtime(error) => error.fmt(f),
|
Self::Runtime(error) => error.fmt(f),
|
||||||
Self::WorkerAdapter(error) => error.fmt(f),
|
Self::WorkerAdapter(error) => error.fmt(f),
|
||||||
|
Self::Auth(error) => error.fmt(f),
|
||||||
Self::Io(error) => error.fmt(f),
|
Self::Io(error) => error.fmt(f),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -350,7 +362,7 @@ impl fmt::Display for ProcessError {
|
||||||
impl Error for ProcessError {
|
impl Error for ProcessError {
|
||||||
fn source(&self) -> Option<&(dyn Error + 'static)> {
|
fn source(&self) -> Option<&(dyn Error + 'static)> {
|
||||||
match self {
|
match self {
|
||||||
Self::Usage(_) | Self::WorkerAdapter(_) => None,
|
Self::Usage(_) | Self::WorkerAdapter(_) | Self::Auth(_) => None,
|
||||||
Self::Server(error) => Some(error),
|
Self::Server(error) => Some(error),
|
||||||
Self::Runtime(error) => Some(error),
|
Self::Runtime(error) => Some(error),
|
||||||
Self::Io(error) => Some(error),
|
Self::Io(error) => Some(error),
|
||||||
|
|
@ -370,6 +382,299 @@ impl From<std::io::Error> for ProcessError {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
struct RuntimeAuthFile {
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
identity: Option<RuntimeIdentityMaterial>,
|
||||||
|
#[serde(default)]
|
||||||
|
trusted_servers: Vec<TrustedServerKey>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
struct RuntimePublicIdentityView {
|
||||||
|
identity_id: String,
|
||||||
|
public_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn runtime_public_identity_view(identity: &RuntimeIdentityMaterial) -> RuntimePublicIdentityView {
|
||||||
|
RuntimePublicIdentityView {
|
||||||
|
identity_id: identity.identity_id.clone(),
|
||||||
|
public_key: identity.public_key.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn runtime_auth_path(config: &ProcessConfig) -> PathBuf {
|
||||||
|
config.resolved_fs_paths().runtime_dir.join("auth.toml")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_runtime_auth_file(path: &Path) -> Result<RuntimeAuthFile, ProcessError> {
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(RuntimeAuthFile::default());
|
||||||
|
}
|
||||||
|
let contents = std::fs::read_to_string(path)?;
|
||||||
|
toml::from_str(&contents).map_err(|error| {
|
||||||
|
ProcessError::Auth(format!("failed to parse {}: {error}", path.display()))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_runtime_auth_file(path: &Path, auth: &RuntimeAuthFile) -> Result<(), ProcessError> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let contents = toml::to_string_pretty(auth).map_err(|error| {
|
||||||
|
ProcessError::Auth(format!("failed to serialize {}: {error}", path.display()))
|
||||||
|
})?;
|
||||||
|
write_secret_file(path, contents.as_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_secret_file(path: &Path, contents: &[u8]) -> Result<(), ProcessError> {
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::OpenOptionsExt;
|
||||||
|
let mut options = std::fs::OpenOptions::new();
|
||||||
|
options.create(true).write(true).truncate(true).mode(0o600);
|
||||||
|
std::io::Write::write_all(&mut options.open(path)?, contents)?;
|
||||||
|
}
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
{
|
||||||
|
std::fs::write(path, contents)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_runtime_http_auth(config: &ProcessConfig) -> Result<Option<RuntimeHttpAuthConfig>, ProcessError> {
|
||||||
|
let path = runtime_auth_path(config);
|
||||||
|
let auth = read_runtime_auth_file(&path)?;
|
||||||
|
let Some(identity) = auth.identity else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
if auth.trusted_servers.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
Ok(Some(RuntimeHttpAuthConfig {
|
||||||
|
runtime_id: identity.identity_id,
|
||||||
|
trusted_servers: auth.trusted_servers,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_auth_storage_flags(args: &mut VecDeque<String>) -> Result<ProcessConfig, ProcessError> {
|
||||||
|
let mut config = ProcessConfig::default()?;
|
||||||
|
while let Some(arg) = args.pop_front() {
|
||||||
|
let (flag, inline_value) = split_flag_value(arg)?;
|
||||||
|
match flag.as_str() {
|
||||||
|
"--fs-root" => {
|
||||||
|
config.fs_root = Some(PathBuf::from(take_value(&flag, inline_value, args)?));
|
||||||
|
}
|
||||||
|
"--fs-runtime-dir" => {
|
||||||
|
config.fs_runtime_dir = Some(PathBuf::from(take_value(&flag, inline_value, args)?));
|
||||||
|
}
|
||||||
|
_ => return Err(ProcessError::usage(format!("unknown auth command argument `{flag}`"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_auth_command(args: Vec<String>) -> Result<(), ProcessError> {
|
||||||
|
let mut args = VecDeque::from(args);
|
||||||
|
let command = args.pop_front().unwrap_or_default();
|
||||||
|
match command.as_str() {
|
||||||
|
"identity" => run_identity_command(args),
|
||||||
|
"trust-server" => run_trust_server_command(args),
|
||||||
|
_ => Err(ProcessError::usage(format!("unknown auth command `{command}`"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_identity_command(mut args: VecDeque<String>) -> Result<(), ProcessError> {
|
||||||
|
let subcommand = args.pop_front().ok_or_else(|| {
|
||||||
|
ProcessError::usage("identity requires subcommand `init` or `show`".to_string())
|
||||||
|
})?;
|
||||||
|
match subcommand.as_str() {
|
||||||
|
"init" => {
|
||||||
|
let mut runtime_id = None;
|
||||||
|
let mut replace = false;
|
||||||
|
let mut rest = VecDeque::new();
|
||||||
|
while let Some(arg) = args.pop_front() {
|
||||||
|
let (flag, inline_value) = split_flag_value(arg)?;
|
||||||
|
match flag.as_str() {
|
||||||
|
"--runtime-id" => runtime_id = Some(take_value(&flag, inline_value, &mut args)?),
|
||||||
|
"--replace" => {
|
||||||
|
ensure_no_inline_value(&flag, inline_value.as_deref())?;
|
||||||
|
replace = true;
|
||||||
|
}
|
||||||
|
"--fs-root" | "--fs-runtime-dir" => {
|
||||||
|
rest.push_back(flag);
|
||||||
|
if let Some(value) = inline_value { rest.push_back(value); }
|
||||||
|
else { rest.push_back(args.pop_front().ok_or_else(|| ProcessError::usage(format!("{} requires a value", rest.back().unwrap())))?); }
|
||||||
|
}
|
||||||
|
_ => return Err(ProcessError::usage(format!("unknown identity init argument `{flag}`"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let config = parse_auth_storage_flags(&mut rest)?;
|
||||||
|
let path = runtime_auth_path(&config);
|
||||||
|
let mut auth = read_runtime_auth_file(&path)?;
|
||||||
|
if auth.identity.is_some() && !replace {
|
||||||
|
return Err(ProcessError::usage(format!(
|
||||||
|
"runtime identity already exists at {}; pass --replace to rotate it",
|
||||||
|
path.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let runtime_id = runtime_id.ok_or_else(|| {
|
||||||
|
ProcessError::usage("identity init requires --runtime-id".to_string())
|
||||||
|
})?;
|
||||||
|
auth.identity = Some(RuntimeIdentityMaterial::generate(runtime_id).map_err(|error| {
|
||||||
|
ProcessError::Auth(error.to_string())
|
||||||
|
})?);
|
||||||
|
write_runtime_auth_file(&path, &auth)?;
|
||||||
|
let identity = auth.identity.as_ref().unwrap();
|
||||||
|
println!("runtime_id={}", identity.identity_id);
|
||||||
|
println!("public_key={}", identity.public_key);
|
||||||
|
println!("auth_file={}", path.display());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
"show" => {
|
||||||
|
let mut json = false;
|
||||||
|
let mut rest = VecDeque::new();
|
||||||
|
while let Some(arg) = args.pop_front() {
|
||||||
|
let (flag, inline_value) = split_flag_value(arg)?;
|
||||||
|
match flag.as_str() {
|
||||||
|
"--json" => {
|
||||||
|
ensure_no_inline_value(&flag, inline_value.as_deref())?;
|
||||||
|
json = true;
|
||||||
|
}
|
||||||
|
"--fs-root" | "--fs-runtime-dir" => {
|
||||||
|
rest.push_back(flag);
|
||||||
|
if let Some(value) = inline_value { rest.push_back(value); }
|
||||||
|
else { rest.push_back(args.pop_front().ok_or_else(|| ProcessError::usage(format!("{} requires a value", rest.back().unwrap())))?); }
|
||||||
|
}
|
||||||
|
_ => return Err(ProcessError::usage(format!("unknown identity show argument `{flag}`"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let config = parse_auth_storage_flags(&mut rest)?;
|
||||||
|
let path = runtime_auth_path(&config);
|
||||||
|
let auth = read_runtime_auth_file(&path)?;
|
||||||
|
let Some(identity) = auth.identity else {
|
||||||
|
return Err(ProcessError::usage(format!("runtime identity is not initialized at {}", path.display())));
|
||||||
|
};
|
||||||
|
let view = runtime_public_identity_view(&identity);
|
||||||
|
if json {
|
||||||
|
println!("{}", serde_json::to_string_pretty(&view).map_err(|error| ProcessError::Auth(error.to_string()))?);
|
||||||
|
} else {
|
||||||
|
println!("runtime_id={}", view.identity_id);
|
||||||
|
println!("public_key={}", view.public_key);
|
||||||
|
println!("auth_file={}", path.display());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
_ => Err(ProcessError::usage(format!("unknown identity subcommand `{subcommand}`"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_trust_server_command(mut args: VecDeque<String>) -> Result<(), ProcessError> {
|
||||||
|
let subcommand = args.pop_front().ok_or_else(|| {
|
||||||
|
ProcessError::usage("trust-server requires subcommand `add`, `list`, or `revoke`".to_string())
|
||||||
|
})?;
|
||||||
|
match subcommand.as_str() {
|
||||||
|
"add" => {
|
||||||
|
let mut server_id = None;
|
||||||
|
let mut public_key = None;
|
||||||
|
let mut display_name = None;
|
||||||
|
let mut replace = false;
|
||||||
|
let mut rest = VecDeque::new();
|
||||||
|
while let Some(arg) = args.pop_front() {
|
||||||
|
let (flag, inline_value) = split_flag_value(arg)?;
|
||||||
|
match flag.as_str() {
|
||||||
|
"--server-id" => server_id = Some(take_value(&flag, inline_value, &mut args)?),
|
||||||
|
"--public-key" => public_key = Some(take_value(&flag, inline_value, &mut args)?),
|
||||||
|
"--display-name" => display_name = Some(take_value(&flag, inline_value, &mut args)?),
|
||||||
|
"--replace" => {
|
||||||
|
ensure_no_inline_value(&flag, inline_value.as_deref())?;
|
||||||
|
replace = true;
|
||||||
|
}
|
||||||
|
"--fs-root" | "--fs-runtime-dir" => {
|
||||||
|
rest.push_back(flag);
|
||||||
|
if let Some(value) = inline_value { rest.push_back(value); }
|
||||||
|
else { rest.push_back(args.pop_front().ok_or_else(|| ProcessError::usage(format!("{} requires a value", rest.back().unwrap())))?); }
|
||||||
|
}
|
||||||
|
_ => return Err(ProcessError::usage(format!("unknown trust-server add argument `{flag}`"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let config = parse_auth_storage_flags(&mut rest)?;
|
||||||
|
let path = runtime_auth_path(&config);
|
||||||
|
let mut auth = read_runtime_auth_file(&path)?;
|
||||||
|
let server_id = server_id.ok_or_else(|| ProcessError::usage("trust-server add requires --server-id".to_string()))?;
|
||||||
|
let public_key = public_key.ok_or_else(|| ProcessError::usage("trust-server add requires --public-key".to_string()))?;
|
||||||
|
decode_public_key(&public_key).map_err(|error| ProcessError::usage(error.to_string()))?;
|
||||||
|
if auth.trusted_servers.iter().any(|server| server.server_id == server_id) && !replace {
|
||||||
|
return Err(ProcessError::usage(format!("trusted server `{server_id}` already exists; pass --replace to update it")));
|
||||||
|
}
|
||||||
|
auth.trusted_servers.retain(|server| server.server_id != server_id);
|
||||||
|
auth.trusted_servers.push(TrustedServerKey { server_id: server_id.clone(), public_key, display_name });
|
||||||
|
write_runtime_auth_file(&path, &auth)?;
|
||||||
|
println!("trusted_server_id={server_id}");
|
||||||
|
println!("auth_file={}", path.display());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
"list" => {
|
||||||
|
let mut json = false;
|
||||||
|
let mut rest = VecDeque::new();
|
||||||
|
while let Some(arg) = args.pop_front() {
|
||||||
|
let (flag, inline_value) = split_flag_value(arg)?;
|
||||||
|
match flag.as_str() {
|
||||||
|
"--json" => {
|
||||||
|
ensure_no_inline_value(&flag, inline_value.as_deref())?;
|
||||||
|
json = true;
|
||||||
|
}
|
||||||
|
"--fs-root" | "--fs-runtime-dir" => {
|
||||||
|
rest.push_back(flag);
|
||||||
|
if let Some(value) = inline_value { rest.push_back(value); }
|
||||||
|
else { rest.push_back(args.pop_front().ok_or_else(|| ProcessError::usage(format!("{} requires a value", rest.back().unwrap())))?); }
|
||||||
|
}
|
||||||
|
_ => return Err(ProcessError::usage(format!("unknown trust-server list argument `{flag}`"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let config = parse_auth_storage_flags(&mut rest)?;
|
||||||
|
let auth = read_runtime_auth_file(&runtime_auth_path(&config))?;
|
||||||
|
if json {
|
||||||
|
println!("{}", serde_json::to_string_pretty(&auth.trusted_servers).map_err(|error| ProcessError::Auth(error.to_string()))?);
|
||||||
|
} else {
|
||||||
|
for server in auth.trusted_servers {
|
||||||
|
println!("server_id={} public_key={} display_name={}", server.server_id, server.public_key, server.display_name.unwrap_or_default());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
"revoke" => {
|
||||||
|
let mut server_id = None;
|
||||||
|
let mut rest = VecDeque::new();
|
||||||
|
while let Some(arg) = args.pop_front() {
|
||||||
|
let (flag, inline_value) = split_flag_value(arg)?;
|
||||||
|
match flag.as_str() {
|
||||||
|
"--server-id" => server_id = Some(take_value(&flag, inline_value, &mut args)?),
|
||||||
|
"--fs-root" | "--fs-runtime-dir" => {
|
||||||
|
rest.push_back(flag);
|
||||||
|
if let Some(value) = inline_value { rest.push_back(value); }
|
||||||
|
else { rest.push_back(args.pop_front().ok_or_else(|| ProcessError::usage(format!("{} requires a value", rest.back().unwrap())))?); }
|
||||||
|
}
|
||||||
|
_ => return Err(ProcessError::usage(format!("unknown trust-server revoke argument `{flag}`"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let config = parse_auth_storage_flags(&mut rest)?;
|
||||||
|
let path = runtime_auth_path(&config);
|
||||||
|
let mut auth = read_runtime_auth_file(&path)?;
|
||||||
|
let server_id = server_id.ok_or_else(|| ProcessError::usage("trust-server revoke requires --server-id".to_string()))?;
|
||||||
|
let before = auth.trusted_servers.len();
|
||||||
|
auth.trusted_servers.retain(|server| server.server_id != server_id);
|
||||||
|
if auth.trusted_servers.len() == before {
|
||||||
|
return Err(ProcessError::usage(format!("trusted server `{server_id}` is not registered")));
|
||||||
|
}
|
||||||
|
write_runtime_auth_file(&path, &auth)?;
|
||||||
|
println!("revoked_server_id={server_id}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
_ => Err(ProcessError::usage(format!("unknown trust-server subcommand `{subcommand}`"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn usage() -> &'static str {
|
fn usage() -> &'static str {
|
||||||
r#"Usage: worker-runtime-rest-server [OPTIONS]
|
r#"Usage: worker-runtime-rest-server [OPTIONS]
|
||||||
|
|
||||||
|
|
@ -391,7 +696,14 @@ Options:
|
||||||
--local-token <TOKEN> Minimal local bearer token placeholder
|
--local-token <TOKEN> Minimal local bearer token placeholder
|
||||||
--local-token-env <ENV> Read local bearer token placeholder from env
|
--local-token-env <ENV> Read local bearer token placeholder from env
|
||||||
--max-event-batch-items <N> Override event batch limit
|
--max-event-batch-items <N> Override event batch limit
|
||||||
-h, --help Show this help"#
|
-h, --help Show this help
|
||||||
|
|
||||||
|
Auth commands:
|
||||||
|
identity init --runtime-id ID [--replace] [--fs-root PATH] [--fs-runtime-dir PATH]
|
||||||
|
identity show [--json] [--fs-root PATH] [--fs-runtime-dir PATH]
|
||||||
|
trust-server add --server-id ID --public-key KEY [--display-name NAME] [--replace] [--fs-root PATH] [--fs-runtime-dir PATH]
|
||||||
|
trust-server list [--json] [--fs-root PATH] [--fs-runtime-dir PATH]
|
||||||
|
trust-server revoke --server-id ID [--fs-root PATH] [--fs-runtime-dir PATH]"#
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
|
||||||
1
crates/worker-runtime/src/worker_runtime_bin.rs
Normal file
1
crates/worker-runtime/src/worker_runtime_bin.rs
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
include!("main.rs");
|
||||||
|
|
@ -13,6 +13,7 @@ use std::{
|
||||||
sync::{Arc, RwLock},
|
sync::{Arc, RwLock},
|
||||||
time::Duration,
|
time::Duration,
|
||||||
};
|
};
|
||||||
|
use worker_runtime::auth::{CapabilityTokenSigner, capability_claims};
|
||||||
use worker_runtime::catalog::{
|
use worker_runtime::catalog::{
|
||||||
ConfigBundleRef, CreateWorkerRequest, ProfileSelector, ProfileSourceArchiveHttpRef,
|
ConfigBundleRef, CreateWorkerRequest, ProfileSelector, ProfileSourceArchiveHttpRef,
|
||||||
ProfileSourceArchiveSource, WorkerDetail as EmbeddedWorkerDetail,
|
ProfileSourceArchiveSource, WorkerDetail as EmbeddedWorkerDetail,
|
||||||
|
|
@ -2015,11 +2016,18 @@ pub struct RemoteRuntimeConfig {
|
||||||
pub display_name: String,
|
pub display_name: String,
|
||||||
pub base_url: String,
|
pub base_url: String,
|
||||||
pub bearer_token: Option<String>,
|
pub bearer_token: Option<String>,
|
||||||
|
pub auth: Option<RemoteRuntimeAuthConfig>,
|
||||||
pub cached_capabilities: RuntimeCapabilitySummary,
|
pub cached_capabilities: RuntimeCapabilitySummary,
|
||||||
pub cached_status: String,
|
pub cached_status: String,
|
||||||
pub timeout: Duration,
|
pub timeout: Duration,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct RemoteRuntimeAuthConfig {
|
||||||
|
pub server_id: String,
|
||||||
|
pub server_private_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
impl std::fmt::Debug for RemoteRuntimeConfig {
|
impl std::fmt::Debug for RemoteRuntimeConfig {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
f.debug_struct("RemoteRuntimeConfig")
|
f.debug_struct("RemoteRuntimeConfig")
|
||||||
|
|
@ -2030,6 +2038,7 @@ impl std::fmt::Debug for RemoteRuntimeConfig {
|
||||||
"bearer_token",
|
"bearer_token",
|
||||||
&self.bearer_token.as_ref().map(|_| "<redacted>"),
|
&self.bearer_token.as_ref().map(|_| "<redacted>"),
|
||||||
)
|
)
|
||||||
|
.field("auth", &self.auth.as_ref().map(|_| "<capability-signer>"))
|
||||||
.field("cached_capabilities", &self.cached_capabilities)
|
.field("cached_capabilities", &self.cached_capabilities)
|
||||||
.field("cached_status", &self.cached_status)
|
.field("cached_status", &self.cached_status)
|
||||||
.field("timeout", &self.timeout)
|
.field("timeout", &self.timeout)
|
||||||
|
|
@ -2049,6 +2058,7 @@ impl RemoteRuntimeConfig {
|
||||||
display_name: display_name.into(),
|
display_name: display_name.into(),
|
||||||
base_url: base_url.into(),
|
base_url: base_url.into(),
|
||||||
bearer_token,
|
bearer_token,
|
||||||
|
auth: None,
|
||||||
cached_capabilities: remote_runtime_capabilities(
|
cached_capabilities: remote_runtime_capabilities(
|
||||||
200, false, false, "unknown", "unknown",
|
200, false, false, "unknown", "unknown",
|
||||||
),
|
),
|
||||||
|
|
@ -2062,6 +2072,11 @@ impl RemoteRuntimeConfig {
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn with_auth(mut self, auth: RemoteRuntimeAuthConfig) -> Self {
|
||||||
|
self.auth = Some(auth);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
pub fn with_cached_status(mut self, status: impl Into<String>) -> Self {
|
pub fn with_cached_status(mut self, status: impl Into<String>) -> Self {
|
||||||
self.cached_status = status.into();
|
self.cached_status = status.into();
|
||||||
self
|
self
|
||||||
|
|
@ -2081,6 +2096,7 @@ pub struct RemoteWorkerRuntime {
|
||||||
backend_base_url: String,
|
backend_base_url: String,
|
||||||
workspace_id: String,
|
workspace_id: String,
|
||||||
bearer_token: Option<String>,
|
bearer_token: Option<String>,
|
||||||
|
auth: Option<RemoteRuntimeAuthConfig>,
|
||||||
cached_capabilities: RuntimeCapabilitySummary,
|
cached_capabilities: RuntimeCapabilitySummary,
|
||||||
cached_status: String,
|
cached_status: String,
|
||||||
host_id: String,
|
host_id: String,
|
||||||
|
|
@ -2088,6 +2104,21 @@ pub struct RemoteWorkerRuntime {
|
||||||
http: BlockingHttpClient,
|
http: BlockingHttpClient,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn all_remote_runtime_permissions() -> Vec<String> {
|
||||||
|
[
|
||||||
|
"workers:list",
|
||||||
|
"workers:create",
|
||||||
|
"workers:read",
|
||||||
|
"workers:delete",
|
||||||
|
"workers:input",
|
||||||
|
"workers:stop",
|
||||||
|
"workers:protocol",
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.map(str::to_string)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
impl RemoteWorkerRuntime {
|
impl RemoteWorkerRuntime {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
config: RemoteRuntimeConfig,
|
config: RemoteRuntimeConfig,
|
||||||
|
|
@ -2112,6 +2143,7 @@ impl RemoteWorkerRuntime {
|
||||||
backend_base_url: backend_base_url.trim_end_matches('/').to_string(),
|
backend_base_url: backend_base_url.trim_end_matches('/').to_string(),
|
||||||
workspace_id,
|
workspace_id,
|
||||||
bearer_token: config.bearer_token,
|
bearer_token: config.bearer_token,
|
||||||
|
auth: config.auth,
|
||||||
cached_capabilities: config.cached_capabilities,
|
cached_capabilities: config.cached_capabilities,
|
||||||
cached_status: config.cached_status,
|
cached_status: config.cached_status,
|
||||||
resource_broker: BackendResourceBroker::default(),
|
resource_broker: BackendResourceBroker::default(),
|
||||||
|
|
@ -2150,7 +2182,7 @@ impl RemoteWorkerRuntime {
|
||||||
where
|
where
|
||||||
T: DeserializeOwned + Send + 'static,
|
T: DeserializeOwned + Send + 'static,
|
||||||
{
|
{
|
||||||
self.send_json(self.http.get(self.endpoint(path)))
|
self.send_json(path, self.http.get(self.endpoint(path)))
|
||||||
}
|
}
|
||||||
|
|
||||||
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>
|
||||||
|
|
@ -2158,25 +2190,56 @@ impl RemoteWorkerRuntime {
|
||||||
B: Serialize + ?Sized,
|
B: Serialize + ?Sized,
|
||||||
T: DeserializeOwned + Send + 'static,
|
T: DeserializeOwned + Send + 'static,
|
||||||
{
|
{
|
||||||
self.send_json(self.http.post(self.endpoint(path)).json(body))
|
self.send_json(path, self.http.post(self.endpoint(path)).json(body))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn delete_json<T>(&self, path: &str) -> Result<T, RuntimeDiagnostic>
|
fn delete_json<T>(&self, path: &str) -> Result<T, RuntimeDiagnostic>
|
||||||
where
|
where
|
||||||
T: DeserializeOwned + Send + 'static,
|
T: DeserializeOwned + Send + 'static,
|
||||||
{
|
{
|
||||||
self.send_json(self.http.delete(self.endpoint(path)))
|
self.send_json(path, self.http.delete(self.endpoint(path)))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn send_json<T>(&self, request: RequestBuilder) -> Result<T, RuntimeDiagnostic>
|
fn runtime_capability_token(&self, path: &str) -> Option<String> {
|
||||||
|
let auth = self.auth.as_ref()?;
|
||||||
|
let signer = CapabilityTokenSigner::new(&auth.server_id, &auth.server_private_key);
|
||||||
|
let claims = capability_claims(
|
||||||
|
&auth.server_id,
|
||||||
|
&self.runtime_id,
|
||||||
|
&self.workspace_id,
|
||||||
|
all_remote_runtime_permissions(),
|
||||||
|
300,
|
||||||
|
)
|
||||||
|
.map_err(|error| {
|
||||||
|
eprintln!(
|
||||||
|
"failed to build Runtime capability claims for {} {}: {error}",
|
||||||
|
self.runtime_id, path
|
||||||
|
);
|
||||||
|
error
|
||||||
|
})
|
||||||
|
.ok()?;
|
||||||
|
signer
|
||||||
|
.sign(&claims)
|
||||||
|
.map_err(|error| {
|
||||||
|
eprintln!(
|
||||||
|
"failed to sign Runtime capability token for {} {}: {error}",
|
||||||
|
self.runtime_id, path
|
||||||
|
);
|
||||||
|
error
|
||||||
|
})
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_json<T>(&self, path: &str, request: RequestBuilder) -> Result<T, RuntimeDiagnostic>
|
||||||
where
|
where
|
||||||
T: DeserializeOwned + Send + 'static,
|
T: DeserializeOwned + Send + 'static,
|
||||||
{
|
{
|
||||||
let runtime_id = self.runtime_id.clone();
|
let runtime_id = self.runtime_id.clone();
|
||||||
let bearer_token = self.bearer_token.clone();
|
let bearer_token = self.bearer_token.clone();
|
||||||
|
let capability_token = self.runtime_capability_token(path);
|
||||||
run_blocking_http(move || {
|
run_blocking_http(move || {
|
||||||
let request = request.header(CONTENT_TYPE, "application/json");
|
let request = request.header(CONTENT_TYPE, "application/json");
|
||||||
let request = if let Some(token) = bearer_token.as_deref() {
|
let request = if let Some(token) = capability_token.as_deref().or(bearer_token.as_deref()) {
|
||||||
request.header(AUTHORIZATION, format!("Bearer {token}"))
|
request.header(AUTHORIZATION, format!("Bearer {token}"))
|
||||||
} else {
|
} else {
|
||||||
request
|
request
|
||||||
|
|
@ -2653,7 +2716,9 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
|
||||||
runtime_id: self.runtime_id.clone(),
|
runtime_id: self.runtime_id.clone(),
|
||||||
worker_id: worker_id.to_string(),
|
worker_id: worker_id.to_string(),
|
||||||
endpoint: self.ws_endpoint(worker_id),
|
endpoint: self.ws_endpoint(worker_id),
|
||||||
bearer_token: self.bearer_token.clone(),
|
bearer_token: self
|
||||||
|
.runtime_capability_token(&format!("/v1/workers/{worker_id}/protocol"))
|
||||||
|
.or_else(|| self.bearer_token.clone()),
|
||||||
},
|
},
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,17 @@
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::path::PathBuf;
|
use std::collections::VecDeque;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::ExitCode;
|
use std::process::ExitCode;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use chrono::Utc;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
use yoi_workspace_server::store::RepositoryRecord;
|
use worker_runtime::auth::{RuntimeIdentityMaterial, decode_public_key};
|
||||||
|
use yoi_workspace_server::hosts::{RemoteRuntimeAuthConfig, RemoteRuntimeConfig};
|
||||||
|
use yoi_workspace_server::store::{RepositoryRecord, SqliteWorkspaceStore, TrustedRuntimeRecord};
|
||||||
use yoi_workspace_server::{
|
use yoi_workspace_server::{
|
||||||
BackendRuntimesConfigFile, ControlPlaneStore, ServerConfig, SqliteWorkspaceStore,
|
BackendRuntimesConfigFile, ControlPlaneStore, ServerConfig,
|
||||||
WORKSPACE_BACKEND_CONFIG_TEMPLATE, WorkspaceBackendConfigFile, WorkspaceIdentity,
|
WORKSPACE_BACKEND_CONFIG_TEMPLATE, WorkspaceBackendConfigFile, WorkspaceIdentity,
|
||||||
WorkspaceRecord, serve,
|
WorkspaceRecord, serve,
|
||||||
};
|
};
|
||||||
|
|
@ -17,6 +22,8 @@ enum Command {
|
||||||
Init(InitOptions),
|
Init(InitOptions),
|
||||||
ConfigDefault,
|
ConfigDefault,
|
||||||
ConfigDiff(WorkspacePathOptions),
|
ConfigDiff(WorkspacePathOptions),
|
||||||
|
Identity(Vec<String>),
|
||||||
|
TrustRuntime(Vec<String>),
|
||||||
Skills(SkillsCommand),
|
Skills(SkillsCommand),
|
||||||
Help,
|
Help,
|
||||||
}
|
}
|
||||||
|
|
@ -72,6 +79,8 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
Command::Init(options) => run_init(options).await,
|
Command::Init(options) => run_init(options).await,
|
||||||
Command::ConfigDefault => run_config_default(),
|
Command::ConfigDefault => run_config_default(),
|
||||||
Command::ConfigDiff(options) => run_config_diff(options),
|
Command::ConfigDiff(options) => run_config_diff(options),
|
||||||
|
Command::Identity(args) => run_identity_command(args),
|
||||||
|
Command::TrustRuntime(args) => run_trust_runtime_command(args),
|
||||||
Command::Skills(command) => run_skills(command),
|
Command::Skills(command) => run_skills(command),
|
||||||
Command::Help => Ok(()),
|
Command::Help => Ok(()),
|
||||||
}
|
}
|
||||||
|
|
@ -92,6 +101,8 @@ fn parse_command(args: &[String]) -> Result<Command, CliError> {
|
||||||
Ok(Command::Init(parse_init_options(rest)?))
|
Ok(Command::Init(parse_init_options(rest)?))
|
||||||
}
|
}
|
||||||
"config" => parse_config_command(rest),
|
"config" => parse_config_command(rest),
|
||||||
|
"identity" => Ok(Command::Identity(rest.to_vec())),
|
||||||
|
"trust-runtime" => Ok(Command::TrustRuntime(rest.to_vec())),
|
||||||
"skills" => parse_skills_command(rest),
|
"skills" => parse_skills_command(rest),
|
||||||
"serve" => {
|
"serve" => {
|
||||||
if rest.iter().any(|arg| arg == "--help" || arg == "-h") {
|
if rest.iter().any(|arg| arg == "--help" || arg == "-h") {
|
||||||
|
|
@ -105,7 +116,7 @@ fn parse_command(args: &[String]) -> Result<Command, CliError> {
|
||||||
Ok(Command::Help)
|
Ok(Command::Help)
|
||||||
}
|
}
|
||||||
other => Err(CliError(format!(
|
other => Err(CliError(format!(
|
||||||
"unknown command `{other}`; expected `init`, `config`, `skills`, or `serve`"
|
"unknown command `{other}`; expected `init`, `config`, `identity`, `trust-runtime`, `skills`, or `serve`"
|
||||||
))),
|
))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -169,6 +180,280 @@ fn run_config_diff(options: WorkspacePathOptions) -> Result<(), Box<dyn std::err
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
struct ServerIdentityFile {
|
||||||
|
identity: RuntimeIdentityMaterial,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
struct PublicIdentityView {
|
||||||
|
identity_id: String,
|
||||||
|
public_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn server_identity_path() -> PathBuf {
|
||||||
|
ServerConfig::default_server_data_root().join("identity.toml")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_server_identity_file(path: &Path) -> Result<Option<ServerIdentityFile>, Box<dyn std::error::Error>> {
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let contents = std::fs::read_to_string(path)?;
|
||||||
|
Ok(Some(toml::from_str(&contents)?))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_server_identity_file(path: &Path, identity: &ServerIdentityFile) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let contents = toml::to_string_pretty(identity)?;
|
||||||
|
write_secret_file(path, contents.as_bytes())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_secret_file(path: &Path, contents: &[u8]) -> std::io::Result<()> {
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::OpenOptionsExt;
|
||||||
|
let mut file = std::fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.write(true)
|
||||||
|
.truncate(true)
|
||||||
|
.mode(0o600)
|
||||||
|
.open(path)?;
|
||||||
|
use std::io::Write as _;
|
||||||
|
file.write_all(contents)?;
|
||||||
|
}
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
{
|
||||||
|
std::fs::write(path, contents)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn public_identity_view(identity: &RuntimeIdentityMaterial) -> PublicIdentityView {
|
||||||
|
PublicIdentityView {
|
||||||
|
identity_id: identity.identity_id.clone(),
|
||||||
|
public_key: identity.public_key.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_identity_command(args: Vec<String>) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let mut args = VecDeque::from(args);
|
||||||
|
let subcommand = args.pop_front().ok_or_else(|| CliError("identity requires `init` or `show`".to_string()))?;
|
||||||
|
match subcommand.as_str() {
|
||||||
|
"init" => {
|
||||||
|
let mut server_id = None;
|
||||||
|
let mut replace = false;
|
||||||
|
while let Some(arg) = args.pop_front() {
|
||||||
|
let (flag, inline_value) = split_flag_value(arg)?;
|
||||||
|
match flag.as_str() {
|
||||||
|
"--server-id" => server_id = Some(take_value(&flag, inline_value, &mut args)?),
|
||||||
|
"--replace" => {
|
||||||
|
ensure_no_inline_value(&flag, inline_value.as_deref())?;
|
||||||
|
replace = true;
|
||||||
|
}
|
||||||
|
_ => return Err(Box::new(CliError(format!("unknown identity init argument `{flag}`")))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let server_id = server_id.ok_or_else(|| {
|
||||||
|
CliError("identity init requires --server-id".to_string())
|
||||||
|
})?;
|
||||||
|
let path = server_identity_path();
|
||||||
|
if read_server_identity_file(&path)?.is_some() && !replace {
|
||||||
|
return Err(Box::new(CliError(format!(
|
||||||
|
"server identity already exists at {}; pass --replace to rotate it",
|
||||||
|
path.display()
|
||||||
|
))));
|
||||||
|
}
|
||||||
|
let identity = RuntimeIdentityMaterial::generate(server_id)?;
|
||||||
|
write_server_identity_file(&path, &ServerIdentityFile { identity: identity.clone() })?;
|
||||||
|
println!("server_id={}", identity.identity_id);
|
||||||
|
println!("public_key={}", identity.public_key);
|
||||||
|
println!("identity_file={}", path.display());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
"show" => {
|
||||||
|
let mut json = false;
|
||||||
|
while let Some(arg) = args.pop_front() {
|
||||||
|
let (flag, inline_value) = split_flag_value(arg)?;
|
||||||
|
match flag.as_str() {
|
||||||
|
"--json" => {
|
||||||
|
ensure_no_inline_value(&flag, inline_value.as_deref())?;
|
||||||
|
json = true;
|
||||||
|
}
|
||||||
|
_ => return Err(Box::new(CliError(format!("unknown identity show argument `{flag}`")))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let path = server_identity_path();
|
||||||
|
let identity = read_server_identity_file(&path)?.ok_or_else(|| {
|
||||||
|
CliError(format!("server identity is not initialized at {}", path.display()))
|
||||||
|
})?;
|
||||||
|
let view = public_identity_view(&identity.identity);
|
||||||
|
if json {
|
||||||
|
println!("{}", serde_json::to_string_pretty(&view)?);
|
||||||
|
} else {
|
||||||
|
println!("server_id={}", view.identity_id);
|
||||||
|
println!("public_key={}", view.public_key);
|
||||||
|
println!("identity_file={}", path.display());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
_ => Err(Box::new(CliError(format!("unknown identity subcommand `{subcommand}`")))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_trust_runtime_command(args: Vec<String>) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let mut args = VecDeque::from(args);
|
||||||
|
let subcommand = args.pop_front().ok_or_else(|| CliError("trust-runtime requires `add`, `list`, or `revoke`".to_string()))?;
|
||||||
|
let database_path = ServerConfig::default_server_database_path();
|
||||||
|
if let Some(parent) = database_path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let store = SqliteWorkspaceStore::open(&database_path)?;
|
||||||
|
match subcommand.as_str() {
|
||||||
|
"add" => {
|
||||||
|
let mut runtime_id = None;
|
||||||
|
let mut base_url = None;
|
||||||
|
let mut public_key = None;
|
||||||
|
let mut display_name = None;
|
||||||
|
let mut replace = false;
|
||||||
|
while let Some(arg) = args.pop_front() {
|
||||||
|
let (flag, inline_value) = split_flag_value(arg)?;
|
||||||
|
match flag.as_str() {
|
||||||
|
"--runtime-id" => runtime_id = Some(take_value(&flag, inline_value, &mut args)?),
|
||||||
|
"--base-url" | "--endpoint" => base_url = Some(take_value(&flag, inline_value, &mut args)?),
|
||||||
|
"--public-key" => public_key = Some(take_value(&flag, inline_value, &mut args)?),
|
||||||
|
"--display-name" => display_name = Some(take_value(&flag, inline_value, &mut args)?),
|
||||||
|
"--replace" => {
|
||||||
|
ensure_no_inline_value(&flag, inline_value.as_deref())?;
|
||||||
|
replace = true;
|
||||||
|
}
|
||||||
|
_ => return Err(Box::new(CliError(format!("unknown trust-runtime add argument `{flag}`")))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let runtime_id = runtime_id.ok_or_else(|| CliError("trust-runtime add requires --runtime-id".to_string()))?;
|
||||||
|
let base_url = base_url.ok_or_else(|| CliError("trust-runtime add requires --base-url".to_string()))?;
|
||||||
|
let public_key = public_key.ok_or_else(|| CliError("trust-runtime add requires --public-key".to_string()))?;
|
||||||
|
decode_public_key(&public_key)?;
|
||||||
|
ensure_trusted_runtime_replace_allowed(&store, &runtime_id, replace)?;
|
||||||
|
let now = Utc::now().to_rfc3339();
|
||||||
|
store.upsert_trusted_runtime(&TrustedRuntimeRecord {
|
||||||
|
runtime_id: runtime_id.clone(),
|
||||||
|
display_name: display_name.unwrap_or_else(|| runtime_id.clone()),
|
||||||
|
base_url,
|
||||||
|
public_key,
|
||||||
|
created_at: now.clone(),
|
||||||
|
updated_at: now,
|
||||||
|
revoked_at: None,
|
||||||
|
})?;
|
||||||
|
println!("trusted_runtime_id={runtime_id}");
|
||||||
|
println!("server_db={}", database_path.display());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
"list" => {
|
||||||
|
let mut json = false;
|
||||||
|
let mut include_revoked = false;
|
||||||
|
while let Some(arg) = args.pop_front() {
|
||||||
|
let (flag, inline_value) = split_flag_value(arg)?;
|
||||||
|
match flag.as_str() {
|
||||||
|
"--json" => {
|
||||||
|
ensure_no_inline_value(&flag, inline_value.as_deref())?;
|
||||||
|
json = true;
|
||||||
|
}
|
||||||
|
"--include-revoked" => {
|
||||||
|
ensure_no_inline_value(&flag, inline_value.as_deref())?;
|
||||||
|
include_revoked = true;
|
||||||
|
}
|
||||||
|
_ => return Err(Box::new(CliError(format!("unknown trust-runtime list argument `{flag}`")))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let records = store.list_trusted_runtimes(include_revoked)?;
|
||||||
|
if json {
|
||||||
|
println!("{}", serde_json::to_string_pretty(&records)?);
|
||||||
|
} else {
|
||||||
|
for runtime in records {
|
||||||
|
println!(
|
||||||
|
"runtime_id={} base_url={} public_key={} revoked_at={}",
|
||||||
|
runtime.runtime_id,
|
||||||
|
runtime.base_url,
|
||||||
|
runtime.public_key,
|
||||||
|
runtime.revoked_at.unwrap_or_default()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
"revoke" => {
|
||||||
|
let mut runtime_id = None;
|
||||||
|
while let Some(arg) = args.pop_front() {
|
||||||
|
let (flag, inline_value) = split_flag_value(arg)?;
|
||||||
|
match flag.as_str() {
|
||||||
|
"--runtime-id" => runtime_id = Some(take_value(&flag, inline_value, &mut args)?),
|
||||||
|
_ => return Err(Box::new(CliError(format!("unknown trust-runtime revoke argument `{flag}`")))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let runtime_id = runtime_id.ok_or_else(|| CliError("trust-runtime revoke requires --runtime-id".to_string()))?;
|
||||||
|
let now = Utc::now().to_rfc3339();
|
||||||
|
if !store.revoke_trusted_runtime(&runtime_id, &now)? {
|
||||||
|
return Err(Box::new(CliError(format!("trusted runtime `{runtime_id}` is not registered or is already revoked"))));
|
||||||
|
}
|
||||||
|
println!("revoked_runtime_id={runtime_id}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
_ => Err(Box::new(CliError(format!("unknown trust-runtime subcommand `{subcommand}`")))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_trusted_runtime_replace_allowed(
|
||||||
|
store: &SqliteWorkspaceStore,
|
||||||
|
runtime_id: &str,
|
||||||
|
replace: bool,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
if store
|
||||||
|
.list_trusted_runtimes(true)?
|
||||||
|
.iter()
|
||||||
|
.any(|runtime| runtime.runtime_id == runtime_id)
|
||||||
|
&& !replace
|
||||||
|
{
|
||||||
|
return Err(Box::new(CliError(format!(
|
||||||
|
"trusted runtime `{runtime_id}` already exists; pass --replace to update it"
|
||||||
|
))));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn split_flag_value(arg: String) -> Result<(String, Option<String>), CliError> {
|
||||||
|
if let Some((flag, value)) = arg.split_once('=') {
|
||||||
|
if flag.is_empty() {
|
||||||
|
return Err(CliError("empty flag name".to_string()));
|
||||||
|
}
|
||||||
|
Ok((flag.to_string(), Some(value.to_string())))
|
||||||
|
} else {
|
||||||
|
Ok((arg, None))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn take_value(
|
||||||
|
flag: &str,
|
||||||
|
inline_value: Option<String>,
|
||||||
|
args: &mut VecDeque<String>,
|
||||||
|
) -> Result<String, CliError> {
|
||||||
|
if let Some(value) = inline_value {
|
||||||
|
return Ok(value);
|
||||||
|
}
|
||||||
|
args.pop_front()
|
||||||
|
.ok_or_else(|| CliError(format!("{flag} requires a value")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_no_inline_value(flag: &str, inline_value: Option<&str>) -> Result<(), CliError> {
|
||||||
|
if inline_value.is_some() {
|
||||||
|
return Err(CliError(format!("{flag} does not accept a value")));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn run_skills(command: SkillsCommand) -> Result<(), Box<dyn std::error::Error>> {
|
fn run_skills(command: SkillsCommand) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
match command {
|
match command {
|
||||||
SkillsCommand::List(options) => {
|
SkillsCommand::List(options) => {
|
||||||
|
|
@ -224,6 +509,7 @@ async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Erro
|
||||||
)?;
|
)?;
|
||||||
resolved.database_path = database_path.clone();
|
resolved.database_path = database_path.clone();
|
||||||
resolved.server.database_path = database_path.clone();
|
resolved.server.database_path = database_path.clone();
|
||||||
|
append_trusted_runtime_sources(store.as_ref(), &mut resolved.server.remote_runtime_sources)?;
|
||||||
if let Some(listen) = options.listen {
|
if let Some(listen) = options.listen {
|
||||||
resolved = resolved.with_listen(listen);
|
resolved = resolved.with_listen(listen);
|
||||||
}
|
}
|
||||||
|
|
@ -239,6 +525,36 @@ async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Erro
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn append_trusted_runtime_sources(
|
||||||
|
store: &SqliteWorkspaceStore,
|
||||||
|
remote_runtime_sources: &mut Vec<RemoteRuntimeConfig>,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let Some(server_identity) = read_server_identity_file(&server_identity_path())? else {
|
||||||
|
if !store.list_trusted_runtimes(false)?.is_empty() {
|
||||||
|
return Err(Box::new(CliError(
|
||||||
|
"trusted runtimes are registered but server identity is not initialized; run `yoi-workspace-server identity init`".to_string(),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
for runtime in store.list_trusted_runtimes(false)? {
|
||||||
|
let auth = RemoteRuntimeAuthConfig {
|
||||||
|
server_id: server_identity.identity.identity_id.clone(),
|
||||||
|
server_private_key: server_identity.identity.private_key.clone(),
|
||||||
|
};
|
||||||
|
let remote = RemoteRuntimeConfig::new(
|
||||||
|
runtime.runtime_id.clone(),
|
||||||
|
runtime.display_name,
|
||||||
|
runtime.base_url,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.with_auth(auth);
|
||||||
|
remote_runtime_sources.retain(|existing| existing.runtime_id != runtime.runtime_id);
|
||||||
|
remote_runtime_sources.push(remote);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn select_serve_workspace(store: &SqliteWorkspaceStore) -> Result<WorkspaceRecord, CliError> {
|
fn select_serve_workspace(store: &SqliteWorkspaceStore) -> Result<WorkspaceRecord, CliError> {
|
||||||
let workspaces = store
|
let workspaces = store
|
||||||
.list_workspaces()
|
.list_workspaces()
|
||||||
|
|
@ -453,7 +769,7 @@ fn parse_listen(value: &str) -> Result<SocketAddr, CliError> {
|
||||||
|
|
||||||
fn print_help() {
|
fn print_help() {
|
||||||
println!(
|
println!(
|
||||||
"yoi-workspace-server\n\nUsage:\n yoi-workspace-server init [OPTIONS]\n yoi-workspace-server config <COMMAND> [OPTIONS]\n yoi-workspace-server skills <COMMAND> [OPTIONS]\n yoi-workspace-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help"
|
"yoi-workspace-server\n\nUsage:\n yoi-workspace-server init [OPTIONS]\n yoi-workspace-server config <COMMAND> [OPTIONS]\n yoi-workspace-server identity init --server-id <SERVER_ID> [--replace]\n yoi-workspace-server identity show [--json]\n yoi-workspace-server trust-runtime add --runtime-id <RUNTIME_ID> --base-url <URL> --public-key <KEY> [--display-name <NAME>] [--replace]\n yoi-workspace-server trust-runtime list [--json] [--include-revoked]\n yoi-workspace-server trust-runtime revoke --runtime-id <RUNTIME_ID>\n yoi-workspace-server skills <COMMAND> [OPTIONS]\n yoi-workspace-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -523,6 +839,39 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn server_identity_init_requires_explicit_server_id() {
|
||||||
|
let error = run_identity_command(vec!["init".to_string()]).unwrap_err();
|
||||||
|
assert_eq!(error.to_string(), "identity init requires --server-id");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trusted_runtime_add_requires_replace_for_existing_record() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let store = SqliteWorkspaceStore::open(temp.path().join("server.db")).unwrap();
|
||||||
|
let public_key = RuntimeIdentityMaterial::generate("runtime-a")
|
||||||
|
.unwrap()
|
||||||
|
.public_key;
|
||||||
|
store
|
||||||
|
.upsert_trusted_runtime(&TrustedRuntimeRecord {
|
||||||
|
runtime_id: "runtime-a".to_string(),
|
||||||
|
display_name: "Runtime A".to_string(),
|
||||||
|
base_url: "http://127.0.0.1:18080".to_string(),
|
||||||
|
public_key,
|
||||||
|
created_at: "2026-07-26T00:00:00Z".to_string(),
|
||||||
|
updated_at: "2026-07-26T00:00:00Z".to_string(),
|
||||||
|
revoked_at: None,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let error = ensure_trusted_runtime_replace_allowed(&store, "runtime-a", false).unwrap_err();
|
||||||
|
assert_eq!(
|
||||||
|
error.to_string(),
|
||||||
|
"trusted runtime `runtime-a` already exists; pass --replace to update it"
|
||||||
|
);
|
||||||
|
ensure_trusted_runtime_replace_allowed(&store, "runtime-a", true).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn init_creates_identity_local_config_and_server_records() {
|
async fn init_creates_identity_local_config_and_server_records() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,11 @@ const MIGRATIONS: &[Migration] = &[
|
||||||
name: "sqlite memory authority documents and staging resolutions",
|
name: "sqlite memory authority documents and staging resolutions",
|
||||||
apply: create_memory_authority_tables,
|
apply: create_memory_authority_tables,
|
||||||
},
|
},
|
||||||
|
Migration {
|
||||||
|
version: 12,
|
||||||
|
name: "trusted remote runtime registry",
|
||||||
|
apply: create_trusted_runtime_registry_tables,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
struct Migration {
|
struct Migration {
|
||||||
|
|
@ -107,6 +112,17 @@ pub struct RepositoryRecord {
|
||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct TrustedRuntimeRecord {
|
||||||
|
pub runtime_id: String,
|
||||||
|
pub display_name: String,
|
||||||
|
pub base_url: String,
|
||||||
|
pub public_key: String,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
pub revoked_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct AccountRecord {
|
pub struct AccountRecord {
|
||||||
pub account_id: String,
|
pub account_id: String,
|
||||||
|
|
@ -498,6 +514,59 @@ impl SqliteWorkspaceStore {
|
||||||
.map_err(|_| Error::Store("sqlite connection lock poisoned".to_string()))?;
|
.map_err(|_| Error::Store("sqlite connection lock poisoned".to_string()))?;
|
||||||
f(&conn)
|
f(&conn)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn upsert_trusted_runtime(&self, record: &TrustedRuntimeRecord) -> Result<()> {
|
||||||
|
self.with_conn(|conn| {
|
||||||
|
conn.execute(
|
||||||
|
r#"INSERT INTO trusted_runtime_records (
|
||||||
|
runtime_id, display_name, base_url, public_key, created_at, updated_at, revoked_at
|
||||||
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
|
||||||
|
ON CONFLICT(runtime_id) DO UPDATE SET
|
||||||
|
display_name = excluded.display_name,
|
||||||
|
base_url = excluded.base_url,
|
||||||
|
public_key = excluded.public_key,
|
||||||
|
updated_at = excluded.updated_at,
|
||||||
|
revoked_at = excluded.revoked_at"#,
|
||||||
|
params![
|
||||||
|
record.runtime_id,
|
||||||
|
record.display_name,
|
||||||
|
record.base_url,
|
||||||
|
record.public_key,
|
||||||
|
record.created_at,
|
||||||
|
record.updated_at,
|
||||||
|
record.revoked_at,
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_trusted_runtimes(&self, include_revoked: bool) -> Result<Vec<TrustedRuntimeRecord>> {
|
||||||
|
self.with_conn(|conn| {
|
||||||
|
let sql = if include_revoked {
|
||||||
|
r#"SELECT runtime_id, display_name, base_url, public_key, created_at, updated_at, revoked_at
|
||||||
|
FROM trusted_runtime_records ORDER BY runtime_id ASC"#
|
||||||
|
} else {
|
||||||
|
r#"SELECT runtime_id, display_name, base_url, public_key, created_at, updated_at, revoked_at
|
||||||
|
FROM trusted_runtime_records WHERE revoked_at IS NULL ORDER BY runtime_id ASC"#
|
||||||
|
};
|
||||||
|
let mut stmt = conn.prepare(sql)?;
|
||||||
|
let rows = stmt.query_map([], read_trusted_runtime_record)?;
|
||||||
|
rows.collect::<std::result::Result<Vec<_>, _>>().map_err(Error::from)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn revoke_trusted_runtime(&self, runtime_id: &str, revoked_at: &str) -> Result<bool> {
|
||||||
|
self.with_conn(|conn| {
|
||||||
|
let changed = conn.execute(
|
||||||
|
r#"UPDATE trusted_runtime_records
|
||||||
|
SET revoked_at = ?2, updated_at = ?2
|
||||||
|
WHERE runtime_id = ?1 AND revoked_at IS NULL"#,
|
||||||
|
params![runtime_id, revoked_at],
|
||||||
|
)?;
|
||||||
|
Ok(changed > 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
|
|
@ -1599,6 +1668,18 @@ fn account_select_sql(where_clause: &str) -> String {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn read_trusted_runtime_record(row: &rusqlite::Row<'_>) -> rusqlite::Result<TrustedRuntimeRecord> {
|
||||||
|
Ok(TrustedRuntimeRecord {
|
||||||
|
runtime_id: row.get(0)?,
|
||||||
|
display_name: row.get(1)?,
|
||||||
|
base_url: row.get(2)?,
|
||||||
|
public_key: row.get(3)?,
|
||||||
|
created_at: row.get(4)?,
|
||||||
|
updated_at: row.get(5)?,
|
||||||
|
revoked_at: row.get(6)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn read_account_record(row: &rusqlite::Row<'_>) -> rusqlite::Result<AccountRecord> {
|
fn read_account_record(row: &rusqlite::Row<'_>) -> rusqlite::Result<AccountRecord> {
|
||||||
Ok(AccountRecord {
|
Ok(AccountRecord {
|
||||||
account_id: row.get(0)?,
|
account_id: row.get(0)?,
|
||||||
|
|
@ -2005,6 +2086,23 @@ CREATE TABLE IF NOT EXISTS memory_staging_resolutions (
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn create_trusted_runtime_registry_tables(conn: &Connection) -> Result<()> {
|
||||||
|
conn.execute_batch(
|
||||||
|
r#"
|
||||||
|
CREATE TABLE IF NOT EXISTS trusted_runtime_records (
|
||||||
|
runtime_id TEXT PRIMARY KEY,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
base_url TEXT NOT NULL,
|
||||||
|
public_key TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
revoked_at TEXT
|
||||||
|
);
|
||||||
|
"#,
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn create_account_identity_tables(conn: &Connection) -> Result<()> {
|
fn create_account_identity_tables(conn: &Connection) -> Result<()> {
|
||||||
conn.execute_batch(
|
conn.execute_batch(
|
||||||
r#"
|
r#"
|
||||||
|
|
|
||||||
|
|
@ -259,6 +259,9 @@ fn parse_args_slice(args: &[String]) -> Result<Mode, ParseError> {
|
||||||
"workspace" => {
|
"workspace" => {
|
||||||
return parse_workspace_args(&args[1..]);
|
return parse_workspace_args(&args[1..]);
|
||||||
}
|
}
|
||||||
|
"server" => {
|
||||||
|
return parse_workspace_args(&args[1..]);
|
||||||
|
}
|
||||||
"login" => {
|
"login" => {
|
||||||
return parse_login_args(&args[1..]);
|
return parse_login_args(&args[1..]);
|
||||||
}
|
}
|
||||||
|
|
@ -1026,7 +1029,7 @@ fn yoi_config_dir() -> Option<PathBuf> {
|
||||||
fn parse_workspace_args(args: &[String]) -> Result<Mode, ParseError> {
|
fn parse_workspace_args(args: &[String]) -> Result<Mode, ParseError> {
|
||||||
let Some((subcommand, rest)) = args.split_first() else {
|
let Some((subcommand, rest)) = args.split_first() else {
|
||||||
return Err(ParseError(
|
return Err(ParseError(
|
||||||
"yoi workspace requires `init`, `config`, or `serve` (try `yoi workspace --help`)"
|
"yoi workspace requires `init`, `config`, `identity`, `trust-runtime`, or `serve` (try `yoi workspace --help`)"
|
||||||
.to_string(),
|
.to_string(),
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
|
|
@ -1049,6 +1052,15 @@ fn parse_workspace_args(args: &[String]) -> Result<Mode, ParseError> {
|
||||||
args: rest.to_vec(),
|
args: rest.to_vec(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
"identity" | "trust-runtime" => {
|
||||||
|
if rest.iter().any(|arg| arg == "--help" || arg == "-h") {
|
||||||
|
return Ok(Mode::WorkspaceHelp);
|
||||||
|
}
|
||||||
|
Ok(Mode::WorkspaceServer {
|
||||||
|
subcommand: subcommand.clone(),
|
||||||
|
args: rest.to_vec(),
|
||||||
|
})
|
||||||
|
}
|
||||||
"serve" => {
|
"serve" => {
|
||||||
if rest.iter().any(|arg| arg == "--help" || arg == "-h") {
|
if rest.iter().any(|arg| arg == "--help" || arg == "-h") {
|
||||||
return Ok(Mode::WorkspaceHelp);
|
return Ok(Mode::WorkspaceHelp);
|
||||||
|
|
@ -1060,7 +1072,7 @@ fn parse_workspace_args(args: &[String]) -> Result<Mode, ParseError> {
|
||||||
}
|
}
|
||||||
"--help" | "-h" => Ok(Mode::WorkspaceHelp),
|
"--help" | "-h" => Ok(Mode::WorkspaceHelp),
|
||||||
other => Err(ParseError(format!(
|
other => Err(ParseError(format!(
|
||||||
"unknown yoi workspace subcommand `{other}`; expected `init`, `config`, or `serve`"
|
"unknown yoi workspace subcommand `{other}`; expected `init`, `config`, `identity`, `trust-runtime`, or `serve`"
|
||||||
))),
|
))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1442,13 +1454,18 @@ fn print_help() {
|
||||||
println!(
|
println!(
|
||||||
"yoi\n\nUsage:\n yoi [OPTIONS]\n yoi resume [--workspace <PATH>] [--all]\n yoi workers [--workspace <PATH>] [--workspace-id <ID>] [--backend <URL>] [--runtime-id <ID>]\n yoi panel [--workspace <PATH>]\n yoi keys\n yoi setup-model\n yoi worker [WORKER_OPTIONS]\n yoi worker delete <NAME> [--force] [--dry-run]\n yoi worker prune --older-than <DURATION> [--force] [--dry-run]\n yoi objective <COMMAND> [OPTIONS]\n yoi session analyze <SESSION_JSONL_PATH> --json\n yoi session prune --unreferenced [--older-than <DURATION>] [--force] [--dry-run]\n yoi ticket <COMMAND> [OPTIONS]\n yoi workspace init [OPTIONS]
|
"yoi\n\nUsage:\n yoi [OPTIONS]\n yoi resume [--workspace <PATH>] [--all]\n yoi workers [--workspace <PATH>] [--workspace-id <ID>] [--backend <URL>] [--runtime-id <ID>]\n yoi panel [--workspace <PATH>]\n yoi keys\n yoi setup-model\n yoi worker [WORKER_OPTIONS]\n yoi worker delete <NAME> [--force] [--dry-run]\n yoi worker prune --older-than <DURATION> [--force] [--dry-run]\n yoi objective <COMMAND> [OPTIONS]\n yoi session analyze <SESSION_JSONL_PATH> --json\n yoi session prune --unreferenced [--older-than <DURATION>] [--force] [--dry-run]\n yoi ticket <COMMAND> [OPTIONS]\n yoi workspace init [OPTIONS]
|
||||||
yoi workspace config <COMMAND> [OPTIONS]
|
yoi workspace config <COMMAND> [OPTIONS]
|
||||||
yoi workspace serve [OPTIONS]\n yoi plugin new <rust-component-tool|rust-component-service> <PATH> [--json]\n yoi plugin check <PATH_OR_PACKAGE> [--json]\n yoi plugin pack <PATH> [--output <FILE>] [--json]\n yoi plugin list [--workspace <PATH>] [--profile <REF>] [--json]\n yoi plugin show <REF> [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp list [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp show <SERVER> [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp tools|resources|prompts [SERVER] [--workspace <PATH>] [--profile <REF>] [--json]\n yoi memory lint [OPTIONS]\n\nSurfaces:\n Console Single-Worker chat/client surface (default, --worker, yoi resume, Backend Runtime target)\n Dashboard Workspace cockpit/action surface (yoi panel)\n TUI Terminal UI implementation umbrella for Console and Dashboard\n\nOptions:\n --workspace <PATH> Runtime workspace root for default Console/--worker/workers (defaults to cwd)\n --workspace-id <ID> Workspace identity for Backend scoped routes\n --backend <URL> Workspace Backend API URL for Backend Runtime attach/list\n --runtime-id <ID> Backend Runtime identity for attach/list\n --worker <NAME> Open the Worker Console by name (attach/restore/create)\n --socket <PATH> Attach a Worker Console to a specific socket with --worker\n --session <UUID> Resume a specific session segment in the Worker Console\n --profile <REF> Select a reusable Profile recipe\n -h, --help Print help\n"
|
yoi workspace identity <COMMAND> [OPTIONS]
|
||||||
|
yoi workspace trust-runtime <COMMAND> [OPTIONS]
|
||||||
|
yoi workspace serve [OPTIONS]
|
||||||
|
yoi server identity <COMMAND> [OPTIONS]
|
||||||
|
yoi server trust-runtime <COMMAND> [OPTIONS]
|
||||||
|
yoi plugin new <rust-component-tool|rust-component-service> <PATH> [--json]\n yoi plugin check <PATH_OR_PACKAGE> [--json]\n yoi plugin pack <PATH> [--output <FILE>] [--json]\n yoi plugin list [--workspace <PATH>] [--profile <REF>] [--json]\n yoi plugin show <REF> [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp list [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp show <SERVER> [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp tools|resources|prompts [SERVER] [--workspace <PATH>] [--profile <REF>] [--json]\n yoi memory lint [OPTIONS]\n\nSurfaces:\n Console Single-Worker chat/client surface (default, --worker, yoi resume, Backend Runtime target)\n Dashboard Workspace cockpit/action surface (yoi panel)\n TUI Terminal UI implementation umbrella for Console and Dashboard\n\nOptions:\n --workspace <PATH> Runtime workspace root for default Console/--worker/workers (defaults to cwd)\n --workspace-id <ID> Workspace identity for Backend scoped routes\n --backend <URL> Workspace Backend API URL for Backend Runtime attach/list\n --runtime-id <ID> Backend Runtime identity for attach/list\n --worker <NAME> Open the Worker Console by name (attach/restore/create)\n --socket <PATH> Attach a Worker Console to a specific socket with --worker\n --session <UUID> Resume a specific session segment in the Worker Console\n --profile <REF> Select a reusable Profile recipe\n -h, --help Print help\n"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn print_workspace_help() {
|
fn print_workspace_help() {
|
||||||
println!(
|
println!(
|
||||||
"yoi workspace\n\nUsage:\n yoi workspace init [OPTIONS]\n yoi workspace config <COMMAND> [OPTIONS]\n yoi workspace serve [OPTIONS]\n\nDescription:\n Launches the separate yoi-workspace-server executable. The yoi binary does not link the workspace server crate. `serve` reads Workspace records from the Yoi server DB.\n\nSubcommands:\n init Initialize .yoi/workspace.toml, .yoi/workspace-backend.local.toml, and a server DB Workspace record\n config default Print the latest packaged Backend config template\n config diff Compare workspace local config with the packaged template\n serve Serve the Workspace recorded in the server DB\n\nOptions forwarded to init/config diff:\n --workspace <PATH> Workspace root (defaults to cwd)\n\nOptions forwarded to serve:\n --listen <ADDR> Listen address override\n -h, --help Print help\n\nEnvironment:\n YOI_WORKSPACE_SERVER_COMMAND Path to yoi-workspace-server executable override\n"
|
"yoi workspace\n\nUsage:\n yoi workspace init [OPTIONS]\n yoi workspace config <COMMAND> [OPTIONS]\n yoi workspace identity <COMMAND> [OPTIONS]\n yoi workspace trust-runtime <COMMAND> [OPTIONS]\n yoi workspace serve [OPTIONS]\n yoi server identity <COMMAND> [OPTIONS]\n yoi server trust-runtime <COMMAND> [OPTIONS]\n\nDescription:\n Launches the separate yoi-workspace-server executable. The yoi binary does not link the workspace server crate. `serve` reads Workspace records from the Yoi server DB.\n\nSubcommands:\n init Initialize .yoi/workspace.toml, .yoi/workspace-backend.local.toml, and a server DB Workspace record\n config default Print the latest packaged Backend config template\n config diff Compare workspace local config with the packaged template\n identity Initialize/show the Server signing identity\n trust-runtime Register/list/revoke trusted remote Runtime records\n serve Serve the Workspace recorded in the server DB\n\nOptions forwarded to init/config diff:\n --workspace <PATH> Workspace root (defaults to cwd)\n\nOptions forwarded to serve:\n --listen <ADDR> Listen address override\n -h, --help Print help\n\nEnvironment:\n YOI_WORKSPACE_SERVER_COMMAND Path to yoi-workspace-server executable override\n"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user