1
use std::os::unix::net::UnixStream as StdUnixStream;
2
use std::path::PathBuf;
3

            
4
use anyhow::Context;
5
use clap::{CommandFactory, Parser, Subcommand, crate_version};
6
use clap_complete::CompleteEnv;
7
use clap_verbosity_flag::{InfoLevel, Verbosity};
8
use tokio::net::UnixStream as TokioUnixStream;
9
use tokio_stream::StreamExt;
10

            
11
use muscl_lib::{
12
    client::{
13
        commands::{
14
            CheckAuthArgs, CreateDbArgs, CreateUserArgs, DropDbArgs, DropUserArgs, EditPrivsArgs,
15
            LockUserArgs, PasswdUserArgs, ShowDbArgs, ShowPrivsArgs, ShowUserArgs, UnlockUserArgs,
16
            check_authorization, create_databases, create_users, drop_databases, drop_users,
17
            edit_database_privileges, lock_users, passwd_user, show_database_privileges,
18
            show_databases, show_users, unlock_users,
19
        },
20
        mysql_admutils_compatibility::{mysql_dbadm, mysql_useradm},
21
    },
22
    core::{
23
        bootstrap::bootstrap_server_connection_and_drop_privileges,
24
        common::{ASCII_BANNER, KIND_REGARDS},
25
        protocol::{ClientToServerMessageStream, Response, create_client_to_server_message_stream},
26
    },
27
};
28

            
29
#[cfg(feature = "suid-sgid-mode")]
30
use muscl_lib::core::common::executing_in_suid_sgid_mode;
31

            
32
const fn long_version() -> &'static str {
33
    macro_rules! feature {
34
        ($title:expr, $flag:expr) => {
35
            if cfg!(feature = $flag) {
36
                concat!($title, ": enabled")
37
            } else {
38
                concat!($title, ": disabled")
39
            }
40
        };
41
    }
42

            
43
    const_format::concatcp!(
44
        crate_version!(),
45
        "\n",
46
        "build profile: ",
47
        env!("BUILD_PROFILE"),
48
        "\n",
49
        "commit: ",
50
        env!("GIT_COMMIT"),
51
        "\n\n",
52
        "[features]\n",
53
        feature!("SUID/SGID mode", "suid-sgid-mode"),
54
        "\n",
55
        feature!(
56
            "mysql-admutils compatibility",
57
            "mysql-admutils-compatibility"
58
        ),
59
        "\n",
60
        "\n",
61
        "[dependencies]\n",
62
        const_format::str_replace!(env!("DEPENDENCY_LIST"), ";", "\n")
63
    )
64
}
65

            
66
const LONG_VERSION: &str = long_version();
67

            
68
const EXAMPLES: &str = const_format::concatcp!(
69
    color_print::cstr!("<bold><underline>Examples:</underline></bold>"),
70
    r#"
71
  # Display help information for any specific command
72
  muscl <command> --help
73

            
74
  # Create two users 'alice_user1' and 'alice_user2'
75
  muscl create-user alice_user1 alice_user2
76

            
77
  # Create two databases 'alice_db1' and 'alice_db2'
78
  muscl create-db alice_db1 alice_db2
79

            
80
  # Grant Select, Update, Insert and Delete privileges on 'alice_db1' to 'alice_user1'
81
  muscl edit-privs alice_db1 alice_user1 +suid
82

            
83
  # Show all databases
84
  muscl show-db
85
  muscl sd
86

            
87
  # Show which users have privileges on which databases
88
  muscl show-privs
89
  muscl sp
90
"#,
91
);
92

            
93
const BEFORE_LONG_HELP: &str = const_format::concatcp!("\x1b[1m", ASCII_BANNER, "\x1b[0m");
94
const AFTER_LONG_HELP: &str = const_format::concatcp!(EXAMPLES, "\n", KIND_REGARDS,);
95

            
96
/// Database administration tool for non-admin users to manage their own MySQL databases and users.
97
///
98
/// This tool allows you to manage users and databases in MySQL.
99
///
100
/// You are only allowed to manage databases and users that are prefixed with
101
/// either your username, or a group that you are a member of.
102
#[derive(Parser, Debug)]
103
#[command(
104
  bin_name = "muscl",
