{ config, lib, pkgs, ... }:

let
  inherit (builtins) head match readFile;
  inherit (lib) types;
  inherit (config.environment) etc;
  cfg = config.security.apparmor;
  mkDisableOption = descr: lib.mkEnableOption descr // { default=true; example=false; };
in

{
  imports = [
    (lib.mkRemovedOptionModule [ "security" "apparmor" "profiles" ] "Please use the new security.apparmor.policies.")
    apparmor/profiles.nix
  ];
  options = {
    security.apparmor = {
      enable = lib.mkEnableOption "Whether to enable the AppArmor Mandatory Access Control system.";
      policies = lib.mkOption {
        description = ''
          AppArmor policies.
        '';
        type = types.attrsOf (types.submodule ({ name, config, ... }: {
          options = {
            enable = mkDisableOption "Whether to load the profile into the kernel.";
            enforce = mkDisableOption "Whether to enforce the policy or only complain in the logs.";
            profile = lib.mkOption {
              description = "The policy of the profile.";
              type = types.lines;
              apply = pkgs.writeText name;
            };
          };
        }));
        default = {};
      };
      includes = lib.mkOption {
        type = types.attrsOf types.lines;
        default = [];
        description = ''
          List of paths to be added to AppArmor's searched paths
          when resolving absolute #include directives.
        '';
        apply = lib.mapAttrs pkgs.writeText;
      };
      packages = lib.mkOption {
        type = types.listOf types.package;
        default = [];
        description = "List of packages to be added to AppArmor's include path";
      };
      enableCache = lib.mkEnableOption ''
        Whether to enable caching of AppArmor policies
        in <literal>/var/cache/apparmor/</literal>.
        Beware that AppArmor policies almost always contain Nix store paths,
        and thus produce at each change of these paths
        a new cached version accumulating in the cache.
      '';
      killUnconfinedConfinables = mkDisableOption ''
        Whether to kill processes which have an AppArmor profile enabled
        (in <link linkend="opt-security.apparmor.policies">policies</link>)
        but are not confined (because AppArmor can only confine new processes).
      '';
    };
  };

  config = lib.mkIf cfg.enable {
    environment.systemPackages = [ pkgs.apparmor-utils ];
    environment.etc."apparmor.d".source = pkgs.linkFarm "apparmor.d" (
      lib.mapAttrsToList (name: p: {inherit name; path=p.profile;}) cfg.policies ++
      lib.mapAttrsToList (name: path: {inherit name path;}) cfg.includes
    );
    environment.etc."apparmor/parser.conf".text = ''
      ${if cfg.enableCache then "write-cache" else "skip-cache"}
      cache-loc /var/cache/apparmor
      Include /etc/apparmor.d
    '' +
    lib.concatMapStrings (p: "Include ${p}/etc/apparmor.d\n") cfg.packages;
    environment.etc."apparmor/logprof.conf".text = ''
      [settings]
        profiledir = /etc/apparmor.d
        inactive_profiledir = ${pkgs.apparmor-profiles}/share/apparmor/extra-profiles
        logfiles = /var/log/audit/audit.log /var/log/syslog /var/log/messages

        parser = ${pkgs.apparmor-parser}/bin/apparmor_parser
        ldd = ${pkgs.glibc.bin}/bin/ldd
        logger = ${pkgs.utillinux}/bin/logger

        # customize how file ownership permissions are presented
        # 0 - off
        # 1 - default of what ever mode the log reported
        # 2 - force the new permissions to be user
        # 3 - force all perms on the rule to be user
        default_owner_prompt = 1

        # custom directory locations to look for #includes
        #
        # each name should be a valid directory containing possible #include
        # candidate files under the profile dir which by default is /etc/apparmor.d.
        #
        # So an entry of my-includes will allow /etc/apparmor.d/my-includes to
        # be used by the yast UI and profiling tools as a source of #include
        # files.
        custom_includes =

      [qualifiers]
        ${pkgs.runtimeShell} = icnu
        ${pkgs.bashInteractive}/bin/sh = icnu
        ${pkgs.bashInteractive}/bin/bash = icnu
    '' + head (match "^.*\\[qualifiers](.*)" # Drop the original [settings] section.
                     (readFile "${pkgs.apparmor-utils}/etc/apparmor/logprof.conf"));

    boot.kernelParams = [ "apparmor=1" "security=apparmor" ];

    systemd.services.apparmor = {
      after = [
        "local-fs.target"
        "systemd-journald-audit.socket"
      ];
      before = [ "sysinit.target" ];
      wantedBy = [ "multi-user.target" ];
      restartTriggers = [
        etc."apparmor/parser.conf".source
        etc."apparmor.d".source
      ];
      unitConfig = {
        Description="Load AppArmor policies";
        DefaultDependencies = "no";
        ConditionSecurity = "apparmor";
      };
      # Reloading instead of restarting enables to load new AppArmor profiles
      # without necessarily restarting all services which have Requires=apparmor.service
      # It works by:
      # - Adding or replacing into the kernel profiles enabled in cfg.policies
      #   (because AppArmor can do that without stopping the processes already confined).
      # - Removing from the kernel any profile whose name is not
      #   one of the names within the content of the profiles in cfg.policies.
      # - Killing the processes which are unconfined but now have a profile loaded
      #   (because AppArmor can only confine new processes).
      reloadIfChanged = true;
      # Avoid searchs in /usr/share/locale/
      environment.LANG="C";
      serviceConfig = let
        enabledPolicies = lib.attrValues (lib.filterAttrs (n: p: p.enable) cfg.policies);
        unloadDisabledProfiles = pkgs.writeShellScript "apparmor-remove" ''
          set -eux
          
          enabledProfiles=$(mktemp)
          loadedProfiles=$(mktemp)
          trap "rm -f $enabledProfiles $loadedProfiles" EXIT
          
          ${pkgs.apparmor-parser}/bin/apparmor_parser --names /dev/null ${
            lib.concatMapStrings (p: "\\\n "+p.profile) enabledPolicies} |
          sort -u >"$enabledProfiles"
          
          sed -e "s/ (\(enforce\|complain\))$//" /sys/kernel/security/apparmor/profiles |
          sort -u >"$loadedProfiles"

          comm -23 "$loadedProfiles" "$enabledProfiles" |
          while IFS=$'\n\r' read -r profile
          do printf %s "$profile" >/sys/kernel/security/apparmor/.remove
          done
        '';
        killUnconfinedConfinables = pkgs.writeShellScript "apparmor-kill" ''
          set -eux
          ${pkgs.apparmor-utils}/bin/aa-status --json |
          ${pkgs.jq}/bin/jq --raw-output '.processes | .[] | .[] | select (.status == "unconfined") | .pid' |
          xargs --verbose --no-run-if-empty --delimiter='\n' \
          kill
        '';
        commonOpts = p: "--verbose --show-cache ${lib.optionalString (!p.enforce) "--complain "}${p.profile}";
        in {
        Type = "oneshot";
        RemainAfterExit = "yes";
        ExecStartPre = "${pkgs.apparmor-utils}/bin/aa-teardown";
        ExecStart = map (p: "${pkgs.apparmor-parser}/bin/apparmor_parser --add ${commonOpts p}") enabledPolicies;
        ExecStartPost = lib.optional cfg.killUnconfinedConfinables killUnconfinedConfinables;
        ExecReload =
          map (p: "${pkgs.apparmor-parser}/bin/apparmor_parser --replace ${commonOpts p}") enabledPolicies ++
          [ unloadDisabledProfiles ] ++
          lib.optional cfg.killUnconfinedConfinables killUnconfinedConfinables;
        ExecStop = "${pkgs.apparmor-utils}/bin/aa-teardown";
        CacheDirectory = [ "apparmor" ];
        CacheDirectoryMode = "0700";
      };
    };
  };

  meta.maintainers = with lib.maintainers; [ julm ];
}