diff --git a/Cargo.lock b/Cargo.lock index 040fe82..65b7b0b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -659,6 +659,12 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" + [[package]] name = "fiat-crypto" version = "0.2.9" @@ -820,6 +826,12 @@ dependencies = [ "byteorder", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "headers" version = "0.4.0" @@ -914,6 +926,12 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "http" version = "1.1.0" @@ -1020,6 +1038,16 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "indexmap" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93ead53efc7ea8ed3cfb0c79fc8023fbb782a5432b52830b6518941cebe6505c" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "inout" version = "0.1.3" @@ -1505,11 +1533,13 @@ dependencies = [ "flume", "futures-util", "heed", + "hex", "pkarr", "postcard", "pubky-common", "serde", "tokio", + "toml", "tower-cookies", "tower-http", "tracing", @@ -1780,6 +1810,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_spanned" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb5b1b31579f3811bf615c144393417496f152e12ac8b7663bf664f4a815306d" +dependencies = [ + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -2075,6 +2114,40 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.8.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1ed1f98e3fdc28d6d910e6737ae6ab1a93bf1985935a1193e68f93eeb68d24e" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "583c44c02ad26b0c3f3066fe629275e50627026c51ac2e595cca4c230ce1ce1d" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "winnow", +] + [[package]] name = "tower" version = "0.4.13" @@ -2523,6 +2596,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "0.6.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68a9bda4691f099d435ad181000724da8e5899daa10713c2d432552b9ccd3a6f" +dependencies = [ + "memchr", +] + [[package]] name = "winreg" version = "0.52.0" diff --git a/pubky-homeserver/Cargo.toml b/pubky-homeserver/Cargo.toml index eaba493..68323b9 100644 --- a/pubky-homeserver/Cargo.toml +++ b/pubky-homeserver/Cargo.toml @@ -14,11 +14,13 @@ dirs-next = "2.0.0" flume = "0.11.0" futures-util = "0.3.30" heed = "0.20.3" +hex = "0.4.3" pkarr = { version = "2.1.0", features = ["async"] } postcard = { version = "1.0.8", features = ["alloc"] } pubky-common = { version = "0.1.0", path = "../pubky-common" } serde = { version = "1.0.204", features = ["derive"] } tokio = { version = "1.37.0", features = ["full"] } +toml = "0.8.19" tower-cookies = "0.10.0" tower-http = { version = "0.5.2", features = ["cors", "trace"] } tracing = "0.1.40" diff --git a/pubky-homeserver/README.md b/pubky-homeserver/README.md new file mode 100644 index 0000000..d1799a2 --- /dev/null +++ b/pubky-homeserver/README.md @@ -0,0 +1,23 @@ +# Pubky Homeserver + +## Usage + +Use `cargo run` + +```bash +cargo run -- --config=./src/config.toml +``` + +Or Build first then run from target. + +Build + +```bash +cargo build --release +``` + +Run with an optional config file + +```bash +../target/release/pubky-homeserver --config=./src/config.toml +``` diff --git a/pubky-homeserver/src/config.rs b/pubky-homeserver/src/config.rs index 110ed05..e177311 100644 --- a/pubky-homeserver/src/config.rs +++ b/pubky-homeserver/src/config.rs @@ -1,10 +1,14 @@ //! Configuration for the server -use anyhow::{anyhow, Result}; +use anyhow::{anyhow, Context, Result}; use pkarr::Keypair; +use serde::{Deserialize, Deserializer, Serialize}; +use std::{ + fmt::Debug, + path::{Path, PathBuf}, + time::Duration, +}; use tracing::info; -// use serde::{Deserialize, Serialize}; -use std::{fmt::Debug, path::PathBuf, time::Duration}; use pubky_common::timestamp::Timestamp; @@ -12,10 +16,7 @@ const DEFAULT_HOMESERVER_PORT: u16 = 6287; const DEFAULT_STORAGE_DIR: &str = "pubky"; /// Server configuration -#[derive( - // Serialize, Deserialize, - Clone, -)] +#[derive(Serialize, Deserialize, Clone)] pub struct Config { port: Option, bootstrap: Option>, @@ -24,20 +25,22 @@ pub struct Config { /// /// Defaults to a directory in the OS data directory storage: Option, - keypair: Keypair, + #[serde(deserialize_with = "secret_key_deserialize")] + secret_key: Option<[u8; 32]>, dht_request_timeout: Option, } impl Config { - // /// Load the config from a file. - // pub async fn load(path: impl AsRef) -> Result { - // let s = tokio::fs::read_to_string(path.as_ref()) - // .await - // .with_context(|| format!("failed to read {}", path.as_ref().to_string_lossy()))?; - // let config: Config = toml::from_str(&s)?; - // Ok(config) - // } + /// Load the config from a file. + pub async fn load(path: impl AsRef) -> Result { + let s = tokio::fs::read_to_string(path.as_ref()) + .await + .with_context(|| format!("failed to read {}", path.as_ref().to_string_lossy()))?; + + let config: Config = toml::from_str(&s)?; + Ok(config) + } /// Testnet configurations pub fn testnet() -> Self { @@ -55,7 +58,6 @@ impl Config { bootstrap, storage, port: Some(15411), - keypair: Keypair::from_secret_key(&[0_u8; 32]), dht_request_timeout: Some(Duration::from_millis(10)), ..Default::default() } @@ -103,8 +105,8 @@ impl Config { Ok(dir.join("homeserver")) } - pub fn keypair(&self) -> &Keypair { - &self.keypair + pub fn keypair(&self) -> Keypair { + Keypair::from_secret_key(&self.secret_key.unwrap_or_default()) } pub(crate) fn dht_request_timeout(&self) -> Option { @@ -119,12 +121,34 @@ impl Default for Config { bootstrap: None, domain: "localhost".to_string(), storage: None, - keypair: Keypair::random(), + secret_key: None, dht_request_timeout: None, } } } +fn secret_key_deserialize<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let opt: Option = Option::deserialize(deserializer)?; + + match opt { + Some(s) => { + let bytes = hex::decode(s).map_err(serde::de::Error::custom)?; + + if bytes.len() != 32 { + return Err(serde::de::Error::custom("Expected a 32-byte array")); + } + + let mut arr = [0u8; 32]; + arr.copy_from_slice(&bytes); + Ok(Some(arr)) + } + None => Ok(None), + } +} + impl Debug for Config { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_map() diff --git a/pubky-homeserver/src/config.toml b/pubky-homeserver/src/config.toml new file mode 100644 index 0000000..dda26e9 --- /dev/null +++ b/pubky-homeserver/src/config.toml @@ -0,0 +1,8 @@ +# Secret key (in hex) to generate the Homeserver's Keypair +secret_key = "0000000000000000000000000000000000000000000000000000000000000000" +# Domain to be published in Pkarr records for this server to be accessible by. +domain = "localhost" +# Port for the Homeserver to listen on. +port = 6287 +# Storage directory Defaults to +# storage = "" diff --git a/pubky-homeserver/src/database/tables/entries.rs b/pubky-homeserver/src/database/tables/entries.rs index 1b72274..e41a5df 100644 --- a/pubky-homeserver/src/database/tables/entries.rs +++ b/pubky-homeserver/src/database/tables/entries.rs @@ -1,7 +1,7 @@ use pkarr::PublicKey; use postcard::{from_bytes, to_allocvec}; use serde::{Deserialize, Serialize}; -use tracing::{debug, instrument}; +use tracing::instrument; use heed::{ types::{Bytes, Str}, @@ -197,8 +197,6 @@ fn next_threshold( reverse: bool, shallow: bool, ) -> String { - debug!("Fuck me!"); - format!( "{path}{file_or_directory}{}", if file_or_directory.is_empty() { diff --git a/pubky-homeserver/src/main.rs b/pubky-homeserver/src/main.rs index 2a17bda..dad25df 100644 --- a/pubky-homeserver/src/main.rs +++ b/pubky-homeserver/src/main.rs @@ -1,3 +1,5 @@ +use std::path::PathBuf; + use anyhow::Result; use pubky_homeserver::{config::Config, Homeserver}; @@ -8,8 +10,14 @@ struct Cli { /// [tracing_subscriber::EnvFilter] #[clap(short, long)] tracing_env_filter: Option, + + /// Run Homeserver in a local testnet #[clap(long)] testnet: bool, + + /// Optional Path to config file. + #[clap(short, long)] + config: Option, } #[tokio::main] @@ -25,8 +33,10 @@ async fn main() -> Result<()> { let server = Homeserver::start(if args.testnet { Config::testnet() + } else if let Some(config_path) = args.config { + Config::load(config_path).await? } else { - Default::default() + Config::default() }) .await?; diff --git a/pubky-homeserver/src/pkarr.rs b/pubky-homeserver/src/pkarr.rs index 113c598..cf4d7b7 100644 --- a/pubky-homeserver/src/pkarr.rs +++ b/pubky-homeserver/src/pkarr.rs @@ -11,6 +11,8 @@ pub async fn publish_server_packet( domain: &str, port: u16, ) -> anyhow::Result<()> { + // TODO: Try to resolve first before publishing. + let mut packet = Packet::new_reply(0); let mut svcb = SVCB::new(0, domain.try_into()?); diff --git a/pubky-homeserver/src/server.rs b/pubky-homeserver/src/server.rs index 3db7441..cdc352c 100644 --- a/pubky-homeserver/src/server.rs +++ b/pubky-homeserver/src/server.rs @@ -30,7 +30,8 @@ impl Homeserver { pub async fn start(config: Config) -> Result { debug!(?config); - let public_key = config.keypair().public_key(); + let keypair = config.keypair(); + let public_key = keypair.public_key(); let db = DB::open(&config.storage()?)?; @@ -72,7 +73,7 @@ impl Homeserver { info!("Homeserver listening on http://localhost:{port}"); - publish_server_packet(pkarr_client, config.keypair(), config.domain(), port).await?; + publish_server_packet(pkarr_client, &keypair, config.domain(), port).await?; info!("Homeserver listening on pubky://{public_key}"); diff --git a/pubky/src/shared/pkarr.rs b/pubky/src/shared/pkarr.rs index 01cd0fb..e624a2a 100644 --- a/pubky/src/shared/pkarr.rs +++ b/pubky/src/shared/pkarr.rs @@ -10,7 +10,7 @@ use crate::{ PubkyClient, }; -const MAX_RECURSIVE_PUBKY_HOMESERVER_RESOLUTION: u8 = 3; +const MAX_ENDPOINT_RESOLUTION_RECURSION: u8 = 3; impl PubkyClient { /// Publish the SVCB record for `_pubky.`. @@ -56,24 +56,28 @@ impl PubkyClient { .map_err(|_| Error::Generic("Could not resolve homeserver".to_string())) } - /// Resolve a service's public_key and clearnet url from a Pubky domain + /// Resolve a service's public_key and "non-pkarr url" from a Pubky domain + /// + /// "non-pkarr" url is any URL where the hostname isn't a 52 z-base32 character, + /// usually an IPv4, IPv6 or ICANN domain, but could also be any other unknown hostname. + /// + /// Recursively resolve SVCB and HTTPS endpoints, with [MAX_ENDPOINT_RESOLUTION_RECURSION] limit. pub(crate) async fn resolve_endpoint(&self, target: &str) -> Result { let original_target = target; // TODO: cache the result of this function? let mut target = target.to_string(); - let mut homeserver_public_key = None; + let mut endpoint_public_key = None; let mut origin = target.clone(); let mut step = 0; // PublicKey is very good at extracting the Pkarr TLD from a string. while let Ok(public_key) = PublicKey::try_from(target.clone()) { - if step >= MAX_RECURSIVE_PUBKY_HOMESERVER_RESOLUTION { + if step >= MAX_ENDPOINT_RESOLUTION_RECURSION { break; }; - step += 1; if let Some(signed_packet) = self @@ -85,8 +89,12 @@ impl PubkyClient { let svcb = signed_packet.resource_records(&target).fold( None, |prev: Option, answer| { - if let pkarr::dns::rdata::RData::SVCB(curr) = &answer.rdata { - let curr = curr.clone(); + if let Some(svcb) = match &answer.rdata { + pkarr::dns::rdata::RData::SVCB(svcb) => Some(svcb), + pkarr::dns::rdata::RData::HTTPS(curr) => Some(&curr.0), + _ => None, + } { + let curr = svcb.clone(); if curr.priority == 0 { return Some(curr); @@ -106,7 +114,7 @@ impl PubkyClient { ); if let Some(svcb) = svcb { - homeserver_public_key = Some(public_key.clone()); + endpoint_public_key = Some(public_key.clone()); target = svcb.target.to_string(); if let Some(port) = svcb.get_param(pkarr::dns::rdata::SVCB::PORT) { @@ -120,14 +128,14 @@ impl PubkyClient { origin.clone_from(&target); }; - if step >= MAX_RECURSIVE_PUBKY_HOMESERVER_RESOLUTION { + if step >= MAX_ENDPOINT_RESOLUTION_RECURSION { continue; }; } } } - if let Some(public_key) = homeserver_public_key { + if let Some(public_key) = endpoint_public_key { let url = Url::parse(&format!( "{}://{}", if origin.starts_with("localhost") { @@ -145,6 +153,7 @@ impl PubkyClient { } } +#[derive(Debug)] pub(crate) struct Endpoint { pub public_key: PublicKey, pub url: Url, @@ -155,12 +164,104 @@ mod tests { use super::*; use pkarr::{ - dns::{rdata::SVCB, Packet}, + dns::{ + rdata::{HTTPS, SVCB}, + Packet, + }, mainline::{dht::DhtSettings, Testnet}, Keypair, PkarrClient, Settings, SignedPacket, }; use pubky_homeserver::Homeserver; + #[tokio::test] + async fn resolve_endpoint_https() { + let testnet = Testnet::new(10); + + let pkarr_client = PkarrClient::new(Settings { + dht: DhtSettings { + bootstrap: Some(testnet.bootstrap.clone()), + ..Default::default() + }, + ..Default::default() + }) + .unwrap() + .as_async(); + + let domain = "example.com"; + let mut target; + + // Server + { + let keypair = Keypair::random(); + + let https = HTTPS(SVCB::new(0, domain.try_into().unwrap())); + + let mut packet = Packet::new_reply(0); + + packet.answers.push(pkarr::dns::ResourceRecord::new( + "foo".try_into().unwrap(), + pkarr::dns::CLASS::IN, + 60 * 60, + pkarr::dns::rdata::RData::HTTPS(https), + )); + + let signed_packet = SignedPacket::from_packet(&keypair, &packet).unwrap(); + + pkarr_client.publish(&signed_packet).await.unwrap(); + + target = format!("foo.{}", keypair.public_key()); + } + + // intermediate + { + let keypair = Keypair::random(); + + let svcb = SVCB::new(0, target.as_str().try_into().unwrap()); + + let mut packet = Packet::new_reply(0); + + packet.answers.push(pkarr::dns::ResourceRecord::new( + "bar".try_into().unwrap(), + pkarr::dns::CLASS::IN, + 60 * 60, + pkarr::dns::rdata::RData::SVCB(svcb), + )); + + let signed_packet = SignedPacket::from_packet(&keypair, &packet).unwrap(); + + pkarr_client.publish(&signed_packet).await.unwrap(); + + target = format!("bar.{}", keypair.public_key()) + } + + { + let keypair = Keypair::random(); + + let svcb = SVCB::new(0, target.as_str().try_into().unwrap()); + + let mut packet = Packet::new_reply(0); + + packet.answers.push(pkarr::dns::ResourceRecord::new( + "pubky".try_into().unwrap(), + pkarr::dns::CLASS::IN, + 60 * 60, + pkarr::dns::rdata::RData::SVCB(svcb), + )); + + let signed_packet = SignedPacket::from_packet(&keypair, &packet).unwrap(); + + pkarr_client.publish(&signed_packet).await.unwrap(); + + target = format!("pubky.{}", keypair.public_key()) + } + + let client = PubkyClient::test(&testnet); + + let endpoint = client.resolve_endpoint(&target).await.unwrap(); + + assert_eq!(endpoint.url.host_str().unwrap(), domain); + } + #[tokio::test] async fn resolve_homeserver() { let testnet = Testnet::new(10); diff --git a/pubky/src/shared/public.rs b/pubky/src/shared/public.rs index 2f9e73b..10d8d91 100644 --- a/pubky/src/shared/public.rs +++ b/pubky/src/shared/public.rs @@ -63,32 +63,31 @@ impl PubkyClient { pub(crate) async fn pubky_to_http>(&self, url: T) -> Result { let original_url: Url = url.try_into().map_err(|_| Error::InvalidUrl)?; - if original_url.scheme() != "pubky" { - return Ok(original_url); - } - let pubky = original_url .host_str() - .ok_or(Error::Generic("Missing Pubky Url host".to_string()))? - .to_string(); + .ok_or(Error::Generic("Missing Pubky Url host".to_string()))?; - let Endpoint { mut url, .. } = self - .resolve_pubky_homeserver(&PublicKey::try_from(pubky.clone())?) - .await?; + if let Ok(public_key) = PublicKey::try_from(pubky) { + let Endpoint { mut url, .. } = self.resolve_pubky_homeserver(&public_key).await?; - let path = original_url.path_segments(); + // TODO: remove if we move to subdomains instead of paths. + if original_url.scheme() == "pubky" { + let path = original_url.path_segments(); - // TODO: replace if we move to subdomains instead of paths. - let mut split = url.path_segments_mut().unwrap(); - split.push(&pubky); - if let Some(segments) = path { - for segment in segments { - split.push(segment); + let mut split = url.path_segments_mut().unwrap(); + split.push(pubky); + if let Some(segments) = path { + for segment in segments { + split.push(segment); + } + } + drop(split); } - } - drop(split); - Ok(url) + return Ok(url); + } + + Ok(original_url) } } diff --git a/pubky/src/shared/recovery_file.rs b/pubky/src/shared/recovery_file.rs index 9fff885..4bcbc27 100644 --- a/pubky/src/shared/recovery_file.rs +++ b/pubky/src/shared/recovery_file.rs @@ -10,30 +10,33 @@ static SPEC_LINE: &str = "pubky.org/recovery"; pub fn decrypt_recovery_file(recovery_file: &[u8], passphrase: &str) -> Result { let encryption_key = recovery_file_encryption_key_from_passphrase(passphrase)?; - let mut split = recovery_file.split(|byte| byte == &10); + let newline_index = recovery_file + .iter() + .position(|&r| r == 10) + .ok_or(()) + .map_err(|_| Error::RecoveryFileMissingSpecLine)?; - match split.next() { - Some(bytes) => { - if !(bytes.starts_with(SPEC_LINE.as_bytes()) - || bytes.starts_with(b"pkarr.org/recovery")) - { - return Err(Error::RecoveryFileVersionNotSupported); - } - } - None => return Err(Error::RecoveryFileMissingSpecLine), + let spec_line = &recovery_file[..newline_index]; + + if !(spec_line.starts_with(SPEC_LINE.as_bytes()) + || spec_line.starts_with(b"pkarr.org/recovery")) + { + return Err(Error::RecoveryFileVersionNotSupported); + } + + let encrypted = &recovery_file[newline_index + 1..]; + + if encrypted.is_empty() { + return Err(Error::RecoverFileMissingEncryptedSecretKey); }; - if let Some(encrypted) = split.next() { - let decrypted = decrypt(encrypted, &encryption_key)?; - let length = decrypted.len(); - let secret_key: [u8; 32] = decrypted - .try_into() - .map_err(|_| Error::RecoverFileInvalidSecretKeyLength(length))?; + let decrypted = decrypt(encrypted, &encryption_key)?; + let length = decrypted.len(); + let secret_key: [u8; 32] = decrypted + .try_into() + .map_err(|_| Error::RecoverFileInvalidSecretKeyLength(length))?; - return Ok(Keypair::from_secret_key(&secret_key)); - }; - - Err(Error::RecoverFileMissingEncryptedSecretKey) + Ok(Keypair::from_secret_key(&secret_key)) } pub fn create_recovery_file(keypair: &Keypair, passphrase: &str) -> Result> {