50 Commits

Author SHA1 Message Date
oysteikt 772942eed0 WIP: show-db: limit output to 5 tables/users by default 2026-05-31 05:51:02 +09:00
oysteikt 43e4cc45ca server/sql: great performance improvements for listing databases
Build and test / check-license (push) Successful in 53s
Build and test / check (push) Successful in 1m54s
Build and test / build (push) Successful in 3m5s
Build and test / test (push) Successful in 3m9s
Build and test / docs (push) Successful in 5m36s
2026-05-31 02:01:44 +09:00
oysteikt 62b1b66bb6 CHANGELOG.md: fix broken link
Build and test / check-license (push) Successful in 55s
Build and test / check (push) Successful in 2m38s
Build and test / build (push) Successful in 2m42s
Build and test / test (push) Successful in 3m8s
Build and test / docs (push) Successful in 6m8s
2026-05-31 00:44:52 +09:00
oysteikt f16239aceb server/sql: fixes for new sqlx crate version
Build and test / check-license (push) Successful in 49s
Build and test / check (push) Successful in 1m51s
Build and test / build (push) Successful in 2m42s
Build and test / test (push) Successful in 5m2s
Build and test / docs (push) Successful in 7m6s
2026-05-31 00:24:53 +09:00
oysteikt 8f475eced1 CHANGELOG.md: add release notes, Cargo.toml: bump version number
Build and test / check-license (push) Successful in 49s
Build and test / check (push) Failing after 1m57s
Build and test / test (push) Failing after 2m53s
Build and test / build (push) Failing after 3m11s
Build and test / docs (push) Failing after 4m15s
2026-05-31 00:09:49 +09:00
oysteikt 6849e99c11 flake.lock: bump, Cargo.{toml,lock}: update inputs 2026-05-31 00:09:40 +09:00
oysteikt 759df9ef42 server/sql: flush privileges after modification
Build and test / check-license (push) Successful in 54s
Build and test / check (push) Successful in 1m42s
Build and test / test (push) Successful in 3m34s
Build and test / build (push) Successful in 3m39s
Build and test / docs (push) Successful in 5m33s
2026-04-28 19:10:16 +09:00
oysteikt a64d1fa1bf scripts/download-and-upload-debs: fix download path
Build and test / check-license (push) Successful in 47s
Build and test / check (push) Successful in 2m20s
Build and test / build (push) Successful in 3m6s
Build and test / test (push) Successful in 3m12s
Build and test / docs (push) Successful in 6m20s
2026-04-28 18:32:28 +09:00
oysteikt 6404e5011a CHANGELOG.md: add release notes, Cargo.toml: bump version number
Build and test / check-license (push) Successful in 52s
Build and test / check (push) Successful in 1m44s
Build and test / build (push) Successful in 3m1s
Build and test / test (push) Successful in 3m12s
Build and test / docs (push) Successful in 7m24s
2026-04-28 18:14:48 +09:00
oysteikt 531fdfc2e9 Cargo.{toml,lock}: bump deps 2026-04-28 18:14:19 +09:00
oysteikt af74e8e540 .gitea/workflows/publish-deb: temporarily disable ubuntu resolute
Build and test / check-license (push) Successful in 51s
Build and test / check (push) Successful in 1m47s
Build and test / build (push) Successful in 2m44s
Build and test / test (push) Successful in 3m31s
Build and test / docs (push) Successful in 5m31s
2026-04-28 18:02:34 +09:00
oysteikt 4132fb58e8 client/various: sort output
Build and test / check (push) Successful in 1m54s
Build and test / check-license (push) Successful in 2m11s
Build and test / build (push) Successful in 2m47s
Build and test / test (push) Successful in 3m13s
Build and test / docs (push) Successful in 6m12s
2026-04-28 17:58:17 +09:00
oysteikt 40c7a935b3 assets/systemd: always restart service when it dies
Build and test / check-license (push) Successful in 46s
Build and test / check (push) Successful in 1m53s
Build and test / build (push) Successful in 3m20s
Build and test / test (push) Successful in 3m33s
Build and test / docs (push) Successful in 5m41s
2026-04-28 17:34:25 +09:00
oysteikt 5aca2314c4 core/protocol: make ModifyPrivileges response serializable
Build and test / check-license (push) Successful in 46s
Build and test / check (push) Successful in 2m25s
Build and test / build (push) Successful in 3m0s
Build and test / test (push) Successful in 3m34s
Build and test / docs (push) Successful in 5m55s
2026-04-28 17:27:40 +09:00
oysteikt 7a9b233611 core/types: add custom de/serialization for DbOrUser
Build and test / check (push) Successful in 1m46s
Build and test / check-license (push) Successful in 2m5s
Build and test / test (push) Failing after 2m54s
Build and test / build (push) Successful in 3m0s
Build and test / docs (push) Successful in 5m20s
2026-04-28 07:45:46 +09:00
oysteikt 5444ab46ca core/protocol: test de/serialization of all protocol messages
Build and test / check (push) Successful in 1m42s
Build and test / check-license (push) Successful in 57s
Build and test / build (push) Successful in 2m40s
Build and test / test (push) Failing after 3m45s
Build and test / docs (push) Successful in 5m23s
2026-04-28 07:30:08 +09:00
oysteikt b12acbf3b4 .gitea/workflows/publish-deb: remove double file extension 2026-04-28 07:14:07 +09:00
oysteikt 8e2aace9d4 server: specify Host for all relevant sql queries 2026-04-28 07:14:06 +09:00
oysteikt 913aad5758 .gitea/workflows/publish-deb: build for ubuntu resolute
Build and test / check-license (push) Successful in 1m26s
Build and test / check (push) Successful in 2m27s
Build and test / build (push) Successful in 3m33s
Build and test / test (push) Successful in 3m15s
Build and test / docs (push) Successful in 6m4s
2026-04-24 05:14:46 +09:00
oysteikt 1d4a19c299 core/types: better fmt::Display implementation for newtypes
Build and test / check-license (push) Successful in 58s
Build and test / check (push) Successful in 2m0s
Build and test / build (push) Successful in 2m50s
Build and test / test (push) Successful in 3m24s
Build and test / docs (push) Successful in 6m53s
2026-04-15 05:09:49 +09:00
oysteikt 9b279a4956 flake.lock: bump, Cargo.{toml,lock}: update inputs
Build and test / check-license (push) Successful in 1m7s
Build and test / check (push) Successful in 1m47s
Build and test / build (push) Successful in 2m48s
Build and test / test (push) Successful in 3m48s
Build and test / docs (push) Successful in 6m46s
2026-04-02 14:00:30 +09:00
oysteikt 124cf9e69e nix/package: fix license meta field
Build and test / check-license (push) Successful in 55s
Build and test / check (push) Successful in 1m53s
Build and test / build (push) Successful in 3m7s
Build and test / test (push) Successful in 4m55s
Build and test / docs (push) Successful in 5m42s
2026-02-12 11:28:14 +09:00
oysteikt 3fe6a3edea flake.lock: bump, Cargo.{toml,lock}: update inputs
Build and test / check (push) Successful in 1m50s
Build and test / check-license (push) Successful in 2m4s
Build and test / build (push) Successful in 3m8s
Build and test / test (push) Successful in 4m7s
Build and test / docs (push) Successful in 6m14s
2026-01-31 12:22:53 +09:00
oysteikt 65e02192dd scripts/download-and-publish-debs: comment out DEL request
Build and test / check-license (push) Successful in 57s
Build and test / check (push) Successful in 2m2s
Build and test / build (push) Successful in 3m43s
Build and test / test (push) Successful in 3m21s
Build and test / docs (push) Successful in 6m22s
This is not a good thing to do now that we have published a stable
version.
2026-01-14 00:48:51 +09:00
oysteikt b2d9400f0e Cargo.toml: 0.1.0 -> 1.0.0
Build and test / check-license (push) Successful in 1m3s
Build and test / check (push) Successful in 2m3s
Build and test / build (push) Successful in 3m35s
Build and test / test (push) Successful in 3m19s
Build and test / docs (push) Successful in 6m41s
2026-01-14 00:30:18 +09:00
oysteikt 2838c584d3 Cargo.toml: state Programvareverkstedet as author 2026-01-14 00:29:46 +09:00
oysteikt ce75aa509d client: add better error messages on failed server connection
Build and test / check-license (push) Successful in 53s
Build and test / check (push) Successful in 2m28s
Build and test / build (push) Successful in 3m11s
Build and test / test (push) Successful in 3m18s
Build and test / docs (push) Successful in 8m0s
2026-01-12 21:12:18 +09:00
oysteikt 87ef63b680 assets/debian/systemd: remove socket on stop 2026-01-12 21:06:25 +09:00
oysteikt 6686b3bbe7 scripts/download-and-upload-debs: add extra logging
Build and test / check-license (push) Successful in 1m54s
Build and test / check (push) Successful in 2m2s
Build and test / build (push) Successful in 2m51s
Build and test / test (push) Successful in 4m49s
Build and test / docs (push) Successful in 6m25s
2026-01-12 16:50:05 +09:00
oysteikt f75b34f40c server: don't warn on empty/comment only lines in group denylists
Build and test / check-license (push) Successful in 57s
Build and test / docs (push) Has been cancelled
Build and test / build (push) Has been cancelled
Build and test / check (push) Has been cancelled
Build and test / test (push) Has been cancelled
2026-01-12 16:48:58 +09:00
oysteikt 902970f271 server: fix systemd reload notifs
Build and test / check-license (push) Successful in 1m0s
Build and test / check (push) Successful in 1m52s
Build and test / build (push) Successful in 3m32s
Build and test / test (push) Successful in 3m41s
Build and test / docs (push) Successful in 5m31s
2026-01-12 16:26:20 +09:00
oysteikt a141e97beb nix/module: use Type=notify-reload 2026-01-12 16:25:36 +09:00
oysteikt 4f1030f1d8 assets/debian/systemd: increase watchdog timeout
Build and test / check-license (push) Successful in 57s
Build and test / build (push) Successful in 3m9s
Build and test / check (push) Successful in 2m24s
Build and test / test (push) Successful in 3m18s
Build and test / docs (push) Successful in 6m17s
2026-01-12 16:04:55 +09:00
oysteikt 206f459d79 assets/debian/systemd: add [Install] section for service unit
Build and test / check-license (push) Successful in 59s
Build and test / check (push) Successful in 2m46s
Build and test / build (push) Successful in 2m50s
Build and test / docs (push) Has been cancelled
Build and test / test (push) Has been cancelled
2026-01-12 16:00:38 +09:00
oysteikt 09e7a22f24 Fix a few typos
Build and test / check-license (push) Successful in 1m4s
Build and test / check (push) Successful in 2m51s
Build and test / build (push) Successful in 2m54s
Build and test / test (push) Successful in 3m45s
Build and test / docs (push) Successful in 8m58s
2026-01-12 15:35:21 +09:00
oysteikt ef42272087 docs: move admin docs to separate documents, expand some sections
Build and test / check (push) Successful in 2m4s
Build and test / build (push) Successful in 2m55s
Build and test / check-license (push) Successful in 2m13s
Build and test / test (push) Successful in 3m46s
Build and test / docs (push) Successful in 5m46s
2026-01-12 15:15:50 +09:00
oysteikt 0baa58a820 flake.lock: bump, Cargo.{toml,lock}: update inputs 2026-01-12 14:35:26 +09:00
oysteikt b2d56e1c85 README: add note about vm
Build and test / check-license (push) Successful in 55s
Build and test / check (push) Successful in 3m5s
Build and test / build (push) Successful in 3m9s
Build and test / test (push) Successful in 3m16s
Build and test / docs (push) Successful in 7m52s
2026-01-11 23:08:11 +09:00
oysteikt ee33c96120 Rename entrypoints dir to bin
Build and test / check-license (push) Successful in 57s
Build and test / check (push) Successful in 2m7s
Build and test / build (push) Successful in 3m32s
Build and test / test (push) Successful in 3m19s
Build and test / docs (push) Successful in 6m21s
2026-01-09 19:14:20 +09:00
oysteikt 94996038c2 nix/module: render group denylist items as gids with comments when possible
Build and test / check (push) Successful in 2m6s
Build and test / build (push) Successful in 2m51s
Build and test / check-license (push) Successful in 1m14s
Build and test / test (push) Successful in 3m22s
Build and test / docs (push) Has been cancelled
2026-01-09 18:49:47 +09:00
oysteikt beb08e1b35 server/auth: allow inline comments for denylist, add test for parser 2026-01-09 18:40:42 +09:00
oysteikt 6a3212bde2 assets/debian/group_denylist: add some more default groups 2026-01-09 17:48:27 +09:00
oysteikt 3ce2a13711 server: demote a few debug log messages to trace
Build and test / check (push) Successful in 2m0s
Build and test / build (push) Successful in 2m52s
Build and test / check-license (push) Successful in 1m7s
Build and test / test (push) Successful in 3m20s
Build and test / docs (push) Successful in 6m59s
2026-01-09 17:36:04 +09:00
oysteikt fbe594d486 server: log once per request, add session ids 2026-01-09 17:06:28 +09:00
oysteikt 2ec31cd146 server: add request tracing span, log affected users/databases 2026-01-09 15:50:19 +09:00
oysteikt 6e648004b5 README: add a history section, add a missing backtick
Build and test / check-license (push) Successful in 1m2s
Build and test / check (push) Successful in 2m43s
Build and test / build (push) Successful in 2m48s
Build and test / test (push) Successful in 3m39s
Build and test / docs (push) Successful in 7m28s
2026-01-04 20:29:21 +09:00
oysteikt cb4b8a78dc session_handler: don't clone request before tracing
Build and test / check-license (push) Successful in 58s
Build and test / check (push) Successful in 2m5s
Build and test / build (push) Successful in 3m45s
Build and test / test (push) Successful in 3m28s
Build and test / docs (push) Successful in 6m43s
2026-01-03 23:04:04 +09:00
oysteikt b9f11d0413 flake.lock: bump, Cargo.{toml,lock}: update inputs
Build and test / test (push) Successful in 3m31s
Build and test / docs (push) Successful in 7m50s
Build and test / check (push) Successful in 1m52s
Build and test / check-license (push) Successful in 2m21s
Build and test / build (push) Successful in 3m10s
2025-12-29 19:02:48 +09:00
oysteikt 9f45c2e5da client: error out on non-tty interactivity
Build and test / build (push) Successful in 3m25s
Build and test / test (push) Successful in 3m41s
Build and test / docs (push) Successful in 5m34s
Build and test / check-license (push) Successful in 1m0s
Build and test / check (push) Successful in 1m53s
2025-12-23 17:40:07 +09:00
oysteikt 107333208c client: add subcommand aliases
Build and test / check-license (push) Successful in 1m37s
Build and test / check (push) Successful in 1m56s
Build and test / build (push) Successful in 3m12s
Build and test / test (push) Successful in 4m39s
Build and test / docs (push) Successful in 5m45s
2025-12-23 15:45:53 +09:00
51 changed files with 2653 additions and 1254 deletions
+8 -2
View File
@@ -31,7 +31,13 @@ jobs:
build-deb:
strategy:
matrix:
os: [debian-trixie, debian-bookworm, ubuntu-noble, ubuntu-jammy]
os: [
debian-trixie,
debian-bookworm,
# ubuntu-resolute,
ubuntu-noble,
ubuntu-jammy,
]
name: Build and publish for ${{ matrix.os }}
runs-on: ${{ matrix.os }}
steps:
@@ -63,7 +69,7 @@ jobs:
- name: Upload deb package artifact
uses: actions/upload-artifact@v3
with:
name: muscl-deb-${{ matrix.os }}-${{ gitea.sha }}.zip
name: muscl-deb-${{ matrix.os }}-${{ gitea.sha }}
path: target/debian/*.deb
if-no-files-found: error
retention-days: 30
+23 -1
View File
@@ -1,5 +1,27 @@
# Changelog
## v1.0.2
Patch release with an important bug fix
### Notable changes
- Run `FLUSH PRIVILEGES` on the server whenever users modify privileges.
- You will have to grant `RELOAD` for the muscl admin user on all databases, see the [installation docs](./docs/installation.md) for details.
- Bump dependencies
## v1.0.1
Patch release with some important bug fixes
### Notable changes
- `mysql.db.Host` would usually be unset when creating privileges for users, this should be fixed now.
- You might have to manually set this field for rows created with the previous version of muscl to have those privileges work properly.
- Fixed an issue where a few select server responses would refuse to serialize properly, leading to an error message: "No response from server"
- The output of various commands is now being sorted.
- Bump dependencies
## v1.0.0 - Initial Release
This is the initial release of `muscl`.
@@ -54,7 +76,7 @@ This is the initial release of `muscl`.
interactive tool, there shouldn't have been any scripts relying on the old formatting.
- The configuration file is shared for all variants of the program, and `muscl` will use
its new logic to look for and parse this file. See the example config and
[installation instructions][installation-instructions] for more information about how to
[installation instructions](./docs/installation.md) for more information about how to
configure the software.
- The order in which input is validated might be differ from the original
(e.g. database ownership checks, invalid character checks, existence checks, ...).
Generated
+677 -644
View File
File diff suppressed because it is too large Load Diff
+26 -27
View File
@@ -1,12 +1,11 @@
[package]
name = "muscl"
version = "0.1.0"
version = "1.0.2"
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"
@@ -19,50 +18,50 @@ autobins = false
autolib = false
[dependencies]
anyhow = "1.0.100"
anyhow = "1.0.102"
async-bincode = "0.8.0"
bincode = "2.0.1"
clap = { version = "4.5.53", features = ["cargo", "derive"] }
clap = { version = "4.6.1", 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.6.5", features = ["unstable-dynamic"] }
color-print = "0.3.7"
const_format = "0.2.35"
const_format = "0.2.36"
derive_more = { version = "2.1.1", features = ["display", "error"] }
dialoguer = "0.12.0"
futures-util = "0.3.31"
futures-util = "0.3.32"
humansize = "2.1.3"
indoc = "2.0.7"
itertools = "0.14.0"
nix = { version = "0.30.1", features = ["fs", "process", "socket", "user"] }
nix = { version = "0.31.3", features = ["fs", "process", "socket", "user"] }
num_cpus = "1.17.0"
prettytable = "0.10.0"
rand = "0.9.2"
rand = "0.10.1"
serde = "1.0.228"
serde_json = { version = "1.0.146", 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"] }
serde_json = { version = "1.0.150", features = ["preserve_order"] }
sqlx = { version = "0.9.0", features = ["runtime-tokio", "mysql", "tls-rustls"] }
thiserror = "2.0.18"
tokio = { version = "1.52.3", 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 = "1.1.2"
tracing = { version = "0.1.44", features = ["log"] }
tracing-subscriber = "0.3.22"
uuid = { version = "1.19.0", features = ["v4"] }
tracing-subscriber = "0.3.23"
uuid = { version = "1.23.2", features = ["v4"] }
[target.'cfg(target_os = "linux")'.dependencies]
landlock = "0.4.4"
sd-notify = "0.4.5"
landlock = "0.4.5"
sd-notify = "0.5.0"
tracing-journald = "0.3.2"
[build-dependencies]
anyhow = "1.0.100"
build-info-build = "0.0.42"
git2 = { version = "0.20.3", default-features = false }
anyhow = "1.0.102"
build-info-build = "0.0.44"
git2 = { version = "0.21.0", default-features = false }
[dev-dependencies]
pretty_assertions = "1.4.1"
regex = "1.12.2"
regex = "1.12.3"
[features]
default = ["mysql-admutils-compatibility"]
@@ -76,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"
+12 -2
View File
@@ -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.
+9 -1
View File
@@ -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
+7 -1
View File
@@ -8,7 +8,9 @@ Type=notify
ExecStart=/usr/bin/muscl-server --systemd --disable-landlock socket-activate
ExecReload=/usr/bin/kill -HUP $MAINPID
WatchdogSec=15
WatchdogSec=3min
Restart=always
RestartSec=10s
# 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 +63,7 @@ SystemCallFilter=@system-service
SystemCallFilter=~@privileged @resources
UMask=0777
[Install]
Also=muscl.socket
WantedBy=multi-user.target
+1
View File
@@ -3,6 +3,7 @@ Description=Muscl MySQL admin tool
[Socket]
ListenStream=/run/muscl/muscl.sock
RemoveOnStop=true
Accept=no
PassCredentials=true
+90
View 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
+6 -9
View File
@@ -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>
```
+4 -24
View File
@@ -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:
@@ -40,7 +42,7 @@ on the MySQL server as the admin user (or another user with sufficient privilege
```sql
CREATE USER `muscl`@`localhost` IDENTIFIED BY '<strong_password_here>';
GRANT SELECT, INSERT, UPDATE, DELETE ON `mysql`.* TO `muscl`@`localhost`;
GRANT GRANT OPTION, CREATE, DROP ON *.* TO `muscl`@`localhost`;
GRANT GRANT OPTION, CREATE, DROP, RELOAD ON *.* TO `muscl`@`localhost`;
FLUSH PRIVILEGES;
```
@@ -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.
+2 -2
View File
@@ -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.
Generated
+9 -9
View File
@@ -2,11 +2,11 @@
"nodes": {
"crane": {
"locked": {
"lastModified": 1766194365,
"narHash": "sha256-4AFsUZ0kl6MXSm4BaQgItD0VGlEKR3iq7gIaL7TjBvc=",
"lastModified": 1780099841,
"narHash": "sha256-EVZd2RsbpreRUDSi9rBwPY+ZxoyMaiEBbZxxhljbaS4=",
"owner": "ipetkov",
"repo": "crane",
"rev": "7d8ec2c71771937ab99790b45e6d9b93d15d9379",
"rev": "0532eb17955225173906d671fb36306bdeb1e2dc",
"type": "github"
},
"original": {
@@ -17,11 +17,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1766309749,
"narHash": "sha256-3xY8CZ4rSnQ0NqGhMKAy5vgC+2IVK0NoVEzDoOh4DA4=",
"lastModified": 1779560665,
"narHash": "sha256-tpyBcxPpcQb8ukyNF7DoCwfSY3VPsxHoYwj00Cayv5o=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "a6531044f6d0bef691ea18d4d4ce44d0daa6e816",
"rev": "64c08a7ca051951c8eae34e3e3cb1e202fe36786",
"type": "github"
},
"original": {
@@ -45,11 +45,11 @@
]
},
"locked": {
"lastModified": 1766457837,
"narHash": "sha256-aeBbkQ0HPFNOIsUeEsXmZHXbYq4bG8ipT9JRlCcKHgU=",
"lastModified": 1780110990,
"narHash": "sha256-6QBThUi7SuK+dgA+DCaEkQGZN4kYx6DpXmK45+MG9zI=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "2c7510a559416d07242621d036847152d970612b",
"rev": "85570ef134d92a8702de6afd1f6f0209c863fa91",
"type": "github"
},
"original": {
+1 -1
View File
@@ -88,7 +88,7 @@ buildFunction ({
'';
meta = with lib; {
license = licenses.mit;
license = licenses.bsd3;
platforms = platforms.linux ++ platforms.darwin;
inherit mainProgram;
};
+31 -7
View File
@@ -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";
+16 -6
View File
@@ -42,9 +42,17 @@ declare -r GIT_SHA="$2"
TMPDIR="$(mktemp -d)"
for variant in debian-bookworm debian-trixie ubuntu-jammy ubuntu-noble; do
declare -a OS_VARIANTS=(
"debian-bookworm"
"debian-trixie"
"ubuntu-jammy"
"ubuntu-noble"
# "ubuntu-resolute"
)
for variant in "${OS_VARIANTS[@]}"; do
echo "Downloading and uploading debs for variant: $variant"
curl "https://git.pvv.ntnu.no/Projects/muscl/actions/runs/$RUN_NUMBER/artifacts/muscl-deb-$variant-$GIT_SHA.zip" --output "$TMPDIR/muscl-deb-$variant-$GIT_SHA.zip"
curl "https://git.pvv.ntnu.no/Projects/muscl/actions/runs/$RUN_NUMBER/artifacts/muscl-deb-$variant-$GIT_SHA" --output "$TMPDIR/muscl-deb-$variant-$GIT_SHA.zip"
unzip "$TMPDIR/muscl-deb-$variant-$GIT_SHA.zip" -d "$TMPDIR/muscl-deb-$variant-$GIT_SHA"
@@ -54,11 +62,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)
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 "[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" \
+17 -2
View File
@@ -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,
+11
View File
@@ -1,3 +1,5 @@
use std::io::IsTerminal;
use clap::Parser;
use clap_complete::ArgValueCompleter;
use dialoguer::Confirm;
@@ -78,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()
+8 -1
View File
@@ -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.");
+8
View File
@@ -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!(
+21 -7
View File
@@ -1,10 +1,14 @@
use std::collections::{BTreeMap, BTreeSet};
use std::{
collections::{BTreeMap, BTreeSet},
io::IsTerminal,
};
use anyhow::Context;
use clap::{Args, Parser};
use clap_complete::ArgValueCompleter;
use dialoguer::{Confirm, Editor};
use futures_util::SinkExt;
use itertools::Itertools;
use nix::unistd::{User, getuid};
use tokio_stream::StreamExt;
@@ -19,7 +23,7 @@ use crate::{
parse_privilege_data_from_editor_content, reduce_privilege_diffs,
},
protocol::{
ClientToServerMessageStream, ListDatabasesError, ListUsersError,
ClientToServerMessageStream, ListDatabasesError, ListDatabasesRequest, ListUsersError,
ModifyDatabasePrivilegesError, Request, Response,
print_modify_database_privileges_output_status, request_validation::ValidationError,
},
@@ -128,7 +132,7 @@ async fn databases_exist(
.map(|diff| diff.get_database_name().clone())
.collect();
let message = Request::ListDatabases(Some(database_list));
let message = Request::ListDatabases(ListDatabasesRequest::new(Some(database_list), false));
server_connection.send(message).await?;
let result = match server_connection.next().await {
@@ -200,9 +204,13 @@ pub async fn edit_database_privileges(
}
})
.flatten()
.sorted_by_key(|row| (row.db.clone(), row.user.clone()))
.collect::<Vec<_>>(),
Some(Ok(Response::ListAllPrivileges(privilege_rows))) => match privilege_rows {
Ok(list) => list,
Ok(list) => list
.into_iter()
.sorted_by_key(|row| (row.db.clone(), row.user.clone()))
.collect(),
Err(err) => {
server_connection.send(Request::Exit).await?;
return Err(anyhow::anyhow!(err.to_error_message())
@@ -213,6 +221,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 +288,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)
@@ -296,7 +310,7 @@ pub async fn edit_database_privileges(
print_modify_database_privileges_output_status(&result);
if result.iter().any(|(_, res)| {
if result.values().flatten().any(|(_, res)| {
matches!(
res,
Err(ModifyDatabasePrivilegesError::UserValidationError(
@@ -311,7 +325,7 @@ pub async fn edit_database_privileges(
server_connection.send(Request::Exit).await?;
if result.values().any(std::result::Result::is_err) {
if result.values().flatten().any(|(_, res)| res.is_err()) {
std::process::exit(1);
}
+6 -1
View File
@@ -1,4 +1,4 @@
use std::path::PathBuf;
use std::{io::IsTerminal, path::PathBuf};
use anyhow::Context;
use clap::Parser;
@@ -88,6 +88,11 @@ pub async fn passwd_user(
.context("Failed to read password from stdin")?;
buffer.trim().to_string()
} else {
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)?
};
+14 -7
View File
@@ -8,8 +8,8 @@ use crate::{
core::{
completion::mysql_database_completer,
protocol::{
ClientToServerMessageStream, ListDatabasesError, Request, Response,
print_list_databases_output_status, print_list_databases_output_status_json,
ClientToServerMessageStream, ListDatabasesError, ListDatabasesRequest, Request,
Response, print_list_databases_output_status, print_list_databases_output_status_json,
request_validation::ValidationError,
},
types::MySQLDatabase,
@@ -27,6 +27,10 @@ pub struct ShowDbArgs {
#[arg(short, long)]
json: bool,
/// Show all tables and users for each database
#[arg(short = 'a', long)]
all: bool,
/// Show sizes in bytes instead of human-readable format
#[arg(short, long)]
bytes: bool,
@@ -36,11 +40,14 @@ pub async fn show_databases(
args: ShowDbArgs,
mut server_connection: ClientToServerMessageStream,
) -> anyhow::Result<()> {
let message = if args.name.is_empty() {
Request::ListDatabases(None)
} else {
Request::ListDatabases(Some(args.name.clone()))
};
let message = Request::ListDatabases(ListDatabasesRequest::new(
if args.name.is_empty() {
None
} else {
Some(args.name.clone())
},
args.all || args.json,
));
server_connection.send(message).await?;
@@ -22,8 +22,8 @@ use crate::{
completion::{mysql_database_completer, prefix_completer},
database_privileges::DatabasePrivilegeRow,
protocol::{
ClientToServerMessageStream, ListPrivilegesError, Request, Response,
create_client_to_server_message_stream,
ClientToServerMessageStream, ListDatabasesRequest, ListPrivilegesError, Request,
Response, create_client_to_server_message_stream,
},
types::MySQLDatabase,
},
@@ -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:
@@ -285,7 +285,7 @@ async fn show_databases(
args.name.iter().map(trim_db_name_to_32_chars).collect();
let message = if database_names.is_empty() {
let message = Request::ListDatabases(None);
let message = Request::ListDatabases(ListDatabasesRequest::new(None, false));
server_connection.send(message).await?;
let response = server_connection.next().await;
let databases = match response {
+32 -4
View File
@@ -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,
+2 -1
View File
@@ -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())
// }
+1 -1
View File
@@ -29,7 +29,7 @@ pub const DATABASE_PRIVILEGE_FIELDS: [&str; 13] = [
// doesn't have any natural implementation semantics.
/// Representation of the set of privileges for a single user on a single database.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, PartialOrd, Ord)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, PartialOrd, Ord, Default)]
pub struct DatabasePrivilegeRow {
// TODO: don't store the db and user here, let the type be stored in a mapping
pub db: MySQLDatabase,
+222
View File
@@ -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,128 @@ 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.names
.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(request) => request
.names
.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 +263,98 @@ 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()
.map(|user_map| user_map.values().filter(|v| v.is_ok()).count())
.sum(),
),
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,
}
}
}
@@ -67,3 +67,43 @@ impl CheckAuthorizationError {
self.0.error_type()
}
}
#[cfg(test)]
mod tests {
use crate::core::protocol::request_validation::NameValidationError;
use super::*;
#[test]
fn test_serialize_deserialize_request() {
let request: CheckAuthorizationRequest = vec![
DbOrUser::Database("test_db".into()),
DbOrUser::User("test_user".into()),
];
let json = serde_json::to_string_pretty(&request).unwrap();
println!("Serialized request:\n{}", json);
let deserialized: CheckAuthorizationRequest = serde_json::from_str(&json).unwrap();
assert_eq!(request, deserialized);
}
#[test]
fn test_serialize_deserialize_response() {
let response: CheckAuthorizationResponse = BTreeMap::from([
(DbOrUser::Database("test_db".into()), Ok(())),
(
DbOrUser::User("test_user".into()),
Err(CheckAuthorizationError(
ValidationError::NameValidationError(NameValidationError::TooLong),
)),
),
]);
let json = serde_json::to_string_pretty(&response).unwrap();
println!("Serialized response:\n{}", json);
let deserialized: CheckAuthorizationResponse = serde_json::from_str(&json).unwrap();
assert_eq!(response, deserialized);
}
}
@@ -87,3 +87,41 @@ impl CreateDatabaseError {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_serialize_deserialize_request() {
let request: CreateDatabasesRequest =
vec!["test_db1".into(), "test_db2".into(), "test_db3".into()];
let json = serde_json::to_string_pretty(&request).unwrap();
println!("Serialized request:\n{}", json);
let deserialized: CreateDatabasesRequest = serde_json::from_str(&json).unwrap();
assert_eq!(request, deserialized);
}
#[test]
fn test_serialize_deserialize_response() {
let response: CreateDatabasesResponse = BTreeMap::from([
("test_db1".into(), Ok(())),
(
"test_db2".into(),
Err(CreateDatabaseError::DatabaseAlreadyExists),
),
(
"test_db3".into(),
Err(CreateDatabaseError::MySqlError("Some MySQL error".into())),
),
]);
let json = serde_json::to_string_pretty(&response).unwrap();
println!("Serialized response:\n{}", json);
let deserialized: CreateDatabasesResponse = serde_json::from_str(&json).unwrap();
assert_eq!(response, deserialized);
}
}
@@ -87,3 +87,37 @@ impl CreateUserError {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_serialize_deserialize_request() {
let request: CreateUsersRequest = vec!["alice".into(), "bob".into(), "charlie".into()];
let json = serde_json::to_string_pretty(&request).unwrap();
println!("Serialized request:\n{}", json);
let deserialized: CreateUsersRequest = serde_json::from_str(&json).unwrap();
assert_eq!(request, deserialized);
}
#[test]
fn test_serialize_deserialize_response() {
let response: CreateUsersResponse = BTreeMap::from([
("alice".into(), Ok(())),
("bob".into(), Err(CreateUserError::UserAlreadyExists)),
(
"charlie".into(),
Err(CreateUserError::MySqlError("Some MySQL error".into())),
),
]);
let json = serde_json::to_string_pretty(&response).unwrap();
println!("Serialized response:\n{}", json);
let deserialized: CreateUsersResponse = serde_json::from_str(&json).unwrap();
assert_eq!(response, deserialized);
}
}
@@ -90,3 +90,37 @@ impl DropDatabaseError {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_serialize_deserialize_request() {
let request: DropDatabasesRequest = vec!["db1".into(), "db2".into(), "db3".into()];
let json = serde_json::to_string_pretty(&request).unwrap();
println!("Serialized request:\n{}", json);
let deserialized: DropDatabasesRequest = serde_json::from_str(&json).unwrap();
assert_eq!(request, deserialized);
}
#[test]
fn test_serialize_deserialize_response() {
let response: DropDatabasesResponse = BTreeMap::from([
("db1".into(), Ok(())),
("db2".into(), Err(DropDatabaseError::DatabaseDoesNotExist)),
(
"db3".into(),
Err(DropDatabaseError::MySqlError("Some MySQL error".into())),
),
]);
let json = serde_json::to_string_pretty(&response).unwrap();
println!("Serialized response:\n{}", json);
let deserialized: DropDatabasesResponse = serde_json::from_str(&json).unwrap();
assert_eq!(response, deserialized);
}
}
+34
View File
@@ -87,3 +87,37 @@ impl DropUserError {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_serialize_deserialize_request() {
let request: DropUsersRequest = vec!["alice".into(), "bob".into(), "charlie".into()];
let json = serde_json::to_string_pretty(&request).unwrap();
println!("Serialized request:\n{}", json);
let deserialized: DropUsersRequest = serde_json::from_str(&json).unwrap();
assert_eq!(request, deserialized);
}
#[test]
fn test_serialize_deserialize_response() {
let response: DropUsersResponse = BTreeMap::from([
("alice".into(), Ok(())),
("bob".into(), Err(DropUserError::UserDoesNotExist)),
(
"charlie".into(),
Err(DropUserError::MySqlError("Some MySQL error".into())),
),
]);
let json = serde_json::to_string_pretty(&response).unwrap();
println!("Serialized response:\n{}", json);
let deserialized: DropUsersResponse = serde_json::from_str(&json).unwrap();
assert_eq!(response, deserialized);
}
}
@@ -27,3 +27,36 @@ impl ListAllDatabasesError {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_serialize_deserialize_response() {
let response: ListAllDatabasesResponse = Ok(vec![
DatabaseRow {
database: "db1".into(),
tables: vec!["table1".into(), "table2".into()],
users: vec!["user1".into(), "user2".into()],
collation: Some("utf8mb4_general_ci".into()),
character_set: Some("utf8mb4".into()),
size_bytes: 1024,
},
DatabaseRow {
database: "db2".into(),
tables: vec!["table3".into(), "table4".into()],
users: vec!["user3".into(), "user4".into()],
collation: Some("utf8mb4_general_ci".into()),
character_set: Some("utf8mb4".into()),
size_bytes: 2048,
},
]);
let json = serde_json::to_string_pretty(&response).unwrap();
println!("Serialized response:\n{}", json);
let deserialized: ListAllDatabasesResponse = serde_json::from_str(&json).unwrap();
assert_eq!(response, deserialized);
}
}
@@ -27,3 +27,34 @@ impl ListAllPrivilegesError {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_serialize_deserialize_response() {
let response: ListAllPrivilegesResponse = Ok(vec![
DatabasePrivilegeRow {
user: "user1".into(),
db: "db1".into(),
select_priv: true,
insert_priv: false,
..Default::default()
},
DatabasePrivilegeRow {
user: "user2".into(),
db: "db2".into(),
select_priv: false,
insert_priv: true,
..Default::default()
},
]);
let json = serde_json::to_string_pretty(&response).unwrap();
println!("Serialized response:\n{}", json);
let deserialized: ListAllPrivilegesResponse = serde_json::from_str(&json).unwrap();
assert_eq!(response, deserialized);
}
}
@@ -27,3 +27,37 @@ impl ListAllUsersError {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_serialize_deserialize_response() {
let response: ListAllUsersResponse = Ok(vec![
DatabaseUser {
user: "user1".into(),
host: "%".into(),
has_password: true,
is_locked: false,
databases: vec!["db1".into(), "db2".into()],
},
DatabaseUser {
user: "user2".into(),
host: "%".into(),
has_password: false,
is_locked: true,
databases: vec!["db3".into()],
},
]);
let json = serde_json::to_string_pretty(&response).unwrap();
println!("Serialized response:\n{}", json);
let mut deserialized: ListAllUsersResponse = serde_json::from_str(&json).unwrap();
deserialized.as_mut().unwrap()[0].host = "%".into();
deserialized.as_mut().unwrap()[1].host = "%".into();
assert_eq!(response, deserialized);
}
}
+71 -2
View File
@@ -14,7 +14,21 @@ use crate::{
server::sql::database_operations::DatabaseRow,
};
pub type ListDatabasesRequest = Option<Vec<MySQLDatabase>>;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ListDatabasesRequest {
pub names: Option<Vec<MySQLDatabase>>,
#[serde(default)]
pub include_all_tables_and_users: bool,
}
impl ListDatabasesRequest {
pub fn new(names: Option<Vec<MySQLDatabase>>, include_all_tables_and_users: bool) -> Self {
Self {
names,
include_all_tables_and_users,
}
}
}
pub type ListDatabasesResponse = BTreeMap<MySQLDatabase, Result<DatabaseRow, ListDatabasesError>>;
@@ -61,7 +75,7 @@ pub fn print_list_databases_output_status(
"Size"
}
]);
for db in final_database_list {
for db in final_database_list.iter().sorted_by_key(|db| &db.database) {
table.add_row(row![
db.database,
db.tables.join("\n"),
@@ -137,3 +151,58 @@ impl ListDatabasesError {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_serialize_deserialize_request() {
let request = ListDatabasesRequest::new(Some(vec!["db1".into(), "db2".into()]), true);
let json = serde_json::to_string_pretty(&request).unwrap();
println!("Serialized request:\n{}", json);
let deserialized: ListDatabasesRequest = serde_json::from_str(&json).unwrap();
assert_eq!(request, deserialized);
}
#[test]
fn test_deserialize_request_without_include_all_tables_and_users_defaults_to_false() {
let json = serde_json::json!({
"names": ["db1", "db2"]
})
.to_string();
let deserialized: ListDatabasesRequest = serde_json::from_str(&json).unwrap();
assert_eq!(
deserialized,
ListDatabasesRequest::new(Some(vec!["db1".into(), "db2".into()]), false)
);
}
#[test]
fn test_serialize_deserialize_response() {
let response: ListDatabasesResponse = vec![
(
"db1".into(),
Ok(DatabaseRow {
database: "db1".into(),
tables: vec!["table1".to_string(), "table2".to_string()],
users: vec!["user1".into(), "user2".into()],
collation: Some("utf8mb4_general_ci".to_string()),
character_set: Some("utf8mb4".to_string()),
size_bytes: 1024,
}),
),
("db2".into(), Err(ListDatabasesError::DatabaseDoesNotExist)),
]
.into_iter()
.collect();
let json = serde_json::to_string_pretty(&response).unwrap();
println!("Serialized response:\n{}", json);
let deserialized: ListDatabasesResponse = serde_json::from_str(&json).unwrap();
assert_eq!(response, deserialized);
}
}
+62 -18
View File
@@ -64,25 +64,28 @@ pub fn print_list_privileges_output_status(output: &ListPrivilegesResponse, long
.collect(),
));
for (_database, rows) in final_privs_map {
for row in &rows {
table.add_row(row![
row.db,
row.user,
c->yn(row.select_priv),
c->yn(row.insert_priv),
c->yn(row.update_priv),
c->yn(row.delete_priv),
c->yn(row.create_priv),
c->yn(row.drop_priv),
c->yn(row.alter_priv),
c->yn(row.index_priv),
c->yn(row.create_tmp_table_priv),
c->yn(row.lock_tables_priv),
c->yn(row.references_priv),
]);
}
for row in final_privs_map
.values()
.flatten()
.sorted_by_key(|row| (&row.db, &row.user))
{
table.add_row(row![
row.db,
row.user,
c->yn(row.select_priv),
c->yn(row.insert_priv),
c->yn(row.update_priv),
c->yn(row.delete_priv),
c->yn(row.create_priv),
c->yn(row.drop_priv),
c->yn(row.alter_priv),
c->yn(row.index_priv),
c->yn(row.create_tmp_table_priv),
c->yn(row.lock_tables_priv),
c->yn(row.references_priv),
]);
}
// }
table.printstd();
}
@@ -153,3 +156,44 @@ impl ListPrivilegesError {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_serialize_deserialize_request() {
let request: ListPrivilegesRequest = Some(vec!["test_db1".into(), "test_db2".into()]);
let json = serde_json::to_string_pretty(&request).unwrap();
println!("Serialized request:\n{}", json);
let deserialized: ListPrivilegesRequest = serde_json::from_str(&json).unwrap();
assert_eq!(request, deserialized);
}
#[test]
fn test_serialize_deserialize_response() {
let response: ListPrivilegesResponse = BTreeMap::from([
(
"test_db1".into(),
Ok(vec![DatabasePrivilegeRow {
db: "test_db1".into(),
user: "user1".into(),
select_priv: true,
insert_priv: false,
..Default::default()
}]),
),
(
"test_db2".into(),
Err(ListPrivilegesError::DatabaseDoesNotExist),
),
]);
let json = serde_json::to_string_pretty(&response).unwrap();
println!("Serialized response:\n{}", json);
let deserialized: ListPrivilegesResponse = serde_json::from_str(&json).unwrap();
assert_eq!(response, deserialized);
}
}
+47 -1
View File
@@ -1,5 +1,6 @@
use std::collections::BTreeMap;
use itertools::Itertools;
use prettytable::Table;
use serde::{Deserialize, Serialize};
use serde_json::json;
@@ -51,7 +52,7 @@ pub fn print_list_users_output_status(output: &ListUsersResponse) {
"Locked",
"Databases where user has privileges"
]);
for user in final_user_list {
for user in final_user_list.iter().sorted_by_key(|user| &user.user) {
table.add_row(row![
user.user,
user.has_password,
@@ -121,3 +122,48 @@ impl ListUsersError {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_serialize_deserialize_request() {
let request: ListUsersRequest = Some(vec!["test_user1".into(), "test_user2".into()]);
let json = serde_json::to_string_pretty(&request).unwrap();
println!("Serialized request:\n{}", json);
let deserialized: ListUsersRequest = serde_json::from_str(&json).unwrap();
assert_eq!(request, deserialized);
}
#[test]
fn test_serialize_deserialize_response() {
let response_ok: ListUsersResponse = BTreeMap::from([
(
"test_user1".into(),
Ok(DatabaseUser {
user: "test_user1".into(),
host: "%".into(),
has_password: true,
is_locked: false,
databases: vec!["db1".into(), "db2".into()],
}),
),
("test_user2".into(), Err(ListUsersError::UserDoesNotExist)),
]);
let json = serde_json::to_string_pretty(&response_ok).unwrap();
println!("Serialized response:\n{}", json);
let mut deserialized: ListUsersResponse = serde_json::from_str(&json).unwrap();
deserialized
.get_mut(&"test_user1".into())
.unwrap()
.as_mut()
.unwrap()
.host = "%".into();
assert_eq!(response_ok, deserialized);
}
}
+30
View File
@@ -94,3 +94,33 @@ impl LockUserError {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_serialize_deserialize_request() {
let request: LockUsersRequest = vec!["test_user1".into(), "test_user2".into()];
let json = serde_json::to_string_pretty(&request).unwrap();
println!("Serialized request:\n{}", json);
let deserialized: LockUsersRequest = serde_json::from_str(&json).unwrap();
assert_eq!(request, deserialized);
}
#[test]
fn test_serialize_deserialize_response() {
let response_ok: LockUsersResponse = BTreeMap::from([
("test_user1".into(), Ok(())),
("test_user2".into(), Err(LockUserError::UserDoesNotExist)),
]);
let json = serde_json::to_string_pretty(&response_ok).unwrap();
println!("Serialized response:\n{}", json);
let deserialized: LockUsersResponse = serde_json::from_str(&json).unwrap();
assert_eq!(response_ok, deserialized);
}
}
@@ -12,7 +12,7 @@ use crate::core::{
pub type ModifyPrivilegesRequest = BTreeSet<DatabasePrivilegesDiff>;
pub type ModifyPrivilegesResponse =
BTreeMap<(MySQLDatabase, MySQLUser), Result<(), ModifyDatabasePrivilegesError>>;
BTreeMap<MySQLDatabase, BTreeMap<MySQLUser, Result<(), ModifyDatabasePrivilegesError>>>;
#[derive(Error, Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum ModifyDatabasePrivilegesError {
@@ -49,7 +49,11 @@ pub enum DiffDoesNotApplyError {
}
pub fn print_modify_database_privileges_output_status(output: &ModifyPrivilegesResponse) {
for ((database_name, username), result) in output {
for ((database_name, username), result) in output.iter().flat_map(|(db, user_map)| {
user_map
.iter()
.map(move |(user, result)| ((db, user), result))
}) {
match result {
Ok(()) => {
println!(
@@ -144,3 +148,46 @@ impl DiffDoesNotApplyError {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::*;
#[test]
fn test_serialize_deserialize_request() {
let request =
BTreeSet::from([DatabasePrivilegesDiff::Modified(DatabasePrivilegeRowDiff {
db: "test_db".into(),
user: "test_user".into(),
select_priv: Some(database_privileges::DatabasePrivilegeChange::NoToYes),
..Default::default()
})]);
let json = serde_json::to_string_pretty(&request).unwrap();
println!("Serialized request:\n{}", json);
let deserialized: ModifyPrivilegesRequest = serde_json::from_str(&json).unwrap();
assert_eq!(request, deserialized);
}
#[test]
fn test_serialize_deserialize_response() {
let response: ModifyPrivilegesResponse = BTreeMap::from([(
"test_db".into(),
BTreeMap::from([
("test_user".into(), Ok(())),
(
"invalid_user".into(),
Err(ModifyDatabasePrivilegesError::UserDoesNotExist),
),
]),
)]);
let json = serde_json::to_string_pretty(&response).unwrap();
println!("Serialized response:\n{}", json);
let deserialized: ModifyPrivilegesResponse = serde_json::from_str(&json).unwrap();
assert_eq!(response, deserialized);
}
}
+32
View File
@@ -60,3 +60,35 @@ impl SetPasswordError {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_serialize_deserialize_request() {
let request: SetUserPasswordRequest = ("test_user".into(), "new_password".into());
let json = serde_json::to_string_pretty(&request).unwrap();
println!("Serialized request:\n{}", json);
let deserialized: SetUserPasswordRequest = serde_json::from_str(&json).unwrap();
assert_eq!(request, deserialized);
}
#[test]
fn test_serialize_deserialize_response() {
let response_ok: SetUserPasswordResponse = Ok(());
let response_err: SetUserPasswordResponse = Err(SetPasswordError::UserDoesNotExist);
let json_ok = serde_json::to_string_pretty(&response_ok).unwrap();
let json_err = serde_json::to_string_pretty(&response_err).unwrap();
println!("Serialized OK response:\n{}", json_ok);
println!("Serialized Error response:\n{}", json_err);
let deserialized_ok: SetUserPasswordResponse = serde_json::from_str(&json_ok).unwrap();
let deserialized_err: SetUserPasswordResponse = serde_json::from_str(&json_err).unwrap();
assert_eq!(response_ok, deserialized_ok);
assert_eq!(response_err, deserialized_err);
}
}
@@ -94,3 +94,33 @@ impl UnlockUserError {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_serialize_deserialize_request() {
let request: UnlockUsersRequest = vec!["test_user1".into(), "test_user2".into()];
let json = serde_json::to_string_pretty(&request).unwrap();
println!("Serialized request:\n{}", json);
let deserialized: UnlockUsersRequest = serde_json::from_str(&json).unwrap();
assert_eq!(request, deserialized);
}
#[test]
fn test_serialize_deserialize_response() {
let response_ok: UnlockUsersResponse = BTreeMap::from([
("test_user1".into(), Ok(())),
("test_user2".into(), Err(UnlockUserError::UserDoesNotExist)),
]);
let json = serde_json::to_string_pretty(&response_ok).unwrap();
println!("Serialized response:\n{}", json);
let deserialized: UnlockUsersResponse = serde_json::from_str(&json).unwrap();
assert_eq!(response_ok, deserialized);
}
}
+34 -3
View File
@@ -34,7 +34,7 @@ impl DerefMut for MySQLUser {
impl fmt::Display for MySQLUser {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{:<width$}", self.0, width = f.width().unwrap_or(0))
self.0.fmt(f)
}
}
@@ -83,7 +83,7 @@ impl DerefMut for MySQLDatabase {
impl fmt::Display for MySQLDatabase {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{:<width$}", self.0, width = f.width().unwrap_or(0))
self.0.fmt(f)
}
}
@@ -105,12 +105,43 @@ impl From<MySQLDatabase> for OsString {
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum DbOrUser {
Database(MySQLDatabase),
User(MySQLUser),
}
impl Serialize for DbOrUser {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
match self {
DbOrUser::Database(db) => ("d:".to_string() + &db.to_string()).serialize(serializer),
DbOrUser::User(user) => ("u:".to_string() + &user.to_string()).serialize(serializer),
}
}
}
impl<'de> Deserialize<'de> for DbOrUser {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
if let Some(rest) = s.strip_prefix("d:") {
Ok(DbOrUser::Database(MySQLDatabase(rest.to_string())))
} else if let Some(rest) = s.strip_prefix("u:") {
Ok(DbOrUser::User(MySQLUser(rest.to_string())))
} else {
Err(serde::de::Error::custom(format!(
"Invalid DbOrUser format: {}",
s
)))
}
}
}
impl DbOrUser {
#[must_use]
pub fn lowercased_noun(&self) -> &'static str {
+56 -20
View File
@@ -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));
}
}
+335 -246
View File
@@ -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;
@@ -34,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,
@@ -61,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,
@@ -82,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,
@@ -96,7 +116,7 @@ pub async fn session_handler(
)
.await;
tracing::info!(
tracing::debug!(
"Finished handling requests for connection from user: {}",
unix_user,
);
@@ -109,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,
@@ -116,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) => {
@@ -133,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,
@@ -144,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
}
@@ -154,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,
@@ -173,247 +196,313 @@ async fn session_handler_with_db_connection(
}
};
// TODO: don't clone the request
let request_to_display = match &request {
Request::PasswdUser((db_user, _)) => {
Request::PasswdUser((db_user.to_owned(), "<REDACTED>".to_string()))
}
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((db_user, 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(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 request) => {
if let Some(database_names) = &request.names {
let result = list_databases(
database_names,
unix_user,
db_connection,
db_is_mariadb,
group_denylist,
request.include_all_tables_and_users,
)
.await;
Response::ListDatabases(result)
} else {
let result = list_all_databases_for_user(
unix_user,
db_connection,
db_is_mariadb,
group_denylist,
request.include_all_tables_and_users,
)
.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(),
);
}
+197 -73
View File
@@ -1,5 +1,6 @@
use std::collections::BTreeMap;
use sqlx::AssertSqlSafe;
use sqlx::MySqlConnection;
use sqlx::prelude::*;
@@ -23,6 +24,8 @@ use crate::{
server::{common::create_user_group_matching_regex, sql::quote_identifier},
};
const MAX_SHOW_DB_RELATED_ITEMS: usize = 5;
// NOTE: this function is unsafe because it does no input validation.
pub(super) async fn unsafe_database_exists(
database_name: &str,
@@ -46,7 +49,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 +90,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 +98,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,
@@ -125,12 +128,15 @@ pub async fn create_databases(
_ => {}
}
let result =
sqlx::query(format!("CREATE DATABASE {}", quote_identifier(&database_name)).as_str())
.execute(&mut *connection)
.await
.map(|_| ())
.map_err(|err| CreateDatabaseError::MySqlError(err.to_string()));
let statement = AssertSqlSafe(format!(
"CREATE DATABASE {}",
quote_identifier(&database_name)
));
let result = sqlx::query(statement)
.execute(&mut *connection)
.await
.map(|_| ())
.map_err(|err| CreateDatabaseError::MySqlError(err.to_string()));
if let Err(err) = &result {
tracing::error!("Failed to create database '{}': {:?}", &database_name, err);
@@ -143,7 +149,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 +157,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,
@@ -181,12 +187,15 @@ pub async fn drop_databases(
_ => {}
}
let result =
sqlx::query(format!("DROP DATABASE {}", quote_identifier(&database_name)).as_str())
.execute(&mut *connection)
.await
.map(|_| ())
.map_err(|err| DropDatabaseError::MySqlError(err.to_string()));
let statement = AssertSqlSafe(format!(
"DROP DATABASE {}",
quote_identifier(&database_name)
));
let result = sqlx::query(statement)
.execute(&mut *connection)
.await
.map(|_| ())
.map_err(|err| DropDatabaseError::MySqlError(err.to_string()));
if let Err(err) = &result {
tracing::error!("Failed to drop database '{}': {:?}", &database_name, err);
@@ -241,16 +250,88 @@ impl FromRow<'_, sqlx::mysql::MySqlRow> for DatabaseRow {
}
}
fn list_database_query(include_all_tables_and_users: bool) -> AssertSqlSafe<String> {
let limit_clause = if include_all_tables_and_users {
"".to_string()
} else {
format!(" LIMIT {}", MAX_SHOW_DB_RELATED_ITEMS)
};
AssertSqlSafe(format!(
r"
SELECT
CAST(s.SCHEMA_NAME AS CHAR(64)) AS `database`,
t.tables,
u.users,
s.DEFAULT_COLLATION_NAME AS `collation`,
s.DEFAULT_CHARACTER_SET_NAME AS `character_set`,
CAST(COALESCE(sz.size_bytes, 0) AS UNSIGNED) AS size_bytes
FROM information_schema.SCHEMATA s
LEFT JOIN (
SELECT
x.TABLE_SCHEMA,
GROUP_CONCAT(x.TABLE_NAME ORDER BY x.TABLE_NAME SEPARATOR ',') AS tables
FROM (
SELECT
TABLE_SCHEMA,
TABLE_NAME
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = ?
ORDER BY TABLE_NAME{limit_clause}
) x
GROUP BY x.TABLE_SCHEMA
) t
ON t.TABLE_SCHEMA = s.SCHEMA_NAME
LEFT JOIN (
SELECT
x.DB,
GROUP_CONCAT(DISTINCT x.User ORDER BY x.User SEPARATOR ',') AS users
FROM (
SELECT
DB,
User
FROM mysql.db
WHERE DB = ?
ORDER BY User{limit_clause}
) x
GROUP BY x.DB
) u
ON u.DB = s.SCHEMA_NAME
LEFT JOIN (
SELECT
TABLE_SCHEMA,
SUM(DATA_LENGTH + INDEX_LENGTH) AS size_bytes
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = ?
GROUP BY TABLE_SCHEMA
) sz
ON sz.TABLE_SCHEMA = s.SCHEMA_NAME
WHERE s.SCHEMA_NAME REGEXP ?
AND s.SCHEMA_NAME NOT IN (
'information_schema',
'performance_schema',
'mysql',
'sys'
)
"
))
}
pub async fn list_databases(
database_names: Vec<MySQLDatabase>,
database_names: &[MySQLDatabase],
unix_user: &UnixUser,
connection: &mut MySqlConnection,
_db_is_mariadb: bool,
group_denylist: &GroupDenylist,
include_all_tables_and_users: bool,
) -> 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,
@@ -262,35 +343,19 @@ pub async fn list_databases(
continue;
}
let result = sqlx::query_as::<_, DatabaseRow>(
r"
SELECT
CAST(`information_schema`.`SCHEMATA`.`SCHEMA_NAME` AS CHAR(64)) AS `database`,
GROUP_CONCAT(DISTINCT CAST(`information_schema`.`TABLES`.`TABLE_NAME` AS CHAR(64)) SEPARATOR ',') AS `tables`,
GROUP_CONCAT(DISTINCT CAST(`mysql`.`db`.`User` AS CHAR(64)) SEPARATOR ',') AS `users`,
MAX(`information_schema`.`SCHEMATA`.`DEFAULT_COLLATION_NAME`) AS `collation`,
MAX(`information_schema`.`SCHEMATA`.`DEFAULT_CHARACTER_SET_NAME`) AS `character_set`,
CAST(IFNULL(
SUM(`information_schema`.`TABLES`.`DATA_LENGTH` + `information_schema`.`TABLES`.`INDEX_LENGTH`),
0
) AS UNSIGNED INTEGER) AS `size_bytes`
FROM `information_schema`.`SCHEMATA`
LEFT OUTER JOIN `information_schema`.`TABLES`
ON `information_schema`.`SCHEMATA`.`SCHEMA_NAME` = `TABLES`.`TABLE_SCHEMA`
LEFT OUTER JOIN `mysql`.`db`
ON `information_schema`.`SCHEMATA`.`SCHEMA_NAME` = `mysql`.`db`.`DB`
WHERE `information_schema`.`SCHEMATA`.`SCHEMA_NAME` = ?
GROUP BY `information_schema`.`SCHEMATA`.`SCHEMA_NAME`
",
let query = list_database_query(include_all_tables_and_users);
)
.bind(database_name.to_string())
.fetch_optional(&mut *connection)
.await
.map_err(|err| ListDatabasesError::MySqlError(err.to_string()))
.and_then(|database| {
database.map_or_else(|| Err(ListDatabasesError::DatabaseDoesNotExist), Ok)
});
let result = sqlx::query_as::<_, DatabaseRow>(query)
.bind(database_name.to_string())
.bind(database_name.to_string())
.bind(database_name.to_string())
.bind(database_name.to_string())
.fetch_optional(&mut *connection)
.await
.map_err(|err| ListDatabasesError::MySqlError(err.to_string()))
.and_then(|database| {
database.map_or_else(|| Err(ListDatabasesError::DatabaseDoesNotExist), Ok)
});
if let Err(err) = &result {
tracing::error!("Failed to list database '{}': {:?}", &database_name, err);
@@ -304,38 +369,97 @@ pub async fn list_databases(
results
}
fn list_all_databases_for_user_query(include_all_tables_and_users: bool) -> AssertSqlSafe<String> {
let limit_clause = if include_all_tables_and_users {
"".to_string()
} else {
format!(" LIMIT {}", MAX_SHOW_DB_RELATED_ITEMS)
};
AssertSqlSafe(format!(
r"
SELECT
CAST(s.SCHEMA_NAME AS CHAR(64)) AS `database`,
t.tables,
u.users,
s.DEFAULT_COLLATION_NAME AS `collation`,
s.DEFAULT_CHARACTER_SET_NAME AS `character_set`,
CAST(COALESCE(sz.size_bytes, 0) AS UNSIGNED) AS size_bytes
FROM information_schema.SCHEMATA s
LEFT JOIN (
SELECT
x.TABLE_SCHEMA,
GROUP_CONCAT(x.TABLE_NAME ORDER BY x.TABLE_NAME SEPARATOR ',') AS tables
FROM (
SELECT
TABLE_SCHEMA,
TABLE_NAME
FROM information_schema.TABLES
WHERE TABLE_SCHEMA REGEXP ?
ORDER BY TABLE_NAME{limit_clause}
) x
GROUP BY x.TABLE_SCHEMA
) t
ON t.TABLE_SCHEMA = s.SCHEMA_NAME
LEFT JOIN (
SELECT
x.DB,
GROUP_CONCAT(DISTINCT x.User ORDER BY x.User SEPARATOR ',') AS users
FROM (
SELECT
DB,
User
FROM mysql.db
WHERE DB REGEXP ?
ORDER BY User{limit_clause}
) x
GROUP BY x.DB
) u
ON u.DB = s.SCHEMA_NAME
LEFT JOIN (
SELECT
TABLE_SCHEMA,
SUM(DATA_LENGTH + INDEX_LENGTH) AS size_bytes
FROM information_schema.TABLES
WHERE TABLE_SCHEMA REGEXP ?
GROUP BY TABLE_SCHEMA
) sz
ON sz.TABLE_SCHEMA = s.SCHEMA_NAME
WHERE s.SCHEMA_NAME REGEXP ?
AND s.SCHEMA_NAME NOT IN (
'information_schema',
'performance_schema',
'mysql',
'sys'
)
ORDER BY s.SCHEMA_NAME
"
))
}
pub async fn list_all_databases_for_user(
unix_user: &UnixUser,
connection: &mut MySqlConnection,
_db_is_mariadb: bool,
group_denylist: &GroupDenylist,
include_all_tables_and_users: bool,
) -> ListAllDatabasesResponse {
let result = sqlx::query_as::<_, DatabaseRow>(
r"
SELECT
CAST(`information_schema`.`SCHEMATA`.`SCHEMA_NAME` AS CHAR(64)) AS `database`,
GROUP_CONCAT(DISTINCT CAST(`information_schema`.`TABLES`.`TABLE_NAME` AS CHAR(64)) SEPARATOR ',') AS `tables`,
GROUP_CONCAT(DISTINCT CAST(`mysql`.`db`.`User` AS CHAR(64)) SEPARATOR ',') AS `users`,
MAX(`information_schema`.`SCHEMATA`.`DEFAULT_COLLATION_NAME`) AS `collation`,
MAX(`information_schema`.`SCHEMATA`.`DEFAULT_CHARACTER_SET_NAME`) AS `character_set`,
CAST(IFNULL(
SUM(`information_schema`.`TABLES`.`DATA_LENGTH` + `information_schema`.`TABLES`.`INDEX_LENGTH`),
0
) AS UNSIGNED INTEGER) AS `size_bytes`
FROM `information_schema`.`SCHEMATA`
LEFT OUTER JOIN `information_schema`.`TABLES`
ON `information_schema`.`SCHEMATA`.`SCHEMA_NAME` = `TABLES`.`TABLE_SCHEMA`
LEFT OUTER JOIN `mysql`.`db`
ON `information_schema`.`SCHEMATA`.`SCHEMA_NAME` = `mysql`.`db`.`DB`
WHERE `information_schema`.`SCHEMATA`.`SCHEMA_NAME` NOT IN ('information_schema', 'performance_schema', 'mysql', 'sys')
AND `information_schema`.`SCHEMATA`.`SCHEMA_NAME` REGEXP ?
GROUP BY `information_schema`.`SCHEMATA`.`SCHEMA_NAME`
",
)
.bind(create_user_group_matching_regex(unix_user, group_denylist))
.fetch_all(connection)
.await
.map_err(|err| ListAllDatabasesError::MySqlError(err.to_string()));
let query = list_all_databases_for_user_query(include_all_tables_and_users);
let user_group_regex = create_user_group_matching_regex(unix_user, group_denylist);
let result = sqlx::query_as::<_, DatabaseRow>(query)
.bind(&user_group_regex)
.bind(&user_group_regex)
.bind(&user_group_regex)
.bind(&user_group_regex)
.fetch_all(connection)
.await
.map_err(|err| ListAllDatabasesError::MySqlError(err.to_string()));
// TODO: should we assert that the users are also owned by the unix_user from the request?
+55 -30
View File
@@ -18,7 +18,7 @@ use std::collections::{BTreeMap, BTreeSet};
use indoc::indoc;
use itertools::Itertools;
use sqlx::{MySqlConnection, mysql::MySqlRow, prelude::*};
use sqlx::{AssertSqlSafe, MySqlConnection, mysql::MySqlRow, prelude::*};
use crate::{
core::{
@@ -84,16 +84,17 @@ async fn unsafe_get_database_privileges(
database_name: &str,
connection: &mut MySqlConnection,
) -> Result<Vec<DatabasePrivilegeRow>, sqlx::Error> {
let result = sqlx::query_as::<_, DatabasePrivilegeRow>(&format!(
let statement = AssertSqlSafe(format!(
"SELECT {} FROM `db` WHERE `Db` = ?",
DATABASE_PRIVILEGE_FIELDS
.iter()
.map(|field| quote_identifier(field))
.join(","),
))
.bind(database_name)
.fetch_all(connection)
.await;
));
let result = sqlx::query_as::<_, DatabasePrivilegeRow>(statement)
.bind(database_name)
.fetch_all(connection)
.await;
if let Err(e) = &result {
tracing::error!(
@@ -113,17 +114,18 @@ pub async fn unsafe_get_database_privileges_for_db_user_pair(
user_name: &MySQLUser,
connection: &mut MySqlConnection,
) -> Result<Option<DatabasePrivilegeRow>, sqlx::Error> {
let result = sqlx::query_as::<_, DatabasePrivilegeRow>(&format!(
"SELECT {} FROM `db` WHERE `Db` = ? AND `User` = ?",
let statement = AssertSqlSafe(format!(
"SELECT {} FROM `db` WHERE `Db` = ? AND `User` = ? AND `Host` = '%'",
DATABASE_PRIVILEGE_FIELDS
.iter()
.map(|field| quote_identifier(field))
.join(","),
))
.bind(database_name.as_str())
.bind(user_name.as_str())
.fetch_optional(connection)
.await;
));
let result = sqlx::query_as::<_, DatabasePrivilegeRow>(statement)
.bind(database_name.as_str())
.bind(user_name.as_str())
.fetch_optional(connection)
.await;
if let Err(e) = &result {
tracing::error!(
@@ -138,7 +140,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 +148,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 +178,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()));
@@ -189,8 +191,8 @@ pub async fn get_databases_privilege_data(
}
/// TODO: make this constant
fn get_all_db_privs_query() -> String {
format!(
fn get_all_db_privs_query() -> AssertSqlSafe<String> {
AssertSqlSafe(format!(
indoc! {r"
SELECT {} FROM `db` WHERE `db` IN
(SELECT DISTINCT CAST(`SCHEMA_NAME` AS CHAR(64)) AS `database`
@@ -202,7 +204,7 @@ fn get_all_db_privs_query() -> String {
.iter()
.map(|field| quote_identifier(field))
.join(","),
)
))
}
/// Get all database + user + privileges pairs that are owned by the current user.
@@ -212,7 +214,7 @@ pub async fn get_all_database_privileges(
_db_is_mariadb: bool,
group_denylist: &GroupDenylist,
) -> ListAllPrivilegesResponse {
let result = sqlx::query_as::<_, DatabasePrivilegeRow>(&get_all_db_privs_query())
let result = sqlx::query_as::<_, DatabasePrivilegeRow>(get_all_db_privs_query())
.bind(create_user_group_matching_regex(unix_user, group_denylist))
.fetch_all(connection)
.await
@@ -234,13 +236,17 @@ async fn unsafe_apply_privilege_diff(
DatabasePrivilegesDiff::New(p) => {
let tables = DATABASE_PRIVILEGE_FIELDS
.iter()
.chain(&["Host"])
.map(|field| quote_identifier(field))
.join(",");
let question_marks =
std::iter::repeat_n("?", DATABASE_PRIVILEGE_FIELDS.len()).join(",");
std::iter::repeat_n("?", DATABASE_PRIVILEGE_FIELDS.len() + 1).join(",");
sqlx::query(format!("INSERT INTO `db` ({tables}) VALUES ({question_marks})").as_str())
let statement = AssertSqlSafe(format!(
"INSERT INTO `db` ({tables}) VALUES ({question_marks})"
));
sqlx::query(statement)
.bind(p.db.to_string())
.bind(p.user.to_string())
.bind(yn(p.select_priv))
@@ -254,6 +260,7 @@ async fn unsafe_apply_privilege_diff(
.bind(yn(p.create_tmp_table_priv))
.bind(yn(p.lock_tables_priv))
.bind(yn(p.references_priv))
.bind("%")
.execute(connection)
.await
.map(|_| ())
@@ -278,7 +285,10 @@ async fn unsafe_apply_privilege_diff(
}
}
sqlx::query(format!("UPDATE `db` SET {changes} WHERE `Db` = ? AND `User` = ?").as_str())
let statement = AssertSqlSafe(format!(
"UPDATE `db` SET {changes} WHERE `Db` = ? AND `User` = ? AND `Host` = ?"
));
sqlx::query(statement)
.bind(p.select_priv.map(change_to_yn))
.bind(p.insert_priv.map(change_to_yn))
.bind(p.update_priv.map(change_to_yn))
@@ -292,14 +302,16 @@ async fn unsafe_apply_privilege_diff(
.bind(p.references_priv.map(change_to_yn))
.bind(p.db.to_string())
.bind(p.user.to_string())
.bind("%")
.execute(connection)
.await
.map(|_| ())
}
DatabasePrivilegesDiff::Deleted(p) => {
sqlx::query("DELETE FROM `db` WHERE `Db` = ? AND `User` = ?")
sqlx::query("DELETE FROM `db` WHERE `Db` = ? AND `User` = ? AND `Host` = ?")
.bind(p.db.to_string())
.bind(p.user.to_string())
.bind("%")
.execute(connection)
.await
.map(|_| ())
@@ -400,7 +412,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,17 +480,30 @@ 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()));
results.insert(key, result);
}
if let Err(err) = connection.execute("FLUSH PRIVILEGES").await {
tracing::error!("Failed to flush privileges: {}", err);
}
results
.into_iter()
.map(|((k1, k2), v)| (k1, (k2, v)))
.into_group_map()
.into_iter()
.map(|(k1, pairs)| {
let inner = pairs.into_iter().collect::<BTreeMap<_, _>>();
(k1, inner)
})
.collect()
}
+78 -71
View File
@@ -1,5 +1,6 @@
use indoc::formatdoc;
use itertools::Itertools;
use sqlx::AssertSqlSafe;
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
@@ -39,6 +40,7 @@ pub(super) async fn unsafe_user_exists(
SELECT 1
FROM `mysql`.`user`
WHERE `User` = ?
AND `Host` = '%'
)
",
)
@@ -55,7 +57,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,
@@ -67,6 +69,7 @@ pub async fn complete_user_name(
FROM `mysql`.`user`
WHERE `User` REGEXP ?
AND `User` LIKE ?
AND `Host` = '%'
",
)
.bind(create_user_group_matching_regex(unix_user, group_denylist))
@@ -95,7 +98,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 +106,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)
@@ -124,7 +127,8 @@ pub async fn create_database_users(
_ => {}
}
let result = sqlx::query(format!("CREATE USER {}@'%'", quote_literal(&db_user),).as_str())
let statement = AssertSqlSafe(format!("CREATE USER {}@'%'", quote_literal(&db_user),));
let result = sqlx::query(statement)
.execute(&mut *connection)
.await
.map(|_| ())
@@ -141,7 +145,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 +153,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)
@@ -170,7 +174,8 @@ pub async fn drop_database_users(
_ => {}
}
let result = sqlx::query(format!("DROP USER {}@'%'", quote_literal(&db_user),).as_str())
let statement = AssertSqlSafe(format!("DROP USER {}@'%'", quote_literal(&db_user),));
let result = sqlx::query(statement)
.execute(&mut *connection)
.await
.map(|_| ())
@@ -203,18 +208,16 @@ pub async fn set_password_for_database_user(
_ => {}
}
let result = sqlx::query(
format!(
"ALTER USER {}@'%' IDENTIFIED BY {}",
quote_literal(db_user),
quote_literal(password).as_str(),
)
.as_str(),
)
.execute(&mut *connection)
.await
.map(|_| ())
.map_err(|err| SetPasswordError::MySqlError(err.to_string()));
let statement = AssertSqlSafe(format!(
"ALTER USER {}@'%' IDENTIFIED BY {}",
quote_literal(db_user),
quote_literal(password).as_str(),
));
let result = sqlx::query(statement)
.execute(&mut *connection)
.await
.map(|_| ())
.map_err(|err| SetPasswordError::MySqlError(err.to_string()));
if result.is_err() {
tracing::error!(
@@ -272,7 +275,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,
@@ -280,7 +283,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)
@@ -313,13 +316,15 @@ pub async fn lock_database_users(
}
}
let result = sqlx::query(
format!("ALTER USER {}@'%' ACCOUNT LOCK", quote_literal(&db_user),).as_str(),
)
.execute(&mut *connection)
.await
.map(|_| ())
.map_err(|err| LockUserError::MySqlError(err.to_string()));
let statement = AssertSqlSafe(format!(
"ALTER USER {}@'%' ACCOUNT LOCK",
quote_literal(&db_user),
));
let result = sqlx::query(statement)
.execute(&mut *connection)
.await
.map(|_| ())
.map_err(|err| LockUserError::MySqlError(err.to_string()));
if let Err(err) = &result {
tracing::error!("Failed to lock database user '{}': {:?}", &db_user, err);
@@ -332,7 +337,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,
@@ -340,7 +345,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)
@@ -373,13 +378,15 @@ pub async fn unlock_database_users(
_ => {}
}
let result = sqlx::query(
format!("ALTER USER {}@'%' ACCOUNT UNLOCK", quote_literal(&db_user),).as_str(),
)
.execute(&mut *connection)
.await
.map(|_| ())
.map_err(|err| UnlockUserError::MySqlError(err.to_string()));
let statement = AssertSqlSafe(format!(
"ALTER USER {}@'%' ACCOUNT UNLOCK",
quote_literal(&db_user),
));
let result = sqlx::query(statement)
.execute(&mut *connection)
.await
.map(|_| ())
.map_err(|err| UnlockUserError::MySqlError(err.to_string()));
if let Err(err) = &result {
tracing::error!("Failed to unlock database user '{}': {:?}", &db_user, err);
@@ -440,7 +447,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,
@@ -448,7 +455,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)
@@ -457,16 +464,17 @@ pub async fn list_database_users(
continue;
}
let mut result = sqlx::query_as::<_, DatabaseUser>(
&(if db_is_mariadb {
let statement = AssertSqlSafe(
if db_is_mariadb {
DB_USER_SELECT_STATEMENT_MARIADB.to_string()
} else {
DB_USER_SELECT_STATEMENT_MYSQL.to_string()
} + "WHERE `mysql`.`user`.`User` = ?"),
)
.bind(db_user.as_str())
.fetch_optional(&mut *connection)
.await;
} + "WHERE `mysql`.`user`.`User` = ? AND `mysql`.`user`.`Host` = '%'",
);
let mut result = sqlx::query_as::<_, DatabaseUser>(statement)
.bind(db_user.as_str())
.fetch_optional(&mut *connection)
.await;
if let Err(err) = &result {
tracing::error!("Failed to list database user '{}': {:?}", &db_user, err);
@@ -494,17 +502,18 @@ pub async fn list_all_database_users_for_unix_user(
db_is_mariadb: bool,
group_denylist: &GroupDenylist,
) -> ListAllUsersResponse {
let mut result = sqlx::query_as::<_, DatabaseUser>(
&(if db_is_mariadb {
let statement = AssertSqlSafe(
if db_is_mariadb {
DB_USER_SELECT_STATEMENT_MARIADB.to_string()
} else {
DB_USER_SELECT_STATEMENT_MYSQL.to_string()
} + "WHERE `user`.`User` REGEXP ?"),
)
.bind(create_user_group_matching_regex(unix_user, group_denylist))
.fetch_all(&mut *connection)
.await
.map_err(|err| ListAllUsersError::MySqlError(err.to_string()));
} + "WHERE `user`.`User` REGEXP ? AND `user`.`Host` = '%'",
);
let mut result = sqlx::query_as::<_, DatabaseUser>(statement)
.bind(create_user_group_matching_regex(unix_user, group_denylist))
.fetch_all(&mut *connection)
.await
.map_err(|err| ListAllUsersError::MySqlError(err.to_string()));
if let Err(err) = &result {
tracing::error!("Failed to list all database users: {:?}", err);
@@ -529,23 +538,21 @@ pub async fn set_databases_where_user_has_privileges(
db_user: &mut DatabaseUser,
connection: &mut MySqlConnection,
) -> Result<(), sqlx::Error> {
let database_list = sqlx::query(
formatdoc!(
r"
SELECT `Db` AS `database`
FROM `db`
WHERE `User` = ? AND ({})
",
DATABASE_PRIVILEGE_FIELDS
.iter()
.map(|field| format!("`{field}` = 'Y'"))
.join(" OR "),
)
.as_str(),
)
.bind(db_user.user.as_str())
.fetch_all(&mut *connection)
.await;
let statement = AssertSqlSafe(formatdoc!(
r"
SELECT `Db` AS `database`
FROM `db`
WHERE `User` = ? AND `Host` = '%' AND ({})
",
DATABASE_PRIVILEGE_FIELDS
.iter()
.map(|field| format!("`{field}` = 'Y'"))
.join(" OR "),
));
let database_list = sqlx::query(statement)
.bind(db_user.user.as_str())
.fetch_all(&mut *connection)
.await;
if let Err(err) = &database_list {
tracing::error!(
+31 -24
View File
@@ -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},
},
};
@@ -87,14 +90,12 @@ impl Supervisor {
};
let mut watchdog_duration = None;
let mut watchdog_micro_seconds = 0;
#[cfg(target_os = "linux")]
let watchdog_task =
if systemd_mode && sd_notify::watchdog_enabled(true, &mut watchdog_micro_seconds) {
let watchdog_duration_ = Duration::from_micros(watchdog_micro_seconds);
if systemd_mode && let Some(watchdog_duration_) = sd_notify::watchdog_enabled() {
tracing::debug!(
"Systemd watchdog enabled with {} millisecond interval",
watchdog_micro_seconds.div_ceil(1000),
watchdog_duration_.as_millis()
);
watchdog_duration = Some(watchdog_duration_);
Some(spawn_watchdog_task(watchdog_duration_))
@@ -137,7 +138,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 +293,12 @@ impl Supervisor {
pub async fn reload(&self) -> anyhow::Result<()> {
#[cfg(target_os = "linux")]
sd_notify::notify(false, &[sd_notify::NotifyState::Reloading])?;
sd_notify::notify(&[
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?;
@@ -329,14 +335,14 @@ impl Supervisor {
}
#[cfg(target_os = "linux")]
sd_notify::notify(false, &[sd_notify::NotifyState::Ready])?;
sd_notify::notify(&[sd_notify::NotifyState::Ready])?;
Ok(())
}
pub async fn shutdown(&self) -> anyhow::Result<()> {
#[cfg(target_os = "linux")]
sd_notify::notify(false, &[sd_notify::NotifyState::Stopping])?;
sd_notify::notify(&[sd_notify::NotifyState::Stopping])?;
tracing::debug!("Stop accepting new connections");
self.stop_receiving_new_connections()?;
@@ -406,7 +412,7 @@ fn spawn_watchdog_task(duration: Duration) -> JoinHandle<()> {
);
loop {
interval.tick().await;
if let Err(err) = sd_notify::notify(false, &[sd_notify::NotifyState::Watchdog]) {
if let Err(err) = sd_notify::notify(&[sd_notify::NotifyState::Watchdog]) {
tracing::warn!("Failed to notify systemd watchdog: {}", err);
}
}
@@ -429,9 +435,7 @@ fn spawn_status_notifier_task(task_tracker: TaskTracker) -> JoinHandle<()> {
"Waiting for connections".to_string()
};
if let Err(e) =
sd_notify::notify(false, &[sd_notify::NotifyState::Status(message.as_str())])
{
if let Err(e) = sd_notify::notify(&[sd_notify::NotifyState::Status(message.as_str())]) {
tracing::warn!("Failed to send systemd status notification: {}", e);
}
}
@@ -546,7 +550,9 @@ async fn listener_task(
group_denylist: Arc<RwLock<GroupDenylist>>,
) -> anyhow::Result<()> {
#[cfg(target_os = "linux")]
sd_notify::notify(false, &[sd_notify::NotifyState::Ready])?;
sd_notify::notify(&[sd_notify::NotifyState::Ready])?;
let connection_counter = AtomicU64::new(0);
loop {
tokio::select! {
@@ -577,28 +583,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),
}
}
}