Compare commits

...

6 Commits

14 changed files with 327 additions and 80 deletions

10
Cargo.lock generated
View File

@ -265,6 +265,15 @@ dependencies = [
"strsim", "strsim",
] ]
[[package]]
name = "clap_complete"
version = "4.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ee158892bd7ce77aa15c208abbdb73e155d191c287a659b57abd5adb92feb03"
dependencies = [
"clap",
]
[[package]] [[package]]
name = "clap_derive" name = "clap_derive"
version = "4.5.13" version = "4.5.13"
@ -1055,6 +1064,7 @@ dependencies = [
"async-bincode", "async-bincode",
"bincode", "bincode",
"clap", "clap",
"clap_complete",
"derive_more", "derive_more",
"dialoguer", "dialoguer",
"env_logger", "env_logger",

View File

@ -8,6 +8,7 @@ anyhow = "1.0.86"
async-bincode = "0.7.2" async-bincode = "0.7.2"
bincode = "1.3.3" bincode = "1.3.3"
clap = { version = "4.5.16", features = ["derive"] } clap = { version = "4.5.16", features = ["derive"] }
clap_complete = "4.5.18"
derive_more = { version = "1.0.0", features = ["display", "error"] } derive_more = { version = "1.0.0", features = ["display", "error"] }
dialoguer = "0.11.0" dialoguer = "0.11.0"
env_logger = "0.11.5" env_logger = "0.11.5"

View File

@ -3,9 +3,7 @@ use anyhow::anyhow;
#[cfg(feature = "mysql-admutils-compatibility")] #[cfg(feature = "mysql-admutils-compatibility")]
use std::{env, os::unix::fs::symlink, path::PathBuf}; use std::{env, os::unix::fs::symlink, path::PathBuf};
fn main() -> anyhow::Result<()> { fn generate_mysql_admutils_symlinks() -> anyhow::Result<()> {
#[cfg(feature = "mysql-admutils-compatibility")]
{
// NOTE: This is slightly illegal, and depends on implementation details. // NOTE: This is slightly illegal, and depends on implementation details.
// But it is only here for ease of testing the compatibility layer, // But it is only here for ease of testing the compatibility layer,
// and not critical in any way. Considering the code is never going // and not critical in any way. Considering the code is never going
@ -36,7 +34,13 @@ fn main() -> anyhow::Result<()> {
) )
.ok(); .ok();
} }
}
Ok(())
}
fn main() -> anyhow::Result<()> {
#[cfg(feature = "mysql-admutils-compatibility")]
generate_mysql_admutils_symlinks()?;
Ok(()) Ok(())
} }

View File

@ -1,5 +1,8 @@
# This should go to `/etc/mysqladm/config.toml` # This should go to `/etc/mysqladm/config.toml`
[server]
socket_path = "/var/run/mysqladm/mysqladm.sock"
[mysql] [mysql]
host = "localhost" host = "localhost"
port = 3306 port = 3306

View File

