blob: 9ebace6e4e3960ad5407a5c8884c179c78abd57d [file]
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
//! Shard-0 HTTP/REST listener. This root binds the listener and assembles the
//! router; the rest is split across submodules: the `state` bridge and axum
//! `State`, the bearer `extractor` and `jwt` issuer, the route `handlers`, the
//! `reads` gates, the `submit` write paths, `wire` request mapping,
//! partition-write `admission`, committed-reply `reply` decoding, the rejection
//! `error` types, and per-credential `session` state.
mod admission;
mod error;
mod extractor;
mod handlers;
mod jwt;
mod reads;
mod reply;
mod session;
mod state;
mod submit;
mod wire;
use std::cell::{Cell, RefCell};
use std::collections::HashMap;
use std::net::SocketAddr;
use std::rc::Rc;
use std::sync::Arc;
use std::sync::atomic::AtomicU64;
use axum::Router;
use axum::extract::{DefaultBodyLimit, Request};
use axum::middleware::{Next, from_fn};
use axum::routing::{delete, get, post, put};
use configs::cluster::{ClusterConfig, TransportPorts};
use configs::http::HttpConfig;
use configs::server_ng::NgSystemConfig;
use message_bus::client_listener;
use send_wrapper::SendWrapper;
use tracing::{error, info};
use crate::bootstrap::ServerNgShard;
use crate::cluster_meta::ClusterRoster;
use crate::http::handlers::{
change_password, create_cg, create_partitions, create_pat, create_stream, create_topic,
create_user, delete_cg, delete_consumer_offset, delete_partitions, delete_pat, delete_segments,
delete_stream, delete_topic, delete_user, get_cg, get_cgs, get_client, get_clients,
get_cluster_metadata, get_consumer_offset, get_pats, get_snapshot, get_stats, get_stream,
get_streams, get_topic, get_topics, get_user, get_users, login_user,
login_with_personal_access_token, logout_user, ping, poll_messages, purge_stream, purge_topic,
refresh_token, send_messages, store_consumer_offset, update_permissions, update_stream,
update_topic, update_user,
};
use crate::http::jwt::JwtManager;
use crate::http::session::RegistrationBarrier;
use crate::http::state::{HttpInner, HttpState, insert_view_header};
use crate::server_error::ServerNgError;
/// Bind the shard-0 HTTP listener and spawn the `cyper-axum` serve loop as a
/// background task on shard 0's compio runtime.
///
/// The caller gates this to shard 0 and to `http.enabled`; the listener stops
/// when the bus shutdown token fires.
///
/// # Errors
///
/// Returns [`ServerNgError`] if the JWT manager cannot be built from
/// `http_config.jwt` or the listener cannot bind to `addr`.
pub async fn start(
shard: &Rc<ServerNgShard>,
addr: SocketAddr,
http_config: &HttpConfig,
cluster: &ClusterConfig,
system_config: Arc<NgSystemConfig>,
self_ports: TransportPorts,
) -> Result<(), ServerNgError> {
let jwt = JwtManager::build(&http_config.jwt)?;
let (listener, bound_addr) = client_listener::tcp::bind(addr).await?;
info!(address = %bound_addr, "server-ng HTTP listener started");
let state: HttpState = SendWrapper::new(Rc::new(HttpInner {
shard: Rc::clone(shard),
jwt,
system_config,
sessions: RefCell::new(HashMap::new()),
registrations: RegistrationBarrier::default(),
roster: ClusterRoster {
enabled: cluster.enabled,
name: cluster.name.clone(),
nodes: cluster.nodes.clone(),
self_ip: bound_addr.ip().to_string(),
// The self node reports the live bound HTTP port; the other client
// ports arrive resolved from the caller.
self_ports: TransportPorts {
http: Some(bound_addr.port()),
..self_ports
},
// The HTTP listener is shard-0-only, where the live consensus
// handle supplies the leader; the published-view fallback is
// never consulted here.
metadata_view: Arc::new(AtomicU64::new(crate::cluster_meta::METADATA_VIEW_UNKNOWN)),
},
in_flight_writes: Cell::new(0),
}));
// Saturating: a configured limit past the pointer width (32-bit target,
// >4 GiB value) clamps to the largest enforceable cap instead of wrapping.
let max_request_size =
usize::try_from(http_config.max_request_size.as_bytes_u64()).unwrap_or(usize::MAX);
let router = router(state, max_request_size);
let shutdown = shard.bus.token();
let handle = compio::runtime::spawn(async move {
if let Err(error) = cyper_axum::serve(listener, router)
.with_graceful_shutdown(async move { shutdown.wait().await })
.await
{
error!(%error, "server-ng HTTP listener terminated with error");
}
});
shard.bus.track_background(handle);
Ok(())
}
/// Health-probe path. Public and pre-auth, and the one success route reached
/// without proving a credential, so the `X-Iggy-View` layer withholds the
/// cluster-internal view number here (see the response layer below).
const PING_PATH: &str = "/ping";
/// Assemble the shard-0 router: unauthenticated health + login routes plus the
/// authenticated REST surface.
///
/// `max_request_size` becomes the router-wide `DefaultBodyLimit` (413 past
/// it), exactly like the legacy server: it bounds the per-request term of the
/// admission math - what one body may cost in bytes and decode CPU - while
/// the in-flight caps bound the multiplier.
fn router(state: HttpState, max_request_size: usize) -> Router {
// Cloned for the response layer so `X-Iggy-View` reads the live view per
// response; the original `state` is moved into `with_state` below.
let view_source = state.clone();
Router::new()
.route(PING_PATH, get(ping))
.route("/users/login", post(login_user))
.route("/users/refresh-token", post(refresh_token))
.route(
"/personal-access-tokens/login",
post(login_with_personal_access_token),
)
.route("/users", get(get_users).post(create_user))
.route(
"/users/{user_id}",
get(get_user).put(update_user).delete(delete_user),
)
.route("/users/{user_id}/password", put(change_password))
.route("/users/{user_id}/permissions", put(update_permissions))
// Static `logout` outranks the `{user_id}` capture in axum's matcher, so
// `DELETE /users/logout` never misroutes to `delete_user`.
.route("/users/logout", delete(logout_user))
.route("/streams", get(get_streams).post(create_stream))
.route(
"/streams/{stream_id}",
get(get_stream).put(update_stream).delete(delete_stream),
)
.route("/streams/{stream_id}/purge", delete(purge_stream))
.route(
"/streams/{stream_id}/topics",
get(get_topics).post(create_topic),
)
.route(
"/streams/{stream_id}/topics/{topic_id}",
get(get_topic).put(update_topic).delete(delete_topic),
)
.route(
"/streams/{stream_id}/topics/{topic_id}/purge",
delete(purge_topic),
)
.route(
"/streams/{stream_id}/topics/{topic_id}/partitions",
post(create_partitions).delete(delete_partitions),
)
.route(
"/streams/{stream_id}/topics/{topic_id}/partitions/{partition_id}",
delete(delete_segments),
)
.route(
"/streams/{stream_id}/topics/{topic_id}/messages",
get(poll_messages).post(send_messages),
)
.route(
"/streams/{stream_id}/topics/{topic_id}/consumer-offsets",
get(get_consumer_offset).put(store_consumer_offset),
)
.route(
"/streams/{stream_id}/topics/{topic_id}/consumer-offsets/{consumer_id}",
delete(delete_consumer_offset),
)
.route(
"/streams/{stream_id}/topics/{topic_id}/consumer-groups",
get(get_cgs).post(create_cg),
)
.route(
"/streams/{stream_id}/topics/{topic_id}/consumer-groups/{group_id}",
get(get_cg).delete(delete_cg),
)
.route("/personal-access-tokens", get(get_pats).post(create_pat))
.route("/personal-access-tokens/{name}", delete(delete_pat))
.route("/stats", get(get_stats))
.route("/snapshot", post(get_snapshot))
.route("/cluster/metadata", get(get_cluster_metadata))
.route("/clients", get(get_clients))
.route("/clients/{client_id}", get(get_client))
.with_state(state)
.layer(DefaultBodyLimit::max(max_request_size))
.layer(from_fn(move |request: Request, next: Next| {
let view_source = view_source.clone();
// `/ping` is the sole success route reached without proving a
// credential, so it must not leak the cluster-internal view number
// (the anon-leak gate). Every other route authenticates before its
// handler, so a success/redirect there is an authed flow that may
// carry the header; the login routes prove credentials on success.
let suppress_view = request.uri().path() == PING_PATH;
async move {
let response = next.run(request).await;
if suppress_view {
response
} else {
insert_view_header(&view_source, response)
}
}
}))
}