6 Commits

Author SHA1 Message Date
912f0e8971 server: hide systemd stuff behind compiletime cond
All checks were successful
Build and test / check (push) Successful in 1m49s
Build and test / check-license (push) Successful in 1m49s
Build and test / build (push) Successful in 2m36s
Build and test / test (push) Successful in 3m25s
Build and test / docs (push) Successful in 6m35s
2025-12-15 17:02:53 +09:00
73f5cd9fd4 .gitea/workflows: actions-rs/toolchain -> dtolnay/rust-toolchain
All checks were successful
Build and test / check-license (push) Successful in 59s
Build and test / check (push) Successful in 1m42s
Build and test / build (push) Successful in 3m2s
Build and test / test (push) Successful in 3m27s
Build and test / docs (push) Successful in 5m7s
2025-12-15 16:22:15 +09:00
caf16c7a21 .gitea/workflows: use cargo-binstall everywhere, disable telemetry
Some checks failed
Build and test / check (push) Successful in 1m44s
Build and test / check-license (push) Successful in 1m48s
Build and test / build (push) Successful in 2m52s
Build and test / test (push) Successful in 3m1s
Build and test / docs (push) Has been cancelled
2025-12-15 16:15:06 +09:00
aac7315fd9 .gitea/workflows: name artifact zips with commit hash
All checks were successful
Build and test / check (push) Successful in 1m40s
Build and test / build (push) Successful in 2m52s
Build and test / test (push) Successful in 3m4s
Build and test / check-license (push) Successful in 6m18s
Build and test / docs (push) Successful in 5m59s
2025-12-15 16:06:22 +09:00
aa96587a35 assets/debian/config.toml: leave link to installation instructions
Some checks failed
Build and test / build (push) Successful in 2m37s
Build and test / check (push) Successful in 2m51s
Build and test / docs (push) Has been cancelled
Build and test / test (push) Has been cancelled
Build and test / check-license (push) Has been cancelled
2025-12-15 16:00:52 +09:00
15ebc5df5b Cargo.toml: (deb) install documentation 2025-12-15 16:00:27 +09:00
14 changed files with 89 additions and 298 deletions

View File

@@ -6,6 +6,9 @@ on:
- main
pull_request:
env:
BINSTALL_DISABLE_TELEMETRY: 'true'
jobs:
build:
runs-on: debian-latest
@@ -13,10 +16,7 @@ jobs:
- uses: actions/checkout@v6
- name: Install rust toolchain
uses: actions-rs/toolchain@v1
with:
toolchain: stable
override: true
uses: dtolnay/rust-toolchain@stable
- name: Build
run: cargo build --all-features --verbose --release
@@ -27,10 +27,8 @@ jobs:
- uses: actions/checkout@v6
- name: Install rust toolchain
uses: actions-rs/toolchain@v1
uses: dtolnay/rust-toolchain@stable
with:
toolchain: stable
override: true
components: rustfmt, clippy
- name: Check code format
@@ -43,15 +41,13 @@ jobs:
runs-on: debian-latest
steps:
- uses: actions/checkout@v6
- uses: cargo-bins/cargo-binstall@main
- name: Install rust toolchain
uses: actions-rs/toolchain@v1
with:
toolchain: stable
override: true
uses: dtolnay/rust-toolchain@stable
- name: Install cargo-deny
run: cargo install cargo-deny
run: cargo binstall -y cargo-deny
- name: Check licenses
run: |
@@ -72,8 +68,7 @@ jobs:
run: cargo binstall -y cargo-nextest --secure
- name: Run tests
run: |
cargo nextest run --release --no-fail-fast
run: cargo nextest run --release --no-fail-fast
env:
RUST_LOG: "trace"
RUSTFLAGS: "-Cinstrument-coverage"
@@ -116,10 +111,7 @@ jobs:
- uses: actions/checkout@v6
- name: Install rust toolchain
uses: actions-rs/toolchain@v1
with:
toolchain: stable
override: true
uses: dtolnay/rust-toolchain@stable
- name: Build docs
run: cargo doc --all-features --document-private-items --release

View File

