commit 12d8d4cd8eaa364372b48dc3ed8a9f61d1d61780 Author: Jonas Heinrich Date: Fri Apr 17 10:51:22 2026 +0200 first commit diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..d982ac0 --- /dev/null +++ b/flake.lock @@ -0,0 +1,26 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1776221942, + "narHash": "sha256-FbQAeVNi7G4v3QCSThrSAAvzQTmrmyDLiHNPvTF2qFM=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "1766437c5509f444c1b15331e82b8b6a9b967000", + "type": "github" + }, + "original": { + "id": "nixpkgs", + "ref": "nixos-25.11", + "type": "indirect" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..3563331 --- /dev/null +++ b/flake.nix @@ -0,0 +1,59 @@ +{ + description = "mail-quota-warning package and service"; + + inputs.nixpkgs.url = "nixpkgs/nixos-25.11"; + + outputs = { self, nixpkgs }: + let + systems = [ "x86_64-linux" "i686-linux" "aarch64-linux" ]; + forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f system); + # Import nixpkgs with our overlay for each system. + nixpkgsFor = forAllSystems (system: + import nixpkgs { + inherit system; + overlays = [ self.overlay ]; + } + ); + in { + overlay = final: prev: { + mail-quota-warning = with final; python3Packages.buildPythonApplication { + pname = "mail-quota-warning"; + version = "0.0.1"; + format = "other"; + + src = self; + + dependencies = with python3Packages; [ + python + pyyaml + imaplib2 + ]; + + installPhase = '' + install -Dm755 ${./mail-quota-warning.py} $out/bin/mail-quota-warning + ''; + + meta.mainProgram = "mail-quota-warning"; + }; + }; + + packages = forAllSystems (system: { + inherit (nixpkgsFor.${system}) mail-quota-warning; + }); + + defaultPackage = forAllSystems (system: self.packages.${system}.mail-quota-warning); + + devShells = forAllSystems (system: let + pkgs = import nixpkgs { inherit system; overlays = [ self.overlay ]; }; + in pkgs.mkShell { + buildInputs = with pkgs; with python3Packages; [ + python + requests + beautifulsoup4 + ]; + }); + + # mail-quota-warning service module + nixosModule = (import ./module.nix); + }; +} diff --git a/module.nix b/module.nix new file mode 100644 index 0000000..d65da3f --- /dev/null +++ b/module.nix @@ -0,0 +1,241 @@ +{ + config, + lib, + pkgs, + ... +}: +let + + cfg = config.services.mail-quota-warning; + +in +{ + + options = { + services.mail-quota-warning = { + + enable = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + Enable mail-quota-warning daemon. + ''; + }; + + settings = lib.mkOption { + type = lib.types.submodule { + freeformType = with lib.types; attrsOf anything; + options = { + CHECK_INTERVAL_DAYS = lib.mkOption { + default = 7; + type = lib.types.int; + description = '' + Interval of days in which a warning message will be + delivered. + ''; + }; + QUOTA_WARNING_THRESHOLD_PERCENT = lib.mkOption { + default = 80; + type = lib.types.int; + description = '' + Threshold of used mailbox space in percent after which + a warning message will be delivered. + ''; + }; + }; + }; + default = { }; + description = '' + Extra options which should be used by the mailbox quota warning script. + ''; + example = lib.literalExpression '' + { + CHECK_INTERVAL_DAYS = 7; + QUOTA_WARNING_THRESHOLD_PERCENT = 80; + } + ''; + }; + + secretFile = lib.mkOption { + type = lib.types.nullOr (lib.types.pathWith { + inStore = false; + absolute = true; + }); + default = null; + example = "/run/keys/mail-quota-warning-secrets"; + description = '' + A YAML file containing secrets, see example config file + in the repository. + ''; + }; + + interval = lib.mkOption { + type = lib.types.str; + default = "*:00,30:00"; + description = '' + How often we run the sync. Default is half an hour. + + The format is described in + {manpage}`systemd.time(7)`. + ''; + }; + + }; + }; + + config = lib.mkIf cfg.enable { + + + # FIXME maybe move this to an external module + systemd.services.tlsa-cloudflare-update = { + description = "Check and update TLSA/DANE record for mx1 from Stalwart ACME Cert"; + + after = [ "network-online.target" "stalwart-mail.service" "agenix.service" ]; + wants = [ "network-online.target" "stalwart-mail.service" "agenix.service" ]; + + serviceConfig = { + Type = "oneshot"; + User = "stalwart-mail"; + Group = "stalwart-mail"; + EnvironmentFile = config.age.secrets.gotlsaflare-cloudflare-token.path; + RuntimeDirectory = "stalwart-tlsa"; + }; + + environment = { + DOMAIN = "project-insanity.org"; + SUBDOMAIN = "mx1"; + PORT = "25"; + ACME_PROVIDER_ID = "cloudflare3"; + }; + + path = with pkgs; [ + bash + coreutils + openssl + dnsutils + gotlsaflare + rocksdb.tools + gawk + ]; + + script = '' + set -eu + + TLSA_RECORD="_$PORT._tcp.$SUBDOMAIN.$DOMAIN" + DB_PATH="/var/lib/stalwart-mail/db" + TEMP_RAW="/run/stalwart-tlsa/cert.bundle" + TEMP_CRT="/run/stalwart-tlsa/cert.crt" + + echo "Starting TLSA update process for $DOMAIN" + + ldb --db="$DB_PATH" --column_family=s get "acme.$ACME_PROVIDER_ID.cert" | base64 -d > "$TEMP_RAW" + + if [ ! -s "$TEMP_RAW" ]; then + echo "ERROR: ACME certificate extraction failed" + exit 1 + fi + + openssl x509 -in "$TEMP_RAW" -out "$TEMP_CRT" + + LOCAL_HASH=$(openssl x509 -in "$TEMP_CRT" -pubkey -noout | openssl pkey -pubin -outform DER | openssl sha256 | awk '{print tolower($2)}') + echo "Local hash: $LOCAL_HASH" + + UPSTREAM_HASH=$(dig +nosplit +short TLSA "$TLSA_RECORD" | awk '{print tolower($4)}' | head -n1) + echo "Upstream hash: $UPSTREAM_HASH" + + if [ "$LOCAL_HASH" = "$UPSTREAM_HASH" ]; then + echo "Hashes match. DNS is up to date." + exit 0 + fi + + echo "Hashes differ! Updating Cloudflare..." + gotlsaflare update \ + --url "$DOMAIN" \ + --subdomain "$SUBDOMAIN" \ + --tcp"$PORT" \ + --cert "$TEMP_CRT" + + echo "TLSA update completed successfully." + ''; + }; + + systemd.timers.tlsa-cloudflare-update = { + description = "Run TLSA check and update every 15 minutes"; + wantedBy = [ "timers.target" ]; + timerConfig = { + OnBootSec = "2m"; + OnUnitActiveSec = "15m"; + Unit = "tlsa-cloudflare-update.service"; + }; + }; + + systemd.services."mail-quota-warning" = { + description = "mail-quota-warning script"; + after = [ "network.target" ]; + wants = [ "network-online.target" ]; + environment = { + PYTHONUNBUFFERED = "1"; + } + // lib.mapAttrs (_: v: toString v) cfg.settings; + serviceConfig = { + Type = "simple"; + LoadCredential = lib.optionalString (cfg.secretFile != null) "secrets.yaml:${cfg.secretFile}"; + ExecStart = "${lib.getExe pkgs.mail-quota-warning} --config \${CREDENTIALS_DIRECTORY}/secrets.yaml"; + WorkingDirectory = "%S/mail-quota-warning"; + StateDirectory = "mail-quota-warning"; + + # hardening + AmbientCapabilities = ""; + CapabilityBoundingSet = ""; + DevicePolicy = "closed"; + DynamicUser = true; + LockPersonality = true; + MemoryDenyWriteExecute = true; + NoNewPrivileges = true; + PrivateDevices = true; + PrivateTmp = true; + PrivateUsers = true; + ProcSubset = "pid"; + ProtectClock = true; + ProtectControlGroups = true; + ProtectHome = true; + ProtectHostname = true; + ProtectKernelLogs = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; + ProtectProc = "invisible"; + ProtectSystem = "strict"; + RemoveIPC = true; + RestrictAddressFamilies = [ + "AF_INET" + "AF_INET6" + ]; + RestrictNamespaces = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + SystemCallArchitectures = "native"; + SystemCallFilter = [ + "@system-service" + "~@privileged" + ]; + UMask = "0077"; + }; + }; + + systemd.timers.mail-quota-warning = { + timerConfig = { + OnCalendar = [ + "" + cfg.interval + ]; + }; + wantedBy = [ "timers.target" ]; + }; + + }; + + meta = { + maintainers = with lib.maintainers; [ onny ]; + }; + +}