summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--flake.nix256
-rw-r--r--nix/module.nix146
-rw-r--r--nix/package.nix15
-rw-r--r--nix/test-socket-usage.nix92
4 files changed, 259 insertions, 250 deletions
diff --git a/flake.nix b/flake.nix
index 93158a6..c4d38f7 100644
--- a/flake.nix
+++ b/flake.nix
@@ -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).
+ '';
+}