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 DIRTY_SUFFIX: &str = match env!("GIT_DIRTY").as_bytes() {
44
        b"true" => " (dirty)",
45
        _ => "",
46
    };
47

            
48
    const_format::concatcp!(
49
        crate_version!(),
50
        "\n",
51
        "build profile: ",
52
        env!("BUILD_PROFILE"),
53
        "\n",
54
        "commit: ",
55
        env!("GIT_COMMIT"),
56
        DIRTY_SUFFIX,
57
        "\n",
58
        "commit date: ",
59
        env!("GIT_COMMIT_DATE"),
60
        "\n\n",
61
        "[features]\n",
62
        feature!("SUID/SGID mode", "suid-sgid-mode"),
63
        "\n",
64
        feature!(
65
            "mysql-admutils compatibility",
66
            "mysql-admutils-compatibility"
67
        ),
68
        "\n",
69
        "\n",
70
        "[dependencies]\n",
71
        const_format::str_replace!(env!("DEPENDENCY_LIST"), ";", "\n")
72
    )
73
}
74

            
75
const LONG_VERSION: &str = long_version();
76

            
77
const EXAMPLES: &str = const_format::concatcp!(
78
    color_print::cstr!("<bold><underline>Examples:</underline></bold>"),
79
    r#"
80
  # Display help information for any specific command
81
  muscl <command> --help
82

            
83
  # Create two users 'alice_user1' and 'alice_user2'
84
  muscl create-user alice_user1 alice_user2
85

            
86
  # Create two databases 'alice_db1' and 'alice_db2'
87
  muscl create-db alice_db1 alice_db2
88

            
89
  # Grant Select, Update, Insert and Delete privileges on 'alice_db1' to 'alice_user1'
90
  muscl edit-privs alice_db1 alice_user1 +suid
91

            
92
  # Show all databases
93
  muscl show-db
94
  muscl sd
95

            
96
  # Show which users have privileges on which databases
97
  muscl show-privs
98
  muscl sp
99
"#,
100
);
101

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

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

            
129
    // NOTE: be careful not to add short options that collide with the `edit-privs` privilege
130
    //       characters. It should in theory be possible for `edit-privs` to ignore any options
131
    //       specified here, but in practice clap is being difficult to work with.
132
    /// Path to the socket of the server.
133
    #[arg(
134
        long = "server-socket",
135
        value_name = "PATH",
136
        value_hint = clap::ValueHint::FilePath,
137
        global = true,
138
        hide_short_help = true
139
    )]
140
    server_socket_path: Option<PathBuf>,
141

            
142
    /// Config file to use for the server.
143
    ///
144
    /// This is only useful when running in SUID/SGID mode.
145
    #[cfg(feature = "suid-sgid-mode")]
146
    #[arg(
147
        long = "config",
148
        value_name = "PATH",
149
        value_hint = clap::ValueHint::FilePath,
150
        global = true,
151
        hide_short_help = true
152
    )]
153
    config_path: Option<PathBuf>,
154

            
155
    #[command(flatten)]
156
    verbose: Verbosity<InfoLevel>,
