diff --git a/Cargo.toml b/Cargo.toml index b8d4f64..942e8ec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -67,10 +67,19 @@ default = ["mysql-admutils-compatibility"] mysql-admutils-compatibility = [] suid-sgid-mode = [] +[lib] +name = "muscl_lib" +path = "src/lib.rs" + [[bin]] name = "muscl" bench = false -path = "src/main.rs" +path = "src/entrypoints/muscl.rs" + +[[bin]] +name = "muscl-server" +bench = false +path = "src/entrypoints/muscl_server.rs" [profile.release-lto] inherits = "release" @@ -120,6 +129,11 @@ assets = [ "usr/bin/", "755", ], + [ + "target/release/muscl-server", + "usr/bin/", + "755", + ], [ "target/release/mysql-useradm", "usr/bin/", diff --git a/assets/systemd/muscl.service b/assets/systemd/muscl.service index c3bc7cd..5d5585d 100644 --- a/assets/systemd/muscl.service +++ b/assets/systemd/muscl.service @@ -5,7 +5,7 @@ After=mysql.service mariadb.service [Service] Type=notify -ExecStart=/usr/bin/muscl server --systemd --disable-landlock socket-activate +ExecStart=/usr/bin/muscl-server --systemd --disable-landlock socket-activate ExecReload=/usr/bin/kill -HUP $MAINPID WatchdogSec=15 diff --git a/nix/default.nix b/nix/default.nix index c2f36bd..734fa5b 100644 --- a/nix/default.nix +++ b/nix/default.nix @@ -84,7 +84,7 @@ buildFunction ({ install -Dm444 assets/systemd/muscl.socket -t "$out/lib/systemd/system" install -Dm644 assets/systemd/muscl.service -t "$out/lib/systemd/system" substituteInPlace "$out/lib/systemd/system/muscl.service" \ - --replace-fail '/usr/bin/muscl' "$out/bin/muscl" + --replace-fail '/usr/bin/muscl-server' "$out/bin/muscl-server" ''; meta = with lib; { diff --git a/nix/module.nix b/nix/module.nix index 723e978..748b461 100644 --- a/nix/module.nix +++ b/nix/module.nix @@ -132,7 +132,7 @@ in serviceConfig = { ExecStart = [ "" - "${lib.getExe cfg.package} ${cfg.logLevel} server --systemd --disable-landlock socket-activate" + "${lib.getExe' cfg.package "muscl-server"} ${cfg.logLevel} --systemd --disable-landlock socket-activate" ]; ExecReload = [ diff --git a/src/client/commands.rs b/src/client/commands.rs index 8dbdbec..1d4b79f 100644 --- a/src/client/commands.rs +++ b/src/client/commands.rs @@ -24,153 +24,12 @@ pub use show_privs::*; pub use show_user::*; pub use unlock_user::*; -use clap::Subcommand; use futures_util::SinkExt; use itertools::Itertools; use tokio_stream::StreamExt; use crate::core::protocol::{ClientToServerMessageStream, Request, Response}; -const EDIT_PRIVS_EXAMPLES: &str = color_print::cstr!( - r#" -Examples: - # Open interactive editor to edit privileges - muscl edit-privs - - # Set privileges `SELECT`, `INSERT`, and `UPDATE` for user `my_user` on database `my_db` - muscl edit-privs my_db my_user siu - - # Set all privileges for user `my_other_user` on database `my_other_db` - muscl edit-privs my_other_db my_other_user A - - # Add the `DELETE` privilege for user `my_user` on database `my_db` - muscl edit-privs my_db my_user +d - - # Set miscellaneous privileges for multiple users on database `my_db` - muscl edit-privs -p my_db:my_user:siu -p my_db:my_other_user:+ct -p my_db:yet_another_user:-d -"# -); - -#[derive(Subcommand, Debug, Clone)] -#[command(subcommand_required = true)] -pub enum ClientCommand { - /// Check whether you are authorized to manage the specified databases or users. - CheckAuth(CheckAuthArgs), - - /// Create one or more databases - CreateDb(CreateDbArgs), - - /// Delete one or more databases - DropDb(DropDbArgs), - - /// Print information about one or more databases - /// - /// If no database name is provided, all databases you have access will be shown. - ShowDb(ShowDbArgs), - - /// Print user privileges for one or more databases - /// - /// If no database names are provided, all databases you have access to will be shown. - ShowPrivs(ShowPrivsArgs), - - /// Change user privileges for one or more databases. See `edit-privs --help` for details. - /// - /// This command has three modes of operation: - /// - /// 1. Interactive mode: - /// - /// If no arguments are provided, the user will be prompted to edit the privileges using a text editor. - /// - /// You can configure your preferred text editor by setting the `VISUAL` or `EDITOR` environment variables. - /// - /// Follow the instructions inside the editor for more information. - /// - /// 2. Non-interactive human-friendly mode: - /// - /// You can provide the command with three positional arguments: - /// - /// - ``: The name of the database for which you want to edit privileges. - /// - ``: The name of the user whose privileges you want to edit. - /// - `<[+-]PRIVILEGES>`: A string representing the privileges to set for the user. - /// - /// The `<[+-]PRIVILEGES>` argument is a string of characters, each representing a single privilege. - /// The character `A` is an exception - it represents all privileges. - /// The optional leading character can be either `+` to grant additional privileges or `-` to revoke privileges. - /// If omitted, the privileges will be set exactly as specified, removing any privileges not listed, and adding any that are. - /// - /// The character-to-privilege mapping is defined as follows: - /// - /// - `s` - SELECT - /// - `i` - INSERT - /// - `u` - UPDATE - /// - `d` - DELETE - /// - `c` - CREATE - /// - `D` - DROP - /// - `a` - ALTER - /// - `I` - INDEX - /// - `t` - CREATE TEMPORARY TABLES - /// - `l` - LOCK TABLES - /// - `r` - REFERENCES - /// - `A` - ALL PRIVILEGES - /// - /// 3. Non-interactive batch mode: - /// - /// By using the `-p` flag, you can provide multiple privilege edits in a single command. - /// - /// The flag value should be formatted as `DB_NAME:USER_NAME:[+-]PRIVILEGES` - /// where the privileges are a string of characters, each representing a single privilege. - /// (See the character-to-privilege mapping above.) - /// - #[command( - verbatim_doc_comment, - override_usage = "muscl edit-privs [OPTIONS] [ -p ... | <[+-]PRIVILEGES> ]", - after_long_help = EDIT_PRIVS_EXAMPLES, - )] - EditPrivs(EditPrivsArgs), - - /// Create one or more users - CreateUser(CreateUserArgs), - - /// Delete one or more users - DropUser(DropUserArgs), - - /// Change the MySQL password for a user - PasswdUser(PasswdUserArgs), - - /// Print information about one or more users - /// - /// If no username is provided, all users you have access will be shown. - ShowUser(ShowUserArgs), - - /// Lock account for one or more users - LockUser(LockUserArgs), - - /// Unlock account for one or more users - UnlockUser(UnlockUserArgs), -} - -pub async fn handle_command( - command: ClientCommand, - server_connection: ClientToServerMessageStream, -) -> anyhow::Result<()> { - match command { - ClientCommand::CheckAuth(args) => check_authorization(args, server_connection).await, - ClientCommand::CreateDb(args) => create_databases(args, server_connection).await, - ClientCommand::DropDb(args) => drop_databases(args, server_connection).await, - ClientCommand::ShowDb(args) => show_databases(args, server_connection).await, - ClientCommand::ShowPrivs(args) => show_database_privileges(args, server_connection).await, - ClientCommand::EditPrivs(args) => { - edit_database_privileges(args, None, server_connection).await - } - ClientCommand::CreateUser(args) => create_users(args, server_connection).await, - ClientCommand::DropUser(args) => drop_users(args, server_connection).await, - ClientCommand::PasswdUser(args) => passwd_user(args, server_connection).await, - ClientCommand::ShowUser(args) => show_users(args, server_connection).await, - ClientCommand::LockUser(args) => lock_users(args, server_connection).await, - ClientCommand::UnlockUser(args) => unlock_users(args, server_connection).await, - } -} - /// Handle an unexpected or erroneous response from the server. /// /// This function checks the provided response and returns an appropriate error message. @@ -199,7 +58,7 @@ pub fn erroneous_server_response( /// /// This function should be used when an authorization error occurs, /// to help the user understand which databases or users they are allowed to manage. -pub async fn print_authorization_owner_hint( +async fn print_authorization_owner_hint( server_connection: &mut ClientToServerMessageStream, ) -> anyhow::Result<()> { server_connection diff --git a/src/entrypoints/muscl.rs b/src/entrypoints/muscl.rs new file mode 100644 index 0000000..f716015 --- /dev/null +++ b/src/entrypoints/muscl.rs @@ -0,0 +1,382 @@ +use std::os::unix::net::UnixStream as StdUnixStream; +use std::path::PathBuf; + +use anyhow::Context; +use clap::{CommandFactory, Parser, Subcommand, crate_version}; +use clap_complete::CompleteEnv; +use clap_verbosity_flag::{InfoLevel, Verbosity}; +use tokio::net::UnixStream as TokioUnixStream; +use tokio_stream::StreamExt; + +use muscl_lib::{ + client::{ + commands::{ + CheckAuthArgs, CreateDbArgs, CreateUserArgs, DropDbArgs, DropUserArgs, EditPrivsArgs, + LockUserArgs, PasswdUserArgs, ShowDbArgs, ShowPrivsArgs, ShowUserArgs, UnlockUserArgs, + check_authorization, create_databases, create_users, drop_databases, drop_users, + edit_database_privileges, lock_users, passwd_user, show_database_privileges, + show_databases, show_users, unlock_users, + }, + mysql_admutils_compatibility::{mysql_dbadm, mysql_useradm}, + }, + core::{ + bootstrap::bootstrap_server_connection_and_drop_privileges, + common::{ASCII_BANNER, KIND_REGARDS}, + protocol::{ClientToServerMessageStream, Response, create_client_to_server_message_stream}, + }, +}; + +const fn long_version() -> &'static str { + macro_rules! feature { + ($title:expr, $flag:expr) => { + if cfg!(feature = $flag) { + concat!($title, ": enabled") + } else { + concat!($title, ": disabled") + } + }; + } + const_format::concatcp!( + crate_version!(), + "\n", + "build profile: ", + env!("BUILD_PROFILE"), + "\n", + "commit: ", + env!("GIT_COMMIT"), + "\n\n", + "[features]\n", + feature!("SUID/SGID mode", "suid-sgid-mode"), + "\n", + feature!( + "mysql-admutils compatibility", + "mysql-admutils-compatibility" + ), + "\n", + ) +} + +const LONG_VERSION: &str = long_version(); + +const EXAMPLES: &str = const_format::concatcp!( + color_print::cstr!("Examples:"), + r#" + # Display help information for any specific command + muscl --help + + # Create two users 'alice_user1' and 'alice_user2' + muscl create-user alice_user1 alice_user2 + + # Create two databases 'alice_db1' and 'alice_db2' + muscl create-db alice_db1 alice_db2 + + # Grant Select, Update, Insert and Delete privileges on 'alice_db1' to 'alice_user1' + muscl edit-privs alice_db1 alice_user1 +suid + + # Show all databases + muscl show-db + + # Show which users have privileges on which databases + muscl show-privs +"#, +); + +const BEFORE_LONG_HELP: &str = const_format::concatcp!("\x1b[1m", ASCII_BANNER, "\x1b[0m"); +const AFTER_LONG_HELP: &str = const_format::concatcp!(EXAMPLES, "\n", KIND_REGARDS,); + +/// Database administration tool for non-admin users to manage their own MySQL databases and users. +/// +/// This tool allows you to manage users and databases in MySQL. +/// +/// You are only allowed to manage databases and users that are prefixed with +/// either your username, or a group that you are a member of. +#[derive(Parser, Debug)] +#[command( + bin_name = "muscl", + author = "Programvareverkstedet ", + version, + about, + disable_help_subcommand = true, + propagate_version = true, + before_long_help = BEFORE_LONG_HELP, + after_long_help = AFTER_LONG_HELP, + long_version = LONG_VERSION, + // NOTE: All non-registered "subcommands" are processed before Arg::parse() is called. + subcommand_required = true, +)] +struct Args { + #[command(subcommand)] + command: ClientCommand, + + // NOTE: be careful not to add short options that collide with the `edit-privs` privilege + // characters. It should in theory be possible for `edit-privs` to ignore any options + // specified here, but in practice clap is being difficult to work with. + /// Path to the socket of the server. + #[arg( + long = "server-socket", + value_name = "PATH", + value_hint = clap::ValueHint::FilePath, + global = true, + hide_short_help = true + )] + server_socket_path: Option, + + // TODO: conditionally include this only when compiling with SUID/SGID support + /// Config file to use for the server. + /// + /// This is only useful when running in SUID/SGID mode. + #[arg( + long = "config", + value_name = "PATH", + value_hint = clap::ValueHint::FilePath, + global = true, + hide_short_help = true + )] + config_path: Option, + + #[command(flatten)] + verbose: Verbosity, +} + +const EDIT_PRIVS_EXAMPLES: &str = color_print::cstr!( + r#" +Examples: + # Open interactive editor to edit privileges + muscl edit-privs + + # Set privileges `SELECT`, `INSERT`, and `UPDATE` for user `my_user` on database `my_db` + muscl edit-privs my_db my_user siu + + # Set all privileges for user `my_other_user` on database `my_other_db` + muscl edit-privs my_other_db my_other_user A + + # Add the `DELETE` privilege for user `my_user` on database `my_db` + muscl edit-privs my_db my_user +d + + # Set miscellaneous privileges for multiple users on database `my_db` + muscl edit-privs -p my_db:my_user:siu -p my_db:my_other_user:+ct -p my_db:yet_another_user:-d +"# +); + +#[derive(Subcommand, Debug, Clone)] +#[command(subcommand_required = true)] +pub enum ClientCommand { + /// Check whether you are authorized to manage the specified databases or users. + CheckAuth(CheckAuthArgs), + + /// Create one or more databases + CreateDb(CreateDbArgs), + + /// Delete one or more databases + DropDb(DropDbArgs), + + /// Print information about one or more databases + /// + /// If no database name is provided, all databases you have access will be shown. + ShowDb(ShowDbArgs), + + /// Print user privileges for one or more databases + /// + /// If no database names are provided, all databases you have access to will be shown. + ShowPrivs(ShowPrivsArgs), + + /// Change user privileges for one or more databases. See `edit-privs --help` for details. + /// + /// This command has three modes of operation: + /// + /// 1. Interactive mode: + /// + /// If no arguments are provided, the user will be prompted to edit the privileges using a text editor. + /// + /// You can configure your preferred text editor by setting the `VISUAL` or `EDITOR` environment variables. + /// + /// Follow the instructions inside the editor for more information. + /// + /// 2. Non-interactive human-friendly mode: + /// + /// You can provide the command with three positional arguments: + /// + /// - ``: The name of the database for which you want to edit privileges. + /// - ``: The name of the user whose privileges you want to edit. + /// - `<[+-]PRIVILEGES>`: A string representing the privileges to set for the user. + /// + /// The `<[+-]PRIVILEGES>` argument is a string of characters, each representing a single privilege. + /// The character `A` is an exception - it represents all privileges. + /// The optional leading character can be either `+` to grant additional privileges or `-` to revoke privileges. + /// If omitted, the privileges will be set exactly as specified, removing any privileges not listed, and adding any that are. + /// + /// The character-to-privilege mapping is defined as follows: + /// + /// - `s` - SELECT + /// - `i` - INSERT + /// - `u` - UPDATE + /// - `d` - DELETE + /// - `c` - CREATE + /// - `D` - DROP + /// - `a` - ALTER + /// - `I` - INDEX + /// - `t` - CREATE TEMPORARY TABLES + /// - `l` - LOCK TABLES + /// - `r` - REFERENCES + /// - `A` - ALL PRIVILEGES + /// + /// 3. Non-interactive batch mode: + /// + /// By using the `-p` flag, you can provide multiple privilege edits in a single command. + /// + /// The flag value should be formatted as `DB_NAME:USER_NAME:[+-]PRIVILEGES` + /// where the privileges are a string of characters, each representing a single privilege. + /// (See the character-to-privilege mapping above.) + /// + #[command( + verbatim_doc_comment, + override_usage = "muscl edit-privs [OPTIONS] [ -p ... | <[+-]PRIVILEGES> ]", + after_long_help = EDIT_PRIVS_EXAMPLES, + )] + EditPrivs(EditPrivsArgs), + + /// Create one or more users + CreateUser(CreateUserArgs), + + /// Delete one or more users + DropUser(DropUserArgs), + + /// Change the MySQL password for a user + PasswdUser(PasswdUserArgs), + + /// Print information about one or more users + /// + /// If no username is provided, all users you have access will be shown. + ShowUser(ShowUserArgs), + + /// Lock account for one or more users + LockUser(LockUserArgs), + + /// Unlock account for one or more users + UnlockUser(UnlockUserArgs), +} + +pub async fn handle_command( + command: ClientCommand, + server_connection: ClientToServerMessageStream, +) -> anyhow::Result<()> { + match command { + ClientCommand::CheckAuth(args) => check_authorization(args, server_connection).await, + ClientCommand::CreateDb(args) => create_databases(args, server_connection).await, + ClientCommand::DropDb(args) => drop_databases(args, server_connection).await, + ClientCommand::ShowDb(args) => show_databases(args, server_connection).await, + ClientCommand::ShowPrivs(args) => show_database_privileges(args, server_connection).await, + ClientCommand::EditPrivs(args) => { + edit_database_privileges(args, None, server_connection).await + } + ClientCommand::CreateUser(args) => create_users(args, server_connection).await, + ClientCommand::DropUser(args) => drop_users(args, server_connection).await, + ClientCommand::PasswdUser(args) => passwd_user(args, server_connection).await, + ClientCommand::ShowUser(args) => show_users(args, server_connection).await, + ClientCommand::LockUser(args) => lock_users(args, server_connection).await, + ClientCommand::UnlockUser(args) => unlock_users(args, server_connection).await, + } +} + +/// **WARNING:** This function may be run with elevated privileges. +fn main() -> anyhow::Result<()> { + if handle_dynamic_completion()?.is_some() { + return Ok(()); + } + + #[cfg(feature = "mysql-admutils-compatibility")] + if handle_mysql_admutils_command()?.is_some() { + return Ok(()); + } + + let args: Args = Args::parse(); + + let connection = bootstrap_server_connection_and_drop_privileges( + args.server_socket_path, + args.config_path, + args.verbose, + )?; + + tokio_run_command(args.command, connection)?; + + Ok(()) +} + +/// **WARNING:** This function may be run with elevated privileges. +fn handle_dynamic_completion() -> anyhow::Result> { + if std::env::var_os("COMPLETE").is_some() { + #[cfg(feature = "suid-sgid-mode")] + if executing_in_suid_sgid_mode()? { + use crate::core::bootstrap::drop_privs; + drop_privs()? + } + + let argv0 = std::env::args() + .next() + .and_then(|s| { + PathBuf::from(s) + .file_name() + .map(|s| s.to_string_lossy().to_string()) + }) + .ok_or(anyhow::anyhow!( + "Could not determine executable name for completion" + ))?; + + let command = match argv0.as_str() { + "muscl" => Args::command(), + "mysql-dbadm" => mysql_dbadm::Args::command(), + "mysql-useradm" => mysql_useradm::Args::command(), + command => anyhow::bail!("Unknown executable name: `{}`", command), + }; + + CompleteEnv::with_factory(move || command.clone()).complete(); + + Ok(Some(())) + } else { + Ok(None) + } +} + +/// **WARNING:** This function may be run with elevated privileges. +fn handle_mysql_admutils_command() -> anyhow::Result> { + let argv0 = std::env::args().next().and_then(|s| { + PathBuf::from(s) + .file_name() + .map(|s| s.to_string_lossy().to_string()) + }); + + match argv0.as_deref() { + Some("mysql-dbadm") => mysql_dbadm::main().map(Some), + Some("mysql-useradm") => mysql_useradm::main().map(Some), + _ => Ok(None), + } +} + +/// Run the given commmand (from the client side) using Tokio. +fn tokio_run_command( + command: ClientCommand, + server_connection: StdUnixStream, +) -> anyhow::Result<()> { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("Failed to start Tokio runtime")? + .block_on(async { + let tokio_socket = TokioUnixStream::from_std(server_connection)?; + let mut message_stream = create_client_to_server_message_stream(tokio_socket); + + while let Some(Ok(message)) = message_stream.next().await { + match message { + Response::Error(err) => { + anyhow::bail!("{}", err); + } + Response::Ready => break, + message => { + eprintln!("Unexpected message from server: {:?}", message); + } + } + } + + handle_command(command, message_stream).await + }) +} diff --git a/src/server/command.rs b/src/entrypoints/muscl_server.rs similarity index 66% rename from src/server/command.rs rename to src/entrypoints/muscl_server.rs index 6f4ace3..450daf8 100644 --- a/src/server/command.rs +++ b/src/entrypoints/muscl_server.rs @@ -3,11 +3,11 @@ use std::path::PathBuf; use anyhow::Context; use clap::{Parser, Subcommand}; use clap_verbosity_flag::{InfoLevel, Verbosity}; -use tracing_subscriber::prelude::*; +use tracing_subscriber::layer::SubscriberExt; -use crate::{ +use muscl_lib::{ core::common::{ASCII_BANNER, DEFAULT_CONFIG_PATH, KIND_REGARDS}, - server::supervisor::Supervisor, + server::{landlock::landlock_restrict_server, supervisor::Supervisor}, }; #[derive(Parser, Debug, Clone)] @@ -25,6 +25,29 @@ pub struct ServerArgs { /// This is useful if you are planning to reload the server's configuration. #[arg(long)] pub disable_landlock: bool, + + // NOTE: be careful not to add short options that collide with the `edit-privs` privilege + // characters. It should in theory be possible for `edit-privs` to ignore any options + // specified here, but in practice clap is being difficult to work with. + /// Path to where the server's unix socket should be created. This is only relevant when + /// not using systemd socket activation. + #[arg( + long = "socket", + value_name = "PATH", + value_hint = clap::ValueHint::FilePath, + )] + socket_path: Option, + + /// Config file to use for the server. + #[arg( + long = "config", + value_name = "PATH", + value_hint = clap::ValueHint::FilePath, + )] + config_path: Option, + + #[command(flatten)] + verbosity: Verbosity, } #[derive(Subcommand, Debug, Clone)] @@ -48,16 +71,34 @@ const LOG_LEVEL_WARNING: &str = r#" =================================================== "#; -pub fn trace_server_prelude() { +const MIN_TOKIO_WORKER_THREADS: usize = 4; + +fn main() -> anyhow::Result<()> { + let args = ServerArgs::parse(); + + if !args.disable_landlock { + landlock_restrict_server(args.config_path.as_deref()) + .context("Failed to apply Landlock restrictions to the server process")?; + } + + let worker_thread_count = std::cmp::max(num_cpus::get(), MIN_TOKIO_WORKER_THREADS); + + tokio::runtime::Builder::new_multi_thread() + .worker_threads(worker_thread_count) + .enable_all() + .build() + .context("Failed to start Tokio runtime")? + .block_on(handle_command(args))?; + + Ok(()) +} + +fn trace_server_prelude() { let message = [ASCII_BANNER, "", KIND_REGARDS, ""].join("\n"); tracing::info!(message); } -pub async fn handle_command( - config_path: Option, - verbosity: Verbosity, - args: ServerArgs, -) -> anyhow::Result<()> { +async fn handle_command(args: ServerArgs) -> anyhow::Result<()> { let mut auto_detected_systemd_mode = false; #[cfg(target_os = "linux")] @@ -77,7 +118,7 @@ pub async fn handle_command( #[cfg(target_os = "linux")] { let subscriber = tracing_subscriber::Registry::default() - .with(verbosity.tracing_level_filter()) + .with(args.verbosity.tracing_level_filter()) .with(tracing_journald::layer()?); tracing::subscriber::set_global_default(subscriber) @@ -85,7 +126,7 @@ pub async fn handle_command( trace_server_prelude(); - if verbosity.tracing_level_filter() >= tracing::Level::TRACE { + if args.verbosity.tracing_level_filter() >= tracing::Level::TRACE { tracing::warn!("{}", LOG_LEVEL_WARNING.trim()); } @@ -97,7 +138,7 @@ pub async fn handle_command( } } else { let subscriber = tracing_subscriber::Registry::default() - .with(verbosity.tracing_level_filter()) + .with(args.verbosity.tracing_level_filter()) .with( tracing_subscriber::fmt::layer() .with_line_number(cfg!(debug_assertions)) @@ -114,7 +155,9 @@ pub async fn handle_command( tracing::debug!("Running in standalone mode"); } - let config_path = config_path.unwrap_or_else(|| PathBuf::from(DEFAULT_CONFIG_PATH)); + let config_path = args + .config_path + .unwrap_or_else(|| PathBuf::from(DEFAULT_CONFIG_PATH)); match args.subcmd { ServerCommand::Listen => { diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..29cfe43 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,6 @@ +#[macro_use] +extern crate prettytable; + +pub mod client; +pub mod core; +pub mod server; diff --git a/src/main.rs b/src/main.rs deleted file mode 100644 index d964505..0000000 --- a/src/main.rs +++ /dev/null @@ -1,308 +0,0 @@ -#[macro_use] -extern crate prettytable; - -use anyhow::Context; -use clap::{CommandFactory, Parser, crate_version}; -use clap_complete::CompleteEnv; -use clap_verbosity_flag::{InfoLevel, Verbosity}; - -use std::path::PathBuf; - -use std::os::unix::net::UnixStream as StdUnixStream; -use tokio::net::UnixStream as TokioUnixStream; - -use futures_util::StreamExt; - -use crate::{ - core::{ - bootstrap::bootstrap_server_connection_and_drop_privileges, - common::{ASCII_BANNER, KIND_REGARDS, executing_in_suid_sgid_mode}, - protocol::{Response, create_client_to_server_message_stream}, - }, - server::{command::ServerArgs, landlock::landlock_restrict_server}, -}; - -#[cfg(feature = "mysql-admutils-compatibility")] -use crate::client::mysql_admutils_compatibility::{mysql_dbadm, mysql_useradm}; - -mod server; - -mod client; -mod core; - -const fn long_version() -> &'static str { - macro_rules! feature { - ($title:expr, $flag:expr) => { - if cfg!(feature = $flag) { - concat!($title, ": enabled") - } else { - concat!($title, ": disabled") - } - }; - } - const_format::concatcp!( - crate_version!(), - "\n", - "build profile: ", - env!("BUILD_PROFILE"), - "\n", - "commit: ", - env!("GIT_COMMIT"), - "\n\n", - "[features]\n", - feature!("SUID/SGID mode", "suid-sgid-mode"), - "\n", - feature!( - "mysql-admutils compatibility", - "mysql-admutils-compatibility" - ), - "\n", - ) -} - -const LONG_VERSION: &str = long_version(); - -const EXAMPLES: &str = const_format::concatcp!( - color_print::cstr!("Examples:"), - r#" - # Display help information for any specific command - muscl --help - - # Create two users 'alice_user1' and 'alice_user2' - muscl create-user alice_user1 alice_user2 - - # Create two databases 'alice_db1' and 'alice_db2' - muscl create-db alice_db1 alice_db2 - - # Grant Select, Update, Insert and Delete privileges on 'alice_db1' to 'alice_user1' - muscl edit-privs alice_db1 alice_user1 +suid - - # Show all databases - muscl show-db - - # Show which users have privileges on which databases - muscl show-privs -"#, -); - -const BEFORE_LONG_HELP: &str = const_format::concatcp!("\x1b[1m", ASCII_BANNER, "\x1b[0m"); -const AFTER_LONG_HELP: &str = const_format::concatcp!(EXAMPLES, "\n", KIND_REGARDS,); - -/// Database administration tool for non-admin users to manage their own MySQL databases and users. -/// -/// This tool allows you to manage users and databases in MySQL. -/// -/// You are only allowed to manage databases and users that are prefixed with -/// either your username, or a group that you are a member of. -#[derive(Parser, Debug)] -#[command( - bin_name = "muscl", - author = "Programvareverkstedet ", - version, - about, - disable_help_subcommand = true, - propagate_version = true, - before_long_help = BEFORE_LONG_HELP, - after_long_help = AFTER_LONG_HELP, - long_version = LONG_VERSION, - // NOTE: All non-registered "subcommands" are processed before Arg::parse() is called. - subcommand_required = true, -)] -struct Args { - #[command(subcommand)] - command: Command, - - // NOTE: be careful not to add short options that collide with the `edit-privs` privilege - // characters. It should in theory be possible for `edit-privs` to ignore any options - // specified here, but in practice clap is being difficult to work with. - /// Path to the socket of the server, if it already exists. - #[arg( - long, - value_name = "PATH", - value_hint = clap::ValueHint::FilePath, - global = true, - hide_short_help = true - )] - server_socket_path: Option, - - /// Config file to use for the server. - #[arg( - long, - value_name = "PATH", - value_hint = clap::ValueHint::FilePath, - global = true, - hide_short_help = true - )] - config: Option, - - #[command(flatten)] - verbose: Verbosity, -} - -#[derive(Parser, Debug, Clone)] -enum Command { - #[command(flatten)] - Client(client::commands::ClientCommand), - - /// Run the server - #[command(hide = true)] - Server(server::command::ServerArgs), -} - -/// **WARNING:** This function may be run with elevated privileges. -fn main() -> anyhow::Result<()> { - if handle_dynamic_completion()?.is_some() { - return Ok(()); - } - - #[cfg(feature = "mysql-admutils-compatibility")] - if handle_mysql_admutils_command()?.is_some() { - return Ok(()); - } - - let args: Args = Args::parse(); - - if handle_server_command(&args)?.is_some() { - return Ok(()); - } - - let connection = bootstrap_server_connection_and_drop_privileges( - args.server_socket_path, - args.config, - args.verbose, - )?; - - tokio_run_command(args.command, connection)?; - - Ok(()) -} - -/// **WARNING:** This function may be run with elevated privileges. -fn handle_dynamic_completion() -> anyhow::Result> { - if std::env::var_os("COMPLETE").is_some() { - #[cfg(feature = "suid-sgid-mode")] - if executing_in_suid_sgid_mode()? { - use crate::core::bootstrap::drop_privs; - drop_privs()? - } - - let argv0 = std::env::args() - .next() - .and_then(|s| { - PathBuf::from(s) - .file_name() - .map(|s| s.to_string_lossy().to_string()) - }) - .ok_or(anyhow::anyhow!( - "Could not determine executable name for completion" - ))?; - - let command = match argv0.as_str() { - "muscl" => Args::command(), - "mysql-dbadm" => mysql_dbadm::Args::command(), - "mysql-useradm" => mysql_useradm::Args::command(), - command => anyhow::bail!("Unknown executable name: `{}`", command), - }; - - CompleteEnv::with_factory(move || command.clone()).complete(); - - Ok(Some(())) - } else { - Ok(None) - } -} - -/// **WARNING:** This function may be run with elevated privileges. -fn handle_mysql_admutils_command() -> anyhow::Result> { - let argv0 = std::env::args().next().and_then(|s| { - PathBuf::from(s) - .file_name() - .map(|s| s.to_string_lossy().to_string()) - }); - - match argv0.as_deref() { - Some("mysql-dbadm") => mysql_dbadm::main().map(Some), - Some("mysql-useradm") => mysql_useradm::main().map(Some), - _ => Ok(None), - } -} - -/// **WARNING:** This function may be run with elevated privileges. -fn handle_server_command(args: &Args) -> anyhow::Result> { - match args.command { - Command::Server(ref command) => { - assert!( - !executing_in_suid_sgid_mode()?, - "The executable should not be SUID or SGID when running the server manually" - ); - - if !command.disable_landlock { - landlock_restrict_server(args.config.as_deref()) - .context("Failed to apply Landlock restrictions to the server process")?; - } - - tokio_start_server( - args.config.to_owned(), - args.verbose.to_owned(), - command.to_owned(), - )?; - Ok(Some(())) - } - _ => Ok(None), - } -} - -const MIN_TOKIO_WORKER_THREADS: usize = 4; - -/// Start a long-lived server using Tokio. -fn tokio_start_server( - config_path: Option, - verbosity: Verbosity, - args: ServerArgs, -) -> anyhow::Result<()> { - let worker_thread_count = std::cmp::max(num_cpus::get(), MIN_TOKIO_WORKER_THREADS); - - tokio::runtime::Builder::new_multi_thread() - .worker_threads(worker_thread_count) - .enable_all() - .build() - .context("Failed to start Tokio runtime")? - .block_on(server::command::handle_command( - config_path, - verbosity, - args, - )) -} - -/// Run the given commmand (from the client side) using Tokio. -/// -/// **WARNING:** This function may be run with elevated privileges. -fn tokio_run_command(command: Command, server_connection: StdUnixStream) -> anyhow::Result<()> { - tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .context("Failed to start Tokio runtime")? - .block_on(async { - let tokio_socket = TokioUnixStream::from_std(server_connection)?; - let mut message_stream = create_client_to_server_message_stream(tokio_socket); - - while let Some(Ok(message)) = message_stream.next().await { - match message { - Response::Error(err) => { - anyhow::bail!("{}", err); - } - Response::Ready => break, - message => { - eprintln!("Unexpected message from server: {:?}", message); - } - } - } - - match command { - Command::Client(client_args) => { - client::commands::handle_command(client_args, message_stream).await - } - Command::Server(_) => unreachable!(), - } - }) -} diff --git a/src/server.rs b/src/server.rs index aa7d712..c00f476 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1,5 +1,4 @@ pub mod authorization; -pub mod command; mod common; pub mod config; pub mod landlock;