105
  author = "Programvareverkstedet <projects@pvv.ntnu.no>",
106
  version,
107
  about,
108
  disable_help_subcommand = true,
109
  propagate_version = true,
110
  before_long_help = BEFORE_LONG_HELP,
111
  after_long_help = AFTER_LONG_HELP,
112
  long_version = LONG_VERSION,
113
  // NOTE: All non-registered "subcommands" are processed before Arg::parse() is called.
114
  subcommand_required = true,
115
)]
116
struct Args {
117
    #[command(subcommand)]
118
    command: ClientCommand,
119

            
120
    // NOTE: be careful not to add short options that collide with the `edit-privs` privilege
121
    //       characters. It should in theory be possible for `edit-privs` to ignore any options
122
    //       specified here, but in practice clap is being difficult to work with.
123
    /// Path to the socket of the server.
124
    #[arg(
125
        long = "server-socket",
126
        value_name = "PATH",
127
        value_hint = clap::ValueHint::FilePath,
128
        global = true,
129
        hide_short_help = true
130
    )]
131
    server_socket_path: Option<PathBuf>,
132

            
133
    /// Config file to use for the server.
134
    ///
135
    /// This is only useful when running in SUID/SGID mode.
136
    #[cfg(feature = "suid-sgid-mode")]
137
    #[arg(
138
        long = "config",
139
        value_name = "PATH",
140
        value_hint = clap::ValueHint::FilePath,
141
        global = true,
142
        hide_short_help = true
143
    )]
144
    config_path: Option<PathBuf>,
145

            
146
    #[command(flatten)]
147
    verbose: Verbosity<InfoLevel>,
148
}
149

            
150
const EDIT_PRIVS_EXAMPLES: &str = color_print::cstr!(
151
    r#"
152
<bold><underline>Examples:</underline></bold>
153
  # Open interactive editor to edit privileges
154
  muscl edit-privs
155

            
156
  # Set privileges `SELECT`, `INSERT`, and `UPDATE` for user `my_user` on database `my_db`
157
  muscl edit-privs my_db my_user siu
158

            
159
  # Set all privileges for user `my_other_user` on database `my_other_db`
160
  muscl edit-privs my_other_db my_other_user A
161

            
162
  # Add the `DELETE` privilege for user `my_user` on database `my_db`
163
  muscl edit-privs my_db my_user +d
164

            
165
  # Set miscellaneous privileges for multiple users on database `my_db`
166
  muscl edit-privs -p my_db:my_user:siu -p my_db:my_other_user:+ct -p my_db:yet_another_user:-d
167
"#
168
);
169

            
170
#[derive(Subcommand, Debug, Clone)]
171
#[command(subcommand_required = true)]
172
pub enum ClientCommand {
173
    /// Check whether you are authorized to manage the specified databases or users.
174
    #[command(alias = "ca")]
175
    CheckAuth(CheckAuthArgs),
176

            
177
    /// Create one or more databases
178
    #[command(alias = "cd")]
179
    CreateDb(CreateDbArgs),
180

            
181
    /// Delete one or more databases
182
    #[command(alias = "dd")]
183
    DropDb(DropDbArgs),
184

            
185
    /// Print information about one or more databases
186
    ///
187
    /// If no database name is provided, all databases you have access will be shown.
188
    #[command(alias = "sd")]
189
    ShowDb(ShowDbArgs),
190

            
191
    /// Print user privileges for one or more databases
192
    ///
193
    /// If no database names are provided, all databases you have access to will be shown.
194
    #[command(alias = "sp")]
195
    ShowPrivs(ShowPrivsArgs),
196

            
197
    /// Change user privileges for one or more databases. See `edit-privs --help` for details.
198
    ///
199
    /// This command has three modes of operation:
200
    ///
201
    /// 1. Interactive mode:
202
    ///
203
    ///    If no arguments are provided, the user will be prompted to edit the privileges using a text editor.
204
    ///
205
    ///    You can configure your preferred text editor by setting the `VISUAL` or `EDITOR` environment variables.
206
    ///
207
    ///    Follow the instructions inside the editor for more information.
208
    ///
209
    /// 2. Non-interactive human-friendly mode:
210
    ///
211
    ///    You can provide the command with three positional arguments:
212
    ///
213
    ///    - `<DB_NAME>`: The name of the database for which you want to edit privileges.
214
    ///    - `<USER_NAME>`: The name of the user whose privileges you want to edit.
215
    ///    - `<[+-]PRIVILEGES>`: A string representing the privileges to set for the user.
216
    ///
217
    ///    The `<[+-]PRIVILEGES>` argument is a string of characters, each representing a single privilege.
218
    ///    The character `A` is an exception - it represents all privileges.
219
    ///    The optional leading character can be either `+` to grant additional privileges or `-` to revoke privileges.
220
    ///    If omitted, the privileges will be set exactly as specified, removing any privileges not listed, and adding any that are.
221
    ///
222
    ///    The character-to-privilege mapping is defined as follows:
223
    ///
224
    ///    - `s` - SELECT
225
    ///    - `i` - INSERT
226
    ///    - `u` - UPDATE
227
    ///    - `d` - DELETE
228
    ///    - `c` - CREATE
229
    ///    - `D` - DROP
230
    ///    - `a` - ALTER
231
    ///    - `I` - INDEX
232
    ///    - `t` - CREATE TEMPORARY TABLES
233
    ///    - `l` - LOCK TABLES
234
    ///    - `r` - REFERENCES
235
    ///    - `A` - ALL PRIVILEGES
236
    ///
237
    /// 3. Non-interactive batch mode:
238
    ///
239
    ///    By using the `-p` flag, you can provide multiple privilege edits in a single command.
240
    ///
241
    ///    The flag value should be formatted as `DB_NAME:USER_NAME:[+-]PRIVILEGES`
242
    ///    where the privileges are a string of characters, each representing a single privilege.
243
    ///    (See the character-to-privilege mapping above.)
244
    ///
245
    #[command(
246
        verbatim_doc_comment,
247
        override_usage = "muscl edit-privs [OPTIONS] [ -p <DB_NAME:USER_NAME:[+-]PRIVILEGES>... | <DB_NAME> <USER_NAME> <[+-]PRIVILEGES> ]",
248
        after_long_help = EDIT_PRIVS_EXAMPLES,
249
        alias = "ep",
250
    )]
