use std::io::{self, Read}; use std::net::Ipv4Addr; use std::path::Path; use std::sync::Arc; use std::{ error::Error, net::{IpAddr, SocketAddr}, path::PathBuf, }; use std::{fmt::Display, str::FromStr}; use clap::{Args, Parser, Subcommand}; use mycelium::message::TopicConfig; use serde::{Deserialize, Deserializer}; use tokio::fs::File; use tokio::io::{AsyncReadExt, AsyncWriteExt}; #[cfg(target_family = "unix")] use tokio::signal::{self, unix::SignalKind}; use tokio::sync::Mutex; use tracing::{debug, error, info, warn}; use crypto::PublicKey; use mycelium::endpoint::Endpoint; use mycelium::{crypto, Node}; use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::util::SubscriberInitExt; use tracing_subscriber::EnvFilter; /// The default port on the underlay to listen on for incoming TCP connections. const DEFAULT_TCP_LISTEN_PORT: u16 = 9651; /// The default port on the underlay to listen on for incoming Quic connections. const DEFAULT_QUIC_LISTEN_PORT: u16 = 9651; /// The default port to use for IPv6 link local peer discovery (UDP). const DEFAULT_PEER_DISCOVERY_PORT: u16 = 9650; /// The default listening address for the HTTP API. const DEFAULT_HTTP_API_SERVER_ADDRESS: SocketAddr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 8989); /// The default listening address for the JSON-RPC API. const DEFAULT_JSONRPC_API_SERVER_ADDRESS: SocketAddr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 8990); const DEFAULT_KEY_FILE: &str = "priv_key.bin"; /// Default name of tun interface #[cfg(not(target_os = "macos"))] const TUN_NAME: &str = "mycelium"; /// Default name of tun interface #[cfg(target_os = "macos")] const TUN_NAME: &str = "utun0"; /// The logging formats that can be selected. #[derive(Clone, PartialEq, Eq)] enum LoggingFormat { Compact, Logfmt, /// Same as Logfmt but with color statically disabled Plain, } impl Display for LoggingFormat { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!( f, "{}", match self { LoggingFormat::Compact => "compact", LoggingFormat::Logfmt => "logfmt", LoggingFormat::Plain => "plain", } ) } } impl FromStr for LoggingFormat { type Err = &'static str; fn from_str(s: &str) -> Result { Ok(match s { "compact" => LoggingFormat::Compact, "logfmt" => LoggingFormat::Logfmt, "plain" => LoggingFormat::Plain, _ => return Err("invalid logging format"), }) } } #[derive(Parser)] #[command(version)] struct Cli { /// Path to the private key file. This will be created if it does not exist. Default /// [priv_key.bin]. #[arg(short = 'k', long = "key-file", global = true)] key_file: Option, // Configuration file #[arg(short = 'c', long = "config-file", global = true)] config_file: Option, /// Enable debug logging. Does nothing if `--silent` is set. #[arg(short = 'd', long = "debug", default_value_t = false)] debug: bool, /// Disable all logs except error logs. #[arg(long = "silent", default_value_t = false)] silent: bool, /// The logging format to use. `logfmt` and `compact` is supported. #[arg(long = "log-format", default_value_t = LoggingFormat::Compact)] logging_format: LoggingFormat, #[clap(flatten)] node_args: NodeArguments, #[command(subcommand)] command: Option, } #[derive(Debug, Subcommand)] pub enum Command { /// Inspect a public key provided in hex format, or export the local public key if no key is /// given. Inspect { /// Output in json format. #[arg(long = "json")] json: bool, /// The key to inspect. key: Option, }, /// Actions on the message subsystem Message { #[command(subcommand)] command: MessageCommand, }, /// Actions related to peers (list, remove, add) Peers { #[command(subcommand)] command: PeersCommand, }, /// Actions related to routes (selected, fallback) Routes { #[command(subcommand)] command: RoutesCommand, }, } #[derive(Debug, Subcommand)] pub enum MessageCommand { Send { /// Wait for a reply from the receiver. #[arg(short = 'w', long = "wait", default_value_t = false)] wait: bool, /// An optional timeout to wait for. This does nothing if the `--wait` flag is not set. If /// `--wait` is set and this flag isn't, wait forever for a reply. #[arg(long = "timeout")] timeout: Option, /// Optional topic of the message. Receivers can filter on this to only receive messages /// for a chosen topic. #[arg(short = 't', long = "topic")] topic: Option, /// Optional file to use as message body. #[arg(long = "msg-path")] msg_path: Option, /// Optional message ID to reply to. #[arg(long = "reply-to")] reply_to: Option, /// Destination of the message, either a hex encoded public key, or an IPv6 address in the /// 400::/7 range. destination: String, /// The message to send. This is required if `--msg_path` is not set message: Option, }, Receive { /// An optional timeout to wait for a message. If this is not set, wait forever. #[arg(long = "timeout")] timeout: Option, /// Optional topic of the message. Only messages with this topic will be received by this /// command. #[arg(short = 't', long = "topic")] topic: Option, /// Optional file in which the message body will be saved. #[arg(long = "msg-path")] msg_path: Option, /// Don't print the metadata #[arg(long = "raw")] raw: bool, }, } #[derive(Debug, Subcommand)] pub enum PeersCommand { /// List the connected peers List { /// Print the peers list in JSON format #[arg(long = "json", default_value_t = false)] json: bool, }, /// Add peer(s) Add { peers: Vec }, /// Remove peer(s) Remove { peers: Vec }, } #[derive(Debug, Subcommand)] pub enum RoutesCommand { /// Print all selected routes Selected { /// Print selected routes in JSON format #[arg(long = "json", default_value_t = false)] json: bool, }, /// Print all fallback routes Fallback { /// Print fallback routes in JSON format #[arg(long = "json", default_value_t = false)] json: bool, }, /// Print the currently queried subnets Queried { /// Print queried subnets in JSON format #[arg(long = "json", default_value_t = false)] json: bool, }, /// Print all subnets which are explicitly marked as not having a route NoRoute { /// Print subnets in JSON format #[arg(long = "json", default_value_t = false)] json: bool, }, } #[derive(Debug, Args)] pub struct NodeArguments { /// Peers to connect to. #[arg(long = "peers", num_args = 1..)] static_peers: Vec, /// Port to listen on for tcp connections. #[arg(short = 't', long = "tcp-listen-port", default_value_t = DEFAULT_TCP_LISTEN_PORT)] tcp_listen_port: u16, /// Disable quic protocol for connecting to peers #[arg(long = "disable-quic", default_value_t = false)] disable_quic: bool, /// Port to listen on for quic connections. #[arg(short = 'q', long = "quic-listen-port", default_value_t = DEFAULT_QUIC_LISTEN_PORT)] quic_listen_port: u16, /// Port to use for link local peer discovery. This uses the UDP protocol. #[arg(long = "peer-discovery-port", default_value_t = DEFAULT_PEER_DISCOVERY_PORT)] peer_discovery_port: u16, /// Disable peer discovery. /// /// If this flag is passed, the automatic link local peer discovery will not be enabled, and /// peers must be configured manually. If this is disabled on all local peers, communication /// between them will go over configured external peers. #[arg(long = "disable-peer-discovery", default_value_t = false)] disable_peer_discovery: bool, /// Address of the HTTP API server. #[arg(long = "api-addr", default_value_t = DEFAULT_HTTP_API_SERVER_ADDRESS)] api_addr: SocketAddr, /// Address of the JSON-RPC API server. #[arg(long = "jsonrpc-addr", default_value_t = DEFAULT_JSONRPC_API_SERVER_ADDRESS)] jsonrpc_addr: SocketAddr, /// Run without creating a TUN interface. /// /// The system will participate in the network as usual, but won't be able to send out L3 /// packets. Inbound L3 traffic will be silently discarded. The message subsystem will still /// work however. #[arg(long = "no-tun", default_value_t = false)] no_tun: bool, /// Name to use for the TUN interface, if one is created. /// /// Setting this only matters if a TUN interface is actually created, i.e. if the `--no-tun` /// flag is **not** set. The name set here must be valid for the current platform, e.g. on OSX, /// the name must start with `utun` and be followed by digits. #[arg(long = "tun-name")] tun_name: Option, /// Enable a private network, with this name. /// /// If this flag is set, the system will run in "private network mode", and use Tls connections /// instead of plain Tcp connections. The name provided here is used as the network name, other /// nodes must use the same name or the connection will be rejected. Note that the name is /// public, and is communicated when connecting to a remote. Do not put confidential data here. #[arg(long = "network-name", requires = "network_key_file")] network_name: Option, /// The path to the file with the key to use for the private network. /// /// The key is expected to be exactly 32 bytes. The key must be shared between all nodes /// participating in the network, and is secret. If the key leaks, anyone can then join the /// network. #[arg(long = "network-key-file", requires = "network_name")] network_key_file: Option, /// The address on which to expose prometheus metrics, if desired. /// /// Setting this flag will attempt to start an HTTP server on the provided address, to serve /// prometheus metrics on the /metrics endpoint. If this flag is not set, metrics are also not /// collected. #[arg(long = "metrics-api-address")] metrics_api_address: Option, /// The firewall mark to set on the mycelium sockets. /// /// This allows to identify packets that contain encapsulated mycelium packets so that /// different routing policies can be applied to them. /// This option only has an effect on Linux. #[arg(long = "firewall-mark")] firewall_mark: Option, /// The amount of worker tasks to spawn to handle updates. /// /// By default, updates are processed on a single task only. This is sufficient for most use /// cases. In case you notice that the node can't keep up with the incoming updates (typically /// because you are running a public node with a lot of connections), this value can be /// increased to process updates in parallel. #[arg(long = "update-workers", default_value_t = 1)] update_workers: usize, /// The topic configuration. /// /// A .toml file containing topic configuration. This is a default action in case the topic is /// not listed, and an explicit whitelist for allowed subnets/ips which are otherwise allowed /// to use a topic. #[arg(long = "topic-config")] topic_config: Option, /// The cache directory for the mycelium CDN module /// /// This directory will be used to cache reconstructed content blocks which were loaded through /// the CDN functionallity for faster access next time. #[arg(long = "cdn-cache")] cdn_cache: Option, } #[derive(Debug, Deserialize)] pub struct MergedNodeConfig { peers: Vec, tcp_listen_port: u16, disable_quic: bool, quic_listen_port: u16, peer_discovery_port: u16, disable_peer_discovery: bool, api_addr: SocketAddr, jsonrpc_addr: SocketAddr, no_tun: bool, tun_name: String, metrics_api_address: Option, network_key_file: Option, network_name: Option, firewall_mark: Option, update_workers: usize, topic_config: Option, cdn_cache: Option, } #[derive(Debug, Deserialize, Default)] struct MyceliumConfig { #[serde(deserialize_with = "deserialize_optional_endpoint_str_from_toml")] peers: Option>, tcp_listen_port: Option, disable_quic: Option, quic_listen_port: Option, no_tun: Option, tun_name: Option, disable_peer_discovery: Option, peer_discovery_port: Option, api_addr: Option, jsonrpc_addr: Option, metrics_api_address: Option, network_name: Option, network_key_file: Option, firewall_mark: Option, update_workers: Option, topic_config: Option, cdn_cache: Option, } #[tokio::main] async fn main() -> Result<(), Box> { let cli = Cli::parse(); // Init default configuration let mut mycelium_config = MyceliumConfig::default(); // Load configuration file if let Some(config_file_path) = &cli.config_file { if Path::new(config_file_path).exists() { let config = config::Config::builder() .add_source(config::File::new( config_file_path.to_str().unwrap(), config::FileFormat::Toml, )) .build()?; mycelium_config = config.try_deserialize()?; } else { let error_msg = format!("Config file {config_file_path:?} not found"); return Err(io::Error::new(io::ErrorKind::NotFound, error_msg).into()); } } else if let Some(mut conf) = dirs::config_dir() { // Windows: %APPDATA%/ThreeFold Tech/Mycelium/mycelium.conf #[cfg(target_os = "windows")] { conf = conf .join("ThreeFold Tech") .join("Mycelium") .join("mycelium.toml") }; // Linux: $HOME/.config/mycelium/mycelium.conf #[allow(clippy::unnecessary_operation)] #[cfg(target_os = "linux")] { conf = conf.join("mycelium").join("mycelium.toml") }; // MacOS: $HOME/Library/Application Support/ThreeFold Tech/Mycelium/mycelium.conf #[cfg(target_os = "macos")] { conf = conf .join("ThreeFold Tech") .join("Mycelium") .join("mycelium.toml") }; if conf.exists() { info!( conf_dir = conf.to_str().unwrap(), "Mycelium is starting with configuration file", ); let config = config::Config::builder() .add_source(config::File::new( conf.to_str().unwrap(), config::FileFormat::Toml, )) .build()?; mycelium_config = config.try_deserialize()?; } } let level = if cli.silent { tracing::Level::ERROR } else if cli.debug { tracing::Level::DEBUG } else { tracing::Level::INFO }; tracing_subscriber::registry() .with( EnvFilter::builder() .with_default_directive(level.into()) .from_env() .expect("invalid RUST_LOG"), ) .with( (cli.logging_format == LoggingFormat::Compact) .then(|| tracing_subscriber::fmt::Layer::new().compact()), ) .with((cli.logging_format == LoggingFormat::Logfmt).then(tracing_logfmt::layer)) .with((cli.logging_format == LoggingFormat::Plain).then(|| { tracing_logfmt::builder() // Explicitly force color off .with_ansi_color(false) .layer() })) .init(); let key_path = cli .key_file .unwrap_or_else(|| PathBuf::from(DEFAULT_KEY_FILE)); match cli.command { None => { let merged_config = merge_config(cli.node_args, mycelium_config); let topic_config = merged_config.topic_config.as_ref().and_then(|path| { let mut content = String::new(); let mut file = std::fs::File::open(path).ok()?; file.read_to_string(&mut content).ok()?; toml::from_str::(&content).ok() }); if topic_config.is_some() { info!(path = ?merged_config.topic_config, "Loaded topic cofig"); } let private_network_config = match (merged_config.network_name, merged_config.network_key_file) { (Some(network_name), Some(network_key_file)) => { let net_key = load_key_file(&network_key_file).await?; Some((network_name, net_key)) } _ => None, }; let node_keys = get_node_keys(&key_path).await?; let node_secret_key = if let Some((node_secret_key, _)) = node_keys { node_secret_key } else { warn!("Node key file {key_path:?} not found, generating new keys"); let secret_key = crypto::SecretKey::new(); save_key_file(&secret_key, &key_path).await?; secret_key }; let _api = if let Some(metrics_api_addr) = merged_config.metrics_api_address { let metrics = mycelium_metrics::PrometheusExporter::new(); let config = mycelium::Config { node_key: node_secret_key, peers: merged_config.peers, no_tun: merged_config.no_tun, tcp_listen_port: merged_config.tcp_listen_port, quic_listen_port: if merged_config.disable_quic { None } else { Some(merged_config.quic_listen_port) }, peer_discovery_port: if merged_config.disable_peer_discovery { None } else { Some(merged_config.peer_discovery_port) }, tun_name: merged_config.tun_name, private_network_config, metrics: metrics.clone(), firewall_mark: merged_config.firewall_mark, update_workers: merged_config.update_workers, topic_config, cdn_cache: merged_config.cdn_cache, }; metrics.spawn(metrics_api_addr); let node = Arc::new(Mutex::new(Node::new(config).await?)); let http_api = mycelium_api::Http::spawn(node.clone(), merged_config.api_addr); // Initialize the JSON-RPC server let rpc_api = mycelium_api::rpc::JsonRpc::spawn(node, merged_config.jsonrpc_addr).await; (http_api, rpc_api) } else { let config = mycelium::Config { node_key: node_secret_key, peers: merged_config.peers, no_tun: merged_config.no_tun, tcp_listen_port: merged_config.tcp_listen_port, quic_listen_port: if merged_config.disable_quic { None } else { Some(merged_config.quic_listen_port) }, peer_discovery_port: if merged_config.disable_peer_discovery { None } else { Some(merged_config.peer_discovery_port) }, tun_name: merged_config.tun_name, private_network_config, metrics: mycelium_metrics::NoMetrics, firewall_mark: merged_config.firewall_mark, update_workers: merged_config.update_workers, topic_config, cdn_cache: merged_config.cdn_cache, }; let node = Arc::new(Mutex::new(Node::new(config).await?)); let http_api = mycelium_api::Http::spawn(node.clone(), merged_config.api_addr); // Initialize the JSON-RPC server let rpc_api = mycelium_api::rpc::JsonRpc::spawn(node, merged_config.jsonrpc_addr).await; (http_api, rpc_api) }; // TODO: put in dedicated file so we can only rely on certain signals on unix platforms #[cfg(target_family = "unix")] { let mut sigint = signal::unix::signal(SignalKind::interrupt()) .expect("Can install SIGINT handler"); let mut sigterm = signal::unix::signal(SignalKind::terminate()) .expect("Can install SIGTERM handler"); tokio::select! { _ = sigint.recv() => { } _ = sigterm.recv() => { } } } #[cfg(not(target_family = "unix"))] { if let Err(e) = tokio::signal::ctrl_c().await { error!("Failed to wait for SIGINT: {e}"); } } } Some(cmd) => match cmd { Command::Inspect { json, key } => { let node_keys = get_node_keys(&key_path).await?; let key = if let Some(key) = key { PublicKey::try_from(key.as_str())? } else if let Some((_, node_pub_key)) = node_keys { node_pub_key } else { error!("No key to inspect provided and no key found at {key_path:?}"); return Err(io::Error::new( io::ErrorKind::NotFound, "no key to inspect and key file not found", ) .into()); }; mycelium_cli::inspect(key, json)?; return Ok(()); } Command::Message { command } => match command { MessageCommand::Send { wait, timeout, topic, msg_path, reply_to, destination, message, } => { return mycelium_cli::send_msg( destination, message, wait, timeout, reply_to, topic, msg_path, cli.node_args.api_addr, ) .await } MessageCommand::Receive { timeout, topic, msg_path, raw, } => { return mycelium_cli::recv_msg( timeout, topic, msg_path, raw, cli.node_args.api_addr, ) .await } }, Command::Peers { command } => match command { PeersCommand::List { json } => { return mycelium_cli::list_peers(cli.node_args.api_addr, json).await; } PeersCommand::Add { peers } => { return mycelium_cli::add_peers(cli.node_args.api_addr, peers).await; } PeersCommand::Remove { peers } => { return mycelium_cli::remove_peers(cli.node_args.api_addr, peers).await; } }, Command::Routes { command } => match command { RoutesCommand::Selected { json } => { return mycelium_cli::list_selected_routes(cli.node_args.api_addr, json).await; } RoutesCommand::Fallback { json } => { return mycelium_cli::list_fallback_routes(cli.node_args.api_addr, json).await; } RoutesCommand::Queried { json } => { return mycelium_cli::list_queried_subnets(cli.node_args.api_addr, json).await; } RoutesCommand::NoRoute { json } => { return mycelium_cli::list_no_route_entries(cli.node_args.api_addr, json).await; } }, }, } Ok(()) } async fn get_node_keys( key_path: &PathBuf, ) -> Result, io::Error> { if key_path.exists() { let sk = load_key_file(key_path).await?; let pk = crypto::PublicKey::from(&sk); debug!("Loaded key file at {key_path:?}"); Ok(Some((sk, pk))) } else { Ok(None) } } async fn load_key_file(path: &Path) -> Result where T: From<[u8; 32]>, { let mut file = File::open(path).await?; let mut secret_bytes = [0u8; 32]; file.read_exact(&mut secret_bytes).await?; Ok(T::from(secret_bytes)) } async fn save_key_file(key: &crypto::SecretKey, path: &Path) -> io::Result<()> { #[cfg(target_family = "unix")] { use tokio::fs::OpenOptions; let mut file = OpenOptions::new() .create(true) .truncate(true) .write(true) .mode(0o600) // rw by the owner, not readable by group or others .open(path) .await?; file.write_all(key.as_bytes()).await?; } #[cfg(not(target_family = "unix"))] { let mut file = File::create(path).await?; file.write_all(key.as_bytes()).await?; } Ok(()) } fn merge_config(cli_args: NodeArguments, file_config: MyceliumConfig) -> MergedNodeConfig { MergedNodeConfig { peers: if !cli_args.static_peers.is_empty() { cli_args.static_peers } else { file_config.peers.unwrap_or_default() }, tcp_listen_port: if cli_args.tcp_listen_port != DEFAULT_TCP_LISTEN_PORT { cli_args.tcp_listen_port } else { file_config .tcp_listen_port .unwrap_or(DEFAULT_TCP_LISTEN_PORT) }, disable_quic: cli_args.disable_quic || file_config.disable_quic.unwrap_or(false), quic_listen_port: if cli_args.quic_listen_port != DEFAULT_QUIC_LISTEN_PORT { cli_args.quic_listen_port } else { file_config .quic_listen_port .unwrap_or(DEFAULT_QUIC_LISTEN_PORT) }, peer_discovery_port: if cli_args.peer_discovery_port != DEFAULT_PEER_DISCOVERY_PORT { cli_args.peer_discovery_port } else { file_config .peer_discovery_port .unwrap_or(DEFAULT_PEER_DISCOVERY_PORT) }, disable_peer_discovery: cli_args.disable_peer_discovery || file_config.disable_peer_discovery.unwrap_or(false), api_addr: if cli_args.api_addr != DEFAULT_HTTP_API_SERVER_ADDRESS { cli_args.api_addr } else { file_config .api_addr .unwrap_or(DEFAULT_HTTP_API_SERVER_ADDRESS) }, jsonrpc_addr: if cli_args.jsonrpc_addr != DEFAULT_JSONRPC_API_SERVER_ADDRESS { cli_args.jsonrpc_addr } else { file_config .jsonrpc_addr .unwrap_or(DEFAULT_JSONRPC_API_SERVER_ADDRESS) }, no_tun: cli_args.no_tun || file_config.no_tun.unwrap_or(false), tun_name: if let Some(tun_name_cli) = cli_args.tun_name { tun_name_cli } else if let Some(tun_name_config) = file_config.tun_name { tun_name_config } else { TUN_NAME.to_string() }, metrics_api_address: cli_args .metrics_api_address .or(file_config.metrics_api_address), network_name: cli_args.network_name.or(file_config.network_name), network_key_file: cli_args.network_key_file.or(file_config.network_key_file), firewall_mark: cli_args.firewall_mark.or(file_config.firewall_mark), update_workers: if cli_args.update_workers != 1 { cli_args.update_workers } else { file_config.update_workers.unwrap_or(1) }, topic_config: cli_args.topic_config.or(file_config.topic_config), cdn_cache: cli_args.cdn_cache.or(file_config.cdn_cache), } } /// Deserialize an optional list of endpoints from TOML format. The endpoints can be provided /// either as a list `[...]`, or in case there is only 1 endpoint, it can also be provided as a /// single string element. If no value is provided, it returns None. fn deserialize_optional_endpoint_str_from_toml<'de, D>( deserializer: D, ) -> Result>, D::Error> where D: Deserializer<'de>, { #[derive(Deserialize)] #[serde(untagged)] enum StringOrVec { String(String), Vec(Vec), } Ok(match Option::::deserialize(deserializer)? { Some(StringOrVec::Vec(v)) => Some( v.into_iter() .map(|s| { ::from_str(&s).map_err(serde::de::Error::custom) }) .collect::, _>>()?, ), Some(StringOrVec::String(s)) => Some(vec![ ::from_str(&s).map_err(serde::de::Error::custom)? ]), None => None, }) }