mirror of
https://github.com/aljazceru/pubky-core.git
synced 2026-01-26 17:34:21 +01:00
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
This commit is contained in:
committed by
GitHub
parent
6f94333101
commit
5eb61d589b
51
Cargo.lock
generated
51
Cargo.lock
generated
@@ -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]]
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
60
pubky-homeserver/config.default.toml
Normal file
60
pubky-homeserver/config.default.toml
Normal file
@@ -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 `<public_ip>:<public_port>`.
|
||||
#
|
||||
# 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"
|
||||
]
|
||||
@@ -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 <System's Data Directory>
|
||||
#
|
||||
# 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 `<public_ip>:<http_port>`.
|
||||
#
|
||||
# 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"
|
||||
@@ -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<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq, Eq)]
|
||||
struct ReverseProxyToml {
|
||||
pub public_port: Option<u16>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
struct LegacyBrowsersTompl {
|
||||
pub domain: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
|
||||
struct AdminToml {
|
||||
pub password: Option<String>,
|
||||
/// "open" or "token_required" (defaults to "token_required", i.e., a signup token is required)
|
||||
pub signup_mode: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq)]
|
||||
struct IoToml {
|
||||
pub http_port: Option<u16>,
|
||||
pub https_port: Option<u16>,
|
||||
pub public_ip: Option<IpAddr>,
|
||||
|
||||
pub reverse_proxy: Option<ReverseProxyToml>,
|
||||
pub legacy_browsers: Option<LegacyBrowsersTompl>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
struct ConfigToml {
|
||||
secret_key: Option<String>,
|
||||
database: Option<DatabaseToml>,
|
||||
io: Option<IoToml>,
|
||||
admin: Option<AdminToml>,
|
||||
}
|
||||
|
||||
/// 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<Self> {
|
||||
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<Path>) -> Result<Config> {
|
||||
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<ConfigToml> for Config {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
fn try_from(value: ConfigToml) -> std::result::Result<Self, Self::Error> {
|
||||
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 <System's Data Directory>
|
||||
# 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 `<public_ip>:<http_port>`.
|
||||
#
|
||||
# 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()));
|
||||
}
|
||||
}
|
||||
@@ -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<String>) -> Result<PathBuf> {
|
||||
let dir = if let Some(storage) = storage {
|
||||
PathBuf::from(storage)
|
||||
pub fn storage(storage: Option<String>) -> anyhow::Result<PathBuf> {
|
||||
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)]
|
||||
|
||||
@@ -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},
|
||||
|
||||
251
pubky-homeserver/src/data_directory/config_toml.rs
Normal file
251
pubky-homeserver/src/data_directory/config_toml.rs
Normal file
@@ -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<Vec<DomainPort>>,
|
||||
|
||||
/// 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<Vec<Url>>,
|
||||
}
|
||||
|
||||
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<Vec<DomainPort>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn default_dht_relay_nodes() -> Option<Vec<Url>> {
|
||||
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<String>,
|
||||
}
|
||||
|
||||
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<String> {
|
||||
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<ConfigToml>` - The parsed configuration or an error if reading/parsing fails
|
||||
pub fn from_file(path: impl AsRef<std::path::Path>) -> Result<Self, ConfigReadError> {
|
||||
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::<Vec<String>>()
|
||||
.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<Self, Self::Err> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
227
pubky-homeserver/src/data_directory/data_dir.rs
Normal file
227
pubky-homeserver/src/data_directory/data_dir.rs
Normal file
@@ -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<ConfigToml> {
|
||||
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<pkarr::Keypair> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
106
pubky-homeserver/src/data_directory/domain.rs
Normal file
106
pubky-homeserver/src/data_directory/domain.rs
Normal file
@@ -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, anyhow::Error> {
|
||||
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, Self::Err> {
|
||||
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<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
serializer.serialize_str(&self.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Domain {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
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> = 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> = domain.parse();
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"Domain '{}' should be invalid: {}",
|
||||
domain,
|
||||
reason
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
91
pubky-homeserver/src/data_directory/domain_port.rs
Normal file
91
pubky-homeserver/src/data_directory/domain_port.rs
Normal file
@@ -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, Self::Error> {
|
||||
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<Self, Self::Err> {
|
||||
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::<Domain>()?;
|
||||
let port = parts[1].parse::<u16>()?;
|
||||
|
||||
Ok(Self { domain, port })
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for DomainPort {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
serializer.serialize_str(&self.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for DomainPort {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
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());
|
||||
}
|
||||
}
|
||||
11
pubky-homeserver/src/data_directory/mod.rs
Normal file
11
pubky-homeserver/src/data_directory/mod.rs
Normal file
@@ -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;
|
||||
74
pubky-homeserver/src/data_directory/signup_mode.rs
Normal file
74
pubky-homeserver/src/data_directory/signup_mode.rs
Normal file
@@ -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<Self, Self::Err> {
|
||||
Ok(match s {
|
||||
"open" => Self::Open,
|
||||
"token_required" => Self::TokenRequired,
|
||||
_ => return Err(anyhow::anyhow!("Invalid signup mode: {}", s)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for SignupMode {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
serializer.serialize_str(self.to_string().as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for SignupMode {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
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");
|
||||
}
|
||||
}
|
||||
5
pubky-homeserver/src/homeserver/mod.rs
Normal file
5
pubky-homeserver/src/homeserver/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
mod http;
|
||||
mod key_republisher;
|
||||
mod server;
|
||||
|
||||
pub use server::*;
|
||||
@@ -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<Path>) -> Result<Self> {
|
||||
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<Self> {
|
||||
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<DataDir> for Config {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
fn try_from(dir: DataDir) -> Result<Self, Self::Error> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<PathBuf, String> {
|
||||
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<String>,
|
||||
|
||||
/// Optional Path to config file.
|
||||
#[clap(short, long)]
|
||||
config: Option<PathBuf>,
|
||||
/// 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?;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user