use affinidi_did_resolver_cache_sdk::DIDCacheClient;
use did_hosting_common::server::didcomm_profile::{ObservedTransport, resolve_service_types};
use serde::{Deserialize, Serialize};
use tracing::debug;
use crate::error::AppError;
use crate::store::KeyspaceHandle;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum ServiceType {
Server,
Witness,
Watcher,
}
impl std::fmt::Display for ServiceType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Server => write!(f, "server"),
Self::Witness => write!(f, "witness"),
Self::Watcher => write!(f, "watcher"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum ServiceStatus {
Active,
Degraded,
Unreachable,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ServiceInstance {
pub instance_id: String,
pub service_type: ServiceType,
pub label: Option<String>,
pub url: String,
pub status: ServiceStatus,
pub last_health_check: Option<u64>,
pub registered_at: u64,
#[serde(default)]
pub metadata: serde_json::Value,
#[serde(default = "default_enabled_methods")]
pub enabled_methods: Vec<String>,
#[serde(default)]
pub served_domains: Vec<String>,
#[serde(default = "default_protocol_version")]
pub protocol_version: String,
#[serde(default)]
pub advertised_services: Option<Vec<String>>,
#[serde(default)]
pub services_checked_at: Option<u64>,
#[serde(default)]
pub trust_task_capable: bool,
#[serde(default)]
pub sync_batch_capable: bool,
#[serde(default)]
pub last_inbound_transport: Option<ObservedTransport>,
#[serde(default)]
pub last_inbound_at: Option<u64>,
#[serde(default)]
pub last_outbound_transport: Option<ObservedTransport>,
#[serde(default)]
pub last_outbound_at: Option<u64>,
}
fn default_enabled_methods() -> Vec<String> {
vec!["webvh".to_string()]
}
fn default_protocol_version() -> String {
"1.0".to_string()
}
fn instance_key(instance_id: &str) -> String {
format!("instance:{instance_id}")
}
pub async fn register_instance(
registry_ks: &KeyspaceHandle,
instance: &ServiceInstance,
) -> Result<(), AppError> {
registry_ks
.insert(instance_key(&instance.instance_id), instance)
.await
}
pub async fn deregister_instance(
registry_ks: &KeyspaceHandle,
instance_id: &str,
) -> Result<(), AppError> {
registry_ks.remove(instance_key(instance_id)).await
}
pub async fn get_instance(
registry_ks: &KeyspaceHandle,
instance_id: &str,
) -> Result<Option<ServiceInstance>, AppError> {
registry_ks.get(instance_key(instance_id)).await
}
pub async fn list_instances(
registry_ks: &KeyspaceHandle,
) -> Result<Vec<ServiceInstance>, AppError> {
let raw = registry_ks.prefix_iter_raw("instance:").await?;
let mut instances = Vec::with_capacity(raw.len());
for (_key, value) in raw {
if let Ok(instance) = serde_json::from_slice::<ServiceInstance>(&value) {
instances.push(instance);
}
}
Ok(instances)
}
pub async fn list_instances_by_type(
registry_ks: &KeyspaceHandle,
service_type: &ServiceType,
) -> Result<Vec<ServiceInstance>, AppError> {
let all = list_instances(registry_ks).await?;
Ok(all
.into_iter()
.filter(|i| &i.service_type == service_type)
.collect())
}
impl ServiceInstance {
pub fn did(&self) -> Option<&str> {
self.metadata.get("did").and_then(|v| v.as_str())
}
}
pub async fn refresh_advertised_services(
registry_ks: &KeyspaceHandle,
instance_id: &str,
did_resolver: Option<&DIDCacheClient>,
now: u64,
) -> Result<(), AppError> {
let Some(resolver) = did_resolver else {
return Ok(());
};
let Some(mut instance) = get_instance(registry_ks, instance_id).await? else {
return Ok(());
};
let Some(did) = instance.did().map(str::to_string) else {
return Ok(());
};
let Some(services) = resolve_service_types(&did, Some(resolver)).await else {
debug!(
instance_id = %instance_id,
did = %did,
"advertised-service refresh failed to resolve; keeping previous cache"
);
return Ok(());
};
debug!(
instance_id = %instance_id,
did = %did,
services = ?services,
"advertised services refreshed"
);
instance.advertised_services = Some(services);
instance.services_checked_at = Some(now);
register_instance(registry_ks, &instance).await
}
pub async fn record_inbound_transport(
registry_ks: &KeyspaceHandle,
sender_did: &str,
transport: ObservedTransport,
now: u64,
) {
let Ok(instances) = list_instances(registry_ks).await else {
return;
};
let Some(mut inst) = instances.into_iter().find(|i| i.did() == Some(sender_did)) else {
return;
};
inst.last_inbound_transport = Some(transport);
inst.last_inbound_at = Some(now);
if let Err(e) = register_instance(registry_ks, &inst).await {
debug!(did = sender_did, error = %e, "failed to record inbound transport");
}
}
pub async fn record_outbound_transport(
registry_ks: &KeyspaceHandle,
instance_id: &str,
transport: ObservedTransport,
now: u64,
) {
let Ok(Some(mut inst)) = get_instance(registry_ks, instance_id).await else {
return;
};
inst.last_outbound_transport = Some(transport);
inst.last_outbound_at = Some(now);
if let Err(e) = register_instance(registry_ks, &inst).await {
debug!(instance_id, error = %e, "failed to record outbound transport");
}
}
pub async fn update_instance_status(
registry_ks: &KeyspaceHandle,
instance_id: &str,
status: ServiceStatus,
timestamp: u64,
) -> Result<(), AppError> {
if let Some(mut instance) = get_instance(registry_ks, instance_id).await? {
instance.status = status;
instance.last_health_check = Some(timestamp);
register_instance(registry_ks, &instance).await?;
}
Ok(())
}
pub fn validate_registered_url(url: &str, allowlist: &[String]) -> Result<(), AppError> {
if allowlist.is_empty() {
return Ok(());
}
let parsed = url::Url::parse(url)
.map_err(|_| AppError::Forbidden("registered URL is malformed".into()))?;
let host = match parsed.host_str() {
Some(h) => h.to_ascii_lowercase(),
None => {
return Err(AppError::Forbidden(
"registered URL has no host component".into(),
));
}
};
if allowlist
.iter()
.any(|entry| entry.eq_ignore_ascii_case(&host))
{
Ok(())
} else {
Err(AppError::Forbidden(
"registered URL host is not in the operator-configured allowlist".into(),
))
}
}
pub fn health_status_from_timestamp(
instance: &ServiceInstance,
now: u64,
timeout_secs: u64,
) -> ServiceStatus {
match instance.last_health_check {
Some(ts) if now.saturating_sub(ts) <= timeout_secs => ServiceStatus::Active,
Some(ts) if now.saturating_sub(ts) <= timeout_secs * 2 => ServiceStatus::Degraded,
Some(_) => ServiceStatus::Unreachable,
None => {
if now.saturating_sub(instance.registered_at) <= timeout_secs * 2 {
ServiceStatus::Active
} else {
ServiceStatus::Unreachable
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn list(items: &[&str]) -> Vec<String> {
items.iter().map(|s| s.to_string()).collect()
}
#[test]
fn pre_t27_record_deserialises_with_compat_defaults() {
let json = r#"{
"instanceId": "did_example_old",
"serviceType": "server",
"label": null,
"url": "http://old.example.com",
"status": "active",
"lastHealthCheck": null,
"registeredAt": 1,
"metadata": { "did": "did:example:old" }
}"#;
let instance: ServiceInstance = serde_json::from_str(json).unwrap();
assert_eq!(instance.enabled_methods, vec!["webvh".to_string()]);
assert!(instance.served_domains.is_empty());
assert_eq!(instance.protocol_version, "1.0");
}
#[test]
fn t27_record_round_trips() {
let original = ServiceInstance {
instance_id: "x".into(),
service_type: ServiceType::Server,
label: None,
url: "http://new.example.com".into(),
status: ServiceStatus::Active,
last_health_check: None,
registered_at: 0,
metadata: serde_json::Value::Null,
enabled_methods: vec!["webvh".into(), "web".into()],
served_domains: vec!["a.example".into()],
protocol_version: "1.1".into(),
advertised_services: Some(vec!["WebVHHosting".into(), "TSPTransport".into()]),
services_checked_at: Some(1234),
trust_task_capable: true,
sync_batch_capable: true,
last_inbound_transport: Some(ObservedTransport::Tsp),
last_inbound_at: Some(1111),
last_outbound_transport: Some(ObservedTransport::Didcomm),
last_outbound_at: Some(2222),
};
let bytes = serde_json::to_vec(&original).unwrap();
let parsed: ServiceInstance = serde_json::from_slice(&bytes).unwrap();
assert_eq!(parsed.enabled_methods, original.enabled_methods);
assert_eq!(parsed.served_domains, original.served_domains);
assert_eq!(parsed.advertised_services, original.advertised_services);
assert_eq!(parsed.services_checked_at, original.services_checked_at);
assert_eq!(
parsed.last_inbound_transport,
original.last_inbound_transport
);
assert_eq!(
parsed.last_outbound_transport,
original.last_outbound_transport
);
assert_eq!(parsed.protocol_version, original.protocol_version);
}
#[test]
fn empty_allowlist_accepts_anything() {
assert!(validate_registered_url("http://anywhere.example/", &[]).is_ok());
assert!(validate_registered_url("http://169.254.169.254/", &[]).is_ok());
}
#[test]
fn host_in_allowlist_accepted() {
let allow = list(&["server-1.internal", "server-2.internal"]);
assert!(validate_registered_url("http://server-1.internal:8080/api", &allow).is_ok());
assert!(validate_registered_url("https://server-2.internal/", &allow).is_ok());
}
#[test]
fn host_match_is_case_insensitive() {
let allow = list(&["Server.Example"]);
assert!(validate_registered_url("http://SERVER.EXAMPLE/", &allow).is_ok());
assert!(validate_registered_url("http://server.example/", &allow).is_ok());
}
#[test]
fn suffix_attacker_host_rejected() {
let allow = list(&["example.com"]);
assert!(validate_registered_url("http://evil.example.com/", &allow).is_err());
}
#[test]
fn metadata_ip_rejected_when_allowlist_excludes_it() {
let allow = list(&["server-1.internal"]);
assert!(validate_registered_url("http://169.254.169.254/", &allow).is_err());
assert!(validate_registered_url("http://127.0.0.1:5432/", &allow).is_err());
assert!(validate_registered_url("http://192.168.1.10/", &allow).is_err());
}
#[test]
fn malformed_url_rejected() {
let allow = list(&["example.com"]);
let err = validate_registered_url("not a url", &allow).unwrap_err();
assert!(matches!(err, AppError::Forbidden(_)));
}
#[test]
fn url_without_host_rejected() {
let allow = list(&["example.com"]);
let err = validate_registered_url("file:///etc/passwd", &allow).unwrap_err();
assert!(matches!(err, AppError::Forbidden(_)));
}
}