diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..e4eeb7c --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,61 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "aho-corasick" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2969dcb958b36655471fc61f7e416fa76033bdd4bfed0678d8fee1e2d07a1f0" +dependencies = [ + "memchr", +] + +[[package]] +name = "libc" +version = "0.2.152" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13e3bf6590cbc649f4d1a3eefc9d5d6eb746f5200ffb04e5e142700b8faa56e7" + +[[package]] +name = "memchr" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "523dc4f511e55ab87b694dc30d0f820d60906ef06413f93d4d7a1385599cc149" + +[[package]] +name = "mumble-docker" +version = "0.1.0" +dependencies = [ + "libc", + "regex", +] + +[[package]] +name = "regex" +version = "1.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b62dbe01f0b06f9d8dc7d49e05a0785f153b00b2c227856282f671e0318c9b15" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b7fa1134405e2ec9353fd416b17f8dacd46c473d7d3fd1cf202706a14eb792a" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..e605d0c --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "mumble-docker" +version = "0.1.0" +edition = "2021" +publish = false + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +regex = "1.10.3" +libc = "0.2.152" + +[profile.release] +lto = true +codegen-units = 1 +opt-level = "s" +strip = true diff --git a/Dockerfile b/Dockerfile index 819a5b9..a294251 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,24 @@ -FROM ubuntu:20.04 as base +ARG DEBIAN_RELEASE_NAME=bullseye +ARG DEBIAN_RELEASE_NUM=11 + +# ------------------------------------------------------# +# --- Build stage for the entrypoint.sh replacement ----# +# ------------------------------------------------------# +FROM rust:slim-${DEBIAN_RELEASE_NAME} as builder +WORKDIR /data +COPY Cargo.toml /data +COPY Cargo.lock /data +RUN mkdir -p /data/src +RUN echo 'fn main() {}' > /data/src/main.rs +RUN cargo build +COPY ./src/main.rs /data/src/main.rs +RUN cargo build --release + +# ------------------------------------------------------# +# --- Runtime image with mumble-server's dependencies --# +# --- (Used to extract the shared libs from later) -----# +# ------------------------------------------------------# +FROM debian:${DEBIAN_RELEASE_NAME}-slim as base ARG DEBIAN_FRONTEND=noninteractive RUN apt-get update && apt-get install --no-install-recommends -y \ @@ -26,6 +46,9 @@ RUN apt-get update && apt-get install --no-install-recommends -y \ +# ------------------------------------------------------# +# --- Build stage for mumble-server itself -------------# +# ------------------------------------------------------# FROM base as build ARG DEBIAN_FRONTEND=noninteractive @@ -62,8 +85,11 @@ RUN /mumble/scripts/clone.sh && /mumble/scripts/build.sh \ && /mumble/scripts/copy_one_of.sh ./scripts/murmur.ini ./auxiliary_files/mumble-server.ini default_config.ini - -FROM base +# ------------------------------------------------------# +# --- Preparation stage that could run mumble ----------# +# --- Determines config files and libs for final stage -# +# ------------------------------------------------------# +FROM base as runner ARG MUMBLE_UID=1000 ARG MUMBLE_GID=1000 @@ -73,11 +99,35 @@ COPY --from=build /mumble/repo/build/mumble-server /usr/bin/mumble-server COPY --from=build /mumble/repo/default_config.ini /etc/mumble/bare_config.ini RUN mkdir -p /data && chown -R mumble:mumble /data && chown -R mumble:mumble /etc/mumble -USER mumble -EXPOSE 64738/tcp 64738/udp + +COPY copy-libs.sh /copy-libs.sh COPY entrypoint.sh /entrypoint.sh +COPY --from=builder /data/target/release/mumble-docker/ /mumble-docker-entrypoint + +# Copy over all required shared libraries to /lib/copy so we can +# reuse them in the distroless container in the final stage +RUN /copy-libs.sh /mumble-docker-entrypoint /lib/copy +RUN /copy-libs.sh /usr/bin/mumble-server /lib/copy +RUN /copy-libs.sh /usr/lib/x86_64-linux-gnu/qt5/plugins/sqldrivers/libqsqlite.so /lib/copy +RUN cp -r /usr/lib/x86_64-linux-gnu/qt-default /lib/copy +RUN cp -r /usr/lib/x86_64-linux-gnu/qt5/ /lib/copy + +# ------------------------------------------------------# +# --- Distroless base image for the final container --- # +# --- Copying over needed libs from previous stage ---- # +# ------------------------------------------------------# +FROM gcr.io/distroless/cc-debian${DEBIAN_RELEASE_NUM}:latest +COPY --from=runner /etc/passwd /etc/passwd +COPY --from=runner /etc/shadow /etc/shadow +COPY --from=runner /usr/bin/mumble-server /usr/bin/mumble-server +COPY --from=runner /lib/copy /usr/lib/x86_64-linux-gnu +COPY --from=runner /etc/mumble /etc/mumble +COPY --chown=1000:1000 --from=runner /data /data +COPY --from=runner /mumble-docker-entrypoint /mumble-docker-entrypoint +USER mumble +EXPOSE 64738/tcp 64738/udp VOLUME ["/data"] -ENTRYPOINT ["/entrypoint.sh"] +ENTRYPOINT ["/mumble-docker-entrypoint"] CMD ["/usr/bin/mumble-server", "-fg"] diff --git a/copy-libs.sh b/copy-libs.sh new file mode 100755 index 0000000..987ed31 --- /dev/null +++ b/copy-libs.sh @@ -0,0 +1,16 @@ +#!/bin/sh +if [ $# -lt 2 ] +then + echo "Too few arguments. Run copy-libs.sh " + exit 1 +fi + +binary_path=$1 +target_path=$2 + +mkdir -p "$2" +for i in $(ldd "$binary_path" | grep "=>" | awk -F ' => ' '{print $2}' | cut -d ' ' -f1) +do + echo "Copying $i" + cp -f "$i" "$2" +done \ No newline at end of file diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..3670bf9 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,215 @@ +const DATA_DIR: &str = "/data"; +const BARE_BONES_CONFIG_FILE: &str = "/etc/mumble/bare_config.ini"; +const CONFIG_REGEX: &str = r"^(;|#)? *([a-zA-Z_0-9]+)=.*"; +use regex::Regex; +use std::os::unix::process::CommandExt; +use std::path::Path; +use std::process::Command; +use std::{collections::HashMap, env, fs, os::unix::prelude::MetadataExt}; + +const SENSITIVE_CONFIGS: [&str; 6] = [ + "dbPassword", + "icesecretread", + "icesecretwrite", + "serverpassword", + "registerpassword", + "sslPassPhrase", +]; + +fn slice_contains(slice: &[&'static str; 6], search: &str) -> bool { + for entry in slice.iter() { + if *entry == search { + return true; + } + } + false +} + +fn normalize_name(name: &str) -> String { + name.to_uppercase().replace('_', "") +} + +fn set_config_internal( + config_file_content: &mut String, + used_configs: &mut Vec, + config_name: &str, + config_value: &str, + is_default: bool, +) { + if is_default && used_configs.contains(&config_name.to_string()) { + //Do not overwrite user specified options with defaults + return; + } + if slice_contains(&SENSITIVE_CONFIGS, config_name) { + println!("Setting config \"{}\" to: *********", config_name); + } else { + println!("Setting config \"{}\" to: {}", config_name, config_value); + } + used_configs.push(String::from(config_name)); + config_file_content.push_str(&format!("{}={}\n", config_name, config_value)); +} + +fn list_mumble_config_secrets() -> Vec<(String, String)> { + let mut result: Vec<(String, String)> = Vec::new(); + if let Ok(read_dir) = fs::read_dir("/run/secrets/") { + for entry in read_dir { + let file_name_os = entry.unwrap().file_name(); + let file_name_str = file_name_os.to_str().unwrap().to_string(); + if file_name_str.starts_with("MUMBLE_CONFIG_") { + let file_content = fs::read_to_string(&file_name_str).unwrap(); + result.push((file_name_str, file_content)); + } + } + } + result +} + +fn list_mumble_config_env_vars() -> Vec<(String, String)> { + let mut result: Vec<(String, String)> = Vec::new(); + for env_var in env::vars() { + if env_var.0.starts_with("MUMBLE_CONFIG_") { + result.push(env_var); + } + } + result +} + +fn get_existing_config_options( + bare_bones_config: &str, + option_for: &mut HashMap, +) -> Vec { + let config_line_regex = Regex::new(CONFIG_REGEX).unwrap(); + let mut existing_config_options: Vec = Vec::new(); + for line in bare_bones_config.lines() { + let captures = config_line_regex.captures(line); + if let Some(matches) = captures { + let option = matches.get(2).unwrap(); + let option_string = option.as_str().to_string(); + option_for.insert( + format!("MUMBLE_CONFIG_{}", normalize_name(&option_string)), + option_string.clone(), + ); + existing_config_options.push(option_string.clone()); + } + } + existing_config_options +} + +fn set_superuser_password(server_invocation: &[String], mumble_supw_password_secret: String) { + let mut set_secret_server_invocation = server_invocation.to_owned(); + set_secret_server_invocation.push(String::from("-supw")); + set_secret_server_invocation.push(mumble_supw_password_secret); + let status = Command::new(&set_secret_server_invocation[0]) + .args(&set_secret_server_invocation[1..]) + .status() + .expect("Could not set superuse password"); + println!( + "Successfully configured superuser password with exit status {}", + status + ); +} + +fn main() { + let mut used_config_options: Vec = Vec::new(); + let mut option_for: HashMap = HashMap::new(); + let mut config_file = format!("{DATA_DIR}/mumble_server_config.ini"); + let mut config_file_content: String = String::from("# Config file automatically generated from the MUMBLE_CONFIG_* environment variables or secrets in /run/secrets/MUMBLE_CONFIG_* files\n"); + let bare_bones_config = + fs::read_to_string(BARE_BONES_CONFIG_FILE).expect("Could not read barebones config file"); + get_existing_config_options(&bare_bones_config, &mut option_for); + let mut set_config = |config_name: &str, config_value: &str, is_default: bool| { + set_config_internal( + &mut config_file_content, + &mut used_config_options, + config_name, + config_value, + is_default, + ); + }; + match env::var("MUMBLE_CUSTOM_CONFIG_FILE") { + Ok(custom_config_path) => { + println!("Using manually specified config file at $MUMBLE_CUSTOM_CONFIG_FILE\nAll MUMBLE_CONFIG variables will be ignored"); + config_file = custom_config_path; + } + Err(_e) => { + for mumble_env in list_mumble_config_env_vars() { + let config_option = option_for.get(mumble_env.0.as_str()); + match config_option { + Some(config_name) => { + set_config(config_name, &mumble_env.1, false); + } + None => { + println!("Could not find config option for variable {}", mumble_env.0); + } + } + } + + for mumble_secret in list_mumble_config_secrets() { + let config_option = option_for.get(mumble_secret.0.as_str()); + match config_option { + Some(config_name) => set_config(config_name, &mumble_secret.1, false), + None => { + println!( + "Could not find config option for secret {}", + mumble_secret.0 + ); + } + } + } + + //Apply default settings if they're missing + let old_db_file = format!("{DATA_DIR}/murmur.sqlite"); + if Path::new(&old_db_file).is_file() { + set_config("database", &old_db_file, true); + } else { + set_config( + "database", + &format!("{DATA_DIR}/mumble-server.sqlite"), + true, + ); + } + set_config("ice", "\"tcp -h 127.0.0.1 -p 6502\"", true); + set_config( + "welcometext", + "\"
Welcome to this server, running the official Mumble Docker image.
Enjoy your stay!
\"", + true, + ); + + set_config("port", "64738", true); + set_config("users", "100", true); + config_file_content + .push_str("\n[Ice]\nIce.Warn.UnknownProperties=1\nIce.MessageSizeMax=65536"); + fs::write(&config_file, &config_file_content) + .expect("Could not write generated config file"); + } + } + let mut server_invocation: Vec = env::args().skip(1).collect(); + server_invocation.push(String::from("-ini")); + server_invocation.push(config_file); + let variable = env::var("MUMBLE_SUPERUSER_PASSWORD").ok(); + let mumble_supw_secret_path = Path::new("/run/secrets/MUMBLE_SUPERUSER_PASSWORD"); + if mumble_supw_secret_path.is_file() { + let mumble_supw_password_secret = fs::read_to_string(mumble_supw_secret_path).unwrap(); + set_superuser_password(&server_invocation, mumble_supw_password_secret); + } else if variable.is_some() { + set_superuser_password(&server_invocation, variable.unwrap()); + } + + let user_uid = unsafe { libc::getuid() }; + let user_gid = unsafe { libc::getgid() }; + println!("Running Mumble server as UID={user_uid} GID={user_gid}"); + let metadata = Path::new(DATA_DIR) + .metadata() + .expect("Could not query permissions for data directory"); + println!( + "{DATA_DIR} has the following permissions set: {:o} with UID={} and GID={}", + metadata.mode(), + metadata.uid(), + metadata.gid() + ); + + println!("Command run to start the service: {:?}", server_invocation); + Command::new(&server_invocation[0]) + .args(&server_invocation[1..]) + .exec(); +}