Radish alpha
h
rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
Radicle Heartwood Protocol & Stack
Radicle
Git
heartwood crates radicle-node src main.rs
use std::io;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::process::exit;
use std::str::FromStr;

use crossbeam_channel as chan;
use thiserror::Error;

use radicle::node::device::Device;
use radicle::profile;

use radicle_node::crypto::ssh::keystore::{Keystore, MemorySigner};
use radicle_node::fingerprint::{Fingerprint, FingerprintVerification};
use radicle_node::{Runtime, VERSION};
use radicle_signals as signals;

const HELP_MSG: &str = r#"
Usage

   radicle-node [<option>...]

   If you're running a public seed node, make sure to use `--listen` to bind a listening socket to
   eg. `0.0.0.0:8776`, and add your external addresses in your configuration.

Options

    --config      <path>                            Config file to use
                  (default: ~/.radicle/config.json)
    --secret      <path>                            Secret key to use
                  (default ~/.radicle/keys/radicle)
    --force                                         Force start even if an existing control socket
                                                      is found
    --listen      <address>                         Address to listen on
    --log-level   <level>                           Set log level
                  (default: info)
    --log-logger  (radicle | structured | systemd)  Set logger implementation
                  (default: radicle)
    --log-format  json                              Set log format for logger implementation
    --version                                       Print program version
    --help                                          Print help
"#;

#[derive(Debug, Clone)]
enum Logger {
    Radicle,
    #[cfg(feature = "structured-logger")]
    Structured,
    #[cfg(all(feature = "systemd", target_os = "linux"))]
    Systemd,
}

// Required for Mac and potentially Windows as clippy complains because of the OS specific
// guard below.
#[allow(clippy::derivable_impls)]
impl Default for Logger {
    fn default() -> Self {
        #[cfg(all(feature = "systemd", target_os = "linux"))]
        if radicle_systemd::journal::connected() {
            return Logger::Systemd;
        }

        Logger::Radicle
    }
}

impl FromStr for Logger {
    type Err = &'static str;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s {
            "radicle" => Ok(Logger::Radicle),
            #[cfg(feature = "structured-logger")]
            "structured" => Ok(Logger::Structured),
            #[cfg(all(feature = "systemd", target_os = "linux"))]
            "systemd" => Ok(Logger::Systemd),
            _ => Err("unknown logger"),
        }
    }
}

#[derive(Clone, Copy)]
enum LogFormat {
    #[cfg(feature = "structured-logger")]
    Json,
}

impl FromStr for LogFormat {
    type Err = &'static str;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s {
            #[cfg(feature = "structured-logger")]
            "json" => Ok(LogFormat::Json),
            _ => Err("unknown log format"),
        }
    }
}

struct LogOptions {
    level: Option<log::Level>,
    logger: Logger,
    format: Option<LogFormat>,
}

struct Options {
    config: Option<PathBuf>,
    secret: Option<PathBuf>,
    listen: Vec<SocketAddr>,
    log: LogOptions,
    force: bool,
}

fn parse_options() -> Result<Options, lexopt::Error> {
    use lexopt::prelude::*;
    use std::str::FromStr as _;

    let mut parser = lexopt::Parser::from_env();
    let mut listen = Vec::new();
    let mut config = None;
    let mut secret = None;
    let mut force = false;
    let mut log_level = None;
    let mut log_logger = Logger::default();
    let mut log_format = None;

    while let Some(arg) = parser.next()? {
        match arg {
            Long("force") => {
                force = true;
            }
            Long("config") => {
                config = Some(parser.value()?.parse_with(PathBuf::from_str)?);
            }
            Long("secret") => {
                secret = Some(parser.value()?.parse()?);
            }
            Long("listen") => {
                let addr = parser.value()?.parse_with(SocketAddr::from_str)?;
                listen.push(addr);
            }
            Long("log") | Long("log-level") => {
                if matches!(arg, Long("log")) {
                    eprintln!(
                        "Warning: The option `--log` is deprecated and will be removed. Please use `--log-level` instead."
                    );
                }
                log_level = Some(parser.value()?.parse_with(log::Level::from_str)?);
            }
            Long("log-logger") => {
                let parsed = parser.value()?.parse_with(Logger::from_str)?;
                if matches!(parsed, Logger::Radicle) {
                    return Err(lexopt::Error::Custom(
                        "explicitly choosing this logger is forbidden, because it is deprecated"
                            .into(),
                    ));
                }
                log_logger = parsed;
            }
            Long("log-format") => {
                log_format = Some(parser.value()?.parse_with(LogFormat::from_str)?);
            }
            Long("help") | Short('h') => {
                println!("{HELP_MSG}");
                exit(0);
            }
            Long("version") => {
                let _ = VERSION.write(&mut io::stdout());
                exit(0);
            }
            _ => {
                return Err(arg.unexpected());
            }
        }
    }

    Ok(Options {
        force,
        secret,
        listen,
        config,
        log: LogOptions {
            level: log_level,
            logger: log_logger,
            format: log_format,
        },
    })
}

