summaryrefslogtreecommitdiff
path: root/nix
diff options
context:
space:
mode:
authorLinnnus <[email protected]>2024-10-02 09:03:19 +0200
committerLinnnus <[email protected]>2024-10-02 09:03:19 +0200
commit144562570e7a5e80a45960579ba7f780d7129a44 (patch)
tree2c4ef3908091f006153fe64e3b547e0711832aba /nix
parenta37cfb89a2a56cac19d932b3338d1d5bebdc654b (diff)
Factor nix code into separate files
Diffstat (limited to 'nix')
-rw-r--r--nix/module.nix146
-rw-r--r--nix/package.nix15
-rw-r--r--nix/test-socket-usage.nix92
3 files changed, 253 insertions, 0 deletions
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).
+ '';
+}