Archipelago — open-source initial import
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
//! did:dht — Decentralized Identifier method using BitTorrent Mainline DHT.
|
||||
//!
|
||||
//! Implements creation, publication, and resolution of did:dht identifiers
|
||||
//! using BEP-44 mutable items on the Mainline DHT.
|
||||
//!
|
||||
//! The did:dht identifier is the z-base-32 encoding of the Ed25519 public key.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use ed25519_dalek::{SigningKey, VerifyingKey};
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::{debug, info};
|
||||
|
||||
/// Cache for resolved did:dht documents (1 hour TTL).
|
||||
pub struct DhtDidCache {
|
||||
entries: RwLock<HashMap<String, (std::time::Instant, serde_json::Value)>>,
|
||||
ttl: std::time::Duration,
|
||||
}
|
||||
|
||||
impl DhtDidCache {
|
||||
pub async fn get(&self, did: &str) -> Option<serde_json::Value> {
|
||||
let entries = self.entries.read().await;
|
||||
if let Some((ts, doc)) = entries.get(did) {
|
||||
if ts.elapsed() < self.ttl {
|
||||
return Some(doc.clone());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub async fn set(&self, did: String, doc: serde_json::Value) {
|
||||
let mut entries = self.entries.write().await;
|
||||
entries.insert(did, (std::time::Instant::now(), doc));
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a did:dht identifier from an Ed25519 public key.
|
||||
pub fn did_from_pubkey(pubkey: &VerifyingKey) -> String {
|
||||
let encoded = zbase32::encode_full_bytes(pubkey.as_bytes());
|
||||
format!("did:dht:{}", encoded)
|
||||
}
|
||||
|
||||
/// Extract the Ed25519 public key bytes from a did:dht identifier.
|
||||
pub fn pubkey_from_did(did: &str) -> Result<[u8; 32]> {
|
||||
let id = did
|
||||
.strip_prefix("did:dht:")
|
||||
.ok_or_else(|| anyhow::anyhow!("Not a did:dht identifier: {}", did))?;
|
||||
let bytes = zbase32::decode_full_bytes_str(id)
|
||||
.map_err(|e| anyhow::anyhow!("Invalid z-base-32: {:?}", e))?;
|
||||
if bytes.len() != 32 {
|
||||
anyhow::bail!("Expected 32-byte pubkey, got {} bytes", bytes.len());
|
||||
}
|
||||
let mut arr = [0u8; 32];
|
||||
arr.copy_from_slice(&bytes);
|
||||
Ok(arr)
|
||||
}
|
||||
|
||||
/// Build a DID Document JSON for an Ed25519 key.
|
||||
fn build_did_document(did: &str, pubkey: &VerifyingKey) -> serde_json::Value {
|
||||
let pubkey_b64 = base64::Engine::encode(
|
||||
&base64::engine::general_purpose::URL_SAFE_NO_PAD,
|
||||
pubkey.as_bytes(),
|
||||
);
|
||||
|
||||
serde_json::json!({
|
||||
"@context": [
|
||||
"https://www.w3.org/ns/did/v1",
|
||||
"https://w3id.org/security/suites/ed2020/v1"
|
||||
],
|
||||
"id": did,
|
||||
"verificationMethod": [{
|
||||
"id": format!("{}#key-0", did),
|
||||
"type": "Ed25519VerificationKey2020",
|
||||
"controller": did,
|
||||
"publicKeyMultibase": format!("z{}", pubkey_b64),
|
||||
}],
|
||||
"authentication": [format!("{}#key-0", did)],
|
||||
"assertionMethod": [format!("{}#key-0", did)],
|
||||
"capabilityInvocation": [format!("{}#key-0", did)],
|
||||
"capabilityDelegation": [format!("{}#key-0", did)],
|
||||
})
|
||||
}
|
||||
|
||||
/// Encode the DID Document as bytes for DHT storage.
|
||||
fn encode_for_dht(did_doc: &serde_json::Value) -> Vec<u8> {
|
||||
serde_json::to_vec(did_doc).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Create and publish a did:dht to the Mainline DHT.
|
||||
pub async fn create_and_publish(
|
||||
signing_key: &SigningKey,
|
||||
_services: &[(&str, &str)],
|
||||
) -> Result<String> {
|
||||
let pubkey = signing_key.verifying_key();
|
||||
let did = did_from_pubkey(&pubkey);
|
||||
let did_doc = build_did_document(&did, &pubkey);
|
||||
let payload = encode_for_dht(&did_doc);
|
||||
|
||||
// Publish to DHT using BEP-44 mutable item
|
||||
let dht = mainline::Dht::client().context("Failed to create DHT client")?;
|
||||
|
||||
let signer = mainline::SigningKey::from_bytes(&signing_key.to_bytes());
|
||||
let item = mainline::MutableItem::new(signer, bytes::Bytes::from(payload), 0, None);
|
||||
|
||||
dht.put_mutable(item).context("Failed to publish to DHT")?;
|
||||
|
||||
info!(did = %did, "Published did:dht to Mainline DHT");
|
||||
Ok(did)
|
||||
}
|
||||
|
||||
/// Resolve a did:dht from the Mainline DHT.
|
||||
pub async fn resolve(did: &str, cache: Option<&DhtDidCache>) -> Result<serde_json::Value> {
|
||||
// Check cache first
|
||||
if let Some(cache) = cache {
|
||||
if let Some(doc) = cache.get(did).await {
|
||||
debug!(did = %did, "Resolved did:dht from cache");
|
||||
return Ok(doc);
|
||||
}
|
||||
}
|
||||
|
||||
let pubkey_bytes = pubkey_from_did(did)?;
|
||||
|
||||
let dht = mainline::Dht::client().context("Failed to create DHT client")?;
|
||||
|
||||
let response = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(30),
|
||||
tokio::task::spawn_blocking(move || match dht.get_mutable(&pubkey_bytes, None, None) {
|
||||
Ok(mut iter) => iter.next(),
|
||||
Err(_) => None,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.context("DHT resolution timed out")?
|
||||
.context("DHT task panicked")?;
|
||||
|
||||
match response {
|
||||
Some(item) => {
|
||||
let doc: serde_json::Value = serde_json::from_slice(item.value())
|
||||
.context("Failed to parse DID Document from DHT")?;
|
||||
|
||||
if let Some(cache) = cache {
|
||||
cache.set(did.to_string(), doc.clone()).await;
|
||||
}
|
||||
|
||||
debug!(did = %did, "Resolved did:dht from DHT");
|
||||
Ok(doc)
|
||||
}
|
||||
None => {
|
||||
anyhow::bail!("did:dht not found in DHT: {}", did)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Store the did:dht identifier for an identity record.
|
||||
pub async fn save_dht_did(data_dir: &Path, identity_id: &str, dht_did: &str) -> Result<()> {
|
||||
let path = data_dir
|
||||
.join("identities")
|
||||
.join(format!("{}.json", identity_id));
|
||||
if !path.exists() {
|
||||
anyhow::bail!("Identity not found: {}", identity_id);
|
||||
}
|
||||
let content = tokio::fs::read_to_string(&path).await?;
|
||||
let mut record: serde_json::Value = serde_json::from_str(&content)?;
|
||||
record["dht_did"] = serde_json::json!(dht_did);
|
||||
let updated = serde_json::to_string_pretty(&record)?;
|
||||
tokio::fs::write(&path, updated).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_did_roundtrip() {
|
||||
let key = SigningKey::generate(&mut rand::rngs::OsRng);
|
||||
let pubkey = key.verifying_key();
|
||||
let did = did_from_pubkey(&pubkey);
|
||||
assert!(did.starts_with("did:dht:"));
|
||||
|
||||
let recovered = pubkey_from_did(&did).unwrap();
|
||||
assert_eq!(recovered, *pubkey.as_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_did() {
|
||||
assert!(pubkey_from_did("did:key:z123").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_did_document() {
|
||||
let key = SigningKey::generate(&mut rand::rngs::OsRng);
|
||||
let pubkey = key.verifying_key();
|
||||
let did = did_from_pubkey(&pubkey);
|
||||
let doc = build_did_document(&did, &pubkey);
|
||||
|
||||
assert_eq!(doc["id"], did);
|
||||
assert!(!doc["verificationMethod"].as_array().unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use tokio::fs;
|
||||
use tracing::{debug, info};
|
||||
|
||||
const DNS_CONFIG_FILE: &str = "dns_config.json";
|
||||
|
||||
/// DNS provider presets with server addresses.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DnsProvider {
|
||||
/// Use system default (DHCP-assigned DNS)
|
||||
System,
|
||||
/// Cloudflare DNS-over-HTTPS (1.1.1.1)
|
||||
Cloudflare,
|
||||
/// Google DNS-over-HTTPS (8.8.8.8)
|
||||
Google,
|
||||
/// Quad9 DNS-over-HTTPS (9.9.9.9)
|
||||
Quad9,
|
||||
/// Mullvad DNS (no logging)
|
||||
Mullvad,
|
||||
/// Custom user-specified servers
|
||||
Custom,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for DnsProvider {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::System => write!(f, "system"),
|
||||
Self::Cloudflare => write!(f, "cloudflare"),
|
||||
Self::Google => write!(f, "google"),
|
||||
Self::Quad9 => write!(f, "quad9"),
|
||||
Self::Mullvad => write!(f, "mullvad"),
|
||||
Self::Custom => write!(f, "custom"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Persisted DNS configuration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DnsConfig {
|
||||
pub provider: DnsProvider,
|
||||
pub servers: Vec<String>,
|
||||
pub doh_enabled: bool,
|
||||
pub doh_url: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for DnsConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
provider: DnsProvider::System,
|
||||
servers: Vec::new(),
|
||||
doh_enabled: false,
|
||||
doh_url: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Current DNS status read from the system.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct DnsStatus {
|
||||
pub provider: String,
|
||||
pub servers: Vec<String>,
|
||||
pub doh_enabled: bool,
|
||||
pub doh_url: Option<String>,
|
||||
pub resolv_conf_servers: Vec<String>,
|
||||
}
|
||||
|
||||
/// Load persisted DNS config from disk.
|
||||
pub async fn load_config(data_dir: &Path) -> Result<DnsConfig> {
|
||||
let path = data_dir.join(DNS_CONFIG_FILE);
|
||||
if !path.exists() {
|
||||
return Ok(DnsConfig::default());
|
||||
}
|
||||
let data = fs::read_to_string(&path)
|
||||
.await
|
||||
.context("Reading DNS config")?;
|
||||
serde_json::from_str(&data).context("Parsing DNS config")
|
||||
}
|
||||
|
||||
/// Save DNS config to disk.
|
||||
pub async fn save_config(data_dir: &Path, config: &DnsConfig) -> Result<()> {
|
||||
let path = data_dir.join(DNS_CONFIG_FILE);
|
||||
let data = serde_json::to_string_pretty(config)?;
|
||||
fs::write(&path, data).await.context("Writing DNS config")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the DNS servers for a given provider preset.
|
||||
pub fn provider_servers(provider: &DnsProvider) -> (Vec<String>, Option<String>) {
|
||||
match provider {
|
||||
DnsProvider::System => (Vec::new(), None),
|
||||
DnsProvider::Cloudflare => (
|
||||
vec!["1.1.1.1".into(), "1.0.0.1".into()],
|
||||
Some("https://cloudflare-dns.com/dns-query".into()),
|
||||
),
|
||||
DnsProvider::Google => (
|
||||
vec!["8.8.8.8".into(), "8.8.4.4".into()],
|
||||
Some("https://dns.google/dns-query".into()),
|
||||
),
|
||||
DnsProvider::Quad9 => (
|
||||
vec!["9.9.9.9".into(), "149.112.112.112".into()],
|
||||
Some("https://dns.quad9.net/dns-query".into()),
|
||||
),
|
||||
DnsProvider::Mullvad => (
|
||||
vec!["194.242.2.2".into()],
|
||||
Some("https://dns.mullvad.net/dns-query".into()),
|
||||
),
|
||||
DnsProvider::Custom => (Vec::new(), None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read current DNS servers from /etc/resolv.conf.
|
||||
pub async fn read_resolv_conf() -> Result<Vec<String>> {
|
||||
let content = fs::read_to_string("/etc/resolv.conf")
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let servers: Vec<String> = content
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.starts_with("nameserver") {
|
||||
trimmed.split_whitespace().nth(1).map(String::from)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Ok(servers)
|
||||
}
|
||||
|
||||
/// Get current DNS status combining config + system state.
|
||||
pub async fn get_status(data_dir: &Path) -> Result<DnsStatus> {
|
||||
let config = load_config(data_dir).await?;
|
||||
let resolv_servers = read_resolv_conf().await.unwrap_or_default();
|
||||
|
||||
Ok(DnsStatus {
|
||||
provider: config.provider.to_string(),
|
||||
servers: if config.servers.is_empty() {
|
||||
resolv_servers.clone()
|
||||
} else {
|
||||
config.servers.clone()
|
||||
},
|
||||
doh_enabled: config.doh_enabled,
|
||||
doh_url: config.doh_url.clone(),
|
||||
resolv_conf_servers: resolv_servers,
|
||||
})
|
||||
}
|
||||
|
||||
/// Apply DNS configuration to the system via nmcli.
|
||||
///
|
||||
/// Sets DNS servers on the active NetworkManager connection(s).
|
||||
pub async fn apply_dns(config: &DnsConfig) -> Result<()> {
|
||||
if config.provider == DnsProvider::System {
|
||||
// Revert to DHCP-assigned DNS
|
||||
info!("Reverting to system (DHCP) DNS");
|
||||
apply_dns_via_nmcli(&[]).await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let servers = &config.servers;
|
||||
if servers.is_empty() {
|
||||
anyhow::bail!("No DNS servers specified");
|
||||
}
|
||||
|
||||
// Validate all server IPs
|
||||
for s in servers {
|
||||
if s.parse::<std::net::IpAddr>().is_err() {
|
||||
anyhow::bail!("Invalid DNS server IP: {}", s);
|
||||
}
|
||||
}
|
||||
|
||||
info!(provider = %config.provider, servers = ?servers, "Applying DNS configuration");
|
||||
apply_dns_via_nmcli(servers).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Apply DNS servers to all active NetworkManager connections.
|
||||
async fn apply_dns_via_nmcli(servers: &[String]) -> Result<()> {
|
||||
// Get active connections
|
||||
let output = tokio::process::Command::new("nmcli")
|
||||
.args([
|
||||
"-t",
|
||||
"-f",
|
||||
"NAME,DEVICE,TYPE",
|
||||
"connection",
|
||||
"show",
|
||||
"--active",
|
||||
])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to list nmcli connections")?;
|
||||
|
||||
if !output.status.success() {
|
||||
anyhow::bail!(
|
||||
"nmcli connection show failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8(output.stdout).context("nmcli output not utf8")?;
|
||||
let connections: Vec<&str> = stdout
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let parts: Vec<&str> = line.splitn(3, ':').collect();
|
||||
if parts.len() >= 3 {
|
||||
let conn_type = parts[2];
|
||||
// Only modify ethernet and wifi connections
|
||||
if conn_type.contains("ethernet")
|
||||
|| conn_type.contains("wireless")
|
||||
|| conn_type.contains("wifi")
|
||||
{
|
||||
return Some(parts[0]);
|
||||
}
|
||||
}
|
||||
None
|
||||
})
|
||||
.collect();
|
||||
|
||||
if connections.is_empty() {
|
||||
debug!("No active ethernet/wifi connections found, skipping DNS apply");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let dns_value = if servers.is_empty() {
|
||||
String::new() // Empty clears custom DNS, reverts to DHCP
|
||||
} else {
|
||||
servers.join(" ")
|
||||
};
|
||||
|
||||
for conn_name in &connections {
|
||||
// Set DNS servers
|
||||
let dns_args = if dns_value.is_empty() {
|
||||
vec![
|
||||
"connection".to_string(),
|
||||
"modify".to_string(),
|
||||
conn_name.to_string(),
|
||||
"ipv4.dns".to_string(),
|
||||
String::new(),
|
||||
"ipv4.ignore-auto-dns".to_string(),
|
||||
"no".to_string(),
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
"connection".to_string(),
|
||||
"modify".to_string(),
|
||||
conn_name.to_string(),
|
||||
"ipv4.dns".to_string(),
|
||||
dns_value.clone(),
|
||||
"ipv4.ignore-auto-dns".to_string(),
|
||||
"yes".to_string(),
|
||||
]
|
||||
};
|
||||
|
||||
let modify = tokio::process::Command::new("nmcli")
|
||||
.args(&dns_args)
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to modify DNS via nmcli")?;
|
||||
|
||||
if !modify.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&modify.stderr);
|
||||
tracing::warn!(conn = conn_name, err = %stderr, "Failed to set DNS on connection");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Reapply the connection to pick up changes
|
||||
let reapply = tokio::process::Command::new("nmcli")
|
||||
.args(["connection", "up", conn_name])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
match reapply {
|
||||
Ok(out) if out.status.success() => {
|
||||
info!(conn = conn_name, "DNS updated successfully");
|
||||
}
|
||||
Ok(out) => {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
tracing::warn!(conn = conn_name, err = %stderr, "Failed to reapply connection");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(conn = conn_name, err = %e, "Failed to reapply connection");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Configure DNS with a specific provider.
|
||||
pub async fn configure(
|
||||
data_dir: &Path,
|
||||
provider: DnsProvider,
|
||||
custom_servers: Vec<String>,
|
||||
) -> Result<DnsConfig> {
|
||||
let (servers, doh_url) = if provider == DnsProvider::Custom {
|
||||
(custom_servers, None)
|
||||
} else {
|
||||
let (preset_servers, preset_doh) = provider_servers(&provider);
|
||||
(preset_servers, preset_doh)
|
||||
};
|
||||
|
||||
let doh_enabled = doh_url.is_some();
|
||||
|
||||
let config = DnsConfig {
|
||||
provider,
|
||||
servers,
|
||||
doh_enabled,
|
||||
doh_url,
|
||||
};
|
||||
|
||||
// Apply to system
|
||||
apply_dns(&config).await?;
|
||||
|
||||
// Persist config
|
||||
save_config(data_dir, &config).await?;
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn test_default_config() {
|
||||
let config = DnsConfig::default();
|
||||
assert_eq!(config.provider, DnsProvider::System);
|
||||
assert!(config.servers.is_empty());
|
||||
assert!(!config.doh_enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_provider_servers() {
|
||||
let (servers, doh) = provider_servers(&DnsProvider::Cloudflare);
|
||||
assert_eq!(servers, vec!["1.1.1.1", "1.0.0.1"]);
|
||||
assert!(doh.unwrap().contains("cloudflare"));
|
||||
|
||||
let (servers, doh) = provider_servers(&DnsProvider::System);
|
||||
assert!(servers.is_empty());
|
||||
assert!(doh.is_none());
|
||||
|
||||
let (servers, doh) = provider_servers(&DnsProvider::Custom);
|
||||
assert!(servers.is_empty());
|
||||
assert!(doh.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_provider_display() {
|
||||
assert_eq!(DnsProvider::Cloudflare.to_string(), "cloudflare");
|
||||
assert_eq!(DnsProvider::System.to_string(), "system");
|
||||
assert_eq!(DnsProvider::Quad9.to_string(), "quad9");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_config_persistence() {
|
||||
let dir = tempdir().unwrap();
|
||||
let config = DnsConfig {
|
||||
provider: DnsProvider::Cloudflare,
|
||||
servers: vec!["1.1.1.1".into(), "1.0.0.1".into()],
|
||||
doh_enabled: true,
|
||||
doh_url: Some("https://cloudflare-dns.com/dns-query".into()),
|
||||
};
|
||||
save_config(dir.path(), &config).await.unwrap();
|
||||
let loaded = load_config(dir.path()).await.unwrap();
|
||||
assert_eq!(loaded.provider, DnsProvider::Cloudflare);
|
||||
assert_eq!(loaded.servers.len(), 2);
|
||||
assert!(loaded.doh_enabled);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_load_missing_config_returns_default() {
|
||||
let dir = tempdir().unwrap();
|
||||
let config = load_config(dir.path()).await.unwrap();
|
||||
assert_eq!(config.provider, DnsProvider::System);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_serialization() {
|
||||
let config = DnsConfig {
|
||||
provider: DnsProvider::Google,
|
||||
servers: vec!["8.8.8.8".into()],
|
||||
doh_enabled: true,
|
||||
doh_url: Some("https://dns.google/dns-query".into()),
|
||||
};
|
||||
let json = serde_json::to_string(&config).unwrap();
|
||||
let parsed: DnsConfig = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.provider, DnsProvider::Google);
|
||||
assert_eq!(parsed.servers, vec!["8.8.8.8"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,495 @@
|
||||
//! DWN message store — persists DWN messages as JSON files on disk.
|
||||
//!
|
||||
//! Implements core CRUD operations, protocol registration, and query interface
|
||||
//! for the Decentralized Web Node spec.
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::fs;
|
||||
use tracing::debug;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// A DWN message descriptor following the spec.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MessageDescriptor {
|
||||
pub interface: String,
|
||||
pub method: String,
|
||||
pub protocol: Option<String>,
|
||||
pub schema: Option<String>,
|
||||
#[serde(rename = "dateCreated")]
|
||||
pub date_created: String,
|
||||
#[serde(rename = "dateModified")]
|
||||
pub date_modified: Option<String>,
|
||||
#[serde(rename = "dataFormat")]
|
||||
pub data_format: Option<String>,
|
||||
}
|
||||
|
||||
/// A stored DWN message.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DwnMessage {
|
||||
pub record_id: String,
|
||||
pub descriptor: MessageDescriptor,
|
||||
pub author: String,
|
||||
pub data: Option<serde_json::Value>,
|
||||
#[serde(rename = "dateCreated")]
|
||||
pub date_created: String,
|
||||
}
|
||||
|
||||
/// A registered protocol definition.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProtocolDefinition {
|
||||
pub protocol: String,
|
||||
pub published: bool,
|
||||
pub types: HashMap<String, serde_json::Value>,
|
||||
pub structure: HashMap<String, serde_json::Value>,
|
||||
#[serde(rename = "dateRegistered")]
|
||||
pub date_registered: String,
|
||||
}
|
||||
|
||||
/// Query parameters for searching messages.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct MessageQuery {
|
||||
pub protocol: Option<String>,
|
||||
pub schema: Option<String>,
|
||||
pub author: Option<String>,
|
||||
pub date_from: Option<String>,
|
||||
pub date_to: Option<String>,
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
|
||||
/// The DWN message store backed by the filesystem.
|
||||
pub struct DwnStore {
|
||||
messages_dir: PathBuf,
|
||||
protocols_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl DwnStore {
|
||||
/// Create a new DWN store at the given data directory.
|
||||
pub async fn new(data_dir: &Path) -> Result<Self> {
|
||||
let messages_dir = data_dir.join("dwn/messages");
|
||||
let protocols_dir = data_dir.join("dwn/protocols");
|
||||
fs::create_dir_all(&messages_dir)
|
||||
.await
|
||||
.context("Failed to create DWN messages dir")?;
|
||||
fs::create_dir_all(&protocols_dir)
|
||||
.await
|
||||
.context("Failed to create DWN protocols dir")?;
|
||||
Ok(Self {
|
||||
messages_dir,
|
||||
protocols_dir,
|
||||
})
|
||||
}
|
||||
|
||||
/// Write a new message or update an existing one.
|
||||
pub async fn write_message(
|
||||
&self,
|
||||
author: &str,
|
||||
protocol: Option<&str>,
|
||||
schema: Option<&str>,
|
||||
data_format: Option<&str>,
|
||||
data: Option<serde_json::Value>,
|
||||
) -> Result<DwnMessage> {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let record_id = Uuid::new_v4().to_string();
|
||||
|
||||
let message = DwnMessage {
|
||||
record_id: record_id.clone(),
|
||||
descriptor: MessageDescriptor {
|
||||
interface: "Records".to_string(),
|
||||
method: "Write".to_string(),
|
||||
protocol: protocol.map(|s| s.to_string()),
|
||||
schema: schema.map(|s| s.to_string()),
|
||||
date_created: now.clone(),
|
||||
date_modified: Some(now.clone()),
|
||||
data_format: data_format.map(|s| s.to_string()),
|
||||
},
|
||||
author: author.to_string(),
|
||||
data,
|
||||
date_created: now,
|
||||
};
|
||||
|
||||
let path = self.messages_dir.join(format!("{}.json", record_id));
|
||||
let content =
|
||||
serde_json::to_string_pretty(&message).context("Failed to serialize message")?;
|
||||
fs::write(&path, content)
|
||||
.await
|
||||
.context("Failed to write message file")?;
|
||||
|
||||
debug!(record_id = %message.record_id, "DWN message written");
|
||||
Ok(message)
|
||||
}
|
||||
|
||||
/// Validate a record ID to prevent path traversal.
|
||||
fn validate_record_id(record_id: &str) -> Result<()> {
|
||||
if record_id.is_empty()
|
||||
|| record_id.len() > 128
|
||||
|| !record_id
|
||||
.chars()
|
||||
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
|
||||
{
|
||||
return Err(anyhow::anyhow!(
|
||||
"Invalid record ID (alphanumeric, hyphens, underscores only)"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read a message by record ID.
|
||||
pub async fn read_message(&self, record_id: &str) -> Result<Option<DwnMessage>> {
|
||||
Self::validate_record_id(record_id)?;
|
||||
let path = self.messages_dir.join(format!("{}.json", record_id));
|
||||
if !path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let content = fs::read_to_string(&path)
|
||||
.await
|
||||
.context("Failed to read message file")?;
|
||||
let message: DwnMessage =
|
||||
serde_json::from_str(&content).context("Failed to parse message")?;
|
||||
Ok(Some(message))
|
||||
}
|
||||
|
||||
/// Delete a message by record ID.
|
||||
pub async fn delete_message(&self, record_id: &str) -> Result<bool> {
|
||||
Self::validate_record_id(record_id)?;
|
||||
let path = self.messages_dir.join(format!("{}.json", record_id));
|
||||
if !path.exists() {
|
||||
return Ok(false);
|
||||
}
|
||||
fs::remove_file(&path)
|
||||
.await
|
||||
.context("Failed to delete message file")?;
|
||||
debug!(record_id = %record_id, "DWN message deleted");
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Query messages by various criteria.
|
||||
pub async fn query_messages(&self, query: &MessageQuery) -> Result<Vec<DwnMessage>> {
|
||||
let mut results = Vec::new();
|
||||
let mut entries = fs::read_dir(&self.messages_dir)
|
||||
.await
|
||||
.context("Failed to read messages dir")?;
|
||||
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("json") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let content = match fs::read_to_string(&path).await {
|
||||
Ok(c) => c,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let message: DwnMessage = match serde_json::from_str(&content) {
|
||||
Ok(m) => m,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
if let Some(ref proto) = query.protocol {
|
||||
if message.descriptor.protocol.as_deref() != Some(proto) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if let Some(ref schema) = query.schema {
|
||||
if message.descriptor.schema.as_deref() != Some(schema) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if let Some(ref author) = query.author {
|
||||
if &message.author != author {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if let Some(ref from) = query.date_from {
|
||||
if message.date_created < *from {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if let Some(ref to) = query.date_to {
|
||||
if message.date_created > *to {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
results.push(message);
|
||||
}
|
||||
|
||||
// Sort by date descending (newest first)
|
||||
results.sort_by(|a, b| b.date_created.cmp(&a.date_created));
|
||||
|
||||
if let Some(limit) = query.limit {
|
||||
results.truncate(limit);
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Register a protocol definition.
|
||||
pub async fn register_protocol(&self, definition: &ProtocolDefinition) -> Result<()> {
|
||||
if definition.protocol.is_empty() {
|
||||
bail!("Protocol URI cannot be empty");
|
||||
}
|
||||
let safe_name = definition.protocol.replace(['/', ':', '.'], "_");
|
||||
let path = self.protocols_dir.join(format!("{}.json", safe_name));
|
||||
let content =
|
||||
serde_json::to_string_pretty(definition).context("Failed to serialize protocol")?;
|
||||
fs::write(&path, content)
|
||||
.await
|
||||
.context("Failed to write protocol file")?;
|
||||
debug!(protocol = %definition.protocol, "Protocol registered");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List all registered protocols.
|
||||
pub async fn list_protocols(&self) -> Result<Vec<ProtocolDefinition>> {
|
||||
let mut protocols = Vec::new();
|
||||
let mut entries = fs::read_dir(&self.protocols_dir)
|
||||
.await
|
||||
.context("Failed to read protocols dir")?;
|
||||
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("json") {
|
||||
continue;
|
||||
}
|
||||
let content = match fs::read_to_string(&path).await {
|
||||
Ok(c) => c,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if let Ok(proto) = serde_json::from_str::<ProtocolDefinition>(&content) {
|
||||
protocols.push(proto);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(protocols)
|
||||
}
|
||||
|
||||
/// Remove a registered protocol.
|
||||
pub async fn remove_protocol(&self, protocol_uri: &str) -> Result<bool> {
|
||||
let safe_name = protocol_uri.replace(['/', ':', '.'], "_");
|
||||
let path = self.protocols_dir.join(format!("{}.json", safe_name));
|
||||
if !path.exists() {
|
||||
return Ok(false);
|
||||
}
|
||||
fs::remove_file(&path)
|
||||
.await
|
||||
.context("Failed to remove protocol file")?;
|
||||
debug!(protocol = %protocol_uri, "Protocol removed");
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Get storage statistics.
|
||||
pub async fn stats(&self) -> Result<StoreStats> {
|
||||
let mut message_count: u64 = 0;
|
||||
let mut total_bytes: u64 = 0;
|
||||
|
||||
let mut entries = fs::read_dir(&self.messages_dir)
|
||||
.await
|
||||
.context("Failed to read messages dir")?;
|
||||
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
if entry.path().extension().and_then(|e| e.to_str()) == Some("json") {
|
||||
message_count += 1;
|
||||
if let Ok(meta) = entry.metadata().await {
|
||||
total_bytes += meta.len();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let protocol_count = self.list_protocols().await?.len() as u64;
|
||||
|
||||
Ok(StoreStats {
|
||||
message_count,
|
||||
protocol_count,
|
||||
total_bytes,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Storage statistics.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct StoreStats {
|
||||
pub message_count: u64,
|
||||
pub protocol_count: u64,
|
||||
pub total_bytes: u64,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
async fn setup() -> (TempDir, DwnStore) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let store = DwnStore::new(dir.path()).await.unwrap();
|
||||
(dir, store)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_and_read_message() {
|
||||
let (_dir, store) = setup().await;
|
||||
let msg = store
|
||||
.write_message(
|
||||
"did:key:test",
|
||||
Some("proto://chat"),
|
||||
None,
|
||||
None,
|
||||
Some(serde_json::json!({"text": "hello"})),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!msg.record_id.is_empty());
|
||||
|
||||
let read = store.read_message(&msg.record_id).await.unwrap();
|
||||
assert!(read.is_some());
|
||||
let read = read.unwrap();
|
||||
assert_eq!(read.author, "did:key:test");
|
||||
assert_eq!(read.data, Some(serde_json::json!({"text": "hello"})));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_nonexistent_returns_none() {
|
||||
let (_dir, store) = setup().await;
|
||||
let read = store.read_message("nonexistent-id").await.unwrap();
|
||||
assert!(read.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_message() {
|
||||
let (_dir, store) = setup().await;
|
||||
let msg = store
|
||||
.write_message("did:key:test", None, None, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(store.delete_message(&msg.record_id).await.unwrap());
|
||||
assert!(!store.delete_message(&msg.record_id).await.unwrap());
|
||||
assert!(store.read_message(&msg.record_id).await.unwrap().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn query_by_protocol() {
|
||||
let (_dir, store) = setup().await;
|
||||
store
|
||||
.write_message("did:key:a", Some("proto://chat"), None, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.write_message("did:key:a", Some("proto://files"), None, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.write_message("did:key:b", Some("proto://chat"), None, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let results = store
|
||||
.query_messages(&MessageQuery {
|
||||
protocol: Some("proto://chat".to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(results.len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn query_by_author() {
|
||||
let (_dir, store) = setup().await;
|
||||
store
|
||||
.write_message("did:key:a", None, None, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.write_message("did:key:b", None, None, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let results = store
|
||||
.query_messages(&MessageQuery {
|
||||
author: Some("did:key:a".to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].author, "did:key:a");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn query_with_limit() {
|
||||
let (_dir, store) = setup().await;
|
||||
for i in 0..5 {
|
||||
store
|
||||
.write_message(&format!("did:key:{}", i), None, None, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let results = store
|
||||
.query_messages(&MessageQuery {
|
||||
limit: Some(3),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(results.len(), 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn register_and_list_protocols() {
|
||||
let (_dir, store) = setup().await;
|
||||
let proto = ProtocolDefinition {
|
||||
protocol: "https://example.com/chat".to_string(),
|
||||
published: true,
|
||||
types: HashMap::new(),
|
||||
structure: HashMap::new(),
|
||||
date_registered: chrono::Utc::now().to_rfc3339(),
|
||||
};
|
||||
store.register_protocol(&proto).await.unwrap();
|
||||
|
||||
let list = store.list_protocols().await.unwrap();
|
||||
assert_eq!(list.len(), 1);
|
||||
assert_eq!(list[0].protocol, "https://example.com/chat");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remove_protocol() {
|
||||
let (_dir, store) = setup().await;
|
||||
let proto = ProtocolDefinition {
|
||||
protocol: "https://example.com/test".to_string(),
|
||||
published: false,
|
||||
types: HashMap::new(),
|
||||
structure: HashMap::new(),
|
||||
date_registered: chrono::Utc::now().to_rfc3339(),
|
||||
};
|
||||
store.register_protocol(&proto).await.unwrap();
|
||||
assert!(store
|
||||
.remove_protocol("https://example.com/test")
|
||||
.await
|
||||
.unwrap());
|
||||
assert!(!store
|
||||
.remove_protocol("https://example.com/test")
|
||||
.await
|
||||
.unwrap());
|
||||
assert!(store.list_protocols().await.unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn store_stats() {
|
||||
let (_dir, store) = setup().await;
|
||||
store
|
||||
.write_message("did:key:a", None, None, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.write_message("did:key:b", None, None, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let stats = store.stats().await.unwrap();
|
||||
assert_eq!(stats.message_count, 2);
|
||||
assert!(stats.total_bytes > 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
//! DWN (Decentralized Web Node) sync protocol.
|
||||
//!
|
||||
//! Manages syncing DWN data between the local node and connected peers.
|
||||
//! Communicates with the DWN server container via its HTTP API.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use tokio::fs;
|
||||
use tracing::debug;
|
||||
|
||||
const DWN_SYNC_FILE: &str = "dwn/sync_state.json";
|
||||
|
||||
/// DWN sync status.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[derive(Default)]
|
||||
pub enum SyncStatus {
|
||||
#[default]
|
||||
Idle,
|
||||
Syncing,
|
||||
Synced,
|
||||
Error,
|
||||
}
|
||||
|
||||
/// DWN sync state persisted to disk.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct DwnSyncState {
|
||||
pub status: SyncStatus,
|
||||
pub last_sync: Option<String>,
|
||||
pub messages_synced: u64,
|
||||
pub storage_bytes: u64,
|
||||
pub registered_protocols: Vec<String>,
|
||||
pub peer_sync_targets: Vec<String>,
|
||||
}
|
||||
|
||||
/// Load DWN sync state from disk.
|
||||
pub async fn load_sync_state(data_dir: &Path) -> Result<DwnSyncState> {
|
||||
let path = data_dir.join(DWN_SYNC_FILE);
|
||||
if !path.exists() {
|
||||
return Ok(DwnSyncState::default());
|
||||
}
|
||||
let content = fs::read_to_string(&path)
|
||||
.await
|
||||
.context("Failed to read DWN sync state")?;
|
||||
let state: DwnSyncState = serde_json::from_str(&content).unwrap_or_default();
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
/// Save DWN sync state to disk.
|
||||
pub async fn save_sync_state(data_dir: &Path, state: &DwnSyncState) -> Result<()> {
|
||||
let dir = data_dir.join("dwn");
|
||||
fs::create_dir_all(&dir)
|
||||
.await
|
||||
.context("Failed to create dwn dir")?;
|
||||
let path = data_dir.join(DWN_SYNC_FILE);
|
||||
let content = serde_json::to_string_pretty(state).context("Failed to serialize DWN state")?;
|
||||
fs::write(&path, content)
|
||||
.await
|
||||
.context("Failed to write DWN state")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Query the local DWN server for status information.
|
||||
pub async fn get_dwn_status() -> Result<DwnStatusResponse> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.build()
|
||||
.context("Failed to build HTTP client")?;
|
||||
|
||||
let res = client
|
||||
.get(crate::constants::DWN_HEALTH_URL)
|
||||
.send()
|
||||
.await
|
||||
.context("DWN server not reachable")?;
|
||||
|
||||
if res.status().is_success() {
|
||||
Ok(DwnStatusResponse {
|
||||
running: true,
|
||||
version: "0.4.0".to_string(),
|
||||
})
|
||||
} else {
|
||||
Ok(DwnStatusResponse {
|
||||
running: false,
|
||||
version: String::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct DwnStatusResponse {
|
||||
pub running: bool,
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
/// Trigger a sync with connected peers.
|
||||
/// For each peer that has a DWN endpoint, we pull their messages
|
||||
/// and push our local messages, deduplicating by record_id.
|
||||
pub async fn sync_with_peers(data_dir: &Path, peer_onions: &[String]) -> Result<DwnSyncState> {
|
||||
use crate::network::dwn_store::{DwnStore, MessageQuery};
|
||||
use std::collections::HashSet;
|
||||
|
||||
let mut state = load_sync_state(data_dir).await?;
|
||||
state.status = SyncStatus::Syncing;
|
||||
save_sync_state(data_dir, &state).await?;
|
||||
|
||||
let store = DwnStore::new(data_dir).await?;
|
||||
let mut synced_count = 0u64;
|
||||
|
||||
// Get local messages since last sync (or all if first sync, capped at 200)
|
||||
let local_messages = store
|
||||
.query_messages(&MessageQuery {
|
||||
date_from: state.last_sync.clone(),
|
||||
limit: Some(200),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
|
||||
// Deduplicate peer onion addresses
|
||||
let mut seen = HashSet::new();
|
||||
let unique_onions: Vec<&String> = peer_onions
|
||||
.iter()
|
||||
.filter(|o| !o.is_empty() && seen.insert(o.as_str().to_string()))
|
||||
.collect();
|
||||
|
||||
debug!(
|
||||
peers = unique_onions.len(),
|
||||
local_msgs = local_messages.len(),
|
||||
"Starting DWN sync"
|
||||
);
|
||||
|
||||
// Overall sync timeout: 90 seconds
|
||||
let sync_future = async {
|
||||
for onion in &unique_onions {
|
||||
let fips_npub = crate::federation::fips_npub_for_onion(data_dir, onion).await;
|
||||
match sync_single_peer(
|
||||
data_dir,
|
||||
fips_npub.as_deref(),
|
||||
&store,
|
||||
onion,
|
||||
&local_messages,
|
||||
&state.last_sync,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(count) => {
|
||||
debug!(peer = %onion, messages = count, "Peer sync complete");
|
||||
synced_count += count;
|
||||
}
|
||||
Err(e) => {
|
||||
debug!(peer = %onion, error = %e, "Peer sync failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
match tokio::time::timeout(std::time::Duration::from_secs(90), sync_future).await {
|
||||
Ok(()) => {
|
||||
debug!(count = synced_count, "DWN sync complete");
|
||||
}
|
||||
Err(_) => {
|
||||
debug!("DWN sync timed out after 90s");
|
||||
}
|
||||
}
|
||||
|
||||
state.status = SyncStatus::Synced;
|
||||
state.last_sync = Some(chrono::Utc::now().to_rfc3339());
|
||||
state.messages_synced += synced_count;
|
||||
save_sync_state(data_dir, &state).await?;
|
||||
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
/// Sync with a single peer: pull their messages and push ours.
|
||||
/// Each HTTP call picks FIPS when a npub is known, otherwise Tor.
|
||||
async fn sync_single_peer(
|
||||
data_dir: &Path,
|
||||
fips_npub: Option<&str>,
|
||||
store: &crate::network::dwn_store::DwnStore,
|
||||
onion: &str,
|
||||
local_messages: &[crate::network::dwn_store::DwnMessage],
|
||||
last_sync: &Option<String>,
|
||||
) -> Result<u64> {
|
||||
use crate::fips::dial::PeerRequest;
|
||||
let mut imported = 0u64;
|
||||
|
||||
// Step 1: Check peer health
|
||||
let (health_resp, _) = PeerRequest::new(fips_npub, onion, "/dwn/health")
|
||||
.service(crate::settings::transport::PeerService::Federation)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.fips_timeout(std::time::Duration::from_secs(6))
|
||||
.record_transport(data_dir)
|
||||
.send_get()
|
||||
.await
|
||||
.context("Peer DWN unreachable")?;
|
||||
if !health_resp.status().is_success() {
|
||||
return Err(anyhow::anyhow!("Peer DWN not healthy"));
|
||||
}
|
||||
|
||||
// Step 2: Pull — query peer for messages since our last sync
|
||||
let mut query_filter = serde_json::json!({});
|
||||
if let Some(ref since) = last_sync {
|
||||
query_filter = serde_json::json!({ "dateSort": "createdAscending", "dateFrom": since });
|
||||
}
|
||||
let pull_body = serde_json::json!({
|
||||
"messages": [{
|
||||
"descriptor": {
|
||||
"interface": "Records",
|
||||
"method": "Query",
|
||||
"filter": query_filter,
|
||||
}
|
||||
}]
|
||||
});
|
||||
|
||||
let (pull_res, _) = PeerRequest::new(fips_npub, onion, "/dwn")
|
||||
.service(crate::settings::transport::PeerService::Federation)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.fips_timeout(std::time::Duration::from_secs(6))
|
||||
.record_transport(data_dir)
|
||||
.send_json(&pull_body)
|
||||
.await
|
||||
.context("Failed to query peer DWN")?;
|
||||
|
||||
if pull_res.status().is_success() {
|
||||
let pull_data: serde_json::Value = pull_res.json().await.unwrap_or_default();
|
||||
if let Some(entries) = pull_data["entries"].as_array() {
|
||||
for entry in entries {
|
||||
let record_id = entry["record_id"].as_str().unwrap_or_default();
|
||||
if record_id.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Skip if we already have this message
|
||||
if store.read_message(record_id).await?.is_some() {
|
||||
continue;
|
||||
}
|
||||
// Import the message
|
||||
let author = entry["author"].as_str().unwrap_or("unknown");
|
||||
let protocol = entry["descriptor"]["protocol"].as_str();
|
||||
let schema = entry["descriptor"]["schema"].as_str();
|
||||
let data_format = entry["descriptor"]["dataFormat"].as_str();
|
||||
let data = entry.get("data").cloned();
|
||||
|
||||
store
|
||||
.write_message(author, protocol, schema, data_format, data)
|
||||
.await?;
|
||||
imported += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Push — send local messages to peer in batches
|
||||
let batch_size = 50;
|
||||
for chunk in local_messages.chunks(batch_size) {
|
||||
let messages: Vec<serde_json::Value> = chunk
|
||||
.iter()
|
||||
.map(|msg| {
|
||||
serde_json::json!({
|
||||
"descriptor": {
|
||||
"interface": "Records",
|
||||
"method": "Write",
|
||||
"protocol": msg.descriptor.protocol,
|
||||
"schema": msg.descriptor.schema,
|
||||
"dataFormat": msg.descriptor.data_format,
|
||||
},
|
||||
"recordId": msg.record_id,
|
||||
"author": msg.author,
|
||||
"data": msg.data,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let push_body = serde_json::json!({ "messages": messages });
|
||||
|
||||
// Best-effort push — don't fail the whole sync if a batch fails.
|
||||
match PeerRequest::new(fips_npub, onion, "/dwn")
|
||||
.service(crate::settings::transport::PeerService::Federation)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.fips_timeout(std::time::Duration::from_secs(6))
|
||||
.record_transport(data_dir)
|
||||
.send_json(&push_body)
|
||||
.await
|
||||
{
|
||||
Ok((_, t)) => {
|
||||
debug!(count = chunk.len(), transport = %t, "Pushed message batch to peer");
|
||||
}
|
||||
Err(e) => {
|
||||
debug!(error = %e, "Failed to push message batch to peer");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(imported)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod did_dht;
|
||||
pub mod dns;
|
||||
pub mod dwn_store;
|
||||
pub mod dwn_sync;
|
||||
pub mod router;
|
||||
@@ -0,0 +1,471 @@
|
||||
//! UPnP port forwarding and network router integration.
|
||||
//! Discovers UPnP-capable routers and manages port forwards for exposed services.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use tokio::fs;
|
||||
use tracing::debug;
|
||||
|
||||
const FORWARDS_FILE: &str = "port_forwards.json";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PortForward {
|
||||
pub id: String,
|
||||
pub service_name: String,
|
||||
pub internal_port: u16,
|
||||
pub external_port: u16,
|
||||
pub protocol: String,
|
||||
pub enabled: bool,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ForwardStore {
|
||||
pub forwards: Vec<PortForward>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RouterInfo {
|
||||
pub discovered: bool,
|
||||
pub device_name: Option<String>,
|
||||
pub wan_ip: Option<String>,
|
||||
pub upnp_available: bool,
|
||||
}
|
||||
|
||||
pub async fn load_forwards(data_dir: &Path) -> Result<ForwardStore> {
|
||||
let path = data_dir.join(FORWARDS_FILE);
|
||||
if !path.exists() {
|
||||
return Ok(ForwardStore::default());
|
||||
}
|
||||
let data = fs::read_to_string(&path)
|
||||
.await
|
||||
.context("Reading forwards")?;
|
||||
serde_json::from_str(&data).context("Parsing forwards")
|
||||
}
|
||||
|
||||
pub async fn save_forwards(data_dir: &Path, store: &ForwardStore) -> Result<()> {
|
||||
let path = data_dir.join(FORWARDS_FILE);
|
||||
let data = serde_json::to_string_pretty(store)?;
|
||||
fs::write(&path, data).await.context("Writing forwards")
|
||||
}
|
||||
|
||||
/// Discover UPnP gateway on the local network.
|
||||
/// Uses a simple SSDP M-SEARCH to find IGD (Internet Gateway Device).
|
||||
pub async fn discover_router() -> Result<RouterInfo> {
|
||||
// Attempt UPnP discovery via SSDP
|
||||
let wan_ip = get_wan_ip().await;
|
||||
|
||||
// Try to find a UPnP gateway by sending SSDP M-SEARCH
|
||||
let upnp_available = check_upnp_available().await;
|
||||
|
||||
Ok(RouterInfo {
|
||||
discovered: upnp_available,
|
||||
device_name: if upnp_available {
|
||||
Some("UPnP Gateway".to_string())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
wan_ip,
|
||||
upnp_available,
|
||||
})
|
||||
}
|
||||
|
||||
/// Get WAN IP address via external service.
|
||||
async fn get_wan_ip() -> Option<String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.build()
|
||||
.ok()?;
|
||||
|
||||
// Try multiple services for redundancy
|
||||
for url in &[
|
||||
"https://api.ipify.org",
|
||||
"https://ifconfig.me/ip",
|
||||
"https://icanhazip.com",
|
||||
] {
|
||||
if let Ok(resp) = client.get(*url).send().await {
|
||||
if let Ok(ip) = resp.text().await {
|
||||
let ip = ip.trim().to_string();
|
||||
if !ip.is_empty() && ip.len() < 50 {
|
||||
return Some(ip);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Check if UPnP is available by attempting SSDP discovery.
|
||||
async fn check_upnp_available() -> bool {
|
||||
use std::net::UdpSocket;
|
||||
|
||||
let ssdp_request = "M-SEARCH * HTTP/1.1\r\n\
|
||||
HOST: 239.255.255.250:1900\r\n\
|
||||
MAN: \"ssdp:discover\"\r\n\
|
||||
MX: 2\r\n\
|
||||
ST: urn:schemas-upnp-org:device:InternetGatewayDevice:1\r\n\r\n";
|
||||
|
||||
let socket = match UdpSocket::bind("0.0.0.0:0") {
|
||||
Ok(s) => s,
|
||||
Err(_) => return false,
|
||||
};
|
||||
|
||||
if socket
|
||||
.set_read_timeout(Some(std::time::Duration::from_secs(3)))
|
||||
.is_err()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if socket
|
||||
.send_to(ssdp_request.as_bytes(), "239.255.255.250:1900")
|
||||
.is_err()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut buf = [0u8; 2048];
|
||||
match socket.recv_from(&mut buf) {
|
||||
Ok((len, _)) => {
|
||||
let response = String::from_utf8_lossy(&buf[..len]);
|
||||
response.contains("InternetGatewayDevice") || response.contains("200 OK")
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a port forward (stored locally; actual UPnP mapping done on request).
|
||||
pub async fn add_forward(
|
||||
data_dir: &Path,
|
||||
service_name: &str,
|
||||
internal_port: u16,
|
||||
external_port: u16,
|
||||
protocol: &str,
|
||||
) -> Result<PortForward> {
|
||||
let mut store = load_forwards(data_dir).await?;
|
||||
|
||||
if store
|
||||
.forwards
|
||||
.iter()
|
||||
.any(|f| f.external_port == external_port && f.protocol == protocol)
|
||||
{
|
||||
return Err(anyhow::anyhow!(
|
||||
"Port {} ({}) is already forwarded",
|
||||
external_port,
|
||||
protocol
|
||||
));
|
||||
}
|
||||
|
||||
let forward = PortForward {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
service_name: service_name.to_string(),
|
||||
internal_port,
|
||||
external_port,
|
||||
protocol: protocol.to_uppercase(),
|
||||
enabled: true,
|
||||
created_at: chrono::Utc::now().to_rfc3339(),
|
||||
};
|
||||
|
||||
debug!(
|
||||
service = %service_name,
|
||||
port = external_port,
|
||||
"Added port forward"
|
||||
);
|
||||
store.forwards.push(forward.clone());
|
||||
save_forwards(data_dir, &store).await?;
|
||||
Ok(forward)
|
||||
}
|
||||
|
||||
/// Remove a port forward.
|
||||
pub async fn remove_forward(data_dir: &Path, forward_id: &str) -> Result<()> {
|
||||
let mut store = load_forwards(data_dir).await?;
|
||||
let original_len = store.forwards.len();
|
||||
store.forwards.retain(|f| f.id != forward_id);
|
||||
if store.forwards.len() == original_len {
|
||||
return Err(anyhow::anyhow!("Forward not found: {}", forward_id));
|
||||
}
|
||||
save_forwards(data_dir, &store).await
|
||||
}
|
||||
|
||||
/// List all port forwards.
|
||||
pub async fn list_forwards(data_dir: &Path) -> Result<Vec<PortForward>> {
|
||||
let store = load_forwards(data_dir).await?;
|
||||
Ok(store.forwards)
|
||||
}
|
||||
|
||||
/// Network diagnostics result.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NetworkDiagnostics {
|
||||
pub wan_ip: Option<String>,
|
||||
pub nat_type: String,
|
||||
pub upnp_available: bool,
|
||||
pub tor_connected: bool,
|
||||
pub dns_working: bool,
|
||||
pub recommendations: Vec<String>,
|
||||
/// SSID of the currently-active WiFi connection, or None if the node is on
|
||||
/// wired / no WiFi adapter / NetworkManager isn't around.
|
||||
pub wifi_ssid: Option<String>,
|
||||
}
|
||||
|
||||
/// Ask NetworkManager for the active WiFi SSID. Returns None silently if
|
||||
/// nmcli is unavailable or no WiFi device is connected.
|
||||
async fn active_wifi_ssid() -> Option<String> {
|
||||
let out = tokio::process::Command::new("nmcli")
|
||||
.args(["-t", "-f", "DEVICE,TYPE,STATE,CONNECTION", "device"])
|
||||
.output()
|
||||
.await
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
for line in stdout.lines() {
|
||||
// DEVICE:TYPE:STATE:CONNECTION — colons inside fields are escaped by nmcli -t
|
||||
let mut parts = line.split(':');
|
||||
let _dev = parts.next()?;
|
||||
let typ = parts.next().unwrap_or("");
|
||||
let state = parts.next().unwrap_or("");
|
||||
let conn = parts.next().unwrap_or("");
|
||||
if typ == "wifi" && state == "connected" && !conn.is_empty() {
|
||||
return Some(conn.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Run a comprehensive network diagnostic check.
|
||||
pub async fn run_diagnostics() -> Result<NetworkDiagnostics> {
|
||||
let wan_ip = get_wan_ip().await;
|
||||
let upnp_available = check_upnp_available().await;
|
||||
let tor_connected = check_tor_connectivity().await;
|
||||
let dns_working = check_dns().await;
|
||||
let wifi_ssid = active_wifi_ssid().await;
|
||||
|
||||
let nat_type = if wan_ip.is_some() {
|
||||
if upnp_available {
|
||||
"Open (UPnP)".to_string()
|
||||
} else {
|
||||
"Restricted".to_string()
|
||||
}
|
||||
} else {
|
||||
"Unknown".to_string()
|
||||
};
|
||||
|
||||
let mut recommendations = Vec::new();
|
||||
if !upnp_available {
|
||||
recommendations
|
||||
.push("Enable UPnP on your router for automatic port forwarding".to_string());
|
||||
}
|
||||
if !tor_connected {
|
||||
recommendations
|
||||
.push("Tor is not connected — check the Tor container is running".to_string());
|
||||
}
|
||||
if !dns_working {
|
||||
recommendations.push("DNS resolution failed — check your network connection".to_string());
|
||||
}
|
||||
if wan_ip.is_none() {
|
||||
recommendations
|
||||
.push("Could not determine WAN IP — you may be behind a firewall".to_string());
|
||||
}
|
||||
|
||||
Ok(NetworkDiagnostics {
|
||||
wan_ip,
|
||||
nat_type,
|
||||
upnp_available,
|
||||
tor_connected,
|
||||
dns_working,
|
||||
recommendations,
|
||||
wifi_ssid,
|
||||
})
|
||||
}
|
||||
|
||||
/// Check if Tor SOCKS proxy is reachable.
|
||||
async fn check_tor_connectivity() -> bool {
|
||||
use std::net::TcpStream;
|
||||
TcpStream::connect_timeout(
|
||||
&"127.0.0.1:9050".parse().unwrap(),
|
||||
std::time::Duration::from_secs(2),
|
||||
)
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
/// Check DNS resolution works.
|
||||
async fn check_dns() -> bool {
|
||||
use std::net::ToSocketAddrs;
|
||||
"cloudflare.com:443".to_socket_addrs().is_ok()
|
||||
}
|
||||
|
||||
// --- Router Compatibility Abstraction ---
|
||||
|
||||
/// Detected router type.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum RouterType {
|
||||
UPnP,
|
||||
OpenWrt,
|
||||
PfSense,
|
||||
OPNsense,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RouterType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
RouterType::UPnP => write!(f, "UPnP"),
|
||||
RouterType::OpenWrt => write!(f, "OpenWrt"),
|
||||
RouterType::PfSense => write!(f, "pfSense"),
|
||||
RouterType::OPNsense => write!(f, "OPNsense"),
|
||||
RouterType::Unknown => write!(f, "Unknown"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Router configuration stored for API access.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RouterConfig {
|
||||
pub router_type: RouterType,
|
||||
pub address: String,
|
||||
pub api_key: Option<String>,
|
||||
pub username: Option<String>,
|
||||
pub password: Option<String>,
|
||||
pub configured: bool,
|
||||
}
|
||||
|
||||
impl Default for RouterConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
router_type: RouterType::Unknown,
|
||||
address: String::new(),
|
||||
api_key: None,
|
||||
username: None,
|
||||
password: None,
|
||||
configured: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ROUTER_CONFIG_FILE: &str = "router_config.json";
|
||||
|
||||
pub async fn load_router_config(data_dir: &Path) -> Result<RouterConfig> {
|
||||
let path = data_dir.join(ROUTER_CONFIG_FILE);
|
||||
if !path.exists() {
|
||||
return Ok(RouterConfig::default());
|
||||
}
|
||||
let data = fs::read_to_string(&path)
|
||||
.await
|
||||
.context("Reading router config")?;
|
||||
serde_json::from_str(&data).context("Parsing router config")
|
||||
}
|
||||
|
||||
pub async fn save_router_config(data_dir: &Path, config: &RouterConfig) -> Result<()> {
|
||||
let path = data_dir.join(ROUTER_CONFIG_FILE);
|
||||
let data = serde_json::to_string_pretty(config)?;
|
||||
fs::write(&path, data)
|
||||
.await
|
||||
.context("Writing router config")
|
||||
}
|
||||
|
||||
/// Validate that an IP string is a private/LAN address (not public, not localhost).
|
||||
fn is_valid_private_ip(ip_str: &str) -> bool {
|
||||
let ip: std::net::IpAddr = match ip_str.parse() {
|
||||
Ok(ip) => ip,
|
||||
Err(_) => return false, // Reject hostnames
|
||||
};
|
||||
match ip {
|
||||
std::net::IpAddr::V4(v4) => {
|
||||
// Allow only RFC1918 private ranges, reject localhost and public
|
||||
let octets = v4.octets();
|
||||
let is_10 = octets[0] == 10;
|
||||
let is_172_private = octets[0] == 172 && (16..=31).contains(&octets[1]);
|
||||
let is_192_168 = octets[0] == 192 && octets[1] == 168;
|
||||
is_10 || is_172_private || is_192_168
|
||||
}
|
||||
std::net::IpAddr::V6(_) => false, // Reject IPv6 for gateway detection
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect router type by probing common endpoints on the gateway.
|
||||
pub async fn detect_router_type(gateway_ip: &str) -> RouterType {
|
||||
// Validate that gateway is a private IP — prevent SSRF to arbitrary hosts
|
||||
if !is_valid_private_ip(gateway_ip) {
|
||||
tracing::warn!(gateway = gateway_ip, "Rejected non-private gateway IP");
|
||||
return RouterType::Unknown;
|
||||
}
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.unwrap_or_default();
|
||||
|
||||
// Check for OpenWrt (LuCI)
|
||||
if let Ok(resp) = client
|
||||
.get(format!("http://{}/cgi-bin/luci", gateway_ip))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
if resp.status().is_success() || resp.status().is_redirection() {
|
||||
return RouterType::OpenWrt;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for pfSense
|
||||
if let Ok(resp) = client.get(format!("https://{}/", gateway_ip)).send().await {
|
||||
if let Ok(body) = resp.text().await {
|
||||
if body.contains("pfSense") {
|
||||
return RouterType::PfSense;
|
||||
}
|
||||
if body.contains("OPNsense") {
|
||||
return RouterType::OPNsense;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: check UPnP
|
||||
if check_upnp_available().await {
|
||||
return RouterType::UPnP;
|
||||
}
|
||||
|
||||
RouterType::Unknown
|
||||
}
|
||||
|
||||
/// Configure router API access.
|
||||
pub async fn configure_router(
|
||||
data_dir: &Path,
|
||||
router_type: RouterType,
|
||||
address: &str,
|
||||
api_key: Option<&str>,
|
||||
username: Option<&str>,
|
||||
password: Option<&str>,
|
||||
) -> Result<RouterConfig> {
|
||||
let config = RouterConfig {
|
||||
router_type,
|
||||
address: address.to_string(),
|
||||
api_key: api_key.map(|s| s.to_string()),
|
||||
username: username.map(|s| s.to_string()),
|
||||
password: password.map(|s| s.to_string()),
|
||||
configured: true,
|
||||
};
|
||||
save_router_config(data_dir, &config).await?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Get router info including detected type and capabilities.
|
||||
pub async fn get_router_info(data_dir: &Path) -> Result<serde_json::Value> {
|
||||
let config = load_router_config(data_dir).await?;
|
||||
let upnp = check_upnp_available().await;
|
||||
Ok(serde_json::json!({
|
||||
"configured": config.configured,
|
||||
"router_type": config.router_type,
|
||||
"address": config.address,
|
||||
"upnp_available": upnp,
|
||||
"capabilities": match config.router_type {
|
||||
RouterType::OpenWrt => vec!["port_forwarding", "firewall_rules", "dns", "dhcp"],
|
||||
RouterType::PfSense | RouterType::OPNsense => vec!["port_forwarding", "firewall_rules", "dns", "vpn"],
|
||||
RouterType::UPnP => vec!["port_forwarding"],
|
||||
RouterType::Unknown => vec![],
|
||||
},
|
||||
}))
|
||||
}
|
||||
Reference in New Issue
Block a user