#[derive(Error, Debug)]
enum ExecutionError {
    #[error(transparent)]
    Io(#[from] io::Error),
    #[error(transparent)]
    ConfigurationLoading(#[from] profile::config::LoadError),
    #[error(transparent)]
    Runtime(#[from] radicle_node::runtime::Error),
    #[error(transparent)]
    Fingerprint(#[from] radicle_node::fingerprint::Error),
    #[error("failed to load secret key '{path}': not found")]
    SecretNotFound { path: PathBuf },
    #[error("failed to load secret '{path}': {source}")]
    SecretLoading {
        path: PathBuf,
        source: radicle::crypto::ssh::keystore::Error,
    },
    #[error(
        "failed to load secret key '{secret}': fingerprint of corresponding public key is different from '{fingerprint}'"
    )]
    FingerprintMismatch {
        secret: PathBuf,
        fingerprint: Fingerprint,
    },
}

/// Loads a credential from systemd, if available.
///
/// The credential ID should only be given as a suffix, as this function will
/// try different prefixes for backwards compatibility reasons.
///
/// The prefix `dev.radicle.node` is the preferred prefix, and should be used
/// for new credentials, while the prefix `xyz.radicle.node` is deprecated and
/// should be migrated away from. If it is used, a warning is logged.
#[cfg(all(feature = "systemd", target_os = "linux"))]
fn load_credential(id_suffix: &str) -> Option<String> {
    const INFIX_NODE: &str = ".radicle.node.";
    const PREFIX_DEV: &str = "dev";
    const PREFIX_XYZ: &str = "xyz";

    let id_dev = format!("{}{}{}", PREFIX_DEV, INFIX_NODE, id_suffix);

    let credential = match radicle_systemd::credential::path(&id_dev) {
        Ok(option) => option,
        Err(err) => {
            log::warn!(target: "node", "Failed to obtain path of the passphrase file via systemd credential with '{id_dev}': {err}");
            None
        }
    };

    let credential = credential.or_else(|| {
        let id_xyz = format!("{}{}{}", PREFIX_XYZ, INFIX_NODE, id_suffix);
        match radicle_systemd::credential::path(&id_xyz) {
            Ok(option) => {
                log::warn!(target: "node", "Obtain path of the passphrase file via systemd credential with '{id_xyz}'. Using this credential ID is discouraged. Please change the ID to '{id_dev}'.");
                option
            },
            Err(err) => {
                log::warn!(target: "node", "Failed to obtain path of the passphrase file via systemd credential with '{id_xyz}': {err}");
                None
            }
        }
    });

    credential.and_then(|ref path| {
        match std::fs::read_to_string(path) {
            Ok(passphrase) => Some(passphrase),
            Err(err) => {
                log::warn!(target: "node", "Failed to read passphrase from '{}': {err}", path.display());
                None
            }
        }
    })
}

fn execute(options: Options) -> Result<(), ExecutionError> {
    let home = profile::home()?;

    // Up to now, the active log level was `LOG_LEVEL_DEFAULT`.
    // The first thing we do after reading command line options is
    // to set the log level, as this influences logging during
    // configuration loading.
    if let Some(level) = options.log.level {
        log::set_max_level(level.to_level_filter());
    }

    let config = options.config.unwrap_or_else(|| home.config());
    let mut config = profile::Config::load(&config)?;

    if options.log.level.is_none() {
        log::set_max_level(log::Level::from(config.node.log).to_level_filter());
    } else {
        // It might seem counter-intuitive at first, as there
        // always is a log level in the configuration, but the command
        // line argument has precedence, and if it is present, the
        // log level has been already set above. Thus, we have nothing
        // to do in this case.
    }

    log::info!(target: "node", "Starting node..");
    log::info!(target: "node", "Version {} ({})", env!("RADICLE_VERSION"), env!("GIT_HEAD"));
    log::info!(target: "node", "Unlocking node keystore..");

    let passphrase = None;

    #[cfg(all(feature = "systemd", target_os = "linux"))]
    let passphrase = passphrase.or_else(|| load_credential("passphrase").map(|s| s.into()));

    let passphrase = passphrase.or_else(profile::env::passphrase);

    let secret_path = options.secret;

    #[cfg(all(feature = "systemd", target_os = "linux"))]
    let secret_path = secret_path.or_else(|| load_credential("secret").map(PathBuf::from));

    let secret_path = secret_path
        .or_else(|| config.node.secret.clone())
        .unwrap_or_else(|| home.keys().join("radicle"));

    let keystore = Keystore::from_secret_path(&secret_path);

    let secret_key = keystore
        .secret_key(passphrase.clone())
        .map_err(|err| ExecutionError::SecretLoading {
            path: secret_path.clone(),
            source: err,
        })?
        .ok_or_else(|| ExecutionError::SecretNotFound {
            path: secret_path.clone(),
        })?;

    if let Some(fp) = Fingerprint::read(&home)? {
        log::debug!(target: "node", "Verifying fingerprint..");
        if fp.verify(&secret_key) != FingerprintVerification::Match {
            return Err(ExecutionError::FingerprintMismatch {
                secret: keystore.secret_key_path().to_path_buf(),
                fingerprint: fp,
            });
        }
    } else {
        log::info!(target: "node", "Initializing fingerprint..");
        Fingerprint::init(&home, &secret_key)?;
    }

    let signer = Device::from(MemorySigner::from_secret(secret_key));
    log::info!(target: "node", "Node ID is {}", signer.public_key());

    // Add the preferred seeds as persistent peers so that we reconnect to them automatically.
    config.node.connect.extend(config.preferred_seeds);

    let listen = if !options.listen.is_empty() {
        options.listen.clone()
    } else {
        config.node.listen.clone()
    };

    if let Err(e) = radicle::io::set_file_limit::<usize>(config.node.limits.max_open_files.into()) {
        log::warn!(target: "node", "Unable to set process open file limit: {e}");
    }

    let signals = {
        let (notify, signals) = chan::bounded(1);
        signals::install(notify)?;
        signals
    };

    let socket = home.socket_from_env();
    if options.force {
        log::debug!(target: "node", "Removing existing control socket..");
        std::fs::remove_file(&socket).ok();
    }
    Runtime::init(home, config.node, socket, listen, signals, signer)?.run()?;

    Ok(())
}

fn initialize_logging(options: &LogOptions) -> Result<(), Box<dyn std::error::Error>> {
    let level = options.level.unwrap_or(log::Level::Info);

    let logger: Box<dyn log::Log> = {
        match options.logger {
            #[cfg(feature = "structured-logger")]
            Logger::Structured => {
                use structured_logger::{Builder, json};

                let writer = match options.format.unwrap_or(LogFormat::Json) {
                    LogFormat::Json => json::new_writer(io::stdout()),
                };

                Box::new(Builder::new().with_default_writer(writer).build())
            }
            #[cfg(all(feature = "systemd", target_os = "linux"))]
            Logger::Systemd => {
                use radicle_systemd::journal::*;
                use thiserror::Error;

                #[derive(Error, Debug)]
                enum JournalError {
                    #[error("journald not connected")]
                    NotConnected,
                    #[error("journald i/o: {0}")]
                    Io(#[from] io::Error),
                }

                if !connected() {
                    return Err(Box::new(JournalError::NotConnected));
                }

                const SYSLOG_IDENTIFIER: &str = "radicle-node";
                logger::<&str, &str, _>(SYSLOG_IDENTIFIER.to_string(), []).map_err(Box::new)?
            }
            Logger::Radicle => Box::new(radicle::logger::Logger::new(level)),
        }
    };

    log::set_boxed_logger(logger).expect("no other logger should have been set already");
    log::set_max_level(level.to_level_filter());

    Ok(())
}

fn panic_hook(info: &std::panic::PanicHookInfo) {
    #[cfg(feature = "backtrace")]
    let backtrace = format!("{:?}", backtrace::Backtrace::new());

    #[cfg(not(feature = "backtrace"))]
    let backtrace = " (no backtrace available)";

    let thread = std::thread::current();
    let thread = thread.name().unwrap_or("<unnamed>");

    let msg = info
        .payload()
        .downcast_ref::<&'static str>()
        .copied()
        .or(info.payload().downcast_ref::<String>().map(|s| s.as_str()))
        .unwrap_or("Box<Any>");

    match info.location() {
        Some(location) => {
            log::error!(
                target: "panic", "thread '{thread}' panicked at '{msg}': {}:{}{backtrace}",
                location.file(),
                location.line(),
            );
        }
        None => log::error!(
            target: "panic", "thread '{thread}' panicked at '{msg}'{backtrace}",
        ),
    }

    log::logger().flush();
}

fn main() {
    let options = parse_options().unwrap_or_else(|err| {
        // The lexopt errors read nicely with a comma.
        eprintln!("Failed to parse options, {err:#}");
        exit(2);
    });

    initialize_logging(&options.log).unwrap_or_else(|err| {
        eprintln!("Failed to initialize logging: {err:#}");
        exit(3);
    });

    std::panic::set_hook(Box::new(panic_hook));

    if let Err(err) = execute(options) {
        log::error!(target: "node", "{err:#}");
        exit(1);
    }
}