diff --git a/Cargo.lock b/Cargo.lock index 621ba58e..a908336b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -175,6 +175,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1640e5cc7fb47dbb8338fd471b105e7ed6c3cb2aeb00c2e067127ffd3764a05d" dependencies = [ "clap_builder", + "clap_derive", + "once_cell", ] [[package]] @@ -191,6 +193,18 @@ dependencies = [ "terminal_size", ] +[[package]] +name = "clap_derive" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8cd2b2a819ad6eec39e8f1d6b53001af1e5469f8c177579cdaeb313115b825f" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.23", +] + [[package]] name = "clap_lex" version = "0.5.0" @@ -409,6 +423,12 @@ version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eabb4a44450da02c90444cf74558da904edde8fb4e9035a9a6a4e15445af0bd7" +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + [[package]] name = "hermit-abi" version = "0.3.2" diff --git a/Cargo.toml b/Cargo.toml index 0f3e51d5..d5cee166 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,7 @@ crate-type = ["rlib", "staticlib"] [dependencies] arrayvec = "0.7" base64 = "0.13" -clap = { version = "4.3", features = ["cargo", "string", "wrap_help"] } +clap = { version = "4.3", features = ["cargo", "string", "wrap_help", "derive"] } httparse = "1.7" ipnet = "2" jsonwebtoken = "8" diff --git a/src/bin/pushpin.rs b/src/bin/pushpin.rs new file mode 100644 index 00000000..adacb902 --- /dev/null +++ b/src/bin/pushpin.rs @@ -0,0 +1,484 @@ +/* + * Copyright (C) 2021-2023 Fanout, Inc. + * + * This file is part of Pushpin. + * + * $FANOUT_BEGIN_LICENSE:APACHE2$ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * $FANOUT_END_LICENSE$ + */ + +use clap::{ArgAction, Parser}; +use std::collections::HashMap; +use std::error::Error; +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::path::{Path, PathBuf}; +use std::process; +use std::string::String; + +#[derive(Parser, Clone)] +#[command( + name = "Pushpin", + version, + about = "Reverse proxy for realtime web services." +)] +struct CliArgs { + #[arg(long, value_name = "file", help = "Config file.")] + pub config: Option, + + #[arg(long, value_name = "file", help = "File to log to.")] + pub logfile: Option, + + #[arg( + long, + value_name = "x", + default_value = "2", + help = "Log level (default: 2)." + )] + pub loglevel: Option, + + #[arg(long, action=ArgAction::SetTrue, help = "Verbose output. Same as --loglevel=3.")] + pub verbose: bool, + + #[arg( + long, + value_name = "[addr:]port", + help = "Run a single HTTP server instance." + )] + pub port: Option, + + #[arg( + long, + value_name = "x", + help = "Set instance ID (needed to run multiple instances)." + )] + pub id: Option, + + #[arg(long, value_name = "line", help = "Add route (overrides routes file).")] + pub route: Option>, +} + +#[derive(Eq, PartialEq, Debug)] +struct ArgsData { + id: Option, + config_file: PathBuf, + log_file: PathBuf, + route_lines: Vec, + log_levels: HashMap, + socket: Option, +} + +impl ArgsData { + fn new(cli_args: CliArgs) -> Result> { + Ok(Self { + id: Self::get_id(cli_args.id)?, + config_file: Self::get_config_file(cli_args.config.as_deref()), + log_file: Self::get_log_file(cli_args.logfile.as_deref()), + route_lines: Self::get_route_lines(cli_args.route.as_deref()), + log_levels: Self::get_log_levels(cli_args.loglevel.as_deref(), cli_args.verbose)?, + socket: Self::get_socket(cli_args.port.as_deref())?, + }) + } + + fn get_id(id: Option) -> Result, Box> { + let id = match id { + Some(x) => x, + _ => return Ok(None), + }; + + if id >= 0 { + Ok(Some(id as u32)) + } else { + Err("id must be greater than or equal to 0".into()) + } + } + + fn get_config_file(config_file: Option<&Path>) -> PathBuf { + match config_file { + Some(x) => x.to_path_buf(), + _ => PathBuf::new(), + } + } + + fn get_log_file(log_file: Option<&Path>) -> PathBuf { + match log_file { + Some(x) => x.to_path_buf(), + _ => PathBuf::new(), + } + } + + fn get_route_lines(route_lines: Option<&[String]>) -> Vec { + match route_lines { + Some(x) => x.to_vec(), + _ => vec![], + } + } + + fn get_log_levels( + levels: Option<&str>, + verbose: bool, + ) -> Result, Box> { + if verbose { + return Ok(HashMap::from([(String::new(), 3)])); + } + + let parts = match levels { + Some(x) => x.split(','), + None => { + // default log level imposed + return Ok(HashMap::from([(String::new(), 2)])); + } + }; + + let mut levels: HashMap = HashMap::new(); + for part in parts { + if part.is_empty() { + return Err("log level component cannot be empty".into()); + } + + match part.find(':') { + None => { + let level: u8 = match part.trim().parse() { + Ok(x) => x, + Err(_) => return Err("log level must be greater than or equal to 0".into()), + }; + + levels.insert(String::new(), level); + } + Some(indx) => { + if indx == 0 { + return Err("log level component name cannot be empty".into()); + } + + let name = &part[..indx]; + let level: u8 = match part[indx + 1..].trim().parse() { + Ok(x) => x, + Err(_) => { + return Err(format!( + "log level for service {} must be greater than or equal to 0", + name + ) + .into()) + } + }; + + levels.insert(String::from(name), level); + } + } + } + + Ok(levels) + } + + fn get_socket(port: Option<&str>) -> Result, Box> { + let socket = match port { + Some(x) => x, + None => return Ok(None), + }; + let (socket, port) = match socket.find(':') { + Some(x) => (Some(socket), &socket[(x + 1)..]), + None => (None, socket), + }; + let port = match port.parse::() { + Ok(x) => x, + Err(_) => return Err("port must be greater than or equal to 1".into()), + }; + if socket.is_none() { + return Ok(Some(SocketAddr::new( + IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)), + port, + ))); + } + let socket = match socket { + Some(x) => x, + None => return Err("error parsing port.".into()), + }; + match socket.parse::() { + Ok(x) => Ok(Some(x)), + Err(e) => Err(format!("error parsing port. {:?}", e).into()), + } + } +} + +fn process_args_and_run(args: CliArgs) -> Result<(), Box> { + let args_data = ArgsData::new(args)?; + println!("Processed: {:?}", args_data); + //To be implemented in the next PR + Ok(()) +} + +fn main() { + let args = CliArgs::parse(); + + if let Err(e) = process_args_and_run(args) { + eprintln!("Error: {}", e); + process::exit(1); + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::error::Error; + use std::net::SocketAddr; + use std::path::PathBuf; + + use crate::{ArgsData, CliArgs}; + + struct TestArgs { + name: &'static str, + input: CliArgs, + output: Result>, + } + + #[test] + fn it_works() { + let test_args: Vec = vec![ + TestArgs { + name: "no input", + input: CliArgs { + id: None, + config: None, + logfile: None, + loglevel: None, + verbose: false, + route: None, + port: None, + }, + output: Ok(ArgsData { + id: None, + config_file: PathBuf::new(), + log_file: PathBuf::new(), + route_lines: vec![], + log_levels: HashMap::from([(String::new(), 2)]), + socket: None, + }), + }, + TestArgs { + name: "basic input", + input: CliArgs { + id: Some(123), + config: Some(PathBuf::from("/cfg/path")), + logfile: Some(PathBuf::from("/log/path")), + loglevel: Some(String::from("2")), + verbose: false, + route: Some(vec![String::from("* test")]), + port: Some(String::from("1234")), + }, + output: Ok(ArgsData { + id: Some(123), + config_file: PathBuf::from("/cfg/path"), + log_file: PathBuf::from("/log/path"), + route_lines: vec![String::from("* test")], + log_levels: HashMap::from([(String::new(), 2)]), + socket: Some("0.0.0.0:1234".parse::().unwrap()), + }), + }, + TestArgs { + name: "verbose", + input: CliArgs { + id: Some(123), + config: Some(PathBuf::from("/cfg/path")), + logfile: Some(PathBuf::from("/log/path")), + loglevel: Some(String::from("2")), + verbose: true, + route: Some(vec![String::from("* test")]), + port: Some(String::from("1234")), + }, + output: Ok(ArgsData { + id: Some(123), + config_file: PathBuf::from("/cfg/path"), + log_file: PathBuf::from("/log/path"), + route_lines: vec![String::from("* test")], + log_levels: HashMap::from([(String::new(), 3)]), + socket: Some("0.0.0.0:1234".parse::().unwrap()), + }), + }, + TestArgs { + name: "log level subservice", + input: CliArgs { + id: Some(123), + config: Some(PathBuf::from("/cfg/path")), + logfile: Some(PathBuf::from("/log/path")), + loglevel: Some(String::from("2,condure:3")), + verbose: false, + route: Some(vec![String::from("* test")]), + port: Some(String::from("1234")), + }, + output: Ok(ArgsData { + id: Some(123), + config_file: PathBuf::from("/cfg/path"), + log_file: PathBuf::from("/log/path"), + route_lines: vec![String::from("* test")], + log_levels: HashMap::from([ + (String::new(), 2u8), + (String::from("condure"), 3u8), + ]), + socket: Some("0.0.0.0:1234".parse::().unwrap()), + }), + }, + TestArgs { + name: "port socket", + input: CliArgs { + id: Some(123), + config: Some(PathBuf::from("/cfg/path")), + logfile: Some(PathBuf::from("/log/path")), + loglevel: Some(String::from("2")), + verbose: false, + route: Some(vec![String::from("* test")]), + port: Some(String::from("127.0.0.1:1234")), + }, + output: Ok(ArgsData { + id: Some(123), + config_file: PathBuf::from("/cfg/path"), + log_file: PathBuf::from("/log/path"), + route_lines: vec![String::from("* test")], + log_levels: HashMap::from([(String::new(), 2u8)]), + socket: Some("127.0.0.1:1234".parse::().unwrap()), + }), + }, + ]; + + for test_arg in test_args.iter() { + let _output = &test_arg.output; + assert!( + matches!(ArgsData::new(test_arg.input.clone()), _output), + "{}", + test_arg.name + ); + } + } + + #[test] + fn it_fails() { + let test_args: Vec = vec![ + TestArgs { + name: "neg id", + input: CliArgs { + id: Some(-123), + config: None, + logfile: None, + loglevel: None, + verbose: false, + route: None, + port: None, + }, + output: Err("id must be greater than or equal to 0".into()), + }, + TestArgs { + name: "missing log level", + input: CliArgs { + id: None, + config: None, + logfile: None, + loglevel: Some(String::from("2,")), + verbose: false, + route: None, + port: None, + }, + output: Err("log level component cannot be empty".into()), + }, + TestArgs { + name: "neg log level", + input: CliArgs { + id: None, + config: None, + logfile: None, + loglevel: Some(String::from("-2")), + verbose: false, + route: None, + port: None, + }, + output: Err("log level must be greater than or equal to 0".into()), + }, + TestArgs { + name: "empty log name", + input: CliArgs { + id: None, + config: None, + logfile: None, + loglevel: Some(String::from(":2,")), + verbose: false, + route: None, + port: None, + }, + output: Err("log level component name cannot be empty".into()), + }, + TestArgs { + name: "neg log level for subservice", + input: CliArgs { + id: None, + config: None, + logfile: None, + loglevel: Some(String::from("condure:-1")), + verbose: false, + route: None, + port: None, + }, + output: Err( + "log level for service condure must be greater than or equal to 0".into(), + ), + }, + TestArgs { + name: "neg port", + input: CliArgs { + id: None, + config: None, + logfile: None, + loglevel: None, + verbose: false, + route: None, + port: Some(String::from("-1234")), + }, + output: Err("port must be greater than or equal to 1".into()), + }, + TestArgs { + name: "empty host", + input: CliArgs { + id: None, + config: None, + logfile: None, + loglevel: None, + verbose: false, + route: None, + port: Some(String::from(":1234")), + }, + output: Err("error parsing port. AddrParseError(Socket)".into()), + }, + TestArgs { + name: "empty port", + input: CliArgs { + id: None, + config: None, + logfile: None, + loglevel: None, + verbose: false, + route: None, + port: Some(String::from("test:")), + }, + output: Err("port must be greater than or equal to 1".into()), + }, + ]; + + for test_arg in test_args.iter() { + let _output = &test_arg.output; + assert!( + matches!(ArgsData::new(test_arg.input.clone()), _output), + "{}", + test_arg.name + ); + } + } +}