1#![allow(clippy::unused_async)]
12
13use std::sync::Arc;
14
15use axum::Router;
16use axum::extract::{Request, State};
17use axum::http::{HeaderMap, StatusCode};
18use axum::middleware::{Next, from_fn, from_fn_with_state};
19use axum::response::{IntoResponse, Response};
20use axum::routing::{get, post};
21
22use crate::admin::{AdminClaim, ClaimError};
23use crate::proxy::{AppState, error_response};
24use crate::{provider_proxy, proxy, token_admin};
25
26const OPEN_PATHS: &[&str] = &[
29 "/api/admin/status",
30 "/api/admin/bootstrap",
31 "/api/admin/bootstrap/confirm",
32];
33
34pub fn router(state: AppState) -> Router {
37 Router::new()
38 .route("/api/admin/status", get(admin_status))
39 .route("/api/admin/bootstrap", post(bootstrap))
40 .route("/api/admin/bootstrap/confirm", post(bootstrap_confirm))
41 .route("/api/admin/rotate", post(rotate_credential))
42 .route("/api/admin/summary", get(admin_summary))
43 .route("/api/admin/usage", get(proxy::usage_endpoint))
44 .route("/api/admin/accounts", get(proxy::accounts_endpoint))
45 .route(
46 "/api/tokens",
47 post(token_admin::issue_token).get(token_admin::list_tokens),
48 )
49 .route("/api/tokens/list", get(token_admin::list_tokens))
50 .route("/api/tokens/revoke", post(token_admin::revoke_token))
51 .route(
52 "/api/tokens/rotate-client",
53 post(token_admin::rotate_client_token),
54 )
55 .route("/api/providers", get(provider_proxy::list_providers))
56 .route_layer(from_fn_with_state(state.clone(), require_admin))
57 .fallback(crate::admin_ui::serve_asset)
58 .layer(from_fn(crate::security_headers::apply))
61 .with_state(state)
62}
63
64async fn require_admin(State(state): State<AppState>, request: Request, next: Next) -> Response {
72 let path = request.uri().path();
73 let is_api = path.starts_with("/api/");
74 if !is_api || OPEN_PATHS.contains(&path) {
75 return next.run(request).await;
76 }
77 if proxy::is_admin_authorised(&state, request.headers()) {
78 return next.run(request).await;
79 }
80 error_response(
81 StatusCode::UNAUTHORIZED,
82 "authentication_error",
83 "admin credential required",
84 )
85}
86
87fn bearer(headers: &HeaderMap) -> Option<&str> {
88 headers
89 .get("authorization")
90 .and_then(|value| value.to_str().ok())
91 .and_then(|value| value.strip_prefix("Bearer "))
92}
93
94pub async fn admin_status(State(state): State<AppState>) -> impl IntoResponse {
96 (StatusCode::OK, axum::Json(state.admin.status())).into_response()
97}
98
99pub async fn bootstrap(
108 State(state): State<AppState>,
109 body: Option<axum::Json<TtlRequest>>,
110) -> impl IntoResponse {
111 let ttl_hours = body.and_then(|axum::Json(request)| request.ttl_hours);
112 match state.admin.begin_with_ttl(ttl_hours) {
113 Ok(candidate) => (
114 StatusCode::OK,
115 axum::Json(serde_json::json!({
116 "claim_id": candidate.claim_id,
117 "token": candidate.token,
118 "expires_in_secs": candidate.expires_in_secs,
119 "ttl_hours": candidate.ttl_hours,
120 "confirm_url": "/api/admin/bootstrap/confirm",
121 })),
122 )
123 .into_response(),
124 Err(e) => claim_error_response(e),
125 }
126}
127
128pub async fn bootstrap_confirm(
133 State(state): State<AppState>,
134 headers: HeaderMap,
135 axum::Json(req): axum::Json<ConfirmRequest>,
136) -> impl IntoResponse {
137 let Some(token) = bearer(&headers) else {
138 return error_response(
139 StatusCode::UNAUTHORIZED,
140 "authentication_error",
141 "confirm must present the candidate token as a Bearer credential",
142 );
143 };
144 match state.admin.confirm(&req.claim_id, token) {
145 Ok(()) => (
146 StatusCode::OK,
147 axum::Json(serde_json::json!({"claimed": true})),
148 )
149 .into_response(),
150 Err(e) => claim_error_response(e),
151 }
152}
153
154pub async fn rotate_credential(
157 State(state): State<AppState>,
158 body: Option<axum::Json<TtlRequest>>,
159) -> impl IntoResponse {
160 let ttl_hours = body.and_then(|axum::Json(request)| request.ttl_hours);
161 match state.admin.rotate_with_ttl(ttl_hours) {
162 Ok(token) => {
163 let status = state.admin.status();
164 (
165 StatusCode::OK,
166 axum::Json(serde_json::json!({
167 "token": token,
168 "token_id": status.token_id,
169 "credential_kind": status.credential_kind,
170 })),
171 )
172 .into_response()
173 }
174 Err(e) => claim_error_response(e),
175 }
176}
177
178#[derive(Debug, Default, serde::Deserialize)]
180pub struct TtlRequest {
181 #[serde(default)]
183 pub ttl_hours: Option<i64>,
184}
185
186pub async fn admin_summary(State(state): State<AppState>) -> impl IntoResponse {
189 let accounts = state
190 .account_router
191 .as_ref()
192 .map_or(0, crate::accounts::AccountRouter::len);
193 let credential = state
194 .oauth_provider
195 .discover_credential_path()
196 .map(|path| path.display().to_string());
197 let subscription = state.subscription_reader.as_ref().map(|reader| {
198 serde_json::json!({
199 "home": reader.home().display().to_string(),
200 "credential_found": reader.discover_credential_path().is_some(),
201 })
202 });
203 let admin_status = state.admin.status();
204 (
205 StatusCode::OK,
206 axum::Json(serde_json::json!({
207 "version": crate::VERSION,
208 "upstream_provider": state.upstream_provider.as_str(),
209 "upstream_base_url": state.upstream_base_url,
210 "accounts": accounts,
211 "claude_credential": credential,
212 "subscription": subscription,
213 "login_api_enabled": state.login_manager.is_enabled(),
214 "admin": admin_status,
215 })),
216 )
217 .into_response()
218}
219
220fn claim_error_response(error: ClaimError) -> Response {
225 let (status, kind) = match error {
226 ClaimError::AlreadyClaimed | ClaimError::ProvisionedByEnvironment => {
227 (StatusCode::CONFLICT, "already_claimed")
228 }
229 ClaimError::NoCandidate | ClaimError::ClaimIdMismatch => {
230 (StatusCode::BAD_REQUEST, "invalid_request_error")
231 }
232 ClaimError::TokenMismatch => (StatusCode::UNAUTHORIZED, "authentication_error"),
233 ClaimError::Storage => (StatusCode::INTERNAL_SERVER_ERROR, "api_error"),
234 };
235 error_response(status, kind, &error.to_string())
236}
237
238#[derive(serde::Deserialize)]
240pub struct ConfirmRequest {
241 pub claim_id: String,
243}
244
245#[must_use]
247pub fn admin_handle(state: &AppState) -> Arc<AdminClaim> {
248 Arc::clone(&state.admin)
249}