use std::net::SocketAddr;
use axum::{
extract::{ConnectInfo, Path, Query, State},
http::{HeaderMap, StatusCode},
response::{IntoResponse, Response},
Extension, Json,
};
use serde::{Deserialize, Serialize};
use serde_json::json;
use tracing::info;
use crate::{
auth::ResolvedAuth,
license::LicenseStatus,
store::{
audit::{
AuditEvent, ACTION_SECRET_BURNED, ACTION_SECRET_CREATE, ACTION_SECRET_DELETE,
ACTION_SECRET_LIST, ACTION_SECRET_PATCH, ACTION_SECRET_PRUNE, ACTION_SECRET_READ,
ACTION_WEBHOOK_CREATE, ACTION_WEBHOOK_DELETE,
},
AuditQuery, GetResult,
},
webhooks::{self, MAX_WEBHOOKS},
AppState,
};
const MAX_TTL_SECS: u64 = 315_360_000;
fn validate_key_name(key: &str) -> bool {
!key.is_empty()
&& key.len() <= 256
&& key
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.'))
}
fn bad_key_name() -> Response {
(
StatusCode::BAD_REQUEST,
Json(json!({"error": "key must be 1–256 characters: alphanumeric, -, _, . only"})),
)
.into_response()
}
fn extract_ip(headers: &HeaderMap, addr: &SocketAddr, trusted_proxies: &[ipnet::IpNet]) -> String {
let peer = addr.ip();
if !trusted_proxies.is_empty() && trusted_proxies.iter().any(|net| net.contains(&peer)) {
if let Some(xff) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok()) {
if let Some(first) = xff.split(',').next() {
let trimmed = first.trim();
if !trimmed.is_empty() {
return trimmed.to_owned();
}
}
}
if let Some(real_ip) = headers.get("x-real-ip").and_then(|v| v.to_str().ok()) {
let trimmed = real_ip.trim();
if !trimmed.is_empty() {
return trimmed.to_owned();
}
}
}
peer.to_string()
}
pub async fn health() -> impl IntoResponse {
Json(json!({"status": "ok"}))
}
#[derive(Debug, Deserialize)]
pub struct AuditQueryParams {
pub since: Option<i64>,
pub until: Option<i64>,
pub action: Option<String>,
pub limit: Option<usize>,
}
pub async fn audit_events(
State(state): State<AppState>,
Extension(_auth): Extension<ResolvedAuth>,
Query(params): Query<AuditQueryParams>,
) -> Response {
let limit = params.limit.unwrap_or(100).min(1000);
let query = AuditQuery {
since: params.since,
until: params.until,
action: params.action,
limit,
org_id: None,
};
match state.store.list_audit(&query) {
Ok(events) => {
if state.redact_audit_keys {
use sha2::{Digest, Sha256};
let redacted: Vec<_> = events
.into_iter()
.map(|mut e| {
if let Some(ref k) = e.key {
let hash = Sha256::digest(k.as_bytes());
e.key = Some(format!("sha256:{}", &hex::encode(hash)[..8]));
}
e
})
.collect();
Json(json!({ "events": redacted })).into_response()
} else {
Json(json!({ "events": events })).into_response()
}
}
Err(e) => internal_error(e),
}
}
pub async fn list_secrets(
State(state): State<AppState>,
Extension(_auth): Extension<ResolvedAuth>,
headers: HeaderMap,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
) -> Response {
let ip = extract_ip(&headers, &addr, &state.trusted_proxies);
match state.store.list() {
Ok(metas) => {
info!(count = metas.len(), "audit: secret.list");
let _ = state.store.record_audit(AuditEvent::new(
ACTION_SECRET_LIST,
None,
ip,
true,
Some(format!("count={}", metas.len())),
None,
None,
));
Json(json!({ "secrets": metas })).into_response()
}
Err(e) => internal_error(e),
}
}
#[derive(Debug, Deserialize)]
pub struct CreateRequest {
pub key: String,
pub value: String,
pub ttl_seconds: Option<u64>,
pub max_reads: Option<u32>,
pub delete: Option<bool>,
pub webhook_url: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct CreateResponse {
pub key: String,
}
pub async fn create_secret(
State(state): State<AppState>,
headers: HeaderMap,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
Json(body): Json<CreateRequest>,
) -> Response {
let ip = extract_ip(&headers, &addr, &state.trusted_proxies);
if !validate_key_name(&body.key) {
return bad_key_name();
}
if body.max_reads == Some(0) {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "max_reads must be ≥ 1; omit to allow unlimited reads"})),
)
.into_response();
}
if body.value.len() > 1_048_576 {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "value exceeds 1 MiB limit"})),
)
.into_response();
}
if let Some(ttl) = body.ttl_seconds {
if ttl > MAX_TTL_SECS {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": format!("ttl_seconds exceeds maximum of {MAX_TTL_SECS} (10 years)")})),
)
.into_response();
}
}
if let Some(ref wurl) = body.webhook_url {
if let Err(reason) = webhooks::validate_webhook_url(wurl, &state.webhook_allowed_origins) {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": format!("webhook_url: {reason}")})),
)
.into_response();
}
}
let max_reads = body.max_reads.or(Some(1));
match state.store.put(
&body.key,
&body.value,
body.ttl_seconds,
max_reads,
body.delete.unwrap_or(true),
body.webhook_url.clone(),
) {
Ok(()) => {
info!(
key = %body.key,
ttl_seconds = ?body.ttl_seconds,
max_reads = ?max_reads,
"audit: secret.create"
);
let _ = state.store.record_audit(AuditEvent::new(
ACTION_SECRET_CREATE,
Some(body.key.clone()),
ip,
true,
None,
None,
None,
));
if let Some(ref sender) = state.webhook_sender {
sender.fire("secret.created", &body.key, json!({}));
}
(StatusCode::CREATED, Json(CreateResponse { key: body.key })).into_response()
}
Err(e) => internal_error(e),
}
}
pub async fn get_secret(
State(state): State<AppState>,
headers: HeaderMap,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
Path(key): Path<String>,
) -> Response {
if !validate_key_name(&key) {
return bad_key_name();
}
let ip = extract_ip(&headers, &addr, &state.trusted_proxies);
match state.store.get(&key) {
Ok(GetResult::Value(value, webhook_url)) => {
let _ = state.store.record_audit(AuditEvent::new(
ACTION_SECRET_READ,
Some(key.clone()),
ip,
true,
None,
None,
None,
));
if let Some(ref sender) = state.webhook_sender {
sender.fire("secret.read", &key, json!({}));
if let Some(ref url) = webhook_url {
sender.fire_for_url(url, "secret.read", &key, json!({}));
}
}
Json(json!({ "key": key, "value": value })).into_response()
}
Ok(GetResult::Burned(value, webhook_url)) => {
let _ = state.store.record_audit(AuditEvent::new(
ACTION_SECRET_BURNED,
Some(key.clone()),
ip,
true,
None,
None,
None,
));
if let Some(ref sender) = state.webhook_sender {
sender.fire("secret.burned", &key, json!({}));
if let Some(ref url) = webhook_url {
sender.fire_for_url(url, "secret.burned", &key, json!({}));
}
}
Json(json!({ "key": key, "value": value })).into_response()
}
Ok(GetResult::Sealed) => {
let _ = state.store.record_audit(AuditEvent::new(
ACTION_SECRET_READ,
Some(key.clone()),
ip,
false,
Some("sealed".into()),
None,
None,
));
(
StatusCode::GONE,
Json(json!({"error": "secret is sealed — reads exhausted"})),
)
.into_response()
}
Ok(GetResult::NotFound) => {
let _ = state.store.record_audit(AuditEvent::new(
ACTION_SECRET_READ,
Some(key.clone()),
ip,
false,
Some("not found or expired".into()),
None,
None,
));
(
StatusCode::NOT_FOUND,
Json(json!({"error": "not found or expired"})),
)
.into_response()
}
Err(e) => internal_error(e),
}
}
pub async fn head_secret(
State(state): State<AppState>,
headers: HeaderMap,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
Path(key): Path<String>,
) -> Response {
if !validate_key_name(&key) {
return bad_key_name();
}
let ip = extract_ip(&headers, &addr, &state.trusted_proxies);
match state.store.head(&key) {
Ok(Some((meta, sealed))) => {
let detail = if sealed { "head;sealed" } else { "head" };
let _ = state.store.record_audit(AuditEvent::new(
ACTION_SECRET_READ,
Some(key.clone()),
ip,
true,
Some(detail.into()),
None,
None,
));
let status = if sealed {
StatusCode::GONE
} else {
StatusCode::OK
};
let reads_remaining = match meta.max_reads {
Some(max) => (max.saturating_sub(meta.read_count)).to_string(),
None => "unlimited".to_string(),
};
let mut builder = Response::builder()
.status(status)
.header("X-Sirr-Read-Count", meta.read_count.to_string())
.header("X-Sirr-Reads-Remaining", reads_remaining)
.header("X-Sirr-Delete", meta.delete.to_string())
.header("X-Sirr-Created-At", meta.created_at.to_string());
if let Some(exp) = meta.expires_at {
builder = builder.header("X-Sirr-Expires-At", exp.to_string());
}
if sealed {
builder = builder.header("X-Sirr-Status", "sealed");
} else {
builder = builder.header("X-Sirr-Status", "active");
}
builder.body(axum::body::Body::empty()).unwrap()
}
Ok(None) => {
let _ = state.store.record_audit(AuditEvent::new(
ACTION_SECRET_READ,
Some(key.clone()),
ip,
false,
Some("head;not found or expired".into()),
None,
None,
));
(
StatusCode::NOT_FOUND,
Json(json!({"error": "not found or expired"})),
)
.into_response()
}
Err(e) => internal_error(e),
}
}
#[derive(Debug, Deserialize)]
pub struct PatchRequest {
pub value: Option<String>,
pub max_reads: Option<u32>,
pub ttl_seconds: Option<u64>,
}
pub async fn patch_secret(
State(state): State<AppState>,
Extension(_auth): Extension<ResolvedAuth>,
headers: HeaderMap,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
Path(key): Path<String>,
Json(body): Json<PatchRequest>,
) -> Response {
if !validate_key_name(&key) {
return bad_key_name();
}
if body.max_reads == Some(0) {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "max_reads must be ≥ 1; omit to allow unlimited reads"})),
)
.into_response();
}
let ip = extract_ip(&headers, &addr, &state.trusted_proxies);
if let Some(ref v) = body.value {
if v.len() > 1_048_576 {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "value exceeds 1 MiB limit"})),
)
.into_response();
}
}
if let Some(ttl) = body.ttl_seconds {
if ttl > MAX_TTL_SECS {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": format!("ttl_seconds exceeds maximum of {MAX_TTL_SECS} (10 years)")})),
)
.into_response();
}
}
match state.store.patch(
&key,
body.value.as_deref(),
body.max_reads,
body.ttl_seconds,
) {
Ok(Some(meta)) => {
let _ = state.store.record_audit(AuditEvent::new(
ACTION_SECRET_PATCH,
Some(key.clone()),
ip,
true,
None,
None,
None,
));
Json(meta).into_response()
}
Ok(None) => {
let _ = state.store.record_audit(AuditEvent::new(
ACTION_SECRET_PATCH,
Some(key.clone()),
ip,
false,
Some("not found or expired".into()),
None,
None,
));
(
StatusCode::NOT_FOUND,
Json(json!({"error": "not found or expired"})),
)
.into_response()
}
Err(e) => {
let msg = e.to_string();
if msg.contains("cannot patch") {
let _ = state.store.record_audit(AuditEvent::new(
ACTION_SECRET_PATCH,
Some(key.clone()),
ip,
false,
Some("conflict: delete=true".into()),
None,
None,
));
(StatusCode::CONFLICT, Json(json!({"error": msg}))).into_response()
} else if msg.starts_with("sealed:") {
let _ = state.store.record_audit(AuditEvent::new(
ACTION_SECRET_PATCH,
Some(key.clone()),
ip,
false,
Some("gone: secret read limit exhausted".into()),
None,
None,
));
(
StatusCode::GONE,
Json(json!({"error": "secret read limit exhausted"})),
)
.into_response()
} else {
internal_error(e)
}
}
}
}
pub async fn delete_secret(
State(state): State<AppState>,
Extension(_auth): Extension<ResolvedAuth>,
headers: HeaderMap,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
Path(key): Path<String>,
) -> Response {
if !validate_key_name(&key) {
return bad_key_name();
}
let ip = extract_ip(&headers, &addr, &state.trusted_proxies);
match state.store.delete(&key) {
Ok(true) => {
info!(key = %key, "audit: secret.delete");
let _ = state.store.record_audit(AuditEvent::new(
ACTION_SECRET_DELETE,
Some(key.clone()),
ip,
true,
None,
None,
None,
));
if let Some(ref sender) = state.webhook_sender {
sender.fire("secret.deleted", &key, json!({}));
}
Json(json!({"deleted": true})).into_response()
}
Ok(false) => {
info!(key = %key, "audit: secret.delete.not_found");
let _ = state.store.record_audit(AuditEvent::new(
ACTION_SECRET_DELETE,
Some(key.clone()),
ip,
false,
Some("not found".into()),
None,
None,
));
(StatusCode::NOT_FOUND, Json(json!({"error": "not found"}))).into_response()
}
Err(e) => internal_error(e),
}
}
pub async fn prune_secrets(
State(state): State<AppState>,
Extension(_auth): Extension<ResolvedAuth>,
headers: HeaderMap,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
) -> Response {
let ip = extract_ip(&headers, &addr, &state.trusted_proxies);
match state.store.prune() {
Ok(pruned_keys) => {
let n = pruned_keys.len();
info!(pruned = n, "audit: secret.prune");
let _ = state.store.record_audit(AuditEvent::new(
ACTION_SECRET_PRUNE,
None,
ip,
true,
Some(format!("pruned={n}")),
None,
None,
));
if let Some(ref sender) = state.webhook_sender {
for key in &pruned_keys {
sender.fire("secret.expired", key, json!({"reason": "manual_prune"}));
}
}
Json(json!({"pruned": n})).into_response()
}
Err(e) => internal_error(e),
}
}
#[derive(Debug, Deserialize)]
pub struct CreateWebhookRequest {
pub url: String,
pub events: Option<Vec<String>>,
}
pub async fn create_webhook(
State(state): State<AppState>,
Extension(_auth): Extension<ResolvedAuth>,
headers: HeaderMap,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
Json(body): Json<CreateWebhookRequest>,
) -> Response {
let ip = extract_ip(&headers, &addr, &state.trusted_proxies);
if state.license == LicenseStatus::Free {
return (
StatusCode::PAYMENT_REQUIRED,
Json(json!({"error": "webhooks require a SIRR_LICENSE_KEY"})),
)
.into_response();
}
if !body.url.starts_with("http://") && !body.url.starts_with("https://") {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "webhook URL must start with http:// or https://"})),
)
.into_response();
}
match state.store.count_webhooks() {
Ok(count) if count >= MAX_WEBHOOKS => {
return (
StatusCode::CONFLICT,
Json(json!({"error": format!("maximum of {MAX_WEBHOOKS} webhooks reached")})),
)
.into_response();
}
Err(e) => return internal_error(e),
_ => {}
}
let events = body.events.unwrap_or_else(|| vec!["*".to_string()]);
let id = webhooks::generate_webhook_id();
let secret = webhooks::generate_signing_secret();
let reg = webhooks::WebhookRegistration {
id: id.clone(),
url: body.url.clone(),
secret: secret.clone(),
events,
created_at: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64,
org_id: None,
};
match state.store.put_webhook(®) {
Ok(()) => {
let _ = state.store.record_audit(AuditEvent::new(
ACTION_WEBHOOK_CREATE,
None,
ip,
true,
Some(format!("id={id}")),
None,
None,
));
(
StatusCode::CREATED,
Json(json!({"id": id, "secret": secret})),
)
.into_response()
}
Err(e) => internal_error(e),
}
}
pub async fn list_webhooks(
State(state): State<AppState>,
Extension(_auth): Extension<ResolvedAuth>,
) -> Response {
match state.store.list_webhooks() {
Ok(regs) => {
let redacted: Vec<_> = regs
.iter()
.map(|r| {
json!({
"id": r.id,
"url": r.url,
"events": r.events,
"created_at": r.created_at,
})
})
.collect();
Json(json!({"webhooks": redacted})).into_response()
}
Err(e) => internal_error(e),
}
}
pub async fn delete_webhook(
State(state): State<AppState>,
Extension(_auth): Extension<ResolvedAuth>,
headers: HeaderMap,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
Path(id): Path<String>,
) -> Response {
let ip = extract_ip(&headers, &addr, &state.trusted_proxies);
match state.store.delete_webhook(&id) {
Ok(true) => {
let _ = state.store.record_audit(AuditEvent::new(
ACTION_WEBHOOK_DELETE,
None,
ip,
true,
Some(format!("id={id}")),
None,
None,
));
Json(json!({"deleted": true})).into_response()
}
Ok(false) => (
StatusCode::NOT_FOUND,
Json(json!({"error": "webhook not found"})),
)
.into_response(),
Err(e) => internal_error(e),
}
}
fn internal_error(e: anyhow::Error) -> Response {
tracing::error!(error = %e, "internal error");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "internal server error"})),
)
.into_response()
}