Compare commits
26 Commits
password-c
...
v1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
b2d9400f0e
|
|||
|
2838c584d3
|
|||
|
ce75aa509d
|
|||
|
87ef63b680
|
|||
|
6686b3bbe7
|
|||
|
f75b34f40c
|
|||
|
902970f271
|
|||
|
a141e97beb
|
|||
|
4f1030f1d8
|
|||
|
206f459d79
|
|||
|
09e7a22f24
|
|||
|
ef42272087
|
|||
|
0baa58a820
|
|||
|
b2d56e1c85
|
|||
|
ee33c96120
|
|||
|
94996038c2
|
|||
|
beb08e1b35
|
|||
|
6a3212bde2
|
|||
|
3ce2a13711
|
|||
|
fbe594d486
|
|||
|
2ec31cd146
|
|||
|
6e648004b5
|
|||
|
cb4b8a78dc
|
|||
|
b9f11d0413
|
|||
|
9f45c2e5da
|
|||
|
107333208c
|
72
Cargo.lock
generated
72
Cargo.lock
generated
@@ -285,18 +285,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2"
|
||||
dependencies = [
|
||||
"iana-time-zone",
|
||||
"js-sys",
|
||||
"num-traits",
|
||||
"serde",
|
||||
"wasm-bindgen",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.5.53"
|
||||
version = "4.5.54"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8"
|
||||
checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
"clap_derive",
|
||||
@@ -315,9 +313,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "clap_builder"
|
||||
version = "4.5.53"
|
||||
version = "4.5.54"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00"
|
||||
checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
@@ -327,9 +325,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "clap_complete"
|
||||
version = "4.5.62"
|
||||
version = "4.5.65"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "004eef6b14ce34759aa7de4aea3217e368f463f46a3ed3764ca4b5a4404003b4"
|
||||
checksum = "430b4dc2b5e3861848de79627b2bedc9f3342c7da5173a14eaa5d0f8dc18ae5d"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"clap_lex",
|
||||
@@ -1104,9 +1102,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.12.1"
|
||||
version = "2.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2"
|
||||
checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.16.1",
|
||||
@@ -1158,9 +1156,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.16"
|
||||
version = "1.0.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ee5b5339afb4c41626dde77b7a611bd4f2c202b897852b4bcf5d03eddc61010"
|
||||
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
|
||||
|
||||
[[package]]
|
||||
name = "jobserver"
|
||||
@@ -1204,9 +1202,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.178"
|
||||
version = "0.2.180"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091"
|
||||
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
|
||||
|
||||
[[package]]
|
||||
name = "libgit2-sys"
|
||||
@@ -1336,7 +1334,6 @@ dependencies = [
|
||||
"async-bincode",
|
||||
"bincode 2.0.1",
|
||||
"build-info-build",
|
||||
"chrono",
|
||||
"clap",
|
||||
"clap-verbosity-flag",
|
||||
"clap_complete",
|
||||
@@ -1619,18 +1616,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.103"
|
||||
version = "1.0.105"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8"
|
||||
checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.42"
|
||||
version = "1.0.43"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f"
|
||||
checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
@@ -1917,16 +1914,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.146"
|
||||
version = "1.0.149"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "217ca874ae0207aac254aa02c957ded05585a90892cc8d87f9e5fa49669dadd8"
|
||||
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"itoa",
|
||||
"memchr",
|
||||
"ryu",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1995,10 +1992,11 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-registry"
|
||||
version = "1.4.7"
|
||||
version = "1.4.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7664a098b8e616bdfcc2dc0e9ac44eb231eedf41db4e9fe95d8d32ec728dedad"
|
||||
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
|
||||
dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
]
|
||||
|
||||
@@ -2277,9 +2275,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.111"
|
||||
version = "2.0.114"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87"
|
||||
checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -2397,9 +2395,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.48.0"
|
||||
version = "1.49.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408"
|
||||
checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
@@ -2439,9 +2437,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tokio-stream"
|
||||
version = "0.1.17"
|
||||
version = "0.1.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047"
|
||||
checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
@@ -2450,9 +2448,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.17"
|
||||
version = "0.7.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594"
|
||||
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
@@ -2464,9 +2462,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.9.10+spec-1.1.0"
|
||||
version = "0.9.11+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0825052159284a1a8b4d6c0c86cbc801f2da5afd2b225fa548c72f2e74002f48"
|
||||
checksum = "f3afc9a848309fe1aaffaed6e1546a7a14de1f935dc9d89d32afd9a44bab7c46"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde_core",
|
||||
@@ -3222,6 +3220,12 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "30e0d8dffbae3d840f64bda38e28391faef673a7b5a6017840f2a106c8145868"
|
||||
|
||||
[[package]]
|
||||
name = "zstd"
|
||||
version = "0.13.3"
|
||||
|
||||
24
Cargo.toml
24
Cargo.toml
@@ -1,12 +1,11 @@
|
||||
[package]
|
||||
name = "muscl"
|
||||
version = "0.1.0"
|
||||
version = "1.0.0"
|
||||
edition = "2024"
|
||||
resolver = "2"
|
||||
license = "BSD-3-Clause"
|
||||
authors = [
|
||||
"oysteikt@pvv.ntnu.no",
|
||||
"felixalb@pvv.ntnu.no",
|
||||
"Programvareverkstedet <projects@pvv.ntnu.no>",
|
||||
]
|
||||
homepage = "https://git.pvv.ntnu.no/Projects/muscl"
|
||||
repository = "https://git.pvv.ntnu.no/Projects/muscl"
|
||||
@@ -22,10 +21,9 @@ autolib = false
|
||||
anyhow = "1.0.100"
|
||||
async-bincode = "0.8.0"
|
||||
bincode = "2.0.1"
|
||||
chrono = { version = "0.4.42", features = ["serde"] }
|
||||
clap = { version = "4.5.53", features = ["cargo", "derive"] }
|
||||
clap = { version = "4.5.54", features = ["cargo", "derive"] }
|
||||
clap-verbosity-flag = { version = "3.0.4", features = [ "tracing" ] }
|
||||
clap_complete = { version = "4.5.62", features = ["unstable-dynamic"] }
|
||||
clap_complete = { version = "4.5.65", features = ["unstable-dynamic"] }
|
||||
color-print = "0.3.7"
|
||||
const_format = "0.2.35"
|
||||
derive_more = { version = "2.1.1", features = ["display", "error"] }
|
||||
@@ -39,14 +37,14 @@ num_cpus = "1.17.0"
|
||||
prettytable = "0.10.0"
|
||||
rand = "0.9.2"
|
||||
serde = "1.0.228"
|
||||
serde_json = { version = "1.0.146", features = ["preserve_order"] }
|
||||
serde_json = { version = "1.0.149", features = ["preserve_order"] }
|
||||
sqlx = { version = "0.8.6", features = ["runtime-tokio", "mysql", "tls-rustls"] }
|
||||
thiserror = "2.0.17"
|
||||
tokio = { version = "1.48.0", features = ["rt-multi-thread", "macros", "signal"] }
|
||||
tokio = { version = "1.49.0", features = ["rt-multi-thread", "macros", "signal"] }
|
||||
tokio-serde = { version = "0.9.0", features = ["bincode"] }
|
||||
tokio-stream = "0.1.17"
|
||||
tokio-util = { version = "0.7.17", features = ["codec", "rt"] }
|
||||
toml = "0.9.10"
|
||||
tokio-stream = "0.1.18"
|
||||
tokio-util = { version = "0.7.18", features = ["codec", "rt"] }
|
||||
toml = "0.9.11"
|
||||
tracing = { version = "0.1.44", features = ["log"] }
|
||||
tracing-subscriber = "0.3.22"
|
||||
uuid = { version = "1.19.0", features = ["v4"] }
|
||||
@@ -77,12 +75,12 @@ path = "src/lib.rs"
|
||||
[[bin]]
|
||||
name = "muscl"
|
||||
bench = false
|
||||
path = "src/entrypoints/muscl.rs"
|
||||
path = "src/bin/muscl.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "muscl-server"
|
||||
bench = false
|
||||
path = "src/entrypoints/muscl_server.rs"
|
||||
path = "src/bin/muscl_server.rs"
|
||||
|
||||
[profile.release-lto]
|
||||
inherits = "release"
|
||||
|
||||
14
README.md
14
README.md
@@ -7,7 +7,7 @@ Dropping DBs (dumbbells) and having MySQL spasms since 2024
|
||||
|
||||
## What is this?
|
||||
|
||||
`muscl is a secure MySQL administration tool for multi-user systems.
|
||||
`muscl` is a secure MySQL administration tool for multi-user systems.
|
||||
It allows unprivileged users to manage their own databases and database users without granting them direct access to the MySQL server.
|
||||
Authorization is handled by a prefix-based model tied to Unix users and groups, making it ideal for shared hosting environments, like university servers, tilde servers, or similar.
|
||||
|
||||
@@ -47,9 +47,19 @@ over a IPC, which then performs the requested operations on behalf of the client
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Installation and configuration](docs/installation.md)
|
||||
- [Installation and initial configuration](docs/installation.md)
|
||||
- [Administration and further configuration](docs/administration.md)
|
||||
- [Development and testing](docs/development.md)
|
||||
- [Compiling and packaging](docs/compiling.md)
|
||||
- [Compatibility mode with mysql-admutils](docs/mysql-admutils-compatibility.md)
|
||||
- [Use with NixOS](docs/nixos.md)
|
||||
- [SUID/SGID mode](docs/suid-sgid-mode.md)
|
||||
|
||||
## History
|
||||
|
||||
This is a rewrite of an older piece of software called [mysql-admutils](https://git.pvv.ntnu.no/Projects/mysql-admutils).
|
||||
Programvareverkstedet used this a lot back in the day, and it was great.
|
||||
But it had some security issues inherent to the software design, particularly related to the use of SUID/SGID.
|
||||
We tried patching it multiple times, but the issue kept popping up again in different ways.
|
||||
The rewrite was intended to iron this issue out completely by splitting the software into two pieces - a client and a server.
|
||||
As far as we know, this was successful, and it is unlikely for similar issues to resurface in the future.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# These are the default system groups on debian.
|
||||
# You can alos add groups by gid by prefixing the line with 'gid:'.
|
||||
# You can also add groups by gid by prefixing the line with 'gid:'.
|
||||
|
||||
group:_ssh
|
||||
group:adm
|
||||
group:audio
|
||||
group:avahi
|
||||
@@ -12,6 +13,7 @@ group:daemon
|
||||
group:dialout
|
||||
group:dip
|
||||
group:disk
|
||||
group:docker
|
||||
group:fax
|
||||
group:floppy
|
||||
group:games
|
||||
@@ -22,9 +24,12 @@ group:kmem
|
||||
group:kvm
|
||||
group:list
|
||||
group:lp
|
||||
group:lxd
|
||||
group:mail
|
||||
group:man
|
||||
group:messagebus
|
||||
group:mlocate
|
||||
group:mysql
|
||||
group:netdev
|
||||
group:news
|
||||
group:nogroup
|
||||
@@ -42,15 +47,18 @@ group:src
|
||||
group:staff
|
||||
group:sudo
|
||||
group:sys
|
||||
group:syslog
|
||||
group:systemd-journal
|
||||
group:systemd-network
|
||||
group:systemd-resolve
|
||||
group:systemd-timesync
|
||||
group:tape
|
||||
group:tcpdump
|
||||
group:tty
|
||||
group:users
|
||||
group:utmp
|
||||
group:uucp
|
||||
group:uuidd
|
||||
group:video
|
||||
group:voice
|
||||
group:www-data
|
||||
|
||||
@@ -8,7 +8,7 @@ Type=notify
|
||||
ExecStart=/usr/bin/muscl-server --systemd --disable-landlock socket-activate
|
||||
ExecReload=/usr/bin/kill -HUP $MAINPID
|
||||
|
||||
WatchdogSec=15
|
||||
WatchdogSec=3min
|
||||
|
||||
# Although this is a multi-instance unit, the constant `User` field is needed
|
||||
# for authentication via mysql's auth_socket plugin to work.
|
||||
@@ -61,3 +61,7 @@ SystemCallFilter=@system-service
|
||||
SystemCallFilter=~@privileged @resources
|
||||
|
||||
UMask=0777
|
||||
|
||||
[Install]
|
||||
Also=muscl.socket
|
||||
WantedBy=multi-user.target
|
||||
|
||||
@@ -3,6 +3,7 @@ Description=Muscl MySQL admin tool
|
||||
|
||||
[Socket]
|
||||
ListenStream=/run/muscl/muscl.sock
|
||||
RemoveOnStop=true
|
||||
Accept=no
|
||||
PassCredentials=true
|
||||
|
||||
|
||||
90
docs/administration.md
Normal file
90
docs/administration.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# Administration and further configuration
|
||||
|
||||
This page describes some additional configuration options and administration tasks for muscl.
|
||||
|
||||
## Configuring group denylists
|
||||
|
||||
In `/etc/muscl/muscl.conf`, you will find an option below `[authorization]` named `group_denylist_file`,
|
||||
which points to `/etc/muscl/group_denylist.txt` by default.
|
||||
|
||||
In this file, you can add unix group names or GIDs to disallow the groups from being used as prefixes.
|
||||
|
||||
The deb package comes with a default denylist that disallows some common system groups.
|
||||
|
||||
The format of the file is one group name or GID per line. Lines starting with `#` and empty lines are ignored.
|
||||
|
||||
```
|
||||
# Disallow using the 'root' group as a prefix
|
||||
gid:0
|
||||
|
||||
# Disallow using the 'adm' group as a prefix
|
||||
group:adm
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> If a user is named the same as a disallowed group, that user will still be able to use their username as a prefix.
|
||||
|
||||
## Configuring logging
|
||||
|
||||
By default, muscl logs to the systemd journal when run as a systemd service,
|
||||
and also limits the log level to `info`. You can request more verbose logging
|
||||
by appending `-v` flags to the `ExecStart=` line in the systemd service file.
|
||||
|
||||
To do this on a system where muscl was installed using a package, you can override
|
||||
the service like this:
|
||||
|
||||
```bash
|
||||
sudo systemctl edit muscl.service
|
||||
```
|
||||
|
||||
This will open an editor where you can add the following lines:
|
||||
|
||||
```ini
|
||||
[Service]
|
||||
ExecStart=
|
||||
ExecStart=/usr/bin/muscl-server -v ...
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> The first `ExecStart=` line is necessary to clear the previous value, as systemd
|
||||
> interprets multiple `ExecStart=` lines as a list of commands to run in sequence.
|
||||
|
||||
You set either `-v` or `-vv` for `debug` and `trace` logging, respectively.
|
||||
|
||||
> [!WARNING]
|
||||
> Be careful when enabling trace logging on production systems, as it might log
|
||||
> passwords and credentials in plaintext.
|
||||
|
||||
## Querying logs in the systemd journal
|
||||
|
||||
Although invisible if you just run `journalctl -u muscl.service`, muscl adds a set of so-called
|
||||
"fields" to its log entries to make it easier to filter and search them.
|
||||
|
||||
Here are some examples of how you can filter logs using `journalctl`:
|
||||
|
||||
```bash
|
||||
# Show only logs related to a specific user
|
||||
journalctl -eu muscl F_USER="<username>"
|
||||
journalctl -eu muscl F_USER=johndoe
|
||||
|
||||
# Show only logs for a specific command types
|
||||
journalctl -eu muscl F_COMMAND="<operation>"
|
||||
journalctl -eu muscl F_COMMAND=create-db
|
||||
|
||||
# Show logs emitted for a specific session id
|
||||
journalctl -eu muscl F_SESSION_ID="<session-id>"
|
||||
journalctl -eu muscl F_SESSION_ID=123
|
||||
|
||||
# Show all of these fields together with the log message in a json format
|
||||
journalctl --output json-pretty --output-fields MESSAGE,F_USER,F_COMMAND,F_SESSION_ID -eu muscl
|
||||
```
|
||||
|
||||
See [`journalctl(1)`][journalctl_1] and [`systemd.journal-fields(7)`][systemd_journal-fields_7] for more information.
|
||||
|
||||
> [!NOTE]
|
||||
> Please note that the commands are not 1-1 mapped to muscl subcommands.
|
||||
> Rather, they are the available requests in the protocol used between the muscl client and server.
|
||||
> These requests will often have the same name as the subcommands, but this is not always the case.
|
||||
|
||||
[journalctl_1]: https://man7.org/linux/man-pages/man1/journalctl.1.html
|
||||
[systemd_journal-fields_7]: https://man7.org/linux/man-pages/man7/systemd.journal-fields.7.html
|
||||
@@ -39,7 +39,12 @@ docker stop mariadb
|
||||
|
||||
## Development using Nix
|
||||
|
||||
If you have nix installed, you can easily test your changes in a NixOS vm by running:
|
||||
> [!NOTE]
|
||||
> We have created some nix code to generate a QEMU VM with a setup similar to a production deployment
|
||||
> There is not necessarily any VMs running in a production setup, and if so then at least not this VM.
|
||||
> It is mainly there for easy access to interactive testing, as well as for testing the NixOS module.
|
||||
|
||||
If you have nix installed, you can easily test your changes in a NixOS test VM by running:
|
||||
|
||||
```bash
|
||||
nix run .#vm # Start a NixOS VM in QEMU with muscl and MariaDB installed
|
||||
@@ -47,11 +52,3 @@ nix run .#vm-mysql # Start a NixOS VM in QEMU with muscl and MySQL installed
|
||||
```
|
||||
|
||||
You can configure the vm in `flake.nix`
|
||||
|
||||
## Filter logs by user with journalctl
|
||||
|
||||
If you want to filter the server logs by user, you can use journalctl's built-in filtering capabilities.
|
||||
|
||||
```bash
|
||||
journalctl -eu muscl F_USER=<username>
|
||||
```
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
# Installation and configuration
|
||||
# Installation and initial configuration
|
||||
|
||||
This document contains instructions for the recommended way of installing and configuring muscl.
|
||||
|
||||
Note that there are separate instructions for [installing on NixOS](nixos.md) and [installing with SUID/SGID mode](suid-sgid-mode.md).
|
||||
|
||||
After installation, you might want to look at the [Administration and further configuration](administration.md) page.
|
||||
|
||||
## Installing with deb on Debian
|
||||
|
||||
You can install muscl by adding the [PVV apt repository][pvv-apt-repository] and installing the package:
|
||||
@@ -103,28 +105,6 @@ If you are using systemd, you should also create an override to unset the `Impor
|
||||
ImportCredential=
|
||||
```
|
||||
|
||||
## Configuring group denylists
|
||||
|
||||
In `/etc/muscl/muscl.conf`, you will find an option below `[authorization]` named `group_denylist_file`,
|
||||
which points to `/etc/muscl/group_denylist.txt` by default.
|
||||
|
||||
In this file, you can add unix group names or GIDs to disallow the groups from being used as prefixes.
|
||||
|
||||
The deb package comes with a default denylist that disallows some common system groups.
|
||||
|
||||
The format of the file is one group name or GID per line. Lines starting with `#` and empty lines are ignored.
|
||||
|
||||
```
|
||||
# Disallow using the 'root' group as a prefix
|
||||
gid:0
|
||||
|
||||
# Disallow using the 'adm' group as a prefix
|
||||
group:adm
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> If a user is named the same as a disallowed group, that user will still be able to use their username as a prefix.
|
||||
|
||||
## A note on minimum version requirements
|
||||
|
||||
The muscl server will work with older versions of systemd, but the recommended version is 254 or newer.
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
> This will be deprecated in a future release, see https://git.pvv.ntnu.no/Projects/muscl/issues/101
|
||||
>
|
||||
> We do not recommend you use this mode unless you absolutely have to. The biggest reason why `muscl` was rewritten from scratch
|
||||
> was to fix an architectural issue that easily caused vulnerabilites due to reliance on SUID/SGID. Althought the architecture now
|
||||
> is more resistant against such vulnerabilites, it is not failsafe.
|
||||
> was to fix an architectural issue that easily caused vulnerabilities due to reliance on SUID/SGID. Although the architecture now
|
||||
> is more resistant against such vulnerabilities, it is not failsafe.
|
||||
|
||||
For backwards compatibility reasons, it is possible to run the program without a daemon by utilizing SUID/SGID.
|
||||
|
||||
|
||||
18
flake.lock
generated
18
flake.lock
generated
@@ -2,11 +2,11 @@
|
||||
"nodes": {
|
||||
"crane": {
|
||||
"locked": {
|
||||
"lastModified": 1766194365,
|
||||
"narHash": "sha256-4AFsUZ0kl6MXSm4BaQgItD0VGlEKR3iq7gIaL7TjBvc=",
|
||||
"lastModified": 1767744144,
|
||||
"narHash": "sha256-9/9ntI0D+HbN4G0TrK3KmHbTvwgswz7p8IEJsWyef8Q=",
|
||||
"owner": "ipetkov",
|
||||
"repo": "crane",
|
||||
"rev": "7d8ec2c71771937ab99790b45e6d9b93d15d9379",
|
||||
"rev": "2fb033290bf6b23f226d4c8b32f7f7a16b043d7e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -17,11 +17,11 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1766309749,
|
||||
"narHash": "sha256-3xY8CZ4rSnQ0NqGhMKAy5vgC+2IVK0NoVEzDoOh4DA4=",
|
||||
"lastModified": 1768127708,
|
||||
"narHash": "sha256-1Sm77VfZh3mU0F5OqKABNLWxOuDeHIlcFjsXeeiPazs=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "a6531044f6d0bef691ea18d4d4ce44d0daa6e816",
|
||||
"rev": "ffbc9f8cbaacfb331b6017d5a5abb21a492c9a38",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -45,11 +45,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1766457837,
|
||||
"narHash": "sha256-aeBbkQ0HPFNOIsUeEsXmZHXbYq4bG8ipT9JRlCcKHgU=",
|
||||
"lastModified": 1768186348,
|
||||
"narHash": "sha256-nkpIe3zkpeoFuOl8xBpexulECsHLQ9Ljg1gW3bPCjSI=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "2c7510a559416d07242621d036847152d970612b",
|
||||
"rev": "af69e497567a5945a64057717bc9b17c8478097e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -42,9 +42,9 @@ in
|
||||
|
||||
authorization = {
|
||||
group_denylist = lib.mkOption {
|
||||
type = with lib.types; nullOr (listOf str);
|
||||
type = with lib.types; nullOr (listOf (either str ints.unsigned));
|
||||
default = [ "wheel" ];
|
||||
description = "List of groups that are denied access";
|
||||
description = "List of groups/GIDs that can not be used as prefixes for databases/database users";
|
||||
};
|
||||
};
|
||||
|
||||
@@ -110,7 +110,32 @@ in
|
||||
];
|
||||
|
||||
environment.etc."muscl/group-denylist" = lib.mkIf (cfg.settings.authorization.group_denylist != [ ]) {
|
||||
text = lib.concatMapStringsSep "\n" (group: "group:${group}") cfg.settings.authorization.group_denylist;
|
||||
text = let
|
||||
nameToGidMapping = lib.pipe config.users.groups [
|
||||
(lib.filterAttrs (_: group: group.gid != null))
|
||||
(lib.mapAttrsToList (name: group: { name = name; value = group.gid; }))
|
||||
lib.listToAttrs
|
||||
];
|
||||
|
||||
gidToNameMapping = lib.pipe config.users.groups [
|
||||
(lib.filterAttrs (_: group: group.gid != null))
|
||||
(lib.mapAttrsToList (name: group: { name = toString group.gid; value = name; }))
|
||||
lib.listToAttrs
|
||||
];
|
||||
in lib.pipe cfg.settings.authorization.group_denylist [
|
||||
# Prefer GIDs for groups we know the GID
|
||||
(map (group: if builtins.isString group
|
||||
then (nameToGidMapping.${group} or group)
|
||||
else group))
|
||||
|
||||
# Then render back to strings
|
||||
(map (group:
|
||||
if builtins.isString group
|
||||
then "group:${group}"
|
||||
else "gid:${toString group} # ${gidToNameMapping.${toString group} or "unknown"}"))
|
||||
|
||||
(lib.concatStringsSep "\n")
|
||||
];
|
||||
};
|
||||
|
||||
services.mysql.ensureUsers = lib.mkIf cfg.createLocalDatabaseUser [
|
||||
@@ -130,15 +155,14 @@ in
|
||||
systemd.services."muscl" = {
|
||||
reloadTriggers = [ config.environment.etc."muscl/config.toml".source ];
|
||||
serviceConfig = {
|
||||
Type = "notify-reload";
|
||||
ExecStart = [
|
||||
""
|
||||
"${lib.getExe' cfg.package "muscl-server"} ${cfg.logLevel} --systemd --disable-landlock socket-activate"
|
||||
];
|
||||
|
||||
ExecReload = [
|
||||
""
|
||||
"${lib.getExe' pkgs.coreutils "kill"} -HUP $MAINPID"
|
||||
];
|
||||
ExecReload = "";
|
||||
ReloadSignal = "SIGHUP";
|
||||
|
||||
RuntimeDirectory = "muscl/root-mnt";
|
||||
RuntimeDirectoryMode = "0700";
|
||||
|
||||
@@ -54,11 +54,13 @@ for variant in debian-bookworm debian-trixie ubuntu-jammy ubuntu-noble; do
|
||||
DEB_VERSION=$(find "$TMPDIR/muscl-deb-$variant-$GIT_SHA"/*.deb -print0 | xargs -0 -n1 basename | cut -d'_' -f2 | head -n1)
|
||||
DEB_ARCH=$(find "$TMPDIR/muscl-deb-$variant-$GIT_SHA"/*.deb -print0 | xargs -0 -n1 basename | cut -d'_' -f3 | cut -d'.' -f1 | head -n1)
|
||||
|
||||
echo "[DELETE] https://git.pvv.ntnu.no/api/packages/Projects/debian/pool/$DISTRO_VERSION_NAME/main/$DEB_NAME/$DEB_VERSION/$DEB_ARCH"
|
||||
curl \
|
||||
-X DELETE \
|
||||
--user "$GITEA_USER:$GITEA_TOKEN" \
|
||||
"https://git.pvv.ntnu.no/api/packages/Projects/debian/pool/$DISTRO_VERSION_NAME/main/$DEB_NAME/$DEB_VERSION/$DEB_ARCH"
|
||||
|
||||
echo "[PUT] https://git.pvv.ntnu.no/api/packages/Projects/debian/pool/$DISTRO_VERSION_NAME/main/upload"
|
||||
curl \
|
||||
-X PUT \
|
||||
--user "$GITEA_USER:$GITEA_TOKEN" \
|
||||
|
||||
@@ -82,9 +82,11 @@ const EXAMPLES: &str = const_format::concatcp!(
|
||||
|
||||
# Show all databases
|
||||
muscl show-db
|
||||
muscl sd
|
||||
|
||||
# Show which users have privileges on which databases
|
||||
muscl show-privs
|
||||
muscl sp
|
||||
"#,
|
||||
);
|
||||
|
||||
@@ -169,22 +171,27 @@ const EDIT_PRIVS_EXAMPLES: &str = color_print::cstr!(
|
||||
#[command(subcommand_required = true)]
|
||||
pub enum ClientCommand {
|
||||
/// Check whether you are authorized to manage the specified databases or users.
|
||||
#[command(alias = "ca")]
|
||||
CheckAuth(CheckAuthArgs),
|
||||
|
||||
/// Create one or more databases
|
||||
#[command(alias = "cd")]
|
||||
CreateDb(CreateDbArgs),
|
||||
|
||||
/// Delete one or more databases
|
||||
#[command(alias = "dd")]
|
||||
DropDb(DropDbArgs),
|
||||
|
||||
/// Print information about one or more databases
|
||||
///
|
||||
/// If no database name is provided, all databases you have access will be shown.
|
||||
#[command(alias = "sd")]
|
||||
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.
|
||||
#[command(alias = "sp")]
|
||||
ShowPrivs(ShowPrivsArgs),
|
||||
|
||||
/// Change user privileges for one or more databases. See `edit-privs --help` for details.
|
||||
@@ -239,27 +246,34 @@ pub enum ClientCommand {
|
||||
verbatim_doc_comment,
|
||||
override_usage = "muscl edit-privs [OPTIONS] [ -p <DB_NAME:USER_NAME:[+-]PRIVILEGES>... | <DB_NAME> <USER_NAME> <[+-]PRIVILEGES> ]",
|
||||
after_long_help = EDIT_PRIVS_EXAMPLES,
|
||||
alias = "ep",
|
||||
)]
|
||||
EditPrivs(EditPrivsArgs),
|
||||
|
||||
/// Create one or more users
|
||||
#[command(alias = "cu")]
|
||||
CreateUser(CreateUserArgs),
|
||||
|
||||
/// Delete one or more users
|
||||
#[command(alias = "du")]
|
||||
DropUser(DropUserArgs),
|
||||
|
||||
/// Change the MySQL password for a user
|
||||
#[command(alias = "pu")]
|
||||
PasswdUser(PasswdUserArgs),
|
||||
|
||||
/// Print information about one or more users
|
||||
///
|
||||
/// If no username is provided, all users you have access will be shown.
|
||||
#[command(alias = "su")]
|
||||
ShowUser(ShowUserArgs),
|
||||
|
||||
/// Lock account for one or more users
|
||||
#[command(alias = "lu")]
|
||||
LockUser(LockUserArgs),
|
||||
|
||||
/// Unlock account for one or more users
|
||||
#[command(alias = "uu")]
|
||||
UnlockUser(UnlockUserArgs),
|
||||
}
|
||||
|
||||
@@ -305,7 +319,8 @@ fn main() -> anyhow::Result<()> {
|
||||
#[cfg(not(feature = "suid-sgid-mode"))]
|
||||
None,
|
||||
args.verbose,
|
||||
)?;
|
||||
)
|
||||
.context("Failed to connect to the server")?;
|
||||
|
||||
tokio_run_command(args.command, connection)?;
|
||||
|
||||
@@ -362,7 +377,7 @@ fn handle_mysql_admutils_command() -> anyhow::Result<Option<()>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the given commmand (from the client side) using Tokio.
|
||||
/// Run the given command (from the client side) using Tokio.
|
||||
fn tokio_run_command(
|
||||
command: ClientCommand,
|
||||
server_connection: StdUnixStream,
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::io::IsTerminal;
|
||||
|
||||
use clap::Parser;
|
||||
use clap_complete::ArgValueCompleter;
|
||||
use dialoguer::Confirm;
|
||||
@@ -6,16 +8,15 @@ use tokio_stream::StreamExt;
|
||||
|
||||
use crate::{
|
||||
client::commands::{
|
||||
erroneous_server_response, interactive_password_dialogue_with_double_check,
|
||||
interactive_password_expiry_dialogue, print_authorization_owner_hint,
|
||||
erroneous_server_response, print_authorization_owner_hint,
|
||||
read_password_from_stdin_with_double_check,
|
||||
},
|
||||
core::{
|
||||
completion::prefix_completer,
|
||||
protocol::{
|
||||
ClientToServerMessageStream, CreateUserError, Request, Response,
|
||||
SetUserPasswordRequest, print_create_users_output_status,
|
||||
print_create_users_output_status_json, print_set_password_output_status,
|
||||
request_validation::ValidationError,
|
||||
print_create_users_output_status, print_create_users_output_status_json,
|
||||
print_set_password_output_status, request_validation::ValidationError,
|
||||
},
|
||||
types::MySQLUser,
|
||||
},
|
||||
@@ -79,6 +80,15 @@ pub async fn create_users(
|
||||
.filter_map(|(username, result)| result.as_ref().ok().map(|()| username))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if !std::io::stdin().is_terminal()
|
||||
&& !args.no_password
|
||||
&& !successfully_created_users.is_empty()
|
||||
{
|
||||
anyhow::bail!(
|
||||
"Cannot prompt for passwords in non-interactive mode. Use --no-password to skip setting passwords."
|
||||
);
|
||||
}
|
||||
|
||||
for username in successfully_created_users {
|
||||
if !args.no_password
|
||||
&& Confirm::new()
|
||||
@@ -88,14 +98,8 @@ pub async fn create_users(
|
||||
.default(false)
|
||||
.interact()?
|
||||
{
|
||||
let password = interactive_password_dialogue_with_double_check(username)?;
|
||||
let expiry = interactive_password_expiry_dialogue(username)?;
|
||||
|
||||
let message = Request::PasswdUser(SetUserPasswordRequest {
|
||||
user: username.clone(),
|
||||
new_password: Some(password),
|
||||
expiry: expiry,
|
||||
});
|
||||
let password = read_password_from_stdin_with_double_check(username)?;
|
||||
let message = Request::PasswdUser((username.to_owned(), password));
|
||||
|
||||
if let Err(err) = server_connection.send(message).await {
|
||||
server_connection.close().await.ok();
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::io::IsTerminal;
|
||||
|
||||
use clap::Parser;
|
||||
use clap_complete::ArgValueCompleter;
|
||||
use dialoguer::Confirm;
|
||||
@@ -41,6 +43,12 @@ pub async fn drop_databases(
|
||||
anyhow::bail!("No database names provided");
|
||||
}
|
||||
|
||||
if !std::io::stdin().is_terminal() && !args.yes {
|
||||
anyhow::bail!(
|
||||
"Cannot prompt for confirmation in non-interactive mode. Use --yes to automatically confirm."
|
||||
);
|
||||
}
|
||||
|
||||
if !args.yes {
|
||||
let confirmation = Confirm::new()
|
||||
.with_prompt(format!(
|
||||
@@ -53,7 +61,6 @@ pub async fn drop_databases(
|
||||
))
|
||||
.interact()?;
|
||||
|
||||
//
|
||||
if !confirmation {
|
||||
// TODO: should we return with an error code here?
|
||||
println!("Aborting drop operation.");
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::io::IsTerminal;
|
||||
|
||||
use clap::Parser;
|
||||
use clap_complete::ArgValueCompleter;
|
||||
use dialoguer::Confirm;
|
||||
@@ -41,6 +43,12 @@ pub async fn drop_users(
|
||||
anyhow::bail!("No usernames provided");
|
||||
}
|
||||
|
||||
if !std::io::stdin().is_terminal() && !args.yes {
|
||||
anyhow::bail!(
|
||||
"Cannot prompt for confirmation in non-interactive mode. Use --yes to automatically confirm."
|
||||
);
|
||||
}
|
||||
|
||||
if !args.yes {
|
||||
let confirmation = Confirm::new()
|
||||
.with_prompt(format!(
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
io::IsTerminal,
|
||||
};
|
||||
|
||||
use anyhow::Context;
|
||||
use clap::{Args, Parser};
|
||||
@@ -213,6 +216,11 @@ pub async fn edit_database_privileges(
|
||||
};
|
||||
|
||||
let diffs: BTreeSet<DatabasePrivilegesDiff> = if privs.is_empty() {
|
||||
if !std::io::stdin().is_terminal() {
|
||||
anyhow::bail!(
|
||||
"Cannot launch editor in non-interactive mode. Please provide privileges via command line arguments."
|
||||
);
|
||||
}
|
||||
let privileges_to_change =
|
||||
edit_privileges_with_editor(&existing_privilege_rows, use_database.as_ref())?;
|
||||
diff_privileges(&existing_privilege_rows, &privileges_to_change)
|
||||
@@ -275,7 +283,8 @@ pub async fn edit_database_privileges(
|
||||
println!("The following changes will be made:\n");
|
||||
println!("{}", display_privilege_diffs(&diffs));
|
||||
|
||||
if !args.yes
|
||||
if std::io::stdin().is_terminal()
|
||||
&& !args.yes
|
||||
&& !Confirm::new()
|
||||
.with_prompt("Do you want to apply these changes?")
|
||||
.default(false)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::path::PathBuf;
|
||||
use std::{io::IsTerminal, path::PathBuf};
|
||||
|
||||
use anyhow::Context;
|
||||
use clap::Parser;
|
||||
@@ -13,8 +13,7 @@ use crate::{
|
||||
completion::mysql_user_completer,
|
||||
protocol::{
|
||||
ClientToServerMessageStream, ListUsersError, Request, Response, SetPasswordError,
|
||||
SetUserPasswordRequest, print_set_password_output_status,
|
||||
request_validation::ValidationError,
|
||||
print_set_password_output_status, request_validation::ValidationError,
|
||||
},
|
||||
types::MySQLUser,
|
||||
},
|
||||
@@ -38,21 +37,9 @@ pub struct PasswdUserArgs {
|
||||
/// Print the information as JSON
|
||||
#[arg(short, long)]
|
||||
json: bool,
|
||||
|
||||
/// Set the password to expire on the given date (YYYY-MM-DD)
|
||||
#[arg(short, long, value_name = "DATE", conflicts_with = "no-expire")]
|
||||
expire_on: Option<chrono::NaiveDate>,
|
||||
|
||||
/// Set the password to never expire
|
||||
#[arg(short, long, conflicts_with = "expire_on")]
|
||||
no_expire: bool,
|
||||
|
||||
/// Clear the password for the user instead of setting a new one
|
||||
#[arg(short, long, conflicts_with_all = &["password_file", "stdin", "expire_on", "no-expire"])]
|
||||
clear: bool,
|
||||
}
|
||||
|
||||
pub fn interactive_password_dialogue_with_double_check(username: &MySQLUser) -> anyhow::Result<String> {
|
||||
pub fn read_password_from_stdin_with_double_check(username: &MySQLUser) -> anyhow::Result<String> {
|
||||
Password::new()
|
||||
.with_prompt(format!("New MySQL password for user '{username}'"))
|
||||
.with_confirmation(
|
||||
@@ -63,29 +50,6 @@ pub fn interactive_password_dialogue_with_double_check(username: &MySQLUser) ->
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn interactive_password_expiry_dialogue(username: &MySQLUser) -> anyhow::Result<Option<chrono::NaiveDate>> {
|
||||
let input = dialoguer::Input::<String>::new()
|
||||
.with_prompt(format!(
|
||||
"Enter the password expiry date for user '{username}' (YYYY-MM-DD)"
|
||||
))
|
||||
.allow_empty(true)
|
||||
.validate_with(|input: &String| {
|
||||
chrono::NaiveDate::parse_from_str(input, "%Y-%m-%d")
|
||||
.map(|_| ())
|
||||
.map_err(|_| "Invalid date format. Please use YYYY-MM-DD".to_string())
|
||||
})
|
||||
.interact_text()?;
|
||||
|
||||
if input.trim().is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let date = chrono::NaiveDate::parse_from_str(&input, "%Y-%m-%d")
|
||||
.map_err(|e| anyhow::anyhow!("Failed to parse date: {}", e))?;
|
||||
|
||||
Ok(Some(date))
|
||||
}
|
||||
|
||||
pub async fn passwd_user(
|
||||
args: PasswdUserArgs,
|
||||
mut server_connection: ClientToServerMessageStream,
|
||||
@@ -112,38 +76,27 @@ pub async fn passwd_user(
|
||||
}
|
||||
}
|
||||
|
||||
let password: Option<String> = if let Some(password_file) = args.password_file {
|
||||
Some(
|
||||
std::fs::read_to_string(password_file)
|
||||
.context("Failed to read password file")?
|
||||
.trim()
|
||||
.to_string(),
|
||||
)
|
||||
let password = if let Some(password_file) = args.password_file {
|
||||
std::fs::read_to_string(password_file)
|
||||
.context("Failed to read password file")?
|
||||
.trim()
|
||||
.to_string()
|
||||
} else if args.stdin {
|
||||
let mut buffer = String::new();
|
||||
std::io::stdin()
|
||||
.read_line(&mut buffer)
|
||||
.context("Failed to read password from stdin")?;
|
||||
Some(buffer.trim().to_string())
|
||||
} else if args.clear {
|
||||
None
|
||||
buffer.trim().to_string()
|
||||
} else {
|
||||
Some(interactive_password_dialogue_with_double_check(&args.username)?)
|
||||
if !std::io::stdin().is_terminal() {
|
||||
anyhow::bail!(
|
||||
"Cannot prompt for password in non-interactive mode. Use --stdin or --password-file to provide the password."
|
||||
);
|
||||
}
|
||||
read_password_from_stdin_with_double_check(&args.username)?
|
||||
};
|
||||
|
||||
let expiry_date = if args.no_expire {
|
||||
None
|
||||
} else if let Some(date) = args.expire_on {
|
||||
Some(date)
|
||||
} else {
|
||||
interactive_password_expiry_dialogue(&args.username)?
|
||||
};
|
||||
|
||||
let message = Request::PasswdUser(SetUserPasswordRequest {
|
||||
user: args.username.clone(),
|
||||
new_password: password,
|
||||
expiry: expiry_date,
|
||||
});
|
||||
let message = Request::PasswdUser((args.username.clone(), password));
|
||||
|
||||
if let Err(err) = server_connection.send(message).await {
|
||||
server_connection.close().await.ok();
|
||||
|
||||
@@ -35,7 +35,7 @@ spawn the editor stored in the $EDITOR environment variable.
|
||||
(pico will be used if the variable is unset)
|
||||
|
||||
The file should contain one line per user, starting with the
|
||||
username and followed by ten Y/N-values seperated by whitespace.
|
||||
username and followed by ten Y/N-values separated by whitespace.
|
||||
Lines starting with # are ignored.
|
||||
|
||||
The Y/N-values corresponds to the following mysql privileges:
|
||||
|
||||
@@ -8,7 +8,7 @@ use tokio::net::UnixStream as TokioUnixStream;
|
||||
|
||||
use crate::{
|
||||
client::{
|
||||
commands::{erroneous_server_response, interactive_password_dialogue_with_double_check},
|
||||
commands::{erroneous_server_response, read_password_from_stdin_with_double_check},
|
||||
mysql_admutils_compatibility::{
|
||||
common::trim_user_name_to_32_chars,
|
||||
error_messages::{
|
||||
@@ -20,7 +20,7 @@ use crate::{
|
||||
bootstrap::bootstrap_server_connection_and_drop_privileges,
|
||||
completion::{mysql_user_completer, prefix_completer},
|
||||
protocol::{
|
||||
ClientToServerMessageStream, Request, Response, SetUserPasswordRequest, create_client_to_server_message_stream
|
||||
ClientToServerMessageStream, Request, Response, create_client_to_server_message_stream,
|
||||
},
|
||||
types::MySQLUser,
|
||||
},
|
||||
@@ -252,12 +252,8 @@ async fn passwd_users(
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
for user in users {
|
||||
let password = interactive_password_dialogue_with_double_check(&user.user)?;
|
||||
let message = Request::PasswdUser(SetUserPasswordRequest {
|
||||
user: user.user.clone(),
|
||||
new_password: Some(password),
|
||||
expiry: None,
|
||||
});
|
||||
let password = read_password_from_stdin_with_double_check(&user.user)?;
|
||||
let message = Request::PasswdUser((user.user.clone(), password));
|
||||
server_connection.send(message).await?;
|
||||
match server_connection.next().await {
|
||||
Some(Ok(Response::SetUserPassword(result))) => match result {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::{
|
||||
fs,
|
||||
os::unix::fs::FileTypeExt,
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
time::Duration,
|
||||
@@ -7,7 +8,10 @@ use std::{
|
||||
|
||||
use anyhow::{Context, anyhow};
|
||||
use clap_verbosity_flag::{InfoLevel, Verbosity};
|
||||
use nix::libc::{EXIT_SUCCESS, exit};
|
||||
use nix::{
|
||||
libc::{EXIT_SUCCESS, exit},
|
||||
unistd::{AccessFlags, access},
|
||||
};
|
||||
use sqlx::mysql::MySqlPoolOptions;
|
||||
use std::os::unix::net::UnixStream as StdUnixStream;
|
||||
use tokio::{net::UnixStream as TokioUnixStream, sync::RwLock};
|
||||
@@ -22,7 +26,7 @@ use crate::{
|
||||
authorization::read_and_parse_group_denylist,
|
||||
config::{MysqlConfig, ServerConfig},
|
||||
landlock::landlock_restrict_server,
|
||||
session_handler,
|
||||
session_handler::{self, SessionId},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -130,11 +134,28 @@ pub fn bootstrap_server_connection_and_drop_privileges(
|
||||
}
|
||||
}
|
||||
|
||||
fn socket_path_is_ok(path: &Path) -> anyhow::Result<()> {
|
||||
fs::metadata(path)
|
||||
.context(format!("Failed to get metadata for {:?}", path))
|
||||
.and_then(|meta| {
|
||||
if !meta.file_type().is_socket() {
|
||||
anyhow::bail!("{:?} is not a unix socket", path);
|
||||
}
|
||||
|
||||
access(path, AccessFlags::R_OK | AccessFlags::W_OK)
|
||||
.with_context(|| format!("Socket at {:?} is not readable/writable", path))?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn connect_to_external_server(
|
||||
server_socket_path: Option<PathBuf>,
|
||||
) -> anyhow::Result<StdUnixStream> {
|
||||
// TODO: ensure this is both readable and writable
|
||||
if let Some(socket_path) = server_socket_path {
|
||||
tracing::trace!("Checking socket at {:?}", socket_path);
|
||||
socket_path_is_ok(&socket_path)?;
|
||||
|
||||
tracing::debug!("Connecting to socket at {:?}", socket_path);
|
||||
return match StdUnixStream::connect(socket_path) {
|
||||
Ok(socket) => Ok(socket),
|
||||
@@ -147,6 +168,9 @@ fn connect_to_external_server(
|
||||
}
|
||||
|
||||
if fs::metadata(DEFAULT_SOCKET_PATH).is_ok() {
|
||||
tracing::trace!("Checking socket at {:?}", DEFAULT_SOCKET_PATH);
|
||||
socket_path_is_ok(Path::new(DEFAULT_SOCKET_PATH))?;
|
||||
|
||||
tracing::debug!("Connecting to default socket at {:?}", DEFAULT_SOCKET_PATH);
|
||||
return match StdUnixStream::connect(DEFAULT_SOCKET_PATH) {
|
||||
Ok(socket) => Ok(socket),
|
||||
@@ -158,7 +182,9 @@ fn connect_to_external_server(
|
||||
};
|
||||
}
|
||||
|
||||
anyhow::bail!("No socket path provided, and no default socket found");
|
||||
anyhow::bail!(
|
||||
"No socket path provided, and no socket found found at default location {DEFAULT_SOCKET_PATH}"
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: this function is security critical, it should be integration tested
|
||||
@@ -308,9 +334,11 @@ fn run_forked_server(
|
||||
version_row.to_lowercase().contains("mariadb")
|
||||
};
|
||||
|
||||
let session_id = SessionId::new(0);
|
||||
let db_pool = Arc::new(RwLock::new(db_pool));
|
||||
session_handler::session_handler_with_unix_user(
|
||||
socket,
|
||||
session_id,
|
||||
unix_user,
|
||||
db_pool,
|
||||
db_is_mariadb,
|
||||
|
||||
@@ -24,6 +24,7 @@ pub const KIND_REGARDS: &str = concat!(
|
||||
"If you experience any bugs or turbulence, please give us a heads up :)",
|
||||
);
|
||||
|
||||
/// TODO: store and display UID
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UnixUser {
|
||||
pub username: String,
|
||||
@@ -99,7 +100,7 @@ impl UnixUser {
|
||||
})
|
||||
}
|
||||
|
||||
// pub fn from_enviroment() -> anyhow::Result<Self> {
|
||||
// pub fn from_environment() -> anyhow::Result<Self> {
|
||||
// let libc_uid = nix::unistd::getuid();
|
||||
// UnixUser::from_uid(libc_uid.as_raw())
|
||||
// }
|
||||
|
||||
@@ -36,11 +36,16 @@ pub use modify_privileges::*;
|
||||
pub use passwd_user::*;
|
||||
pub use unlock_users::*;
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::fmt;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::net::UnixStream;
|
||||
use tokio_serde::{Framed as SerdeFramed, formats::Bincode};
|
||||
use tokio_util::codec::{Framed, LengthDelimitedCodec};
|
||||
|
||||
use crate::core::types::{MySQLDatabase, MySQLUser};
|
||||
|
||||
pub type ServerToClientMessageStream = SerdeFramed<
|
||||
Framed<UnixStream, LengthDelimitedCodec>,
|
||||
Request,
|
||||
@@ -104,6 +109,124 @@ pub enum Request {
|
||||
Exit,
|
||||
}
|
||||
|
||||
impl Request {
|
||||
/// Get the command name associated with this request.
|
||||
pub fn command_name(&self) -> &str {
|
||||
match self {
|
||||
Request::CheckAuthorization(_) => "check-authorization",
|
||||
Request::ListValidNamePrefixes => "list-valid-name-prefixes",
|
||||
Request::CompleteDatabaseName(_) => "complete-database-name",
|
||||
Request::CompleteUserName(_) => "complete-user-name",
|
||||
Request::CreateDatabases(_) => "create-databases",
|
||||
Request::DropDatabases(_) => "drop-databases",
|
||||
Request::ListDatabases(_) => "list-databases",
|
||||
Request::ListPrivileges(_) => "list-privileges",
|
||||
Request::ModifyPrivileges(_) => "modify-privileges",
|
||||
Request::CreateUsers(_) => "create-users",
|
||||
Request::DropUsers(_) => "drop-users",
|
||||
Request::PasswdUser(_) => "passwd-user",
|
||||
Request::ListUsers(_) => "list-users",
|
||||
Request::LockUsers(_) => "lock-users",
|
||||
Request::UnlockUsers(_) => "unlock-users",
|
||||
Request::Exit => "exit",
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a short summary string representing this request for logging purposes.
|
||||
pub fn log_summary(&self) -> String {
|
||||
match self {
|
||||
Request::CheckAuthorization(req) => format!("{}({})", self.command_name(), req.len()),
|
||||
|
||||
Request::CreateDatabases(req) => format!("{}({})", self.command_name(), req.len()),
|
||||
Request::DropDatabases(req) => format!("{}({})", self.command_name(), req.len()),
|
||||
Request::ListDatabases(req) => format!(
|
||||
"{}{}",
|
||||
self.command_name(),
|
||||
req.as_ref()
|
||||
.map_or("".to_string(), |r| format!("({})", r.len()))
|
||||
),
|
||||
Request::ListPrivileges(req) => format!(
|
||||
"{}{}",
|
||||
self.command_name(),
|
||||
req.as_ref()
|
||||
.map_or("".to_string(), |r| format!("({})", r.len()))
|
||||
),
|
||||
Request::ModifyPrivileges(req) => format!("{}({})", self.command_name(), req.len()),
|
||||
|
||||
Request::CreateUsers(req) => format!("{}({})", self.command_name(), req.len()),
|
||||
Request::DropUsers(req) => format!("{}({})", self.command_name(), req.len()),
|
||||
Request::ListUsers(req) => format!(
|
||||
"{}{}",
|
||||
self.command_name(),
|
||||
req.as_ref()
|
||||
.map_or("".to_string(), |r| format!("({})", r.len()))
|
||||
),
|
||||
Request::LockUsers(req) => format!("{}({})", self.command_name(), req.len()),
|
||||
Request::UnlockUsers(req) => format!("{}({})", self.command_name(), req.len()),
|
||||
|
||||
_ => self.command_name().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the set of users affected by this request.
|
||||
pub fn affected_users(&self) -> BTreeSet<MySQLUser> {
|
||||
match self {
|
||||
Request::CheckAuthorization(_) => Default::default(),
|
||||
Request::ListValidNamePrefixes => Default::default(),
|
||||
Request::CompleteDatabaseName(_) => Default::default(),
|
||||
Request::CompleteUserName(_) => Default::default(),
|
||||
Request::CreateDatabases(_) => Default::default(),
|
||||
Request::DropDatabases(_) => Default::default(),
|
||||
Request::ListDatabases(_) => Default::default(),
|
||||
Request::ListPrivileges(_) => Default::default(),
|
||||
Request::ModifyPrivileges(priv_diffs) => priv_diffs
|
||||
.iter()
|
||||
.map(|priv_diff| priv_diff.get_user_name().clone())
|
||||
.collect(),
|
||||
Request::CreateUsers(users) => users.iter().cloned().collect(),
|
||||
Request::DropUsers(users) => users.iter().cloned().collect(),
|
||||
Request::PasswdUser(user_passwd_req) => {
|
||||
let mut result = BTreeSet::new();
|
||||
result.insert(user_passwd_req.0.clone());
|
||||
result
|
||||
}
|
||||
Request::ListUsers(users) => users.clone().unwrap_or_default().into_iter().collect(),
|
||||
Request::LockUsers(users) => users.iter().cloned().collect(),
|
||||
Request::UnlockUsers(users) => users.iter().cloned().collect(),
|
||||
Request::Exit => Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the set of databases affected by this request.
|
||||
pub fn affected_databases(&self) -> BTreeSet<MySQLDatabase> {
|
||||
match self {
|
||||
Request::CheckAuthorization(_) => Default::default(),
|
||||
Request::ListValidNamePrefixes => Default::default(),
|
||||
Request::CompleteDatabaseName(_) => Default::default(),
|
||||
Request::CompleteUserName(_) => Default::default(),
|
||||
Request::CreateDatabases(databases) => databases.iter().cloned().collect(),
|
||||
Request::DropDatabases(databases) => databases.iter().cloned().collect(),
|
||||
Request::ListDatabases(databases) => {
|
||||
databases.clone().unwrap_or_default().into_iter().collect()
|
||||
}
|
||||
Request::ListPrivileges(databases) => {
|
||||
databases.clone().unwrap_or_default().into_iter().collect()
|
||||
}
|
||||
Request::ModifyPrivileges(priv_diffs) => priv_diffs
|
||||
.iter()
|
||||
.map(|priv_diff| priv_diff.get_database_name().clone())
|
||||
.collect(),
|
||||
Request::CreateUsers(_) => Default::default(),
|
||||
Request::DropUsers(_) => Default::default(),
|
||||
Request::PasswdUser(_) => Default::default(),
|
||||
Request::ListUsers(_) => Default::default(),
|
||||
Request::LockUsers(_) => Default::default(),
|
||||
Request::UnlockUsers(_) => Default::default(),
|
||||
Request::Exit => Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: include a generic "message" that will display a message to the user?
|
||||
|
||||
#[non_exhaustive]
|
||||
@@ -136,3 +259,95 @@ pub enum Response {
|
||||
Ready,
|
||||
Error(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum ResponseOkStatus {
|
||||
Success,
|
||||
PartialSuccess(usize, usize), // succeeded, total
|
||||
Error,
|
||||
}
|
||||
|
||||
impl ResponseOkStatus {
|
||||
pub fn from_counts(total: usize, succeeded: usize) -> Self {
|
||||
if succeeded == total {
|
||||
ResponseOkStatus::Success
|
||||
} else if succeeded == 0 {
|
||||
ResponseOkStatus::Error
|
||||
} else {
|
||||
ResponseOkStatus::PartialSuccess(succeeded, total)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_bool(is_ok: bool) -> Self {
|
||||
if is_ok {
|
||||
ResponseOkStatus::Success
|
||||
} else {
|
||||
ResponseOkStatus::Error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ResponseOkStatus {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
ResponseOkStatus::Success => write!(f, "OK"),
|
||||
ResponseOkStatus::PartialSuccess(succeeded, total) => {
|
||||
write!(f, "PARTIAL_OK({}/{})", succeeded, total)
|
||||
}
|
||||
ResponseOkStatus::Error => write!(f, "ERR"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Response {
|
||||
pub fn ok_status(&self) -> ResponseOkStatus {
|
||||
match self {
|
||||
Response::CheckAuthorization(res) => {
|
||||
ResponseOkStatus::from_counts(res.len(), res.values().filter(|v| v.is_ok()).count())
|
||||
}
|
||||
|
||||
Response::ListValidNamePrefixes(_) => ResponseOkStatus::Success,
|
||||
Response::CompleteDatabaseName(_) => ResponseOkStatus::Success,
|
||||
Response::CompleteUserName(_) => ResponseOkStatus::Success,
|
||||
|
||||
Response::CreateDatabases(res) => {
|
||||
ResponseOkStatus::from_counts(res.len(), res.values().filter(|v| v.is_ok()).count())
|
||||
}
|
||||
Response::DropDatabases(res) => {
|
||||
ResponseOkStatus::from_counts(res.len(), res.values().filter(|v| v.is_ok()).count())
|
||||
}
|
||||
Response::ListDatabases(res) => {
|
||||
ResponseOkStatus::from_counts(res.len(), res.values().filter(|v| v.is_ok()).count())
|
||||
}
|
||||
Response::ListAllDatabases(res) => ResponseOkStatus::from_bool(res.is_ok()),
|
||||
Response::ListPrivileges(res) => {
|
||||
ResponseOkStatus::from_counts(res.len(), res.values().filter(|v| v.is_ok()).count())
|
||||
}
|
||||
Response::ListAllPrivileges(res) => ResponseOkStatus::from_bool(res.is_ok()),
|
||||
Response::ModifyPrivileges(res) => {
|
||||
ResponseOkStatus::from_counts(res.len(), res.values().filter(|v| v.is_ok()).count())
|
||||
}
|
||||
|
||||
Response::CreateUsers(res) => {
|
||||
ResponseOkStatus::from_counts(res.len(), res.values().filter(|v| v.is_ok()).count())
|
||||
}
|
||||
Response::DropUsers(res) => {
|
||||
ResponseOkStatus::from_counts(res.len(), res.values().filter(|v| v.is_ok()).count())
|
||||
}
|
||||
Response::SetUserPassword(res) => ResponseOkStatus::from_bool(res.is_ok()),
|
||||
Response::ListUsers(res) => {
|
||||
ResponseOkStatus::from_counts(res.len(), res.values().filter(|v| v.is_ok()).count())
|
||||
}
|
||||
Response::ListAllUsers(res) => ResponseOkStatus::from_bool(res.is_ok()),
|
||||
Response::LockUsers(res) => {
|
||||
ResponseOkStatus::from_counts(res.len(), res.values().filter(|v| v.is_ok()).count())
|
||||
}
|
||||
Response::UnlockUsers(res) => {
|
||||
ResponseOkStatus::from_counts(res.len(), res.values().filter(|v| v.is_ok()).count())
|
||||
}
|
||||
|
||||
Response::Ready => ResponseOkStatus::Success,
|
||||
Response::Error(_) => ResponseOkStatus::Error,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,12 +6,7 @@ use crate::core::{
|
||||
types::{DbOrUser, MySQLUser},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SetUserPasswordRequest {
|
||||
pub user: MySQLUser,
|
||||
pub new_password: Option<String>,
|
||||
pub expiry: Option<chrono::NaiveDate>,
|
||||
}
|
||||
pub type SetUserPasswordRequest = (MySQLUser, String);
|
||||
|
||||
pub type SetUserPasswordResponse = Result<(), SetPasswordError>;
|
||||
|
||||
@@ -23,9 +18,6 @@ pub enum SetPasswordError {
|
||||
#[error("User does not exist")]
|
||||
UserDoesNotExist,
|
||||
|
||||
#[error("Cannot clear password with an expiry date set")]
|
||||
ClearPasswordWithExpiry,
|
||||
|
||||
#[error("MySQL error: {0}")]
|
||||
MySqlError(String),
|
||||
}
|
||||
@@ -52,9 +44,6 @@ impl SetPasswordError {
|
||||
SetPasswordError::UserDoesNotExist => {
|
||||
format!("User '{username}' does not exist.")
|
||||
}
|
||||
SetPasswordError::ClearPasswordWithExpiry => {
|
||||
format!("Cannot clear password for user '{username}' when an expiry date is set.")
|
||||
}
|
||||
SetPasswordError::MySqlError(err) => {
|
||||
format!("MySQL error: {err}")
|
||||
}
|
||||
@@ -67,7 +56,6 @@ impl SetPasswordError {
|
||||
match self {
|
||||
SetPasswordError::ValidationError(err) => err.error_type(),
|
||||
SetPasswordError::UserDoesNotExist => "user-does-not-exist".to_string(),
|
||||
SetPasswordError::ClearPasswordWithExpiry => "clear-password-with-expiry".to_string(),
|
||||
SetPasswordError::MySqlError(_) => "mysql-error".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::{collections::HashSet, path::Path};
|
||||
use std::{collections::HashSet, path::Path, str::Lines};
|
||||
|
||||
use anyhow::Context;
|
||||
use nix::unistd::Group;
|
||||
@@ -13,23 +13,19 @@ use crate::core::{
|
||||
};
|
||||
|
||||
pub async fn check_authorization(
|
||||
dbs_or_users: Vec<DbOrUser>,
|
||||
dbs_or_users: &[DbOrUser],
|
||||
unix_user: &UnixUser,
|
||||
group_denylist: &GroupDenylist,
|
||||
) -> std::collections::BTreeMap<DbOrUser, Result<(), CheckAuthorizationError>> {
|
||||
let mut results = std::collections::BTreeMap::new();
|
||||
|
||||
for db_or_user in dbs_or_users {
|
||||
if let Err(err) = validate_db_or_user_request(&db_or_user, unix_user, group_denylist)
|
||||
.map_err(CheckAuthorizationError)
|
||||
{
|
||||
results.insert(db_or_user.clone(), Err(err));
|
||||
continue;
|
||||
}
|
||||
results.insert(db_or_user.clone(), Ok(()));
|
||||
}
|
||||
|
||||
results
|
||||
dbs_or_users
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|db_or_user| {
|
||||
let result = validate_db_or_user_request(&db_or_user, unix_user, group_denylist)
|
||||
.map_err(CheckAuthorizationError);
|
||||
(db_or_user, result)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Reads and parses a group denylist file, returning a set of GUIDs
|
||||
@@ -45,12 +41,25 @@ pub fn read_and_parse_group_denylist(denylist_path: &Path) -> anyhow::Result<Gro
|
||||
let content = std::fs::read_to_string(denylist_path)
|
||||
.context(format!("Failed to read denylist file at {denylist_path:?}"))?;
|
||||
|
||||
let mut groups = HashSet::with_capacity(content.lines().count());
|
||||
let lines = content.lines();
|
||||
|
||||
for (line_number, line) in content.lines().enumerate() {
|
||||
let trimmed_line = line.trim();
|
||||
let groups = parse_group_denylist(denylist_path, lines);
|
||||
|
||||
if trimmed_line.is_empty() || trimmed_line.starts_with('#') {
|
||||
Ok(groups)
|
||||
}
|
||||
|
||||
fn parse_group_denylist(denylist_path: &Path, lines: Lines) -> GroupDenylist {
|
||||
let mut groups = HashSet::<u32>::new();
|
||||
|
||||
for (line_number, line) in lines.enumerate() {
|
||||
let trimmed_line = if let Some(comment_start) = line.find('#') {
|
||||
&line[..comment_start]
|
||||
} else {
|
||||
line
|
||||
}
|
||||
.trim();
|
||||
|
||||
if trimmed_line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -141,5 +150,32 @@ pub fn read_and_parse_group_denylist(denylist_path: &Path) -> anyhow::Result<Gro
|
||||
}
|
||||
}
|
||||
|
||||
Ok(groups)
|
||||
groups
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use indoc::indoc;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_group_denylist() {
|
||||
let denylist_content = indoc! {"
|
||||
# Valid entries
|
||||
gid:0 # This is usually the 'root' group
|
||||
group:root # This is also the 'root' group, should deduplicate
|
||||
|
||||
# Invalid entries
|
||||
invalid_line
|
||||
gid:not_a_number
|
||||
group:nonexistent_group
|
||||
"};
|
||||
|
||||
let lines = denylist_content.lines();
|
||||
let group_denylist = parse_group_denylist(Path::new("test_denylist"), lines);
|
||||
|
||||
assert_eq!(group_denylist.len(), 1);
|
||||
assert!(group_denylist.contains(&0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
use std::{collections::BTreeSet, sync::Arc};
|
||||
use std::sync::Arc;
|
||||
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use indoc::concatdoc;
|
||||
use itertools::Itertools;
|
||||
use sqlx::{MySqlConnection, MySqlPool};
|
||||
use tokio::{net::UnixStream, sync::RwLock};
|
||||
use tracing::Instrument;
|
||||
@@ -11,8 +12,7 @@ use crate::{
|
||||
common::UnixUser,
|
||||
protocol::{
|
||||
Request, Response, ServerToClientMessageStream, SetPasswordError,
|
||||
SetUserPasswordRequest, create_server_to_client_message_stream,
|
||||
request_validation::GroupDenylist,
|
||||
create_server_to_client_message_stream, request_validation::GroupDenylist,
|
||||
},
|
||||
},
|
||||
server::{
|
||||
@@ -35,10 +35,24 @@ use crate::{
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct SessionId(u64);
|
||||
|
||||
impl SessionId {
|
||||
pub fn new(id: u64) -> Self {
|
||||
SessionId(id)
|
||||
}
|
||||
|
||||
pub fn inner(&self) -> u64 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: don't use database connection unless necessary.
|
||||
|
||||
pub async fn session_handler(
|
||||
socket: UnixStream,
|
||||
session_id: SessionId,
|
||||
db_pool: Arc<RwLock<MySqlPool>>,
|
||||
db_is_mariadb: bool,
|
||||
group_denylist: &GroupDenylist,
|
||||
@@ -62,7 +76,7 @@ pub async fn session_handler(
|
||||
}
|
||||
};
|
||||
|
||||
tracing::debug!("Validated peer UID: {}", uid);
|
||||
tracing::trace!("Validated peer UID: {}", uid);
|
||||
|
||||
let unix_user = match UnixUser::from_uid(uid) {
|
||||
Ok(user) => user,
|
||||
@@ -83,13 +97,18 @@ pub async fn session_handler(
|
||||
}
|
||||
};
|
||||
|
||||
let span = tracing::info_span!("user_session", user = %unix_user);
|
||||
let span = tracing::info_span!(
|
||||
"user_session",
|
||||
session_id = session_id.inner(),
|
||||
user = %unix_user,
|
||||
);
|
||||
|
||||
(async move {
|
||||
tracing::info!("Accepted connection from user: {}", unix_user);
|
||||
tracing::debug!("Accepted connection from user: {}", unix_user);
|
||||
|
||||
let result = session_handler_with_unix_user(
|
||||
socket,
|
||||
session_id,
|
||||
&unix_user,
|
||||
db_pool,
|
||||
db_is_mariadb,
|
||||
@@ -97,7 +116,7 @@ pub async fn session_handler(
|
||||
)
|
||||
.await;
|
||||
|
||||
tracing::info!(
|
||||
tracing::debug!(
|
||||
"Finished handling requests for connection from user: {}",
|
||||
unix_user,
|
||||
);
|
||||
@@ -110,6 +129,7 @@ pub async fn session_handler(
|
||||
|
||||
pub async fn session_handler_with_unix_user(
|
||||
socket: UnixStream,
|
||||
session_id: SessionId,
|
||||
unix_user: &UnixUser,
|
||||
db_pool: Arc<RwLock<MySqlPool>>,
|
||||
db_is_mariadb: bool,
|
||||
@@ -117,7 +137,7 @@ pub async fn session_handler_with_unix_user(
|
||||
) -> anyhow::Result<()> {
|
||||
let mut message_stream = create_server_to_client_message_stream(socket);
|
||||
|
||||
tracing::debug!("Requesting database connection from pool");
|
||||
tracing::trace!("Requesting database connection from pool");
|
||||
let mut db_connection = match db_pool.read().await.acquire().await {
|
||||
Ok(connection) => connection,
|
||||
Err(err) => {
|
||||
@@ -134,10 +154,11 @@ pub async fn session_handler_with_unix_user(
|
||||
return Err(err.into());
|
||||
}
|
||||
};
|
||||
tracing::debug!("Successfully acquired database connection from pool");
|
||||
tracing::trace!("Successfully acquired database connection from pool");
|
||||
|
||||
let result = session_handler_with_db_connection(
|
||||
message_stream,
|
||||
session_id,
|
||||
unix_user,
|
||||
&mut db_connection,
|
||||
db_is_mariadb,
|
||||
@@ -145,7 +166,7 @@ pub async fn session_handler_with_unix_user(
|
||||
)
|
||||
.await;
|
||||
|
||||
tracing::debug!("Releasing database connection back to pool");
|
||||
tracing::trace!("Releasing database connection back to pool");
|
||||
|
||||
result
|
||||
}
|
||||
@@ -155,6 +176,7 @@ pub async fn session_handler_with_unix_user(
|
||||
|
||||
async fn session_handler_with_db_connection(
|
||||
mut stream: ServerToClientMessageStream,
|
||||
session_id: SessionId,
|
||||
unix_user: &UnixUser,
|
||||
db_connection: &mut MySqlConnection,
|
||||
db_is_mariadb: bool,
|
||||
@@ -174,258 +196,311 @@ async fn session_handler_with_db_connection(
|
||||
}
|
||||
};
|
||||
|
||||
// TODO: don't clone the request
|
||||
let request_to_display = match &request {
|
||||
Request::PasswdUser(SetUserPasswordRequest {
|
||||
user,
|
||||
new_password,
|
||||
expiry,
|
||||
}) => Request::PasswdUser(SetUserPasswordRequest {
|
||||
user: user.clone(),
|
||||
new_password: new_password.as_ref().map(|_| "<REDACTED>".to_string()),
|
||||
expiry: *expiry,
|
||||
}),
|
||||
request => request.to_owned(),
|
||||
};
|
||||
let request_span = tracing::info_span!("request", command = request.command_name());
|
||||
|
||||
if request_to_display == Request::Exit {
|
||||
tracing::debug!("Received request: {:#?}", request_to_display);
|
||||
} else {
|
||||
tracing::info!("Received request: {:#?}", request_to_display);
|
||||
if !handle_request(
|
||||
request,
|
||||
session_id,
|
||||
unix_user,
|
||||
db_connection,
|
||||
db_is_mariadb,
|
||||
group_denylist,
|
||||
&mut stream,
|
||||
)
|
||||
.instrument(request_span)
|
||||
.await?
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
let response = match request {
|
||||
Request::CheckAuthorization(dbs_or_users) => {
|
||||
let result = check_authorization(dbs_or_users, unix_user, group_denylist).await;
|
||||
Response::CheckAuthorization(result)
|
||||
}
|
||||
Request::ListValidNamePrefixes => {
|
||||
let mut result = Vec::with_capacity(unix_user.groups.len() + 1);
|
||||
result.push(unix_user.username.clone());
|
||||
|
||||
for group in get_user_filtered_groups(unix_user, group_denylist) {
|
||||
result.push(group.clone());
|
||||
}
|
||||
|
||||
Response::ListValidNamePrefixes(result)
|
||||
}
|
||||
Request::CompleteDatabaseName(partial_database_name) => {
|
||||
// TODO: more correct validation here
|
||||
if partial_database_name
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
|
||||
{
|
||||
let result = complete_database_name(
|
||||
partial_database_name,
|
||||
unix_user,
|
||||
db_connection,
|
||||
db_is_mariadb,
|
||||
group_denylist,
|
||||
)
|
||||
.await;
|
||||
Response::CompleteDatabaseName(result)
|
||||
} else {
|
||||
Response::CompleteDatabaseName(vec![])
|
||||
}
|
||||
}
|
||||
Request::CompleteUserName(partial_user_name) => {
|
||||
// TODO: more correct validation here
|
||||
if partial_user_name
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
|
||||
{
|
||||
let result = complete_user_name(
|
||||
partial_user_name,
|
||||
unix_user,
|
||||
db_connection,
|
||||
db_is_mariadb,
|
||||
group_denylist,
|
||||
)
|
||||
.await;
|
||||
Response::CompleteUserName(result)
|
||||
} else {
|
||||
Response::CompleteUserName(vec![])
|
||||
}
|
||||
}
|
||||
Request::CreateDatabases(databases_names) => {
|
||||
let result = create_databases(
|
||||
databases_names,
|
||||
unix_user,
|
||||
db_connection,
|
||||
db_is_mariadb,
|
||||
group_denylist,
|
||||
)
|
||||
.await;
|
||||
Response::CreateDatabases(result)
|
||||
}
|
||||
Request::DropDatabases(databases_names) => {
|
||||
let result = drop_databases(
|
||||
databases_names,
|
||||
unix_user,
|
||||
db_connection,
|
||||
db_is_mariadb,
|
||||
group_denylist,
|
||||
)
|
||||
.await;
|
||||
Response::DropDatabases(result)
|
||||
}
|
||||
Request::ListDatabases(database_names) => {
|
||||
if let Some(database_names) = database_names {
|
||||
let result = list_databases(
|
||||
database_names,
|
||||
unix_user,
|
||||
db_connection,
|
||||
db_is_mariadb,
|
||||
group_denylist,
|
||||
)
|
||||
.await;
|
||||
Response::ListDatabases(result)
|
||||
} else {
|
||||
let result = list_all_databases_for_user(
|
||||
unix_user,
|
||||
db_connection,
|
||||
db_is_mariadb,
|
||||
group_denylist,
|
||||
)
|
||||
.await;
|
||||
Response::ListAllDatabases(result)
|
||||
}
|
||||
}
|
||||
Request::ListPrivileges(database_names) => {
|
||||
if let Some(database_names) = database_names {
|
||||
let privilege_data = get_databases_privilege_data(
|
||||
database_names,
|
||||
unix_user,
|
||||
db_connection,
|
||||
db_is_mariadb,
|
||||
group_denylist,
|
||||
)
|
||||
.await;
|
||||
Response::ListPrivileges(privilege_data)
|
||||
} else {
|
||||
let privilege_data = get_all_database_privileges(
|
||||
unix_user,
|
||||
db_connection,
|
||||
db_is_mariadb,
|
||||
group_denylist,
|
||||
)
|
||||
.await;
|
||||
Response::ListAllPrivileges(privilege_data)
|
||||
}
|
||||
}
|
||||
Request::ModifyPrivileges(database_privilege_diffs) => {
|
||||
let result = apply_privilege_diffs(
|
||||
BTreeSet::from_iter(database_privilege_diffs),
|
||||
unix_user,
|
||||
db_connection,
|
||||
db_is_mariadb,
|
||||
group_denylist,
|
||||
)
|
||||
.await;
|
||||
Response::ModifyPrivileges(result)
|
||||
}
|
||||
Request::CreateUsers(db_users) => {
|
||||
let result = create_database_users(
|
||||
db_users,
|
||||
unix_user,
|
||||
db_connection,
|
||||
db_is_mariadb,
|
||||
group_denylist,
|
||||
)
|
||||
.await;
|
||||
Response::CreateUsers(result)
|
||||
}
|
||||
Request::DropUsers(db_users) => {
|
||||
let result = drop_database_users(
|
||||
db_users,
|
||||
unix_user,
|
||||
db_connection,
|
||||
db_is_mariadb,
|
||||
group_denylist,
|
||||
)
|
||||
.await;
|
||||
Response::DropUsers(result)
|
||||
}
|
||||
Request::PasswdUser(SetUserPasswordRequest {
|
||||
user,
|
||||
new_password,
|
||||
expiry,
|
||||
}) => {
|
||||
let result = set_password_for_database_user(
|
||||
&user,
|
||||
new_password.as_deref(),
|
||||
expiry,
|
||||
unix_user,
|
||||
db_connection,
|
||||
db_is_mariadb,
|
||||
group_denylist,
|
||||
)
|
||||
.await;
|
||||
Response::SetUserPassword(result)
|
||||
}
|
||||
Request::ListUsers(db_users) => {
|
||||
if let Some(db_users) = db_users {
|
||||
let result = list_database_users(
|
||||
db_users,
|
||||
unix_user,
|
||||
db_connection,
|
||||
db_is_mariadb,
|
||||
group_denylist,
|
||||
)
|
||||
.await;
|
||||
Response::ListUsers(result)
|
||||
} else {
|
||||
let result = list_all_database_users_for_unix_user(
|
||||
unix_user,
|
||||
db_connection,
|
||||
db_is_mariadb,
|
||||
group_denylist,
|
||||
)
|
||||
.await;
|
||||
Response::ListAllUsers(result)
|
||||
}
|
||||
}
|
||||
Request::LockUsers(db_users) => {
|
||||
let result = lock_database_users(
|
||||
db_users,
|
||||
unix_user,
|
||||
db_connection,
|
||||
db_is_mariadb,
|
||||
group_denylist,
|
||||
)
|
||||
.await;
|
||||
Response::LockUsers(result)
|
||||
}
|
||||
Request::UnlockUsers(db_users) => {
|
||||
let result = unlock_database_users(
|
||||
db_users,
|
||||
unix_user,
|
||||
db_connection,
|
||||
db_is_mariadb,
|
||||
group_denylist,
|
||||
)
|
||||
.await;
|
||||
Response::UnlockUsers(result)
|
||||
}
|
||||
Request::Exit => {
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
let response_to_display = match &response {
|
||||
Response::SetUserPassword(Err(SetPasswordError::MySqlError(_))) => {
|
||||
&Response::SetUserPassword(Err(SetPasswordError::MySqlError(
|
||||
"<REDACTED>".to_string(),
|
||||
)))
|
||||
}
|
||||
response => response,
|
||||
};
|
||||
tracing::debug!("Response: {:#?}", response_to_display);
|
||||
|
||||
stream.send(response).await?;
|
||||
stream.flush().await?;
|
||||
tracing::debug!("Successfully processed request");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle a single request from a client.
|
||||
///
|
||||
/// If the function returns `true`, the session should continue.
|
||||
async fn handle_request(
|
||||
request: Request,
|
||||
session_id: SessionId,
|
||||
unix_user: &UnixUser,
|
||||
db_connection: &mut MySqlConnection,
|
||||
db_is_mariadb: bool,
|
||||
group_denylist: &GroupDenylist,
|
||||
stream: &mut ServerToClientMessageStream,
|
||||
) -> anyhow::Result<bool> {
|
||||
match &request {
|
||||
Request::Exit => tracing::debug!("Request: exit"),
|
||||
Request::PasswdUser((db_user, _)) => tracing::debug!(
|
||||
"Request:\n{}",
|
||||
serde_json::to_string_pretty(&Request::PasswdUser((
|
||||
db_user.to_owned(),
|
||||
"<REDACTED>".to_string()
|
||||
)))?
|
||||
),
|
||||
request => tracing::debug!("Request:\n{}", serde_json::to_string_pretty(request)?),
|
||||
}
|
||||
|
||||
let affected_dbs = request.affected_databases();
|
||||
if !affected_dbs.is_empty() {
|
||||
tracing::trace!(
|
||||
"Affected databases: {}",
|
||||
affected_dbs.into_iter().map(|db| db.to_string()).join(", ")
|
||||
);
|
||||
}
|
||||
|
||||
let affected_users = request.affected_users();
|
||||
if !affected_users.is_empty() {
|
||||
tracing::trace!(
|
||||
"Affected users: {}",
|
||||
affected_users.into_iter().map(|u| u.to_string()).join(", "),
|
||||
);
|
||||
}
|
||||
|
||||
let response = match request {
|
||||
Request::CheckAuthorization(ref dbs_or_users) => {
|
||||
let result = check_authorization(dbs_or_users, unix_user, group_denylist).await;
|
||||
Response::CheckAuthorization(result)
|
||||
}
|
||||
Request::ListValidNamePrefixes => {
|
||||
let mut result = Vec::with_capacity(unix_user.groups.len() + 1);
|
||||
result.push(unix_user.username.clone());
|
||||
|
||||
for group in get_user_filtered_groups(unix_user, group_denylist) {
|
||||
result.push(group.clone());
|
||||
}
|
||||
|
||||
Response::ListValidNamePrefixes(result)
|
||||
}
|
||||
Request::CompleteDatabaseName(ref partial_database_name) => {
|
||||
// TODO: more correct validation here
|
||||
if partial_database_name
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
|
||||
{
|
||||
let result = complete_database_name(
|
||||
partial_database_name,
|
||||
unix_user,
|
||||
db_connection,
|
||||
db_is_mariadb,
|
||||
group_denylist,
|
||||
)
|
||||
.await;
|
||||
Response::CompleteDatabaseName(result)
|
||||
} else {
|
||||
Response::CompleteDatabaseName(vec![])
|
||||
}
|
||||
}
|
||||
Request::CompleteUserName(ref partial_user_name) => {
|
||||
// TODO: more correct validation here
|
||||
if partial_user_name
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
|
||||
{
|
||||
let result = complete_user_name(
|
||||
partial_user_name,
|
||||
unix_user,
|
||||
db_connection,
|
||||
db_is_mariadb,
|
||||
group_denylist,
|
||||
)
|
||||
.await;
|
||||
Response::CompleteUserName(result)
|
||||
} else {
|
||||
Response::CompleteUserName(vec![])
|
||||
}
|
||||
}
|
||||
Request::CreateDatabases(ref databases_names) => {
|
||||
let result = create_databases(
|
||||
databases_names,
|
||||
unix_user,
|
||||
db_connection,
|
||||
db_is_mariadb,
|
||||
group_denylist,
|
||||
)
|
||||
.await;
|
||||
Response::CreateDatabases(result)
|
||||
}
|
||||
Request::DropDatabases(ref databases_names) => {
|
||||
let result = drop_databases(
|
||||
databases_names,
|
||||
unix_user,
|
||||
db_connection,
|
||||
db_is_mariadb,
|
||||
group_denylist,
|
||||
)
|
||||
.await;
|
||||
Response::DropDatabases(result)
|
||||
}
|
||||
Request::ListDatabases(ref database_names) => {
|
||||
if let Some(database_names) = database_names {
|
||||
let result = list_databases(
|
||||
database_names,
|
||||
unix_user,
|
||||
db_connection,
|
||||
db_is_mariadb,
|
||||
group_denylist,
|
||||
)
|
||||
.await;
|
||||
Response::ListDatabases(result)
|
||||
} else {
|
||||
let result = list_all_databases_for_user(
|
||||
unix_user,
|
||||
db_connection,
|
||||
db_is_mariadb,
|
||||
group_denylist,
|
||||
)
|
||||
.await;
|
||||
Response::ListAllDatabases(result)
|
||||
}
|
||||
}
|
||||
Request::ListPrivileges(ref database_names) => {
|
||||
if let Some(database_names) = database_names {
|
||||
let privilege_data = get_databases_privilege_data(
|
||||
database_names,
|
||||
unix_user,
|
||||
db_connection,
|
||||
db_is_mariadb,
|
||||
group_denylist,
|
||||
)
|
||||
.await;
|
||||
Response::ListPrivileges(privilege_data)
|
||||
} else {
|
||||
let privilege_data = get_all_database_privileges(
|
||||
unix_user,
|
||||
db_connection,
|
||||
db_is_mariadb,
|
||||
group_denylist,
|
||||
)
|
||||
.await;
|
||||
Response::ListAllPrivileges(privilege_data)
|
||||
}
|
||||
}
|
||||
Request::ModifyPrivileges(ref database_privilege_diffs) => {
|
||||
let result = apply_privilege_diffs(
|
||||
database_privilege_diffs,
|
||||
unix_user,
|
||||
db_connection,
|
||||
db_is_mariadb,
|
||||
group_denylist,
|
||||
)
|
||||
.await;
|
||||
Response::ModifyPrivileges(result)
|
||||
}
|
||||
Request::CreateUsers(ref db_users) => {
|
||||
let result = create_database_users(
|
||||
db_users,
|
||||
unix_user,
|
||||
db_connection,
|
||||
db_is_mariadb,
|
||||
group_denylist,
|
||||
)
|
||||
.await;
|
||||
Response::CreateUsers(result)
|
||||
}
|
||||
Request::DropUsers(ref db_users) => {
|
||||
let result = drop_database_users(
|
||||
db_users,
|
||||
unix_user,
|
||||
db_connection,
|
||||
db_is_mariadb,
|
||||
group_denylist,
|
||||
)
|
||||
.await;
|
||||
Response::DropUsers(result)
|
||||
}
|
||||
Request::PasswdUser((ref db_user, ref password)) => {
|
||||
let result = set_password_for_database_user(
|
||||
db_user,
|
||||
password,
|
||||
unix_user,
|
||||
db_connection,
|
||||
db_is_mariadb,
|
||||
group_denylist,
|
||||
)
|
||||
.await;
|
||||
Response::SetUserPassword(result)
|
||||
}
|
||||
Request::ListUsers(ref db_users) => {
|
||||
if let Some(db_users) = db_users {
|
||||
let result = list_database_users(
|
||||
db_users,
|
||||
unix_user,
|
||||
db_connection,
|
||||
db_is_mariadb,
|
||||
group_denylist,
|
||||
)
|
||||
.await;
|
||||
Response::ListUsers(result)
|
||||
} else {
|
||||
let result = list_all_database_users_for_unix_user(
|
||||
unix_user,
|
||||
db_connection,
|
||||
db_is_mariadb,
|
||||
group_denylist,
|
||||
)
|
||||
.await;
|
||||
Response::ListAllUsers(result)
|
||||
}
|
||||
}
|
||||
Request::LockUsers(ref db_users) => {
|
||||
let result = lock_database_users(
|
||||
db_users,
|
||||
unix_user,
|
||||
db_connection,
|
||||
db_is_mariadb,
|
||||
group_denylist,
|
||||
)
|
||||
.await;
|
||||
Response::LockUsers(result)
|
||||
}
|
||||
Request::UnlockUsers(ref db_users) => {
|
||||
let result = unlock_database_users(
|
||||
db_users,
|
||||
unix_user,
|
||||
db_connection,
|
||||
db_is_mariadb,
|
||||
group_denylist,
|
||||
)
|
||||
.await;
|
||||
Response::UnlockUsers(result)
|
||||
}
|
||||
Request::Exit => {
|
||||
return Ok(false);
|
||||
}
|
||||
};
|
||||
|
||||
let response_to_display = match &response {
|
||||
Response::SetUserPassword(Err(SetPasswordError::MySqlError(_))) => {
|
||||
&Response::SetUserPassword(Err(SetPasswordError::MySqlError("<REDACTED>".to_string())))
|
||||
}
|
||||
response => response,
|
||||
};
|
||||
tracing::debug!(
|
||||
"Response:\n{}",
|
||||
serde_json::to_string_pretty(&response_to_display)?
|
||||
);
|
||||
|
||||
log_request(session_id, unix_user, &request, &response);
|
||||
|
||||
stream.send(response).await?;
|
||||
stream.flush().await?;
|
||||
tracing::trace!("Successfully processed request");
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Log a summary of the request and its result.
|
||||
fn log_request(
|
||||
session_id: SessionId,
|
||||
unix_user: &UnixUser,
|
||||
request: &Request,
|
||||
response: &Response,
|
||||
) {
|
||||
tracing::info!(
|
||||
"[{}|session:{}|user:{unix_user}] {}",
|
||||
response.ok_status(),
|
||||
session_id.inner(),
|
||||
request.log_summary(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ pub(super) async fn unsafe_database_exists(
|
||||
}
|
||||
|
||||
pub async fn complete_database_name(
|
||||
database_prefix: String,
|
||||
database_prefix: &str,
|
||||
unix_user: &UnixUser,
|
||||
connection: &mut MySqlConnection,
|
||||
_db_is_mariadb: bool,
|
||||
@@ -87,7 +87,7 @@ pub async fn complete_database_name(
|
||||
}
|
||||
|
||||
pub async fn create_databases(
|
||||
database_names: Vec<MySQLDatabase>,
|
||||
database_names: &[MySQLDatabase],
|
||||
unix_user: &UnixUser,
|
||||
connection: &mut MySqlConnection,
|
||||
_db_is_mariadb: bool,
|
||||
@@ -95,7 +95,7 @@ pub async fn create_databases(
|
||||
) -> CreateDatabasesResponse {
|
||||
let mut results = BTreeMap::new();
|
||||
|
||||
for database_name in database_names {
|
||||
for database_name in database_names.iter().cloned() {
|
||||
if let Err(err) = validate_db_or_user_request(
|
||||
&DbOrUser::Database(database_name.clone()),
|
||||
unix_user,
|
||||
@@ -143,7 +143,7 @@ pub async fn create_databases(
|
||||
}
|
||||
|
||||
pub async fn drop_databases(
|
||||
database_names: Vec<MySQLDatabase>,
|
||||
database_names: &[MySQLDatabase],
|
||||
unix_user: &UnixUser,
|
||||
connection: &mut MySqlConnection,
|
||||
_db_is_mariadb: bool,
|
||||
@@ -151,7 +151,7 @@ pub async fn drop_databases(
|
||||
) -> DropDatabasesResponse {
|
||||
let mut results = BTreeMap::new();
|
||||
|
||||
for database_name in database_names {
|
||||
for database_name in database_names.iter().cloned() {
|
||||
if let Err(err) = validate_db_or_user_request(
|
||||
&DbOrUser::Database(database_name.clone()),
|
||||
unix_user,
|
||||
@@ -242,7 +242,7 @@ impl FromRow<'_, sqlx::mysql::MySqlRow> for DatabaseRow {
|
||||
}
|
||||
|
||||
pub async fn list_databases(
|
||||
database_names: Vec<MySQLDatabase>,
|
||||
database_names: &[MySQLDatabase],
|
||||
unix_user: &UnixUser,
|
||||
connection: &mut MySqlConnection,
|
||||
_db_is_mariadb: bool,
|
||||
@@ -250,7 +250,7 @@ pub async fn list_databases(
|
||||
) -> ListDatabasesResponse {
|
||||
let mut results = BTreeMap::new();
|
||||
|
||||
for database_name in database_names {
|
||||
for database_name in database_names.iter().cloned() {
|
||||
if let Err(err) = validate_db_or_user_request(
|
||||
&DbOrUser::Database(database_name.clone()),
|
||||
unix_user,
|
||||
|
||||
@@ -138,7 +138,7 @@ pub async fn unsafe_get_database_privileges_for_db_user_pair(
|
||||
}
|
||||
|
||||
pub async fn get_databases_privilege_data(
|
||||
database_names: Vec<MySQLDatabase>,
|
||||
database_names: &[MySQLDatabase],
|
||||
unix_user: &UnixUser,
|
||||
connection: &mut MySqlConnection,
|
||||
_db_is_mariadb: bool,
|
||||
@@ -146,19 +146,19 @@ pub async fn get_databases_privilege_data(
|
||||
) -> ListPrivilegesResponse {
|
||||
let mut results = BTreeMap::new();
|
||||
|
||||
for database_name in &database_names {
|
||||
for database_name in database_names.iter().cloned() {
|
||||
if let Err(err) = validate_db_or_user_request(
|
||||
&DbOrUser::Database(database_name.clone()),
|
||||
&DbOrUser::Database(database_name.to_owned()),
|
||||
unix_user,
|
||||
group_denylist,
|
||||
)
|
||||
.map_err(ListPrivilegesError::ValidationError)
|
||||
{
|
||||
results.insert(database_name.to_owned(), Err(err));
|
||||
results.insert(database_name, Err(err));
|
||||
continue;
|
||||
}
|
||||
|
||||
match unsafe_database_exists(database_name, connection).await {
|
||||
match unsafe_database_exists(&database_name, connection).await {
|
||||
Ok(false) => {
|
||||
results.insert(
|
||||
database_name.to_owned(),
|
||||
@@ -176,7 +176,7 @@ pub async fn get_databases_privilege_data(
|
||||
Ok(true) => {}
|
||||
}
|
||||
|
||||
let result = unsafe_get_database_privileges(database_name, connection)
|
||||
let result = unsafe_get_database_privileges(&database_name, connection)
|
||||
.await
|
||||
.map_err(|e| ListPrivilegesError::MySqlError(e.to_string()));
|
||||
|
||||
@@ -400,7 +400,7 @@ async fn validate_diff(
|
||||
|
||||
/// Uses the result of [`diff_privileges`] to modify privileges in the database.
|
||||
pub async fn apply_privilege_diffs(
|
||||
database_privilege_diffs: BTreeSet<DatabasePrivilegesDiff>,
|
||||
database_privilege_diffs: &BTreeSet<DatabasePrivilegesDiff>,
|
||||
unix_user: &UnixUser,
|
||||
connection: &mut MySqlConnection,
|
||||
_db_is_mariadb: bool,
|
||||
@@ -468,12 +468,12 @@ pub async fn apply_privilege_diffs(
|
||||
Ok(true) => {}
|
||||
}
|
||||
|
||||
if let Err(err) = validate_diff(&diff, connection).await {
|
||||
if let Err(err) = validate_diff(diff, connection).await {
|
||||
results.insert(key, Err(err));
|
||||
continue;
|
||||
}
|
||||
|
||||
let result = unsafe_apply_privilege_diff(&diff, connection)
|
||||
let result = unsafe_apply_privilege_diff(diff, connection)
|
||||
.await
|
||||
.map_err(|e| ModifyDatabasePrivilegesError::MySqlError(e.to_string()));
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ pub(super) async fn unsafe_user_exists(
|
||||
}
|
||||
|
||||
pub async fn complete_user_name(
|
||||
user_prefix: String,
|
||||
user_prefix: &str,
|
||||
unix_user: &UnixUser,
|
||||
connection: &mut MySqlConnection,
|
||||
_db_is_mariadb: bool,
|
||||
@@ -95,7 +95,7 @@ pub async fn complete_user_name(
|
||||
}
|
||||
|
||||
pub async fn create_database_users(
|
||||
db_users: Vec<MySQLUser>,
|
||||
db_users: &[MySQLUser],
|
||||
unix_user: &UnixUser,
|
||||
connection: &mut MySqlConnection,
|
||||
_db_is_mariadb: bool,
|
||||
@@ -103,7 +103,7 @@ pub async fn create_database_users(
|
||||
) -> CreateUsersResponse {
|
||||
let mut results = BTreeMap::new();
|
||||
|
||||
for db_user in db_users {
|
||||
for db_user in db_users.iter().cloned() {
|
||||
if let Err(err) =
|
||||
validate_db_or_user_request(&DbOrUser::User(db_user.clone()), unix_user, group_denylist)
|
||||
.map_err(CreateUserError::ValidationError)
|
||||
@@ -141,7 +141,7 @@ pub async fn create_database_users(
|
||||
}
|
||||
|
||||
pub async fn drop_database_users(
|
||||
db_users: Vec<MySQLUser>,
|
||||
db_users: &[MySQLUser],
|
||||
unix_user: &UnixUser,
|
||||
connection: &mut MySqlConnection,
|
||||
_db_is_mariadb: bool,
|
||||
@@ -149,7 +149,7 @@ pub async fn drop_database_users(
|
||||
) -> DropUsersResponse {
|
||||
let mut results = BTreeMap::new();
|
||||
|
||||
for db_user in db_users {
|
||||
for db_user in db_users.iter().cloned() {
|
||||
if let Err(err) =
|
||||
validate_db_or_user_request(&DbOrUser::User(db_user.clone()), unix_user, group_denylist)
|
||||
.map_err(DropUserError::ValidationError)
|
||||
@@ -188,8 +188,7 @@ pub async fn drop_database_users(
|
||||
|
||||
pub async fn set_password_for_database_user(
|
||||
db_user: &MySQLUser,
|
||||
password: Option<&str>,
|
||||
expiry: Option<chrono::NaiveDate>,
|
||||
password: &str,
|
||||
unix_user: &UnixUser,
|
||||
connection: &mut MySqlConnection,
|
||||
_db_is_mariadb: bool,
|
||||
@@ -198,44 +197,24 @@ pub async fn set_password_for_database_user(
|
||||
validate_db_or_user_request(&DbOrUser::User(db_user.clone()), unix_user, group_denylist)
|
||||
.map_err(SetPasswordError::ValidationError)?;
|
||||
|
||||
if password.is_none() && expiry.is_some() {
|
||||
return Err(SetPasswordError::ClearPasswordWithExpiry);
|
||||
}
|
||||
|
||||
match unsafe_user_exists(db_user, &mut *connection).await {
|
||||
Ok(false) => return Err(SetPasswordError::UserDoesNotExist),
|
||||
Err(err) => return Err(SetPasswordError::MySqlError(err.to_string())),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let result = if let Some(password) = password {
|
||||
let mut query = format!(
|
||||
let result = sqlx::query(
|
||||
format!(
|
||||
"ALTER USER {}@'%' IDENTIFIED BY {}",
|
||||
quote_literal(db_user),
|
||||
quote_literal(password).as_str(),
|
||||
);
|
||||
|
||||
if let Some(expiry_date) = expiry {
|
||||
query.push_str(&format!(" PASSWORD EXPIRE DATE '{}'", expiry_date));
|
||||
}
|
||||
|
||||
sqlx::query(query.as_str())
|
||||
.execute(&mut *connection)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|err| SetPasswordError::MySqlError(err.to_string()))
|
||||
} else {
|
||||
let query = format!(
|
||||
"ALTER USER {}@'%' IDENTIFIED WITH mysql_native_password AS ''",
|
||||
quote_literal(db_user),
|
||||
);
|
||||
|
||||
sqlx::query(query.as_str())
|
||||
.execute(&mut *connection)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|err| SetPasswordError::MySqlError(err.to_string()))
|
||||
};
|
||||
)
|
||||
.as_str(),
|
||||
)
|
||||
.execute(&mut *connection)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|err| SetPasswordError::MySqlError(err.to_string()));
|
||||
|
||||
if result.is_err() {
|
||||
tracing::error!(
|
||||
@@ -293,7 +272,7 @@ async fn database_user_is_locked_unsafe(
|
||||
}
|
||||
|
||||
pub async fn lock_database_users(
|
||||
db_users: Vec<MySQLUser>,
|
||||
db_users: &[MySQLUser],
|
||||
unix_user: &UnixUser,
|
||||
connection: &mut MySqlConnection,
|
||||
db_is_mariadb: bool,
|
||||
@@ -301,7 +280,7 @@ pub async fn lock_database_users(
|
||||
) -> LockUsersResponse {
|
||||
let mut results = BTreeMap::new();
|
||||
|
||||
for db_user in db_users {
|
||||
for db_user in db_users.iter().cloned() {
|
||||
if let Err(err) =
|
||||
validate_db_or_user_request(&DbOrUser::User(db_user.clone()), unix_user, group_denylist)
|
||||
.map_err(LockUserError::ValidationError)
|
||||
@@ -353,7 +332,7 @@ pub async fn lock_database_users(
|
||||
}
|
||||
|
||||
pub async fn unlock_database_users(
|
||||
db_users: Vec<MySQLUser>,
|
||||
db_users: &[MySQLUser],
|
||||
unix_user: &UnixUser,
|
||||
connection: &mut MySqlConnection,
|
||||
db_is_mariadb: bool,
|
||||
@@ -361,7 +340,7 @@ pub async fn unlock_database_users(
|
||||
) -> UnlockUsersResponse {
|
||||
let mut results = BTreeMap::new();
|
||||
|
||||
for db_user in db_users {
|
||||
for db_user in db_users.iter().cloned() {
|
||||
if let Err(err) =
|
||||
validate_db_or_user_request(&DbOrUser::User(db_user.clone()), unix_user, group_denylist)
|
||||
.map_err(UnlockUserError::ValidationError)
|
||||
@@ -461,7 +440,7 @@ FROM `user`
|
||||
";
|
||||
|
||||
pub async fn list_database_users(
|
||||
db_users: Vec<MySQLUser>,
|
||||
db_users: &[MySQLUser],
|
||||
unix_user: &UnixUser,
|
||||
connection: &mut MySqlConnection,
|
||||
db_is_mariadb: bool,
|
||||
@@ -469,7 +448,7 @@ pub async fn list_database_users(
|
||||
) -> ListUsersResponse {
|
||||
let mut results = BTreeMap::new();
|
||||
|
||||
for db_user in db_users {
|
||||
for db_user in db_users.iter().cloned() {
|
||||
if let Err(err) =
|
||||
validate_db_or_user_request(&DbOrUser::User(db_user.clone()), unix_user, group_denylist)
|
||||
.map_err(ListUsersError::ValidationError)
|
||||
|
||||
@@ -2,7 +2,10 @@ use std::{
|
||||
fs,
|
||||
os::{fd::FromRawFd, unix::net::UnixListener as StdUnixListener},
|
||||
path::PathBuf,
|
||||
sync::Arc,
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicU64, Ordering},
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
@@ -22,7 +25,7 @@ use crate::{
|
||||
server::{
|
||||
authorization::read_and_parse_group_denylist,
|
||||
config::{MysqlConfig, ServerConfig},
|
||||
session_handler::session_handler,
|
||||
session_handler::{SessionId, session_handler},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -137,7 +140,7 @@ impl Supervisor {
|
||||
|
||||
let (tx, rx) = broadcast::channel(1);
|
||||
|
||||
// TODO: try to detech systemd socket before using the provided socket path
|
||||
// TODO: try to detect systemd socket before using the provided socket path
|
||||
#[cfg(target_os = "linux")]
|
||||
let listener = Arc::new(RwLock::new(match config.socket_path {
|
||||
Some(ref path) => create_unix_listener_with_socket_path(path.clone()).await?,
|
||||
@@ -292,7 +295,15 @@ impl Supervisor {
|
||||
|
||||
pub async fn reload(&self) -> anyhow::Result<()> {
|
||||
#[cfg(target_os = "linux")]
|
||||
sd_notify::notify(false, &[sd_notify::NotifyState::Reloading])?;
|
||||
sd_notify::notify(
|
||||
false,
|
||||
&[
|
||||
sd_notify::NotifyState::Reloading,
|
||||
sd_notify::NotifyState::monotonic_usec_now()
|
||||
.expect("Failed to get monotonic time to send to systemd while reloading"),
|
||||
sd_notify::NotifyState::Status("Reloading configuration"),
|
||||
],
|
||||
)?;
|
||||
|
||||
let previous_config = self.config.lock().await.clone();
|
||||
self.reload_config().await?;
|
||||
@@ -548,6 +559,8 @@ async fn listener_task(
|
||||
#[cfg(target_os = "linux")]
|
||||
sd_notify::notify(false, &[sd_notify::NotifyState::Ready])?;
|
||||
|
||||
let connection_counter = AtomicU64::new(0);
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
biased;
|
||||
@@ -577,28 +590,29 @@ async fn listener_task(
|
||||
} => {
|
||||
match accept_result {
|
||||
Ok((conn, _addr)) => {
|
||||
tracing::debug!("Got new connection");
|
||||
connection_counter.fetch_add(1, Ordering::Relaxed);
|
||||
let conn_id = connection_counter.load(Ordering::Relaxed);
|
||||
|
||||
tracing::debug!("Got new connection, assigned session ID {}", conn_id);
|
||||
|
||||
let session_id = SessionId::new(conn_id);
|
||||
let db_pool_clone = db_pool.clone();
|
||||
let db_is_mariadb_clone = *db_is_mariadb.read().await;
|
||||
let group_denylist_arc_clone = group_denylist.clone();
|
||||
task_tracker.spawn(async move {
|
||||
match session_handler(
|
||||
conn,
|
||||
session_id,
|
||||
db_pool_clone,
|
||||
db_is_mariadb_clone,
|
||||
&*group_denylist_arc_clone.read().await,
|
||||
).await {
|
||||
Ok(()) => {}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to run server: {}", e);
|
||||
}
|
||||
Ok(()) => {},
|
||||
Err(e) => tracing::error!("Session {} failed: {}", conn_id, e),
|
||||
}
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to accept new connection: {}", e);
|
||||
}
|
||||
},
|
||||
Err(e) => tracing::error!("Failed to accept new connection: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user