From 144562570e7a5e80a45960579ba7f780d7129a44 Mon Sep 17 00:00:00 2001 From: Linnnus Date: Wed, 2 Oct 2024 09:03:19 +0200 Subject: Factor nix code into separate files --- nix/module.nix | 146 ++++++++++++++++++++++++++++++++++++++++++++++ nix/package.nix | 15 +++++ nix/test-socket-usage.nix | 92 +++++++++++++++++++++++++++++ 3 files changed, 253 insertions(+) create mode 100644 nix/module.nix create mode 100644 nix/package.nix create mode 100644 nix/test-socket-usage.nix (limited to 'nix') diff --git a/nix/module.nix b/nix/module.nix new file mode 100644 index 0000000..35eba4c --- /dev/null +++ b/nix/module.nix @@ -0,0 +1,146 @@ +self: +{ pkgs, lib, config, options, ... }: + +let + defaultUser = "webhooklistener"; + defaultGroup = "webhooklistener"; + + cfg = config.services.webhook-listener; +in { + options = with lib; { + services.webhook-listener = { + enable = mkEnableOption "Webhook listener"; + + package = mkOption { + description = "Package containing `webhook-listener` binary."; + type = types.package; + default = self.packages.${pkgs.system}.webhook-listener; + }; + + user = mkOption { + description = '' + The user to run the qBittorrent service as. This is also the + user that will run the command. + + The user is not automatically created if it is changed from the default value. + ''; + type = types.str; + default = defaultUser; + }; + + group = mkOption { + description = '' + The group to run the webhook listener service as. This is + also the group that will run the command. + + The group is not automatically created if it is changed from the default value. + ''; + type = types.str; + default = defaultGroup; + }; + + commands = mkOption { + description = "List of event/command pairs, which will be matched against events from GitHub"; + type = with types; listOf (submodule { + options = { + event = mkOption { + description = '' + An event from the GitHub API. + + See [the GitHub documentation](https://docs.github.com/en/webhooks/webhook-events-and-payloads) for event types and data. + ''; + type = types.str; + example = "push"; + }; + + command = mkOption { + description = "The command to run upon receiving webhook event from GitHub."; + type = types.str; + example = "run-ci-or-something"; + }; + + args = mkOption { + description = "Additional arguments to be supplied to `command`."; + type = with types; listOf str; + default = []; + example = [ "--some-option" ]; + }; + }; + }); + }; + + secret-path = mkOption { + description = "Path to file containing the secret given to GitHub."; + type = types.path; + example = "/run/github_secret.txt"; + }; + + socket-path = mkOption { + description = '' + Path of socket file where the server will be listening. + + You should set up a redirect with your reverse proxy such + that a POST request from GitHub (i.e. to the webhook url you + give to GitHub) is translated to a request to `/` on this socket. + ''; + type = types.path; + readOnly = true; + }; + }; + }; + + config = lib.mkIf cfg.enable { + # Create the user/group if required. + users.users = lib.mkIf (cfg.user == defaultUser) { + ${defaultUser} = { + description = "Runs ${options.services.webhook-listener.enable.description}"; + group = cfg.group; + isSystemUser = true; + }; + }; + users.groups = lib.mkIf (cfg.group == defaultGroup) { + ${defaultGroup} = {}; + }; + + # Create socket for server. + services.webhook-listener.socket-path = "/run/webhook-listener.sock"; + systemd.sockets."webhook-listener" = { + unitConfig = { + Description = "Socket for receiving webhook requests from GitHub"; + PartOf = [ "webhook-listener.service" ]; + }; + + socketConfig = { + ListenStream = config.services.webhook-listener.socket-path; + }; + + wantedBy = [ "sockets.target" ]; + }; + + # Create the listening server + systemd.services.webhook-listener = { + unitConfig = { + Description = "listening for webhook requests from GitHub"; + After = [ "network.target" "webhook-listener.socket" ]; + # Otherwise unit would need to create socket itself if started manually. + Requires = [ "webhook-listener.socket" ]; + }; + + serviceConfig = + let + config = { + "secret_path" = cfg.secret-path; + "commands" = cfg.commands; + }; + + config-file = pkgs.writers.writeJSON "config.json" config; + in + { + Type = "simple"; + User = cfg.user; + Group = cfg.group; + ExecStart = "${cfg.package}/bin/webhook-listener ${config-file}"; + }; + }; + }; +} diff --git a/nix/package.nix b/nix/package.nix new file mode 100644 index 0000000..36fdd6d --- /dev/null +++ b/nix/package.nix @@ -0,0 +1,15 @@ +{ rustPlatform +, version +}: + +rustPlatform.buildRustPackage { + pname = "webhook-listener"; + inherit version; + src = ../.; + cargoLock.lockFile = ../Cargo.lock; + + # Tests in systemd_socket are extremely finicky, so they cannot be run in parallel with other unit tests. + checkPhase = '' + cargo test -- --test-threads=1 + ''; +} diff --git a/nix/test-socket-usage.nix b/nix/test-socket-usage.nix new file mode 100644 index 0000000..25aa5fc --- /dev/null +++ b/nix/test-socket-usage.nix @@ -0,0 +1,92 @@ +{ + name = "socket-usage"; + + nodes.machine = { pkgs, config, lib, self, ... }: { + imports = [ self.nixosModules.webhook-listener ]; + + services.webhook-listener = { + enable = true; + + commands = [ + # We will use the file created by this command as a marker of a received event. + { + event = "push"; + command = "touch"; + args = ["/tmp/received-push-event"]; + } + ]; + + # The secret to be used when authenticating event's signature. + secret-path = toString (pkgs.writeText "secret.txt" "mysecret"); + }; + + environment.systemPackages = [ + (pkgs.writeShellScriptBin "send-push-event.sh" '' + ${pkgs.curl}/bin/curl ${lib.escapeShellArgs [ + # Connection details + "--unix-socket" config.services.webhook-listener.socket-path + "http://localhost/" + + # All the data our application needs for a push event. + "-X" "POST" + "--data" (builtins.readFile ../examples/sample_push_payload.json) + "-H" "X-Github-Event: push" + "-H" "X-Hub-Signature-256: sha256=6803d2a3e495fc4bd286d428ea4b794476a1ff1b72bbea4dfafd2477d5d89188" + "-H" "Content-Length: 7413" + "-H" "Content-Type: application/json" + + # We want detailed output but no smart output tricks + # which 100% break under the 2-3 layers of translation + # they undergo during interactive testing. + "--verbose" + "--no-progress-meter" + + # Fail the command if the request is rejected. This is + # important for use with `Machine.succeed`. + "--fail" + ]} + '') + ]; + + system.stateVersion = "24.05"; + }; + + # Open shell for interactive testing + interactive.nodes.machine = { + services.openssh = { + enable = true; + settings = { + PermitRootLogin = "yes"; + PermitEmptyPasswords = "yes"; + }; + }; + + security.pam.services.sshd.allowNullPassword = true; + + virtualisation.forwardPorts = [ + { from = "host"; host.port = 2000; guest.port = 22; } + ]; + }; + + testScript = '' + machine.start() + + with subtest("Proper (lazy) socket activation"): + machine.wait_for_unit("webhook-listener.socket") + exit_code, _ = machine.systemctl("is-active webhook-listener.service --quiet") + # According to systemctl(1): "Returns an exit code 0 if at least one is active, or non-zero otherwise." + # Combined with table 3, we get $? == 3 => inactive. + # See: + assert exit_code == 3, "Event should be inactive" + + with subtest("Sending valid request"): + machine.succeed("send-push-event.sh") + machine.wait_for_file("/tmp/received-push-event") + + with subtest("Service should be activated after request"): + exit_code, _ = machine.systemctl("is-active webhook-listener.service --quiet") + assert exit_code == 0, "Event should be active" + + # TODO: Send an invalid request (subtest). + ''; +} -- cgit v1.2.3