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