use crate::pubky::Pubky;
use anyhow::Context;
use std::{
net::{IpAddr, Ipv4Addr, SocketAddr},
path::{Path, PathBuf},
str::FromStr,
};
use crate::common::testnet_keypair;
use crate::Testnet;
use http_relay::HttpRelay;
use pubky_common::constants::testnet_ports;
use pubky_homeserver::{
AppContext, ConfigToml, ConnectionString, DataDir, DomainPort, HomeserverApp, MockDataDir,
PersistentDataDir,
};
const BIND_ALL: IpAddr = IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0));
fn apply_static_testnet_overrides(config: &mut ConfigToml, bootstrap_nodes: Vec<DomainPort>) {
config.pkdns.dht_bootstrap_nodes = Some(bootstrap_nodes);
config.pkdns.dht_relay_nodes = None;
config.drive.icann_listen_socket =
SocketAddr::new(BIND_ALL, testnet_ports::HOMESERVER_ICANN_HTTP);
config.drive.pubky_listen_socket =
SocketAddr::new(BIND_ALL, testnet_ports::HOMESERVER_PUBKY_HTTPS);
config.admin.enabled = true;
config.admin.listen_socket = SocketAddr::new(BIND_ALL, testnet_ports::HOMESERVER_ADMIN);
}
#[derive(Debug)]
enum StorageMode {
InMemory,
Persistent(PathBuf),
}
pub struct StaticTestnetBuilder {
homeserver_config: Option<PathBuf>,
mode: StorageMode,
}
impl StaticTestnetBuilder {
fn new() -> Self {
Self {
homeserver_config: None,
mode: StorageMode::InMemory,
}
}
pub fn homeserver_config(mut self, path: PathBuf) -> Self {
self.homeserver_config = Some(path);
self
}
pub fn persistent(mut self, data_dir: PathBuf) -> Self {
self.mode = StorageMode::Persistent(data_dir);
self
}
pub async fn build(self) -> anyhow::Result<StaticTestnet> {
let mut testnet = StaticTestnet::start_infra().await?;
let persistent = match self.mode {
StorageMode::InMemory => {
testnet
.run_in_memory_homeserver(self.homeserver_config.as_deref())
.await
.map_err(|e| anyhow::anyhow!("Failed to run in-memory homeserver: {}", e))?;
false
}
StorageMode::Persistent(data_dir) => {
testnet
.run_persistent_homeserver(data_dir, self.homeserver_config.as_deref())
.await
.map_err(|e| anyhow::anyhow!("Failed to run persistent homeserver: {}", e))?;
true
}
};
testnet.is_persistent = persistent;
Ok(testnet)
}
}
pub struct StaticTestnet {
pub testnet: Testnet,
is_persistent: bool,
#[allow(dead_code)]
fixed_bootstrap_node: Option<mainline::Dht>, #[allow(dead_code)]
temp_dirs: Vec<tempfile::TempDir>, }
impl StaticTestnet {
pub fn builder() -> StaticTestnetBuilder {
StaticTestnetBuilder::new()
}
pub async fn start() -> anyhow::Result<Self> {
Self::builder().build().await
}
#[deprecated(
since = "0.9.0",
note = "Use StaticTestnet::builder().homeserver_config(path).build() instead"
)]
pub async fn start_with_homeserver_config(config_path: PathBuf) -> anyhow::Result<Self> {
Self::builder().homeserver_config(config_path).build().await
}
pub fn is_persistent(&self) -> bool {
self.is_persistent
}
async fn start_infra() -> anyhow::Result<Self> {
let testnet = Testnet::new().await?;
let fixed_bootsrap =
Self::run_fixed_bootsrap_node(&testnet.dht.bootstrap).map_err(|e| {
anyhow::anyhow!(
"Failed to run bootstrap node on port {}: {}",
testnet_ports::BOOTSTRAP,
e
)
})?;
let mut testnet = Self {
testnet,
fixed_bootstrap_node: fixed_bootsrap,
temp_dirs: vec![],
is_persistent: false,
};
testnet.run_fixed_pkarr_relays().await.map_err(|e| {
anyhow::anyhow!(
"Failed to run pkarr relay on port {}: {}",
testnet_ports::PKARR_RELAY,
e
)
})?;
testnet.run_fixed_http_relay().await.map_err(|e| {
anyhow::anyhow!(
"Failed to run http relay on port {}: {}",
testnet_ports::HTTP_RELAY,
e
)
})?;
Ok(testnet)
}
pub async fn create_random_homeserver(
&mut self,
) -> anyhow::Result<&pubky_homeserver::HomeserverApp> {
self.testnet.create_random_homeserver().await
}
pub fn client_builder(&self) -> pubky::PubkyHttpClientBuilder {
self.testnet.client_builder()
}
pub fn client(&self) -> Result<pubky::PubkyHttpClient, pubky::BuildError> {
self.testnet.client()
}
pub fn sdk(&self) -> Result<Pubky, pubky::BuildError> {
self.testnet.sdk()
}
pub fn pkarr_client_builder(&self) -> pkarr::ClientBuilder {
self.testnet.pkarr_client_builder()
}
pub fn homeserver_app(&self) -> &pubky_homeserver::HomeserverApp {
self.testnet
.homeservers
.first()
.expect("homeservers should be non-empty")
}
pub fn http_relay(&self) -> &HttpRelay {
self.testnet
.http_relays
.first()
.expect("http relays should be non-empty")
}
pub fn pkarr_relay(&self) -> &pkarr_relay::Relay {
self.testnet
.pkarr_relays
.first()
.expect("pkarr relays should be non-empty")
}
pub fn bootstrap_nodes(&self) -> Vec<String> {
let mut nodes = vec![];
if let Some(dht) = &self.fixed_bootstrap_node {
#[allow(deprecated, reason = "mainline has no synchronous replacement")]
nodes.push(dht.info().local_addr().to_string());
}
nodes.extend(
self.testnet
.dht_bootstrap_nodes()
.iter()
.map(|node| node.to_string()),
);
nodes
}
fn run_fixed_bootsrap_node(
other_bootstrap_nodes: &[String],
) -> anyhow::Result<Option<mainline::Dht>> {
let port_suffix = format!(":{}", testnet_ports::BOOTSTRAP);
if other_bootstrap_nodes
.iter()
.any(|node| node.ends_with(&port_suffix))
{
return Ok(None);
}
let mut builder = mainline::Dht::builder();
let dht = builder
.port(testnet_ports::BOOTSTRAP)
.bootstrap(other_bootstrap_nodes)
.server_mode()
.build()?;
Ok(Some(dht))
}
async fn run_fixed_pkarr_relays(&mut self) -> anyhow::Result<()> {
let temp_dir = tempfile::tempdir()?; let mut builder = pkarr_relay::Relay::builder();
builder
.http_port(testnet_ports::PKARR_RELAY)
.storage(temp_dir.path().to_path_buf())
.disable_rate_limiter()
.report_policy(pkarr::dht::ReportPolicy::testnet())
.dht(|config| {
config.bootstrap = Some(
self.testnet
.dht
.bootstrap
.iter()
.map(|address| address.parse().expect("testnet bootstrap address is valid"))
.collect(),
);
config
});
let relay = unsafe { builder.run() }.await?;
self.testnet.pkarr_relays.push(relay);
self.temp_dirs.push(temp_dir);
Ok(())
}
async fn run_fixed_http_relay(&mut self) -> anyhow::Result<()> {
let relay = HttpRelay::builder()
.bind_address(BIND_ALL)
.http_port(testnet_ports::HTTP_RELAY)
.cors_allow_all(true)
.run()
.await?;
self.testnet.http_relays.push(relay);
Ok(())
}
fn parse_bootstrap_nodes(&self) -> anyhow::Result<Vec<DomainPort>> {
self.bootstrap_nodes()
.iter()
.map(|node| {
DomainPort::from_str(node).map_err(|e| {
anyhow::anyhow!("Failed to parse bootstrap node '{}': {}", node, e)
})
})
.collect()
}
async fn run_persistent_homeserver(
&mut self,
data_dir: PathBuf,
config_path: Option<&Path>,
) -> anyhow::Result<()> {
let persistent_dir = PersistentDataDir::new(data_dir);
if let Some(source) = config_path {
persistent_dir.seed_config(source).context(
"Remove --homeserver-config to use the existing config, \
or delete the file to replace it.",
)?;
}
persistent_dir.ensure_data_dir_exists_and_is_writable()?;
let testnet_dir = TestnetDataDir {
inner: persistent_dir,
dht_bootstrap_nodes: self.parse_bootstrap_nodes()?,
postgres_connection_string: self.testnet.postgres_connection_string.clone(),
};
let context = AppContext::read_from(testnet_dir).await?;
let homeserver = HomeserverApp::start(context).await?;
self.testnet.homeservers.push(homeserver);
Ok(())
}
async fn run_in_memory_homeserver(&mut self, config_path: Option<&Path>) -> anyhow::Result<()> {
let mut config = if let Some(config_path) = config_path {
ConfigToml::from_file(config_path)?
} else {
ConfigToml::default_test_config()
};
apply_static_testnet_overrides(&mut config, self.parse_bootstrap_nodes()?);
let mock = MockDataDir::new(config, Some(testnet_keypair()))?;
let homeserver = HomeserverApp::start_with_mock_data_dir(mock).await?;
self.testnet.homeservers.push(homeserver);
Ok(())
}
}
#[derive(Debug, Clone)]
struct TestnetDataDir {
inner: PersistentDataDir,
dht_bootstrap_nodes: Vec<DomainPort>,
postgres_connection_string: Option<ConnectionString>,
}
impl DataDir for TestnetDataDir {
fn path(&self) -> &Path {
self.inner.path()
}
fn resolve_database_mode(
&self,
conf: &ConfigToml,
) -> anyhow::Result<pubky_homeserver::DatabaseMode> {
self.inner.resolve_database_mode(conf)
}
fn ensure_data_dir_exists_and_is_writable(&self) -> anyhow::Result<()> {
self.inner.ensure_data_dir_exists_and_is_writable()
}
fn read_or_create_config_file(&self) -> anyhow::Result<ConfigToml> {
let mut config = self.inner.read_or_create_config_file()?;
apply_static_testnet_overrides(&mut config, self.dht_bootstrap_nodes.clone());
config.general.database_url = self
.postgres_connection_string
.clone()
.or(config.general.database_url);
if config.general.database_url.is_none() {
anyhow::bail!(
"Persistent testnet requires an explicit database URL. \
Set `database_url` in config.toml under [general]."
);
}
Ok(config)
}
fn read_or_create_keypair(&self) -> anyhow::Result<pubky_common::crypto::Keypair> {
let secret_file = self.inner.get_secret_file_path();
if !secret_file.exists() {
let keypair = testnet_keypair();
keypair.write_secret_key_file(&secret_file)?;
tracing::info!(
"Seeded deterministic keypair (pubkey {}) at {}",
keypair.public_key(),
secret_file.display()
);
}
self.inner.read_or_create_keypair()
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn testnet_data_dir_overrides_dht_config() {
let temp = TempDir::new().unwrap();
let persistent = PersistentDataDir::new(temp.path().to_path_buf());
persistent.init().unwrap();
let bootstrap = vec![DomainPort::from_str("127.0.0.1:6881").unwrap()];
let testnet_dir = TestnetDataDir {
inner: persistent.clone(),
dht_bootstrap_nodes: bootstrap.clone(),
postgres_connection_string: None,
};
let config = testnet_dir.read_or_create_config_file().unwrap();
assert_eq!(config.pkdns.dht_bootstrap_nodes, Some(bootstrap));
assert_eq!(config.pkdns.dht_relay_nodes, None);
}
#[test]
fn testnet_data_dir_delegates_path() {
let temp = TempDir::new().unwrap();
let persistent = PersistentDataDir::new(temp.path().to_path_buf());
let testnet_dir = TestnetDataDir {
inner: persistent.clone(),
dht_bootstrap_nodes: vec![],
postgres_connection_string: None,
};
assert_eq!(testnet_dir.path(), persistent.path());
}
#[test]
fn testnet_data_dir_seeds_deterministic_keypair() {
let temp = TempDir::new().unwrap();
let persistent = PersistentDataDir::new(temp.path().to_path_buf());
persistent.init().unwrap();
std::fs::remove_file(persistent.get_secret_file_path()).unwrap();
let testnet_dir = TestnetDataDir {
inner: persistent.clone(),
dht_bootstrap_nodes: vec![],
postgres_connection_string: None,
};
let expected = testnet_keypair();
let kp = testnet_dir.read_or_create_keypair().unwrap();
assert_eq!(
kp.public_key(),
expected.public_key(),
"TestnetDataDir should seed the deterministic keypair"
);
let kp2 = testnet_dir.read_or_create_keypair().unwrap();
assert_eq!(kp.public_key(), kp2.public_key());
}
#[test]
fn testnet_data_dir_preserves_existing_keypair() {
let temp = TempDir::new().unwrap();
let persistent = PersistentDataDir::new(temp.path().to_path_buf());
persistent.init().unwrap();
let existing_kp = persistent.read_or_create_keypair().unwrap();
let testnet_dir = TestnetDataDir {
inner: persistent,
dht_bootstrap_nodes: vec![],
postgres_connection_string: None,
};
let kp = testnet_dir.read_or_create_keypair().unwrap();
assert_eq!(
kp.public_key(),
existing_kp.public_key(),
"TestnetDataDir should not overwrite an existing keypair"
);
}
#[test]
fn config_seeding_copies_file_to_empty_data_dir() {
let temp = TempDir::new().unwrap();
let data_dir = temp.path().join("testnet");
let persistent = PersistentDataDir::new(data_dir);
let source_config = temp.path().join("custom.toml");
let sample = ConfigToml::sample_string();
std::fs::write(&source_config, &sample).unwrap();
persistent.seed_config(&source_config).unwrap();
assert!(persistent.get_config_file_path().exists());
let content = std::fs::read_to_string(persistent.get_config_file_path()).unwrap();
assert_eq!(content, sample);
}
#[test]
fn config_seeding_rejects_when_config_already_exists() {
let temp = TempDir::new().unwrap();
let persistent = PersistentDataDir::new(temp.path().to_path_buf());
persistent.init().unwrap();
let source = temp.path().join("other.toml");
std::fs::write(&source, "[general]\nsignup_mode = \"open\"\n").unwrap();
let result = persistent.seed_config(&source);
assert!(
result.is_err(),
"Seeding should be rejected when config.toml already exists"
);
assert!(
result.unwrap_err().to_string().contains("already exists"),
"Error message should mention existing config"
);
}
#[test]
fn builder_defaults_to_in_memory() {
let builder = StaticTestnet::builder();
assert!(
matches!(builder.mode, StorageMode::InMemory),
"Default mode should be InMemory"
);
assert!(builder.homeserver_config.is_none());
}
#[test]
fn builder_persistent_sets_mode() {
let dir = PathBuf::from("/tmp/test-data");
let builder = StaticTestnet::builder().persistent(dir.clone());
match &builder.mode {
StorageMode::Persistent(d) => assert_eq!(d, &dir),
StorageMode::InMemory => panic!("Expected Persistent mode"),
}
}
#[test]
fn builder_homeserver_config_sets_path() {
let config = PathBuf::from("/tmp/my-config.toml");
let builder = StaticTestnet::builder().homeserver_config(config.clone());
assert_eq!(builder.homeserver_config, Some(config));
}
#[test]
fn builder_chaining_all_options() {
let dir = PathBuf::from("/tmp/data");
let config = PathBuf::from("/tmp/config.toml");
let builder = StaticTestnet::builder()
.homeserver_config(config.clone())
.persistent(dir.clone());
assert_eq!(builder.homeserver_config, Some(config));
assert!(matches!(builder.mode, StorageMode::Persistent(d) if d == dir));
}
#[test]
fn persistent_data_dir_init_creates_structure() {
let temp = TempDir::new().unwrap();
let data_dir = temp.path().join("new-testnet");
let persistent = PersistentDataDir::new(data_dir.clone());
persistent.init().unwrap();
assert!(
persistent.get_config_file_path().exists(),
"config.toml should be created"
);
let kp = persistent.read_or_create_keypair().unwrap();
let kp2 = persistent.read_or_create_keypair().unwrap();
assert_eq!(
kp.public_key(),
kp2.public_key(),
"Keypair should be stable across reads"
);
}
#[test]
fn seeded_config_is_readable_by_testnet_data_dir() {
let temp = TempDir::new().unwrap();
let data_dir = temp.path().join("testnet");
let persistent = PersistentDataDir::new(data_dir);
let config_content = "[general]\nsignup_mode = \"token_required\"\n";
let source = temp.path().join("seed.toml");
std::fs::write(&source, config_content).unwrap();
std::fs::create_dir_all(persistent.path()).unwrap();
std::fs::copy(&source, persistent.get_config_file_path()).unwrap();
persistent.init().unwrap();
let bootstrap = vec![DomainPort::from_str("127.0.0.1:6881").unwrap()];
let testnet_dir = TestnetDataDir {
inner: persistent,
dht_bootstrap_nodes: bootstrap.clone(),
postgres_connection_string: None,
};
let config = testnet_dir.read_or_create_config_file().unwrap();
assert_eq!(
config.general.signup_mode,
pubky_homeserver::SignupMode::TokenRequired
);
assert_eq!(config.pkdns.dht_bootstrap_nodes, Some(bootstrap));
assert_eq!(config.pkdns.dht_relay_nodes, None);
assert_eq!(
config.drive.icann_listen_socket.port(),
testnet_ports::HOMESERVER_ICANN_HTTP
);
assert_eq!(
config.drive.pubky_listen_socket.port(),
testnet_ports::HOMESERVER_PUBKY_HTTPS
);
assert_eq!(
config.admin.listen_socket.port(),
testnet_ports::HOMESERVER_ADMIN
);
assert!(config.admin.enabled);
}
#[test]
fn persistent_state_survives_restart() {
let temp = TempDir::new().unwrap();
let data_dir = temp.path().join("testnet");
let bootstrap = vec![DomainPort::from_str("127.0.0.1:6881").unwrap()];
let expected_key = testnet_keypair();
let source = temp.path().join("seed.toml");
std::fs::write(&source, "[general]\nsignup_mode = \"token_required\"\n").unwrap();
let persistent1 = PersistentDataDir::new(data_dir.clone());
persistent1.seed_config(&source).unwrap();
persistent1
.ensure_data_dir_exists_and_is_writable()
.unwrap();
persistent1.read_or_create_config_file().unwrap();
let dir1 = TestnetDataDir {
inner: persistent1,
dht_bootstrap_nodes: bootstrap.clone(),
postgres_connection_string: None,
};
let kp1 = dir1.read_or_create_keypair().unwrap();
let config1 = dir1.read_or_create_config_file().unwrap();
assert_eq!(
kp1.public_key(),
expected_key.public_key(),
"First run should use the deterministic keypair"
);
drop(dir1);
let persistent2 = PersistentDataDir::new(data_dir);
let dir2 = TestnetDataDir {
inner: persistent2,
dht_bootstrap_nodes: bootstrap,
postgres_connection_string: None,
};
let kp2 = dir2.read_or_create_keypair().unwrap();
let config2 = dir2.read_or_create_config_file().unwrap();
assert_eq!(
kp1.public_key(),
kp2.public_key(),
"Keypair should persist across restarts"
);
assert_eq!(
config1.general.signup_mode, config2.general.signup_mode,
"Config should persist across restarts"
);
assert_eq!(
config2.general.signup_mode,
pubky_homeserver::SignupMode::TokenRequired
);
}
}