use anyhow::Result;
use clap::Parser;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::mpsc;
use tracing::{error, info, warn};
use nntp_proxy::{
CommonArgs, NntpProxy, RuntimeConfig, UiMode, metrics::MetricsStore, runtime, tui,
};
#[derive(Parser, Debug)]
#[command(author, version, about = "NNTP Proxy server", long_about = None)]
struct Args {
#[command(flatten)]
common: CommonArgs,
}
fn main() -> Result<()> {
let args = Args::parse();
args.common.validate_runtime_mode()?;
let ui_mode = args.common.effective_ui_mode();
let capture_headless_tui_buffer =
ui_mode == UiMode::Headless && args.common.tui_listen.is_some();
if ui_mode == UiMode::Tui
&& let Some(connect_addr) = args.common.tui_attach
{
let file_level = nntp_proxy::Config::default().proxy.log_file_level;
let _ = nntp_proxy::logging::init_logging(UiMode::Tui, &file_level, false, false);
let threads = args.common.threads;
return RuntimeConfig::from_args(threads)
.build_runtime()?
.block_on(tui::run_attached_tui(connect_addr));
}
let startup_tui = if ui_mode == UiMode::Tui {
Some(tui::start_startup_tui_session()?)
} else {
None
};
let (mut config, _) = runtime::load_and_log_config(args.common.config.as_str())?;
args.common.apply_overrides(&mut config);
let write_debug_log = nntp_proxy::logging::should_write_debug_log(
ui_mode,
args.common.tui_attach.is_some(),
&config.proxy.log_file_level,
);
let capture_in_memory_logs = nntp_proxy::logging::should_capture_in_memory_logs(
ui_mode,
args.common.tui_attach.is_some(),
capture_headless_tui_buffer,
);
let log_buffer = nntp_proxy::logging::init_logging(
ui_mode,
&config.proxy.log_file_level,
capture_in_memory_logs,
write_debug_log,
);
let threads = args.common.threads.or(Some(config.proxy.threads));
RuntimeConfig::from_args(threads)
.build_runtime()?
.block_on(run_proxy(args, config, ui_mode, log_buffer, startup_tui))
}
async fn run_proxy(
args: Args,
config: nntp_proxy::config::Config,
ui_mode: UiMode,
log_buffer: Option<tui::LogBuffer>,
startup_tui: Option<tui::StartupTuiSession>,
) -> Result<()> {
let launch = prepare_proxy_launch(&args, &config);
args.common
.validate_dashboard_listen(&launch.host, launch.port)?;
let proxy = build_proxy(config, launch.routing_mode, launch.metrics_store).await?;
let (shutdown_tx, mut shutdown_rx) = mpsc::channel::<()>(1);
if let Some(path) = launch.availability_path.as_ref() {
let _ = runtime::load_availability_from_disk(proxy.cache(), path);
}
let listener = runtime::bind_listener(&launch.host, launch.port, launch.routing_mode).await?;
runtime::spawn_stats_flusher(proxy.connection_stats());
runtime::spawn_cache_stats_logger(&proxy);
runtime::spawn_metrics_saver(
&proxy,
launch.stats_path.clone(),
launch.server_names.clone(),
);
runtime::spawn_availability_saver(&proxy, launch.availability_path.clone());
runtime::spawn_idle_connection_clearer(&proxy);
runtime::spawn_response_write_metrics_logger();
runtime::spawn_client_writer_lock_metrics_logger();
runtime::spawn_tokio_runtime_metrics_logger();
let (dashboard_handle, dashboard_shutdown_tx) =
launch_dashboard_publisher(args.common.tui_listen, &proxy, log_buffer.clone()).await?;
let (tui_handle, tui_shutdown_tx) = launch_tui(
ui_mode,
&proxy,
log_buffer,
shutdown_tx.clone(),
startup_tui,
);
let error_tui_shutdown_tx = tui_shutdown_tx.clone();
let error_dashboard_shutdown_tx = dashboard_shutdown_tx.clone();
spawn_signal_forwarder(shutdown_tx, tui_shutdown_tx, dashboard_shutdown_tx);
info!("Prewarming connection pools...");
let prewarm_result = tokio::select! {
result = proxy.prewarm_connections() => result,
_ = shutdown_rx.recv() => {
info!("Shutdown requested before connection pools finished prewarming");
shutdown_background_tasks(
error_tui_shutdown_tx,
error_dashboard_shutdown_tx,
tui_handle,
dashboard_handle,
).await?;
return Ok(());
}
};
if let Err(e) = prewarm_result {
if let Some(tx) = error_tui_shutdown_tx {
let _ = tx.send(()).await;
}
if let Some(tx) = error_dashboard_shutdown_tx {
let _ = tx.send(()).await;
}
if let Some(handle) = tui_handle {
handle.await?;
}
if let Some(handle) = dashboard_handle {
handle.await?;
}
return Err(e);
}
info!("Connection pools ready, accepting clients");
let accept_result =
runtime::run_accept_loop(proxy.clone(), listener, shutdown_rx, launch.routing_mode).await;
shutdown_background_tasks(
error_tui_shutdown_tx,
error_dashboard_shutdown_tx,
tui_handle,
dashboard_handle,
)
.await?;
if let Err(e) = runtime::persist_runtime_state(
&proxy,
launch.stats_path,
launch.availability_path,
launch.server_names,
)
.await
{
warn!("Failed to persist runtime state after shutdown: {}", e);
}
accept_result
}
async fn shutdown_background_tasks(
tui_shutdown_tx: Option<mpsc::Sender<()>>,
dashboard_shutdown_tx: Option<mpsc::Sender<()>>,
tui_handle: Option<TuiHandle>,
dashboard_handle: Option<TuiHandle>,
) -> Result<()> {
if let Some(tx) = tui_shutdown_tx {
let _ = tx.send(()).await;
}
if let Some(tx) = dashboard_shutdown_tx {
let _ = tx.send(()).await;
}
if let Some(handle) = tui_handle {
handle.await?;
}
if let Some(handle) = dashboard_handle {
handle.await?;
}
Ok(())
}
async fn build_proxy(
config: nntp_proxy::config::Config,
routing_mode: nntp_proxy::RoutingMode,
metrics_store: Option<MetricsStore>,
) -> Result<Arc<NntpProxy>> {
let mut builder = NntpProxy::builder(config).with_routing_mode(routing_mode);
if let Some(store) = metrics_store {
builder = builder.with_metrics_store(store);
}
Ok(Arc::new(builder.build().await?))
}
type TuiHandle = tokio::task::JoinHandle<()>;
struct ProxyLaunch {
routing_mode: nntp_proxy::RoutingMode,
host: String,
port: nntp_proxy::types::Port,
stats_path: PathBuf,
availability_path: Option<PathBuf>,
server_names: Vec<String>,
metrics_store: Option<MetricsStore>,
}
fn prepare_proxy_launch(args: &Args, config: &nntp_proxy::config::Config) -> ProxyLaunch {
let routing_mode = args
.common
.routing_mode
.unwrap_or(config.routing.routing_mode);
let (host, port) =
runtime::resolve_listen_address(args.common.host.as_deref(), args.common.port, config);
let stats_path = runtime::resolve_stats_file_path(
args.common.config.as_str(),
config.proxy.stats_file.as_ref(),
);
let availability_path =
runtime::resolve_availability_file_path(args.common.config.as_str(), config.cache.as_ref());
let server_names: Vec<String> = config
.servers
.iter()
.map(|s| s.name.as_ref().to_string())
.collect();
let metrics_store = runtime::load_metrics_from_disk(&stats_path, &server_names);
ProxyLaunch {
routing_mode,
host,
port,
stats_path,
availability_path,
server_names,
metrics_store,
}
}
fn launch_tui(
ui_mode: UiMode,
proxy: &Arc<NntpProxy>,
log_buffer: Option<tui::LogBuffer>,
shutdown_tx: mpsc::Sender<()>,
startup_tui: Option<tui::StartupTuiSession>,
) -> (Option<TuiHandle>, Option<mpsc::Sender<()>>) {
if !ui_mode.uses_tui() {
return (None, None);
}
let (tui_shutdown_tx, tui_shutdown_rx) = mpsc::channel::<()>(1);
info!("Building TUI dashboard...");
let cache = proxy.cache();
info!(
"Adding cache to TUI: entries={}, size={}",
cache.entry_count(),
cache.weighted_size()
);
let tui_app = build_dashboard_app(proxy, log_buffer);
let handle = tokio::spawn(async move {
info!("Initializing TUI dashboard...");
let result = if let Some(session) = startup_tui {
tui::run_tui_from_startup_session(session, tui_app, shutdown_tx, tui_shutdown_rx).await
} else {
tui::run_tui(tui_app, shutdown_tx, tui_shutdown_rx).await
};
if let Err(e) = result {
error!("TUI error: {}", e);
}
info!("TUI closed");
});
(Some(handle), Some(tui_shutdown_tx))
}
async fn launch_dashboard_publisher(
listen_addr: Option<SocketAddr>,
proxy: &Arc<NntpProxy>,
log_buffer: Option<tui::LogBuffer>,
) -> Result<(Option<TuiHandle>, Option<mpsc::Sender<()>>)> {
let Some(listen_addr) = listen_addr else {
return Ok((None, None));
};
let (dashboard_shutdown_tx, dashboard_shutdown_rx) = mpsc::channel::<()>(1);
info!("Building websocket dashboard publisher...");
let tui_app = build_dashboard_app(proxy, log_buffer);
let listener = tui::bind_dashboard_listener(listen_addr).await?;
let handle = tokio::spawn(async move {
info!("Initializing websocket dashboard on {}", listen_addr);
if let Err(e) =
tui::run_dashboard_publisher_on_listener(tui_app, listener, dashboard_shutdown_rx).await
{
error!("Websocket dashboard error: {}", e);
}
info!("Websocket dashboard closed");
});
Ok((Some(handle), Some(dashboard_shutdown_tx)))
}
fn build_dashboard_app(proxy: &Arc<NntpProxy>, log_buffer: Option<tui::LogBuffer>) -> tui::TuiApp {
let mut builder = tui::TuiAppBuilder::new(
proxy.metrics().clone(),
proxy.router().clone(),
Arc::from(proxy.servers()),
);
if let Some(buffer) = log_buffer {
builder = builder.with_log_buffer(buffer);
}
builder
.with_cache(proxy.cache().clone())
.with_buffer_pool(proxy.buffer_pool().clone())
.build()
}
fn spawn_signal_forwarder(
shutdown_tx: mpsc::Sender<()>,
tui_shutdown_tx: Option<mpsc::Sender<()>>,
dashboard_shutdown_tx: Option<mpsc::Sender<()>>,
) {
tokio::spawn(async move {
runtime::shutdown_signal().await;
info!("Shutdown signal received");
if let Some(tx) = tui_shutdown_tx {
let _ = tx.send(()).await;
}
if let Some(tx) = dashboard_shutdown_tx {
let _ = tx.send(()).await;
}
let _ = shutdown_tx.send(()).await;
});
}
#[cfg(test)]
mod tests {
use super::*;
use nntp_proxy::config::Config;
use nntp_proxy::types::Port;
use std::path::PathBuf;
fn test_common_args() -> CommonArgs {
CommonArgs {
config: "config.toml".parse().unwrap(),
ui: UiMode::Headless,
tui: false,
headless: false,
no_tui: false,
tui_listen: None,
tui_attach: None,
port: None,
host: None,
routing_mode: Some(nntp_proxy::RoutingMode::Stateful),
backend_selection: None,
article_cache_capacity: None,
article_cache_ttl_secs: None,
store_article_bodies: None,
threads: None,
}
}
#[test]
fn prepare_proxy_launch_prefers_cli_routing_mode_and_host_port() {
let args = Args {
common: CommonArgs {
routing_mode: Some(nntp_proxy::RoutingMode::Stateful),
host: Some("127.0.0.1".to_string()),
port: Some(Port::try_new(9120).unwrap()),
..test_common_args()
},
};
let mut config = Config::default();
config.proxy.stats_file = Some(PathBuf::from("proxy-stats.json"));
config.servers = vec![
nntp_proxy::config::Server::builder("server.example.com", Port::try_new(119).unwrap())
.name("Primary")
.build()
.unwrap(),
];
let launch = prepare_proxy_launch(&args, &config);
assert_eq!(launch.routing_mode, nntp_proxy::RoutingMode::Stateful);
assert_eq!(launch.host, "127.0.0.1");
assert_eq!(launch.port.get(), 9120);
assert_eq!(launch.server_names, vec!["Primary".to_string()]);
assert!(launch.metrics_store.is_none());
}
}