157
}
158

            
159
const EDIT_PRIVS_EXAMPLES: &str = color_print::cstr!(
160
    r#"
161
<bold><underline>Examples:</underline></bold>
162
  # Open interactive editor to edit privileges
163
  muscl edit-privs
164

            
165
  # Set privileges `SELECT`, `INSERT`, and `UPDATE` for user `my_user` on database `my_db`
166
  muscl edit-privs my_db my_user siu
167

            
168
  # Set all privileges for user `my_other_user` on database `my_other_db`
169
  muscl edit-privs my_other_db my_other_user A
170

            
171
  # Add the `DELETE` privilege for user `my_user` on database `my_db`
172
  muscl edit-privs my_db my_user +d
173

            
174
  # Set miscellaneous privileges for multiple users on database `my_db`
175
  muscl edit-privs -p my_db:my_user:siu -p my_db:my_other_user:+ct -p my_db:yet_another_user:-d
176
"#
177
);
178

            
179
#[derive(Subcommand, Debug, Clone)]
180
#[command(subcommand_required = true)]
181
pub enum ClientCommand {
182
    /// Check whether you are authorized to manage the specified databases or users.
183
    #[command(alias = "ca")]
184
    CheckAuth(CheckAuthArgs),
185

            
186
    /// Create one or more databases
187
    #[command(alias = "cd")]
188
    CreateDb(CreateDbArgs),
189

            
190
    /// Delete one or more databases
191
    #[command(alias = "dd")]
192
    DropDb(DropDbArgs),
193

            
194
    /// Print information about one or more databases
195
    ///
196
    /// If no database name is provided, all databases you have access will be shown.
197
    #[command(alias = "sd")]
198
    ShowDb(ShowDbArgs),
199

            
200
    /// Print user privileges for one or more databases
201
    ///
202
    /// If no database names are provided, all databases you have access to will be shown.
203
    #[command(alias = "sp")]
204
    ShowPrivs(ShowPrivsArgs),
205

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

            
265
    /// Create one or more users
266
    #[command(alias = "cu")]
267
    CreateUser(CreateUserArgs),
268

            
269
    /// Delete one or more users
270
    #[command(alias = "du")]
271
    DropUser(DropUserArgs),
272

            
273
    /// Change the MySQL password for a user
274
    #[command(alias = "pu")]
275
    PasswdUser(PasswdUserArgs),
276

            
277
    /// Print information about one or more users
278
    ///
279
    /// If no username is provided, all users you have access will be shown.
280
    #[command(alias = "su")]
281
    ShowUser(ShowUserArgs),
282

            
283
    /// Lock account for one or more users
284
    #[command(alias = "lu")]
285
    LockUser(LockUserArgs),
286

            
287
    /// Unlock account for one or more users
288
    #[command(alias = "uu")]
289
    UnlockUser(UnlockUserArgs),
290
}
291

            
292
pub async fn handle_command(
293
    command: ClientCommand,
294
    server_connection: ClientToServerMessageStream,
295
) -> anyhow::Result<()> {
296
    match command {
297
        ClientCommand::CheckAuth(args) => check_authorization(args, server_connection).await,
298
        ClientCommand::CreateDb(args) => create_databases(args, server_connection).await,
299
        ClientCommand::DropDb(args) => drop_databases(args, server_connection).await,
300
        ClientCommand::ShowDb(args) => show_databases(args, server_connection).await,
301
        ClientCommand::ShowPrivs(args) => show_database_privileges(args, server_connection).await,
302
        ClientCommand::EditPrivs(args) => {
303
            edit_database_privileges(args, None, server_connection).await
304
        }
305
        ClientCommand::CreateUser(args) => create_users(args, server_connection).await,
306
        ClientCommand::DropUser(args) => drop_users(args, server_connection).await,
307
        ClientCommand::PasswdUser(args) => passwd_user(args, server_connection).await,
308
        ClientCommand::ShowUser(args) => show_users(args, server_connection).await,
309
        ClientCommand::LockUser(args) => lock_users(args, server_connection).await,
310
        ClientCommand::UnlockUser(args) => unlock_users(args, server_connection).await,
311
    }
312
}
313

            
314
/// **WARNING:** This function may be run with elevated privileges.
315
fn main() -> anyhow::Result<()> {
316
    if handle_dynamic_completion()?.is_some() {
317
        return Ok(());
318
    }
319

            
320
    #[cfg(feature = "mysql-admutils-compatibility")]
321
    if handle_mysql_admutils_command()?.is_some() {
322
        return Ok(());
323
    }
324

            
325
    let args: Args = Args::parse();
326

            
327
    let connection = bootstrap_server_connection_and_drop_privileges(
328
        args.server_socket_path,
329
        #[cfg(feature = "suid-sgid-mode")]
330
        args.config_path,
331
        #[cfg(not(feature = "suid-sgid-mode"))]
332
        None,
333
        args.verbose,
334
    )
335
    .context("Failed to connect to the server")?;
336

            
337
    tokio_run_command(args.command, connection)?;
338

            
339
    Ok(())
340
}
341

            
342
/// **WARNING:** This function may be run with elevated privileges.
343
fn handle_dynamic_completion() -> anyhow::Result<Option<()>> {
344
    if std::env::var_os("COMPLETE").is_some() {
345
        #[cfg(feature = "suid-sgid-mode")]
346
        if executing_in_suid_sgid_mode()? {
347
            use muscl_lib::core::bootstrap::drop_privs;
348
            drop_privs()?
349
        }
350

            
351
        let argv0 = std::env::args()
352
            .next()
353
            .and_then(|s| {
354
                PathBuf::from(s)
355
                    .file_name()
356
                    .map(|s| s.to_string_lossy().to_string())
357
            })
358
            .ok_or(anyhow::anyhow!(
359
                "Could not determine executable name for completion"
360
            ))?;
361

            
362
        let command = match argv0.as_str() {
363
            "muscl" => Args::command(),
364
            "mysql-dbadm" => mysql_dbadm::Args::command(),
365
            "mysql-useradm" => mysql_useradm::Args::command(),
366
            command => anyhow::bail!("Unknown executable name: `{}`", command),
367
        };
368

            
369
        CompleteEnv::with_factory(move || command.clone()).complete();
370

            
371
        Ok(Some(()))
372
    } else {
373
        Ok(None)
374
    }
375
}
376

            
377
/// **WARNING:** This function may be run with elevated privileges.
378
fn handle_mysql_admutils_command() -> anyhow::Result<Option<()>> {
379
    let argv0 = std::env::args().next().and_then(|s| {
380
        PathBuf::from(s)
381
            .file_name()
382
            .map(|s| s.to_string_lossy().to_string())
383
    });
384

            
385
    match argv0.as_deref() {
386
        Some("mysql-dbadm") => mysql_dbadm::main().map(Some),
387
        Some("mysql-useradm") => mysql_useradm::main().map(Some),
388
        _ => Ok(None),
389
    }
390
}
391

            
392
/// Run the given command (from the client side) using Tokio.
393
fn tokio_run_command(
394
    command: ClientCommand,
395
    server_connection: StdUnixStream,
396
) -> anyhow::Result<()> {
397
    tokio::runtime::Builder::new_current_thread()
398
        .enable_all()
399
        .build()
400
        .context("Failed to start Tokio runtime")?
401
        .block_on(async {
402
            let tokio_socket = TokioUnixStream::from_std(server_connection)?;
403
            let mut message_stream = create_client_to_server_message_stream(tokio_socket);
404

            
405
            while let Some(Ok(message)) = message_stream.next().await {
406
                match message {
407
                    Response::Error(err) => {
408
                        anyhow::bail!("{}", err);
409
                    }
410
                    Response::Ready => break,
411
                    message => {
412
                        eprintln!("Unexpected message from server: {:?}", message);
413
                    }
414
                }
415
            }
416

            
417
            handle_command(command, message_stream).await
418
        })
419
}