diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index 1a2d75307496..7a818f11ee27 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -698,6 +698,7 @@ ./services/monitoring/arbtt.nix ./services/monitoring/bosun.nix ./services/monitoring/cadvisor.nix + ./services/monitoring/cockpit.nix ./services/monitoring/collectd.nix ./services/monitoring/das_watchdog.nix ./services/monitoring/datadog-agent.nix diff --git a/nixos/modules/services/monitoring/cockpit.nix b/nixos/modules/services/monitoring/cockpit.nix new file mode 100644 index 000000000000..2947b4d80120 --- /dev/null +++ b/nixos/modules/services/monitoring/cockpit.nix @@ -0,0 +1,231 @@ +{ pkgs, config, lib, ... }: + +let + cfg = config.services.cockpit; + inherit (lib) types mkEnableOption mkOption mkIf mdDoc literalMD mkPackageOptionMD; + settingsFormat = pkgs.formats.ini {}; +in { + options = { + services.cockpit = { + enable = mkEnableOption (mdDoc "Cockpit"); + + package = mkPackageOptionMD pkgs "Cockpit" { + default = [ "cockpit" ]; + }; + + settings = lib.mkOption { + type = settingsFormat.type; + + default = {}; + + description = mdDoc '' + Settings for cockpit that will be saved in /etc/cockpit/cockpit.conf. + + See the [documentation](https://cockpit-project.org/guide/latest/cockpit.conf.5.html), that is also available with `man cockpit.conf.5` for details. + ''; + }; + + port = mkOption { + description = mdDoc "Port where cockpit will listen."; + type = types.port; + default = 9090; + }; + + openFirewall = mkOption { + description = mdDoc "Open port for cockpit."; + type = types.bool; + default = false; + }; + }; + }; + config = mkIf cfg.enable { + + # expose cockpit-bridge system-wide + environment.systemPackages = [ cfg.package ]; + + # allow cockpit to find its plugins + environment.pathsToLink = [ "/share/cockpit" ]; + + # generate cockpit settings + environment.etc."cockpit/cockpit.conf".source = settingsFormat.generate "cockpit.conf" cfg.settings; + + security.pam.services.cockpit = {}; + + networking.firewall.allowedTCPPorts = mkIf cfg.openFirewall [ cfg.port ]; + + # units are in reverse sort order if you ls $out/lib/systemd/system + # all these units are basically verbatim translated from upstream + + # Translation from $out/lib/systemd/system/systemd-cockpithttps.slice + systemd.slices.system-cockpithttps = { + description = "Resource limits for all cockpit-ws-https@.service instances"; + sliceConfig = { + TasksMax = 200; + MemoryHigh = "75%"; + MemoryMax = "90%"; + }; + }; + + # Translation from $out/lib/systemd/system/cockpit-wsinstance-https@.socket + systemd.sockets."cockpit-wsinstance-https@" = { + unitConfig = { + Description = "Socket for Cockpit Web Service https instance %I"; + BindsTo = [ "cockpit.service" "cockpit-wsinstance-https@%i.service" ]; + # clean up the socket after the service exits, to prevent fd leak + # this also effectively prevents a DoS by starting arbitrarily many sockets, as + # the services are resource-limited by system-cockpithttps.slice + Documentation = "man:cockpit-ws(8)"; + }; + socketConfig = { + ListenStream = "/run/cockpit/wsinstance/https@%i.sock"; + SocketUser = "root"; + SocketMode = "0600"; + }; + }; + + # Translation from $out/lib/systemd/system/cockpit-wsinstance-https@.service + systemd.services."cockpit-wsinstance-https@" = { + description = "Cockpit Web Service https instance %I"; + bindsTo = [ "cockpit.service"]; + path = [ cfg.package ]; + documentation = [ "man:cockpit-ws(8)" ]; + serviceConfig = { + Slice = "system-cockpithttps.slice"; + ExecStart = "${cfg.package}/libexec/cockpit-ws --for-tls-proxy --port=0"; + User = "root"; + Group = ""; + }; + }; + + # Translation from $out/lib/systemd/system/cockpit-wsinstance-http.socket + systemd.sockets.cockpit-wsinstance-http = { + unitConfig = { + Description = "Socket for Cockpit Web Service http instance"; + BindsTo = "cockpit.service"; + Documentation = "man:cockpit-ws(8)"; + }; + socketConfig = { + ListenStream = "/run/cockpit/wsinstance/http.sock"; + SocketUser = "root"; + SocketMode = "0600"; + }; + }; + + # Translation from $out/lib/systemd/system/cockpit-wsinstance-https-factory.socket + systemd.sockets.cockpit-wsinstance-https-factory = { + unitConfig = { + Description = "Socket for Cockpit Web Service https instance factory"; + BindsTo = "cockpit.service"; + Documentation = "man:cockpit-ws(8)"; + }; + socketConfig = { + ListenStream = "/run/cockpit/wsinstance/https-factory.sock"; + Accept = true; + SocketUser = "root"; + SocketMode = "0600"; + }; + }; + + # Translation from $out/lib/systemd/system/cockpit-wsinstance-https-factory@.service + systemd.services."cockpit-wsinstance-https-factory@" = { + description = "Cockpit Web Service https instance factory"; + documentation = [ "man:cockpit-ws(8)" ]; + path = [ cfg.package ]; + serviceConfig = { + ExecStart = "${cfg.package}/libexec/cockpit-wsinstance-factory"; + User = "root"; + }; + }; + + # Translation from $out/lib/systemd/system/cockpit-wsinstance-http.service + systemd.services."cockpit-wsinstance-http" = { + description = "Cockpit Web Service http instance"; + bindsTo = [ "cockpit.service" ]; + path = [ cfg.package ]; + documentation = [ "man:cockpit-ws(8)" ]; + serviceConfig = { + ExecStart = "${cfg.package}/libexec/cockpit-ws --no-tls --port=0"; + User = "root"; + Group = ""; + }; + }; + + # Translation from $out/lib/systemd/system/cockpit.socket + systemd.sockets."cockpit" = { + unitConfig = { + Description = "Cockpit Web Service Socket"; + Documentation = "man:cockpit-ws(8)"; + Wants = "cockpit-motd.service"; + }; + socketConfig = { + ListenStream = cfg.port; + ExecStartPost = [ + "-${cfg.package}/share/cockpit/motd/update-motd \"\" localhost" + "-${pkgs.coreutils}/bin/ln -snf active.motd /run/cockpit/motd" + ]; + ExecStopPost = "-${pkgs.coreutils}/bin/ln -snf inactive.motd /run/cockpit/motd"; + }; + wantedBy = [ "sockets.target" ]; + }; + + # Translation from $out/lib/systemd/system/cockpit.service + systemd.services."cockpit" = { + description = "Cockpit Web Service"; + documentation = [ "man:cockpit-ws(8)" ]; + restartIfChanged = true; + path = with pkgs; [ coreutils cfg.package ]; + requires = [ "cockpit.socket" "cockpit-wsinstance-http.socket" "cockpit-wsinstance-https-factory.socket" ]; + after = [ "cockpit-wsinstance-http.socket" "cockpit-wsinstance-https-factory.socket" ]; + environment = { + G_MESSAGES_DEBUG = "cockpit-ws,cockpit-bridge"; + }; + serviceConfig = { + RuntimeDirectory="cockpit/tls"; + ExecStartPre = [ + # cockpit-tls runs in a more constrained environment, these + means that these commands + # will run with full privilege instead of inside that constrained environment + # See https://www.freedesktop.org/software/systemd/man/systemd.service.html#ExecStart= for details + "+${cfg.package}/libexec/cockpit-certificate-ensure --for-cockpit-tls" + ]; + ExecStart = "${cfg.package}/libexec/cockpit-tls"; + User = "root"; + Group = ""; + NoNewPrivileges = true; + ProtectSystem = "strict"; + ProtectHome = true; + PrivateTmp = true; + PrivateDevices = true; + ProtectKernelTunables = true; + RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" ]; + MemoryDenyWriteExecute = true; + }; + }; + + # Translation from $out/lib/systemd/system/cockpit-motd.service + # This part basically implements a motd state machine: + # - If cockpit.socket is enabled then /run/cockpit/motd points to /run/cockpit/active.motd + # - If cockpit.socket is disabled then /run/cockpit/motd points to /run/cockpit/inactive.motd + # - As cockpit.socket is disabled by default, /run/cockpit/motd points to /run/cockpit/inactive.motd + # /run/cockpit/active.motd is generated dynamically by cockpit-motd.service + systemd.services."cockpit-motd" = { + path = with pkgs; [ nettools ]; + serviceConfig = { + Type = "oneshot"; + ExecStart = "${cfg.package}/share/cockpit/motd/update-motd"; + }; + description = "Cockpit motd updater service"; + documentation = [ "man:cockpit-ws(8)" ]; + wants = [ "network.target" ]; + after = [ "network.target" "cockpit.socket" ]; + }; + + systemd.tmpfiles.rules = [ # From $out/lib/tmpfiles.d/cockpit-tmpfiles.conf + "C /run/cockpit/inactive.motd 0640 root root - ${cfg.package}/share/cockpit/motd/inactive.motd" + "f /run/cockpit/active.motd 0640 root root -" + "L+ /run/cockpit/motd - - - - inactive.motd" + "d /etc/cockpit/ws-certs.d 0600 root root 0" + ]; + }; + + meta.maintainers = pkgs.cockpit.meta.maintainers; +} diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index 7b1159d66715..7ee83be6672e 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -134,6 +134,7 @@ in { cloud-init-hostname = handleTest ./cloud-init-hostname.nix {}; cloudlog = handleTest ./cloudlog.nix {}; cntr = handleTestOn ["aarch64-linux" "x86_64-linux"] ./cntr.nix {}; + cockpit = handleTest ./cockpit.nix {}; cockroachdb = handleTestOn ["x86_64-linux"] ./cockroachdb.nix {}; collectd = handleTest ./collectd.nix {}; connman = handleTest ./connman.nix {}; diff --git a/nixos/tests/cockpit.nix b/nixos/tests/cockpit.nix new file mode 100644 index 000000000000..4a4983f9bc4e --- /dev/null +++ b/nixos/tests/cockpit.nix @@ -0,0 +1,135 @@ +import ./make-test-python.nix ( + { pkgs, lib, ... }: + + let + user = "alice"; # from ./common/user-account.nix + password = "foobar"; # from ./common/user-account.nix + in { + name = "cockpit"; + meta = { + maintainers = with lib.maintainers; [ lucasew ]; + }; + nodes = { + server = { config, ... }: { + imports = [ ./common/user-account.nix ]; + security.polkit.enable = true; + users.users.${user} = { + extraGroups = [ "wheel" ]; + }; + services.cockpit = { + enable = true; + openFirewall = true; + settings = { + WebService = { + Origins = "https://server:9090"; + }; + }; + }; + }; + client = { config, ... }: { + imports = [ ./common/user-account.nix ]; + environment.systemPackages = let + seleniumScript = pkgs.writers.writePython3Bin "selenium-script" { + libraries = with pkgs.python3Packages; [ selenium ]; + } '' + from selenium import webdriver + from selenium.webdriver.common.by import By + from selenium.webdriver.firefox.options import Options + from selenium.webdriver.support.ui import WebDriverWait + from selenium.webdriver.support import expected_conditions as EC + from time import sleep + + + def log(msg): + from sys import stderr + print(f"[*] {msg}", file=stderr) + + + log("Initializing") + + options = Options() + options.add_argument("--headless") + + driver = webdriver.Firefox(options=options) + + driver.implicitly_wait(10) + + log("Opening homepage") + driver.get("https://server:9090") + + wait = WebDriverWait(driver, 60) + + + def wait_elem(by, query): + wait.until(EC.presence_of_element_located((by, query))) + + + def wait_title_contains(title): + wait.until(EC.title_contains(title)) + + + def find_element(by, query): + return driver.find_element(by, query) + + + def set_value(elem, value): + script = 'arguments[0].value = arguments[1]' + return driver.execute_script(script, elem, value) + + + log("Waiting for the homepage to load") + + # cockpit sets initial title as hostname + wait_title_contains("server") + wait_elem(By.CSS_SELECTOR, 'input#login-user-input') + + log("Homepage loaded!") + + log("Filling out username") + login_input = find_element(By.CSS_SELECTOR, 'input#login-user-input') + set_value(login_input, "${user}") + + log("Filling out password") + password_input = find_element(By.CSS_SELECTOR, 'input#login-password-input') + set_value(password_input, "${password}") + + log("Submiting credentials for login") + driver.find_element(By.CSS_SELECTOR, 'button#login-button').click() + + # driver.implicitly_wait(1) + # driver.get("https://server:9090/system") + + log("Waiting dashboard to load") + wait_title_contains("${user}@server") + + log("Waiting for the frontend to initalize") + sleep(1) + + log("Looking for that banner that tells about limited access") + container_iframe = find_element(By.CSS_SELECTOR, 'iframe.container-frame') + driver.switch_to.frame(container_iframe) + + assert "Web console is running in limited access mode" in driver.page_source + + driver.close() + ''; + in with pkgs; [ firefox-unwrapped geckodriver seleniumScript ]; + }; + }; + + testScript = '' + start_all() + + server.wait_for_open_port(9090) + server.wait_for_unit("network.target") + server.wait_for_unit("multi-user.target") + server.systemctl("start", "polkit") + + client.wait_for_unit("multi-user.target") + + client.succeed("curl -k https://server:9090 -o /dev/stderr") + print(client.succeed("whoami")) + client.succeed('PYTHONUNBUFFERED=1 selenium-script') + ''; + } +) diff --git a/pkgs/servers/monitoring/cockpit/default.nix b/pkgs/servers/monitoring/cockpit/default.nix new file mode 100644 index 000000000000..76f2f777f60c --- /dev/null +++ b/pkgs/servers/monitoring/cockpit/default.nix @@ -0,0 +1,226 @@ +{ lib +, stdenv +, fetchzip +, fetchurl +, fetchFromGitHub +, autoreconfHook +, bashInteractive +, cacert +, coreutils +, dbus +, docbook_xml_dtd_43 +, docbook_xsl +, findutils +, gettext +, git +, glib +, glibc +, glib-networking +, gnused +, gnutls +, json-glib +, krb5 +, libssh +, libxcrypt +, libxslt +, makeWrapper +, nodejs +, nixosTests +, openssh +, openssl +, pam +, pkg-config +, polkit +, python3Packages +, ripgrep +, runtimeShell +, systemd +, udev +, xmlto +}: + +let + pythonWithGobject = python3Packages.python.withPackages (p: with p; [ + pygobject3 + ]); +in + +stdenv.mkDerivation rec { + pname = "cockpit"; + version = "284"; + + src = fetchFromGitHub { + owner = "cockpit-project"; + repo = "cockpit"; + rev = "80a7c7cfed9157915067666fe95b298896f2aea8"; + sha256 = "sha256-iAIW6nVUk1FJD2dQvDMREPVqrq0JkExJ7lVio//ALts="; + fetchSubmodules = true; + }; + + nativeBuildInputs = [ + autoreconfHook + makeWrapper + docbook_xml_dtd_43 + docbook_xsl + findutils + gettext + git + (lib.getBin libxslt) + nodejs + openssl + pam + pkg-config + pythonWithGobject.python + python3Packages.setuptools + systemd + ripgrep + xmlto + ]; + + buildInputs = [ + (lib.getDev glib) + libxcrypt + gnutls + json-glib + krb5 + libssh + polkit + udev + ]; + + patches = [ + # Instead of requiring Internet access to do an npm install to generate the package-lock.json + # it copies the package-lock.json already present in the node_modules folder fetched as a git + # submodule. + ./nerf-node-modules.patch + + # sysconfdir is $(prefix)/etc by default and it breaks the configuration file loading feature + # changing sysconfdir to /etc breaks the build in this part of the makefile because it tries + # to write to /etc inside the sandbox + # this patch redirects it to write to $out/etc instead of /etc + ./fix-makefiles.patch + ]; + + postPatch = '' + # instruct users with problems to create a nixpkgs issue instead of nagging upstream directly + substituteInPlace configure.ac \ + --replace 'devel@lists.cockpit-project.org' 'https://github.com/NixOS/nixpkgs/issues/new?assignees=&labels=0.kind%3A+bug&template=bug_report.md&title=cockpit%25' + patchShebangs \ + test/common/pixel-tests \ + test/common/run-tests \ + test/common/tap-cdp \ + test/static-code \ + tools/escape-to-c \ + tools/make-compile-commands \ + tools/node-modules \ + tools/termschutz \ + tools/webpack-make + + for f in node_modules/.bin/*; do + patchShebangs $(realpath $f) + done + + export HOME=$(mktemp -d) + + cp node_modules/.package-lock.json package-lock.json + + substituteInPlace src/systemd_ctypes/libsystemd.py \ + --replace libsystemd.so.0 ${systemd}/lib/libsystemd.so.0 + + for f in pkg/**/*.js pkg/**/*.jsx test/**/* src/**/*; do + # some files substituteInPlace report as missing and it's safe to ignore them + substituteInPlace "$(realpath "$f")" \ + --replace '"/usr/bin/' '"' \ + --replace '"/bin/' '"' || true + done + + substituteInPlace src/common/Makefile-common.am \ + --replace 'TEST_PROGRAM += test-pipe' "" # skip test-pipe because it hangs the build + + substituteInPlace test/pytest/*.py \ + --replace "'bash" "'${bashInteractive}/bin/bash" + + echo "m4_define(VERSION_NUMBER, [${version}])" > version.m4 + ''; + + configureFlags = [ + "--enable-prefix-only=yes" + "--sysconfdir=/etc" + "--disable-pcp" # TODO: figure out how to package its dependency + "--with-default-session-path=/run/wrappers/bin:/run/current-system/sw/bin" + ]; + + enableParallelBuilding = true; + + preBuild = '' + patchShebangs \ + tools/test-driver + ''; + + postBuild = '' + find | grep cockpit-bridge + chmod +x \ + src/systemd/update-motd \ + src/tls/cockpit-certificate-helper \ + src/ws/cockpit-desktop + + patchShebangs \ + src/systemd/update-motd \ + src/tls/cockpit-certificate-helper \ + src/ws/cockpit-desktop + + PATH=${pythonWithGobject}/bin patchShebangs src/client/cockpit-client + + substituteInPlace src/ws/cockpit-desktop \ + --replace ' /bin/bash' ' ${runtimeShell}' + ''; + + fixupPhase = '' + runHook preFixup + + wrapProgram $out/libexec/cockpit-certificate-helper \ + --prefix PATH : ${lib.makeBinPath [ coreutils openssl ]} \ + --run 'cd $(mktemp -d)' + + wrapProgram $out/share/cockpit/motd/update-motd \ + --prefix PATH : ${lib.makeBinPath [ gnused ]} + + substituteInPlace $out/share/polkit-1/actions/org.cockpit-project.cockpit-bridge.policy \ + --replace /usr $out + + runHook postFixup + ''; + + doCheck = true; + checkInputs = [ + bashInteractive + cacert + dbus + glib-networking + openssh + python3Packages.pytest + python3Packages.vulture + ]; + checkPhase = '' + export GIO_EXTRA_MODULES=$GIO_EXTRA_MODULES:${glib-networking}/lib/gio/modules + export G_DEBUG=fatal-criticals + export G_MESSAGES_DEBUG=cockpit-ws,cockpit-wrapper,cockpit-bridge + export PATH=$PATH:$(pwd) + + cockpit-bridge --version + make pytest -j$NIX_BUILD_CORES || true + make check -j$NIX_BUILD_CORES || true + test/static-code + npm run eslint + npm run stylelint + ''; + + passthru.tests = { inherit (nixosTests) cockpit; }; + + meta = with lib; { + description = "Web-based graphical interface for servers"; + homepage = "https://cockpit-project.org/"; + license = licenses.lgpl21; + maintainers = with maintainers; [ lucasew ]; + }; +} diff --git a/pkgs/servers/monitoring/cockpit/fix-makefiles.patch b/pkgs/servers/monitoring/cockpit/fix-makefiles.patch new file mode 100644 index 000000000000..a22eb9f00ea8 --- /dev/null +++ b/pkgs/servers/monitoring/cockpit/fix-makefiles.patch @@ -0,0 +1,34 @@ +diff --git a/src/systemd/Makefile.am b/src/systemd/Makefile.am +index f28ea41df..684b82006 100644 +--- a/src/systemd/Makefile.am ++++ b/src/systemd/Makefile.am +@@ -23,10 +23,10 @@ dist_motd_SCRIPTS = src/systemd/update-motd + + # Automake: 'Variables using ... ‘sysconf’ ... are installed by install-exec.' + install-exec-hook:: +- mkdir -p $(DESTDIR)$(sysconfdir)/motd.d +- ln -sTfr $(DESTDIR)/run/cockpit/motd $(DESTDIR)$(sysconfdir)/motd.d/cockpit +- mkdir -p $(DESTDIR)$(sysconfdir)/issue.d +- ln -sTfr $(DESTDIR)/run/cockpit/motd $(DESTDIR)$(sysconfdir)/issue.d/cockpit.issue ++ mkdir -p $(DESTDIR)$(prefix)$(sysconfdir)/motd.d ++ ln -sTfr $(DESTDIR)$(prefix)/run/cockpit/motd $(DESTDIR)$(prefix)$(sysconfdir)/motd.d/cockpit ++ mkdir -p $(DESTDIR)$(prefix)$(sysconfdir)/issue.d ++ ln -sTfr $(DESTDIR)$(prefix)/run/cockpit/motd $(DESTDIR)$(prefix)$(sysconfdir)/issue.d/cockpit.issue + + tempconfdir = $(prefix)/lib/tmpfiles.d + nodist_tempconf_DATA = src/systemd/cockpit-tempfiles.conf +diff --git a/src/ws/Makefile-ws.am b/src/ws/Makefile-ws.am +index ed4e4363e..77a35b0e5 100644 +--- a/src/ws/Makefile-ws.am ++++ b/src/ws/Makefile-ws.am +@@ -169,8 +169,8 @@ install-tests:: + $(INSTALL_DATA) mock-pam-conv-mod.so $(DESTDIR)$(pamdir)/ + + install-exec-hook:: +- mkdir -p $(DESTDIR)$(sysconfdir)/cockpit/ws-certs.d $(DESTDIR)$(sysconfdir)/cockpit/machines.d +- chmod 755 $(DESTDIR)$(sysconfdir)/cockpit/ws-certs.d $(DESTDIR)$(sysconfdir)/cockpit/machines.d ++ mkdir -p $(DESTDIR)$(prefix)$(sysconfdir)/cockpit/ws-certs.d $(DESTDIR)$(prefix)$(sysconfdir)/cockpit/machines.d ++ chmod 755 $(DESTDIR)$(prefix)$(sysconfdir)/cockpit/ws-certs.d $(DESTDIR)$(prefix)$(sysconfdir)/cockpit/machines.d + + dist_check_DATA += \ + src/ws/mock-combined.crt \ diff --git a/pkgs/servers/monitoring/cockpit/nerf-node-modules.patch b/pkgs/servers/monitoring/cockpit/nerf-node-modules.patch new file mode 100644 index 000000000000..36b0e3861a4a --- /dev/null +++ b/pkgs/servers/monitoring/cockpit/nerf-node-modules.patch @@ -0,0 +1,12 @@ +diff --git a/tools/node-modules b/tools/node-modules +index 518875d..72b51e0 100755 +--- a/tools/node-modules ++++ b/tools/node-modules +@@ -7,6 +7,7 @@ V="${V-0}" # default to friendly messages + + set -eu + cd "${0%/*}/.." ++exit 0 + . tools/git-utils.sh + + cmd_remove() { diff --git a/pkgs/top-level/all-packages.nix b/pkgs/top-level/all-packages.nix index 7f20738844ad..a1c96f709425 100644 --- a/pkgs/top-level/all-packages.nix +++ b/pkgs/top-level/all-packages.nix @@ -24246,6 +24246,8 @@ with pkgs; clickhouse-backup = callPackage ../development/tools/database/clickhouse-backup { }; + cockpit = callPackage ../servers/monitoring/cockpit { }; + codeowners = callPackage ../development/tools/codeowners { }; couchdb3 = callPackage ../servers/http/couchdb/3.nix { };