use std::collections::hash_map::DefaultHasher; use std::hash::{Hash, Hasher}; use std::sync::atomic::{AtomicU64, Ordering}; use serde_json::json; use super::discovery::{workspace_url, AdminConfig, KcpInfo}; use super::runner::{shell_quote, Runner}; pub const READONLY_SERVICE_ACCOUNT: &str = "socialweb-computer-kcp-readonly"; pub const READONLY_CLUSTER_ROLE: &str = "socialweb-computer-kcp-readonly"; const TOKEN_DIR: &str = "socialweb-computer-kcp-mcp"; static TOKEN_FILE_SEQUENCE: AtomicU64 = AtomicU64::new(0); const READONLY_MANIFESTS: &str = r#"apiVersion: v1 kind: ServiceAccount metadata: name: socialweb-computer-kcp-readonly namespace: default --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: socialweb-computer-kcp-readonly rules: - apiGroups: ["*"] resources: ["*"] verbs: ["get", "list", "watch"] - apiGroups: [""] resources: ["pods/log", "pods/status", "namespaces/status", "nodes/status"] verbs: ["get", "list"] - nonResourceURLs: ["/api", "/apis", "/version", "/healthz", "/livez", "/readyz", "/openapi/*"] verbs: ["get"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: name: socialweb-computer-kcp-readonly roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole name: socialweb-computer-kcp-readonly subjects: - kind: ServiceAccount name: socialweb-computer-kcp-readonly namespace: default "#; #[derive(Clone, Debug)] pub struct ReadonlyCredential { pub kubeconfig: String, pub token_expires_in_seconds: u64, } pub async fn ensure_readonly_credential( runner: &Runner, kcp: &KcpInfo, workspace: &str, admin: &AdminConfig, ttl_seconds: u64, ) -> Result { let server = workspace_url(&kcp.server, workspace); let apply_args = vec![ "--kubeconfig".to_string(), kcp.kubeconfig.clone(), "--server".to_string(), server.clone(), "--request-timeout=30s".to_string(), "apply".to_string(), "-f".to_string(), "-".to_string(), ]; runner .kubectl(&apply_args, Some(READONLY_MANIFESTS)) .await .map_err(|error| { format!("failed to ensure the read-only service account in {workspace}: {error}") })?; let token_args = vec![ "--kubeconfig".to_string(), kcp.kubeconfig.clone(), "--server".to_string(), server.clone(), "--request-timeout=30s".to_string(), "create".to_string(), "token".to_string(), READONLY_SERVICE_ACCOUNT.to_string(), "-n".to_string(), "default".to_string(), format!("--duration={ttl_seconds}s"), ]; let token = runner .kubectl(&token_args, None) .await .map_err(|error| format!("failed to mint the read-only token in {workspace}: {error}"))? .trim() .to_string(); if token.is_empty() { return Err(format!( "kubectl create token returned an empty token in {workspace}" )); } let kubeconfig = token_kubeconfig(&server, admin, &token); let path = write_token_kubeconfig(runner, kcp, workspace, &kubeconfig).await?; Ok(ReadonlyCredential { kubeconfig: path, token_expires_in_seconds: ttl_seconds, }) } fn token_kubeconfig(server: &str, admin: &AdminConfig, token: &str) -> String { let mut cluster = json!({ "server": server, }); if let Some(ca) = &admin.ca_data { cluster["certificate-authority-data"] = json!(ca); } if let Some(name) = &admin.tls_server_name { cluster["tls-server-name"] = json!(name); } json!({ "apiVersion": "v1", "kind": "Config", "clusters": [{"name": "kcp", "cluster": cluster}], "users": [{"name": "readonly", "user": {"token": token}}], "contexts": [{"name": "readonly", "context": {"cluster": "kcp", "user": "readonly"}}], "current-context": "readonly", }) .to_string() } fn local_token_dir() -> std::path::PathBuf { #[cfg(unix)] { let uid = unsafe { libc::geteuid() }; std::env::temp_dir().join(format!("{TOKEN_DIR}-{uid}")) } #[cfg(not(unix))] { std::env::temp_dir().join(TOKEN_DIR) } } async fn write_token_kubeconfig( runner: &Runner, kcp: &KcpInfo, workspace: &str, contents: &str, ) -> Result { let name = token_file_name(kcp, workspace); if runner.ssh_host().is_some() { let directory = format!("/tmp/{TOKEN_DIR}"); let path = format!("{directory}/{name}.kubeconfig"); let script = format!( "umask 077; mkdir -p {} && cat > {}", shell_quote(&directory), shell_quote(&path) ); runner .shell_with_stdin(&script, Some(contents)) .await .map_err(|error| { format!("failed to write the read-only kubeconfig on the host: {error}") })?; return Ok(path); } let path = local_token_dir().join(format!("{name}.kubeconfig")); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent) .map_err(|error| format!("failed to create {}: {error}", parent.display()))?; } std::fs::write(&path, contents) .map_err(|error| format!("failed to write {}: {error}", path.display()))?; set_private_permissions(&path)?; Ok(path.display().to_string()) } fn token_file_name(kcp: &KcpInfo, workspace: &str) -> String { let mut hasher = DefaultHasher::new(); kcp.id.hash(&mut hasher); workspace.hash(&mut hasher); let sequence = TOKEN_FILE_SEQUENCE.fetch_add(1, Ordering::Relaxed); format!("{}-{sequence}-{:016x}", std::process::id(), hasher.finish()) } #[cfg(unix)] fn set_private_permissions(path: &std::path::Path) -> Result<(), String> { use std::os::unix::fs::PermissionsExt; let permissions = std::fs::Permissions::from_mode(0o600); std::fs::set_permissions(path, permissions) .map_err(|error| format!("failed to chmod {}: {error}", path.display())) } #[cfg(not(unix))] fn set_private_permissions(_path: &std::path::Path) -> Result<(), String> { Ok(()) } #[cfg(test)] mod tests { use super::{token_kubeconfig, AdminConfig}; #[test] fn token_kubeconfig_has_no_client_credentials() { let admin = AdminConfig { ca_data: Some("Y2E=".to_string()), tls_server_name: Some("127.0.0.1".to_string()), }; let rendered = token_kubeconfig("https://127.0.0.1:33059/clusters/root:app", &admin, "tok"); assert!(rendered.contains("\"token\":\"tok\"")); assert!(rendered.contains("certificate-authority-data")); assert!(rendered.contains("tls-server-name")); assert!(!rendered.contains("client-certificate")); assert!(!rendered.contains("client-key")); } }