diff --git a/morph/grid/local/config.json b/morph/grid/local/config.json index 9a929d2cf4613874379fdcc7a52f241c10f63f18..8b23b6f1152be4fa94e8935342bf11f7706d036c 100644 --- a/morph/grid/local/config.json +++ b/morph/grid/local/config.json @@ -9,4 +9,5 @@ , "allowedChargeOrigins": [ "http://localhost:5000" ] +, "monitoringGoogleOAuthClientID": "" } diff --git a/morph/grid/local/grid.nix b/morph/grid/local/grid.nix index 4e82fa29db5df7da2c9e977f978e67228e527be5..3def2d77556e8b82b5fd0dbd2513f3d08b7ea2c7 100644 --- a/morph/grid/local/grid.nix +++ b/morph/grid/local/grid.nix @@ -61,7 +61,8 @@ let (gridlib.hardware-virtual ({ publicIPv4 = "192.168.67.24"; })) (gridlib.customize-monitoring { inherit hostsMap vpnClientIPs nodeExporterTargets paymentExporterTargets; - inherit (config) domain publicKeyPath privateKeyPath; + inherit (config) domain publicKeyPath privateKeyPath letsEncryptAdminEmail; + googleOAuthClientID = config.monitoringGoogleOAuthClientID; monitoringvpnIPv4 = "172.23.23.1"; stateVersion = "19.09"; }) diff --git a/morph/grid/production/config.json b/morph/grid/production/config.json index 092e4dff7b4c026c816afdd85b2a454089204141..fcae1563a8fc0d3a8a11324fc6667105ae3179c8 100644 --- a/morph/grid/production/config.json +++ b/morph/grid/production/config.json @@ -15,4 +15,5 @@ , "https://private.storage" , "https://www.private.storage" ] +, "monitoringGoogleOAuthClientID": "802959152038-klpkk38sfnqmknn1ucg7pvs4hcc2k8ae.apps.googleusercontent.com" } diff --git a/morph/grid/production/grid.nix b/morph/grid/production/grid.nix index 3fa9e287d2cbbb9e5b8e03baaad5912003f99cae..e663d2243e4aa6078260e41f07f807f606e64ef6 100644 --- a/morph/grid/production/grid.nix +++ b/morph/grid/production/grid.nix @@ -38,7 +38,8 @@ let gridlib.hardware-aws (gridlib.customize-monitoring { inherit hostsMap vpnClientIPs nodeExporterTargets paymentExporterTargets; - inherit (config) domain publicKeyPath privateKeyPath; + inherit (config) domain publicKeyPath privateKeyPath letsEncryptAdminEmail; + googleOAuthClientID = config.monitoringGoogleOAuthClientID; monitoringvpnIPv4 = "172.23.23.1"; stateVersion = "19.09"; }) diff --git a/morph/grid/testing/config.json b/morph/grid/testing/config.json index 8b94959557364d8af8f1f4aa61c5647b46db9932..a10840db52e8cd74bbac2a0ad38f4887c1a03258 100644 --- a/morph/grid/testing/config.json +++ b/morph/grid/testing/config.json @@ -14,4 +14,5 @@ , "https://privatestorage-staging.com" , "https://www.privatestorage-staging.com" ] +, "monitoringGoogleOAuthClientID": "802959152038-6esn1c6u2lm3j82lf29jvmn8s63hi8dc.apps.googleusercontent.com" } diff --git a/morph/grid/testing/grid.nix b/morph/grid/testing/grid.nix index 85cbe54057c3a234e36e66289725e5d46a6f197f..fbbbd9f13e49cfdc7fd2f0687fa2fe12df91ea33 100644 --- a/morph/grid/testing/grid.nix +++ b/morph/grid/testing/grid.nix @@ -51,7 +51,8 @@ let gridlib.hardware-aws (gridlib.customize-monitoring { inherit hostsMap vpnClientIPs nodeExporterTargets paymentExporterTargets; - inherit (config) domain publicKeyPath privateKeyPath; + inherit (config) domain publicKeyPath privateKeyPath letsEncryptAdminEmail; + googleOAuthClientID = config.monitoringGoogleOAuthClientID; monitoringvpnIPv4 = "172.23.23.1"; stateVersion = "19.09"; }) diff --git a/morph/lib/customize-monitoring.nix b/morph/lib/customize-monitoring.nix index 05fe45107e44c583c495ee55aeb9e351ba3871f1..36bb564a3d26eca419c46dcdef070584e6ff5d7d 100644 --- a/morph/lib/customize-monitoring.nix +++ b/morph/lib/customize-monitoring.nix @@ -13,6 +13,7 @@ , privateKeyPath , monitoringvpnIPv4 , domain +, letsEncryptAdminEmail # A list of VPN IP addresses as strings indicating which clients will be # allowed onto the VPN. @@ -30,6 +31,10 @@ # which nodes to scrape PaymentServer metrics from. , paymentExporterTargets ? [] + # A string containing the GSuite OAuth2 ClientID to use to authenticate + # logins to Grafana. +, googleOAuthClientID + # A string giving the NixOS state version for the system. , stateVersion , ... @@ -38,10 +43,35 @@ # See customize-issuer.nix for an explanatoin of targetHost value. deployment.targetHost = "${config.networking.hostName}.${config.networking.domain}"; - deployment.secrets = { - "monitoringvpn-private-key".source = "${privateKeyPath}/monitoringvpn/server.key"; - "monitoringvpn-preshared-key".source = "${privateKeyPath}/monitoringvpn/preshared.key"; - }; + deployment.secrets = let + # When Grafana SSO is disabled there is not necessarily any client secret + # available. Avoid telling morph that there is one in this case (so it + # avoids trying to read it and then failing). Even if the secret did + # exist, if SSO is disabled there's no point sending the secret to the + # server. + # + # Also, we have to define this whole secret here so that we can configure + # it completely or not at all. morph gets angry if we half configure it + # (say, by just omitting the "source" value). + grafanaSSO = + if googleOAuthClientID == "" + then { } + else { + "grafana-google-sso-secret" = { + source = "${privateKeyPath}/grafana-google-sso.secret"; + destination = "/run/keys/grafana-google-sso.secret"; + owner.user = config.systemd.services.grafana.serviceConfig.User; + owner.group = config.users.users.grafana.group; + permissions = "0400"; + action = ["sudo" "systemctl" "restart" "grafana.service"]; + }; + }; + monitoringvpn = { + "monitoringvpn-private-key".source = "${privateKeyPath}/monitoringvpn/server.key"; + "monitoringvpn-preshared-key".source = "${privateKeyPath}/monitoringvpn/preshared.key"; + }; + in + grafanaSSO // monitoringvpn; networking.domain = domain; networking.hosts = hostsMap; @@ -59,5 +89,11 @@ inherit paymentExporterTargets; }; + services.private-storage.monitoring.grafana = { + inherit letsEncryptAdminEmail; + inherit googleOAuthClientID; + domain = "${config.networking.hostName}.${config.networking.domain}"; + }; + system.stateVersion = stateVersion; } diff --git a/morph/lib/monitoring.nix b/morph/lib/monitoring.nix index fa769d5ebcb32d893310136291064a85c09beee2..d8af93b24119ba6dff5ce63a5b2d16fbd18edb71 100644 --- a/morph/lib/monitoring.nix +++ b/morph/lib/monitoring.nix @@ -31,10 +31,4 @@ rec { # Loki 0.3.0 from Nixpkgs 19.09 is too old and does not work: # ../../nixos/modules/monitoring/server/loki.nix ]; - - services.private-storage.monitoring.grafana = { - domain = "monitoring.private.storage"; - prometheusUrl = "http://localhost:9090/"; - lokiUrl = "http://localhost:3100/"; - }; } diff --git a/nixos/modules/monitoring/server/grafana.nix b/nixos/modules/monitoring/server/grafana.nix index 425c15f46735d81ac2f916ebb4009e7664bd0728..d320907e8e71562b47829850ff85245c265d5040 100644 --- a/nixos/modules/monitoring/server/grafana.nix +++ b/nixos/modules/monitoring/server/grafana.nix @@ -7,6 +7,16 @@ let cfg = config.services.private-storage.monitoring.grafana; + grafanaAuth = if (cfg.googleOAuthClientID == "") then { + anonymous.enable = true; + } else { + google.enable = true; + # Grafana considers it "sign up" to let in a user it has + # never seen before. + google.allowSignUp = true; + google.clientSecretFile = cfg.googleOAuthClientSecretFile; + google.clientId = cfg.googleOAuthClientID; + }; in { options.services.private-storage.monitoring.grafana = { @@ -18,19 +28,39 @@ in { prometheusUrl = lib.mkOption { type = lib.types.str; example = lib.literalExample "http://prometheus:9090/"; - default = "http://prometheus:9090/"; + default = "http://localhost:9090/"; description = "The URL of the Prometheus host to access"; }; lokiUrl = lib.mkOption { type = lib.types.str; example = lib.literalExample "http://loki:3100/"; - default = "http://loki:3100/"; + default = "http://localhost:3100/"; description = "The URL of the Loki host to access"; }; + letsEncryptAdminEmail = lib.mkOption + { type = lib.types.str; + description = '' + An email address to give to Let's Encrypt as an + operational contact for the service's TLS certificate. + ''; + }; + googleOAuthClientID = lib.mkOption + { type = lib.types.str; + example = lib.literalExample "grafana-staging-345678"; + default = "replace-by-your-client-id-or-set-empty-string-for-anonymous-access"; + description = "The GSuite OAuth2 SSO Client ID. Empty string turns SSO auth off and anonymous (free for all) access on."; + }; + googleOAuthClientSecretFile = lib.mkOption + { type = lib.types.path; + example = lib.literalExample "/var/secret/monitoring-gsuite-client-secret"; + default = /run/keys/grafana-google-sso.secret; + description = "The path to the GSuite SSO secret file."; + }; }; config = { - # networking.firewall.allowedTCPPorts = [ 80 443 ]; + # Port 80 for ACME ssl retrieval only. 443 for nginx -> grafana. + networking.firewall.allowedTCPPorts = [ 80 443 ]; services.grafana = { enable = true; @@ -41,12 +71,23 @@ in { # No phoning home analytics.reporting.enable = false; - # All three are required to forego the user/pass prompt: - auth.anonymous.enable = true; - auth.anonymous.org_role = "Admin"; - auth.anonymous.org_name = "Main Org."; + # Force Grafana to believe it is reachable via https on the default port + # number because that's where the nginx that forwards traffic to it is + # listening. Grafana's own server listens on an internal address that + # doesn't matter to anyone except our nginx instance. + rootUrl = "https://%(domain)s/"; + + extraOptions = { + # Defend against DNS rebinding attacks. + SERVER_ENFORCE_DOMAIN = "true"; + }; }; + services.grafana.auth = { + anonymous.org_role = "Admin"; + anonymous.org_name = "Main Org."; + } // grafanaAuth; + services.grafana.provision = { enable = true; # See https://grafana.com/docs/grafana/latest/administration/provisioning/#datasources @@ -70,13 +111,30 @@ in { }; # nginx reverse proxy - services.nginx.enable = true; - services.nginx.virtualHosts.${config.services.grafana.domain} = { - locations."/" = { - proxyPass = "http://127.0.0.1:${toString config.services.grafana.port}"; - proxyWebsockets = true; + security.acme.email = cfg.letsEncryptAdminEmail; + security.acme.acceptTerms = true; + services.nginx = { + enable = true; + + recommendedGzipSettings = true; + recommendedOptimisation = true; + recommendedProxySettings = true; + recommendedTlsSettings = true; + + # Only allow PFS-enabled ciphers with AES256: + sslCiphers = "AES256+EECDH:AES256+EDH:!aNULL"; + + virtualHosts.${config.services.grafana.domain} = { + enableACME = true; + forceSSL = true; + locations."/" = { + proxyPass = "http://127.0.0.1:${toString config.services.grafana.port}"; + proxyWebsockets = true; + }; }; }; + + # Let Grafana read from keys, if necessary. + users.users.grafana.extraGroups = [ "keys" ]; }; } -