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", get(login_page).post(login))
.route("/logout", get(logout))
.route("/password", post(change_password))
.route("/licence", post(install_licence))
.route("/devices", post(add_device))
.route("/grants", post(add_grant))
.route("/grants/{id}/revoke", post(revoke_grant))
.route("/health", get(health))
.route("/api/v1/ingest", post(post_ingest))
.route("/api/v1/enrol", post(post_enrol))
.route("/api/v1/notes", post(post_notes))
.route("/api/v1/erase", post(post_erase))
.route("/api/v1/fetch", post(post_fetch))
.route("/conflicts", get(get_conflicts))
.route("/conflicts/{id}", post(post_conflict))
.route("/requests", get(get_requests).post(post_request))
.route("/requests/{id}/approve", post(approve))
.route("/grants/{id}/approve", post(countersign))
.route("/purges/{id}/approve", post(countersign_purge))
.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.and_then(|t| state.sessions.who(&t, jiff::Timestamp::now())) {
Some(super::admin::Who::Admin) => Who::Admin,
Some(super::admin::Who::Principal { role, .. }) if role.administers() => 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 match signed_in(&state, &headers) {
Some(w) => Redirect::to(home_for(&w)).into_response(),
None => 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 who = match state.hub.lock() {
Ok(hub) => {
if super::admin::verify(&hub, &form.password) {
Some(super::admin::Who::Admin)
} else {
match hub.principal_by_token(&form.password) {
Ok(Some(p)) if p.is_active() => Some(super::admin::Who::Principal {
id: p.id.clone(),
name: p.name.clone(),
role: p.role,
}),
_ => None,
}
}
}
Err(_) => None,
};
let Some(who) = who else {
stumble().await;
return html(super::page::login_page(Some(
"That is not the password or a credential this hub knows.",
)));
};
let home = home_for(&who);
let token = state.sessions.open_as(jiff::Timestamp::now(), who);
(
[(
header::SET_COOKIE,
super::admin::set_cookie(&token, state.encrypted),
)],
Redirect::to(home),
)
.into_response()
}
async fn login_page() -> Response {
html(super::page::login_page(None))
}
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.seats_in_use().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_machine(headers: &HeaderMap) -> Option<String> {
headers
.get("x-cyberbrain-machine")?
.to_str()
.ok()
.and_then(super::normalise_machine)
}
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)
}
#[derive(serde::Deserialize)]
struct GrantForm {
device: String,
bereich: String,
direction: String,
reason: String,
}
async fn add_grant(
State(state): State<Arc<HubState>>,
headers: HeaderMap,
ConnectInfo(from): ConnectInfo<std::net::SocketAddr>,
Form(form): Form<GrantForm>,
) -> Response {
if !matches!(who(&state, &headers, &from), Who::Admin) {
return html(super::page::login_page(None));
}
let now = jiff::Timestamp::now().to_string();
let Ok(hub) = state.hub.lock() else {
return (StatusCode::INTERNAL_SERVER_ERROR, PLAINTEXT).into_response();
};
let problem = (|| -> Result<(), String> {
cyberbrain_core::frontmatter::validate_bereich(&form.bereich)
.map_err(|r| format!("bereich: {r}"))?;
if form.reason.trim().is_empty() {
return Err("a grant needs a reason: it is what an auditor reads later".into());
}
let dir =
super::sync_access::Direction::parse(&form.direction).map_err(|e| e.to_string())?;
let id = format!("bg_{}", cyberbrain_core::NoteId::generate());
hub.grant_bereich(
&id,
&form.device,
&form.bereich,
dir,
form.reason.trim(),
"hub-page",
&now,
)
.map_err(|e| e.to_string())?;
let _ = hub.record(
"hub-page",
"grant.added",
json!({
"id": id, "device": form.device, "bereich": form.bereich,
"direction": dir.as_str(), "reason": form.reason.trim(),
}),
&now,
);
Ok(())
})();
match problem {
Ok(()) => Redirect::to("/").into_response(),
Err(e) => (StatusCode::BAD_REQUEST, e).into_response(),
}
}
async fn revoke_grant(
State(state): State<Arc<HubState>>,
headers: HeaderMap,
ConnectInfo(from): ConnectInfo<std::net::SocketAddr>,
axum::extract::Path(id): axum::extract::Path<String>,
) -> Response {
if !matches!(who(&state, &headers, &from), Who::Admin) {
return html(super::page::login_page(None));
}
let now = jiff::Timestamp::now().to_string();
if let Ok(hub) = state.hub.lock()
&& hub.revoke_grant(&id, &now).unwrap_or(false)
{
let _ = hub.record("hub-page", "grant.revoked", json!({ "id": id }), &now);
}
Redirect::to("/").into_response()
}
fn signed_in(state: &HubState, headers: &HeaderMap) -> Option<super::admin::Who> {
let token =
super::admin::cookie_from(headers.get(header::COOKIE).and_then(|v| v.to_str().ok()))?;
state.sessions.who(&token, jiff::Timestamp::now())
}
fn home_for(who: &super::admin::Who) -> &'static str {
use super::access::Role;
match who {
super::admin::Who::Admin => "/",
super::admin::Who::Principal { role, .. } => match role {
Role::Admin => "/",
Role::Editor => "/conflicts",
Role::Auditor | Role::Countersigner => "/requests",
},
}
}
fn editor_of(state: &HubState, headers: &HeaderMap) -> Option<(String, String)> {
match signed_in(state, headers)? {
super::admin::Who::Principal {
id,
name,
role: super::access::Role::Editor,
} => Some((id, name)),
_ => None,
}
}
async fn get_conflicts(State(state): State<Arc<HubState>>, headers: HeaderMap) -> Response {
let Some((id, name)) = editor_of(&state, &headers) else {
return Redirect::to("/login").into_response();
};
let Ok(hub) = state.hub.lock() else {
return (StatusCode::INTERNAL_SERVER_ERROR, PLAINTEXT).into_response();
};
match hub.conflicts_for_principal(&id) {
Ok(list) => Html(super::page::conflicts_page(&name, &list)).into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("cannot read conflicts: {e}"),
)
.into_response(),
}
}
#[derive(serde::Deserialize)]
struct TakeForm {
take: String,
}
async fn post_conflict(
State(state): State<Arc<HubState>>,
headers: HeaderMap,
axum::extract::Path(cid): axum::extract::Path<String>,
Form(form): Form<TakeForm>,
) -> Response {
let Some((pid, pname)) = editor_of(&state, &headers) else {
return Redirect::to("/login").into_response();
};
let now = jiff::Timestamp::now().to_string();
let Ok(hub) = state.hub.lock() else {
return (StatusCode::INTERNAL_SERVER_ERROR, PLAINTEXT).into_response();
};
match hub.conflict_for_principal(&pid, &cid) {
Ok(Some(_)) => {}
_ => return Redirect::to("/conflicts").into_response(),
}
let take_offered = form.take == "offered";
if hub
.resolve_conflict(&cid, take_offered, &now)
.unwrap_or(false)
{
let _ = hub.record(
&pid,
"conflict.resolved",
json!({
"id": cid,
"by": pname,
"took": if take_offered { "offered" } else { "held" },
}),
&now,
);
}
Redirect::to("/conflicts").into_response()
}
async fn post_fetch(
State(state): State<Arc<HubState>>,
headers: HeaderMap,
body: String,
) -> Response {
let since = serde_json::from_str::<serde_json::Value>(&body)
.ok()
.and_then(|v| v.get("since").and_then(|s| s.as_str()).map(str::to_string));
let token = bearer(&headers);
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 super::fetch_notes(&hub, token.as_deref(), since.as_deref()) {
Ok(f) => (StatusCode::OK, Json(json!(f))).into_response(),
Err(refusal) => {
let code = match &refusal {
Refusal::NotAuthorised(_) => StatusCode::UNAUTHORIZED,
_ => StatusCode::BAD_REQUEST,
};
(code, Json(json!({ "error": refusal.to_string() }))).into_response()
}
}
}
async fn post_erase(
State(state): State<Arc<HubState>>,
headers: HeaderMap,
body: String,
) -> Response {
let token = bearer(&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();
}
};
match super::erase_note(&mut hub, token.as_deref(), &body, &now) {
Ok(c) => (StatusCode::OK, Json(json!(c))).into_response(),
Err(refusal) => {
let code = match &refusal {
Refusal::NotAuthorised(_) => StatusCode::UNAUTHORIZED,
_ => StatusCode::BAD_REQUEST,
};
(code, Json(json!({ "error": refusal.to_string() }))).into_response()
}
}
}
async fn post_notes(
State(state): State<Arc<HubState>>,
headers: HeaderMap,
body: String,
) -> Response {
let token = bearer(&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 super::ingest_notes(&mut hub, &licence, token.as_deref(), &body, &now) {
Ok(a) => (StatusCode::OK, Json(json!(a))).into_response(),
Err(refusal) => {
let code = match &refusal {
Refusal::NotAuthorised(_) => StatusCode::UNAUTHORIZED,
Refusal::NotCollecting(_) => StatusCode::SERVICE_UNAVAILABLE,
_ => StatusCode::BAD_REQUEST,
};
(code, Json(json!({ "error": refusal.to_string() }))).into_response()
}
}
}
async fn post_ingest(
State(state): State<Arc<HubState>>,
headers: HeaderMap,
body: String,
) -> Response {
let token = bearer(&headers);
let version = client_version(&headers);
let machine = client_machine(&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) => {
if let Some(m) = &machine {
let _ = hub.set_machine(&a.device, m);
}
(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(),
}
}
fn signer_of(
state: &HubState,
headers: &HeaderMap,
) -> Option<(String, String, super::access::Role)> {
use super::access::Role;
match signed_in(state, headers)? {
super::admin::Who::Principal { id, name, role }
if matches!(role, Role::Auditor | Role::Countersigner) =>
{
Some((id, name, role))
}
_ => None,
}
}
async fn get_requests(State(state): State<Arc<HubState>>, headers: HeaderMap) -> Response {
use super::access::Role;
let Some((id, name, role)) = signer_of(&state, &headers) else {
return Redirect::to("/login").into_response();
};
let Ok(hub) = state.hub.lock() else {
return (StatusCode::INTERNAL_SERVER_ERROR, PLAINTEXT).into_response();
};
let now = jiff::Timestamp::now();
let all = hub.requests().unwrap_or_default();
let requests: Vec<_> = all
.into_iter()
.filter(|r| role == Role::Countersigner || r.requester == id)
.map(|r| {
let st = r.state(now);
(r, st)
})
.collect();
let grants = if role == Role::Countersigner {
hub.devices()
.unwrap_or_default()
.into_iter()
.filter_map(|d| hub.grants_for_device(&d.id).ok())
.flatten()
.filter(|g| g.is_pending())
.collect()
} else {
Vec::new()
};
let purges = if role == Role::Countersigner {
hub.purges()
.unwrap_or_default()
.into_iter()
.filter(|p| p.is_pending())
.collect()
} else {
Vec::new()
};
let log = if role == Role::Countersigner {
hub.hub_events(200).unwrap_or_default()
} else {
Vec::new()
};
let devices = hub
.devices()
.unwrap_or_default()
.into_iter()
.filter(|d| d.is_active())
.map(|d| (d.id, d.name))
.collect();
let flash = state.flash.lock().ok().and_then(|mut f| f.take());
let view = super::page::SigningView {
name: &name,
role,
requests,
grants,
purges,
log,
devices,
flash,
};
html(super::page::signing_page(&view))
}
async fn countersign_purge(
State(state): State<Arc<HubState>>,
headers: HeaderMap,
axum::extract::Path(id): axum::extract::Path<String>,
) -> Response {
use super::access::Role;
use super::store::PurgeOutcome as O;
let Some((_, name, role)) = signer_of(&state, &headers) else {
return Redirect::to("/login").into_response();
};
if role != Role::Countersigner {
return Redirect::to("/requests").into_response();
}
let now = jiff::Timestamp::now().to_string();
{
let Ok(hub) = state.hub.lock() else {
return (StatusCode::INTERNAL_SERVER_ERROR, PLAINTEXT).into_response();
};
let token =
super::admin::cookie_from(headers.get(header::COOKIE).and_then(|v| v.to_str().ok()));
let Some(who) = session_principal(&state, token.as_deref(), &hub) else {
return Redirect::to("/login").into_response();
};
let message = match hub.countersign_purge(&id, &who, &now) {
Ok(O::CarriedOut { rows, devices }) => Ok(format!(
"{id} carried out, countersigned by {name}: {rows} row(s) removed from {} device(s).",
devices.len()
)),
Ok(O::Unknown) => Err(format!("{id}: no purge with that id.")),
Ok(O::AlreadyDone { by }) => Err(format!(
"{id} was already carried out, countersigned by {by}."
)),
Ok(O::SamePerson) => Err(format!(
"{id} was proposed by you. Two signatures from one hand are one signature."
)),
Err(e) => Err(e.to_string()),
};
if let Ok(mut f) = state.flash.lock() {
*f = Some(message);
}
}
Redirect::to("/requests").into_response()
}
#[derive(serde::Deserialize)]
struct EnrolBody {
code: String,
machine: String,
project: String,
}
async fn post_enrol(State(state): State<Arc<HubState>>, Json(body): Json<EnrolBody>) -> Response {
use super::store::EnrolRefusal as R;
let now = jiff::Timestamp::now();
let Ok(hub) = state.hub.lock() else {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": "hub record unavailable" })),
)
.into_response();
};
let licence = LicenceState::read(&hub, now);
match hub.enrol_with_code(
&body.code,
&body.machine,
&body.project,
&licence,
&now.to_string(),
) {
Ok(Ok((device, token))) => (
StatusCode::OK,
Json(json!({
"device": device.id, "name": device.name, "machine": device.machine,
"token": token, "hub_cert_sha256": super::pin_to_offer(&hub),
})),
)
.into_response(),
Ok(Err(refusal)) => {
let status = match &refusal {
R::UnknownCode => StatusCode::UNAUTHORIZED,
R::Expired(_) | R::UsedUp(_) => StatusCode::FORBIDDEN,
R::NotLicensed(_) => StatusCode::SERVICE_UNAVAILABLE,
R::NoSeat(_) => StatusCode::CONFLICT,
R::BadRequest(_) => StatusCode::BAD_REQUEST,
};
(
status,
Json(json!({ "error": refusal.to_string(), "refused": refusal })),
)
.into_response()
}
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": e.to_string() })),
)
.into_response(),
}
}
#[derive(serde::Deserialize)]
struct AskForm {
device: String,
reason: String,
}
async fn post_request(
State(state): State<Arc<HubState>>,
headers: HeaderMap,
Form(form): Form<AskForm>,
) -> Response {
use super::access::Role;
let Some((_, _, role)) = signer_of(&state, &headers) else {
return Redirect::to("/login").into_response();
};
if role != Role::Auditor {
return Redirect::to("/requests").into_response();
}
let now = jiff::Timestamp::now().to_string();
{
let Ok(hub) = state.hub.lock() else {
return (StatusCode::INTERNAL_SERVER_ERROR, PLAINTEXT).into_response();
};
let token =
super::admin::cookie_from(headers.get(header::COOKIE).and_then(|v| v.to_str().ok()));
let who = match session_principal(&state, token.as_deref(), &hub) {
Some(p) => p,
None => return Redirect::to("/login").into_response(),
};
let reason = form.reason.trim();
let outcome = if reason.is_empty() {
Err(
"a request needs a reason: it is the only thing the countersigner reads"
.to_string(),
)
} else {
let device = (!form.device.trim().is_empty()).then(|| form.device.trim());
hub.create_request(&who, device, None, None, reason, &now)
.map(|r| {
format!(
"Asked. {} is waiting for somebody else to countersign it.",
r.id
)
})
.map_err(|e| e.to_string())
};
if let Ok(mut f) = state.flash.lock() {
*f = Some(outcome);
}
}
Redirect::to("/requests").into_response()
}
fn session_principal(
state: &HubState,
token: Option<&str>,
hub: &super::store::HubStore,
) -> Option<super::access::Principal> {
let who = state.sessions.who(token?, jiff::Timestamp::now())?;
let super::admin::Who::Principal { id, .. } = who else {
return None;
};
hub.principals().ok()?.into_iter().find(|p| p.id == id)
}
async fn approve(
State(state): State<Arc<HubState>>,
headers: HeaderMap,
axum::extract::Path(id): axum::extract::Path<String>,
) -> Response {
use super::access::Role;
let Some((_, _, role)) = signer_of(&state, &headers) else {
return Redirect::to("/login").into_response();
};
if role != Role::Countersigner {
return Redirect::to("/requests").into_response();
}
let now = jiff::Timestamp::now();
{
let Ok(hub) = state.hub.lock() else {
return (StatusCode::INTERNAL_SERVER_ERROR, PLAINTEXT).into_response();
};
let token =
super::admin::cookie_from(headers.get(header::COOKIE).and_then(|v| v.to_str().ok()));
let Some(who) = session_principal(&state, token.as_deref(), &hub) else {
return Redirect::to("/login").into_response();
};
let until = (now + jiff::Span::new().days(7)).to_string();
let message = match hub.approve_request(&id, &who, &until, &now.to_string()) {
Ok(r) => Ok(format!(
"{} is open until {}.",
r.id,
r.expires_at.as_deref().unwrap_or(&until)
)),
Err(d) => Err(d.to_string()),
};
if let Ok(mut f) = state.flash.lock() {
*f = Some(message);
}
}
Redirect::to("/requests").into_response()
}
async fn countersign(
State(state): State<Arc<HubState>>,
headers: HeaderMap,
axum::extract::Path(id): axum::extract::Path<String>,
) -> Response {
use super::access::Role;
use super::store::CountersignOutcome as O;
let Some((_, name, role)) = signer_of(&state, &headers) else {
return Redirect::to("/login").into_response();
};
if role != Role::Countersigner {
return Redirect::to("/requests").into_response();
}
let now = jiff::Timestamp::now().to_string();
{
let Ok(hub) = state.hub.lock() else {
return (StatusCode::INTERNAL_SERVER_ERROR, PLAINTEXT).into_response();
};
let token =
super::admin::cookie_from(headers.get(header::COOKIE).and_then(|v| v.to_str().ok()));
let Some(who) = session_principal(&state, token.as_deref(), &hub) else {
return Redirect::to("/login").into_response();
};
let message = match hub.countersign_grant(&id, &who, &now) {
Ok(O::Signed) => Ok(format!("{id} takes effect now, countersigned by {name}.")),
Ok(O::Unknown) => Err(format!("{id}: no grant with that id.")),
Ok(O::Withdrawn) => Err(format!(
"{id} was withdrawn. Reviving it is a new decision: it needs a new grant, \
with its reason."
)),
Ok(O::AlreadySigned { by }) => Err(format!("{id} was already countersigned by {by}.")),
Ok(O::SamePerson) => Err(format!(
"{id} was written by you. Two signatures from one hand are one signature."
)),
Err(e) => Err(e.to_string()),
};
if let Ok(mut f) = state.flash.lock() {
*f = Some(message);
}
}
Redirect::to("/requests").into_response()
}