diff options
-rw-r--r-- | flake.nix | 256 | ||||
-rw-r--r-- | nix/module.nix | 146 | ||||
-rw-r--r-- | nix/package.nix | 15 | ||||
-rw-r--r-- | nix/test-socket-usage.nix | 92 |
4 files changed, 259 insertions, 250 deletions
@@ -24,21 +24,7 @@ in { overlays.default = final: prev: { - webhook-listener = final.callPackage - ({ rustPlatform }: - 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 - ''; - } - ) - { }; + webhook-listener = final.callPackage ./nix/package.nix { inherit version; }; }; packages = forAllSystems (system: rec { @@ -64,149 +50,7 @@ }); nixosModules = { - webhook-listener = { 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}"; - }; - }; - }; - }; + webhook-listener = import ./nix/module.nix self; default = self.nixosModules.webhook-listener; }; @@ -229,100 +73,12 @@ # This speeds up the evaluation by skipping evaluating documentation (optional) defaults.documentation.enable = lib.mkDefault false; + # This makes `self` available in the NixOS configuration of our virtual machines. + node.specialArgs = { inherit self; }; + # Each module in this list is a test (?). imports = [ - { - name = "handles-valid-event"; - - nodes.machine = { pkgs, config, lib, ... }: { - 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: <https://www.commandlinux.com/man-page/man1/systemctl.1.html> - 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). - ''; - } + ./nix/test-socket-usage.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: <https://www.commandlinux.com/man-page/man1/systemctl.1.html> + 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). + ''; +} |