From 5eb61d589b0acc24cbb2b5f3d0bb9d7b54bd575e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Severin=20Alexander=20B=C3=BChler?= <8782386+SeverinAlexB@users.noreply.github.com> Date: Tue, 25 Mar 2025 09:44:05 +0200 Subject: [PATCH] chore(homeserver): Refactor config (#91) * first draft * config2 for the time being * more refactoring * write default config if it doesnt exist * added relays to config * some refactor * proper bootstrap nodes and relay config validation * small comments * rename module * renamings * turn listen_ports to listen_socket * connected config with homeserver * cleaned up old config * cleaned up config_old * removed old config.example.toml * cleanup tryfrom conversions * removed dirs-next * review cleanup * extracted default config to its own toml file * use hostname_validator for rfc1123 domain verification * Domain struct * fmt * small config restructure * use SignupMode in config and moved it to config dir --- Cargo.lock | 51 ++- pubky-homeserver/Cargo.toml | 9 +- pubky-homeserver/config.default.toml | 60 +++ pubky-homeserver/src/config.example.toml | 52 --- pubky-homeserver/src/config.rs | 349 ------------------ pubky-homeserver/src/core/homeserver_core.rs | 39 +- pubky-homeserver/src/core/routes/auth.rs | 11 +- .../src/data_directory/config_toml.rs | 251 +++++++++++++ .../src/data_directory/data_dir.rs | 227 ++++++++++++ pubky-homeserver/src/data_directory/domain.rs | 106 ++++++ .../src/data_directory/domain_port.rs | 91 +++++ pubky-homeserver/src/data_directory/mod.rs | 11 + .../src/data_directory/signup_mode.rs | 74 ++++ .../src/{io => homeserver}/http.rs | 0 .../key_republisher.rs} | 0 pubky-homeserver/src/homeserver/mod.rs | 5 + .../src/{io/mod.rs => homeserver/server.rs} | 122 ++++-- pubky-homeserver/src/lib.rs | 9 +- pubky-homeserver/src/main.rs | 42 ++- 19 files changed, 1032 insertions(+), 477 deletions(-) create mode 100644 pubky-homeserver/config.default.toml delete mode 100644 pubky-homeserver/src/config.example.toml delete mode 100644 pubky-homeserver/src/config.rs create mode 100644 pubky-homeserver/src/data_directory/config_toml.rs create mode 100644 pubky-homeserver/src/data_directory/data_dir.rs create mode 100644 pubky-homeserver/src/data_directory/domain.rs create mode 100644 pubky-homeserver/src/data_directory/domain_port.rs create mode 100644 pubky-homeserver/src/data_directory/mod.rs create mode 100644 pubky-homeserver/src/data_directory/signup_mode.rs rename pubky-homeserver/src/{io => homeserver}/http.rs (100%) rename pubky-homeserver/src/{io/homeserver_key_republisher.rs => homeserver/key_republisher.rs} (100%) create mode 100644 pubky-homeserver/src/homeserver/mod.rs rename pubky-homeserver/src/{io/mod.rs => homeserver/server.rs} (62%) diff --git a/Cargo.lock b/Cargo.lock index ad75cb1..5b63345 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -713,6 +713,15 @@ dependencies = [ "subtle", ] +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + [[package]] name = "dirs-next" version = "2.0.0" @@ -723,6 +732,18 @@ dependencies = [ "dirs-sys-next", ] +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.5.0", + "windows-sys 0.59.0", +] + [[package]] name = "dirs-sys-next" version = "0.1.2" @@ -730,7 +751,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" dependencies = [ "libc", - "redox_users", + "redox_users 0.4.6", "winapi", ] @@ -1220,6 +1241,12 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hostname-validator" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f558a64ac9af88b5ba400d99b579451af0d39c6d360980045b91aac966d705e2" + [[package]] name = "http" version = "1.2.0" @@ -1872,6 +1899,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "overload" version = "0.1.1" @@ -2232,11 +2265,12 @@ dependencies = [ "base32", "bytes", "clap", - "dirs-next", + "dirs", "flume", "futures-util", "heed", "hex", + "hostname-validator", "httpdate", "page_size", "pkarr", @@ -2244,6 +2278,7 @@ dependencies = [ "postcard", "pubky-common", "serde", + "tempfile", "thiserror 2.0.12", "tokio", "toml", @@ -2462,6 +2497,17 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "redox_users" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" +dependencies = [ + "getrandom 0.2.15", + "libredox", + "thiserror 2.0.12", +] + [[package]] name = "regex" version = "1.11.1" @@ -3448,6 +3494,7 @@ dependencies = [ "form_urlencoded", "idna", "percent-encoding", + "serde", ] [[package]] diff --git a/pubky-homeserver/Cargo.toml b/pubky-homeserver/Cargo.toml index f254901..3d3fd89 100644 --- a/pubky-homeserver/Cargo.toml +++ b/pubky-homeserver/Cargo.toml @@ -25,7 +25,6 @@ axum-extra = { version = "0.10.0", features = [ base32 = "0.5.1" bytes = "^1.10.0" clap = { version = "4.5.29", features = ["derive"] } -dirs-next = "2.0.0" flume = "0.11.1" futures-util = "0.3.31" heed = "0.21.0" @@ -41,9 +40,15 @@ tower-cookies = "0.11.0" tower-http = { version = "0.6.2", features = ["cors", "trace"] } tracing = "0.1.41" tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } -url = "2.5.4" +url = { version = "2.5.4", features = ["serde"] } axum-server = { version = "0.7.1", features = ["tls-rustls-no-provider"] } tower = "0.5.2" page_size = "0.6.0" pkarr-republisher = { version = "0.1.0", path = "../pkarr-republisher" } thiserror = "2.0.12" +dirs = "6.0.0" +hostname-validator = "1.1.1" + +[dev-dependencies] +tempfile = "3.10.1" + diff --git a/pubky-homeserver/config.default.toml b/pubky-homeserver/config.default.toml new file mode 100644 index 0000000..7373e4b --- /dev/null +++ b/pubky-homeserver/config.default.toml @@ -0,0 +1,60 @@ +[general] +# The mode for the signup. Default: "token_required" Options: +# "open" - anyone can signup. +# "token_required" - a signup token is required to signup. +signup_mode = "token_required" + +[drive] +# The port number to run an HTTPS (Pkarr TLS) server on. +# Pkarr TLS is a TLS implementation that is compatible with the Pkarr protocol. +# No need to provide a ICANN TLS certificate. +pubky_listen_socket = "127.0.0.1:6287" + +# The port number to run an HTTP (clear text) server on. +# Used for http requests from regular browsers. +# May be put behind a reverse proxy with TLS enabled. +icann_listen_socket = "127.0.0.1:6286" + +# An ICANN domain name is necessary to support legacy browsers +# +# Make sure to setup a domain name and point it the IP +# address of this machine where you are running this server. +# +# This domain should point to the `:`. +# +# ICANN TLS is not natively supported, so you should be running +# a reverse proxy and managing certificates yourself. +icann_domain = "example.com" + + +[admin] +# The port number to run the admin HTTP (clear text) server on. +# Used for admin requests from the admin UI. +listen_socket = "127.0.0.1:6288" + +# The password for the admin user to access the admin UI. +admin_password = "admin" + +[pkdns] +# The public IP address and port of the homeserver pubky_drive_api to be advertised on the DHT. +# Must be set to be reachable from the outside. +public_socket = "127.0.0.1:6286" + +# The interval at which user keys are republished to the DHT. +user_keys_republisher_interval = 14400 # 4 hours in seconds + +# List of bootstrap nodes for the DHT. +# domain:port format. +dht_bootstrap_nodes = [ + "router.bittorrent.com:6881", + "dht.transmissionbt.com:6881", + "dht.libtorrent.org:25401", + "relay.pkarr.org:6881" +] + +# Relay node urls for the DHT. +# Improves the availability of pkarr packets. +dht_relay_nodes = [ + "https://relay.pkarr.org", + "https://pkarr.pubky.org" +] \ No newline at end of file diff --git a/pubky-homeserver/src/config.example.toml b/pubky-homeserver/src/config.example.toml deleted file mode 100644 index 9678ccd..0000000 --- a/pubky-homeserver/src/config.example.toml +++ /dev/null @@ -1,52 +0,0 @@ -# Secret key (in hex) to generate the Homeserver's Keypair -# secret_key = "0000000000000000000000000000000000000000000000000000000000000000" - -# The interval at which user keys are republished to the DHT. -user_keys_republisher_interval = 14400 # 4 hour in seconds - -[admin] -# Set an admin password to protect admin endpoints. -# If no password is set, the admin endpoints will not be accessible. -password = "admin" -# Set signup_mode to "open" to allow anyone to signup a new user, -# otherwise, "token_required" (the default) to require a valid invite token on signup; -signup_mode = "token_required" - -[database] -# Storage directory Defaults to -# -# Storage path can be relative or absolute. -storage = "./storage/" - -[io] -# The port number to run an HTTP (clear text) server on. -http_port = 6286 -# The port number to run an HTTPs (Pkarr TLS) server on. -https_port = 6287 - -# The public IP of this server. -# -# This address will be mentioned in the Pkarr records of this -# Homeserver that is published on its public key (derivde from `secret_key`) -public_ip = "127.0.0.1" - -# If you are running this server behind a reverse proxy, -# you need to provide some extra configurations. -[io.reverse_proxy] -# The public port should be mapped to the `io::https_port` -# and you should setup tcp forwarding (don't terminate TLS on that port). -public_port = 6287 - -# If you want your server to be accessible from legacy browsers, -# you need to provide some extra configurations. -[io.legacy_browsers] -# An ICANN domain name is necessary to support legacy browsers -# -# Make sure to setup a domain name and point it the IP -# address of this machine where you are running this server. -# -# This domain should point to the `:`. -# -# Currently we don't support ICANN TLS, so you should be running -# a reverse proxy and managing certificates there for this endpoint. -domain = "example.com" diff --git a/pubky-homeserver/src/config.rs b/pubky-homeserver/src/config.rs deleted file mode 100644 index ec43c06..0000000 --- a/pubky-homeserver/src/config.rs +++ /dev/null @@ -1,349 +0,0 @@ -//! Configuration for the server - -use anyhow::{anyhow, Context, Result}; -use pkarr::Keypair; -use serde::{Deserialize, Serialize}; -use std::{ - fmt::Debug, - fs, - net::{IpAddr, SocketAddr}, - path::{Path, PathBuf}, -}; - -use crate::{ - core::{AdminConfig, CoreConfig, SignupMode}, - io::IoConfig, -}; - -pub const DEFAULT_REPUBLISHER_INTERVAL: u64 = 4 * 60 * 60; // 4 hours in seconds - -// === Core == -pub const DEFAULT_STORAGE_DIR: &str = "pubky"; -pub const DEFAULT_MAP_SIZE: usize = 10995116277760; // 10TB (not = disk-space used) - -pub const DEFAULT_LIST_LIMIT: u16 = 100; -pub const DEFAULT_MAX_LIST_LIMIT: u16 = 1000; - -// === IO === -pub const DEFAULT_HTTP_PORT: u16 = 6286; -pub const DEFAULT_HTTPS_PORT: u16 = 6287; - -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -struct DatabaseToml { - storage: Option, -} - -#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq, Eq)] -struct ReverseProxyToml { - pub public_port: Option, -} - -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -struct LegacyBrowsersTompl { - pub domain: Option, -} - -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] -struct AdminToml { - pub password: Option, - /// "open" or "token_required" (defaults to "token_required", i.e., a signup token is required) - pub signup_mode: Option, -} - -#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq)] -struct IoToml { - pub http_port: Option, - pub https_port: Option, - pub public_ip: Option, - - pub reverse_proxy: Option, - pub legacy_browsers: Option, -} - -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -struct ConfigToml { - secret_key: Option, - database: Option, - io: Option, - admin: Option, -} - -/// Server configuration -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Config { - /// Server keypair. - /// - /// Defaults to a random keypair. - pub keypair: Keypair, - pub io: IoConfig, - pub core: CoreConfig, - pub admin: AdminConfig, -} - -impl Default for Config { - fn default() -> Self { - Self { - keypair: Keypair::random(), - io: IoConfig::default(), - core: CoreConfig::default(), - admin: AdminConfig::default(), - } - } -} - -impl Config { - fn try_from_str(value: &str) -> Result { - let config_toml: ConfigToml = toml::from_str(value)?; - - config_toml.try_into() - } - - /// Load the config from a file. - pub async fn load(path: impl AsRef) -> Result { - let config_file_path = path.as_ref(); - - let s = tokio::fs::read_to_string(config_file_path) - .await - .with_context(|| format!("failed to read {}", path.as_ref().to_string_lossy()))?; - - let mut config = Config::try_from_str(&s)?; - - // support relative path. - if config.core.storage.is_relative() { - config.core.storage = config_file_path - .parent() - .unwrap_or_else(|| Path::new(".")) - .join(config.core.storage.clone()); - } - - fs::create_dir_all(&config.core.storage)?; - config.core.storage = config.core.storage.canonicalize()?; - - Ok(config) - } - - /// Create test configurations - pub fn test(bootstrap: &[String]) -> Self { - let bootstrap = Some(bootstrap.to_vec()); - - Self { - io: IoConfig { - bootstrap, - http_port: 0, - https_port: 0, - ..Default::default() - }, - core: CoreConfig::test(), - admin: AdminConfig::test(), - ..Default::default() - } - } -} - -impl TryFrom for Config { - type Error = anyhow::Error; - - fn try_from(value: ConfigToml) -> std::result::Result { - let keypair = if let Some(secret_key) = value.secret_key { - let secret_key = deserialize_secret_key(secret_key)?; - Keypair::from_secret_key(&secret_key) - } else { - Keypair::random() - }; - - let storage = { - let dir = - if let Some(storage) = value.database.as_ref().and_then(|db| db.storage.clone()) { - storage - } else { - let path = dirs_next::data_dir().ok_or_else(|| { - anyhow!("operating environment provides no directory for application data") - })?; - path.join(DEFAULT_STORAGE_DIR) - }; - - dir.join("homeserver") - }; - - let io = if let Some(io) = value.io { - IoConfig { - http_port: io.http_port.unwrap_or(DEFAULT_HTTP_PORT), - https_port: io.https_port.unwrap_or(DEFAULT_HTTPS_PORT), - domain: io.legacy_browsers.and_then(|l| l.domain), - public_addr: io.public_ip.map(|ip| { - SocketAddr::from(( - ip, - io.reverse_proxy - .and_then(|r| r.public_port) - .unwrap_or(io.https_port.unwrap_or(0)), - )) - }), - ..Default::default() - } - } else { - IoConfig { - http_port: DEFAULT_HTTP_PORT, - https_port: DEFAULT_HTTPS_PORT, - ..Default::default() - } - }; - - let admin = if let Some(admin_toml) = value.admin { - AdminConfig { - password: admin_toml.password.clone(), - signup_mode: match admin_toml.signup_mode.as_deref() { - Some("open") => SignupMode::Open, - _ => SignupMode::TokenRequired, - }, - } - } else { - AdminConfig::default() - }; - - Ok(Config { - keypair, - io, - core: CoreConfig { - storage, - ..Default::default() - }, - admin, - }) - } -} - -fn deserialize_secret_key(s: String) -> anyhow::Result<[u8; 32]> { - let bytes = - hex::decode(s).map_err(|_| anyhow!("secret_key in config.toml should hex encoded"))?; - - if bytes.len() != 32 { - return Err(anyhow!(format!( - "secret_key in config.toml should be 32 bytes in hex (64 characters), got: {}", - bytes.len() - ))); - } - - let mut arr = [0u8; 32]; - arr.copy_from_slice(&bytes); - - Ok(arr) -} - -#[cfg(test)] -mod tests { - - use super::*; - - #[test] - fn parse_empty() { - let config = Config::try_from_str("").unwrap(); - - assert_eq!( - config, - Config { - keypair: config.keypair.clone(), - ..Default::default() - } - ) - } - - #[tokio::test] - async fn config_load() { - let crate_dir = std::env::current_dir().unwrap(); - let config_file_path = crate_dir.join("./src/config.example.toml"); - let canonical_file_path = config_file_path.canonicalize().unwrap(); - - let config = Config::load(canonical_file_path).await.unwrap(); - - assert!(config - .core - .storage - .ends_with("pubky-homeserver/src/storage/homeserver")); - } - - #[test] - fn config_test() { - let config = Config::test(&[]); - - assert_eq!( - config, - Config { - keypair: config.keypair.clone(), - - io: IoConfig { - bootstrap: Some(vec![]), - http_port: 0, - https_port: 0, - - ..Default::default() - }, - core: CoreConfig { - db_map_size: 10485760, - storage: config.core.storage.clone(), - - ..Default::default() - }, - admin: AdminConfig { - password: Some("admin".to_string()), - signup_mode: SignupMode::Open - } - } - ) - } - - #[test] - fn parse() { - let config = Config::try_from_str( - r#" -# Secret key (in hex) to generate the Homeserver's Keypair -secret_key = "0000000000000000000000000000000000000000000000000000000000000000" - -[database] -# Storage directory Defaults to -# storage = "" - -[io] -# The port number to run an HTTP (clear text) server on. -http_port = 6286 -# The port number to run an HTTPs (Pkarr TLS) server on. -https_port = 6287 - -# The public IP of this server. -# -# This address will be mentioned in the Pkarr records of this -# Homeserver that is published on its public key (derivde from `secret_key`) -public_ip = "127.0.0.1" - -# If you are running this server behind a reverse proxy, -# you need to provide some extra configurations. -[io.reverse_proxy] -# The public port should be mapped to the `io::https_port` -# and you should setup tcp forwarding (don't terminate TLS on that port). -public_port = 6287 - -# If you want your server to be accessible from legacy browsers, -# you need to provide some extra configurations. -[io.legacy_browsers] -# An ICANN domain name is necessary to support legacy browsers -# -# Make sure to setup a domain name and point it the IP -# address of this machine where you are running this server. -# -# This domain should point to the `:`. -# -# Currently we don't support ICANN TLS, so you should be running -# a reverse proxy and managing certificates there for this endpoint. -domain = "example.com" - "#, - ) - .unwrap(); - - assert_eq!(config.keypair, Keypair::from_secret_key(&[0; 32])); - assert_eq!(config.io.https_port, 6287); - assert_eq!( - config.io.public_addr, - Some(SocketAddr::from(([127, 0, 0, 1], 6287))) - ); - assert_eq!(config.io.domain, Some("example.com".to_string())); - } -} diff --git a/pubky-homeserver/src/core/homeserver_core.rs b/pubky-homeserver/src/core/homeserver_core.rs index 9d1fb7a..8bcee07 100644 --- a/pubky-homeserver/src/core/homeserver_core.rs +++ b/pubky-homeserver/src/core/homeserver_core.rs @@ -1,17 +1,20 @@ use std::{path::PathBuf, time::Duration}; +use crate::core::database::DB; use crate::core::user_keys_republisher::UserKeysRepublisher; +use crate::SignupMode; use anyhow::Result; use axum::Router; use pubky_common::auth::AuthVerifier; use tokio::time::sleep; -use crate::config::{ - DEFAULT_LIST_LIMIT, DEFAULT_MAP_SIZE, DEFAULT_MAX_LIST_LIMIT, DEFAULT_REPUBLISHER_INTERVAL, - DEFAULT_STORAGE_DIR, -}; +pub const DEFAULT_REPUBLISHER_INTERVAL: u64 = 4 * 60 * 60; // 4 hours in seconds -use crate::core::database::DB; +pub const DEFAULT_STORAGE_DIR: &str = "pubky"; +pub const DEFAULT_MAP_SIZE: usize = 10995116277760; // 10TB (not = disk-space used) + +pub const DEFAULT_LIST_LIMIT: u16 = 100; +pub const DEFAULT_MAX_LIST_LIMIT: u16 = 1000; #[derive(Clone, Debug)] pub(crate) struct AppState { @@ -74,13 +77,6 @@ impl HomeserverCore { } } -#[derive(Debug, Clone, PartialEq, Eq, Default)] -pub enum SignupMode { - Open, - #[default] - TokenRequired, -} - #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct AdminConfig { /// The password used to authorize admin endpoints. @@ -156,17 +152,16 @@ impl CoreConfig { } } -pub fn storage(storage: Option) -> Result { - let dir = if let Some(storage) = storage { - PathBuf::from(storage) +pub fn storage(storage: Option) -> anyhow::Result { + if let Some(storage) = storage { + Ok(PathBuf::from(storage)) } else { - let path = dirs_next::data_dir().ok_or_else(|| { - anyhow::anyhow!("operating environment provides no directory for application data") - })?; - path.join(DEFAULT_STORAGE_DIR) - }; - - Ok(dir.join("homeserver")) + dirs::home_dir() + .map(|dir| dir.join(".pubky/data/lmdb")) + .ok_or_else(|| { + anyhow::anyhow!("operating environment provides no directory for application data") + }) + } } #[cfg(test)] diff --git a/pubky-homeserver/src/core/routes/auth.rs b/pubky-homeserver/src/core/routes/auth.rs index aefc42e..d3550d0 100644 --- a/pubky-homeserver/src/core/routes/auth.rs +++ b/pubky-homeserver/src/core/routes/auth.rs @@ -1,7 +1,10 @@ -use crate::core::{ - database::tables::users::User, - error::{Error, Result}, - AppState, SignupMode, +use crate::{ + core::{ + database::tables::users::User, + error::{Error, Result}, + AppState, + }, + SignupMode, }; use axum::{ extract::{Query, State}, diff --git a/pubky-homeserver/src/data_directory/config_toml.rs b/pubky-homeserver/src/data_directory/config_toml.rs new file mode 100644 index 0000000..3c0f28b --- /dev/null +++ b/pubky-homeserver/src/data_directory/config_toml.rs @@ -0,0 +1,251 @@ +//! +//! Configuration file for the homeserver. +//! +use super::{domain_port::DomainPort, SignupMode}; +use serde::{Deserialize, Serialize}; +use std::{ + fmt::Debug, + net::{IpAddr, Ipv4Addr, SocketAddr}, + num::NonZeroU64, + str::FromStr, +}; +use url::Url; + +/// Default TOML configuration for the homeserver. +/// This is used to create a default config file if it doesn't exist. +/// Why not use the Default trait? The `toml` crate doesn't support adding comments. +/// So we maintain this default manually. +pub const DEFAULT_CONFIG: &str = include_str!("../../config.default.toml"); + +/// All configuration related to the DHT +/// and /pkarr. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct PkdnsToml { + /// The public IP address and port of the server to be advertised in the DHT. + #[serde(default = "default_public_socket")] + pub public_socket: SocketAddr, + + /// The interval at which the user keys are republished in the DHT. + #[serde(default = "default_user_keys_republisher_interval")] + pub user_keys_republisher_interval: NonZeroU64, + + /// The list of bootstrap nodes for the DHT. If None, the default pkarr bootstrap nodes will be used. + #[serde(default = "default_dht_bootstrap_nodes")] + pub dht_bootstrap_nodes: Option>, + + /// The list of relay nodes for the DHT. If None, the default pkarr relay nodes will be used. + #[serde(default = "default_dht_relay_nodes")] + pub dht_relay_nodes: Option>, +} + +fn default_public_socket() -> SocketAddr { + let ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)); + let port = 6286; + SocketAddr::from((ip, port)) +} + +fn default_dht_bootstrap_nodes() -> Option> { + None +} + +fn default_dht_relay_nodes() -> Option> { + None +} + +fn default_user_keys_republisher_interval() -> NonZeroU64 { + // 4 hours + NonZeroU64::new(14400).expect("14400 is a valid non-zero u64") +} + +fn default_pubky_drive_listen_socket() -> SocketAddr { + let ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)); + let port = 6287; + SocketAddr::from((ip, port)) +} + +/// All configuration related to file drive +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct DriveToml { + /// The port on which the Pubky TLS Drive API will listen. + #[serde(default = "default_pubky_drive_listen_socket")] + pub pubky_listen_socket: SocketAddr, + /// The port on which the regular http API will listen. + #[serde(default = "default_icann_drive_listen_socket")] + pub icann_listen_socket: SocketAddr, + /// Optional domain name of the regular http API. + #[serde(default = "default_icann_drive_domain")] + pub icann_domain: Option, +} + +fn default_icann_drive_listen_socket() -> SocketAddr { + let ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)); + let port = 6286; + SocketAddr::from((ip, port)) +} + +fn default_icann_drive_domain() -> Option { + None +} + +/// All configuration related to the admin API +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct AdminToml { + /// The socket on which the admin API will listen. + #[serde(default = "default_admin_listen_socket")] + pub listen_socket: SocketAddr, + /// The password for the admin API. + #[serde(default = "default_admin_password")] + pub admin_password: String, +} + +fn default_admin_password() -> String { + "admin".to_string() +} + +fn default_admin_listen_socket() -> SocketAddr { + let ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)); + let port = 6288; + SocketAddr::from((ip, port)) +} + +/// All configuration related to the admin API +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct GeneralToml { + /// The mode of the signup. + #[serde(default = "default_signup_mode")] + pub signup_mode: SignupMode, +} + +fn default_signup_mode() -> SignupMode { + SignupMode::TokenRequired +} + +/// The error that can occur when reading the config file +#[derive(Debug, thiserror::Error)] +pub enum ConfigReadError { + /// The config file not found + #[error("Config file not found. {0}")] + ConfigFileNotFound(#[from] std::io::Error), + /// The config file is not valid + #[error("Config file is not valid. {0}")] + ConfigFileNotValid(#[from] toml::de::Error), +} + +/// The main server configuration +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct ConfigToml { + /// The configuration for the general settings. + pub general: GeneralToml, + /// The configuration for the drive files. + pub drive: DriveToml, + /// The configuration for the admin API. + pub admin: AdminToml, + /// The configuration for the pkdns. + pub pkdns: PkdnsToml, +} + +impl ConfigToml { + /// Reads the configuration from a TOML file at the specified path. + /// + /// # Arguments + /// * `path` - The path to the TOML configuration file + /// + /// # Returns + /// * `Result` - The parsed configuration or an error if reading/parsing fails + pub fn from_file(path: impl AsRef) -> Result { + let contents = std::fs::read_to_string(path)?; + let config: ConfigToml = contents.parse()?; + Ok(config) + } + + /// Returns the default config with all variables commented out. + pub fn default_string() -> String { + // Comment out all variables so they are not fixed by default. + DEFAULT_CONFIG + .split("\n") + .map(|line| { + let is_not_commented_variable = + !line.starts_with("#") && !line.starts_with("[") && line.is_empty(); + if is_not_commented_variable { + format!("# {}", line) + } else { + line.to_string() + } + }) + .collect::>() + .join("\n") + } +} + +impl Default for ConfigToml { + fn default() -> Self { + DEFAULT_CONFIG + .parse() + .expect("Default config is always valid") + } +} + +impl FromStr for ConfigToml { + type Err = toml::de::Error; + + fn from_str(s: &str) -> Result { + let config: ConfigToml = toml::from_str(s)?; + Ok(config) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_config() { + let c: ConfigToml = ConfigToml::default(); + + assert_eq!(c.general.signup_mode, SignupMode::TokenRequired); + assert_eq!( + c.drive.icann_listen_socket, + default_icann_drive_listen_socket() + ); + assert_eq!(c.drive.icann_domain, Some("example.com".to_string())); + + assert_eq!( + c.drive.pubky_listen_socket, + default_pubky_drive_listen_socket() + ); + + assert_eq!(c.admin.listen_socket, default_admin_listen_socket()); + assert_eq!(c.admin.admin_password, default_admin_password()); + + // Verify pkdns config + assert_eq!(c.pkdns.public_socket, default_public_socket()); + assert_eq!( + c.pkdns.user_keys_republisher_interval, + default_user_keys_republisher_interval() + ); + assert_eq!( + c.pkdns.dht_bootstrap_nodes, + Some(vec![ + DomainPort::from_str("router.bittorrent.com:6881").unwrap(), + DomainPort::from_str("dht.transmissionbt.com:6881").unwrap(), + DomainPort::from_str("dht.libtorrent.org:25401").unwrap(), + DomainPort::from_str("relay.pkarr.org:6881").unwrap(), + ]) + ); + assert_eq!( + c.pkdns.dht_relay_nodes, + Some(vec![ + Url::parse("https://relay.pkarr.org").unwrap(), + Url::parse("https://pkarr.pubky.org").unwrap(), + ]) + ); + } + + #[test] + fn test_default_config_commented_out() { + // Sanity check that the default config is valid + // even when the variables are commented out. + let s = ConfigToml::default_string(); + let _: ConfigToml = s.parse().expect("Failed to parse config"); + } +} diff --git a/pubky-homeserver/src/data_directory/data_dir.rs b/pubky-homeserver/src/data_directory/data_dir.rs new file mode 100644 index 0000000..3cf3ed2 --- /dev/null +++ b/pubky-homeserver/src/data_directory/data_dir.rs @@ -0,0 +1,227 @@ +use super::ConfigToml; +use std::{io::Write, os::unix::fs::PermissionsExt, path::PathBuf}; + +/// The data directory for the homeserver. +/// +/// This is the directory that will store the homeservers data. +/// +#[derive(Debug, Clone)] +pub struct DataDir { + expanded_path: PathBuf, +} + +impl DataDir { + /// Creates a new data directory. + /// `path` will be expanded to the home directory if it starts with "~". + pub fn new(path: PathBuf) -> Self { + Self { + expanded_path: Self::expand_home_dir(path), + } + } + + /// Returns the full path to the data directory. + pub fn path(&self) -> &PathBuf { + &self.expanded_path + } + + /// Expands the data directory to the home directory if it starts with "~". + /// Return the full path to the data directory. + fn expand_home_dir(path: PathBuf) -> PathBuf { + let path = match path.to_str() { + Some(path) => path, + None => { + // Path not valid utf-8 so we can't expand it. + return path; + } + }; + + if path.starts_with("~/") { + if let Some(home) = dirs::home_dir() { + let without_home = path.strip_prefix("~/").expect("Invalid ~ prefix"); + let joined = home.join(without_home); + return joined; + } + } + PathBuf::from(path) + } + + /// Makes sure the data directory exists. + /// Create the directory if it doesn't exist. + pub fn ensure_data_dir_exists_and_is_writable(&self) -> anyhow::Result<()> { + std::fs::create_dir_all(&self.expanded_path)?; + + // Check if we can write to the data directory + let test_file_path = self + .expanded_path + .join("test_write_f2d560932f9b437fa9ef430ba436d611"); // random file name to not conflict with anything + std::fs::write(test_file_path.clone(), b"test") + .map_err(|err| anyhow::anyhow!("Failed to write to data directory: {}", err))?; + std::fs::remove_file(test_file_path) + .map_err(|err| anyhow::anyhow!("Failed to write to data directory: {}", err))?; + Ok(()) + } + + /// Returns the config file path in this directory. + pub fn get_config_file_path(&self) -> PathBuf { + self.expanded_path.join("config.toml") + } + + /// Reads the config file from the data directory. + /// Creates a default config file if it doesn't exist. + pub fn read_or_create_config_file(&self) -> anyhow::Result { + let config_file_path = self.get_config_file_path(); + if !config_file_path.exists() { + self.write_default_config_file()?; + } + let config = ConfigToml::from_file(config_file_path)?; + Ok(config) + } + + fn write_default_config_file(&self) -> anyhow::Result<()> { + let config_string = ConfigToml::default_string(); + let config_file_path = self.get_config_file_path(); + let mut config_file = std::fs::File::create(config_file_path)?; + config_file.write_all(config_string.as_bytes())?; + Ok(()) + } + + /// Returns the path to the secret file. + pub fn get_secret_file_path(&self) -> PathBuf { + self.expanded_path.join("secret") + } + + /// Reads the secret file. Creates a new secret file if it doesn't exist. + pub fn read_or_create_keypair(&self) -> anyhow::Result { + let secret_file_path = self.get_secret_file_path(); + if !secret_file_path.exists() { + // Create a new secret file + let keypair = pkarr::Keypair::random(); + let secret = keypair.secret_key(); + let hex_string = hex::encode(secret); + std::fs::write(secret_file_path.clone(), hex_string)?; + std::fs::set_permissions(&secret_file_path, std::fs::Permissions::from_mode(0o600))?; + tracing::info!("Secret file created at {}", secret_file_path.display()); + } + // Read the secret file + let secret = std::fs::read(secret_file_path)?; + let secret_bytes = hex::decode(secret)?; + let secret_bytes: [u8; 32] = secret_bytes.try_into().map_err(|_| { + anyhow::anyhow!("Failed to convert secret bytes into array of length 32") + })?; + let keypair = pkarr::Keypair::from_secret_key(&secret_bytes); + Ok(keypair) + } +} + +impl Default for DataDir { + fn default() -> Self { + Self::new(PathBuf::from("~/.pubky")) + } +} + +#[cfg(test)] +mod tests { + use std::io::Write; + + use super::*; + use tempfile::TempDir; + + /// Test that the home directory is expanded correctly. + #[test] + pub fn test_expand_home_dir() { + let data_dir = DataDir::new(PathBuf::from("~/.pubky")); + let homedir = dirs::home_dir().unwrap(); + let expanded_path = homedir.join(".pubky"); + assert_eq!(data_dir.expanded_path, expanded_path); + } + + /// Test that the data directory is created if it doesn't exist. + #[test] + pub fn test_ensure_data_dir_exists_and_is_accessible() { + let temp_dir = TempDir::new().unwrap(); + let test_path = temp_dir.path().join(".pubky"); + let data_dir = DataDir::new(test_path.clone()); + + data_dir.ensure_data_dir_exists_and_is_writable().unwrap(); + assert!(test_path.exists()); + // temp_dir will be automatically cleaned up when it goes out of scope + } + + #[test] + pub fn test_get_default_config_file_path_exists() { + let temp_dir = TempDir::new().unwrap(); + let test_path = temp_dir.path().join(".pubky"); + let data_dir = DataDir::new(test_path.clone()); + data_dir.ensure_data_dir_exists_and_is_writable().unwrap(); + let config_file_path = data_dir.get_config_file_path(); + assert!(!config_file_path.exists()); // Should not exist yet + + let mut config_file = std::fs::File::create(config_file_path.clone()).unwrap(); + config_file.write_all(b"test").unwrap(); + assert!(config_file_path.exists()); // Should exist now + // temp_dir will be automatically cleaned up when it goes out of scope + } + + #[test] + pub fn test_read_or_create_config_file() { + let temp_dir = TempDir::new().unwrap(); + let test_path = temp_dir.path().join(".pubky"); + let data_dir = DataDir::new(test_path.clone()); + data_dir.ensure_data_dir_exists_and_is_writable().unwrap(); + let _ = data_dir.read_or_create_config_file().unwrap(); // Should create a default config file + assert!(data_dir.get_config_file_path().exists()); + + let _ = data_dir.read_or_create_config_file().unwrap(); // Should read the existing file + assert!(data_dir.get_config_file_path().exists()); + } + + #[test] + pub fn test_read_or_create_config_file_dont_override_existing_file() { + let temp_dir = TempDir::new().unwrap(); + let test_path = temp_dir.path().join(".pubky"); + let data_dir = DataDir::new(test_path.clone()); + data_dir.ensure_data_dir_exists_and_is_writable().unwrap(); + + // Write a broken config file + let config_file_path = data_dir.get_config_file_path(); + std::fs::write(config_file_path.clone(), b"test").unwrap(); + assert!(config_file_path.exists()); // Should exist now + + // Try to read the config file and fail because config is broken + let read_result = data_dir.read_or_create_config_file(); + assert!(read_result.is_err()); + + // Make sure the broken config file is still there + let content = std::fs::read_to_string(config_file_path).unwrap(); + assert_eq!(content, "test"); + } + + #[test] + pub fn test_create_secret_file() { + let temp_dir = TempDir::new().unwrap(); + let test_path = temp_dir.path().join(".pubky"); + let data_dir = DataDir::new(test_path.clone()); + data_dir.ensure_data_dir_exists_and_is_writable().unwrap(); + + let _ = data_dir.read_or_create_keypair().unwrap(); + assert!(data_dir.get_secret_file_path().exists()); + } + + #[test] + pub fn test_dont_override_existing_secret_file() { + let temp_dir = TempDir::new().unwrap(); + let test_path = temp_dir.path().join(".pubky"); + let data_dir = DataDir::new(test_path.clone()); + data_dir.ensure_data_dir_exists_and_is_writable().unwrap(); + + // Create a secret file + let secret_file_path = data_dir.get_secret_file_path(); + std::fs::write(secret_file_path.clone(), b"test").unwrap(); + + let result = data_dir.read_or_create_keypair(); + assert!(result.is_err()); + assert!(data_dir.get_secret_file_path().exists()); + let content = std::fs::read_to_string(secret_file_path).unwrap(); + assert_eq!(content, "test"); + } +} diff --git a/pubky-homeserver/src/data_directory/domain.rs b/pubky-homeserver/src/data_directory/domain.rs new file mode 100644 index 0000000..1ffd119 --- /dev/null +++ b/pubky-homeserver/src/data_directory/domain.rs @@ -0,0 +1,106 @@ +use std::fmt::{self, Display}; +use std::str::FromStr; + +use serde::{Deserialize, Serialize}; + +/// Validated domain name according to RFC 1123. +#[derive(Debug, Clone, PartialEq)] +pub struct Domain(pub String); + +impl Domain { + /// Create a new domain from a string. + pub fn new(domain: String) -> Result { + Self::is_valid_domain(&domain)?; + Ok(Self(domain)) + } + + /// Validate a domain name according to RFC 1123 + pub fn is_valid_domain(domain: &str) -> anyhow::Result<()> { + // Check if it's a valid hostname according to RFC 1123 + if !hostname_validator::is_valid(domain) { + return Err(anyhow::anyhow!( + "Invalid domain '{}': is not a valid RFC 1123 hostname", + domain + )); + } + Ok(()) + } +} + +impl FromStr for Domain { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + Self::is_valid_domain(s)?; + Ok(Self(s.to_string())) + } +} + +impl Display for Domain { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl Serialize for Domain { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.to_string()) + } +} + +impl<'de> Deserialize<'de> for Domain { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + Self::from_str(&s).map_err(|e| serde::de::Error::custom(e.to_string())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_domain_validation() { + // Test valid domains + let valid_domains = [ + "example.com", + "sub.example.com", + "a.b.c.d", + "valid-domain.com", + "valid.domain-name.com", + "localhost", + "test.local", + ]; + + for domain in valid_domains { + let result: anyhow::Result = domain.parse(); + assert!(result.is_ok(), "Domain '{}' should be valid", domain); + } + + // Test invalid domains + let invalid_domains = [ + ("invalid@domain.com", "contains invalid characters"), + ("domain..com", "contains consecutive dots"), + (".domain.com", "starts with a dot"), + ("domain.com.", "ends with a dot"), + ("-domain.com", "starts with a hyphen"), + ("domain.com-", "ends with a hyphen"), + ]; + + for (domain, reason) in invalid_domains { + let result: anyhow::Result = domain.parse(); + assert!( + result.is_err(), + "Domain '{}' should be invalid: {}", + domain, + reason + ); + } + } +} diff --git a/pubky-homeserver/src/data_directory/domain_port.rs b/pubky-homeserver/src/data_directory/domain_port.rs new file mode 100644 index 0000000..1e61e17 --- /dev/null +++ b/pubky-homeserver/src/data_directory/domain_port.rs @@ -0,0 +1,91 @@ +use serde::{Deserialize, Serialize}; +use std::fmt; +use std::result::Result; +use std::str::FromStr; + +use super::domain::Domain; + +/// A domain and port pair. +#[derive(Debug, Clone, PartialEq)] +pub struct DomainPort { + /// The domain name. + pub domain: Domain, + /// The port number. + pub port: u16, +} + +impl TryFrom<&str> for DomainPort { + type Error = anyhow::Error; + + fn try_from(s: &str) -> Result { + Self::from_str(s) + } +} + +impl fmt::Display for DomainPort { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}:{}", self.domain, self.port) + } +} + +impl FromStr for DomainPort { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + let parts: Vec<&str> = s.split(':').collect(); + if parts.len() != 2 { + return Err(anyhow::anyhow!( + "Invalid domain:port format. Expected 'domain:port'" + )); + } + let part0 = parts[0]; + + let domain = part0.parse::()?; + let port = parts[1].parse::()?; + + Ok(Self { domain, port }) + } +} + +impl Serialize for DomainPort { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.to_string()) + } +} + +impl<'de> Deserialize<'de> for DomainPort { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + Self::from_str(&s).map_err(|e| serde::de::Error::custom(e.to_string())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_domain_port_from_str() { + let domain_port = DomainPort::from_str("example.com:6286").unwrap(); + assert_eq!(domain_port.domain.to_string(), "example.com"); + assert_eq!(domain_port.port, 6286); + } + + #[test] + fn test_domain_port_from_str_invalid1() { + let domain_port = DomainPort::from_str("example.com"); + assert!(domain_port.is_err()); + } + + #[test] + fn test_domain_port_from_str_invalid2() { + let domain_port = DomainPort::from_str("example..com:80"); + assert!(domain_port.is_err()); + } +} diff --git a/pubky-homeserver/src/data_directory/mod.rs b/pubky-homeserver/src/data_directory/mod.rs new file mode 100644 index 0000000..2442337 --- /dev/null +++ b/pubky-homeserver/src/data_directory/mod.rs @@ -0,0 +1,11 @@ +mod config_toml; +mod data_dir; +mod domain; +mod domain_port; +mod signup_mode; + +pub use config_toml::{ConfigReadError, ConfigToml}; +pub use data_dir::DataDir; +pub use domain::Domain; +pub use domain_port::DomainPort; +pub use signup_mode::SignupMode; diff --git a/pubky-homeserver/src/data_directory/signup_mode.rs b/pubky-homeserver/src/data_directory/signup_mode.rs new file mode 100644 index 0000000..e133b91 --- /dev/null +++ b/pubky-homeserver/src/data_directory/signup_mode.rs @@ -0,0 +1,74 @@ +use core::fmt; +use std::{fmt::Display, str::FromStr}; + +use serde::{Deserialize, Serialize}; + +/// The mode of signup. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub enum SignupMode { + /// Everybody can signup. + Open, + /// Only users with a valid token can signup. + #[default] + TokenRequired, +} + +impl Display for SignupMode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Open => write!(f, "open"), + Self::TokenRequired => write!(f, "token_required"), + } + } +} + +impl FromStr for SignupMode { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + Ok(match s { + "open" => Self::Open, + "token_required" => Self::TokenRequired, + _ => return Err(anyhow::anyhow!("Invalid signup mode: {}", s)), + }) + } +} + +impl Serialize for SignupMode { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.to_string().as_str()) + } +} + +impl<'de> Deserialize<'de> for SignupMode { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + Ok(Self::from_str(&s).unwrap()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_signup_mode_from_str() { + assert_eq!(SignupMode::from_str("open").unwrap(), SignupMode::Open); + assert_eq!( + SignupMode::from_str("token_required").unwrap(), + SignupMode::TokenRequired + ); + } + + #[test] + fn test_signup_mode_display() { + assert_eq!(SignupMode::Open.to_string(), "open"); + assert_eq!(SignupMode::TokenRequired.to_string(), "token_required"); + } +} diff --git a/pubky-homeserver/src/io/http.rs b/pubky-homeserver/src/homeserver/http.rs similarity index 100% rename from pubky-homeserver/src/io/http.rs rename to pubky-homeserver/src/homeserver/http.rs diff --git a/pubky-homeserver/src/io/homeserver_key_republisher.rs b/pubky-homeserver/src/homeserver/key_republisher.rs similarity index 100% rename from pubky-homeserver/src/io/homeserver_key_republisher.rs rename to pubky-homeserver/src/homeserver/key_republisher.rs diff --git a/pubky-homeserver/src/homeserver/mod.rs b/pubky-homeserver/src/homeserver/mod.rs new file mode 100644 index 0000000..fd65d75 --- /dev/null +++ b/pubky-homeserver/src/homeserver/mod.rs @@ -0,0 +1,5 @@ +mod http; +mod key_republisher; +mod server; + +pub use server::*; diff --git a/pubky-homeserver/src/io/mod.rs b/pubky-homeserver/src/homeserver/server.rs similarity index 62% rename from pubky-homeserver/src/io/mod.rs rename to pubky-homeserver/src/homeserver/server.rs index 9c7e2f4..6170c82 100644 --- a/pubky-homeserver/src/io/mod.rs +++ b/pubky-homeserver/src/homeserver/server.rs @@ -1,22 +1,16 @@ -use std::{ - net::SocketAddr, - path::{Path, PathBuf}, - time::Duration, -}; +use std::{net::SocketAddr, path::PathBuf, time::Duration}; -use ::pkarr::{Keypair, PublicKey}; +use super::http::HttpServers; +use super::key_republisher::HomeserverKeyRepublisher; +use crate::{data_directory::DataDir, SignupMode}; use anyhow::Result; -use homeserver_key_republisher::HomeserverKeyRepublisher; -use http::HttpServers; +use pkarr::{Keypair, PublicKey}; use tracing::info; -use crate::{ - config::{Config, DEFAULT_HTTPS_PORT, DEFAULT_HTTP_PORT}, - core::{HomeserverCore, SignupMode}, -}; +use crate::core::{AdminConfig, CoreConfig, HomeserverCore}; -mod homeserver_key_republisher; -mod http; +pub const DEFAULT_HTTP_PORT: u16 = 6286; +pub const DEFAULT_HTTPS_PORT: u16 = 6287; #[derive(Debug, Default)] /// Builder for [Homeserver]. @@ -88,7 +82,7 @@ impl HomeserverBuilder { pub struct Homeserver { http_servers: HttpServers, keypair: Keypair, - pkarr_server: HomeserverKeyRepublisher, + key_republisher: HomeserverKeyRepublisher, } impl Homeserver { @@ -97,13 +91,11 @@ impl Homeserver { HomeserverBuilder::default() } - /// Run a Homeserver with a configuration file path. - /// - /// # Safety - /// Homeserver uses LMDB, [opening][heed::EnvOpenOptions::open] which is marked unsafe, - /// because the possible Undefined Behavior (UB) if the lock file is broken. - pub async fn run_with_config_file(config_path: impl AsRef) -> Result { - unsafe { Self::run(Config::load(config_path).await?) }.await + /// Run the homeserver with configurations from a data directory. + pub async fn run_with_data_dir(dir_path: PathBuf) -> Result { + let data_dir = DataDir::new(dir_path); + let config = Config::try_from(data_dir)?; + unsafe { Self::run(config) }.await } /// Run a Homeserver with configurations suitable for ephemeral tests. @@ -152,7 +144,7 @@ impl Homeserver { Ok(Self { http_servers, keypair, - pkarr_server: dht_republisher, + key_republisher: dht_republisher, }) } @@ -173,7 +165,7 @@ impl Homeserver { /// Send a shutdown signal to all open resources pub async fn shutdown(&self) { self.http_servers.shutdown(); - self.pkarr_server.stop_periodic_republish().await; + self.key_republisher.stop_periodic_republish().await; } } @@ -203,3 +195,85 @@ impl Default for IoConfig { } } } + +/// Server configuration +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Config { + /// Server keypair. + /// + /// Defaults to a random keypair. + pub keypair: Keypair, + pub io: IoConfig, + pub core: CoreConfig, + pub admin: AdminConfig, +} + +impl Config { + /// Create test configurations + pub fn test(bootstrap: &[String]) -> Self { + let bootstrap = Some(bootstrap.to_vec()); + + Self { + io: IoConfig { + bootstrap, + http_port: 0, + https_port: 0, + ..Default::default() + }, + core: CoreConfig::test(), + admin: AdminConfig::test(), + ..Default::default() + } + } +} + +impl Default for Config { + fn default() -> Self { + Self { + keypair: Keypair::random(), + io: IoConfig::default(), + core: CoreConfig::default(), + admin: AdminConfig::default(), + } + } +} + +impl TryFrom for Config { + type Error = anyhow::Error; + + fn try_from(dir: DataDir) -> Result { + dir.ensure_data_dir_exists_and_is_writable()?; + let conf = dir.read_or_create_config_file()?; + let keypair = dir.read_or_create_keypair()?; + + // TODO: Needs refactoring of the Homeserver Config struct. I am not doing + // it yet because I am concentrating on the config currently. + let io = IoConfig { + http_port: conf.drive.icann_listen_socket.port(), + https_port: conf.drive.pubky_listen_socket.port(), + domain: conf.drive.icann_domain, + public_addr: Some(conf.pkdns.public_socket), + ..Default::default() + }; + + let core = CoreConfig { + storage: dir.path().join("data/lmdb"), + user_keys_republisher_interval: Some(Duration::from_secs( + conf.pkdns.user_keys_republisher_interval.into(), + )), + ..Default::default() + }; + + let admin = AdminConfig { + signup_mode: conf.general.signup_mode, + password: Some(conf.admin.admin_password), + }; + + Ok(Config { + keypair, + io, + core, + admin, + }) + } +} diff --git a/pubky-homeserver/src/lib.rs b/pubky-homeserver/src/lib.rs index 83b27d3..a66e510 100644 --- a/pubky-homeserver/src/lib.rs +++ b/pubky-homeserver/src/lib.rs @@ -5,9 +5,10 @@ #![deny(rustdoc::broken_intra_doc_links)] #![cfg_attr(any(), deny(clippy::unwrap_used))] -mod config; mod core; -mod io; +mod data_directory; +mod homeserver; -pub use io::Homeserver; -pub use io::HomeserverBuilder; +pub use data_directory::*; +pub use homeserver::Homeserver; +pub use homeserver::HomeserverBuilder; diff --git a/pubky-homeserver/src/main.rs b/pubky-homeserver/src/main.rs index 7cfd9d0..4b16d40 100644 --- a/pubky-homeserver/src/main.rs +++ b/pubky-homeserver/src/main.rs @@ -1,19 +1,29 @@ use std::path::PathBuf; use anyhow::Result; -use pubky_homeserver::Homeserver; - use clap::Parser; +use pubky_homeserver::Homeserver; +use tracing_subscriber::EnvFilter; + +fn default_config_dir_path() -> PathBuf { + dirs::home_dir().unwrap_or_default().join(".pubky") +} + +/// Validate that the data_dir path is a directory. +/// It doesnt need to exist, but if it does, it needs to be a directory. +fn validate_config_dir_path(path: &str) -> Result { + let path = PathBuf::from(path); + if path.exists() && path.is_file() { + return Err(format!("Given path is not a directory: {}", path.display())); + } + Ok(path) +} #[derive(Parser, Debug)] struct Cli { - /// [tracing_subscriber::EnvFilter] - #[clap(short, long)] - tracing_env_filter: Option, - - /// Optional Path to config file. - #[clap(short, long)] - config: Option, + /// Path to config file. Defaults to ~/.pubky/config.toml + #[clap(short, long, default_value_os_t = default_config_dir_path(), value_parser = validate_config_dir_path)] + data_dir: PathBuf, } #[tokio::main] @@ -22,18 +32,14 @@ async fn main() -> Result<()> { tracing_subscriber::fmt() .with_env_filter( - args.tracing_env_filter - .unwrap_or("pubky_homeserver=debug,tower_http=debug".to_string()), + EnvFilter::try_from_default_env() + .unwrap_or_else(|_| EnvFilter::new("pubky_homeserver=debug,tower_http=debug")), ) .init(); - let server = unsafe { - if let Some(config_path) = args.config { - Homeserver::run_with_config_file(config_path).await? - } else { - Homeserver::builder().run().await? - } - }; + tracing::debug!("Using data dir: {}", args.data_dir.display()); + + let server = Homeserver::run_with_data_dir(args.data_dir).await?; tokio::signal::ctrl_c().await?;