mirror of
https://github.com/NixOS/nixpkgs.git
synced 2026-09-15 03:40:22 +00:00
921 lines
30 KiB
Nix
921 lines
30 KiB
Nix
{
|
|
config,
|
|
lib,
|
|
pkgs,
|
|
utils,
|
|
...
|
|
}:
|
|
|
|
let
|
|
inherit (lib)
|
|
boolToString
|
|
concatMapStringsSep
|
|
concatStringsSep
|
|
escapeRegex
|
|
escapeShellArg
|
|
genAttrs
|
|
getExe
|
|
getExe'
|
|
getName
|
|
literalExpression
|
|
mapAttrs
|
|
mapAttrsToList
|
|
mkEnableOption
|
|
mkIf
|
|
mkOption
|
|
mkPackageOption
|
|
optional
|
|
optionalAttrs
|
|
optionals
|
|
types
|
|
;
|
|
inherit (lib.strings) sanitizeDerivationName;
|
|
|
|
cfg = config.services.portmaster;
|
|
settingsFormat = pkgs.formats.json { };
|
|
|
|
nonEmptyStringType = types.addCheck types.str (value: value != "");
|
|
relativeDirectoryType = types.addCheck types.str (
|
|
value:
|
|
value == ""
|
|
|| (
|
|
!(lib.hasPrefix "/" value)
|
|
&& !(lib.hasSuffix "/" value)
|
|
&& builtins.all (
|
|
component:
|
|
!builtins.elem component [
|
|
""
|
|
"."
|
|
".."
|
|
]
|
|
) (lib.splitString "/" value)
|
|
)
|
|
);
|
|
|
|
fingerprintType = types.submodule {
|
|
options = {
|
|
type = mkOption {
|
|
type = types.enum [
|
|
"path"
|
|
"cmdline"
|
|
"env"
|
|
"tag"
|
|
];
|
|
description = "Portmaster fingerprint type.";
|
|
};
|
|
key = mkOption {
|
|
type = types.nullOr nonEmptyStringType;
|
|
default = null;
|
|
description = ''
|
|
Environment variable or metadata tag key. Required for `env` and
|
|
`tag` fingerprints, and invalid for `path` and `cmdline` fingerprints.
|
|
'';
|
|
};
|
|
operation = mkOption {
|
|
type = types.enum [
|
|
"equals"
|
|
"prefix"
|
|
"regex"
|
|
];
|
|
description = "Operation used to compare the fingerprint value.";
|
|
};
|
|
value = mkOption {
|
|
type = nonEmptyStringType;
|
|
description = "The value to match against.";
|
|
};
|
|
};
|
|
};
|
|
|
|
packageMatchType = types.submodule (
|
|
{ config, ... }:
|
|
{
|
|
options = {
|
|
package = mkOption {
|
|
type = types.package;
|
|
description = "Package from which to derive a Portmaster fingerprint.";
|
|
};
|
|
|
|
type = mkOption {
|
|
type = types.enum [
|
|
"path"
|
|
"cmdline"
|
|
];
|
|
default = "path";
|
|
description = "Fingerprint type used for the generated package regular expression.";
|
|
};
|
|
|
|
storeNameRegex = mkOption {
|
|
type = types.nullOr nonEmptyStringType;
|
|
default = null;
|
|
example = "python3[0-9.]*-umu-launcher-unwrapped-[0-9.]+";
|
|
description = ''
|
|
Regular expression matching the package output name after the Nix
|
|
store hash. `null` matches the package's `pname` with an optional
|
|
version suffix.
|
|
'';
|
|
};
|
|
|
|
directory = mkOption {
|
|
type = relativeDirectoryType;
|
|
default = "bin";
|
|
example = "share/go/bin";
|
|
description = ''
|
|
Relative directory below the package output that contains the
|
|
executable. An empty string selects the output root.
|
|
'';
|
|
};
|
|
|
|
name = mkOption {
|
|
type = types.nullOr nonEmptyStringType;
|
|
default = config.package.meta.mainProgram or (config.package.pname or (getName config.package));
|
|
defaultText = literalExpression "package.meta.mainProgram or package.pname";
|
|
example = "nmap";
|
|
description = ''
|
|
Executable name. Set this to `null` and disable `strictLast` to
|
|
match every path below `directory`.
|
|
'';
|
|
};
|
|
|
|
wrapped = mkOption {
|
|
type = types.bool;
|
|
default = true;
|
|
description = "Also match Nix wrapper names such as `.program-wrapped`.";
|
|
};
|
|
|
|
strictHead = mkOption {
|
|
type = types.bool;
|
|
default = true;
|
|
description = "Anchor the generated regular expression at the beginning.";
|
|
};
|
|
|
|
strictLast = mkOption {
|
|
type = types.bool;
|
|
default = true;
|
|
description = "Anchor the generated regular expression at the end.";
|
|
};
|
|
};
|
|
}
|
|
);
|
|
|
|
profilePackageType = types.coercedTo types.package (package: { inherit package; }) packageMatchType;
|
|
|
|
normalizeFingerprint =
|
|
fingerprint:
|
|
{
|
|
inherit (fingerprint) type operation;
|
|
# Keep string context so manually specified package paths remain in the
|
|
# system closure.
|
|
value = toString fingerprint.value;
|
|
}
|
|
// optionalAttrs ((fingerprint.key or null) != null) {
|
|
inherit (fingerprint) key;
|
|
};
|
|
|
|
fingerprintIdentityKey =
|
|
fingerprint: builtins.unsafeDiscardStringContext (builtins.toJSON fingerprint);
|
|
|
|
normalizeFingerprints =
|
|
fingerprints:
|
|
mapAttrsToList (
|
|
_: fingerprintsWithSameIdentity:
|
|
let
|
|
fingerprint = builtins.head fingerprintsWithSameIdentity;
|
|
in
|
|
fingerprint
|
|
// {
|
|
value = builtins.foldl' (
|
|
value: duplicate: lib.addContextFrom duplicate.value value
|
|
) fingerprint.value (builtins.tail fingerprintsWithSameIdentity);
|
|
}
|
|
) (builtins.groupBy fingerprintIdentityKey (map normalizeFingerprint fingerprints));
|
|
|
|
mkPackageRegex =
|
|
packageMatch:
|
|
let
|
|
packageName = packageMatch.package.pname or (getName packageMatch.package);
|
|
storeNameRegex =
|
|
if packageMatch.storeNameRegex == null then
|
|
"${escapeRegex packageName}(-[^/]+)?"
|
|
else
|
|
packageMatch.storeNameRegex;
|
|
inherit (packageMatch) name;
|
|
in
|
|
concatStringsSep "" [
|
|
(lib.optionalString packageMatch.strictHead "^")
|
|
(escapeRegex builtins.storeDir)
|
|
"/[a-z0-9]{32}-"
|
|
storeNameRegex
|
|
(lib.optionalString (packageMatch.directory != "") "/${escapeRegex packageMatch.directory}")
|
|
"/"
|
|
(
|
|
if name == null then
|
|
""
|
|
else if packageMatch.wrapped then
|
|
"(${escapeRegex name}|\\.${escapeRegex name}-wrapped)"
|
|
else
|
|
escapeRegex name
|
|
)
|
|
(lib.optionalString packageMatch.strictLast "$")
|
|
];
|
|
|
|
mkPackageFingerprint = packageMatch: {
|
|
inherit (packageMatch) type;
|
|
operation = "regex";
|
|
value = mkPackageRegex packageMatch;
|
|
};
|
|
|
|
fingerprintError =
|
|
fingerprint:
|
|
if
|
|
builtins.elem fingerprint.type [
|
|
"env"
|
|
"tag"
|
|
]
|
|
&& (fingerprint.key or null) == null
|
|
then
|
|
"`${fingerprint.type}` fingerprints require `key`"
|
|
else if
|
|
builtins.elem fingerprint.type [
|
|
"path"
|
|
"cmdline"
|
|
]
|
|
&& (fingerprint.key or null) != null
|
|
then
|
|
"`${fingerprint.type}` fingerprints must not set `key`"
|
|
else
|
|
null;
|
|
|
|
stateDir = toString cfg.stateDir;
|
|
logsDir = "${stateDir}/logs";
|
|
configDir = "${stateDir}/config";
|
|
packageLibDir = "${cfg.package}/lib/portmaster";
|
|
|
|
runtimeConfigPath = "${stateDir}/config.json";
|
|
runtimeConfigManagedMarkerPath = "${stateDir}/.config.json.nix-managed";
|
|
profilesApiKeyPath = "${configDir}/nix-managed-profiles-api-key";
|
|
|
|
profilesApiBaseUrl = "http://127.0.0.1:817/api/v1";
|
|
profilesPingUrl = "${profilesApiBaseUrl}/ping";
|
|
profilesImportUrl = "${profilesApiBaseUrl}/sync/profile/import?allowReplace";
|
|
|
|
configSettings = builtins.removeAttrs cfg.settings [ "devmode" ];
|
|
hasExternalConfigInputs = cfg.settingsFile != null || cfg.secretsFile != null;
|
|
profilesEnabled = cfg.profiles != { };
|
|
shouldGenerateManagedConfig = configSettings != { } || hasExternalConfigInputs || profilesEnabled;
|
|
|
|
managedConfigSettings =
|
|
optionalAttrs (shouldGenerateManagedConfig && !builtins.hasAttr "core/devMode" cfg.settings) {
|
|
"core/devMode" = cfg.settings.devmode;
|
|
}
|
|
// configSettings;
|
|
generatedManagedConfigFile = settingsFormat.generate "portmaster-config.json" managedConfigSettings;
|
|
managedConfigMergeInputs =
|
|
optionals shouldGenerateManagedConfig [ generatedManagedConfigFile ]
|
|
++ optional (cfg.settingsFile != null) cfg.settingsFile
|
|
++ optional (cfg.secretsFile != null) cfg.secretsFile;
|
|
managesConfig = managedConfigMergeInputs != [ ];
|
|
|
|
portmasterCapabilities = [
|
|
"cap_chown"
|
|
"cap_kill"
|
|
"cap_net_admin"
|
|
"cap_net_bind_service"
|
|
"cap_net_broadcast"
|
|
"cap_net_raw"
|
|
"cap_sys_module"
|
|
"cap_sys_ptrace"
|
|
"cap_dac_override"
|
|
"cap_fowner"
|
|
"cap_fsetid"
|
|
"cap_sys_resource"
|
|
"cap_bpf"
|
|
"cap_perfmon"
|
|
];
|
|
|
|
tmpfileDir = {
|
|
mode = "0755";
|
|
user = "root";
|
|
group = "root";
|
|
};
|
|
|
|
tmpfileDirectories = [
|
|
stateDir
|
|
logsDir
|
|
configDir
|
|
];
|
|
|
|
mkProfileResolution =
|
|
logicalName: profileCfg:
|
|
let
|
|
rawFingerprints = map mkPackageFingerprint profileCfg.packages ++ profileCfg.fingerprints;
|
|
fingerprints = normalizeFingerprints rawFingerprints;
|
|
packageErrors = builtins.filter (error: error != null) (
|
|
map (
|
|
packageMatch:
|
|
if packageMatch.name == null && packageMatch.strictLast then
|
|
"`strictLast` must be false when a package match has no executable `name`"
|
|
else
|
|
null
|
|
) profileCfg.packages
|
|
);
|
|
fingerprintErrors = builtins.filter (error: error != null) (map fingerprintError fingerprints);
|
|
error =
|
|
if rawFingerprints == [ ] then
|
|
"one of `packages` or `fingerprints` must be non-empty"
|
|
else if packageErrors != [ ] then
|
|
builtins.head packageErrors
|
|
else if fingerprintErrors != [ ] then
|
|
builtins.head fingerprintErrors
|
|
else
|
|
null;
|
|
in
|
|
{
|
|
inherit logicalName error fingerprints;
|
|
inherit (profileCfg) settings;
|
|
name = cfg.profilePrefix + profileCfg.name;
|
|
};
|
|
|
|
profileResolutions = mapAttrs mkProfileResolution cfg.profiles;
|
|
profileErrors = builtins.filter (error: error != null) (
|
|
mapAttrsToList (
|
|
logicalName: resolution:
|
|
if resolution.error != null then "`${logicalName}`: ${resolution.error}" else null
|
|
) profileResolutions
|
|
);
|
|
|
|
mkProfilePayload = profileCfg: {
|
|
type = "profile";
|
|
source = "local";
|
|
inherit (profileCfg) name;
|
|
inherit (profileCfg) fingerprints;
|
|
config = profileCfg.settings;
|
|
};
|
|
|
|
profileIdentityEntries = builtins.filter (entry: entry != null) (
|
|
mapAttrsToList (
|
|
logicalName: resolution:
|
|
if resolution.error == null then
|
|
{
|
|
inherit logicalName;
|
|
normalizedFingerprints = resolution.fingerprints;
|
|
identityKey = fingerprintIdentityKey resolution.fingerprints;
|
|
}
|
|
else
|
|
null
|
|
) profileResolutions
|
|
);
|
|
|
|
profileIdentityCollisions = builtins.filter (group: builtins.length group > 1) (
|
|
builtins.attrValues (builtins.groupBy (entry: entry.identityKey) profileIdentityEntries)
|
|
);
|
|
|
|
formatProfileIdentityCollision =
|
|
group:
|
|
let
|
|
profileNames = map (entry: "`${entry.logicalName}`") group;
|
|
inherit ((builtins.head group)) normalizedFingerprints;
|
|
in
|
|
"profiles ${concatStringsSep ", " profileNames} normalize to the same fingerprints: ${builtins.toJSON normalizedFingerprints}";
|
|
|
|
profileExports = mapAttrsToList (logicalName: resolution: {
|
|
inherit logicalName;
|
|
path = settingsFormat.generate "portmaster-profile-${sanitizeDerivationName logicalName}.json" (
|
|
mkProfilePayload resolution
|
|
);
|
|
}) profileResolutions;
|
|
|
|
mergeConfig = pkgs.writeShellScript "portmaster-merge-config" ''
|
|
set -euo pipefail
|
|
|
|
umask 077
|
|
key_tmp=
|
|
cleanup() {
|
|
${getExe' pkgs.coreutils "rm"} -f \
|
|
${escapeShellArg runtimeConfigPath}.tmp \
|
|
"''${key_tmp:-}"
|
|
}
|
|
trap cleanup EXIT
|
|
|
|
inputs=(
|
|
${concatMapStringsSep "\n" (input: " ${escapeShellArg (toString input)}") managedConfigMergeInputs}
|
|
)
|
|
|
|
${lib.optionalString profilesEnabled ''
|
|
if [ ! -s ${escapeShellArg profilesApiKeyPath} ]; then
|
|
key_tmp="$(${getExe' pkgs.coreutils "mktemp"} ${escapeShellArg "${configDir}/.nix-managed-profiles-api-key.XXXXXX"})"
|
|
${getExe' pkgs.coreutils "od"} -An -tx1 -N32 /dev/urandom | ${getExe' pkgs.coreutils "tr"} -d ' \n' > "$key_tmp"
|
|
if [ "$(${getExe' pkgs.coreutils "wc"} -c < "$key_tmp")" -ne 64 ]; then
|
|
printf >&2 '%s\n' "Failed to generate Portmaster managed profiles API key"
|
|
exit 1
|
|
fi
|
|
${getExe' pkgs.coreutils "install"} -m 0600 -o root -g root "$key_tmp" ${escapeShellArg profilesApiKeyPath}
|
|
${getExe' pkgs.coreutils "rm"} -f "$key_tmp"
|
|
key_tmp=
|
|
fi
|
|
|
|
managed_api_key="$(${getExe' pkgs.coreutils "tr"} -d '\r\n' < ${escapeShellArg profilesApiKeyPath})"
|
|
if [[ ! "$managed_api_key" =~ ^[0-9a-f]{64}$ ]]; then
|
|
printf >&2 '%s\n' "Portmaster managed profiles API key must contain exactly 64 lowercase hexadecimal characters"
|
|
exit 1
|
|
fi
|
|
unset managed_api_key
|
|
|
|
${getExe' pkgs.coreutils "chown"} root:root ${escapeShellArg profilesApiKeyPath}
|
|
${getExe' pkgs.coreutils "chmod"} 0600 ${escapeShellArg profilesApiKeyPath}
|
|
''}
|
|
|
|
if [ "''${#inputs[@]}" -eq 0 ]; then
|
|
exit 0
|
|
fi
|
|
|
|
for input in "''${inputs[@]}"; do
|
|
if ! ${getExe pkgs.jq} -e '
|
|
if type == "object" then
|
|
if has("core/apiKeys") and (."core/apiKeys" | type != "array") then
|
|
error("core/apiKeys must be an array")
|
|
else
|
|
true
|
|
end
|
|
else
|
|
error("top-level JSON value must be an object")
|
|
end
|
|
' "$input" > /dev/null; then
|
|
printf >&2 'Invalid Portmaster config input: %s\n' "$input"
|
|
exit 1
|
|
fi
|
|
done
|
|
|
|
jq_merge_args=(-s)
|
|
${lib.optionalString profilesEnabled ''
|
|
jq_merge_args+=(--rawfile profilesApiKey ${escapeShellArg profilesApiKeyPath})
|
|
''}
|
|
|
|
${getExe pkgs.jq} "''${jq_merge_args[@]}" '
|
|
reduce .[] as $item ({}; . * $item)
|
|
${lib.optionalString profilesEnabled ''
|
|
| if has("core/apiKeys") and (."core/apiKeys" | type != "array") then
|
|
error("core/apiKeys must be an array")
|
|
else
|
|
.
|
|
end
|
|
| (."core/apiKeys" // []) as $apiKeys
|
|
| (($profilesApiKey | gsub("[\\r\\n]"; "")) + "?read=admin&write=admin") as $managedApiKey
|
|
| ."core/apiKeys" = (
|
|
$apiKeys + (if ($apiKeys | index($managedApiKey)) then [] else [ $managedApiKey ] end)
|
|
)
|
|
''}
|
|
' "''${inputs[@]}" > ${escapeShellArg runtimeConfigPath}.tmp
|
|
${getExe' pkgs.coreutils "install"} -m 0600 ${escapeShellArg runtimeConfigPath}.tmp ${escapeShellArg runtimeConfigPath}
|
|
: > ${escapeShellArg runtimeConfigManagedMarkerPath}
|
|
${getExe' pkgs.coreutils "chmod"} 0600 ${escapeShellArg runtimeConfigManagedMarkerPath}
|
|
'';
|
|
in
|
|
{
|
|
options.services.portmaster = {
|
|
enable = mkEnableOption "Portmaster application firewall";
|
|
|
|
package = mkPackageOption pkgs "portmaster" { };
|
|
|
|
stateDir = mkOption {
|
|
type = types.externalPath;
|
|
default = "/var/lib/portmaster";
|
|
example = "/persist/portmaster";
|
|
description = "Directory used for Portmaster's mutable state, configuration, and logs.";
|
|
};
|
|
|
|
settings = mkOption {
|
|
type = types.submodule {
|
|
freeformType = settingsFormat.type;
|
|
options.devmode = mkOption {
|
|
type = types.bool;
|
|
default = false;
|
|
description = ''
|
|
Enable development mode, which relaxes local API authentication
|
|
for debugging and browser access to 127.0.0.1:817. The packaged
|
|
desktop client does not require this option.
|
|
'';
|
|
};
|
|
};
|
|
default = { };
|
|
example = {
|
|
"core/log/level" = "warning";
|
|
};
|
|
description = ''
|
|
Global Portmaster settings for runtime {file}`config.json`.
|
|
Per-application profiles are managed by {option}`profiles`.
|
|
|
|
Merge order is recursive and later inputs win: this option,
|
|
{option}`settingsFile`, then {option}`secretsFile`.
|
|
|
|
When declarative configuration is active, the module owns runtime
|
|
{file}`config.json`; global changes made only through the UI are not
|
|
preserved across service starts.
|
|
|
|
Do not put secrets here; values are stored in the Nix store. Use
|
|
{option}`secretsFile` instead.
|
|
|
|
Upstream currently documents the available keys in
|
|
[basic_config.go](https://github.com/safing/portmaster/blob/v2.2.1/base/config/basic_config.go#L10-L96).
|
|
'';
|
|
};
|
|
|
|
settingsFile = mkOption {
|
|
type = types.nullOr types.externalPath;
|
|
default = null;
|
|
example = "/run/secrets/portmaster-settings.json";
|
|
description = ''
|
|
Additional global settings JSON file merged after {option}`settings`.
|
|
Overlapping keys override values from that option.
|
|
'';
|
|
};
|
|
|
|
secretsFile = mkOption {
|
|
type = types.nullOr types.externalPath;
|
|
default = null;
|
|
example = "/run/secrets/portmaster-secrets.json";
|
|
description = ''
|
|
Sensitive global settings JSON file merged last into runtime
|
|
{file}`config.json`, keeping secret values out of the Nix store.
|
|
'';
|
|
};
|
|
|
|
profilePrefix = mkOption {
|
|
type = types.str;
|
|
default = "";
|
|
example = "[NixOS] ";
|
|
description = "Prefix added to the display name of every declarative profile.";
|
|
};
|
|
|
|
profiles = mkOption {
|
|
type = types.attrsOf (
|
|
types.submodule (
|
|
{ name, ... }:
|
|
{
|
|
options = {
|
|
name = mkOption {
|
|
type = nonEmptyStringType;
|
|
default = name;
|
|
description = "Human-readable Portmaster profile name.";
|
|
};
|
|
|
|
packages = mkOption {
|
|
type = types.listOf profilePackageType;
|
|
default = [ ];
|
|
example = literalExpression ''
|
|
[
|
|
pkgs.firefox
|
|
{
|
|
package = pkgs.go;
|
|
directory = "share/go/bin";
|
|
}
|
|
]
|
|
'';
|
|
description = ''
|
|
Packages from which to derive Portmaster fingerprints.
|
|
|
|
A package can be specified directly for the default matching
|
|
behavior, or as an attribute set to customize its generated
|
|
fingerprint. Package-derived regular expressions do not include
|
|
a specific store hash or package version and also match
|
|
Nix-generated `.program-wrapped` executables by default.
|
|
'';
|
|
};
|
|
|
|
fingerprints = mkOption {
|
|
type = types.listOf fingerprintType;
|
|
default = [ ];
|
|
example = [
|
|
{
|
|
type = "env";
|
|
key = "CHROME_DESKTOP";
|
|
operation = "equals";
|
|
value = "vesktop.desktop";
|
|
}
|
|
];
|
|
description = ''
|
|
Additional Portmaster fingerprints for this profile. These are
|
|
merged with fingerprints generated from `packages`.
|
|
|
|
Portmaster fingerprints are ORed, not ANDed, so broad
|
|
fingerprints can match unrelated applications.
|
|
'';
|
|
};
|
|
|
|
settings = mkOption {
|
|
type = types.submodule {
|
|
freeformType = settingsFormat.type;
|
|
options = { };
|
|
};
|
|
default = { };
|
|
example = {
|
|
filter.defaultAction = "permit";
|
|
};
|
|
description = ''
|
|
Per-application Portmaster settings for this profile, using the
|
|
upstream nested per-app configuration shape such as
|
|
`history.enable`, `filter.defaultAction`, or `spn.use`.
|
|
Global-only settings are rejected by Portmaster during sync.
|
|
|
|
Do not put secrets here; generated profile payloads are stored in
|
|
the Nix store.
|
|
'';
|
|
};
|
|
};
|
|
}
|
|
)
|
|
);
|
|
default = { };
|
|
example = literalExpression ''
|
|
{
|
|
Firefox = {
|
|
packages = [ pkgs.firefox ];
|
|
settings.filter.defaultAction = "permit";
|
|
};
|
|
}
|
|
'';
|
|
description = ''
|
|
Declarative per-application profiles keyed by logical name.
|
|
|
|
Profiles are imported through Portmaster's profile import API with
|
|
`allowReplace`. Package-derived regular expressions remain stable across
|
|
package updates. At least one entry in `packages` or `fingerprints` is
|
|
required. Changing a profile's normalized
|
|
fingerprints creates a different Portmaster identity and can leave the
|
|
previous imported profile behind. Removing a declaration does not delete
|
|
a previously imported profile.
|
|
'';
|
|
};
|
|
|
|
extraArgs = mkOption {
|
|
type = types.listOf types.str;
|
|
default = [ ];
|
|
example = [ "--print-stack-on-exit" ];
|
|
description = ''
|
|
Extra command-line arguments to pass to portmaster-core.
|
|
Do not include secrets because these arguments are visible in the
|
|
generated systemd unit and process list.
|
|
'';
|
|
};
|
|
};
|
|
|
|
config = mkIf cfg.enable {
|
|
assertions = optional profilesEnabled {
|
|
assertion = profileErrors == [ ] && profileIdentityCollisions == [ ];
|
|
message = ''
|
|
services.portmaster.profiles must be valid and have unique normalized fingerprints; ${
|
|
concatStringsSep "; " (
|
|
profileErrors ++ map formatProfileIdentityCollision profileIdentityCollisions
|
|
)
|
|
}
|
|
'';
|
|
};
|
|
|
|
system.activationScripts.portmaster-cleanup = ''
|
|
if ${boolToString (!managesConfig)}; then
|
|
if [ -e ${escapeShellArg runtimeConfigManagedMarkerPath} ]; then
|
|
${getExe' pkgs.coreutils "rm"} -f ${escapeShellArg runtimeConfigPath} ${escapeShellArg runtimeConfigManagedMarkerPath}
|
|
fi
|
|
fi
|
|
|
|
if ${boolToString (!profilesEnabled)}; then
|
|
${getExe' pkgs.coreutils "rm"} -f ${escapeShellArg profilesApiKeyPath}
|
|
fi
|
|
'';
|
|
|
|
environment.systemPackages = [ cfg.package ];
|
|
|
|
boot.kernelModules = [ "nfnetlink_queue" ];
|
|
|
|
systemd = {
|
|
tmpfiles.settings."10-portmaster" = genAttrs tmpfileDirectories (_: {
|
|
d = tmpfileDir;
|
|
});
|
|
|
|
services.portmaster = {
|
|
description = "Portmaster by Safing";
|
|
documentation = [
|
|
"https://safing.io"
|
|
"https://docs.safing.io"
|
|
];
|
|
before = [
|
|
"nss-lookup.target"
|
|
"network.target"
|
|
"shutdown.target"
|
|
];
|
|
after = [
|
|
"systemd-networkd.service"
|
|
"systemd-tmpfiles-setup.service"
|
|
];
|
|
conflicts = [
|
|
"shutdown.target"
|
|
"firewalld.service"
|
|
];
|
|
wants = [ "nss-lookup.target" ];
|
|
wantedBy = [ "multi-user.target" ];
|
|
requires = [ "systemd-tmpfiles-setup.service" ];
|
|
|
|
preStart = lib.optionalString managesConfig ''
|
|
${mergeConfig}
|
|
'';
|
|
|
|
serviceConfig =
|
|
let
|
|
baseArgs = [
|
|
(getExe' cfg.package "portmaster-core")
|
|
"--bin-dir=${packageLibDir}"
|
|
"--data-dir=${stateDir}"
|
|
"--log-dir=${logsDir}"
|
|
];
|
|
devmodeArgs = optional (!managesConfig && cfg.settings.devmode) "--devmode";
|
|
in
|
|
{
|
|
Type = "simple";
|
|
ExecStart = utils.escapeSystemdExecArgs (baseArgs ++ devmodeArgs ++ cfg.extraArgs);
|
|
ExecStopPost = "-${getExe' cfg.package "portmaster-core"} recover-iptables";
|
|
Restart = "on-failure";
|
|
RestartSec = "10";
|
|
RestartPreventExitStatus = "24";
|
|
User = "root";
|
|
Group = "root";
|
|
LockPersonality = true;
|
|
MemoryDenyWriteExecute = true;
|
|
MemoryLow = "2G";
|
|
NoNewPrivileges = true;
|
|
PrivateTmp = true;
|
|
PIDFile = "${stateDir}/core-lock.pid";
|
|
WorkingDirectory = stateDir;
|
|
ProtectSystem = true;
|
|
ReadOnlyPaths = [ builtins.storeDir ];
|
|
ReadWritePaths = [ stateDir ];
|
|
ProtectHome = "read-only";
|
|
ProtectKernelTunables = true;
|
|
ProtectKernelLogs = true;
|
|
ProtectControlGroups = true;
|
|
PrivateDevices = true;
|
|
RestrictNamespaces = true;
|
|
AmbientCapabilities = portmasterCapabilities;
|
|
CapabilityBoundingSet = portmasterCapabilities;
|
|
RestrictAddressFamilies = [
|
|
"AF_UNIX"
|
|
"AF_NETLINK"
|
|
"AF_INET"
|
|
"AF_INET6"
|
|
];
|
|
Environment = [ "LOGLEVEL=info" ];
|
|
};
|
|
};
|
|
|
|
services.portmaster-managed-profiles = mkIf profilesEnabled {
|
|
description = "Sync declarative Portmaster managed profiles";
|
|
wantedBy = [ "portmaster.service" ];
|
|
after = [ "portmaster.service" ];
|
|
partOf = [ "portmaster.service" ];
|
|
|
|
serviceConfig = {
|
|
Type = "oneshot";
|
|
RemainAfterExit = true;
|
|
LoadCredential = [ "managed-profiles-api-key:${profilesApiKeyPath}" ];
|
|
AmbientCapabilities = [ ];
|
|
CapabilityBoundingSet = [ ];
|
|
LockPersonality = true;
|
|
MemoryDenyWriteExecute = true;
|
|
NoNewPrivileges = true;
|
|
PrivateDevices = true;
|
|
PrivateTmp = true;
|
|
ProtectControlGroups = true;
|
|
ProtectHome = true;
|
|
ProtectKernelLogs = true;
|
|
ProtectKernelTunables = true;
|
|
ProtectSystem = "strict";
|
|
RestrictAddressFamilies = [
|
|
"AF_UNIX"
|
|
"AF_INET"
|
|
"AF_INET6"
|
|
];
|
|
RestrictNamespaces = true;
|
|
RestrictSUIDSGID = true;
|
|
SystemCallArchitectures = "native";
|
|
UMask = "0077";
|
|
};
|
|
|
|
script = ''
|
|
set -euo pipefail
|
|
|
|
curl_config="$(${getExe' pkgs.coreutils "mktemp"} --tmpdir portmaster-managed-profiles-curl.XXXXXX)"
|
|
response_file="$(${getExe' pkgs.coreutils "mktemp"} --tmpdir portmaster-managed-profiles-response.XXXXXX)"
|
|
cleanup() {
|
|
${getExe' pkgs.coreutils "rm"} -f "$curl_config" "$response_file"
|
|
}
|
|
trap cleanup EXIT
|
|
${getExe' pkgs.coreutils "chmod"} 0600 "$curl_config" "$response_file"
|
|
|
|
managed_api_key="$(${getExe' pkgs.coreutils "tr"} -d '\r\n' < "$CREDENTIALS_DIRECTORY/managed-profiles-api-key")"
|
|
if [[ ! "$managed_api_key" =~ ^[0-9a-f]{64}$ ]]; then
|
|
printf >&2 '%s\n' "Portmaster managed profiles API key credential is invalid"
|
|
exit 1
|
|
fi
|
|
|
|
printf 'header = "Authorization: Bearer %s"\n' "$managed_api_key" > "$curl_config"
|
|
unset managed_api_key
|
|
|
|
ready=0
|
|
for _ in $(${getExe' pkgs.coreutils "seq"} 1 60); do
|
|
if ${getExe pkgs.curl} --silent --fail --noproxy '*' --max-time 2 ${escapeShellArg profilesPingUrl} > /dev/null; then
|
|
ready=1
|
|
break
|
|
fi
|
|
${getExe' pkgs.coreutils "sleep"} 1
|
|
done
|
|
|
|
if [ "$ready" -ne 1 ]; then
|
|
printf >&2 '%s\n' "Portmaster API at ${profilesPingUrl} did not become ready in time"
|
|
exit 1
|
|
fi
|
|
|
|
import_profile() {
|
|
local logical_name="$1"
|
|
local profile="$2"
|
|
|
|
if ! ${getExe pkgs.jq} -e '
|
|
.type == "profile"
|
|
and (.source == "local")
|
|
and (.name | type == "string" and length > 0)
|
|
and (.config | type == "object")
|
|
and (.fingerprints | type == "array")
|
|
and (.fingerprints | length > 0)
|
|
and (has("id") | not)
|
|
and all(
|
|
.fingerprints[];
|
|
(.type == "path" or .type == "cmdline" or .type == "env" or .type == "tag")
|
|
and (.operation == "equals" or .operation == "prefix" or .operation == "regex")
|
|
and (.value | type == "string" and length > 0)
|
|
and (
|
|
if (.type == "env" or .type == "tag") then
|
|
(.key | type == "string" and length > 0)
|
|
else
|
|
(has("key") | not)
|
|
end
|
|
)
|
|
)
|
|
' "$profile" > /dev/null
|
|
then
|
|
printf >&2 'Invalid generated Portmaster profile `%s` (%s)\n' "$logical_name" "$profile"
|
|
exit 1
|
|
fi
|
|
|
|
: > "$response_file"
|
|
if ${getExe pkgs.curl} \
|
|
--config "$curl_config" \
|
|
--silent \
|
|
--show-error \
|
|
--fail-with-body \
|
|
--noproxy '*' \
|
|
--max-time 30 \
|
|
--header "Content-Type: application/json" \
|
|
--data-binary @"$profile" \
|
|
--output "$response_file" \
|
|
${escapeShellArg profilesImportUrl}
|
|
then
|
|
:
|
|
else
|
|
curl_status=$?
|
|
printf >&2 'Failed to import Portmaster profile `%s` from %s (curl exit %s)\n' \
|
|
"$logical_name" "$profile" "$curl_status"
|
|
if [ -s "$response_file" ]; then
|
|
printf >&2 '%s\n' 'Portmaster response:'
|
|
${getExe' pkgs.coreutils "cat"} "$response_file" >&2
|
|
printf >&2 '\n'
|
|
fi
|
|
exit "$curl_status"
|
|
fi
|
|
|
|
if ! ${getExe pkgs.jq} -e '
|
|
(.restartRequired | type == "boolean")
|
|
and (.replacesExisting | type == "boolean")
|
|
' "$response_file" > /dev/null
|
|
then
|
|
printf >&2 'Portmaster returned an invalid response while importing profile `%s` from %s:\n' \
|
|
"$logical_name" "$profile"
|
|
${getExe' pkgs.coreutils "cat"} "$response_file" >&2
|
|
printf >&2 '\n'
|
|
exit 1
|
|
fi
|
|
}
|
|
|
|
${concatMapStringsSep "\n" (
|
|
profile: "import_profile ${escapeShellArg profile.logicalName} ${escapeShellArg profile.path}"
|
|
) profileExports}
|
|
'';
|
|
};
|
|
};
|
|
};
|
|
|
|
meta = {
|
|
doc = ./portmaster.md;
|
|
maintainers = with lib.maintainers; [
|
|
WitteShadovv
|
|
nyabinary
|
|
];
|
|
};
|
|
}
|