Files

820 lines
25 KiB
Nix

{
config,
pkgs,
lib,
...
}:
let
cfg = config.services.ncps;
logLevels = [
"trace"
"debug"
"info"
"warn"
"error"
"fatal"
"panic"
];
ncpsWrapper = pkgs.writeShellScript "ncps-wrapper" ''
${lib.optionalString (cfg.cache.secretKeyPath != null) ''
export CACHE_SECRET_KEY_PATH="$CREDENTIALS_DIRECTORY/secretKey"
''}
${lib.optionalString (cfg.cache.storage.s3 != null) ''
export CACHE_STORAGE_S3_ACCESS_KEY_ID="$(cat "$CREDENTIALS_DIRECTORY/s3AccessKeyId")"
export CACHE_STORAGE_S3_SECRET_ACCESS_KEY="$(cat "$CREDENTIALS_DIRECTORY/s3SecretAccessKey")"
''}
${lib.optionalString (cfg.cache.redis != null) (
if cfg.cache.redis.passwordFile != null then
''export CACHE_REDIS_PASSWORD="$(cat "$CREDENTIALS_DIRECTORY/redisPassword")"''
else if cfg.cache.redis.password != null then
''export CACHE_REDIS_PASSWORD="${cfg.cache.redis.password}"''
else
""
)}
${lib.optionalString (cfg.cache.databaseURLFile != null) ''
export CACHE_DATABASE_URL="$(cat "$CREDENTIALS_DIRECTORY/databaseURL")"
''}
exec ${lib.getExe cfg.package} --config "${configFile}" "$@"
'';
settings = {
log.level = cfg.logLevel;
opentelemetry = lib.optionalAttrs cfg.openTelemetry.enable {
enabled = true;
grpc-url = cfg.openTelemetry.grpcURL;
};
prometheus = lib.optionalAttrs cfg.prometheus.enable {
enabled = true;
};
analytics.reporting = {
enabled = cfg.analytics.reporting.enable;
samples = cfg.analytics.reporting.samples;
};
server.addr = cfg.server.addr;
cache = {
allow-delete-verb = cfg.cache.allowDeleteVerb;
allow-put-verb = cfg.cache.allowPutVerb;
hostname = cfg.cache.hostName;
database-url = cfg.cache.databaseURL;
database.pool = {
max-open-conns = cfg.cache.database.pool.maxOpenConns;
max-idle-conns = cfg.cache.database.pool.maxIdleConns;
};
cdc = {
inherit (cfg.cache.cdc)
enabled
min
avg
max
;
};
max-size = cfg.cache.maxSize;
lru = {
schedule = cfg.cache.lru.schedule;
timezone = cfg.cache.lru.scheduleTimeZone;
};
sign-narinfo = cfg.cache.signNarinfo;
storage =
if cfg.cache.storage.s3 != null then
{
s3 = {
bucket = cfg.cache.storage.s3.bucket;
endpoint = cfg.cache.storage.s3.endpoint;
region = cfg.cache.storage.s3.region;
force-path-style = cfg.cache.storage.s3.forcePathStyle;
};
}
else
{
local = cfg.cache.storage.local;
};
temp-path = cfg.cache.tempPath;
netrc-file = cfg.netrcFile;
upstream = {
urls = cfg.cache.upstream.urls;
public-keys = cfg.cache.upstream.publicKeys;
dialer-timeout = cfg.cache.upstream.dialerTimeout;
response-header-timeout = cfg.cache.upstream.responseHeaderTimeout;
};
lock = {
backend = cfg.cache.lock.backend;
redis.key-prefix = cfg.cache.lock.redisKeyPrefix;
download-lock-ttl = cfg.cache.lock.downloadTTL;
lru-lock-ttl = cfg.cache.lock.lruTTL;
retry = {
max-attempts = cfg.cache.lock.retry.maxAttempts;
initial-delay = cfg.cache.lock.retry.initialDelay;
max-delay = cfg.cache.lock.retry.maxDelay;
jitter = cfg.cache.lock.retry.jitter;
};
allow-degraded-mode = cfg.cache.lock.allowDegradedMode;
};
redis = lib.optionalAttrs (cfg.cache.redis != null) {
addrs = cfg.cache.redis.addresses;
db = cfg.cache.redis.database;
username = cfg.cache.redis.username;
use-tls = cfg.cache.redis.useTLS;
pool-size = cfg.cache.redis.poolSize;
};
};
};
configFile = pkgs.writeText "ncps-config.json" (
builtins.toJSON (
lib.filterAttrsRecursive (_: v: v != null && v != { } && v != "" && v != [ ]) settings
)
);
isSqlite = cfg.cache.databaseURL != null && lib.strings.hasPrefix "sqlite:" cfg.cache.databaseURL;
dbPath = if isSqlite then lib.removePrefix "sqlite:" cfg.cache.databaseURL else null;
dbDir = if isSqlite then dirOf dbPath else null;
in
{
imports = [
(lib.mkRenamedOptionModule
[ "services" "ncps" "cache" "dataPath" ]
[ "services" "ncps" "cache" "storage" "local" ]
)
(lib.mkRenamedOptionModule
[ "services" "ncps" "upstream" "caches" ]
[ "services" "ncps" "cache" "upstream" "urls" ]
)
(lib.mkRenamedOptionModule
[ "services" "ncps" "upstream" "publicKeys" ]
[ "services" "ncps" "cache" "upstream" "publicKeys" ]
)
(lib.mkRemovedOptionModule [
"services"
"ncps"
"dbmatePackage"
] "dbmate is now wrapped within ncps package, you need to override ncps to change dbmate package")
(lib.mkRemovedOptionModule [
"services"
"ncps"
"cache"
"lock"
"postgresKeyPrefix"
] "PostgreSQL lock backend was removed upstream")
];
options = {
services.ncps = {
enable = lib.mkEnableOption "ncps: Nix binary cache proxy service implemented in Go";
analytics.reporting = {
enable = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Enable reporting anonymous usage statistics (DB type, Lock type, Total Size) to the project maintainers.
'';
};
samples = lib.mkEnableOption "Enable printing the analytics samples to stdout. This is useful for debugging and verification purposes only.";
};
package = lib.mkPackageOption pkgs "ncps" { };
openTelemetry = {
enable = lib.mkEnableOption "Enable OpenTelemetry logs, metrics, and tracing";
grpcURL = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = ''
Configure OpenTelemetry gRPC URL. Missing or "https" scheme enables
secure gRPC, "insecure" otherwise. Omit to emit telemetry to
stdout.
'';
};
};
prometheus.enable = lib.mkEnableOption "Enable Prometheus metrics endpoint at /metrics";
logLevel = lib.mkOption {
type = lib.types.enum logLevels;
default = "info";
description = ''
Set the level for logging. Refer to
<https://pkg.go.dev/github.com/rs/zerolog#readme-leveled-logging> for
more information.
'';
};
cache = {
allowDeleteVerb = lib.mkEnableOption ''
Whether to allow the DELETE verb to delete narinfo and nar files from
the cache.
'';
allowPutVerb = lib.mkEnableOption ''
Whether to allow the PUT verb to push narinfo and nar files directly
to the cache.
'';
cdc = {
enabled = lib.mkEnableOption ''
Whether to enable Content-Defined Chunking (CDC) for deduplication (experimental).
'';
min = lib.mkOption {
type = lib.types.ints.u32;
default = 16384;
description = ''
The minimum chunk size for CDC in bytes.
'';
};
avg = lib.mkOption {
type = lib.types.ints.u32;
default = 65536;
description = ''
The average chunk size for CDC in bytes.
'';
};
max = lib.mkOption {
type = lib.types.ints.u32;
default = 262144;
description = ''
The maximum chunk size for CDC in bytes.
'';
};
};
hostName = lib.mkOption {
type = lib.types.str;
description = ''
The hostname of the cache server. **This is used to generate the
private key used for signing store paths (.narinfo)**
'';
};
databaseURL = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = "sqlite:${cfg.cache.storage.local}/db/db.sqlite";
defaultText = "sqlite:/var/lib/ncps/db/db.sqlite";
description = ''
The URL of the database (currently only SQLite is supported)
'';
};
databaseURLFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = ''
File containing the URL of the database.
'';
};
database = {
pool = {
maxOpenConns = lib.mkOption {
type = lib.types.int;
default = 0;
description = ''
Maximum number of open connections to the database (0 = use
database-specific defaults).
'';
};
maxIdleConns = lib.mkOption {
type = lib.types.int;
default = 0;
description = ''
Maximum number of idle connections in the pool (0 = use
database-specific defaults).
'';
};
};
};
lru = {
schedule = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "0 2 * * *";
description = ''
The cron spec for cleaning the store to keep it under
config.ncps.cache.maxSize. Refer to
https://pkg.go.dev/github.com/robfig/cron/v3#hdr-Usage for
documentation.
'';
};
scheduleTimeZone = lib.mkOption {
type = lib.types.str;
default = "Local";
example = "America/Los_Angeles";
description = ''
The name of the timezone to use for the cron schedule. See
<https://en.wikipedia.org/wiki/List_of_tz_database_time_zones>
for a comprehensive list of possible values for this setting.
'';
};
};
lock = {
backend = lib.mkOption {
type = lib.types.enum [
"local"
"redis"
];
default = "local";
description = ''
Lock backend to use: 'local' (single instance), 'redis'
(distributed).
'';
};
redisKeyPrefix = lib.mkOption {
type = lib.types.str;
default = "ncps:lock:";
description = ''
Prefix for all Redis lock keys (only used when Redis is
configured).
'';
};
downloadTTL = lib.mkOption {
type = lib.types.str;
default = "5m0s";
description = ''
TTL for download locks (per-hash locks).
'';
};
lruTTL = lib.mkOption {
type = lib.types.str;
default = "30m0s";
description = ''
TTL for LRU lock (global exclusive lock).
'';
};
retry = {
maxAttempts = lib.mkOption {
type = lib.types.int;
default = 3;
description = ''
Maximum number of retry attempts for distributed locks.
'';
};
initialDelay = lib.mkOption {
type = lib.types.str;
default = "100ms";
description = ''
Initial retry delay for distributed locks.
'';
};
maxDelay = lib.mkOption {
type = lib.types.str;
default = "2s";
description = ''
Maximum retry delay for distributed locks (exponential backoff
caps at this).
'';
};
jitter = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Enable jitter in retry delays to prevent thundering herd.
'';
};
};
allowDegradedMode = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Allow falling back to local locks if Redis is unavailable (WARNING:
breaks HA guarantees).
'';
};
};
maxSize = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "100G";
description = ''
The maximum size of the store. It can be given with units such as
5K, 10G etc. Supported units: B, K, M, G, T.
'';
};
redis = lib.mkOption {
type = lib.types.nullOr (
lib.types.submodule {
options = {
addresses = lib.mkOption {
type = lib.types.listOf lib.types.str;
example = ''
["redis0:6379" "redis1:6379"]
'';
description = ''
A list of host:port for the Redis servers that are part of a cluster.
To use a single Redis instance, just set this to its single address.
'';
};
database = lib.mkOption {
type = lib.types.int;
default = 0;
description = ''
Redis database number (0-15)
'';
};
username = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = ''
Redis username for authentication (for Redis ACL).
'';
};
password = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = ''
Redis password for authentication (for Redis ACL).
'';
};
passwordFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = ''
File containing the redis password for authentication (for Redis ACL).
'';
};
poolSize = lib.mkOption {
type = lib.types.int;
default = 10;
description = ''
Redis connection pool size.
'';
};
useTLS = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Use TLS for Redis connection.
'';
};
};
}
);
default = null;
description = ''
Configure Redis.
'';
};
secretKeyPath = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = ''
The path to load the secretKey for signing narinfos. Leave this
empty to automatically generate a private/public key.
'';
};
storage = {
local = lib.mkOption {
type = lib.types.path;
default = "/var/lib/ncps";
description = ''
The local directory for storing configuration and cached store
paths. This is ignored if services.ncps.cache.storage.s3 is not
null.
'';
};
s3 = lib.mkOption {
type = lib.types.nullOr (
lib.types.submodule {
options = {
bucket = lib.mkOption {
type = lib.types.str;
description = ''
The name of the S3 bucket.
'';
};
endpoint = lib.mkOption {
type = lib.types.str;
description = ''
S3-compatible endpoint URL with scheme.
'';
example = "https://s3.amazonaws.com";
};
forcePathStyle = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Force path-style S3 addressing (bucket/key vs key.bucket).
'';
};
region = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = ''
The S3 region.
'';
};
accessKeyIdPath = lib.mkOption {
type = lib.types.path;
description = ''
The path to a file containing only the access-key-id.
'';
};
secretAccessKeyPath = lib.mkOption {
type = lib.types.path;
description = ''
The path to a file containing only the secret-access-key.
'';
};
};
}
);
default = null;
description = ''
Use S3 for storage instead of local storage.
'';
};
};
tempPath = lib.mkOption {
type = lib.types.path;
default = "/tmp";
description = ''
The path to the temporary directory that is used by the cache to download NAR files
'';
};
signNarinfo = lib.mkOption {
type = lib.types.bool;
default = true;
example = false;
description = ''
Whether to sign narInfo files or passthru as-is from upstream
'';
};
upstream = {
dialerTimeout = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = ''
Timeout for establishing TCP connections to upstream caches (e.g., 3s, 5s, 10s).
'';
};
responseHeaderTimeout = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "5s";
description = ''
Timeout for waiting for upstream server's response headers.
'';
};
publicKeys = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
example = [ "cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=" ];
description = ''
A list of public keys of upstream caches in the format
`host[-[0-9]*]:public-key`. This flag is used to verify the
signatures of store paths downloaded from upstream caches.
'';
};
urls = lib.mkOption {
type = lib.types.listOf lib.types.str;
example = [ "https://cache.nixos.org" ];
description = ''
A list of URLs of upstream binary caches.
'';
};
};
};
server = {
addr = lib.mkOption {
type = lib.types.str;
default = ":8501";
description = ''
The address and port the server listens on.
'';
};
};
netrcFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
example = "/etc/nix/netrc";
description = ''
The path to netrc file for upstream authentication.
When unspecified ncps will look for ``$HOME/.netrc`.
'';
};
};
};
config = lib.mkIf cfg.enable {
assertions = [
{
assertion = lib.xor (cfg.cache.databaseURL != null) (cfg.cache.databaseURLFile != null);
message = "You must specify exactly one of config.ncps.cache.databaseURL or config.ncps.cache.databaseURLFile";
}
{
assertion = cfg.cache.lru.schedule == null || cfg.cache.maxSize != null;
message = "You must specify config.ncps.cache.lru.schedule when config.ncps.cache.maxSize is set";
}
{
assertion =
cfg.cache.redis == null || cfg.cache.redis.password == null || cfg.cache.redis.passwordFile == null;
message = "You cannot specify both config.ncps.cache.redis.password and config.ncps.cache.redis.passwordFile";
}
{
assertion = cfg.cache.lock.backend == "redis" -> cfg.cache.redis != null;
message = "You must specify config.ncps.cache.redis when config.ncps.cache.lock.backend is set to 'redis'";
}
{
assertion = cfg.cache.redis != null -> cfg.cache.lock.backend == "redis";
message = "You must set config.ncps.cache.lock.backend to 'redis' when config.ncps.cache.redis is set";
}
];
users.users.ncps = {
isSystemUser = true;
group = "ncps";
};
users.groups.ncps = { };
systemd.tmpfiles.settings.ncps =
let
perms = {
group = "ncps";
mode = "0700";
user = "ncps";
};
in
lib.mkMerge [
(lib.mkIf (cfg.cache.storage.s3 == null && cfg.cache.storage.local != "/var/lib/ncps") {
"${cfg.cache.storage.local}".d = perms;
})
(lib.mkIf isSqlite { "${dbDir}".d = perms; })
(lib.mkIf (cfg.cache.tempPath != "/tmp") { "${cfg.cache.tempPath}".d = perms; })
];
systemd.services.ncps = {
description = "ncps binary cache proxy service";
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
wantedBy = [ "multi-user.target" ];
preStart = ''
${lib.optionalString (cfg.cache.databaseURLFile != null) ''
export DATABASE_URL="$(cat "$CREDENTIALS_DIRECTORY/databaseURL")"
''}
${lib.optionalString (cfg.cache.databaseURL != null) ''
export DATABASE_URL="${cfg.cache.databaseURL}"
''}
echo ${cfg.package}/bin/dbmate-ncps up
${cfg.package}/bin/dbmate-ncps up
'';
serviceConfig = lib.mkMerge [
{
ExecStart = "${ncpsWrapper} serve";
User = "ncps";
Group = "ncps";
Restart = "on-failure";
RuntimeDirectory = "ncps";
}
# credentials for cache.secretKeyPath
(lib.mkIf (cfg.cache.secretKeyPath != null) {
LoadCredential = lib.singleton "secretKey:${cfg.cache.secretKeyPath}";
})
# credentials for cache.storage.s3 accessKeyIdPath and secretAccessKeyPath
(lib.mkIf (cfg.cache.storage.s3 != null) {
LoadCredential = [
"s3AccessKeyId:${cfg.cache.storage.s3.accessKeyIdPath}"
"s3SecretAccessKey:${cfg.cache.storage.s3.secretAccessKeyPath}"
];
})
# credentials for Redis
(lib.mkIf (cfg.cache.redis != null && cfg.cache.redis.passwordFile != null) {
LoadCredential = lib.singleton "redisPassword:${cfg.cache.redis.passwordFile}";
})
(lib.mkIf (cfg.cache.databaseURLFile != null) {
LoadCredential = lib.singleton "databaseURL:${cfg.cache.databaseURLFile}";
})
# ensure permissions on required directories
(lib.mkIf (cfg.cache.storage.s3 == null && cfg.cache.storage.local != "/var/lib/ncps") {
ReadWritePaths = [ cfg.cache.storage.local ];
})
(lib.mkIf (cfg.cache.storage.s3 == null && cfg.cache.storage.local == "/var/lib/ncps") {
StateDirectory = "ncps";
StateDirectoryMode = "0700";
})
(lib.mkIf (cfg.cache.storage.s3 != null && isSqlite && lib.strings.hasPrefix "/var/lib/ncps" dbDir)
{
StateDirectory = "ncps";
StateDirectoryMode = "0700";
}
)
(lib.mkIf (isSqlite && !lib.strings.hasPrefix "/var/lib/ncps" dbDir) {
ReadWritePaths = [ dbDir ];
})
(lib.mkIf (cfg.cache.tempPath != "/tmp") {
ReadWritePaths = [ cfg.cache.tempPath ];
})
# Hardening
{
SystemCallFilter = [
"@system-service"
"~@privileged"
"~@resources"
];
CapabilityBoundingSet = "";
PrivateUsers = true;
DevicePolicy = "closed";
DeviceAllow = [ "" ];
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectControlGroups = true;
ProtectKernelLogs = true;
ProtectHostname = true;
ProtectClock = true;
ProtectProc = "invisible";
ProtectSystem = "strict";
ProtectHome = true;
RestrictSUIDSGID = true;
RestrictRealtime = true;
MemoryDenyWriteExecute = true;
ProcSubset = "pid";
RestrictNamespaces = true;
SystemCallArchitectures = "native";
PrivateNetwork = false;
PrivateTmp = true;
PrivateDevices = true;
PrivateMounts = true;
NoNewPrivileges = true;
LockPersonality = true;
RestrictAddressFamilies = "AF_UNIX AF_INET AF_INET6";
LimitNOFILE = 65536;
UMask = "0066";
}
];
unitConfig.RequiresMountsFor = lib.concatStringsSep " " (
(lib.optional (cfg.cache.storage.s3 == null) "${cfg.cache.storage.local}")
++ (lib.optional isSqlite dbDir)
);
};
};
meta.maintainers = with lib.maintainers; [
kalbasit
aciceri
];
}