diff --git a/examples/auth_daemon_python/README.md b/examples/auth_daemon_python/README.md new file mode 100644 index 0000000..e69de29 diff --git a/examples/auth_daemon_python/muscl-auth-daemon.service b/examples/auth_daemon_python/muscl-auth-daemon.service new file mode 100644 index 0000000..0c73b3b --- /dev/null +++ b/examples/auth_daemon_python/muscl-auth-daemon.service @@ -0,0 +1,53 @@ +[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 diff --git a/examples/auth_daemon_python/muscl-auth-daemon.socket b/examples/auth_daemon_python/muscl-auth-daemon.socket new file mode 100644 index 0000000..a4e4ba0 --- /dev/null +++ b/examples/auth_daemon_python/muscl-auth-daemon.socket @@ -0,0 +1,8 @@ +[Unit] +Description=Authorization daemon for Muscl +WantedBy=sockets.target + +[Socket] +ListenStream=/run/muscl/muscl-auth-daemon.socket +Accept=no +SocketMode=0660 diff --git a/examples/auth_daemon_python/muscl_auth_daemon.py b/examples/auth_daemon_python/muscl_auth_daemon.py new file mode 100644 index 0000000..1a77eca --- /dev/null +++ b/examples/auth_daemon_python/muscl_auth_daemon.py @@ -0,0 +1,84 @@ +#!/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) diff --git a/flake.nix b/flake.nix index 5d04334..e32afe2 100644 --- a/flake.nix +++ b/flake.nix @@ -103,6 +103,7 @@ fileset = lib.fileset.unions [ (craneLib.fileset.commonCargoSources ./.) ./assets + ./examples ]; }; in { diff --git a/nix/default.nix b/nix/default.nix index 36f04c0..8da7c6c 100644 --- a/nix/default.nix +++ b/nix/default.nix @@ -84,6 +84,9 @@ 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; { diff --git a/nix/module.nix b/nix/module.nix index 9b13c6c..ec5ef18 100644 --- a/nix/module.nix +++ b/nix/module.nix @@ -27,6 +27,31 @@ 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 { @@ -149,5 +174,72 @@ 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" + ]; + }; + }; }; } diff --git a/nix/nixos-configurations/vm.nix b/nix/nixos-configurations/vm.nix index 11c8b2a..82726d2 100644 --- a/nix/nixos-configurations/vm.nix +++ b/nix/nixos-configurations/vm.nix @@ -55,6 +55,25 @@ 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 = {