Archipelago — open-source initial import

This commit is contained in:
Archipelago
2026-08-12 10:55:50 +00:00
commit e9c69062fe
1936 changed files with 443175 additions and 0 deletions
+201
View File
@@ -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());
}
}
+394
View File
@@ -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"]);
}
}
+495
View File
@@ -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);
}
}
+293
View File
@@ -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)
}
+5
View File
@@ -0,0 +1,5 @@
pub mod did_dht;
pub mod dns;
pub mod dwn_store;
pub mod dwn_sync;
pub mod router;
+471
View File
@@ -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![],
},
}))
}