1{
2 config,
3 pkgs,
4 lib,
5 ...
6}:
7let
8 cfg = config.services.ncps;
9
10 logLevels = [
11 "trace"
12 "debug"
13 "info"
14 "warn"
15 "error"
16 "fatal"
17 "panic"
18 ];
19
20 globalFlags = lib.concatStringsSep " " (
21 [ "--log-level='${cfg.logLevel}'" ]
22 ++ (lib.optionals cfg.openTelemetry.enable (
23 [
24 "--otel-enabled"
25 ]
26 ++ (lib.optional (
27 cfg.openTelemetry.grpcURL != null
28 ) "--otel-grpc-url='${cfg.openTelemetry.grpcURL}'")
29 ))
30 ++ (lib.optionals cfg.prometheus.enable [
31 "--prometheus-enabled"
32 ])
33 );
34
35 serveFlags = lib.concatStringsSep " " (
36 [
37 "--cache-hostname='${cfg.cache.hostName}'"
38 "--cache-data-path='${cfg.cache.dataPath}'"
39 "--cache-database-url='${cfg.cache.databaseURL}'"
40 "--cache-temp-path='${cfg.cache.tempPath}'"
41 "--server-addr='${cfg.server.addr}'"
42 ]
43 ++ (lib.optional cfg.cache.allowDeleteVerb "--cache-allow-delete-verb")
44 ++ (lib.optional cfg.cache.allowPutVerb "--cache-allow-put-verb")
45 ++ (lib.optional (cfg.cache.maxSize != null) "--cache-max-size='${cfg.cache.maxSize}'")
46 ++ (lib.optionals (cfg.cache.lru.schedule != null) [
47 "--cache-lru-schedule='${cfg.cache.lru.schedule}'"
48 "--cache-lru-schedule-timezone='${cfg.cache.lru.scheduleTimeZone}'"
49 ])
50 ++ (lib.optional (cfg.cache.secretKeyPath != null) "--cache-secret-key-path='%d/secretKey'")
51 ++ (lib.forEach cfg.upstream.caches (url: "--upstream-cache='${url}'"))
52 ++ (lib.forEach cfg.upstream.publicKeys (pk: "--upstream-public-key='${pk}'"))
53 );
54
55 isSqlite = lib.strings.hasPrefix "sqlite:" cfg.cache.databaseURL;
56
57 dbPath = lib.removePrefix "sqlite:" cfg.cache.databaseURL;
58 dbDir = dirOf dbPath;
59in
60{
61 options = {
62 services.ncps = {
63 enable = lib.mkEnableOption "ncps: Nix binary cache proxy service implemented in Go";
64
65 package = lib.mkPackageOption pkgs "ncps" { };
66
67 dbmatePackage = lib.mkPackageOption pkgs "dbmate" { };
68
69 openTelemetry = {
70 enable = lib.mkEnableOption "Enable OpenTelemetry logs, metrics, and tracing";
71
72 grpcURL = lib.mkOption {
73 type = lib.types.nullOr lib.types.str;
74 default = null;
75 description = ''
76 Configure OpenTelemetry gRPC URL. Missing or "https" scheme enables
77 secure gRPC, "insecure" otherwise. Omit to emit telemetry to
78 stdout.
79 '';
80 };
81 };
82
83 prometheus.enable = lib.mkEnableOption "Enable Prometheus metrics endpoint at /metrics";
84
85 logLevel = lib.mkOption {
86 type = lib.types.enum logLevels;
87 default = "info";
88 description = ''
89 Set the level for logging. Refer to
90 <https://pkg.go.dev/github.com/rs/zerolog#readme-leveled-logging> for
91 more information.
92 '';
93 };
94
95 cache = {
96 allowDeleteVerb = lib.mkEnableOption ''
97 Whether to allow the DELETE verb to delete narinfo and nar files from
98 the cache.
99 '';
100
101 allowPutVerb = lib.mkEnableOption ''
102 Whether to allow the PUT verb to push narinfo and nar files directly
103 to the cache.
104 '';
105
106 hostName = lib.mkOption {
107 type = lib.types.str;
108 description = ''
109 The hostname of the cache server. **This is used to generate the
110 private key used for signing store paths (.narinfo)**
111 '';
112 };
113
114 dataPath = lib.mkOption {
115 type = lib.types.str;
116 default = "/var/lib/ncps";
117 description = ''
118 The local directory for storing configuration and cached store paths
119 '';
120 };
121
122 databaseURL = lib.mkOption {
123 type = lib.types.str;
124 default = "sqlite:${cfg.cache.dataPath}/db/db.sqlite";
125 defaultText = "sqlite:/var/lib/ncps/db/db.sqlite";
126 description = ''
127 The URL of the database (currently only SQLite is supported)
128 '';
129 };
130
131 lru = {
132 schedule = lib.mkOption {
133 type = lib.types.nullOr lib.types.str;
134 default = null;
135 example = "0 2 * * *";
136 description = ''
137 The cron spec for cleaning the store to keep it under
138 config.ncps.cache.maxSize. Refer to
139 https://pkg.go.dev/github.com/robfig/cron/v3#hdr-Usage for
140 documentation.
141 '';
142 };
143
144 scheduleTimeZone = lib.mkOption {
145 type = lib.types.str;
146 default = "Local";
147 example = "America/Los_Angeles";
148 description = ''
149 The name of the timezone to use for the cron schedule. See
150 <https://en.wikipedia.org/wiki/List_of_tz_database_time_zones>
151 for a comprehensive list of possible values for this setting.
152 '';
153 };
154 };
155
156 maxSize = lib.mkOption {
157 type = lib.types.nullOr lib.types.str;
158 default = null;
159 example = "100G";
160 description = ''
161 The maximum size of the store. It can be given with units such as
162 5K, 10G etc. Supported units: B, K, M, G, T.
163 '';
164 };
165
166 secretKeyPath = lib.mkOption {
167 type = lib.types.nullOr lib.types.str;
168 default = null;
169 description = ''
170 The path to load the secretKey for signing narinfos. Leave this
171 empty to automatically generate a private/public key.
172 '';
173 };
174
175 tempPath = lib.mkOption {
176 type = lib.types.str;
177 default = "/tmp";
178 description = ''
179 The path to the temporary directory that is used by the cache to download NAR files
180 '';
181 };
182 };
183
184 server = {
185 addr = lib.mkOption {
186 type = lib.types.str;
187 default = ":8501";
188 description = ''
189 The address and port the server listens on.
190 '';
191 };
192 };
193
194 upstream = {
195 caches = lib.mkOption {
196 type = lib.types.listOf lib.types.str;
197 example = [ "https://cache.nixos.org" ];
198 description = ''
199 A list of URLs of upstream binary caches.
200 '';
201 };
202
203 publicKeys = lib.mkOption {
204 type = lib.types.listOf lib.types.str;
205 default = [ ];
206 example = [ "cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=" ];
207 description = ''
208 A list of public keys of upstream caches in the format
209 `host[-[0-9]*]:public-key`. This flag is used to verify the
210 signatures of store paths downloaded from upstream caches.
211 '';
212 };
213 };
214 };
215 };
216
217 config = lib.mkIf cfg.enable {
218 assertions = [
219 {
220 assertion = cfg.cache.lru.schedule == null || cfg.cache.maxSize != null;
221 message = "You must specify config.ncps.cache.lru.schedule when config.ncps.cache.maxSize is set";
222 }
223 ];
224
225 users.users.ncps = {
226 isSystemUser = true;
227 group = "ncps";
228 };
229 users.groups.ncps = { };
230
231 systemd.services.ncps-create-directories = {
232 description = "Created required directories by ncps";
233 serviceConfig = {
234 Type = "oneshot";
235 UMask = "0066";
236 };
237 script =
238 (lib.optionalString (cfg.cache.dataPath != "/var/lib/ncps") ''
239 if ! test -d ${cfg.cache.dataPath}; then
240 mkdir -p ${cfg.cache.dataPath}
241 chown ncps:ncps ${cfg.cache.dataPath}
242 fi
243 '')
244 + (lib.optionalString isSqlite ''
245 if ! test -d ${dbDir}; then
246 mkdir -p ${dbDir}
247 chown ncps:ncps ${dbDir}
248 fi
249 '')
250 + (lib.optionalString (cfg.cache.tempPath != "/tmp") ''
251 if ! test -d ${cfg.cache.tempPath}; then
252 mkdir -p ${cfg.cache.tempPath}
253 chown ncps:ncps ${cfg.cache.tempPath}
254 fi
255 '');
256 wantedBy = [ "ncps.service" ];
257 before = [ "ncps.service" ];
258 };
259
260 systemd.services.ncps = {
261 description = "ncps binary cache proxy service";
262
263 after = [ "network-online.target" ];
264 wants = [ "network-online.target" ];
265 wantedBy = [ "multi-user.target" ];
266
267 preStart = ''
268 ${lib.getExe cfg.dbmatePackage} --migrations-dir=${cfg.package}/share/ncps/db/migrations --url=${cfg.cache.databaseURL} up
269 '';
270
271 serviceConfig = lib.mkMerge [
272 {
273 ExecStart = "${lib.getExe cfg.package} ${globalFlags} serve ${serveFlags}";
274 User = "ncps";
275 Group = "ncps";
276 Restart = "on-failure";
277 RuntimeDirectory = "ncps";
278 }
279
280 # credentials
281 (lib.mkIf (cfg.cache.secretKeyPath != null) {
282 LoadCredential = "secretKey:${cfg.cache.secretKeyPath}";
283 })
284
285 # ensure permissions on required directories
286 (lib.mkIf (cfg.cache.dataPath != "/var/lib/ncps") {
287 ReadWritePaths = [ cfg.cache.dataPath ];
288 })
289 (lib.mkIf (cfg.cache.dataPath == "/var/lib/ncps") {
290 StateDirectory = "ncps";
291 StateDirectoryMode = "0700";
292 })
293 (lib.mkIf (isSqlite && !lib.strings.hasPrefix "/var/lib/ncps" dbDir) {
294 ReadWritePaths = [ dbDir ];
295 })
296 (lib.mkIf (cfg.cache.tempPath != "/tmp") {
297 ReadWritePaths = [ cfg.cache.tempPath ];
298 })
299
300 # Hardening
301 {
302 SystemCallFilter = [
303 "@system-service"
304 "~@privileged"
305 "~@resources"
306 ];
307 CapabilityBoundingSet = "";
308 PrivateUsers = true;
309 DevicePolicy = "closed";
310 DeviceAllow = [ "" ];
311 ProtectKernelModules = true;
312 ProtectKernelTunables = true;
313 ProtectControlGroups = true;
314 ProtectKernelLogs = true;
315 ProtectHostname = true;
316 ProtectClock = true;
317 ProtectProc = "invisible";
318 ProtectSystem = "strict";
319 ProtectHome = true;
320 RestrictSUIDSGID = true;
321 RestrictRealtime = true;
322 MemoryDenyWriteExecute = true;
323 ProcSubset = "pid";
324 RestrictNamespaces = true;
325 SystemCallArchitectures = "native";
326 PrivateNetwork = false;
327 PrivateTmp = true;
328 PrivateDevices = true;
329 PrivateMounts = true;
330 NoNewPrivileges = true;
331 LockPersonality = true;
332 RestrictAddressFamilies = "AF_UNIX AF_INET AF_INET6";
333 LimitNOFILE = 65536;
334 UMask = "0066";
335 }
336 ];
337
338 unitConfig.RequiresMountsFor = lib.concatStringsSep " " (
339 [ "${cfg.cache.dataPath}" ] ++ lib.optional (isSqlite) dbDir
340 );
341 };
342 };
343
344 meta.maintainers = with lib.maintainers; [ kalbasit ];
345}