use kanade_shared::ipc::error::{ErrorKind, RpcError};
use kanade_shared::ipc::support::{
SupportLockParams, SupportLockResult, SupportStatusParams, SupportStatusResult,
SupportUnlockParams, SupportUnlockResult,
};
use kanade_shared::kv::{BUCKET_SERVER_SETTINGS, KEY_SERVER_SETTINGS};
use kanade_shared::wire::{ObsEvent, ServerSettings, SupportCode};
use tracing::{info, warn};
use super::super::connection::ConnectionState;
use super::super::unlock;
use super::system::HandlerResult;
use crate::obs_outbox;
pub async fn handle_support_unlock(
conn: &ConnectionState,
params: SupportUnlockParams,
) -> HandlerResult<SupportUnlockResult> {
let sid = conn.peer.user_sid.clone();
if let Some(remaining) = unlock::lockout_remaining(&sid) {
warn!(
user = %conn.peer.user,
secs = remaining.as_secs(),
"support.unlock: refused, caller is rate-limited",
);
return Err(RpcError::new(
ErrorKind::RateLimit,
format!(
"too many failed attempts; try again in {} seconds",
remaining.as_secs().max(1)
),
));
}
let code = params.code.trim().to_string();
if code.is_empty() {
return Err(RpcError::new(
ErrorKind::InvalidParams,
"support.unlock: code must not be empty",
));
}
let client = conn.nats.as_ref().ok_or_else(|| {
RpcError::new(
ErrorKind::InternalError,
"support.unlock: NATS client not wired into the connection",
)
})?;
let settings = read_server_settings(client).await?;
let usable: Vec<SupportCode> = settings
.support_codes
.iter()
.filter(|c| c.is_usable())
.cloned()
.collect();
let matched = tokio::task::spawn_blocking(move || match_code(&code, &usable))
.await
.map_err(|e| {
warn!(error = %e, "support.unlock: verify task failed");
RpcError::new(ErrorKind::InternalError, "support.unlock: verify failed")
})?;
let Some(code) = matched else {
let lockout = unlock::record_failure(&sid);
warn!(
user = %conn.peer.user,
locked_out = lockout.is_some(),
"support.unlock: rejected an unrecognised code",
);
audit(conn, "support_unlock_failed", serde_json::json!({}));
return Err(RpcError::new(
ErrorKind::Unauthorized,
"support.unlock: code not recognised",
));
};
unlock::clear_failures(&sid);
let ttl = code.effective_ttl_minutes();
let grants = unlock::grant(&sid, &code.scope, code.label.clone(), ttl);
info!(
user = %conn.peer.user,
scope = %code.scope,
ttl_minutes = ttl,
"support.unlock: granted",
);
audit(
conn,
"support_unlock",
serde_json::json!({ "scope": code.scope, "ttl_minutes": ttl, "label": code.label }),
);
Ok(SupportUnlockResult { grants })
}
pub fn handle_support_lock(
conn: &ConnectionState,
_params: SupportLockParams,
) -> HandlerResult<SupportLockResult> {
let released = unlock::lock(&conn.peer.user_sid);
if released > 0 {
info!(user = %conn.peer.user, released, "support.lock: grants released");
audit(
conn,
"support_lock",
serde_json::json!({ "released": released }),
);
}
Ok(SupportLockResult { released })
}
pub fn handle_support_status(
conn: &ConnectionState,
_params: SupportStatusParams,
) -> HandlerResult<SupportStatusResult> {
Ok(SupportStatusResult {
grants: unlock::grants(&conn.peer.user_sid),
})
}
fn match_code(code: &str, codes: &[SupportCode]) -> Option<SupportCode> {
use argon2::{Argon2, PasswordHash, PasswordVerifier};
let mut hit: Option<SupportCode> = None;
for candidate in codes {
let parsed = match PasswordHash::new(&candidate.hash) {
Ok(p) => p,
Err(e) => {
warn!(scope = %candidate.scope, error = %e, "support code hash is unparseable");
continue;
}
};
if Argon2::default()
.verify_password(code.as_bytes(), &parsed)
.is_ok()
&& hit.is_none()
{
hit = Some(candidate.clone());
}
}
hit
}
async fn read_server_settings(client: &async_nats::Client) -> HandlerResult<ServerSettings> {
let js = async_nats::jetstream::new(client.clone());
let kv = js
.get_key_value(BUCKET_SERVER_SETTINGS)
.await
.map_err(|e| {
warn!(error = %e, "support.unlock: open server_settings bucket");
RpcError::new(
ErrorKind::InternalError,
"support.unlock: server settings unavailable",
)
})?;
match kv.get(KEY_SERVER_SETTINGS).await {
Ok(Some(bytes)) => serde_json::from_slice(&bytes).map_err(|e| {
warn!(error = %e, "support.unlock: decode server_settings");
RpcError::new(
ErrorKind::InternalError,
"support.unlock: server settings are corrupt",
)
}),
Ok(None) => Ok(ServerSettings::default()),
Err(e) => {
warn!(error = %e, "support.unlock: read server_settings");
Err(RpcError::new(
ErrorKind::InternalError,
"support.unlock: server settings unavailable",
))
}
}
}
fn audit(conn: &ConnectionState, kind: &str, mut payload: serde_json::Value) {
if let Some(obj) = payload.as_object_mut() {
obj.insert("user".into(), conn.peer.user.clone().into());
obj.insert("user_sid".into(), conn.peer.user_sid.clone().into());
}
let event = ObsEvent {
pc_id: conn.pc_id.clone(),
at: chrono::Utc::now(),
kind: kind.to_string(),
source: "agent:support".to_string(),
event_record_id: Some(format!("support_{}", uuid::Uuid::new_v4().simple())),
payload,
};
let dir = obs_outbox::default_dir();
let res = obs_outbox::ensure_outbox_dir(&dir)
.and_then(|()| obs_outbox::enqueue(&dir, &event).map(|_| ()));
if let Err(e) = res {
warn!(error = %e, kind, "failed to queue support audit event");
}
}
#[cfg(test)]
mod tests {
use super::*;
fn hash_of(code: &str) -> String {
use argon2::password_hash::{PasswordHasher, SaltString, rand_core::OsRng};
let salt = SaltString::generate(&mut OsRng);
argon2::Argon2::default()
.hash_password(code.as_bytes(), &salt)
.unwrap()
.to_string()
}
fn code(scope: &str, plain: &str) -> SupportCode {
SupportCode {
scope: scope.into(),
hash: hash_of(plain),
label: None,
ttl_minutes: None,
disabled: false,
}
}
#[test]
fn matches_the_right_scope() {
let codes = vec![code("support", "hunter2"), code("admin", "correct-horse")];
assert_eq!(match_code("hunter2", &codes).unwrap().scope, "support");
assert_eq!(match_code("correct-horse", &codes).unwrap().scope, "admin");
}
#[test]
fn rejects_a_wrong_code() {
let codes = vec![code("support", "hunter2")];
assert!(match_code("hunter3", &codes).is_none());
assert!(match_code("", &codes).is_none());
}
#[test]
fn no_configured_codes_matches_nothing() {
assert!(match_code("anything", &[]).is_none());
}
#[test]
fn skips_an_unparseable_hash_without_matching() {
let codes = vec![SupportCode {
scope: "broken".into(),
hash: "not-a-phc-string".into(),
..Default::default()
}];
assert!(match_code("not-a-phc-string", &codes).is_none());
assert!(match_code("anything", &codes).is_none());
}
#[test]
fn a_later_scope_still_matches_after_an_earlier_miss() {
let codes = vec![code("a", "aaa"), code("b", "bbb"), code("c", "ccc")];
assert_eq!(match_code("ccc", &codes).unwrap().scope, "c");
}
}