251
    EditPrivs(EditPrivsArgs),
252

            
253
    /// Create one or more users
254
    #[command(alias = "cu")]
255
    CreateUser(CreateUserArgs),
256

            
257
    /// Delete one or more users
258
    #[command(alias = "du")]
259
    DropUser(DropUserArgs),
260

            
261
    /// Change the MySQL password for a user
262
    #[command(alias = "pu")]
263
    PasswdUser(PasswdUserArgs),
264

            
265
    /// Print information about one or more users
266
    ///
267
    /// If no username is provided, all users you have access will be shown.
268
    #[command(alias = "su")]
269
    ShowUser(ShowUserArgs),
270

            
271
    /// Lock account for one or more users
272
    #[command(alias = "lu")]
273
    LockUser(LockUserArgs),
274

            
275
    /// Unlock account for one or more users
276
    #[command(alias = "uu")]
277
    UnlockUser(UnlockUserArgs),
278
}
279

            
280
pub async fn handle_command(
281
    command: ClientCommand,
282
    server_connection: ClientToServerMessageStream,
283
) -> anyhow::Result<()> {
284
    match command {
285
        ClientCommand::CheckAuth(args) => check_authorization(args, server_connection).await,
286
        ClientCommand::CreateDb(args) => create_databases(args, server_connection).await,
287
        ClientCommand::DropDb(args) => drop_databases(args, server_connection).await,
288
        ClientCommand::ShowDb(args) => show_databases(args, server_connection).await,
289
        ClientCommand::ShowPrivs(args) => show_database_privileges(args, server_connection).await,
290
        ClientCommand::EditPrivs(args) => {
291
            edit_database_privileges(args, None, server_connection).await
292
        }
293
        ClientCommand::CreateUser(args) => create_users(args, server_connection).await,
294
        ClientCommand::DropUser(args) => drop_users(args, server_connection).await,
295
        ClientCommand::PasswdUser(args) => passwd_user(args, server_connection).await,
296
        ClientCommand::ShowUser(args) => show_users(args, server_connection).await,
297
        ClientCommand::LockUser(args) => lock_users(args, server_connection).await,
298
        ClientCommand::UnlockUser(args) => unlock_users(args, server_connection).await,
299
    }
300
}
301

            
302
/// **WARNING:** This function may be run with elevated privileges.
303
fn main() -> anyhow::Result<()> {
304
    if handle_dynamic_completion()?.is_some() {
305
        return Ok(());
306
    }
307

            
308
    #[cfg(feature = "mysql-admutils-compatibility")]
309
    if handle_mysql_admutils_command()?.is_some() {
310
        return Ok(());
311
    }
312

            
313
    let args: Args = Args::parse();
314

            
315
    let connection = bootstrap_server_connection_and_drop_privileges(
316
        args.server_socket_path,
317
        #[cfg(feature = "suid-sgid-mode")]
318
        args.config_path,
319
        #[cfg(not(feature = "suid-sgid-mode"))]
320
        None,
321
        args.verbose,
322
    )
323
    .context("Failed to connect to the server")?;
324

            
325
    tokio_run_command(args.command, connection)?;
326

            
327
    Ok(())
328
}
329

            
330
/// **WARNING:** This function may be run with elevated privileges.
331
fn handle_dynamic_completion() -> anyhow::Result<Option<()>> {
332
    if std::env::var_os("COMPLETE").is_some() {
333
        #[cfg(feature = "suid-sgid-mode")]
334
        if executing_in_suid_sgid_mode()? {
335
            use muscl_lib::core::bootstrap::drop_privs;
336
            drop_privs()?
337
        }
338

            
339
        let argv0 = std::env::args()
340
            .next()
341
            .and_then(|s| {
342
                PathBuf::from(s)
343
                    .file_name()
344
                    .map(|s| s.to_string_lossy().to_string())
345
            })
346
            .ok_or(anyhow::anyhow!(
347
                "Could not determine executable name for completion"
348
            ))?;
349

            
350
        let command = match argv0.as_str() {
351
            "muscl" => Args::command(),
352
            "mysql-dbadm" => mysql_dbadm::Args::command(),
353
            "mysql-useradm" => mysql_useradm::Args::command(),
354
            command => anyhow::bail!("Unknown executable name: `{}`", command),
355
        };
356

            
357
        CompleteEnv::with_factory(move || command.clone()).complete();
358

            
359
        Ok(Some(()))
360
    } else {
361
        Ok(None)
362
    }
363
}
364

            
365
/// **WARNING:** This function may be run with elevated privileges.
366
fn handle_mysql_admutils_command() -> anyhow::Result<Option<()>> {
367
    let argv0 = std::env::args().next().and_then(|s| {
368
        PathBuf::from(s)
369
            .file_name()
370
            .map(|s| s.to_string_lossy().to_string())
371
    });
372

            
373
    match argv0.as_deref() {
374
        Some("mysql-dbadm") => mysql_dbadm::main().map(Some),
375
        Some("mysql-useradm") => mysql_useradm::main().map(Some),
376
        _ => Ok(None),
377
    }
378
}
379

            
380
/// Run the given command (from the client side) using Tokio.
381
fn tokio_run_command(
382
    command: ClientCommand,
383
    server_connection: StdUnixStream,
384
) -> anyhow::Result<()> {
385
    tokio::runtime::Builder::new_current_thread()
386
        .enable_all()
387
        .build()
388
        .context("Failed to start Tokio runtime")?
389
        .block_on(async {
390
            let tokio_socket = TokioUnixStream::from_std(server_connection)?;
391
            let mut message_stream = create_client_to_server_message_stream(tokio_socket);
392

            
393
            while let Some(Ok(message)) = message_stream.next().await {
394
                match message {
395
                    Response::Error(err) => {
396
                        anyhow::bail!("{}", err);
397
                    }
398
                    Response::Ready => break,
399
                    message => {
400
                        eprintln!("Unexpected message from server: {:?}", message);
401
                    }
402
                }
403
            }
404

            
405
            handle_command(command, message_stream).await
406
        })
407
}