use alloc::string::String;
use ts_control_serde::{C2NVIPServicesResponse, PingType};
use ts_http_util::{BytesBody, ClientExt, Http2, Request};
use url::Url;
use crate::StateUpdate;
const C2N_PATH_ECHO: &str = "/echo";
const C2N_PATH_VIP_SERVICES: &str = "/vip-services";
const C2N_PREFIX_REMOTE_API: &str = "/remoteapi/localapi/";
const C2N_REMOTE_API_STRIP: &str = "/remoteapi";
const C2N_LOCAL_API_PREFIX: &str = "/localapi/";
const C2N_PATH_UNKNOWN: &str = "HTTP/1.1 400 Bad Request\r\n\r\nunknown c2n path";
const C2N_REMOTE_CONFIG_DISABLED: &str =
"HTTP/1.1 403 Forbidden\r\n\r\nremote config not enabled by local machine";
const C2N_REMOTE_API_BAD_PATH: &str =
"HTTP/1.1 400 Bad Request\r\n\r\nunexpected remote-config path";
const C2N_RESPONSE_ECHO_PREAMBLE: &str = "HTTP/1.1 200 OK\r\n\r\n";
fn build_vip_services_response(config: &crate::Config) -> String {
let vip_services = config.advertised_vip_services();
let services_hash = crate::services_hash(&vip_services);
let response = C2NVIPServicesResponse {
vip_services,
services_hash,
};
let body = serde_json::to_string(&response).unwrap_or_else(|_| {
tracing::error!("serializing c2n /vip-services response");
String::from(r#"{"VIPServices":[],"ServicesHash":""}"#)
});
format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n{body}")
}
async fn build_c2n_response(request: &Request<String>, config: &crate::Config) -> String {
let c2n_request_path = request.uri().path();
match c2n_request_path {
C2N_PATH_ECHO => {
tracing::trace!(c2n_request_path, "handling c2n echo");
return format!("{}{}", C2N_RESPONSE_ECHO_PREAMBLE, request.body());
}
C2N_PATH_VIP_SERVICES => {
tracing::trace!(c2n_request_path, "handling c2n vip-services fetch");
return build_vip_services_response(config);
}
_ => {}
}
if let Some(local_api) = &config.local_api
&& c2n_request_path.starts_with(C2N_PREFIX_REMOTE_API)
{
tracing::trace!(
c2n_request_path,
"handling c2n remote-config localapi proxy"
);
return handle_c2n_remote_api(request, local_api.as_ref(), config.remote_config).await;
}
tracing::debug!(c2n_request_path, "no handler for c2n path");
C2N_PATH_UNKNOWN.to_string()
}
async fn handle_c2n_remote_api(
request: &Request<String>,
local_api: &dyn crate::LocalApi,
remote_config: bool,
) -> String {
if !remote_config {
tracing::debug!("refusing c2n remote-config request: pref not enabled by local machine");
return C2N_REMOTE_CONFIG_DISABLED.to_string();
}
let path = request.uri().path();
if !path.starts_with(C2N_PREFIX_REMOTE_API) {
tracing::debug!(
c2n_request_path = path,
"remote-config path outside the c2n prefix"
);
return C2N_REMOTE_API_BAD_PATH.to_string();
}
let Some(local_api_path) = path
.strip_prefix(C2N_REMOTE_API_STRIP)
.filter(|stripped| stripped.starts_with(C2N_LOCAL_API_PREFIX))
else {
tracing::debug!(
c2n_request_path = path,
"remote-config path is not a LocalAPI path"
);
return C2N_REMOTE_API_BAD_PATH.to_string();
};
let target = match request.uri().query() {
Some(query) => format!("{local_api_path}?{query}"),
None => local_api_path.to_string(),
};
local_api
.serve(request.method().as_str(), &target, request.body())
.await
}
#[derive(Debug, thiserror::Error, Clone, Copy, Eq, PartialEq)]
pub enum PingError {
#[error("HTTP error")]
Http,
#[error("URL parsing error")]
Url,
#[error("Ping request with invalid format (missing payload)")]
MessageFormat,
#[error("Network error")]
NetworkError,
}
impl From<ts_http_util::Error> for PingError {
fn from(error: ts_http_util::Error) -> Self {
tracing::error!(%error, "HTTP error handling ping");
if crate::http_error_is_recoverable(error) {
PingError::NetworkError
} else {
PingError::Http
}
}
}
impl From<url::ParseError> for PingError {
fn from(error: url::ParseError) -> Self {
tracing::error!(%error, "Error parsing URL");
PingError::Url
}
}
fn parse_c2n_ping(payload: &str) -> Result<Request<String>, PingError> {
let req = ts_http_util::http1::parse_request(payload.as_bytes())?;
tracing::trace!(
payload_len = req.body().len(),
payload = req.body(),
"extracted payload from ping request body"
);
Ok(req)
}
pub async fn handle_ping(
state: &StateUpdate,
control_url: &Url,
http2_client: &Http2<BytesBody>,
config: &crate::Config,
) -> Result<(), PingError> {
let Some(ping_request) = &state.ping else {
return Ok(());
};
tracing::trace!(request = ?ping_request, "handling ping request");
for typ in &ping_request.types {
if typ != &PingType::C2N {
tracing::warn!(ping_type = ?typ, "ignoring unsupported ping type");
continue;
}
let ping_request_body = ping_request.payload.as_ref().ok_or_else(|| {
tracing::error!("message format error in ping request: missing payload");
PingError::MessageFormat
})?;
let c2n_request = match parse_c2n_ping(ping_request_body) {
Ok(c2n_request) => {
tracing::trace!(?c2n_request, "parsed c2n ping");
c2n_request
}
Err(_) => {
tracing::warn!(?ping_request_body, "ignoring malformed c2n ping");
continue;
}
};
let c2n_response = build_c2n_response(&c2n_request, config).await;
let ping_response_url = control_url.join(ping_request.url.path())?;
tracing::trace!(%ping_response_url, ?c2n_response, "posting c2n response");
let response = http2_client
.post(&ping_response_url, None, c2n_response.into())
.await?;
if !response.status().is_success() {
tracing::error!(status = %response.status(), "responding to c2n ping");
} else {
tracing::debug!("c2n response sent");
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use alloc::string::ToString;
use super::*;
fn c2n_request(raw: &str) -> Request<String> {
parse_c2n_ping(raw).expect("control sends a well-formed HTTP/1.1 c2n request")
}
#[tokio::test]
async fn c2n_debug_endpoints_answer_unknown_path() {
let config = crate::Config::default();
for raw in [
"GET /debug/netmap HTTP/1.1\r\nHost: c2n\r\n\r\n",
"POST /debug/netmap HTTP/1.1\r\nHost: c2n\r\nContent-Length: 2\r\n\r\n{}",
"GET /debug/health HTTP/1.1\r\nHost: c2n\r\n\r\n",
"GET /debug/tka HTTP/1.1\r\nHost: c2n\r\n\r\n",
"GET /debug/tka/log HTTP/1.1\r\nHost: c2n\r\n\r\n",
"GET /debug/tka/log?limit=60 HTTP/1.1\r\nHost: c2n\r\n\r\n",
] {
let resp = build_c2n_response(&c2n_request(raw), &config).await;
assert_eq!(
resp, "HTTP/1.1 400 Bad Request\r\n\r\nunknown c2n path",
"{raw} must take the unknown-c2n-path fallthrough"
);
}
}
#[tokio::test]
async fn c2n_unknown_path_still_answers_400() {
let config = crate::Config::default();
for raw in [
"GET /debug/goroutines HTTP/1.1\r\nHost: c2n\r\n\r\n",
"GET /debug/metrics HTTP/1.1\r\nHost: c2n\r\n\r\n",
"POST /netfilter-kind HTTP/1.1\r\nHost: c2n\r\n\r\n",
"GET /not-a-real-path HTTP/1.1\r\nHost: c2n\r\n\r\n",
"GET / HTTP/1.1\r\nHost: c2n\r\n\r\n",
] {
let resp = build_c2n_response(&c2n_request(raw), &config).await;
assert_eq!(
resp, "HTTP/1.1 400 Bad Request\r\n\r\nunknown c2n path",
"{raw} must take the unknown-c2n-path fallthrough"
);
}
}
#[derive(Default)]
struct RecordingLocalApi {
seen: std::sync::Mutex<alloc::vec::Vec<(String, String, String)>>,
}
impl crate::LocalApi for RecordingLocalApi {
fn serve<'a>(
&'a self,
method: &'a str,
target: &'a str,
body: &'a str,
) -> core::pin::Pin<alloc::boxed::Box<dyn core::future::Future<Output = String> + Send + 'a>>
{
alloc::boxed::Box::pin(async move {
self.seen
.lock()
.expect("no test panics while holding it")
.push((method.to_string(), target.to_string(), body.to_string()));
format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n{{\"Target\":\"{target}\"}}"
)
})
}
}
fn config_with_local_api(
remote_config: bool,
) -> (crate::Config, alloc::sync::Arc<RecordingLocalApi>) {
let local_api = alloc::sync::Arc::new(RecordingLocalApi::default());
let config = crate::Config {
remote_config,
local_api: Some(local_api.clone()),
..Default::default()
};
(config, local_api)
}
#[tokio::test]
async fn c2n_remote_api_proxies_into_the_local_api() {
let (config, local_api) = config_with_local_api(true);
let status = build_c2n_response(
&c2n_request("GET /remoteapi/localapi/v0/status HTTP/1.1\r\nHost: c2n\r\n\r\n"),
&config,
)
.await;
assert_eq!(
status,
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n{\"Target\":\"/localapi/v0/status\"}",
"the LocalAPI's own response is returned to control verbatim"
);
let set = build_c2n_response(
&c2n_request(
"POST /remoteapi/localapi/v1/prefs?exit-node=hq HTTP/1.1\r\nHost: c2n\r\nContent-Length: 14\r\n\r\n{\"RouteAll\":1}",
),
&config,
)
.await;
assert!(
set.starts_with("HTTP/1.1 200 OK"),
"a LocalAPI version the prefix does not name proxies the same way; got {set}"
);
let seen = local_api
.seen
.lock()
.expect("no test panics while holding it")
.clone();
assert_eq!(
seen,
alloc::vec![
(
"GET".to_string(),
"/localapi/v0/status".to_string(),
String::new()
),
(
"POST".to_string(),
"/localapi/v1/prefs?exit-node=hq".to_string(),
"{\"RouteAll\":1}".to_string(),
),
],
"method, the `/remoteapi`-stripped target (query string kept) and body all cross intact"
);
}
#[tokio::test]
async fn c2n_remote_api_refuses_when_pref_disabled() {
let (config, local_api) = config_with_local_api(false);
for raw in [
"GET /remoteapi/localapi/v0/status HTTP/1.1\r\nHost: c2n\r\n\r\n",
"POST /remoteapi/localapi/v0/prefs HTTP/1.1\r\nHost: c2n\r\n\r\n",
] {
let resp = build_c2n_response(&c2n_request(raw), &config).await;
assert_eq!(
resp, "HTTP/1.1 403 Forbidden\r\n\r\nremote config not enabled by local machine",
"{raw} must be refused while the local machine has not opted in"
);
}
assert!(
local_api
.seen
.lock()
.expect("no test panics while holding it")
.is_empty(),
"a refused request must never reach the LocalAPI"
);
}
#[tokio::test]
async fn c2n_remote_api_near_miss_paths_still_answer_400() {
let (config, local_api) = config_with_local_api(true);
for raw in [
"GET /remoteapi/localapi HTTP/1.1\r\nHost: c2n\r\n\r\n",
"GET /remoteapi/localapinot/v0/status HTTP/1.1\r\nHost: c2n\r\n\r\n",
"GET /remoteapi/ HTTP/1.1\r\nHost: c2n\r\n\r\n",
"GET /remoteapi HTTP/1.1\r\nHost: c2n\r\n\r\n",
"GET /localapi/v0/status HTTP/1.1\r\nHost: c2n\r\n\r\n",
"GET /remoteapi/localapi?v0/status HTTP/1.1\r\nHost: c2n\r\n\r\n",
] {
let resp = build_c2n_response(&c2n_request(raw), &config).await;
assert_eq!(
resp, "HTTP/1.1 400 Bad Request\r\n\r\nunknown c2n path",
"{raw} must take the unknown-c2n-path fallthrough"
);
}
assert!(
local_api
.seen
.lock()
.expect("no test panics while holding it")
.is_empty(),
"no near-miss path may reach the LocalAPI"
);
}
#[tokio::test]
async fn c2n_remote_api_unknown_without_a_local_api() {
for remote_config in [false, true] {
let config = crate::Config {
remote_config,
local_api: None,
..Default::default()
};
let resp = build_c2n_response(
&c2n_request("GET /remoteapi/localapi/v0/status HTTP/1.1\r\nHost: c2n\r\n\r\n"),
&config,
)
.await;
assert_eq!(
resp, "HTTP/1.1 400 Bad Request\r\n\r\nunknown c2n path",
"with no LocalAPI to proxy into, the prefix route does not exist \
(remote_config = {remote_config})"
);
}
}
#[tokio::test]
async fn c2n_remote_api_handler_refuses_a_path_it_cannot_rewrite() {
let local_api = RecordingLocalApi::default();
for raw in [
"GET /remoteapi/v0/status HTTP/1.1\r\nHost: c2n\r\n\r\n",
"GET /localapi/v0/status HTTP/1.1\r\nHost: c2n\r\n\r\n",
"GET /remoteapi/localapi HTTP/1.1\r\nHost: c2n\r\n\r\n",
] {
let resp = handle_c2n_remote_api(&c2n_request(raw), &local_api, true).await;
assert_eq!(
resp, "HTTP/1.1 400 Bad Request\r\n\r\nunexpected remote-config path",
"{raw} is not rewritable into a LocalAPI path"
);
}
assert!(
local_api
.seen
.lock()
.expect("no test panics while holding it")
.is_empty(),
"a path the handler cannot rewrite must never reach the LocalAPI"
);
}
#[tokio::test]
async fn c2n_echo_and_vip_services_still_route() {
let config = crate::Config {
advertise_services: alloc::vec!["svc:web".to_string()],
..Default::default()
};
let echo = build_c2n_response(
&c2n_request("GET /echo HTTP/1.1\r\nContent-Length: 5\r\n\r\nhello"),
&config,
)
.await;
assert_eq!(echo, "HTTP/1.1 200 OK\r\n\r\nhello");
let vip = build_c2n_response(
&c2n_request("GET /vip-services HTTP/1.1\r\nHost: c2n\r\n\r\n"),
&config,
)
.await;
let (status, json) = parse_response(&vip);
assert_eq!(status, "HTTP/1.1 200 OK");
assert_eq!(json["VIPServices"][0]["Name"].as_str().unwrap(), "svc:web");
}
fn parse_response(resp: &str) -> (&str, serde_json::Value) {
let (head, body) = resp.split_once("\r\n\r\n").expect("response has a body");
let status = head.lines().next().unwrap();
let json: serde_json::Value = serde_json::from_str(body).expect("body is JSON");
(status, json)
}
#[test]
fn vip_services_response_lists_configured_services() {
let config = crate::Config {
advertise_services: alloc::vec!["svc:samba".to_string(), "svc:web".to_string()],
..Default::default()
};
let resp = build_vip_services_response(&config);
let (status, json) = parse_response(&resp);
assert_eq!(status, "HTTP/1.1 200 OK");
let names: alloc::vec::Vec<&str> = json["VIPServices"]
.as_array()
.unwrap()
.iter()
.map(|s| s["Name"].as_str().unwrap())
.collect();
assert!(names.contains(&"svc:samba"));
assert!(names.contains(&"svc:web"));
let expected = crate::services_hash(&config.advertised_vip_services());
assert_eq!(json["ServicesHash"].as_str().unwrap(), expected);
assert!(!expected.is_empty());
}
#[test]
fn vip_services_response_empty_when_none_configured() {
let config = crate::Config::default();
let resp = build_vip_services_response(&config);
let (status, json) = parse_response(&resp);
assert_eq!(status, "HTTP/1.1 200 OK");
assert!(json["VIPServices"].as_array().unwrap().is_empty());
assert_eq!(json["ServicesHash"].as_str().unwrap(), "");
}
#[test]
fn vip_services_response_drops_invalid_names() {
let config = crate::Config {
advertise_services: alloc::vec![
"svc:good".to_string(),
"not-a-service".to_string(), ],
..Default::default()
};
let resp = build_vip_services_response(&config);
let (_, json) = parse_response(&resp);
let services = json["VIPServices"].as_array().unwrap();
assert_eq!(services.len(), 1);
assert_eq!(services[0]["Name"].as_str().unwrap(), "svc:good");
}
}