diff --git a/interface/src/api/client.ts b/interface/src/api/client.ts index 881581691..df511a52f 100644 --- a/interface/src/api/client.ts +++ b/interface/src/api/client.ts @@ -1230,6 +1230,8 @@ export interface GlobalSettingsResponse { api_bind: string; worker_log_mode: string; opencode: OpenCodeSettings; + ssh_enabled: boolean; + ssh_port: number; } export interface GlobalSettingsUpdate { @@ -1239,6 +1241,8 @@ export interface GlobalSettingsUpdate { api_bind?: string; worker_log_mode?: string; opencode?: OpenCodeSettingsUpdate; + ssh_enabled?: boolean; + ssh_port?: number; } export interface GlobalSettingsUpdateResponse { @@ -1247,6 +1251,13 @@ export interface GlobalSettingsUpdateResponse { requires_restart: boolean; } +export interface SshStatusResponse { + enabled: boolean; + running: boolean; + port: number; + has_authorized_key: boolean; +} + export interface RawConfigResponse { content: string; } @@ -1955,6 +1966,9 @@ export const api = { return response.json() as Promise; }, + // SSH API + sshStatus: () => fetchJson("/ssh/status"), + // Raw config API rawConfig: () => fetchJson("/config/raw"), updateRawConfig: async (content: string) => { diff --git a/interface/src/routes/Settings.tsx b/interface/src/routes/Settings.tsx index 9ea2df47d..b163629f5 100644 --- a/interface/src/routes/Settings.tsx +++ b/interface/src/routes/Settings.tsx @@ -13,7 +13,7 @@ import { parse as parseToml } from "smol-toml"; import { useTheme, THEMES, type ThemeId } from "@/hooks/useTheme"; import { Markdown } from "@/components/Markdown"; -type SectionId = "appearance" | "providers" | "channels" | "api-keys" | "secrets" | "server" | "opencode" | "worker-logs" | "updates" | "config-file" | "changelog"; +type SectionId = "appearance" | "providers" | "channels" | "api-keys" | "secrets" | "server" | "ssh" | "opencode" | "worker-logs" | "updates" | "config-file" | "changelog"; const SECTIONS = [ { @@ -46,6 +46,12 @@ const SECTIONS = [ group: "system" as const, description: "API server configuration", }, + { + id: "ssh" as const, + label: "SSH", + group: "system" as const, + description: "SSH server access", + }, { id: "opencode" as const, label: "OpenCode", @@ -309,7 +315,7 @@ export function Settings() { queryKey: ["global-settings"], queryFn: api.globalSettings, staleTime: 5_000, - enabled: activeSection === "api-keys" || activeSection === "server" || activeSection === "opencode" || activeSection === "worker-logs", + enabled: activeSection === "api-keys" || activeSection === "server" || activeSection === "ssh" || activeSection === "opencode" || activeSection === "worker-logs", }); const updateMutation = useMutation({ @@ -700,6 +706,8 @@ export function Settings() { ) : activeSection === "server" ? ( + ) : activeSection === "ssh" ? ( + ) : activeSection === "opencode" ? ( ) : activeSection === "worker-logs" ? ( @@ -1885,6 +1893,138 @@ function ServerSection({ settings, isLoading }: GlobalSettingsSectionProps) { ); } +function SshSection({ settings, isLoading }: GlobalSettingsSectionProps) { + const queryClient = useQueryClient(); + const [sshEnabled, setSshEnabled] = useState(settings?.ssh_enabled ?? false); + const [sshPort, setSshPort] = useState(settings?.ssh_port.toString() ?? "22"); + const [message, setMessage] = useState<{ text: string; type: "success" | "error" } | null>(null); + + const { data: sshStatus } = useQuery({ + queryKey: ["ssh-status"], + queryFn: api.sshStatus, + refetchInterval: 5_000, + }); + + useEffect(() => { + if (settings) { + setSshEnabled(settings.ssh_enabled); + setSshPort(settings.ssh_port.toString()); + } + }, [settings]); + + const updateMutation = useMutation({ + mutationFn: api.updateGlobalSettings, + onSuccess: (result) => { + if (result.success) { + setMessage({ text: result.message, type: "success" }); + queryClient.invalidateQueries({ queryKey: ["global-settings"] }); + queryClient.invalidateQueries({ queryKey: ["ssh-status"] }); + } else { + setMessage({ text: result.message, type: "error" }); + } + }, + onError: (error) => { + setMessage({ text: `Failed: ${error.message}`, type: "error" }); + }, + }); + + const handleSave = () => { + const port = parseInt(sshPort, 10); + if (isNaN(port) || port < 1 || port > 65535) { + setMessage({ text: "Port must be between 1 and 65535", type: "error" }); + return; + } + updateMutation.mutate({ + ssh_enabled: sshEnabled, + ssh_port: port, + }); + }; + + return ( +
+
+

SSH Server

+

+ Enable SSH access to this instance. Requires an authorized public key to be set by the hosting platform. +

+
+ + {isLoading ? ( +
+
+ Loading settings... +
+ ) : ( +
+ {/* Status indicator */} + {sshStatus && ( +
+
+
+
+ + {sshStatus.running ? "Running" : "Stopped"} + + {sshStatus.running && !sshStatus.has_authorized_key && ( +

+ No authorized key configured. SSH connections will be rejected. +

+ )} +
+
+
+ )} + + {/* Enable SSH toggle */} +
+
+
+ Enable SSH Server +

+ Start an sshd process on this instance +

+
+ +
+
+ + {/* Port input */} +
+ +
+ + +
+ )} + + {message && ( +
+ {message.text} +
+ )} +
+ ); +} + function WorkerLogsSection({ settings, isLoading }: GlobalSettingsSectionProps) { const queryClient = useQueryClient(); const [logMode, setLogMode] = useState(settings?.worker_log_mode ?? "errors_only"); diff --git a/src/api.rs b/src/api.rs index b9eca7e90..6905e5ff9 100644 --- a/src/api.rs +++ b/src/api.rs @@ -23,6 +23,7 @@ mod secrets; mod server; mod settings; mod skills; +mod ssh; mod state; mod system; mod tasks; diff --git a/src/api/server.rs b/src/api/server.rs index d2c89b092..e82996d17 100644 --- a/src/api/server.rs +++ b/src/api/server.rs @@ -3,8 +3,8 @@ use super::state::ApiState; use super::{ agents, bindings, channels, config, cortex, cron, ingest, links, mcp, memories, messaging, - models, opencode_proxy, projects, providers, secrets, settings, skills, system, tasks, tools, - webchat, workers, + models, opencode_proxy, projects, providers, secrets, settings, skills, ssh, system, tasks, + tools, webchat, workers, }; use axum::Json; @@ -242,6 +242,11 @@ pub async fn start_http_server( .route("/changelog", get(settings::changelog)) .route("/webchat/send", post(webchat::webchat_send)) .route("/webchat/history", get(webchat::webchat_history)) + .route("/ssh/status", get(ssh::ssh_status)) + .route( + "/ssh/authorized-key", + put(ssh::set_authorized_key).delete(ssh::clear_authorized_keys), + ) .route("/links", get(links::list_links).post(links::create_link)) .route( "/links/{from}/{to}", diff --git a/src/api/settings.rs b/src/api/settings.rs index d06b27730..e862710dc 100644 --- a/src/api/settings.rs +++ b/src/api/settings.rs @@ -14,6 +14,8 @@ pub(super) struct GlobalSettingsResponse { api_bind: String, worker_log_mode: String, opencode: OpenCodeSettingsResponse, + ssh_enabled: bool, + ssh_port: u16, } #[derive(Serialize)] @@ -41,6 +43,8 @@ pub(super) struct GlobalSettingsUpdate { api_bind: Option, worker_log_mode: Option, opencode: Option, + ssh_enabled: Option, + ssh_port: Option, } #[derive(Deserialize)] @@ -88,129 +92,154 @@ pub(super) async fn get_global_settings( ) -> Result, StatusCode> { let config_path = state.config_path.read().await.clone(); - let (brave_search_key, api_enabled, api_port, api_bind, worker_log_mode, opencode) = - if config_path.exists() { - let content = tokio::fs::read_to_string(&config_path) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - let doc: toml_edit::DocumentMut = content - .parse() - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - let brave_search = doc - .get("defaults") - .and_then(|d| d.get("brave_search_key")) - .and_then(|v| v.as_str()) - .and_then(|s| { - if let Some(var) = s.strip_prefix("env:") { - std::env::var(var).ok() - } else { - Some(s.to_string()) - } - }); - - let api_enabled = doc - .get("api") - .and_then(|a| a.get("enabled")) + let ( + brave_search_key, + api_enabled, + api_port, + api_bind, + worker_log_mode, + opencode, + ssh_enabled, + ssh_port, + ) = if config_path.exists() { + let content = tokio::fs::read_to_string(&config_path) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let doc: toml_edit::DocumentMut = content + .parse() + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let brave_search = doc + .get("defaults") + .and_then(|d| d.get("brave_search_key")) + .and_then(|v| v.as_str()) + .and_then(|s| { + if let Some(var) = s.strip_prefix("env:") { + std::env::var(var).ok() + } else { + Some(s.to_string()) + } + }); + + let api_enabled = doc + .get("api") + .and_then(|a| a.get("enabled")) + .and_then(|v| v.as_bool()) + .unwrap_or(true); + + let api_port = doc + .get("api") + .and_then(|a| a.get("port")) + .and_then(|v| v.as_integer()) + .and_then(|i| u16::try_from(i).ok()) + .unwrap_or(19898); + + let api_bind = doc + .get("api") + .and_then(|a| a.get("bind")) + .and_then(|v| v.as_str()) + .unwrap_or("127.0.0.1") + .to_string(); + + let worker_log_mode = doc + .get("defaults") + .and_then(|d| d.get("worker_log_mode")) + .and_then(|v| v.as_str()) + .unwrap_or("errors_only") + .to_string(); + + let opencode_table = doc.get("defaults").and_then(|d| d.get("opencode")); + let opencode_perms = opencode_table.and_then(|o| o.get("permissions")); + let opencode = OpenCodeSettingsResponse { + enabled: opencode_table + .and_then(|o| o.get("enabled")) .and_then(|v| v.as_bool()) - .unwrap_or(true); - - let api_port = doc - .get("api") - .and_then(|a| a.get("port")) - .and_then(|v| v.as_integer()) - .and_then(|i| u16::try_from(i).ok()) - .unwrap_or(19898); - - let api_bind = doc - .get("api") - .and_then(|a| a.get("bind")) - .and_then(|v| v.as_str()) - .unwrap_or("127.0.0.1") - .to_string(); - - let worker_log_mode = doc - .get("defaults") - .and_then(|d| d.get("worker_log_mode")) + .unwrap_or(false), + path: opencode_table + .and_then(|o| o.get("path")) .and_then(|v| v.as_str()) - .unwrap_or("errors_only") - .to_string(); - - let opencode_table = doc.get("defaults").and_then(|d| d.get("opencode")); - let opencode_perms = opencode_table.and_then(|o| o.get("permissions")); - let opencode = OpenCodeSettingsResponse { - enabled: opencode_table - .and_then(|o| o.get("enabled")) - .and_then(|v| v.as_bool()) - .unwrap_or(false), - path: opencode_table - .and_then(|o| o.get("path")) + .unwrap_or("opencode") + .to_string(), + max_servers: opencode_table + .and_then(|o| o.get("max_servers")) + .and_then(|v| v.as_integer()) + .and_then(|i| usize::try_from(i).ok()) + .unwrap_or(5), + server_startup_timeout_secs: opencode_table + .and_then(|o| o.get("server_startup_timeout_secs")) + .and_then(|v| v.as_integer()) + .and_then(|i| u64::try_from(i).ok()) + .unwrap_or(30), + max_restart_retries: opencode_table + .and_then(|o| o.get("max_restart_retries")) + .and_then(|v| v.as_integer()) + .and_then(|i| u32::try_from(i).ok()) + .unwrap_or(5), + permissions: OpenCodePermissionsResponse { + edit: opencode_perms + .and_then(|p| p.get("edit")) + .and_then(|v| v.as_str()) + .unwrap_or("allow") + .to_string(), + bash: opencode_perms + .and_then(|p| p.get("bash")) + .and_then(|v| v.as_str()) + .unwrap_or("allow") + .to_string(), + webfetch: opencode_perms + .and_then(|p| p.get("webfetch")) .and_then(|v| v.as_str()) - .unwrap_or("opencode") + .unwrap_or("allow") .to_string(), - max_servers: opencode_table - .and_then(|o| o.get("max_servers")) - .and_then(|v| v.as_integer()) - .and_then(|i| usize::try_from(i).ok()) - .unwrap_or(5), - server_startup_timeout_secs: opencode_table - .and_then(|o| o.get("server_startup_timeout_secs")) - .and_then(|v| v.as_integer()) - .and_then(|i| u64::try_from(i).ok()) - .unwrap_or(30), - max_restart_retries: opencode_table - .and_then(|o| o.get("max_restart_retries")) - .and_then(|v| v.as_integer()) - .and_then(|i| u32::try_from(i).ok()) - .unwrap_or(5), + }, + }; + + let ssh_enabled = doc + .get("ssh") + .and_then(|s| s.get("enabled")) + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + let ssh_port = doc + .get("ssh") + .and_then(|s| s.get("port")) + .and_then(|v| v.as_integer()) + .and_then(|i| u16::try_from(i).ok()) + .unwrap_or(22); + + ( + brave_search, + api_enabled, + api_port, + api_bind, + worker_log_mode, + opencode, + ssh_enabled, + ssh_port, + ) + } else { + ( + None, + true, + 19898, + "127.0.0.1".to_string(), + "errors_only".to_string(), + OpenCodeSettingsResponse { + enabled: false, + path: "opencode".to_string(), + max_servers: 5, + server_startup_timeout_secs: 30, + max_restart_retries: 5, permissions: OpenCodePermissionsResponse { - edit: opencode_perms - .and_then(|p| p.get("edit")) - .and_then(|v| v.as_str()) - .unwrap_or("allow") - .to_string(), - bash: opencode_perms - .and_then(|p| p.get("bash")) - .and_then(|v| v.as_str()) - .unwrap_or("allow") - .to_string(), - webfetch: opencode_perms - .and_then(|p| p.get("webfetch")) - .and_then(|v| v.as_str()) - .unwrap_or("allow") - .to_string(), - }, - }; - - ( - brave_search, - api_enabled, - api_port, - api_bind, - worker_log_mode, - opencode, - ) - } else { - ( - None, - true, - 19898, - "127.0.0.1".to_string(), - "errors_only".to_string(), - OpenCodeSettingsResponse { - enabled: false, - path: "opencode".to_string(), - max_servers: 5, - server_startup_timeout_secs: 30, - max_restart_retries: 5, - permissions: OpenCodePermissionsResponse { - edit: "allow".to_string(), - bash: "allow".to_string(), - webfetch: "allow".to_string(), - }, + edit: "allow".to_string(), + bash: "allow".to_string(), + webfetch: "allow".to_string(), }, - ) - }; + }, + false, + 22, + ) + }; Ok(Json(GlobalSettingsResponse { brave_search_key, @@ -219,6 +248,8 @@ pub(super) async fn get_global_settings( api_bind, worker_log_mode, opencode, + ssh_enabled, + ssh_port, })) } @@ -329,10 +360,78 @@ pub(super) async fn update_global_settings( } } + if let Some(port) = request.ssh_port { + if port == 0 { + return Ok(Json(GlobalSettingsUpdateResponse { + success: false, + message: "SSH port must be between 1 and 65535".to_string(), + requires_restart: false, + })); + } + } + + if request.ssh_enabled.is_some() || request.ssh_port.is_some() { + if doc.get("ssh").is_none() { + doc["ssh"] = toml_edit::Item::Table(toml_edit::Table::new()); + } + + if let Some(enabled) = request.ssh_enabled { + doc["ssh"]["enabled"] = toml_edit::value(enabled); + } + if let Some(port) = request.ssh_port { + doc["ssh"]["port"] = toml_edit::value(i64::from(port)); + } + } + tokio::fs::write(&config_path, doc.to_string()) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + // Apply SSH lifecycle changes when enabled or port is updated. + if request.ssh_enabled.is_some() || request.ssh_port.is_some() { + let ssh_enabled = doc + .get("ssh") + .and_then(|s| s.get("enabled")) + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let ssh_port = doc + .get("ssh") + .and_then(|s| s.get("port")) + .and_then(|v| v.as_integer()) + .and_then(|i| u16::try_from(i).ok()) + .unwrap_or(22); + + let mut ssh_manager = state.ssh_manager.lock().await; + if ssh_enabled { + // Stop first so a port change takes effect on restart. + if ssh_manager.is_running() { + if let Err(error) = ssh_manager.stop().await { + tracing::error!(%error, "failed to stop sshd before restart"); + return Ok(Json(GlobalSettingsUpdateResponse { + success: false, + message: "Failed to apply SSH settings".to_string(), + requires_restart: false, + })); + } + } + if let Err(error) = ssh_manager.start(ssh_port).await { + tracing::error!(%error, "failed to start sshd after settings update"); + return Ok(Json(GlobalSettingsUpdateResponse { + success: false, + message: "Failed to apply SSH settings".to_string(), + requires_restart: false, + })); + } + } else if let Err(error) = ssh_manager.stop().await { + tracing::error!(%error, "failed to stop sshd after settings update"); + return Ok(Json(GlobalSettingsUpdateResponse { + success: false, + message: "Failed to apply SSH settings".to_string(), + requires_restart: false, + })); + } + } + let reload_path = config_path.clone(); match tokio::task::spawn_blocking(move || crate::config::Config::load_from_path(&reload_path)) .await diff --git a/src/api/ssh.rs b/src/api/ssh.rs new file mode 100644 index 000000000..8d149ff0c --- /dev/null +++ b/src/api/ssh.rs @@ -0,0 +1,136 @@ +use super::state::ApiState; + +use axum::Json; +use axum::extract::State; +use axum::http::StatusCode; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +#[derive(Serialize)] +pub(super) struct SshStatusResponse { + enabled: bool, + running: bool, + port: u16, + has_authorized_key: bool, +} + +#[derive(Deserialize)] +pub(super) struct SetAuthorizedKeyRequest { + public_key: String, +} + +#[derive(Serialize)] +pub(super) struct SetAuthorizedKeyResponse { + success: bool, + message: String, +} + +/// GET /api/ssh/status — returns current SSH server state. +pub(super) async fn ssh_status( + State(state): State>, +) -> Result, StatusCode> { + let config_path = state.config_path.read().await.clone(); + + let (enabled, port) = if config_path.exists() { + let content = tokio::fs::read_to_string(&config_path) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let doc: toml_edit::DocumentMut = content + .parse() + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let enabled = doc + .get("ssh") + .and_then(|s| s.get("enabled")) + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let port = doc + .get("ssh") + .and_then(|s| s.get("port")) + .and_then(|v| v.as_integer()) + .and_then(|i| u16::try_from(i).ok()) + .unwrap_or(22); + (enabled, port) + } else { + (false, 22) + }; + + let mut manager = state.ssh_manager.lock().await; + let running = manager.is_running(); + let has_authorized_key = manager.has_authorized_key(); + + Ok(Json(SshStatusResponse { + enabled, + running, + port, + has_authorized_key, + })) +} + +/// PUT /api/ssh/authorized-key — set the authorized public key for SSH access. +pub(super) async fn set_authorized_key( + State(state): State>, + Json(request): Json, +) -> Result, StatusCode> { + let public_key = request.public_key.trim(); + if public_key.contains('\n') || public_key.contains('\r') { + return Ok(Json(SetAuthorizedKeyResponse { + success: false, + message: "public_key must be a single line".to_string(), + })); + } + if public_key.is_empty() { + return Ok(Json(SetAuthorizedKeyResponse { + success: false, + message: "public_key is required".to_string(), + })); + } + + // Validate minimal SSH public key structure: " [comment...]" + let parts: Vec<&str> = public_key.split_whitespace().collect(); + let key_type = parts.first().copied().unwrap_or_default(); + let key_body = parts.get(1).copied().unwrap_or_default(); + let valid_type = key_type.starts_with("ssh-") + || key_type.starts_with("ecdsa-") + || key_type.starts_with("sk-ssh-") + || key_type.starts_with("sk-ecdsa-"); + if parts.len() < 2 || !valid_type || key_body.is_empty() { + return Ok(Json(SetAuthorizedKeyResponse { + success: false, + message: "Invalid SSH public key format".to_string(), + })); + } + + let manager = state.ssh_manager.lock().await; + if let Err(error) = manager.set_authorized_key(public_key).await { + tracing::error!(%error, "failed to set SSH authorized key"); + return Ok(Json(SetAuthorizedKeyResponse { + success: false, + message: "Failed to write authorized key".to_string(), + })); + } + + Ok(Json(SetAuthorizedKeyResponse { + success: true, + message: "Authorized key updated".to_string(), + })) +} + +/// DELETE /api/ssh/authorized-key — remove all authorized keys. +pub(super) async fn clear_authorized_keys( + State(state): State>, +) -> Result, StatusCode> { + let manager = state.ssh_manager.lock().await; + if let Err(error) = manager.clear_authorized_keys().await { + tracing::error!(%error, "failed to clear SSH authorized keys"); + return Ok(Json(SetAuthorizedKeyResponse { + success: false, + message: "Failed to clear authorized keys".to_string(), + })); + } + + Ok(Json(SetAuthorizedKeyResponse { + success: true, + message: "Authorized keys cleared".to_string(), + })) +} diff --git a/src/api/state.rs b/src/api/state.rs index 77ec725b1..b9395e353 100644 --- a/src/api/state.rs +++ b/src/api/state.rs @@ -119,6 +119,8 @@ pub struct ApiState { pub agent_groups: ArcSwap>, /// Org-level humans for the topology UI. pub agent_humans: ArcSwap>, + /// SSH server manager. + pub ssh_manager: tokio::sync::Mutex, /// Live transcript cache for running workers. Accumulates `TranscriptStep`s /// from `ToolStarted`/`ToolCompleted` events so that page refreshes can /// recover the transcript without waiting for the worker to complete. @@ -307,6 +309,9 @@ impl ApiState { agent_links: ArcSwap::from_pointee(Vec::new()), agent_groups: ArcSwap::from_pointee(Vec::new()), agent_humans: ArcSwap::from_pointee(Vec::new()), + ssh_manager: tokio::sync::Mutex::new(crate::ssh::SshManager::new( + std::path::Path::new(""), + )), live_worker_transcripts: Arc::new(RwLock::new(HashMap::new())), } } @@ -785,6 +790,11 @@ impl ApiState { self.agent_humans.store(Arc::new(humans)); } + /// Replace the SSH manager with one configured for the correct instance directory. + pub async fn set_ssh_manager(&self, manager: crate::ssh::SshManager) { + *self.ssh_manager.lock().await = manager; + } + /// Send an event to all SSE subscribers. pub fn send_event(&self, event: ApiEvent) { let _ = self.event_tx.send(event); diff --git a/src/config/load.rs b/src/config/load.rs index 78a68d178..72baa9a12 100644 --- a/src/config/load.rs +++ b/src/config/load.rs @@ -15,8 +15,8 @@ use super::{ DiscordInstanceConfig, EmailConfig, EmailInstanceConfig, GroupDef, HumanDef, IngestionConfig, LinkDef, LlmConfig, McpServerConfig, McpTransport, MemoryPersistenceConfig, MessagingConfig, MetricsConfig, OpenCodeConfig, ProjectsConfig, ProviderConfig, SlackCommandConfig, SlackConfig, - SlackInstanceConfig, TelegramConfig, TelegramInstanceConfig, TelemetryConfig, TwitchConfig, - TwitchInstanceConfig, WarmupConfig, WebhookConfig, normalize_adapter, + SlackInstanceConfig, SshConfig, TelegramConfig, TelegramInstanceConfig, TelemetryConfig, + TwitchConfig, TwitchInstanceConfig, WarmupConfig, WebhookConfig, normalize_adapter, validate_named_messaging_adapters, }; use crate::error::{ConfigError, Result}; @@ -76,6 +76,7 @@ const KNOWN_TOP_LEVEL_KEYS: &[&str] = &[ "messaging", "bindings", "api", + "ssh", "metrics", "telemetry", ]; @@ -839,6 +840,7 @@ impl Config { messaging: MessagingConfig::default(), bindings: Vec::new(), api, + ssh: SshConfig::default(), metrics: MetricsConfig::default(), telemetry: TelemetryConfig { otlp_endpoint: std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT").ok(), @@ -2131,6 +2133,14 @@ impl Config { auth_token: toml.api.auth_token.as_deref().and_then(resolve_env_value), }; + if toml.ssh.enabled && toml.ssh.port == 0 { + return Err(ConfigError::Invalid("ssh.port must be between 1 and 65535".into()).into()); + } + let ssh = SshConfig { + enabled: toml.ssh.enabled, + port: toml.ssh.port, + }; + let metrics = MetricsConfig { enabled: toml.metrics.enabled, port: toml.metrics.port, @@ -2221,6 +2231,7 @@ impl Config { messaging, bindings, api, + ssh, metrics, telemetry, }) diff --git a/src/config/toml_schema.rs b/src/config/toml_schema.rs index 1ae50a64b..ed6bf7f23 100644 --- a/src/config/toml_schema.rs +++ b/src/config/toml_schema.rs @@ -24,6 +24,8 @@ pub(super) struct TomlConfig { #[serde(default)] pub(super) api: TomlApiConfig, #[serde(default)] + pub(super) ssh: TomlSshConfig, + #[serde(default)] pub(super) metrics: TomlMetricsConfig, #[serde(default)] pub(super) telemetry: TomlTelemetryConfig, @@ -114,6 +116,27 @@ pub(super) fn hosted_api_bind(bind: String) -> String { } } +#[derive(Deserialize)] +pub(super) struct TomlSshConfig { + #[serde(default)] + pub(super) enabled: bool, + #[serde(default = "default_ssh_port")] + pub(super) port: u16, +} + +impl Default for TomlSshConfig { + fn default() -> Self { + Self { + enabled: false, + port: default_ssh_port(), + } + } +} + +pub(super) fn default_ssh_port() -> u16 { + 22 +} + #[derive(Deserialize)] pub(super) struct TomlMetricsConfig { #[serde(default)] diff --git a/src/config/types.rs b/src/config/types.rs index 577101678..5827b1523 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -55,6 +55,8 @@ pub struct Config { pub bindings: Vec, /// HTTP API server configuration. pub api: ApiConfig, + /// SSH server configuration. + pub ssh: SshConfig, /// Prometheus metrics endpoint configuration. pub metrics: MetricsConfig, /// OpenTelemetry export configuration. @@ -138,6 +140,24 @@ impl Default for ApiConfig { } } +/// SSH server configuration. +#[derive(Debug, Clone)] +pub struct SshConfig { + /// Whether the SSH server is enabled. + pub enabled: bool, + /// Port for sshd to listen on. + pub port: u16, +} + +impl Default for SshConfig { + fn default() -> Self { + Self { + enabled: false, + port: 22, + } + } +} + /// Prometheus metrics endpoint configuration. #[derive(Debug, Clone)] pub struct MetricsConfig { diff --git a/src/lib.rs b/src/lib.rs index c13adaf87..5004d476e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -25,6 +25,7 @@ pub mod secrets; pub mod self_awareness; pub mod settings; pub mod skills; +pub mod ssh; pub mod tasks; #[cfg(feature = "metrics")] pub mod telemetry; diff --git a/src/main.rs b/src/main.rs index fcea9944d..1dd2c392e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1387,6 +1387,17 @@ async fn run( None }; + // Initialize SSH manager before HTTP server so /api/ssh/* endpoints have a + // registered manager from the moment the server starts accepting requests. + let ssh_manager = spacebot::ssh::SshManager::new(&config.instance_dir); + api_state.set_ssh_manager(ssh_manager).await; + if config.ssh.enabled { + let mut ssh = api_state.ssh_manager.lock().await; + if let Err(error) = ssh.start(config.ssh.port).await { + tracing::error!(%error, "failed to start sshd"); + } + } + let _http_handle = if config.api.enabled { // IPv6 addresses need brackets when combined with port: [::]:19898 let raw_bind = config @@ -2258,6 +2269,11 @@ async fn run( messaging_manager.shutdown().await; + // Stop sshd if running + if let Err(error) = api_state.ssh_manager.lock().await.stop().await { + tracing::warn!(%error, "failed to stop sshd during shutdown"); + } + for (agent_id, agent) in agents { tracing::info!(%agent_id, "shutting down agent"); agent.deps.mcp_manager.disconnect_all().await; diff --git a/src/ssh.rs b/src/ssh.rs new file mode 100644 index 000000000..a268fdb08 --- /dev/null +++ b/src/ssh.rs @@ -0,0 +1,173 @@ +//! SSH server management. +//! +//! Manages the lifecycle of an sshd child process. When enabled via config, +//! generates host keys (persisted on the data volume) and starts sshd in +//! foreground mode as a tokio-managed child process. + +use anyhow::{Context, Result}; +use std::path::{Path, PathBuf}; +use tokio::process::{Child, Command}; + +/// Manages an sshd child process. +pub struct SshManager { + child: Option, + ssh_dir: PathBuf, +} + +impl SshManager { + pub fn new(instance_dir: &Path) -> Self { + Self { + child: None, + ssh_dir: instance_dir.join("ssh"), + } + } + + /// Start sshd if not already running. Generates host keys on first call. + pub async fn start(&mut self, port: u16) -> Result<()> { + if self.is_running() { + return Ok(()); + } + + tokio::fs::create_dir_all(&self.ssh_dir) + .await + .context("failed to create ssh directory")?; + + // Generate host key if missing + let host_key = self.ssh_dir.join("ssh_host_ed25519_key"); + let host_key_str = host_key + .to_str() + .context("ssh host key path is not valid UTF-8")?; + if !host_key.exists() { + let output = Command::new("ssh-keygen") + .args(["-t", "ed25519", "-f", host_key_str, "-N", "", "-q"]) + .output() + .await + .context("failed to run ssh-keygen")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("ssh-keygen failed: {stderr}"); + } + tracing::info!("generated SSH host key"); + } + + // sshd requires /run/sshd to exist + if let Err(error) = tokio::fs::create_dir_all("/run/sshd").await { + tracing::warn!(%error, "could not create /run/sshd (may not have permissions)"); + } + + let authorized_keys = self.ssh_dir.join("authorized_keys"); + let authorized_keys_str = authorized_keys + .to_str() + .context("authorized_keys path is not valid UTF-8")?; + let authorized_keys_option = format!("AuthorizedKeysFile={authorized_keys_str}"); + let port_option = format!("Port={port}"); + let child = Command::new("/usr/sbin/sshd") + .args([ + "-D", // foreground + "-e", // log to stderr + "-h", + host_key_str, + "-o", + &port_option, + "-o", + "PasswordAuthentication=no", + "-o", + "KbdInteractiveAuthentication=no", + "-o", + "PermitRootLogin=prohibit-password", + "-o", + &authorized_keys_option, + "-o", + "ListenAddress=[::]", + ]) + .kill_on_drop(true) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::inherit()) + .spawn() + .context("failed to start sshd")?; + + tracing::info!(port, "sshd started"); + self.child = Some(child); + Ok(()) + } + + /// Stop sshd if running. + pub async fn stop(&mut self) -> Result<()> { + if let Some(mut child) = self.child.take() { + child.kill().await.context("failed to kill sshd")?; + child.wait().await.context("failed to wait for sshd")?; + tracing::info!("sshd stopped"); + } + Ok(()) + } + + /// Write an authorized public key for root access. + pub async fn set_authorized_key(&self, pubkey: &str) -> Result<()> { + tokio::fs::create_dir_all(&self.ssh_dir) + .await + .context("failed to create ssh directory")?; + + let path = self.ssh_dir.join("authorized_keys"); + tokio::fs::write(&path, format!("{}\n", pubkey.trim())) + .await + .context("failed to write authorized_keys")?; + + // ssh requires strict permissions on authorized_keys + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let perms = std::fs::Permissions::from_mode(0o600); + tokio::fs::set_permissions(&path, perms) + .await + .context("failed to set authorized_keys permissions")?; + } + + tracing::info!("SSH authorized key updated"); + Ok(()) + } + + /// Remove all authorized keys. + pub async fn clear_authorized_keys(&self) -> Result<()> { + let path = self.ssh_dir.join("authorized_keys"); + if path.exists() { + tokio::fs::remove_file(&path) + .await + .context("failed to remove authorized_keys")?; + tracing::info!("SSH authorized keys cleared"); + } + Ok(()) + } + + /// Returns true if sshd is running. + pub fn is_running(&mut self) -> bool { + match &mut self.child { + Some(child) => match child.try_wait() { + Ok(Some(_)) => { + // Process exited + self.child = None; + false + } + Ok(None) => true, + Err(_) => { + self.child = None; + false + } + }, + None => false, + } + } + + /// Returns true if the authorized_keys file contains at least one real key + /// (skipping blank lines and comments). + pub fn has_authorized_key(&self) -> bool { + let path = self.ssh_dir.join("authorized_keys"); + std::fs::read_to_string(&path) + .map(|content| { + content + .lines() + .any(|line| !line.trim().is_empty() && !line.trim_start().starts_with('#')) + }) + .unwrap_or(false) + } +}