feat: add runtime trust capability auth
This commit is contained in:
@@ -11,6 +11,11 @@ name = "worker-runtime-rest-server"
|
||||
path = "src/main.rs"
|
||||
required-features = ["ws-server", "fs-store"]
|
||||
|
||||
[[bin]]
|
||||
name = "worker-runtime"
|
||||
path = "src/worker_runtime_bin.rs"
|
||||
required-features = ["ws-server", "fs-store"]
|
||||
|
||||
[features]
|
||||
default = []
|
||||
fs-store = []
|
||||
@@ -19,6 +24,7 @@ ws-server = ["http-server", "axum/ws", "dep:futures", "tokio/sync"]
|
||||
|
||||
[dependencies]
|
||||
async-trait.workspace = true
|
||||
base64.workspace = true
|
||||
axum = { workspace = true, optional = true }
|
||||
futures = { workspace = true, optional = true }
|
||||
decodal.workspace = true
|
||||
@@ -29,6 +35,7 @@ session-store.workspace = true
|
||||
sha2.workspace = true
|
||||
serde_json.workspace = true
|
||||
reqwest = { version = "0.13", optional = true, default-features = false, features = ["json", "rustls"] }
|
||||
ring.workspace = true
|
||||
tar.workspace = true
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true, features = ["net", "rt", "sync", "time"] }
|
||||
|
||||
@@ -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
|
||||
//! credentials, registration, and policy.
|
||||
|
||||
use crate::auth::{RuntimeAuthContext, RuntimeHttpAuthConfig, unix_now_seconds, verify_capability_token};
|
||||
use crate::Runtime;
|
||||
use crate::catalog::{
|
||||
ConfigBundleRef, CreateWorkerRequest, WorkerDetail, WorkerLifecycleAck, WorkerSummary,
|
||||
@@ -23,7 +24,7 @@ use axum::extract::rejection::{JsonRejection, QueryRejection};
|
||||
#[cfg(feature = "ws-server")]
|
||||
use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade};
|
||||
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::response::{IntoResponse, Response};
|
||||
use axum::routing::{get, post};
|
||||
@@ -61,6 +62,8 @@ pub struct RuntimeHttpServerConfig {
|
||||
/// Minimal local bearer token placeholder for backend-to-Runtime calls.
|
||||
/// This is not a browser-facing credential model.
|
||||
pub local_token: Option<String>,
|
||||
/// Optional signed Server-to-Runtime capability token authority.
|
||||
pub auth: Option<RuntimeHttpAuthConfig>,
|
||||
}
|
||||
|
||||
impl Default for RuntimeHttpServerConfig {
|
||||
@@ -71,6 +74,7 @@ impl Default for RuntimeHttpServerConfig {
|
||||
limits: RuntimeLimits::default(),
|
||||
store: RuntimeHttpStoreSelection::Memory,
|
||||
local_token: None,
|
||||
auth: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -113,15 +117,36 @@ pub async fn serve_runtime_http(
|
||||
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.
|
||||
///
|
||||
/// Handlers delegate to [`Runtime`] methods and keep Worker authority Runtime-local.
|
||||
/// The path contains only a Runtime-local `worker_id`; backend aliases are not
|
||||
/// accepted or forwarded as Runtime authority.
|
||||
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 {
|
||||
runtime,
|
||||
local_token: local_token.map(Arc::<str>::from),
|
||||
auth: auth.map(Arc::new),
|
||||
};
|
||||
|
||||
let router = Router::new()
|
||||
@@ -163,13 +188,14 @@ pub fn runtime_http_router(runtime: Runtime, local_token: Option<String>) -> Rou
|
||||
|
||||
router
|
||||
.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)]
|
||||
struct RuntimeHttpState {
|
||||
runtime: Runtime,
|
||||
local_token: Option<Arc<str>>,
|
||||
auth: Option<Arc<RuntimeHttpAuthConfig>>,
|
||||
}
|
||||
|
||||
/// `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>,
|
||||
request: Request<Body>,
|
||||
mut request: Request<Body>,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let supplied = request
|
||||
.headers()
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.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() {
|
||||
let supplied = request
|
||||
.headers()
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| value.strip_prefix("Bearer "));
|
||||
if supplied != Some(expected) {
|
||||
return RuntimeHttpRestError::new(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
@@ -719,10 +776,51 @@ async fn require_local_token(
|
||||
)
|
||||
.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
|
||||
}
|
||||
|
||||
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)]
|
||||
struct RuntimeHttpRestError {
|
||||
status: StatusCode,
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//! server is available only through the optional `http-server` / `ws-server`
|
||||
//! features.
|
||||
|
||||
pub mod auth;
|
||||
pub mod catalog;
|
||||
pub mod config_bundle;
|
||||
pub mod diagnostics;
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
//! Worker-backed Runtime REST process wrapper.
|
||||
//!
|
||||
//! 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
|
||||
//! Workspace Browser.
|
||||
// Worker-backed Runtime REST process wrapper.
|
||||
//
|
||||
// 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
|
||||
// Workspace Browser.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::env;
|
||||
use std::error::Error;
|
||||
use std::fmt;
|
||||
use std::net::SocketAddr;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::ExitCode;
|
||||
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::fs_store::FsRuntimeStoreOptions;
|
||||
use worker_runtime::http_server::{
|
||||
@@ -39,10 +43,15 @@ fn main() -> ExitCode {
|
||||
}
|
||||
|
||||
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());
|
||||
return Ok(());
|
||||
};
|
||||
config.http.auth = load_runtime_http_auth(&config)?;
|
||||
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
@@ -54,10 +63,11 @@ fn run() -> Result<(), ProcessError> {
|
||||
eprintln!(
|
||||
"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,
|
||||
listener,
|
||||
config.http.local_token,
|
||||
config.http.auth,
|
||||
)
|
||||
.await
|
||||
.map_err(ProcessError::from)
|
||||
@@ -326,6 +336,7 @@ enum ProcessError {
|
||||
Server(RuntimeHttpServerError),
|
||||
Runtime(RuntimeError),
|
||||
WorkerAdapter(String),
|
||||
Auth(String),
|
||||
Io(std::io::Error),
|
||||
}
|
||||
|
||||
@@ -342,6 +353,7 @@ impl fmt::Display for ProcessError {
|
||||
Self::Server(error) => error.fmt(f),
|
||||
Self::Runtime(error) => error.fmt(f),
|
||||
Self::WorkerAdapter(error) => error.fmt(f),
|
||||
Self::Auth(error) => error.fmt(f),
|
||||
Self::Io(error) => error.fmt(f),
|
||||
}
|
||||
}
|
||||
@@ -350,7 +362,7 @@ impl fmt::Display for ProcessError {
|
||||
impl Error for ProcessError {
|
||||
fn source(&self) -> Option<&(dyn Error + 'static)> {
|
||||
match self {
|
||||
Self::Usage(_) | Self::WorkerAdapter(_) => None,
|
||||
Self::Usage(_) | Self::WorkerAdapter(_) | Self::Auth(_) => None,
|
||||
Self::Server(error) => Some(error),
|
||||
Self::Runtime(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 {
|
||||
r#"Usage: worker-runtime-rest-server [OPTIONS]
|
||||
|
||||
@@ -391,7 +696,14 @@ Options:
|
||||
--local-token <TOKEN> Minimal local bearer token placeholder
|
||||
--local-token-env <ENV> Read local bearer token placeholder from env
|
||||
--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)]
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
include!("main.rs");
|
||||
Reference in New Issue
Block a user