@ -2,11 +2,11 @@
"nodes": { "nodes": {
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1713297878, "lastModified": 1723637854,
"narHash": "sha256-hOkzkhLT59wR8VaMbh1ESjtZLbGi+XNaBN6h49SPqEc=", "narHash": "sha256-med8+5DSWa2UnOqtdICndjDAEjxr5D7zaIiK4pn0Q7c=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "66adc1e47f8784803f2deb6cacd5e07264ec2d5c", "rev": "c3aa7b8938b17aebd2deecf7be0636000d62a2b9",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -29,11 +29,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1720577957, "lastModified": 1723947704,
"narHash": "sha256-RZuzLdB/8FaXaSzEoWLg3au/mtbuH7MGn2LmXUKT62g=", "narHash": "sha256-TcVf66N2NgGhxORFytzgqWcg0XJ+kk8uNLNsTRI5sYM=",
"owner": "oxalica", "owner": "oxalica",
"repo": "rust-overlay", "repo": "rust-overlay",
"rev": "a434177dfcc53bf8f1f348a3c39bfb336d760286", "rev": "456e78a55feade2c3bc6d7bc0bf5e710c9d86120",
"type": "github" "type": "github"
}, },
"original": { "original": {

View File

@ -52,11 +52,16 @@
overlays = { overlays = {
default = self.overlays.mysqladm-rs; default = self.overlays.mysqladm-rs;
greg-ng = final: prev: { mysqladm-rs = final: prev: {
inherit (self.packages.${prev.system}) mysqladm-rs; inherit (self.packages.${prev.system}) mysqladm-rs;
}; };
}; };
nixosModules = {
default = self.nixosModules.mysqladm-rs;
mysqladm-rs = import ./nix/module.nix;
};
packages = let packages = let
cargoToml = builtins.fromTOML (builtins.readFile ./Cargo.toml); cargoToml = builtins.fromTOML (builtins.readFile ./Cargo.toml);
cargoLock = ./Cargo.lock; cargoLock = ./Cargo.lock;

View File

@ -4,8 +4,10 @@
, cargoToml , cargoToml
, cargoLock , cargoLock
, src , src
, installShellFiles
}: }:
let let
mainProgram = (lib.head cargoToml.bin).name;
in in
rustPlatform.buildRustPackage { rustPlatform.buildRustPackage {
pname = cargoToml.package.name; pname = cargoToml.package.name;
@ -14,9 +16,20 @@ rustPlatform.buildRustPackage {
cargoLock.lockFile = cargoLock; cargoLock.lockFile = cargoLock;
nativeBuildInputs = [ installShellFiles ];
postInstall = let
commands = lib.mapCartesianProduct ({ shell, command }: ''
"$out/bin/${mainProgram}" generate-completions --shell "${shell}" --command "${command}" > "$TMP/mysqladm.${shell}"
installShellCompletion "--${shell}" --cmd "${command}" "$TMP/mysqladm.${shell}"
'') {
shell = [ "bash" "zsh" "fish" ];
command = [ "mysqladm" "mysql-dbadm" "mysql-useradm" ];
};
in lib.concatStringsSep "\n" commands;
meta = with lib; { meta = with lib; {
license = licenses.mit; license = licenses.mit;
platforms = platforms.linux ++ platforms.darwin; platforms = platforms.linux ++ platforms.darwin;
mainProgram = (lib.head cargoToml.bin).name; inherit mainProgram;
}; };
} }

108
nix/module.nix Normal file
View File

@ -0,0 +1,108 @@
{ config, pkgs, lib, ... }:
let
cfg = config.services.mysqladm-rs;
format = pkgs.formats.toml { };
in
{
options.services.mysqladm-rs = {
enable = lib.mkEnableOption "Enable mysqladm-rs";
package = lib.mkPackageOption pkgs "mysqladm-rs" { };
createLocalUser = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Create a local database user for mysqladm-rs";
};
settings = lib.mkOption {
default = { };
type = lib.types.submodule {
freeformType = format.type;
options = {
server = {
socket_path = lib.mkOption {
type = lib.types.path;
default = "/var/run/mysqladm/mysqladm.sock";
description = "Path to the MySQL socket";
};
};
mysql = {
host = lib.mkOption {
type = lib.types.str;
default = "localhost";
description = "MySQL host";
};
port = lib.mkOption {
type = lib.types.int;
default = 3306;
description = "MySQL port";
};
username = lib.mkOption {
type = lib.types.str;
default = "root";
description = "MySQL username";
};
# passwordFile = lib.mkOption {
# type = lib.types.path;
# default = "secret";
# description = "Path to a file containing the MySQL password";
# };
password = lib.mkOption {
type = lib.types.str;
default = "secret";
description = "MySQL password";
};
timeout = lib.mkOption {
type = lib.types.int;
default = 2;
description = "Number of seconds to wait for a response from the MySQL server";
};
};
};
};
};
};
config = let
configFile = format.generate "mysqladm-rs.conf" cfg.settings;
in lib.mkIf config.services.mysqladm-rs.enable {
environment.systemPackages = [ cfg.package ];
services.mysql.ensureUsers = lib.mkIf cfg.createLocalUser [
{
name = "mysqladm";
ensurePermissions = {
"mysql.*" = "SELECT, INSERT, UPDATE, DELETE";
"information_schema.*" = "SELECT";
"*.*" = "CREATE USER, GRANT OPTION";
};
}
];
systemd.services."mysqladm@" = {
description = "MySQL administration tool for non-admin users";
# after = [ "mysql.target" ];
environment.RUST_LOG = "debug";
serviceConfig = {
Type = "notify";
ExecStart = "${lib.getExe cfg.package} server socket-activate --config ${configFile}";
User = "mysqladm";
Group = "mysqladm";
DynamicUser = true;
};
};
systemd.sockets."mysqladm" = {
description = "MySQL administration tool for non-admin users";
wantedBy = [ "sockets.target" ];
restartTriggers = [ configFile ];
socketConfig = {
ListenStream = cfg.settings.server.socket_path;
Accept = "yes";
PassCredentials = true;
};
};
};
}

