use std::collections::HashMap;
use std::time::Duration;
use anyhow::{Context, Result, bail};
use axum::Json;
use axum::extract::{Path, State};
use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, put};
use serde::{Deserialize, Serialize};
use crate::gateway::{Jars, Watch};
use crate::registry::Control;
#[derive(Serialize, Deserialize)]
pub struct SetJar {
pub cookies: Vec<(String, String)>,
}
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]
pub struct JarView {
pub app: String,
pub server: String,
pub cookies: Vec<String>,
}
#[derive(Clone)]
struct Ctx {
token: String,
jars: Jars,
watch: Watch,
}
pub fn token() -> String {
use rand::Rng;
let mut bytes = [0u8; 32];
rand::rng().fill_bytes(&mut bytes);
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
}
pub async fn serve(listener: tokio::net::TcpListener, token: String, jars: Jars, watch: Watch) {
let router = axum::Router::new()
.route("/jars", get(list))
.route("/jar/{app}/{server}", put(set).delete(clear))
.with_state(Ctx { token, jars, watch });
let _ = axum::serve(listener, router).await;
}
fn authorized(ctx: &Ctx, headers: &HeaderMap) -> bool {
let Some(given) = headers
.get(axum::http::header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.strip_prefix("Bearer "))
else {
return false;
};
let (given, token) = (given.as_bytes(), ctx.token.as_bytes());
given.len() == token.len() && given.iter().zip(token).fold(0u8, |diff, (a, b)| diff | (a ^ b)) == 0
}
fn refused() -> Response {
(StatusCode::FORBIDDEN, "turnout gateway: this channel takes requests from turnout only\n").into_response()
}
async fn list(State(ctx): State<Ctx>, headers: HeaderMap) -> Response {
if !authorized(&ctx, &headers) {
return refused();
}
let jars = ctx.jars.lock().expect("cookie jar poisoned");
let mut views: Vec<JarView> = jars
.iter()
.filter(|(_, jar)| !jar.is_empty())
.map(|((app, server), jar)| {
let mut cookies: Vec<String> = jar.keys().cloned().collect();
cookies.sort();
JarView {
app: app.clone(),
server: server.clone(),
cookies,
}
})
.collect();
views.sort_by(|a, b| (&a.app, &a.server).cmp(&(&b.app, &b.server)));
Json(views).into_response()
}
async fn set(State(ctx): State<Ctx>, Path((app, server)): Path<(String, String)>, headers: HeaderMap, Json(body): Json<SetJar>) -> Response {
if !authorized(&ctx, &headers) {
return refused();
}
let jar: HashMap<String, String> = body.cookies.into_iter().collect();
let count = jar.len();
ctx.jars.lock().expect("cookie jar poisoned").insert((app.clone(), server.clone()), jar);
println!("{app} -> {server}: {count} cookies handed over with `turnout cookie set`");
ctx.watch.renewed(&app, &server);
StatusCode::NO_CONTENT.into_response()
}
async fn clear(State(ctx): State<Ctx>, Path((app, server)): Path<(String, String)>, headers: HeaderMap) -> Response {
if !authorized(&ctx, &headers) {
return refused();
}
ctx.jars.lock().expect("cookie jar poisoned").remove(&(app.clone(), server.clone()));
println!("{app} -> {server}: cookies forgotten with `turnout cookie clear`");
StatusCode::NO_CONTENT.into_response()
}
pub fn set_jar(control: &Control, app: &str, server: &str, cookies: Vec<(String, String)>) -> Result<()> {
let body = serde_json::to_vec(&SetJar { cookies })?;
request(control, reqwest::Method::PUT, &format!("/jar/{app}/{server}"), Some(body)).map(drop)
}
pub fn clear_jar(control: &Control, app: &str, server: &str) -> Result<()> {
request(control, reqwest::Method::DELETE, &format!("/jar/{app}/{server}"), None).map(drop)
}
pub fn jars(control: &Control) -> Result<Vec<JarView>> {
let text = request(control, reqwest::Method::GET, "/jars", None)?;
serde_json::from_str(&text).context("the gateway answered with something other than its jars")
}
fn request(control: &Control, method: reqwest::Method, path: &str, body: Option<Vec<u8>>) -> Result<String> {
let url = format!("http://127.0.0.1:{}{path}", control.port);
let token = control.token.clone();
crate::utils::run_blocking(async move {
let client = reqwest::Client::builder()
.no_proxy()
.timeout(Duration::from_secs(5))
.build()
.context("cannot build the HTTP client")?;
let mut request = client.request(method, &url).bearer_auth(token);
if let Some(body) = body {
request = request.header(reqwest::header::CONTENT_TYPE, "application/json").body(body);
}
let response = request.send().await.context("the gateway does not answer on its control port")?;
let status = response.status();
let text = response.text().await.unwrap_or_default();
if !status.is_success() {
bail!("the gateway refused: {status} {}", text.trim());
}
Ok(text)
})
}