use super::{HubStore, LicenceState, Refusal, ingest};
use axum::extract::{ConnectInfo, Form, State};
use axum::http::{HeaderMap, StatusCode, header};
use axum::response::{Html, Redirect};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Json, Router};
use serde_json::json;
use std::sync::{Arc, Mutex};
pub struct HubState {
pub hub: Mutex<HubStore>,
pub sessions: super::admin::Sessions,
pub port: u16,
pub record: std::path::PathBuf,
pub encrypted: bool,
pub flash: Mutex<Option<Result<String, String>>>,
}
pub fn router(state: Arc<HubState>) -> Router {
Router::new()
.route("/", get(page))
.route("/claim", post(claim))
.route("/login", post(login))
.route("/logout", get(logout))
.route("/password", post(change_password))
.route("/licence", post(install_licence))
.route("/devices", post(add_device))
.route("/health", get(health))
.route("/api/v1/ingest", post(post_ingest))
.route("/api/v1/fleet", get(get_fleet))
.with_state(state)
}
pub(crate) fn at_the_machine(who: &std::net::SocketAddr) -> bool {
who.ip().is_loopback()
}
const ELSEWHERE: &str = "This hub has not been set up yet. Open it on the machine it runs \
on to set the administrator password. Devices deliver to /api/v1/ingest as usual.";
const PLAINTEXT: &str = "This hub is not encrypted, so a password typed here would travel \
across the network in the clear. Sign in on the machine the hub runs on, or start it \
with --tls-cert and --tls-key and come back over https (docs/HUB.md). Devices go on \
delivering to /api/v1/ingest either way.";
fn password_may_travel(state: &HubState, from: &std::net::SocketAddr) -> bool {
state.encrypted || at_the_machine(from)
}
enum Who {
Admin,
MayClaim,
Stranger,
TooEarly,
}
fn who(state: &HubState, headers: &HeaderMap, from: &std::net::SocketAddr) -> Who {
let claimed = {
match state.hub.lock() {
Ok(hub) => super::admin::is_claimed(&hub),
Err(_) => true, }
};
if !claimed {
return if at_the_machine(from) {
Who::MayClaim
} else {
Who::TooEarly
};
}
let cookie =
super::admin::cookie_from(headers.get(header::COOKIE).and_then(|v| v.to_str().ok()));
match cookie {
Some(t) if state.sessions.holds(&t, jiff::Timestamp::now()) => Who::Admin,
_ => Who::Stranger,
}
}
fn html(body: String) -> Response {
Html(body).into_response()
}
async fn stumble() {
tokio::time::sleep(std::time::Duration::from_millis(
super::admin::FAILURE_DELAY_MS,
))
.await;
}
async fn page(
State(state): State<Arc<HubState>>,
headers: HeaderMap,
ConnectInfo(from): ConnectInfo<std::net::SocketAddr>,
) -> Response {
match who(&state, &headers, &from) {
Who::Admin => {}
Who::MayClaim => return html(super::page::claim_page(None)),
Who::TooEarly => return (StatusCode::FORBIDDEN, ELSEWHERE).into_response(),
Who::Stranger => return html(super::page::login_page(None)),
}
let hub = match state.hub.lock() {
Ok(h) => h,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("hub record unavailable: {e}"),
)
.into_response();
}
};
let flash = state.flash.lock().ok().and_then(|mut f| f.take());
let view = super::page::View::gather(
&hub,
&state.record,
state.port,
state.encrypted,
jiff::Timestamp::now(),
flash,
);
Html(super::page::render(&view)).into_response()
}
#[derive(serde::Deserialize)]
pub struct ClaimForm {
password: String,
again: String,
}
async fn claim(
State(state): State<Arc<HubState>>,
ConnectInfo(from): ConnectInfo<std::net::SocketAddr>,
Form(form): Form<ClaimForm>,
) -> Response {
if !at_the_machine(&from) {
return (StatusCode::FORBIDDEN, ELSEWHERE).into_response();
}
let hub = match state.hub.lock() {
Ok(h) => h,
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
};
if super::admin::is_claimed(&hub) {
return html(super::page::login_page(Some(
"This hub already has a password.",
)));
}
if form.password != form.again {
return html(super::page::claim_page(Some("The two did not match.")));
}
match super::admin::set_password(&hub, &form.password) {
Ok(()) => {
drop(hub);
let token = state.sessions.open(jiff::Timestamp::now());
(
[(
header::SET_COOKIE,
super::admin::set_cookie(&token, state.encrypted),
)],
Redirect::to("/"),
)
.into_response()
}
Err(e) => html(super::page::claim_page(Some(&e))),
}
}
#[derive(serde::Deserialize)]
pub struct LoginForm {
password: String,
}
async fn login(
State(state): State<Arc<HubState>>,
ConnectInfo(from): ConnectInfo<std::net::SocketAddr>,
Form(form): Form<LoginForm>,
) -> Response {
if !password_may_travel(&state, &from) {
return (StatusCode::FORBIDDEN, PLAINTEXT).into_response();
}
let ok = match state.hub.lock() {
Ok(hub) => super::admin::verify(&hub, &form.password),
Err(_) => false,
};
if !ok {
stumble().await;
return html(super::page::login_page(Some("That is not the password.")));
}
let token = state.sessions.open(jiff::Timestamp::now());
(
[(
header::SET_COOKIE,
super::admin::set_cookie(&token, state.encrypted),
)],
Redirect::to("/"),
)
.into_response()
}
async fn logout(State(state): State<Arc<HubState>>, headers: HeaderMap) -> Response {
if let Some(t) =
super::admin::cookie_from(headers.get(header::COOKIE).and_then(|v| v.to_str().ok()))
{
state.sessions.close(&t);
}
(
[(
header::SET_COOKIE,
super::admin::clear_cookie(state.encrypted),
)],
Redirect::to("/"),
)
.into_response()
}
#[derive(serde::Deserialize)]
pub struct PasswordForm {
current: String,
password: String,
again: String,
}
async fn change_password(
State(state): State<Arc<HubState>>,
headers: HeaderMap,
ConnectInfo(from): ConnectInfo<std::net::SocketAddr>,
Form(form): Form<PasswordForm>,
) -> Response {
if !matches!(who(&state, &headers, &from), Who::Admin) {
return html(super::page::login_page(None));
}
if !password_may_travel(&state, &from) {
return (StatusCode::FORBIDDEN, PLAINTEXT).into_response();
}
let outcome = {
let hub = match state.hub.lock() {
Ok(h) => h,
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
};
if !super::admin::verify(&hub, &form.current) {
Err("The current password is not right.".to_string())
} else if form.password != form.again {
Err("The two new ones did not match.".to_string())
} else {
super::admin::set_password(&hub, &form.password)
.map(|()| "The password has been changed.".to_string())
}
};
if outcome.is_err() {
stumble().await;
}
if let Ok(mut f) = state.flash.lock() {
*f = Some(outcome);
}
Redirect::to("/").into_response()
}
#[derive(serde::Deserialize)]
pub struct LicenceForm {
#[serde(default)]
text: String,
#[serde(default)]
use_found: String,
}
async fn install_licence(
State(state): State<Arc<HubState>>,
headers: HeaderMap,
ConnectInfo(from): ConnectInfo<std::net::SocketAddr>,
Form(form): Form<LicenceForm>,
) -> Response {
if !matches!(who(&state, &headers, &from), Who::Admin) {
return html(super::page::login_page(None));
}
let outcome = {
let hub = match state.hub.lock() {
Ok(h) => h,
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
};
if form.use_found.is_empty() {
install_text(&hub, &form.text)
} else {
let dir = state
.record
.parent()
.unwrap_or(std::path::Path::new("."))
.to_path_buf();
match super::service::adopt_dropped_licence(&hub, &dir) {
super::service::Dropped::Installed(m) => Ok(m),
super::service::Dropped::Unchanged => {
Ok("That licence is already installed.".into())
}
super::service::Dropped::Problem(m) => Err(m),
super::service::Dropped::None => Err("The file is no longer there.".into()),
}
}
};
if let Ok(mut f) = state.flash.lock() {
*f = Some(outcome);
}
Redirect::to("/").into_response()
}
#[derive(serde::Deserialize)]
pub struct DeviceForm {
name: String,
#[serde(default)]
hub_url: String,
}
async fn add_device(
State(state): State<Arc<HubState>>,
headers: HeaderMap,
ConnectInfo(from): ConnectInfo<std::net::SocketAddr>,
Form(form): Form<DeviceForm>,
) -> Response {
if !matches!(who(&state, &headers, &from), Who::Admin) {
return html(super::page::login_page(None));
}
let outcome = {
let hub = match state.hub.lock() {
Ok(h) => h,
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
};
register(&hub, &state.record, form.name.trim(), form.hub_url.trim())
};
if let Ok(mut f) = state.flash.lock() {
*f = Some(outcome);
}
Redirect::to("/").into_response()
}
fn register(
hub: &HubStore,
record: &std::path::Path,
name: &str,
hub_url: &str,
) -> Result<String, String> {
if name.is_empty() {
return Err("A machine needs a name.".into());
}
let state = LicenceState::read(hub, jiff::Timestamp::now());
match state.seats() {
None => return Err(format!("{} No device can be registered.", state.line())),
Some(seats) => {
let active = hub.active_device_count().map_err(|e| e.to_string())?;
if active >= seats {
return Err(format!(
"The licence covers {seats} seat(s) and {active} are in use. Revoke a \
machine that is gone, or extend the licence — its rows are kept either \
way."
));
}
}
}
let (device, token) = hub
.add_device(name, &jiff::Timestamp::now().to_string())
.map_err(|e| e.to_string())?;
let invitation = json!({
"kind": "cyberbrain.hub.invitation",
"version": 1,
"device": device.id,
"name": device.name,
"token": token,
"hub_url": if hub_url.is_empty() { serde_json::Value::Null } else { json!(hub_url) },
"inference_url": serde_json::Value::Null,
});
let text = serde_json::to_string_pretty(&invitation).map_err(|e| e.to_string())?;
let dir = record
.parent()
.unwrap_or(std::path::Path::new("."))
.join("invitations");
std::fs::create_dir_all(&dir).map_err(|e| format!("cannot make {}: {e}", dir.display()))?;
let path = dir.join(format!("{}.json", device.id));
std::fs::write(&path, format!("{text}\n"))
.map_err(|e| format!("cannot write {}: {e}", path.display()))?;
Ok(format!(
"{name} registered. Its invitation is at {} — it carries the token, so hand it over \
the way you would a password and delete it once that machine is set up.{}",
path.display(),
if hub_url.is_empty() {
" No address was given, so the machine will still have to be told where to \
deliver."
} else {
""
}
))
}
fn install_text(hub: &HubStore, text: &str) -> Result<String, String> {
let text = text.trim();
if text.is_empty() {
return Err("Nothing was pasted.".into());
}
let signed = super::licence::parse(text).map_err(|e| e.to_string())?;
hub.set_licence(text).map_err(|e| e.to_string())?;
let l = signed.licence();
Ok(format!(
"Installed: {}, {} seat(s), until {}.",
l.customer, l.seats, l.valid_until
))
}
async fn health() -> impl IntoResponse {
Json(json!({ "role": "hub", "version": env!("CARGO_PKG_VERSION") }))
}
fn bearer(headers: &HeaderMap) -> Option<String> {
let v = headers.get(header::AUTHORIZATION)?.to_str().ok()?;
v.strip_prefix("Bearer ").map(|t| t.trim().to_string())
}
fn client_version(headers: &HeaderMap) -> Option<String> {
headers
.get("x-cyberbrain-version")?
.to_str()
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty() && s.len() <= 64)
}
async fn post_ingest(
State(state): State<Arc<HubState>>,
headers: HeaderMap,
body: String,
) -> Response {
let token = bearer(&headers);
let version = client_version(&headers);
let now = jiff::Timestamp::now().to_string();
let mut hub = match state.hub.lock() {
Ok(h) => h,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": format!("hub record unavailable: {e}") })),
)
.into_response();
}
};
let licence = LicenceState::read(&hub, jiff::Timestamp::now());
match ingest(
&mut hub,
&licence,
token.as_deref(),
&body,
version.as_deref(),
&now,
) {
Ok(a) => (StatusCode::OK, Json(json!(a))).into_response(),
Err(refusal) => {
let code = match &refusal {
Refusal::NotAuthorised(_) => StatusCode::UNAUTHORIZED,
Refusal::BadBundle(_) => StatusCode::BAD_REQUEST,
Refusal::WrongAnchor { .. } => StatusCode::CONFLICT,
Refusal::NotCollecting(_) => StatusCode::SERVICE_UNAVAILABLE,
};
let mut body = json!({ "error": refusal.to_string() });
if let Refusal::WrongAnchor { expected, got } = &refusal {
body["expected_anchor"] = json!(expected);
body["got_anchor"] = json!(got);
}
(code, Json(body)).into_response()
}
}
}
async fn get_fleet(
State(state): State<Arc<HubState>>,
headers: HeaderMap,
ConnectInfo(from): ConnectInfo<std::net::SocketAddr>,
) -> Response {
if !matches!(who(&state, &headers, &from), Who::Admin) {
return (
StatusCode::UNAUTHORIZED,
Json(json!({ "error": "sign in at / first" })),
)
.into_response();
}
let hub = match state.hub.lock() {
Ok(h) => h,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": format!("hub record unavailable: {e}") })),
)
.into_response();
}
};
match hub.devices() {
Ok(devices) => (StatusCode::OK, Json(json!({ "devices": devices }))).into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": e.to_string() })),
)
.into_response(),
}
}