View File

@ -49,7 +49,19 @@ The Y/N-values corresponds to the following mysql privileges:
References - Enables use of REFERENCES References - Enables use of REFERENCES
"#; "#;
/// Create, drop or edit permissions for the DATABASE(s),
/// as determined by the COMMAND.
///
/// This is a compatibility layer for the mysql-dbadm command.
/// Please consider using the newer mysqladm command instead.
#[derive(Parser)] #[derive(Parser)]
#[command(
bin_name = "mysql-dbadm",
version,
about,
disable_help_subcommand = true,
verbatim_doc_comment,
)]
pub struct Args { pub struct Args {
#[command(subcommand)] #[command(subcommand)]
pub command: Option<Command>, pub command: Option<Command>,
@ -82,14 +94,7 @@ pub struct Args {
// NOTE: mysql-dbadm explicitly calls privileges "permissions". // NOTE: mysql-dbadm explicitly calls privileges "permissions".
// This is something we're trying to move away from. // This is something we're trying to move away from.
// See https://git.pvv.ntnu.no/Projects/mysqladm-rs/issues/29 // See https://git.pvv.ntnu.no/Projects/mysqladm-rs/issues/29
/// Create, drop or edit permissions for the DATABASE(s),
/// as determined by the COMMAND.
///
/// This is a compatibility layer for the mysql-dbadm command.
/// Please consider using the newer mysqladm command instead.
#[derive(Parser)] #[derive(Parser)]
#[command(version, about, disable_help_subcommand = true, verbatim_doc_comment)]
pub enum Command { pub enum Command {
/// create the DATABASE(s). /// create the DATABASE(s).
Create(CreateArgs), Create(CreateArgs),

View File

@ -25,7 +25,19 @@ use crate::{
server::sql::user_operations::DatabaseUser, server::sql::user_operations::DatabaseUser,
}; };
/// Create, delete or change password for the USER(s),
/// as determined by the COMMAND.
///
/// This is a compatibility layer for the mysql-useradm command.
/// Please consider using the newer mysqladm command instead.
#[derive(Parser)] #[derive(Parser)]
#[command(
bin_name = "mysql-useradm",
version,
about,
disable_help_subcommand = true,
verbatim_doc_comment,
)]
pub struct Args { pub struct Args {
#[command(subcommand)] #[command(subcommand)]
pub command: Option<Command>, pub command: Option<Command>,
@ -51,13 +63,7 @@ pub struct Args {
config: Option<PathBuf>, config: Option<PathBuf>,
} }
/// Create, delete or change password for the USER(s),
/// as determined by the COMMAND.
///
/// This is a compatibility layer for the mysql-useradm command.
/// Please consider using the newer mysqladm command instead.
#[derive(Parser)] #[derive(Parser)]
#[command(version, about, disable_help_subcommand = true, verbatim_doc_comment)]
pub enum Command { pub enum Command {
/// create the USER(s). /// create the USER(s).
Create(CreateArgs), Create(CreateArgs),

View File

@ -6,7 +6,8 @@ use futures_util::{SinkExt, StreamExt};
use crate::core::protocol::{ use crate::core::protocol::{
print_create_users_output_status, print_drop_users_output_status, print_create_users_output_status, print_drop_users_output_status,
print_lock_users_output_status, print_set_password_output_status, print_lock_users_output_status, print_set_password_output_status,
print_unlock_users_output_status, ClientToServerMessageStream, Request, Response, print_unlock_users_output_status, ClientToServerMessageStream, ListUsersError, Request,
Response,
}; };
use super::common::erroneous_server_response; use super::common::erroneous_server_response;
@ -207,6 +208,28 @@ async fn passwd_user(
args: UserPasswdArgs, args: UserPasswdArgs,
mut server_connection: ClientToServerMessageStream, mut server_connection: ClientToServerMessageStream,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
// TODO: create a "user" exists check" command
let message = Request::ListUsers(Some(vec![args.username.clone()]));
if let Err(err) = server_connection.send(message).await {
server_connection.close().await.ok();
anyhow::bail!(err);
}
let response = match server_connection.next().await {
Some(Ok(Response::ListUsers(users))) => users,
response => return erroneous_server_response(response),
};
match response
.get(&args.username)
.unwrap_or(&Err(ListUsersError::UserDoesNotExist))
{
Ok(_) => {}
Err(err) => {
server_connection.send(Request::Exit).await?;
server_connection.close().await.ok();
anyhow::bail!("{}", err.to_error_message(&args.username));
}
}
let password = if let Some(password_file) = args.password_file { let password = if let Some(password_file) = args.password_file {
std::fs::read_to_string(password_file) std::fs::read_to_string(password_file)
.context("Failed to read password file")? .context("Failed to read password file")?

View File

@ -76,6 +76,7 @@ impl OwnerValidationError {
indoc! {r#" indoc! {r#"
Invalid {} name prefix: '{}' does not match your username or any of your groups. Invalid {} name prefix: '{}' does not match your username or any of your groups.
Are you sure you are allowed to create {} names with this prefix? Are you sure you are allowed to create {} names with this prefix?
The format should be: <prefix>_<{} name>
Allowed prefixes: Allowed prefixes:
- {} - {}
@ -84,6 +85,7 @@ impl OwnerValidationError {
db_or_user.lowercased(), db_or_user.lowercased(),
name, name,
db_or_user.lowercased(), db_or_user.lowercased(),
db_or_user.lowercased(),
user.as_ref() user.as_ref()
.map(|u| u.username.clone()) .map(|u| u.username.clone())
.unwrap_or("???".to_string()), .unwrap_or("???".to_string()),

View File

@ -1,7 +1,8 @@
#[macro_use] #[macro_use]
extern crate prettytable; extern crate prettytable;
use clap::Parser; use clap::{CommandFactory, Parser, ValueEnum};
use clap_complete::{generate, Shell};
use std::path::PathBuf; use std::path::PathBuf;
@ -27,7 +28,14 @@ mod core;
#[cfg(feature = "tui")] #[cfg(feature = "tui")]
mod tui; mod tui;
/// 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)] #[derive(Parser, Debug)]
#[command(bin_name = "mysqladm", version, about, disable_help_subcommand = true)]
struct Args { struct Args {
#[command(subcommand)] #[command(subcommand)]
command: Command, command: Command,
@ -57,14 +65,7 @@ struct Args {
interactive: bool, interactive: bool,
} }
// 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, Clone)] #[derive(Parser, Debug, Clone)]
#[command(version, about, disable_help_subcommand = true)]
enum Command { enum Command {
#[command(flatten)] #[command(flatten)]
Db(cli::database_command::DatabaseCommand), Db(cli::database_command::DatabaseCommand),
@ -74,6 +75,26 @@ enum Command {
#[command(hide = true)] #[command(hide = true)]
Server(server::command::ServerArgs), Server(server::command::ServerArgs),
#[command(hide = true)]
GenerateCompletions(GenerateCompletionArgs),
}
#[derive(Parser, Debug, Clone)]
struct GenerateCompletionArgs {
#[arg(long, default_value = "bash")]
shell: Shell,
#[arg(long, default_value = "mysqladm")]
command: ToplevelCommands,
}
#[cfg(feature = "mysql-admutils-compatibility")]
#[derive(ValueEnum, Debug, Clone)]
enum ToplevelCommands {
Mysqladm,
MysqlDbadm,
MysqlUseradm,
} }
// TODO: tag all functions that are run with elevated privileges with // TODO: tag all functions that are run with elevated privileges with
@ -86,28 +107,18 @@ fn main() -> anyhow::Result<()> {
env_logger::init(); env_logger::init();
#[cfg(feature = "mysql-admutils-compatibility")] #[cfg(feature = "mysql-admutils-compatibility")]
{ if let Some(_) = handle_mysql_admutils_command()? {
let argv0 = std::env::args().next().and_then(|s| { return Ok(());
PathBuf::from(s)
.file_name()
.map(|s| s.to_string_lossy().to_string())
});
match argv0.as_deref() {
Some("mysql-dbadm") => return mysql_dbadm::main(),
Some("mysql-useradm") => return mysql_useradm::main(),
_ => { /* fall through */ }
}
} }
let args: Args = Args::parse(); let args: Args = Args::parse();
match args.command {
Command::Server(ref command) => { if let Some(_) = handle_server_command(&args)? {
drop_privs()?;
tokio_start_server(args.server_socket_path, args.config, command.clone())?;
return Ok(()); return Ok(());
} }
_ => { /* fall through */ }
if let Some(_) = handle_generate_completions_command(&args)? {
return Ok(());
} }
let server_connection = let server_connection =
@ -118,6 +129,61 @@ fn main() -> anyhow::Result<()> {
Ok(()) Ok(())
} }
fn handle_mysql_admutils_command() -> anyhow::Result<Option<()>> {
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(|result| Some(result)),
Some("mysql-useradm") => mysql_useradm::main().map(|result| Some(result)),
_ => Ok(None),
}
}
fn handle_server_command(args: &Args) -> anyhow::Result<Option<()>> {
match args.command {
Command::Server(ref command) => {
drop_privs()?;
tokio_start_server(
args.server_socket_path.clone(),
args.config.clone(),
command.clone(),
)?;
Ok(Some(()))
}
_ => Ok(None),
}
}
fn handle_generate_completions_command(args: &Args) -> anyhow::Result<Option<()>> {
match args.command {
Command::GenerateCompletions(ref completion_args) => {
let mut cmd = match completion_args.command {
ToplevelCommands::Mysqladm => Args::command(),
#[cfg(feature = "mysql-admutils-compatibility")]
ToplevelCommands::MysqlDbadm => mysql_dbadm::Args::command(),
#[cfg(feature = "mysql-admutils-compatibility")]
ToplevelCommands::MysqlUseradm => mysql_useradm::Args::command(),
};
let binary_name = cmd.get_bin_name().unwrap().to_owned();
generate(
completion_args.shell,
&mut cmd,
binary_name,
&mut std::io::stdout(),
);
Ok(Some(()))
}
_ => Ok(None),
}
}
fn tokio_start_server( fn tokio_start_server(
server_socket_path: Option<PathBuf>, server_socket_path: Option<PathBuf>,
config_path: Option<PathBuf>, config_path: Option<PathBuf>,
@ -148,6 +214,7 @@ fn tokio_run_command(command: Command, server_connection: StdUnixStream) -> anyh
cli::database_command::handle_command(db_args, message_stream).await cli::database_command::handle_command(db_args, message_stream).await
} }
Command::Server(_) => unreachable!(), Command::Server(_) => unreachable!(),
Command::GenerateCompletions(_) => unreachable!(),
} }
}) })
} }

View File

@ -45,7 +45,7 @@ pub fn validate_ownership_by_prefixes(
if prefixes if prefixes
.iter() .iter()
.filter(|p| name.starts_with(*p)) .filter(|p| name.starts_with(&(p.to_string() + "_")))
.collect::<Vec<_>>() .collect::<Vec<_>>()
.is_empty() .is_empty()
{ {