1{ options, config, lib, pkgs, ... }:
2
3with lib;
4
5let
6 cfg = config.services.grafana;
7 opt = options.services.grafana;
8 declarativePlugins = pkgs.linkFarm "grafana-plugins" (builtins.map (pkg: { name = pkg.pname; path = pkg; }) cfg.declarativePlugins);
9 useMysql = cfg.database.type == "mysql";
10 usePostgresql = cfg.database.type == "postgres";
11
12 envOptions = {
13 PATHS_DATA = cfg.dataDir;
14 PATHS_PLUGINS = if builtins.isNull cfg.declarativePlugins then "${cfg.dataDir}/plugins" else declarativePlugins;
15 PATHS_LOGS = "${cfg.dataDir}/log";
16
17 SERVER_PROTOCOL = cfg.protocol;
18 SERVER_HTTP_ADDR = cfg.addr;
19 SERVER_HTTP_PORT = cfg.port;
20 SERVER_SOCKET = cfg.socket;
21 SERVER_DOMAIN = cfg.domain;
22 SERVER_ROOT_URL = cfg.rootUrl;
23 SERVER_STATIC_ROOT_PATH = cfg.staticRootPath;
24 SERVER_CERT_FILE = cfg.certFile;
25 SERVER_CERT_KEY = cfg.certKey;
26
27 DATABASE_TYPE = cfg.database.type;
28 DATABASE_HOST = cfg.database.host;
29 DATABASE_NAME = cfg.database.name;
30 DATABASE_USER = cfg.database.user;
31 DATABASE_PASSWORD = cfg.database.password;
32 DATABASE_PATH = cfg.database.path;
33 DATABASE_CONN_MAX_LIFETIME = cfg.database.connMaxLifetime;
34
35 SECURITY_ADMIN_USER = cfg.security.adminUser;
36 SECURITY_ADMIN_PASSWORD = cfg.security.adminPassword;
37 SECURITY_SECRET_KEY = cfg.security.secretKey;
38
39 USERS_ALLOW_SIGN_UP = boolToString cfg.users.allowSignUp;
40 USERS_ALLOW_ORG_CREATE = boolToString cfg.users.allowOrgCreate;
41 USERS_AUTO_ASSIGN_ORG = boolToString cfg.users.autoAssignOrg;
42 USERS_AUTO_ASSIGN_ORG_ROLE = cfg.users.autoAssignOrgRole;
43
44 AUTH_ANONYMOUS_ENABLED = boolToString cfg.auth.anonymous.enable;
45 AUTH_ANONYMOUS_ORG_NAME = cfg.auth.anonymous.org_name;
46 AUTH_ANONYMOUS_ORG_ROLE = cfg.auth.anonymous.org_role;
47 AUTH_GOOGLE_ENABLED = boolToString cfg.auth.google.enable;
48 AUTH_GOOGLE_ALLOW_SIGN_UP = boolToString cfg.auth.google.allowSignUp;
49 AUTH_GOOGLE_CLIENT_ID = cfg.auth.google.clientId;
50
51 ANALYTICS_REPORTING_ENABLED = boolToString cfg.analytics.reporting.enable;
52
53 SMTP_ENABLED = boolToString cfg.smtp.enable;
54 SMTP_HOST = cfg.smtp.host;
55 SMTP_USER = cfg.smtp.user;
56 SMTP_PASSWORD = cfg.smtp.password;
57 SMTP_FROM_ADDRESS = cfg.smtp.fromAddress;
58 } // cfg.extraOptions;
59
60 datasourceConfiguration = {
61 apiVersion = 1;
62 datasources = cfg.provision.datasources;
63 };
64
65 datasourceFile = pkgs.writeText "datasource.yaml" (builtins.toJSON datasourceConfiguration);
66
67 dashboardConfiguration = {
68 apiVersion = 1;
69 providers = cfg.provision.dashboards;
70 };
71
72 dashboardFile = pkgs.writeText "dashboard.yaml" (builtins.toJSON dashboardConfiguration);
73
74 notifierConfiguration = {
75 apiVersion = 1;
76 notifiers = cfg.provision.notifiers;
77 };
78
79 notifierFile = pkgs.writeText "notifier.yaml" (builtins.toJSON notifierConfiguration);
80
81 provisionConfDir = pkgs.runCommand "grafana-provisioning" { } ''
82 mkdir -p $out/{datasources,dashboards,notifiers}
83 ln -sf ${datasourceFile} $out/datasources/datasource.yaml
84 ln -sf ${dashboardFile} $out/dashboards/dashboard.yaml
85 ln -sf ${notifierFile} $out/notifiers/notifier.yaml
86 '';
87
88 # Get a submodule without any embedded metadata:
89 _filter = x: filterAttrs (k: v: k != "_module") x;
90
91 # http://docs.grafana.org/administration/provisioning/#datasources
92 grafanaTypes.datasourceConfig = types.submodule {
93 options = {
94 name = mkOption {
95 type = types.str;
96 description = "Name of the datasource. Required.";
97 };
98 type = mkOption {
99 type = types.str;
100 description = "Datasource type. Required.";
101 };
102 access = mkOption {
103 type = types.enum ["proxy" "direct"];
104 default = "proxy";
105 description = "Access mode. proxy or direct (Server or Browser in the UI). Required.";
106 };
107 orgId = mkOption {
108 type = types.int;
109 default = 1;
110 description = "Org id. will default to orgId 1 if not specified.";
111 };
112 url = mkOption {
113 type = types.str;
114 description = "Url of the datasource.";
115 };
116 password = mkOption {
117 type = types.nullOr types.str;
118 default = null;
119 description = "Database password, if used.";
120 };
121 user = mkOption {
122 type = types.nullOr types.str;
123 default = null;
124 description = "Database user, if used.";
125 };
126 database = mkOption {
127 type = types.nullOr types.str;
128 default = null;
129 description = "Database name, if used.";
130 };
131 basicAuth = mkOption {
132 type = types.nullOr types.bool;
133 default = null;
134 description = "Enable/disable basic auth.";
135 };
136 basicAuthUser = mkOption {
137 type = types.nullOr types.str;
138 default = null;
139 description = "Basic auth username.";
140 };
141 basicAuthPassword = mkOption {
142 type = types.nullOr types.str;
143 default = null;
144 description = "Basic auth password.";
145 };
146 withCredentials = mkOption {
147 type = types.bool;
148 default = false;
149 description = "Enable/disable with credentials headers.";
150 };
151 isDefault = mkOption {
152 type = types.bool;
153 default = false;
154 description = "Mark as default datasource. Max one per org.";
155 };
156 jsonData = mkOption {
157 type = types.nullOr types.attrs;
158 default = null;
159 description = "Datasource specific configuration.";
160 };
161 secureJsonData = mkOption {
162 type = types.nullOr types.attrs;
163 default = null;
164 description = "Datasource specific secure configuration.";
165 };
166 version = mkOption {
167 type = types.int;
168 default = 1;
169 description = "Version.";
170 };
171 editable = mkOption {
172 type = types.bool;
173 default = false;
174 description = "Allow users to edit datasources from the UI.";
175 };
176 };
177 };
178
179 # http://docs.grafana.org/administration/provisioning/#dashboards
180 grafanaTypes.dashboardConfig = types.submodule {
181 options = {
182 name = mkOption {
183 type = types.str;
184 default = "default";
185 description = "Provider name.";
186 };
187 orgId = mkOption {
188 type = types.int;
189 default = 1;
190 description = "Organization ID.";
191 };
192 folder = mkOption {
193 type = types.str;
194 default = "";
195 description = "Add dashboards to the specified folder.";
196 };
197 type = mkOption {
198 type = types.str;
199 default = "file";
200 description = "Dashboard provider type.";
201 };
202 disableDeletion = mkOption {
203 type = types.bool;
204 default = false;
205 description = "Disable deletion when JSON file is removed.";
206 };
207 updateIntervalSeconds = mkOption {
208 type = types.int;
209 default = 10;
210 description = "How often Grafana will scan for changed dashboards.";
211 };
212 options = {
213 path = mkOption {
214 type = types.path;
215 description = "Path grafana will watch for dashboards.";
216 };
217 };
218 };
219 };
220
221 grafanaTypes.notifierConfig = types.submodule {
222 options = {
223 name = mkOption {
224 type = types.str;
225 default = "default";
226 description = "Notifier name.";
227 };
228 type = mkOption {
229 type = types.enum ["dingding" "discord" "email" "googlechat" "hipchat" "kafka" "line" "teams" "opsgenie" "pagerduty" "prometheus-alertmanager" "pushover" "sensu" "sensugo" "slack" "telegram" "threema" "victorops" "webhook"];
230 description = "Notifier type.";
231 };
232 uid = mkOption {
233 type = types.str;
234 description = "Unique notifier identifier.";
235 };
236 org_id = mkOption {
237 type = types.int;
238 default = 1;
239 description = "Organization ID.";
240 };
241 org_name = mkOption {
242 type = types.str;
243 default = "Main Org.";
244 description = "Organization name.";
245 };
246 is_default = mkOption {
247 type = types.bool;
248 description = "Is the default notifier.";
249 default = false;
250 };
251 send_reminder = mkOption {
252 type = types.bool;
253 default = true;
254 description = "Should the notifier be sent reminder notifications while alerts continue to fire.";
255 };
256 frequency = mkOption {
257 type = types.str;
258 default = "5m";
259 description = "How frequently should the notifier be sent reminders.";
260 };
261 disable_resolve_message = mkOption {
262 type = types.bool;
263 default = false;
264 description = "Turn off the message that sends when an alert returns to OK.";
265 };
266 settings = mkOption {
267 type = types.nullOr types.attrs;
268 default = null;
269 description = "Settings for the notifier type.";
270 };
271 secure_settings = mkOption {
272 type = types.nullOr types.attrs;
273 default = null;
274 description = "Secure settings for the notifier type.";
275 };
276 };
277 };
278in {
279 options.services.grafana = {
280 enable = mkEnableOption "grafana";
281
282 protocol = mkOption {
283 description = "Which protocol to listen.";
284 default = "http";
285 type = types.enum ["http" "https" "socket"];
286 };
287
288 addr = mkOption {
289 description = "Listening address.";
290 default = "127.0.0.1";
291 type = types.str;
292 };
293
294 port = mkOption {
295 description = "Listening port.";
296 default = 3000;
297 type = types.port;
298 };
299
300 socket = mkOption {
301 description = "Listening socket.";
302 default = "/run/grafana/grafana.sock";
303 type = types.str;
304 };
305
306 domain = mkOption {
307 description = "The public facing domain name used to access grafana from a browser.";
308 default = "localhost";
309 type = types.str;
310 };
311
312 rootUrl = mkOption {
313 description = "Full public facing url.";
314 default = "%(protocol)s://%(domain)s:%(http_port)s/";
315 type = types.str;
316 };
317
318 certFile = mkOption {
319 description = "Cert file for ssl.";
320 default = "";
321 type = types.str;
322 };
323
324 certKey = mkOption {
325 description = "Cert key for ssl.";
326 default = "";
327 type = types.str;
328 };
329
330 staticRootPath = mkOption {
331 description = "Root path for static assets.";
332 default = "${cfg.package}/share/grafana/public";
333 defaultText = literalExpression ''"''${package}/share/grafana/public"'';
334 type = types.str;
335 };
336
337 package = mkOption {
338 description = "Package to use.";
339 default = pkgs.grafana;
340 defaultText = literalExpression "pkgs.grafana";
341 type = types.package;
342 };
343
344 declarativePlugins = mkOption {
345 type = with types; nullOr (listOf path);
346 default = null;
347 description = "If non-null, then a list of packages containing Grafana plugins to install. If set, plugins cannot be manually installed.";
348 example = literalExpression "with pkgs.grafanaPlugins; [ grafana-piechart-panel ]";
349 # Make sure each plugin is added only once; otherwise building
350 # the link farm fails, since the same path is added multiple
351 # times.
352 apply = x: if isList x then lib.unique x else x;
353 };
354
355 dataDir = mkOption {
356 description = "Data directory.";
357 default = "/var/lib/grafana";
358 type = types.path;
359 };
360
361 database = {
362 type = mkOption {
363 description = "Database type.";
364 default = "sqlite3";
365 type = types.enum ["mysql" "sqlite3" "postgres"];
366 };
367
368 host = mkOption {
369 description = "Database host.";
370 default = "127.0.0.1:3306";
371 type = types.str;
372 };
373
374 name = mkOption {
375 description = "Database name.";
376 default = "grafana";
377 type = types.str;
378 };
379
380 user = mkOption {
381 description = "Database user.";
382 default = "root";
383 type = types.str;
384 };
385
386 password = mkOption {
387 description = ''
388 Database password.
389 This option is mutual exclusive with the passwordFile option.
390 '';
391 default = "";
392 type = types.str;
393 };
394
395 passwordFile = mkOption {
396 description = ''
397 File that containts the database password.
398 This option is mutual exclusive with the password option.
399 '';
400 default = null;
401 type = types.nullOr types.path;
402 };
403
404 path = mkOption {
405 description = "Database path.";
406 default = "${cfg.dataDir}/data/grafana.db";
407 type = types.path;
408 };
409
410 connMaxLifetime = mkOption {
411 description = ''
412 Sets the maximum amount of time (in seconds) a connection may be reused.
413 For MySQL this setting should be shorter than the `wait_timeout' variable.
414 '';
415 default = "unlimited";
416 example = 14400;
417 type = types.either types.int (types.enum [ "unlimited" ]);
418 };
419 };
420
421 provision = {
422 enable = mkEnableOption "provision";
423 datasources = mkOption {
424 description = "Grafana datasources configuration.";
425 default = [];
426 type = types.listOf grafanaTypes.datasourceConfig;
427 apply = x: map _filter x;
428 };
429 dashboards = mkOption {
430 description = "Grafana dashboard configuration.";
431 default = [];
432 type = types.listOf grafanaTypes.dashboardConfig;
433 apply = x: map _filter x;
434 };
435 notifiers = mkOption {
436 description = "Grafana notifier configuration.";
437 default = [];
438 type = types.listOf grafanaTypes.notifierConfig;
439 apply = x: map _filter x;
440 };
441 };
442
443 security = {
444 adminUser = mkOption {
445 description = "Default admin username.";
446 default = "admin";
447 type = types.str;
448 };
449
450 adminPassword = mkOption {
451 description = ''
452 Default admin password.
453 This option is mutual exclusive with the adminPasswordFile option.
454 '';
455 default = "admin";
456 type = types.str;
457 };
458
459 adminPasswordFile = mkOption {
460 description = ''
461 Default admin password.
462 This option is mutual exclusive with the <literal>adminPassword</literal> option.
463 '';
464 default = null;
465 type = types.nullOr types.path;
466 };
467
468 secretKey = mkOption {
469 description = "Secret key used for signing.";
470 default = "SW2YcwTIb9zpOOhoPsMm";
471 type = types.str;
472 };
473
474 secretKeyFile = mkOption {
475 description = "Secret key used for signing.";
476 default = null;
477 type = types.nullOr types.path;
478 };
479 };
480
481 smtp = {
482 enable = mkEnableOption "smtp";
483 host = mkOption {
484 description = "Host to connect to.";
485 default = "localhost:25";
486 type = types.str;
487 };
488 user = mkOption {
489 description = "User used for authentication.";
490 default = "";
491 type = types.str;
492 };
493 password = mkOption {
494 description = ''
495 Password used for authentication.
496 This option is mutual exclusive with the passwordFile option.
497 '';
498 default = "";
499 type = types.str;
500 };
501 passwordFile = mkOption {
502 description = ''
503 Password used for authentication.
504 This option is mutual exclusive with the password option.
505 '';
506 default = null;
507 type = types.nullOr types.path;
508 };
509 fromAddress = mkOption {
510 description = "Email address used for sending.";
511 default = "admin@grafana.localhost";
512 type = types.str;
513 };
514 };
515
516 users = {
517 allowSignUp = mkOption {
518 description = "Disable user signup / registration.";
519 default = false;
520 type = types.bool;
521 };
522
523 allowOrgCreate = mkOption {
524 description = "Whether user is allowed to create organizations.";
525 default = false;
526 type = types.bool;
527 };
528
529 autoAssignOrg = mkOption {
530 description = "Whether to automatically assign new users to default org.";
531 default = true;
532 type = types.bool;
533 };
534
535 autoAssignOrgRole = mkOption {
536 description = "Default role new users will be auto assigned.";
537 default = "Viewer";
538 type = types.enum ["Viewer" "Editor"];
539 };
540 };
541
542 auth = {
543 anonymous = {
544 enable = mkOption {
545 description = "Whether to allow anonymous access.";
546 default = false;
547 type = types.bool;
548 };
549 org_name = mkOption {
550 description = "Which organization to allow anonymous access to.";
551 default = "Main Org.";
552 type = types.str;
553 };
554 org_role = mkOption {
555 description = "Which role anonymous users have in the organization.";
556 default = "Viewer";
557 type = types.str;
558 };
559 };
560 google = {
561 enable = mkOption {
562 description = "Whether to allow Google OAuth2.";
563 default = false;
564 type = types.bool;
565 };
566 allowSignUp = mkOption {
567 description = "Whether to allow sign up with Google OAuth2.";
568 default = false;
569 type = types.bool;
570 };
571 clientId = mkOption {
572 description = "Google OAuth2 client ID.";
573 default = "";
574 type = types.str;
575 };
576 clientSecretFile = mkOption {
577 description = "Google OAuth2 client secret.";
578 default = null;
579 type = types.nullOr types.path;
580 };
581 };
582 };
583
584 analytics.reporting = {
585 enable = mkOption {
586 description = "Whether to allow anonymous usage reporting to stats.grafana.net.";
587 default = true;
588 type = types.bool;
589 };
590 };
591
592 extraOptions = mkOption {
593 description = ''
594 Extra configuration options passed as env variables as specified in
595 <link xlink:href="http://docs.grafana.org/installation/configuration/">documentation</link>,
596 but without GF_ prefix
597 '';
598 default = {};
599 type = with types; attrsOf (either str path);
600 };
601 };
602
603 config = mkIf cfg.enable {
604 warnings = flatten [
605 (optional (
606 cfg.database.password != opt.database.password.default ||
607 cfg.security.adminPassword != opt.security.adminPassword.default
608 ) "Grafana passwords will be stored as plaintext in the Nix store!")
609 (optional (
610 any (x: x.password != null || x.basicAuthPassword != null || x.secureJsonData != null) cfg.provision.datasources
611 ) "Datasource passwords will be stored as plaintext in the Nix store!")
612 (optional (
613 any (x: x.secure_settings != null) cfg.provision.notifiers
614 ) "Notifier secure settings will be stored as plaintext in the Nix store!")
615 ];
616
617 environment.systemPackages = [ cfg.package ];
618
619 assertions = [
620 {
621 assertion = cfg.database.password != opt.database.password.default -> cfg.database.passwordFile == null;
622 message = "Cannot set both password and passwordFile";
623 }
624 {
625 assertion = cfg.security.adminPassword != opt.security.adminPassword.default -> cfg.security.adminPasswordFile == null;
626 message = "Cannot set both adminPassword and adminPasswordFile";
627 }
628 {
629 assertion = cfg.security.secretKey != opt.security.secretKey.default -> cfg.security.secretKeyFile == null;
630 message = "Cannot set both secretKey and secretKeyFile";
631 }
632 {
633 assertion = cfg.smtp.password != opt.smtp.password.default -> cfg.smtp.passwordFile == null;
634 message = "Cannot set both password and passwordFile";
635 }
636 ];
637
638 systemd.services.grafana = {
639 description = "Grafana Service Daemon";
640 wantedBy = ["multi-user.target"];
641 after = ["networking.target"] ++ lib.optional usePostgresql "postgresql.service" ++ lib.optional useMysql "mysql.service";
642 environment = {
643 QT_QPA_PLATFORM = "offscreen";
644 } // mapAttrs' (n: v: nameValuePair "GF_${n}" (toString v)) envOptions;
645 script = ''
646 set -o errexit -o pipefail -o nounset -o errtrace
647 shopt -s inherit_errexit
648
649 ${optionalString (cfg.auth.google.clientSecretFile != null) ''
650 GF_AUTH_GOOGLE_CLIENT_SECRET="$(<${escapeShellArg cfg.auth.google.clientSecretFile})"
651 export GF_AUTH_GOOGLE_CLIENT_SECRET
652 ''}
653 ${optionalString (cfg.database.passwordFile != null) ''
654 GF_DATABASE_PASSWORD="$(<${escapeShellArg cfg.database.passwordFile})"
655 export GF_DATABASE_PASSWORD
656 ''}
657 ${optionalString (cfg.security.adminPasswordFile != null) ''
658 GF_SECURITY_ADMIN_PASSWORD="$(<${escapeShellArg cfg.security.adminPasswordFile})"
659 export GF_SECURITY_ADMIN_PASSWORD
660 ''}
661 ${optionalString (cfg.security.secretKeyFile != null) ''
662 GF_SECURITY_SECRET_KEY="$(<${escapeShellArg cfg.security.secretKeyFile})"
663 export GF_SECURITY_SECRET_KEY
664 ''}
665 ${optionalString (cfg.smtp.passwordFile != null) ''
666 GF_SMTP_PASSWORD="$(<${escapeShellArg cfg.smtp.passwordFile})"
667 export GF_SMTP_PASSWORD
668 ''}
669 ${optionalString cfg.provision.enable ''
670 export GF_PATHS_PROVISIONING=${provisionConfDir};
671 ''}
672 exec ${cfg.package}/bin/grafana-server -homepath ${cfg.dataDir}
673 '';
674 serviceConfig = {
675 WorkingDirectory = cfg.dataDir;
676 User = "grafana";
677 RuntimeDirectory = "grafana";
678 RuntimeDirectoryMode = "0755";
679 # Hardening
680 AmbientCapabilities = lib.mkIf (cfg.port < 1024) [ "CAP_NET_BIND_SERVICE" ];
681 CapabilityBoundingSet = if (cfg.port < 1024) then [ "CAP_NET_BIND_SERVICE" ] else [ "" ];
682 DeviceAllow = [ "" ];
683 LockPersonality = true;
684 NoNewPrivileges = true;
685 PrivateDevices = true;
686 PrivateTmp = true;
687 ProtectClock = true;
688 ProtectControlGroups = true;
689 ProtectHome = true;
690 ProtectHostname = true;
691 ProtectKernelLogs = true;
692 ProtectKernelModules = true;
693 ProtectKernelTunables = true;
694 ProtectProc = "invisible";
695 ProtectSystem = "full";
696 RemoveIPC = true;
697 RestrictAddressFamilies = [ "AF_INET" "AF_INET6" "AF_UNIX" ];
698 RestrictNamespaces = true;
699 RestrictRealtime = true;
700 RestrictSUIDSGID = true;
701 SystemCallArchitectures = "native";
702 # Upstream grafana is not setting SystemCallFilter for compatibility
703 # reasons, see https://github.com/grafana/grafana/pull/40176
704 SystemCallFilter = [ "@system-service" "~@privileged" "~@resources" ];
705 UMask = "0027";
706 };
707 preStart = ''
708 ln -fs ${cfg.package}/share/grafana/conf ${cfg.dataDir}
709 ln -fs ${cfg.package}/share/grafana/tools ${cfg.dataDir}
710 '';
711 };
712
713 users.users.grafana = {
714 uid = config.ids.uids.grafana;
715 description = "Grafana user";
716 home = cfg.dataDir;
717 createHome = true;
718 group = "grafana";
719 };
720 users.groups.grafana = {};
721 };
722}