use crate::server::app::AppState;
use crate::server::body::{decode_uri_component, read_json_object};
use crate::server::errors::{config_failure, error, method_not_allowed};
use crate::server::routes::query::query_value;
use crate::server::routes::terminal::spawn;
use axum::body::Bytes;
use axum::extract::State;
use axum::http::{StatusCode, Uri};
use axum::response::{IntoResponse, Response};
use axum::routing::{delete, get, post};
use axum::{Json, Router};
use nomoreide_core::config::SshServerDef;
use nomoreide_core::ssh_servers::{
check_ssh_host, check_ssh_server, discover_ssh_hosts, host_provider_ssh_targets,
merge_ssh_servers, probe_ssh_server, read_remote_directory, read_remote_file,
read_remote_host_metrics, ssh_directory,
};
use nomoreide_core::ssh_setup::{
inspect_ssh_setup, resolve_ssh_setup_terminal, SshSetupAction, SshSetupTerminal,
};
use nomoreide_core::terminal::TerminalSpawnSpec;
use serde_json::{json, Value};
pub(crate) fn routes() -> Router<AppState> {
Router::new()
.route("/api/servers", get(list).post(register))
.route("/api/servers/setup/status", get(setup_status))
.route(
"/api/servers/setup/terminal",
post(setup_terminal).fallback(method_not_allowed),
)
.route(
"/api/servers/:host",
delete(remove).fallback(method_not_allowed),
)
.route(
"/api/servers/:host/files",
get(files).fallback(method_not_allowed),
)
.route(
"/api/servers/:host/file",
get(file).fallback(method_not_allowed),
)
.route(
"/api/servers/:host/probe",
post(probe).fallback(method_not_allowed),
)
.route(
"/api/servers/:host/metrics",
get(metrics).fallback(method_not_allowed),
)
.route(
"/api/servers/:host/terminal",
post(server_terminal).fallback(method_not_allowed),
)
}
#[allow(clippy::result_large_err)]
fn host_of(uri: &Uri, trailing: Option<&str>) -> Result<String, Response> {
let rest = uri.path().strip_prefix("/api/servers/").unwrap_or_default();
let raw = match trailing {
Some(suffix) => rest.strip_suffix(suffix).unwrap_or(rest),
None => rest,
};
decode_uri_component(raw.trim_end_matches('/'))
.ok_or_else(|| error(StatusCode::INTERNAL_SERVER_ERROR, "URI malformed"))
}
async fn servers(state: &AppState) -> Result<Vec<Value>, Response> {
let config = state
.config_store
.load()
.await
.map_err(|failure| config_failure(&failure))?;
let discovered = discover_ssh_hosts(&ssh_directory().join("config")).await;
Ok(merge_ssh_servers(
&config.ssh_servers,
&discovered,
&host_provider_ssh_targets(&state.config_store, &config).await,
))
}
async fn list(State(state): State<AppState>) -> Response {
match servers(&state).await {
Ok(servers) => Json(json!({ "ok": true, "servers": servers })).into_response(),
Err(response) => response,
}
}
fn optional_text(body: &Value, key: &str) -> Option<String> {
body.get(key)
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
.map(str::to_string)
}
async fn register(State(state): State<AppState>, body: Bytes) -> Response {
let payload = read_json_object(&body);
let definition = SshServerDef {
host: payload
.get("host")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
name: optional_text(&payload, "name"),
environment: optional_text(&payload, "environment"),
};
let checked = match check_ssh_server(&definition) {
Ok(checked) => checked,
Err(report) => return error(StatusCode::BAD_REQUEST, &report),
};
match state.config_store.register_ssh_server(checked).await {
Ok(config) => Json(json!({ "ok": true, "servers": config.ssh_servers })).into_response(),
Err(failure) => error(StatusCode::BAD_REQUEST, &failure.to_string()),
}
}
async fn remove(State(state): State<AppState>, uri: Uri) -> Response {
let host = match host_of(&uri, None) {
Ok(host) => host,
Err(response) => return response,
};
let target = match check_ssh_host(&host) {
Ok(target) => target,
Err(report) => return error(StatusCode::BAD_REQUEST, &report),
};
match state.config_store.remove_ssh_server(&target).await {
Ok(config) => Json(json!({ "ok": true, "servers": config.ssh_servers })).into_response(),
Err(failure) => error(StatusCode::BAD_REQUEST, &failure.to_string()),
}
}
async fn setup_status() -> Response {
let status =
inspect_ssh_setup(&ssh_directory(), &std::env::var("PATH").unwrap_or_default()).await;
Json(json!({ "ok": true, "setup": status })).into_response()
}
async fn setup_terminal(State(state): State<AppState>, body: Bytes) -> Response {
let payload = read_json_object(&body);
let Some(action) = payload
.get("action")
.and_then(Value::as_str)
.and_then(SshSetupAction::parse)
else {
return error(StatusCode::BAD_REQUEST, "Unknown SSH setup action.");
};
let destination = payload.get("host").and_then(Value::as_str);
let SshSetupTerminal { shell, args, label } =
match resolve_ssh_setup_terminal(action, destination) {
Ok(terminal) => terminal,
Err(reason) => return error(StatusCode::BAD_REQUEST, &reason),
};
spawn(
&state,
TerminalSpawnSpec {
id: state.next_session_id(),
service_name: None,
cwd: state.workspace_cwd().await,
shell: shell.into(),
args,
env: Vec::new(),
label: Some(label),
kind: Some("shell".to_string()),
provider: None,
},
)
}
async fn server_terminal(State(state): State<AppState>, uri: Uri) -> Response {
let host = match host_of(&uri, Some("/terminal")) {
Ok(host) => host,
Err(response) => return response,
};
let servers = match servers(&state).await {
Ok(servers) => servers,
Err(response) => return response,
};
let Some(server) = servers
.iter()
.find(|server| server.get("host").and_then(Value::as_str) == Some(host.as_str()))
else {
return error(
StatusCode::NOT_FOUND,
&format!("Unknown SSH server: {host}"),
);
};
let label = server
.get("name")
.and_then(Value::as_str)
.unwrap_or(host.as_str())
.to_string();
spawn(
&state,
TerminalSpawnSpec {
id: format!("ssh:{host}"),
service_name: None,
cwd: state.workspace_cwd().await,
shell: "ssh".into(),
args: vec!["-t".to_string(), host.clone()],
env: Vec::new(),
label: Some(label),
kind: Some("shell".to_string()),
provider: None,
},
)
}
fn unreachable(reason: String) -> Response {
error(StatusCode::BAD_GATEWAY, &reason)
}
async fn files(uri: Uri) -> Response {
let host = match host_of(&uri, Some("/files")) {
Ok(host) => host,
Err(response) => return response,
};
let path = query_value(&uri, "path")
.filter(|value| !value.is_empty())
.unwrap_or_else(|| ".".to_string());
let hidden = query_value(&uri, "hidden").as_deref() == Some("1");
match read_remote_directory(&host, &path, hidden).await {
Ok(directory) => Json(json!({ "ok": true, "directory": directory })).into_response(),
Err(reason) => unreachable(reason),
}
}
async fn file(uri: Uri) -> Response {
let host = match host_of(&uri, Some("/file")) {
Ok(host) => host,
Err(response) => return response,
};
let Some(path) = query_value(&uri, "path").filter(|value| !value.is_empty()) else {
return error(StatusCode::BAD_REQUEST, "path is required");
};
match read_remote_file(&host, &path).await {
Ok(file) => Json(json!({ "ok": true, "file": file })).into_response(),
Err(reason) => unreachable(reason),
}
}
async fn probe(uri: Uri) -> Response {
let host = match host_of(&uri, Some("/probe")) {
Ok(host) => host,
Err(response) => return response,
};
match probe_ssh_server(&host).await {
Ok(probe) => Json(json!({ "ok": true, "probe": probe })).into_response(),
Err(reason) => error(StatusCode::BAD_REQUEST, &reason),
}
}
async fn metrics(uri: Uri) -> Response {
let host = match host_of(&uri, Some("/metrics")) {
Ok(host) => host,
Err(response) => return response,
};
match read_remote_host_metrics(&host).await {
Ok(metrics) => Json(json!({ "ok": true, "metrics": metrics })).into_response(),
Err(reason) => unreachable(reason),
}
}