pub mod access_key;
pub mod mcp;
pub mod tools;
use hyper::StatusCode;
use serde_json::{Value, json};
use access_key::{AccessKey, KeyFormatError};
pub const MANUAL: &str = include_str!("manual.md");
pub fn login(
body: &[u8],
expected: Option<&str>,
host: Option<&str>,
scheme: &str,
) -> (StatusCode, Value) {
let raw = match extract_key(body) {
Ok(raw) => raw,
Err(fail) => return (StatusCode::BAD_REQUEST, format_error(&fail)),
};
let key = match AccessKey::decode(&raw) {
Ok(key) => key,
Err(fail) => return (StatusCode::BAD_REQUEST, format_error(&fail)),
};
if let Some(expected) = expected
&& !crate::api::http::constant_time_eq(key.token.as_bytes(), expected.as_bytes())
{
return (
StatusCode::UNAUTHORIZED,
json!({
"error": {
"code": "invalid_key",
"message": "o token de conexão está bem formado, mas o token da API \
dentro dele não é o deste servidor — ele é de outra \
instalação, ou o token foi trocado no config.toml",
}
}),
);
}
let authority = host
.map(str::trim)
.filter(|h| !h.is_empty())
.unwrap_or(&key.address);
let server = format!("{scheme}://{authority}");
(
StatusCode::OK,
json!({
"access_token": key.token,
"token_type": "Bearer",
"server": server,
"next": "Guarde access_token e mande 'Authorization: Bearer <access_token>' em toda chamada.",
"ai_agent": {
"if_you_are": "um agente de IA",
"mcp": format!("{server}/mcp"),
"manual": format!("{server}/api/agent/manual"),
"instructions": "Leia o manual com GET e o mesmo Bearer, ou conecte no MCP \
(Streamable HTTP). Tudo o que um agente pode fazer neste \
servidor está lá.",
},
}),
)
}
fn extract_key(body: &[u8]) -> Result<String, KeyFormatError> {
let texto = String::from_utf8_lossy(body);
match serde_json::from_str::<Value>(&texto) {
Ok(Value::String(s)) => Ok(s),
Ok(Value::Object(m)) => m
.get("key")
.and_then(Value::as_str)
.map(str::to_owned)
.ok_or(KeyFormatError::Empty),
_ => Ok(texto.into_owned()),
}
}
fn format_error(fail: &KeyFormatError) -> Value {
json!({
"error": {
"code": fail.code(),
"message": fail.message(),
"expected": {
"method": "POST",
"path": "/api/login",
"body": { "key": "<base64 de host:porta::token>" },
"key_format": "base64 (com ou sem padding, alfabeto padrão ou url-safe) de \
`host:porta::token`, sem esquema — ex. \
base64(\"rustploy.exemplo.com:9797::a3f9\")",
"also_accepted": "o token de conexão cru como corpo (Content-Type qualquer)",
"example": "curl -s https://host:porta/api/login -d '{\"key\":\"<token de conexão>\"}'",
"where_to_get_one": "no config.toml do daemon, [api].connection_token; na VPS: \
`rustployd access-key`; numa sessão já aberta: \
Configurações → Copiar token de conexão (GUI ou webui)",
}
}
})
}
pub fn public_address(api: &crate::platform::config::ApiConfig, fallback_host: &str) -> String {
let host = api
.domain
.as_deref()
.map(str::trim)
.filter(|d| !d.is_empty())
.unwrap_or(fallback_host);
format!("{host}:{}", api.port)
}
pub fn access_key_for(address: &str, token: Option<&str>) -> Option<String> {
let token = token.map(str::trim).filter(|t| !t.is_empty())?;
Some(
AccessKey {
address: address.trim().to_owned(),
token: token.to_owned(),
}
.encode(),
)
}
pub fn print_access_key_and_exit(config: &crate::platform::config::RustployConfig) -> ! {
let address = public_address(&config.api, &crate::bootstrap::state::outbound_ip());
match access_key_for(&address, config.api.token.as_deref()) {
Some(key) => {
println!("{key}");
std::process::exit(0);
}
None => {
eprintln!(
"sem [api].token configurado — a API está aberta sem autenticação e não \
há token de conexão a gerar. Defina api.token (ou RUSTPLOY_API_TOKEN)."
);
std::process::exit(1);
}
}
}
pub const CONNECTION_TOKEN_KEY: &str = "connection_token";
pub fn write_connection_token(
config: &crate::platform::config::RustployConfig,
) -> anyhow::Result<()> {
let Some(path) = config_file_in_use() else {
return Ok(());
};
let Some(token) = config.api.token.as_deref() else {
return Ok(());
};
let address = public_address(&config.api, &crate::bootstrap::state::outbound_ip());
let Some(key) = access_key_for(&address, Some(token)) else {
return Ok(());
};
let contents = std::fs::read_to_string(&path)?;
if let Some(updated) = with_connection_token(&contents, token, &key) {
std::fs::write(&path, updated)?;
tracing::info!(path = %path.display(), "token de conexão gravado no config.toml");
}
Ok(())
}
fn config_file_in_use() -> Option<std::path::PathBuf> {
let candidates = [
std::env::var("RUSTPLOY_CONFIG")
.ok()
.map(std::path::PathBuf::from),
Some(std::path::PathBuf::from("/etc/rustploy/config.toml")),
crate::platform::config::user_home()
.map(|home| std::path::PathBuf::from(home).join(".config/rustploy/config.toml")),
];
candidates.into_iter().flatten().find(|path| {
std::fs::read_to_string(path).ok().is_some_and(|text| {
toml::from_str::<crate::platform::config::RustployConfig>(&text).is_ok()
})
})
}
fn with_connection_token(contents: &str, api_token: &str, key: &str) -> Option<String> {
let new_line = format!(
"{CONNECTION_TOKEN_KEY} = \"{key}\" # gerado pelo rustployd a cada boot: \
base64(host:porta::token), para colar na GUI/webui"
);
let key_of = |line: &str| -> Option<String> {
let trimmed = line.trim_start();
if trimmed.starts_with('#') {
return None;
}
trimmed
.split_once('=')
.map(|(name, _)| name.trim().trim_matches('"').to_owned())
};
let mut output: Vec<String> = Vec::new();
let mut in_api = false;
let mut inserted = false;
for line in contents.lines() {
let trimmed = line.trim();
if trimmed.starts_with('[') {
in_api = trimmed == "[api]";
}
if in_api && key_of(line).as_deref() == Some(CONNECTION_TOKEN_KEY) {
continue;
}
output.push(line.to_owned());
if in_api && !inserted && key_of(line).as_deref() == Some("token") {
let value = toml::from_str::<toml::Table>(line)
.ok()
.and_then(|t| t.get("token")?.as_str().map(str::to_owned));
if value.as_deref() != Some(api_token) {
return None;
}
output.push(new_line.clone());
inserted = true;
}
}
if !inserted {
return None;
}
let mut updated = output.join("\n");
if contents.ends_with('\n') {
updated.push('\n');
}
(updated != contents).then_some(updated)
}
#[cfg(test)]
mod tests {
use super::*;
fn chave() -> String {
access_key_for("10.0.0.5:9797", Some("segredo")).unwrap()
}
#[test]
fn login_com_chave_certa_devolve_o_token_e_o_caminho_do_agente() {
let corpo = json!({ "key": chave() }).to_string();
let (status, resp) = login(corpo.as_bytes(), Some("segredo"), None, "http");
assert_eq!(status, StatusCode::OK);
assert_eq!(resp["access_token"], "segredo");
assert_eq!(resp["server"], "http://10.0.0.5:9797");
assert_eq!(resp["ai_agent"]["mcp"], "http://10.0.0.5:9797/mcp");
}
#[test]
fn login_aceita_a_chave_crua_e_string_json() {
assert_eq!(
login(chave().as_bytes(), Some("segredo"), None, "http").0,
StatusCode::OK
);
let como_string = serde_json::to_string(&chave()).unwrap();
assert_eq!(
login(como_string.as_bytes(), Some("segredo"), None, "http").0,
StatusCode::OK
);
}
#[test]
fn formato_errado_explica_o_formato_certo() {
let (status, resp) = login(br#"{"key":"segredo"}"#, Some("segredo"), None, "http");
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(resp["error"]["code"], "missing_separator");
assert_eq!(resp["error"]["expected"]["path"], "/api/login");
let (status, resp) = login(br#"{"token":"x"}"#, Some("segredo"), None, "http");
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(resp["error"]["code"], "empty_key");
}
#[test]
fn token_de_outra_instalacao_e_401_sem_ecoar_o_token() {
let (status, resp) = login(chave().as_bytes(), Some("outro"), None, "http");
assert_eq!(status, StatusCode::UNAUTHORIZED);
assert!(!resp.to_string().contains("segredo"));
}
#[test]
fn urls_do_agente_usam_o_host_pelo_qual_ele_chegou_e_o_esquema_do_listener() {
let (_, resp) = login(
chave().as_bytes(),
Some("segredo"),
Some("127.0.0.1:9797"),
"https",
);
assert_eq!(resp["server"], "https://127.0.0.1:9797");
assert_eq!(resp["ai_agent"]["mcp"], "https://127.0.0.1:9797/mcp");
}
#[test]
fn sem_token_configurado_nao_ha_chave() {
assert_eq!(access_key_for("x:1", None), None);
assert_eq!(access_key_for("x:1", Some(" ")), None);
}
#[test]
fn endereco_publico_sempre_leva_a_porta() {
let mut api = crate::platform::config::ApiConfig {
port: 443,
domain: Some("rp.exemplo.com".into()),
..Default::default()
};
assert_eq!(public_address(&api, "10.0.0.5"), "rp.exemplo.com:443");
api.domain = None;
api.port = 9797;
assert_eq!(public_address(&api, "10.0.0.5"), "10.0.0.5:9797");
}
const CONFIG: &str = "[daemon]\nlog_level = \"info\"\n\n[api]\nport = 9797\ntoken = \"abc\"\ndomain = \"rp.exemplo.com\"\n\n[registry]\ntoken = \"nao-e-este\"\n";
#[test]
fn grava_o_token_de_conexao_logo_abaixo_do_token_da_api() {
let atualizado = with_connection_token(CONFIG, "abc", "CHAVE").unwrap();
let linhas: Vec<&str> = atualizado.lines().collect();
let posicao = linhas.iter().position(|l| *l == "token = \"abc\"").unwrap();
assert!(linhas[posicao + 1].starts_with("connection_token = \"CHAVE\""));
assert_eq!(atualizado.matches("connection_token").count(), 1);
assert!(atualizado.ends_with('\n'));
let tabela: toml::Table = toml::from_str(&atualizado).unwrap();
let api: crate::platform::config::ApiConfig = tabela["api"].clone().try_into().unwrap();
assert_eq!(api.token.as_deref(), Some("abc"));
assert_eq!(with_connection_token(&atualizado, "abc", "CHAVE"), None);
let trocado = with_connection_token(&atualizado, "abc", "OUTRA").unwrap();
assert_eq!(trocado.matches("connection_token").count(), 1);
assert!(trocado.contains("connection_token = \"OUTRA\""));
}
#[test]
fn nao_grava_quando_o_token_do_arquivo_nao_e_o_que_vale() {
assert_eq!(with_connection_token(CONFIG, "do-ambiente", "CHAVE"), None);
assert_eq!(
with_connection_token("[api]\nport = 9797\n", "abc", "CHAVE"),
None
);
}
}