1 Commits

Author SHA1 Message Date
c6bce54859 WIP: flake.nix: create debian vm test 2025-12-15 15:19:03 +09:00
15 changed files with 92 additions and 336 deletions

1
.gitignore vendored
View File

@@ -9,6 +9,7 @@ result-*
# Nix VM
*.qcow2
.nixos-test-history
# Packaging
!/assets/debian/config.toml

View File

@@ -132,11 +132,6 @@ assets = [
"etc/muscl/config.toml",
"644",
],
[
"assets/debian/group_denylist.txt",
"etc/muscl/group_denylist.txt",
"644",
],
[
"assets/completions/_*",
"usr/share/zsh/site-functions/completions/",

View File

@@ -21,6 +21,3 @@ password_file = "/run/credentials/muscl.service/muscl_mysql_password"
# Database connection timeout in seconds
timeout = 2
[authorization]
group_denylist_file = "/etc/muscl/group_denylist.txt"

View File

@@ -1,58 +0,0 @@
# These are the default system groups on debian.
# You can alos add groups by gid by prefixing the line with 'gid:'.
group:adm
group:audio
group:avahi
group:backup
group:bin
group:cdrom
group:crontab
group:daemon
group:dialout
group:dip
group:disk
group:fax
group:floppy
group:games
group:gnats
group:input
group:irc
group:kmem
group:kvm
group:list
group:lp
group:mail
group:man
group:mlocate
group:netdev
group:news
group:nogroup
group:openldap
group:operator
group:plocate
group:plugdev
group:polkitd
group:postgres
group:proxy
group:render
group:root
group:sasl
group:shadow
group:src
group:staff
group:sudo
group:sync
group:sys
group:systemd-journal
group:systemd-network
group:systemd-resolve
group:systemd-timesync
group:tape
group:tty
group:users
group:utmp
group:uucp
group:video
group:voice
group:www-data

21
flake.lock generated
View File

@@ -15,6 +15,26 @@
"type": "github"
}
},
"nix-vm-test": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1763976673,
"narHash": "sha256-QPeI8WR+brwodiy4YNfOnLI7rOHJfFPrGm+xT/HmtT4=",
"owner": "numtide",
"repo": "nix-vm-test",
"rev": "8611bdd7a49750a880be9ee2ea9f68c53f8c9299",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "nix-vm-test",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1765472234,
@@ -34,6 +54,7 @@
"root": {
"inputs": {
"crane": "crane",
"nix-vm-test": "nix-vm-test",
"nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay"
}

View File

@@ -6,9 +6,12 @@
rust-overlay.inputs.nixpkgs.follows = "nixpkgs";
crane.url = "github:ipetkov/crane";
nix-vm-test.url = "github:numtide/nix-vm-test";
nix-vm-test.inputs.nixpkgs.follows = "nixpkgs";
};
outputs = { self, nixpkgs, rust-overlay, crane }:
outputs = { self, nixpkgs, rust-overlay, crane, nix-vm-test }:
let
inherit (nixpkgs) lib;
@@ -95,6 +98,8 @@
muscl = import ./nix/module.nix;
};
# vmlib = forAllSystems(system: _: _: nix-vm-test.lib.${system});
packages = forAllSystems (system: pkgs: _:
let
cargoToml = builtins.fromTOML (builtins.readFile ./Cargo.toml);
@@ -130,6 +135,8 @@
filteredSource = pkgs.runCommandLocal "filtered-source" { } ''
ln -s ${src} $out
'';
debianVm = import ./nix/debian-vm-configuration.nix { inherit nix-vm-test nixpkgs system pkgs; };
});
checks = forAllSystems (system: pkgs: _: {

View File

@@ -0,0 +1,49 @@
{ nix-vm-test, nixpkgs, system, pkgs, ... }:
let
image = nix-vm-test.lib.${system}.debian.images."13";
generic = import "${nix-vm-test}/generic" { inherit pkgs nixpkgs; inherit (pkgs) lib; };
makeVmTestForImage =
image:
{
testScript,
sharedDirs ? {},
diskSize ? null,
config ? { }
}:
generic.makeVmTest {
inherit
system
testScript
sharedDirs;
image = nix-vm-test.lib.${system}.debian.prepareDebianImage {
inherit diskSize;
hostPkgs = pkgs;
originalImage = image;
};
machineConfigModule = config;
};
vmTest = makeVmTestForImage image {
diskSize = "10G";
sharedDirs = {
debDir = {
source = "${./.}";
target = "/mnt";
};
};
testScript = ''
vm.wait_for_unit("multi-user.target")
vm.succeed("apt-get update && apt-get -y install mariadb-server build-essential curl")
vm.succeed("curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y")
vm.succeed("source /root/.cargo/env && cargo install cargo-deb")
vm.succeed("cp -r /mnt /root/src && chmod -R +w /root/src")
vm.succeed("source /root/.cargo/env && cd /root/src && ./create-deb.sh")
'';
config.nodes.vm = {
virtualisation.memorySize = 8192;
virtualisation.cpus = 4;
};
};
in vmTest.driverInteractive

View File

@@ -40,14 +40,6 @@ in
};
};
authorization = {
group_denylist = lib.mkOption {
type = with lib.types; nullOr (listOf str);
default = [ "wheel" ];
description = "List of groups that are denied access";
};
};
mysql = {
socket_path = lib.mkOption {
type = with lib.types; nullOr path;
@@ -89,12 +81,6 @@ in
environment.systemPackages = [ cfg.package ];
environment.etc."muscl/config.toml".source = lib.pipe cfg.settings [
# Handle group_denylist_file
(conf: lib.recursiveUpdate conf {
authorization.group_denylist_file = if (conf.authorization.group_denylist != [ ]) then "/etc/muscl/group-denylist" else null;
authorization.group_denylist = null;
})
# Remove nulls
(lib.filterAttrsRecursive (_: v: v != null))
@@ -109,10 +95,6 @@ in
(format.generate "muscl.conf")
];
environment.etc."muscl/group-denylist" = lib.mkIf (cfg.settings.authorization.group_denylist != [ ]) {
text = lib.concatMapStringsSep "\n" (group: "group:${group}") cfg.settings.authorization.group_denylist;
};
services.mysql.ensureUsers = lib.mkIf cfg.createLocalDatabaseUser [
{
name = cfg.settings.mysql.username;

View File

@@ -9,12 +9,10 @@ use tokio::{net::UnixStream as TokioUnixStream, sync::RwLock};
use tracing_subscriber::prelude::*;
use crate::{
core::{
common::{DEFAULT_CONFIG_PATH, DEFAULT_SOCKET_PATH, UnixUser, executing_in_suid_sgid_mode},
protocol::request_validation::GroupDenylist,
core::common::{
DEFAULT_CONFIG_PATH, DEFAULT_SOCKET_PATH, UnixUser, executing_in_suid_sgid_mode,
},
server::{
authorization::read_and_parse_group_denylist,
config::{MysqlConfig, ServerConfig},
landlock::landlock_restrict_server,
session_handler,
@@ -272,13 +270,6 @@ fn run_forked_server(
let config = ServerConfig::read_config_from_path(&config_path)
.context("Failed to read server config in forked process")?;
let group_denylist = if let Some(denylist_path) = &config.authorization.group_denylist_file {
read_and_parse_group_denylist(denylist_path)
.context("Failed to read and parse group denylist")?
} else {
GroupDenylist::new()
};
let result: anyhow::Result<()> = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
@@ -301,7 +292,6 @@ fn run_forked_server(
&unix_user,
db_pool,
db_is_mariadb,
&group_denylist,
)
.await?;
Ok(())

View File

@@ -1,8 +1,5 @@
use std::collections::HashSet;
use indoc::indoc;
use itertools::Itertools;
use nix::{libc::gid_t, unistd::Group};
use serde::{Deserialize, Serialize};
use thiserror::Error;
@@ -63,9 +60,6 @@ pub enum AuthorizationError {
// TODO: I don't think this should ever happen?
#[error("Name cannot be empty")]
StringEmpty,
#[error("Group was found in denylist")]
DenylistError,
}
impl AuthorizationError {
@@ -111,9 +105,6 @@ impl AuthorizationError {
db_or_user.lowercased_noun()
)
.to_string(),
AuthorizationError::DenylistError => {
format!("'{}' is denied by the group denylist", db_or_user.name())
}
}
}
@@ -121,7 +112,6 @@ impl AuthorizationError {
match self {
AuthorizationError::NoMatch => "no-match",
AuthorizationError::StringEmpty => "string-empty",
AuthorizationError::DenylistError => "denylist-error",
}
}
}
@@ -165,25 +155,8 @@ impl ValidationError {
}
}
pub type GroupDenylist = HashSet<gid_t>;
const MAX_NAME_LENGTH: usize = 64;
// TODO: use this to render the help message for authorization-error/no-match
fn get_user_groups(user: &UnixUser, group_denylist: &GroupDenylist) -> Vec<String> {
use nix::unistd::Group;
let mut groups = Vec::new();
for group in user.groups.iter() {
if let Some(g) = Group::from_name(group).ok().flatten()
&& !group_denylist.contains(&g.gid.as_raw()) {
groups.push(group.clone());
}
}
groups
}
pub fn validate_name(name: &str) -> Result<(), NameValidationError> {
if name.is_empty() {
Err(NameValidationError::EmptyString)
@@ -234,30 +207,6 @@ pub fn validate_authorization_by_prefixes(
Ok(())
}
pub fn validate_authorization_by_group_denylist(
name: &str,
user: &UnixUser,
group_denylist: &GroupDenylist,
) -> Result<(), AuthorizationError> {
// NOTE: if the username matches, we allow it regardless of denylist
if user.username == name {
return Ok(());
}
let user_group = Group::from_name(name)
.ok()
.flatten()
.map(|g| g.gid.as_raw());
if let Some(gid) = user_group
&& group_denylist.contains(&gid)
{
Err(AuthorizationError::DenylistError)
} else {
Ok(())
}
}
pub fn validate_db_or_user_request(
db_or_user: &DbOrUser,
unix_user: &UnixUser,
@@ -267,14 +216,6 @@ pub fn validate_db_or_user_request(
validate_authorization_by_unix_user(db_or_user.name(), unix_user)
.map_err(ValidationError::AuthorizationError)?;
validate_authorization_by_group_denylist(
db_or_user.name(),
unix_user,
// TODO: pass actual denylist
&GroupDenylist::new(),
)
.map_err(ValidationError::AuthorizationError)?;
Ok(())
}

View File

@@ -1,4 +1,4 @@
pub mod authorization;
mod authorization;
pub mod command;
mod common;
pub mod config;

View File

@@ -1,21 +1,12 @@
use std::{collections::HashSet, path::Path};
use anyhow::Context;
use nix::unistd::Group;
use crate::core::{
common::UnixUser,
protocol::{
CheckAuthorizationError,
request_validation::{GroupDenylist, validate_db_or_user_request},
},
protocol::{CheckAuthorizationError, request_validation::validate_db_or_user_request},
types::DbOrUser,
};
pub async fn check_authorization(
dbs_or_users: Vec<DbOrUser>,
unix_user: &UnixUser,
_group_denylist: &GroupDenylist,
) -> std::collections::BTreeMap<DbOrUser, Result<(), CheckAuthorizationError>> {
let mut results = std::collections::BTreeMap::new();
@@ -26,102 +17,9 @@ pub async fn check_authorization(
results.insert(db_or_user.clone(), Err(err));
continue;
}
results.insert(db_or_user.clone(), Ok(()));
}
results
}
/// Reads and parses a group denylist file, returning a set of GUIDs
///
/// The format of the denylist file is expected to be one group name or GID per line.
/// Lines starting with '#' are treated as comments and ignored.
/// Empty lines are also ignored.
///
/// Each line looks like one of the following:
/// - `gid:1001`
/// - `group:admins`
pub fn read_and_parse_group_denylist(denylist_path: &Path) -> anyhow::Result<GroupDenylist> {
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());
for (line_number, line) in content.lines().enumerate() {
let trimmed_line = line.trim();
if trimmed_line.is_empty() || trimmed_line.starts_with('#') {
continue;
}
let parts: Vec<&str> = trimmed_line.splitn(2, ':').collect();
if parts.len() != 2 {
anyhow::bail!(
"Invalid format in denylist file at {:?} on line {}: {}",
denylist_path,
line_number + 1,
line
);
}
match parts[0] {
"gid" => {
let gid: u32 = parts[1].parse().with_context(|| {
format!(
"Invalid GID in denylist file at {:?} on line {}: {}",
denylist_path,
line_number + 1,
parts[1]
)
})?;
let group = Group::from_gid(nix::unistd::Gid::from_raw(gid))
.context(format!(
"Failed to get group for GID {} in denylist file at {:?} on line {}",
gid,
denylist_path,
line_number + 1
))?
.ok_or_else(|| {
anyhow::anyhow!(
"No group found for GID {} in denylist file at {:?} on line {}",
gid,
denylist_path,
line_number + 1
)
})?;
groups.insert(group.gid.as_raw());
}
"group" => {
let group = Group::from_name(parts[1])
.context(format!(
"Failed to get group for name '{}' in denylist file at {:?} on line {}",
parts[1],
denylist_path,
line_number + 1
))?
.ok_or_else(|| {
anyhow::anyhow!(
"No group found for name '{}' in denylist file at {:?} on line {}",
parts[1],
denylist_path,
line_number + 1
)
})?;
groups.insert(group.gid.as_raw());
}
_ => {
anyhow::bail!(
"Invalid prefix '{}' in denylist file at {:?} on line {}: {}",
parts[0],
denylist_path,
line_number + 1,
line
);
}
}
}
Ok(groups)
}

View File

@@ -78,15 +78,9 @@ impl MysqlConfig {
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
pub struct AuthorizationConfig {
pub group_denylist_file: Option<PathBuf>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
pub struct ServerConfig {
pub socket_path: Option<PathBuf>,
pub authorization: AuthorizationConfig,
pub mysql: MysqlConfig,
}

View File

@@ -11,7 +11,7 @@ use crate::{
common::UnixUser,
protocol::{
Request, Response, ServerToClientMessageStream, SetPasswordError,
create_server_to_client_message_stream, request_validation::GroupDenylist,
create_server_to_client_message_stream,
},
},
server::{
@@ -39,7 +39,6 @@ pub async fn session_handler(
socket: UnixStream,
db_pool: Arc<RwLock<MySqlPool>>,
db_is_mariadb: bool,
group_denylist: &GroupDenylist,
) -> anyhow::Result<()> {
let uid = match socket.peer_cred() {
Ok(cred) => cred.uid(),
@@ -86,14 +85,8 @@ pub async fn session_handler(
(async move {
tracing::info!("Accepted connection from user: {}", unix_user);
let result = session_handler_with_unix_user(
socket,
&unix_user,
db_pool,
db_is_mariadb,
group_denylist,
)
.await;
let result =
session_handler_with_unix_user(socket, &unix_user, db_pool, db_is_mariadb).await;
tracing::info!(
"Finished handling requests for connection from user: {}",
@@ -111,7 +104,6 @@ pub async fn session_handler_with_unix_user(
unix_user: &UnixUser,
db_pool: Arc<RwLock<MySqlPool>>,
db_is_mariadb: bool,
group_denylist: &GroupDenylist,
) -> anyhow::Result<()> {
let mut message_stream = create_server_to_client_message_stream(socket);
@@ -139,7 +131,6 @@ pub async fn session_handler_with_unix_user(
unix_user,
&mut db_connection,
db_is_mariadb,
group_denylist,
)
.await;
@@ -156,7 +147,6 @@ async fn session_handler_with_db_connection(
unix_user: &UnixUser,
db_connection: &mut MySqlConnection,
db_is_mariadb: bool,
group_denylist: &GroupDenylist,
) -> anyhow::Result<()> {
stream.send(Response::Ready).await?;
loop {
@@ -188,7 +178,7 @@ async fn session_handler_with_db_connection(
let response = match request {
Request::CheckAuthorization(dbs_or_users) => {
let result = check_authorization(dbs_or_users, unix_user, group_denylist).await;
let result = check_authorization(dbs_or_users, unix_user).await;
Response::CheckAuthorization(result)
}
Request::CompleteDatabaseName(partial_database_name) => {

View File

@@ -17,13 +17,9 @@ use tokio::{
};
use tokio_util::{sync::CancellationToken, task::TaskTracker};
use crate::{
core::protocol::request_validation::GroupDenylist,
server::{
authorization::read_and_parse_group_denylist,
config::{MysqlConfig, ServerConfig},
session_handler::session_handler,
},
use crate::server::{
config::{MysqlConfig, ServerConfig},
session_handler::session_handler,
};
#[derive(Clone, Debug)]
@@ -40,7 +36,6 @@ pub struct ReloadEvent;
pub struct Supervisor {
config_path: PathBuf,
config: Arc<Mutex<ServerConfig>>,
group_deny_list: Arc<RwLock<GroupDenylist>>,
systemd_mode: bool,
shutdown_cancel_token: CancellationToken,
@@ -71,23 +66,6 @@ impl Supervisor {
let config = ServerConfig::read_config_from_path(&config_path)
.context("Failed to read server configuration")?;
let group_deny_list = match &config.authorization.group_denylist_file {
Some(denylist_path) => {
let denylist = read_and_parse_group_denylist(denylist_path)
.context("Failed to read group denylist file")?;
tracing::debug!(
"Loaded group denylist with {} entries from {:?}",
denylist.len(),
denylist_path
);
Arc::new(RwLock::new(denylist))
}
None => {
tracing::debug!("No group denylist file specified, proceeding without a denylist");
Arc::new(RwLock::new(GroupDenylist::new()))
}
};
let mut watchdog_duration = None;
let mut watchdog_micro_seconds = 0;
let watchdog_task =
@@ -152,14 +130,12 @@ impl Supervisor {
db_connection_pool.clone(),
rx,
db_is_mariadb.clone(),
group_deny_list.clone(),
))
};
Ok(Self {
config_path,
config: Arc::new(Mutex::new(config)),
group_deny_list,
systemd_mode,
reload_message_receiver: reload_rx,
shutdown_cancel_token,
@@ -202,26 +178,6 @@ impl Supervisor {
.context("Failed to read server configuration")?;
let mut config = self.config.clone().lock_owned().await;
*config = new_config;
let group_deny_list = match &config.authorization.group_denylist_file {
Some(denylist_path) => {
let denylist = read_and_parse_group_denylist(denylist_path)
.context("Failed to read group denylist file")?;
tracing::debug!(
"Loaded group denylist with {} entries from {:?}",
denylist.len(),
denylist_path
);
denylist
}
None => {
tracing::debug!("No group denylist file specified, proceeding without a denylist");
GroupDenylist::new()
}
};
let mut group_deny_list_lock = self.group_deny_list.write().await;
*group_deny_list_lock = group_deny_list;
Ok(())
}
@@ -511,7 +467,6 @@ async fn listener_task(
db_pool: Arc<RwLock<MySqlPool>>,
mut supervisor_message_receiver: broadcast::Receiver<SupervisorMessage>,
db_is_mariadb: Arc<RwLock<bool>>,
group_denylist: Arc<RwLock<GroupDenylist>>,
) -> anyhow::Result<()> {
sd_notify::notify(false, &[sd_notify::NotifyState::Ready])?;
@@ -548,14 +503,8 @@ async fn listener_task(
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,
db_pool_clone,
db_is_mariadb_clone,
&*group_denylist_arc_clone.read().await,
).await {
match session_handler(conn, db_pool_clone, db_is_mariadb_clone).await {
Ok(()) => {}
Err(e) => {
tracing::error!("Failed to run server: {}", e);