@@ -22,6 +22,9 @@ on:
- beta
default: stable
env:
BINSTALL_DISABLE_TELEMETRY: 'true'
# TODO: dynamic matrix builds when...
# https://github.com/go-gitea/gitea/issues/25179
jobs:
@@ -33,15 +36,15 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v6
- uses: cargo-bins/cargo-binstall@main
- name: Install rust toolchain
uses: actions-rs/toolchain@v1
uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ inputs.rust_toolchain }}
override: true
- name: Install cargo-deb
run: cargo install cargo-deb
run: cargo binstall -y cargo-deb
- name: Build deb package
env:
@@ -60,7 +63,7 @@ jobs:
- name: Upload deb package artifact
uses: actions/upload-artifact@v3
with:
name: muscl-deb-${{ matrix.os }}.zip
name: muscl-deb-${{ matrix.os }}-${{ gitea.sha }}.zip
path: target/debian/*.deb
if-no-files-found: error
retention-days: 30

View File

@@ -34,7 +34,6 @@ nix = { version = "0.30.1", features = ["fs", "process", "socket", "user"] }
num_cpus = "1.17.0"
prettytable = "0.10.0"
rand = "0.9.2"
sd-notify = "0.4.5"
serde = "1.0.228"
serde_json = { version = "1.0.145", features = ["preserve_order"] }
sqlx = { version = "0.8.6", features = ["runtime-tokio", "mysql", "tls-rustls"] }
@@ -45,12 +44,13 @@ tokio-stream = "0.1.17"
tokio-util = { version = "0.7.17", features = ["codec", "rt"] }
toml = "0.9.8"
tracing = { version = "0.1.43", features = ["log"] }
tracing-journald = "0.3.2"
tracing-subscriber = "0.3.22"
uuid = { version = "1.19.0", features = ["v4"] }
[target.'cfg(target_os = "linux")'.dependencies]
landlock = "0.4.4"
sd-notify = "0.4.5"
tracing-journald = "0.3.2"
[build-dependencies]
anyhow = "1.0.100"
@@ -147,6 +147,16 @@ assets = [
"usr/share/fish/vendor_completions.d/",
"644",
],
[
"README.md",
"usr/share/doc/muscl/",
"644",
],
[
"docs/*.md",
"usr/share/doc/muscl/docs/",
"644",
],
]
preserve-symlinks = true
maintainer-scripts = "debian/"

View File

@@ -16,7 +16,8 @@ port = 3306
# systemd unit.
username = "muscl"
# This file gets created by systemd automatically, given you have set
# the password with `systemd-creds`.
# the password with `systemd-creds`. See /usr/share/doc/muscl/docs/installation.md
# for more information.
password_file = "/run/credentials/muscl.service/muscl_mysql_password"
# Database connection timeout in seconds

View File

@@ -1,53 +0,0 @@
[Unit]
Description=Authorization daemon for Muscl
[Service]
Type=notify
ExecStart=/usr/local/bin/muscl_auth_daemon.py
# WatchdogSec=15
User=muscl
Group=muscl
DynamicUser=yes
; ConfigurationDirectory=muscl
; RuntimeDirectory=muscl
; # This is required to read unix user/group details.
; PrivateUsers=false
; # Needed to communicate with MySQL.
; PrivateNetwork=false
; PrivateIPC=false
; AmbientCapabilities=
; CapabilityBoundingSet=
; DeviceAllow=
; DevicePolicy=closed
; LockPersonality=true
; MemoryDenyWriteExecute=true
; NoNewPrivileges=true
; PrivateDevices=true
; PrivateMounts=true
; PrivateTmp=yes
; ProcSubset=pid
; ProtectClock=true
; ProtectControlGroups=strict
; ProtectHome=true
; ProtectHostname=true
; ProtectKernelLogs=true
; ProtectKernelModules=true
; ProtectKernelTunables=true
; ProtectProc=invisible
; ProtectSystem=strict
; RemoveIPC=true
; RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
; RestrictNamespaces=true
; RestrictRealtime=true
; RestrictSUIDSGID=true
; SocketBindDeny=any
; SystemCallArchitectures=native
; SystemCallFilter=@system-service
; SystemCallFilter=~@privileged @resources
; UMask=0777

View File

@@ -1,8 +0,0 @@
[Unit]
Description=Authorization daemon for Muscl
WantedBy=sockets.target
[Socket]
ListenStream=/run/muscl/muscl-auth-daemon.socket
Accept=no
SocketMode=0660

View File

@@ -1,84 +0,0 @@
#!/usr/bin/env python3
# TODO: create pool of workers to handle requests concurrently
# the socket should be a listener socket and each worker should accept connections from it
# the socket should accept requests as newline-separated JSON objects
# there should be a watchdog to monitor worker health and restart them if they die
# graceful shutdown should be implemented for the workers
# optional logging of requests and responses
# use systemd notify to signal readiness and amount of connections handled
import json
import os
from socket import AF_UNIX, SOCK_DGRAM, SOCK_STREAM, fromfd, socket
from multiprocessing import Pool
def get_listener_from_systemd() -> socket:
listen_fds = int(os.getenv("LISTEN_FDS", "0"))
listen_pid = int(os.getenv("LISTEN_PID", "0"))
if listen_fds != 1 or listen_pid != os.getpid():
raise RuntimeError("No socket passed from systemd")
assert listen_fds == 1
sock = fromfd(3, AF_UNIX, SOCK_STREAM)
sock.setblocking(False)
return sock
def get_notify_socket_from_systemd() -> socket:
notify_socket_path = os.getenv("NOTIFY_SOCKET")
if not notify_socket_path:
raise RuntimeError("No notify socket path found in environment")
sock = socket(AF_UNIX, SOCK_DGRAM)
sock.connect(notify_socket_path)
return sock
def run_auth_daemon(sock: socket):
sock.listen()
print("Auth daemon is running and listening for connections...")
with Pool() as worker_pool:
with get_notify_socket_from_systemd() as notify_socket:
notify_socket.sendall(b"READY=1\n")
while True:
conn, _ = sock.accept()
worker_pool.apply_async(session_handler, args=(conn,))
def session_handler(sock: socket):
buffer = ""
while True:
data = sock.recv(4096).decode("utf-8")
if not data:
print("Connection closed by client")
break
buffer += data
if buffer.endswith("\n"):
requests = buffer.strip().split("\n")
buffer = ""
for request in requests:
try:
req_json = json.loads(request)
username = req_json.get("username", "")
groups = req_json.get("groups", [])
resource_type = req_json.get("resource_type", "")
resource = req_json.get("resource", "")
allowed = process_request(username, groups, resource_type, resource)
response = {"allowed": allowed}
except json.JSONDecodeError:
response = {"error": "Invalid JSON"}
sock.sendall((json.dumps(response) + "\n").encode("utf-8"))
def process_request(
username: str,
groups: list[str],
resource_type: str,
resource: str,
) -> bool:
...
if __name__ == "__main__":
listener_socket = get_listener_from_systemd()
run_auth_daemon(listener_socket)

View File

@@ -105,7 +105,6 @@
fileset = lib.fileset.unions [
(craneLib.fileset.commonCargoSources ./.)
./assets
./examples
];
};
in {

View File

@@ -85,9 +85,6 @@ buildFunction ({
install -Dm644 assets/systemd/muscl.service -t "$out/lib/systemd/system"
substituteInPlace "$out/lib/systemd/system/muscl.service" \
--replace-fail '/usr/bin/muscl' "$out/bin/muscl"
mkdir -p "$out/share/muscl"
cp -r examples "$out/share/muscl"
'';
meta = with lib; {

View File

@@ -27,31 +27,6 @@ in
}.${level};
};
authHandler = lib.mkOption {
type = with lib.types; nullOr lines;
default = null;
description = "Custom authentication handler, written in python";
example = ''
def process_request(
username: str,
groups: list[str],
resource_type: str,
resource: str,
) -> bool:
if resource_type == "database":
if resource.startswith(username) or any(
resource.startswith(group) for group in groups
):
return True
elif resource_type == "user":
if resource.startswith(username) or any(
resource.startswith(group) for group in groups
):
return True
return False
'';
};
settings = lib.mkOption {
default = { };
type = lib.types.submodule {
@@ -174,72 +149,5 @@ in
++ (lib.optionals (cfg.settings.mysql.host != null) [ "AF_INET" "AF_INET6" ]);
};
};
systemd.sockets."muscl-auth-daemon" = lib.mkIf (cfg.authHandler != null) {
description = "Authorization daemon for Muscl";
wantedBy = [ "sockets.target" ];
socketConfig = {
ListenStream = "/run/muscl/muscl-auth-daemon.sock";
Accept = "no";
};
};
systemd.services."muscl-auth-daemon" = lib.mkIf (cfg.authHandler != null) {
description = "Authorization daemon for Muscl";
requires = [ "muscl-auth-daemon.socket" ];
serviceConfig = {
Type = "notify";
ExecStart = let
authScript = lib.pipe ../examples/auth_daemon_python/muscl_auth_daemon.py [
lib.fileContents
(lib.replaceString ''
def process_request(
username: str,
groups: list[str],
resource_type: str,
resource: str,
) -> bool:
...
'' cfg.authHandler)
(pkgs.writers.writePyPy3Bin "muscl-auth-handler.py" { })
];
in lib.getExe authScript;
User = "muscl-auth-daemon";
Group = "muscl-auth-daemon";
DynamicUser = true;
AmbientCapabilities = [ "" ];
CapabilityBoundingSet = [ "" ];
DeviceAllow = [ "" ];
LockPersonality = true;
NoNewPrivileges = true;
PrivateDevices = true;
PrivateMounts = true;
PrivateTmp = "yes";
ProcSubset = "pid";
ProtectClock = true;
ProtectControlGroups = "strict";
ProtectHome = true;
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectProc = "invisible";
ProtectSystem = "strict";
RemoveIPC = true;
UMask = "0777";
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
SystemCallArchitectures = "native";
SocketBindDeny = [ "any" ];
SystemCallFilter = [
"@system-service"
"~@privileged"
"~@resources"
];
};
};
};
}

View File

@@ -56,25 +56,6 @@ nixpkgs.lib.nixosSystem {
enable = true;
logLevel = "trace";
createLocalDatabaseUser = true;
authHandler = ''
def process_request(
username: str,
groups: list[str],
resource_type: str,
resource: str,
) -> bool:
if resource_type == "database":
if resource.startswith(username) or any(
resource.startswith(group) for group in groups
):
return True
elif resource_type == "user":
if resource.startswith(username) or any(
resource.startswith(group) for group in groups
):
return True
return False
'';
};
programs.vim = {

View File

@@ -16,6 +16,7 @@ pub struct ServerArgs {
pub subcmd: ServerCommand,
/// Enable systemd mode
#[cfg(target_os = "linux")]
#[arg(long)]
pub systemd: bool,
@@ -58,6 +59,8 @@ pub async fn handle_command(
args: ServerArgs,
) -> anyhow::Result<()> {
let mut auto_detected_systemd_mode = false;
#[cfg(target_os = "linux")]
let systemd_mode = args.systemd || {
if let Ok(true) = sd_notify::booted() {
auto_detected_systemd_mode = true;
@@ -67,24 +70,30 @@ pub async fn handle_command(
}
};
#[cfg(not(target_os = "linux"))]
let systemd_mode = false;
if systemd_mode {
let subscriber = tracing_subscriber::Registry::default()
.with(verbosity.tracing_level_filter())
.with(tracing_journald::layer()?);
#[cfg(target_os = "linux")]
{
let subscriber = tracing_subscriber::Registry::default()
.with(verbosity.tracing_level_filter())
.with(tracing_journald::layer()?);
tracing::subscriber::set_global_default(subscriber)
.context("Failed to set global default tracing subscriber")?;
tracing::subscriber::set_global_default(subscriber)
.context("Failed to set global default tracing subscriber")?;
trace_server_prelude();
trace_server_prelude();
if verbosity.tracing_level_filter() >= tracing::Level::TRACE {
tracing::warn!("{}", LOG_LEVEL_WARNING.trim());
}
if verbosity.tracing_level_filter() >= tracing::Level::TRACE {
tracing::warn!("{}", LOG_LEVEL_WARNING.trim());
}
if auto_detected_systemd_mode {
tracing::debug!("Running in systemd mode, auto-detected");
} else {
tracing::debug!("Running in systemd mode");
if auto_detected_systemd_mode {
tracing::debug!("Running in systemd mode, auto-detected");
} else {
tracing::debug!("Running in systemd mode");
}
}
} else {
let subscriber = tracing_subscriber::Registry::default()

View File

@@ -68,6 +68,7 @@ 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) {
watchdog_duration = Some(Duration::from_micros(watchdog_micro_seconds));
@@ -80,6 +81,8 @@ impl Supervisor {
tracing::debug!("Systemd watchdog not enabled, skipping watchdog thread");
None
};
#[cfg(not(target_os = "linux"))]
let watchdog_task = None;
let db_connection_pool =
Arc::new(RwLock::new(create_db_connection_pool(&config.mysql).await?));
@@ -102,19 +105,34 @@ impl Supervisor {
let task_tracker = TaskTracker::new();
#[cfg(target_os = "linux")]
let status_notifier_task = if systemd_mode {
Some(spawn_status_notifier_task(task_tracker.clone()))
} else {
None
};
#[cfg(not(target_os = "linux"))]
let status_notifier_task = None;
let (tx, rx) = broadcast::channel(1);
// TODO: try to detech 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?,
None => create_unix_listener_with_systemd_socket().await?,
}));
#[cfg(not(target_os = "linux"))]
let listener = Arc::new(RwLock::new(
create_unix_listener_with_socket_path(
config
.socket_path
.as_ref()
.ok_or(anyhow!("Socket path must be set"))?
.clone(),
)
.await?,
));
let (reload_tx, reload_rx) = broadcast::channel(1);
let shutdown_cancel_token = CancellationToken::new();
@@ -211,16 +229,28 @@ impl Supervisor {
// first. Make sure to handle that appropriately to avoid a deadlock.
async fn reload_listener(&self) -> anyhow::Result<()> {
let config = self.config.lock().await;
#[cfg(target_os = "linux")]
let new_listener = match config.socket_path {
Some(ref path) => create_unix_listener_with_socket_path(path.clone()).await?,
None => create_unix_listener_with_systemd_socket().await?,
};
#[cfg(not(target_os = "linux"))]
let new_listener = create_unix_listener_with_socket_path(
config
.socket_path
.as_ref()
.ok_or(anyhow!("Socket path must be set"))?
.clone(),
)
.await?;
let mut listener = self.listener.write().await;
*listener = new_listener;
Ok(())
}
pub async fn reload(&self) -> anyhow::Result<()> {
#[cfg(target_os = "linux")]
sd_notify::notify(false, &[sd_notify::NotifyState::Reloading])?;
let previous_config = self.config.lock().await.clone();
@@ -257,12 +287,14 @@ impl Supervisor {
self.resume_receiving_new_connections()?;
}
#[cfg(target_os = "linux")]
sd_notify::notify(false, &[sd_notify::NotifyState::Ready])?;
Ok(())
}
pub async fn shutdown(&self) -> anyhow::Result<()> {
#[cfg(target_os = "linux")]
sd_notify::notify(false, &[sd_notify::NotifyState::Stopping])?;
tracing::debug!("Stop accepting new connections");
@@ -323,6 +355,7 @@ impl Supervisor {
}
}
#[cfg(target_os = "linux")]
fn spawn_watchdog_task(duration: Duration) -> JoinHandle<()> {
tokio::spawn(async move {
let mut interval = interval(duration.div_f32(2.0));
@@ -339,6 +372,7 @@ fn spawn_watchdog_task(duration: Duration) -> JoinHandle<()> {
})
}
#[cfg(target_os = "linux")]
fn spawn_status_notifier_task(task_tracker: TaskTracker) -> JoinHandle<()> {
const STATUS_UPDATE_INTERVAL_SECS: Duration = Duration::from_secs(1);
@@ -385,6 +419,7 @@ async fn create_unix_listener_with_socket_path(
Ok(listener)
}
#[cfg(target_os = "linux")]
async fn create_unix_listener_with_systemd_socket() -> anyhow::Result<TokioUnixListener> {
let fd = sd_notify::listen_fds()
.context("Failed to get file descriptors from systemd")?
@@ -468,6 +503,7 @@ async fn listener_task(
mut supervisor_message_receiver: broadcast::Receiver<SupervisorMessage>,
db_is_mariadb: Arc<RwLock<bool>>,
) -> anyhow::Result<()> {
#[cfg(target_os = "linux")]
sd_notify::notify(false, &[sd_notify::NotifyState::Ready])?;
loop {