diff --git a/nixos/doc/manual/from_md/release-notes/rl-2305.section.xml b/nixos/doc/manual/from_md/release-notes/rl-2305.section.xml
index b1c4745a3f59..0e85eee0f9ab 100644
--- a/nixos/doc/manual/from_md/release-notes/rl-2305.section.xml
+++ b/nixos/doc/manual/from_md/release-notes/rl-2305.section.xml
@@ -37,6 +37,13 @@
programs.bash.blesh.
+
+
+ webhook,
+ a lightweight webhook server. Available as
+ services.webhook.
+
+
cups-pdf-to-pdf,
diff --git a/nixos/doc/manual/release-notes/rl-2305.section.md b/nixos/doc/manual/release-notes/rl-2305.section.md
index 630157901674..ac23c0004683 100644
--- a/nixos/doc/manual/release-notes/rl-2305.section.md
+++ b/nixos/doc/manual/release-notes/rl-2305.section.md
@@ -18,6 +18,8 @@ In addition to numerous new and upgraded packages, this release has the followin
- [blesh](https://github.com/akinomyoga/ble.sh), a line editor written in pure bash. Available as [programs.bash.blesh](#opt-programs.bash.blesh.enable).
+- [webhook](https://github.com/adnanh/webhook), a lightweight webhook server. Available as [services.webhook](#opt-services.webhook.enable).
+
- [cups-pdf-to-pdf](https://github.com/alexivkin/CUPS-PDF-to-PDF), a pdf-generating cups backend based on [cups-pdf](https://www.cups-pdf.de/). Available as [services.printing.cups-pdf](#opt-services.printing.cups-pdf.enable).
- [fzf](https://github.com/junegunn/fzf), a command line fuzzyfinder. Available as [programs.fzf](#opt-programs.fzf.fuzzyCompletion).
diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix
index dd0921243a7c..d40ed00ec495 100644
--- a/nixos/modules/module-list.nix
+++ b/nixos/modules/module-list.nix
@@ -1012,6 +1012,7 @@
./services/networking/wasabibackend.nix
./services/networking/websockify.nix
./services/networking/wg-netmanager.nix
+ ./services/networking/webhook.nix
./services/networking/wg-quick.nix
./services/networking/wireguard.nix
./services/networking/wpa_supplicant.nix
diff --git a/nixos/modules/services/networking/webhook.nix b/nixos/modules/services/networking/webhook.nix
new file mode 100644
index 000000000000..b020db6961c3
--- /dev/null
+++ b/nixos/modules/services/networking/webhook.nix
@@ -0,0 +1,214 @@
+{ config, lib, pkgs, ... }:
+
+with lib;
+
+let
+ cfg = config.services.webhook;
+ defaultUser = "webhook";
+
+ hookFormat = pkgs.formats.json {};
+
+ hookType = types.submodule ({ name, ... }: {
+ freeformType = hookFormat.type;
+ options = {
+ id = mkOption {
+ type = types.str;
+ default = name;
+ description = mdDoc ''
+ The ID of your hook. This value is used to create the HTTP endpoint (`protocol://yourserver:port/prefix/''${id}`).
+ '';
+ };
+ execute-command = mkOption {
+ type = types.str;
+ description = mdDoc "The command that should be executed when the hook is triggered.";
+ };
+ };
+ });
+
+ hookFiles = mapAttrsToList (name: hook: hookFormat.generate "webhook-${name}.json" [ hook ]) cfg.hooks
+ ++ mapAttrsToList (name: hook: pkgs.writeText "webhook-${name}.json.tmpl" "[${hook}]") cfg.hooksTemplated;
+
+in {
+ options = {
+ services.webhook = {
+ enable = mkEnableOption (mdDoc ''
+ [Webhook](https://github.com/adnanh/webhook), a server written in Go that allows you to create HTTP endpoints (hooks),
+ which execute configured commands for any person or service that knows the URL
+ '');
+
+ package = mkPackageOption pkgs "webhook" {};
+ user = mkOption {
+ type = types.str;
+ default = defaultUser;
+ description = mdDoc ''
+ Webhook will be run under this user.
+
+ If set, you must create this user yourself!
+ '';
+ };
+ group = mkOption {
+ type = types.str;
+ default = defaultUser;
+ description = mdDoc ''
+ Webhook will be run under this group.
+
+ If set, you must create this group yourself!
+ '';
+ };
+ ip = mkOption {
+ type = types.str;
+ default = "0.0.0.0";
+ description = mdDoc ''
+ The IP webhook should serve hooks on.
+
+ The default means it can be reached on any interface if `openFirewall = true`.
+ '';
+ };
+ port = mkOption {
+ type = types.port;
+ default = 9000;
+ description = mdDoc "The port webhook should be reachable from.";
+ };
+ openFirewall = mkOption {
+ type = types.bool;
+ default = false;
+ description = lib.mdDoc ''
+ Open the configured port in the firewall for external ingress traffic.
+ Preferably the Webhook server is instead put behind a reverse proxy.
+ '';
+ };
+ enableTemplates = mkOption {
+ type = types.bool;
+ default = cfg.hooksTemplated != {};
+ defaultText = literalExpression "hooksTemplated != {}";
+ description = mdDoc ''
+ Enable the generated hooks file to be parsed as a Go template.
+ See [the documentation](https://github.com/adnanh/webhook/blob/master/docs/Templates.md) for more information.
+ '';
+ };
+ urlPrefix = mkOption {
+ type = types.str;
+ default = "hooks";
+ description = mdDoc ''
+ The URL path prefix to use for served hooks (`protocol://yourserver:port/''${prefix}/hook-id`).
+ '';
+ };
+ hooks = mkOption {
+ type = types.attrsOf hookType;
+ default = {};
+ example = {
+ echo = {
+ execute-command = "echo";
+ response-message = "Webhook is reachable!";
+ };
+ redeploy-webhook = {
+ execute-command = "/var/scripts/redeploy.sh";
+ command-working-directory = "/var/webhook";
+ };
+ };
+ description = mdDoc ''
+ The actual configuration of which hooks will be served.
+
+ Read more on the [project homepage] and on the [hook definition] page.
+ At least one hook needs to be configured.
+
+ [hook definition]: https://github.com/adnanh/webhook/blob/master/docs/Hook-Definition.md
+ [project homepage]: https://github.com/adnanh/webhook#configuration
+ '';
+ };
+ hooksTemplated = mkOption {
+ type = types.attrsOf types.str;
+ default = {};
+ example = {
+ echo-template = ''
+ {
+ "id": "echo-template",
+ "execute-command": "echo",
+ "response-message": "{{ getenv "MESSAGE" }}"
+ }
+ '';
+ };
+ description = mdDoc ''
+ Same as {option}`hooks`, but these hooks are specified as literal strings instead of Nix values,
+ and hence can include [template syntax](https://github.com/adnanh/webhook/blob/master/docs/Templates.md)
+ which might not be representable as JSON.
+
+ Template syntax requires the {option}`enableTemplates` option to be set to `true`, which is
+ done by default if this option is set.
+ '';
+ };
+ verbose = mkOption {
+ type = types.bool;
+ default = true;
+ description = mdDoc "Whether to show verbose output.";
+ };
+ extraArgs = mkOption {
+ type = types.listOf types.str;
+ default = [];
+ example = [ "-secure" ];
+ description = mdDoc ''
+ These are arguments passed to the webhook command in the systemd service.
+ You can find the available arguments and options in the [documentation][parameters].
+
+ [parameters]: https://github.com/adnanh/webhook/blob/master/docs/Webhook-Parameters.md
+ '';
+ };
+ environment = mkOption {
+ type = types.attrsOf types.str;
+ default = {};
+ description = mdDoc "Extra environment variables passed to webhook.";
+ };
+ };
+ };
+
+ config = mkIf cfg.enable {
+ assertions = let
+ overlappingHooks = builtins.intersectAttrs cfg.hooks cfg.hooksTemplated;
+ in [
+ {
+ assertion = hookFiles != [];
+ message = "At least one hook needs to be configured for webhook to run.";
+ }
+ {
+ assertion = overlappingHooks == {};
+ message = "`services.webhook.hooks` and `services.webhook.hooksTemplated` have overlapping attribute(s): ${concatStringsSep ", " (builtins.attrNames overlappingHooks)}";
+ }
+ ];
+
+ users.users = mkIf (cfg.user == defaultUser) {
+ ${defaultUser} =
+ {
+ isSystemUser = true;
+ group = cfg.group;
+ description = "Webhook daemon user";
+ };
+ };
+
+ users.groups = mkIf (cfg.user == defaultUser && cfg.group == defaultUser) {
+ ${defaultUser} = {};
+ };
+
+ networking.firewall.allowedTCPPorts = mkIf cfg.openFirewall [ cfg.port ];
+
+ systemd.services.webhook = {
+ description = "Webhook service";
+ after = [ "network.target" ];
+ wantedBy = [ "multi-user.target" ];
+ environment = config.networking.proxy.envVars // cfg.environment;
+ script = let
+ args = [ "-ip" cfg.ip "-port" (toString cfg.port) "-urlprefix" cfg.urlPrefix ]
+ ++ concatMap (hook: [ "-hooks" hook ]) hookFiles
+ ++ optional cfg.enableTemplates "-template"
+ ++ optional cfg.verbose "-verbose"
+ ++ cfg.extraArgs;
+ in ''
+ ${cfg.package}/bin/webhook ${escapeShellArgs args}
+ '';
+ serviceConfig = {
+ Restart = "on-failure";
+ User = cfg.user;
+ Group = cfg.group;
+ };
+ };
+ };
+}
diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix
index 36a5c9843c2f..6f76c337fd04 100644
--- a/nixos/tests/all-tests.nix
+++ b/nixos/tests/all-tests.nix
@@ -709,6 +709,7 @@ in {
vsftpd = handleTest ./vsftpd.nix {};
warzone2100 = handleTest ./warzone2100.nix {};
wasabibackend = handleTest ./wasabibackend.nix {};
+ webhook = runTest ./webhook.nix;
wiki-js = handleTest ./wiki-js.nix {};
wine = handleTest ./wine.nix {};
wireguard = handleTest ./wireguard {};
diff --git a/nixos/tests/webhook.nix b/nixos/tests/webhook.nix
new file mode 100644
index 000000000000..ed7051408640
--- /dev/null
+++ b/nixos/tests/webhook.nix
@@ -0,0 +1,65 @@
+{ pkgs, ... }:
+let
+ forwardedPort = 19000;
+ internalPort = 9000;
+in
+{
+ name = "webhook";
+
+ nodes = {
+ webhookMachine = { pkgs, ... }: {
+ virtualisation.forwardPorts = [{
+ host.port = forwardedPort;
+ guest.port = internalPort;
+ }];
+ services.webhook = {
+ enable = true;
+ port = internalPort;
+ openFirewall = true;
+ hooks = {
+ echo = {
+ execute-command = "echo";
+ response-message = "Webhook is reachable!";
+ };
+ };
+ hooksTemplated = {
+ echoTemplate = ''
+ {
+ "id": "echo-template",
+ "execute-command": "echo",
+ "response-message": "{{ getenv "WEBHOOK_MESSAGE" }}"
+ }
+ '';
+ };
+ environment.WEBHOOK_MESSAGE = "Templates are working!";
+ };
+ };
+ };
+
+ extraPythonPackages = p: [
+ p.requests
+ p.types-requests
+ ];
+
+ testScript = { nodes, ... }: ''
+ import requests
+ webhookMachine.wait_for_unit("webhook")
+ webhookMachine.wait_for_open_port(${toString internalPort})
+
+ with subtest("Check that webhooks can be called externally"):
+ response = requests.get("http://localhost:${toString forwardedPort}/hooks/echo")
+ print(f"Response code: {response.status_code}")
+ print("Response: %r" % response.content)
+
+ assert response.status_code == 200
+ assert response.content == b"Webhook is reachable!"
+
+ with subtest("Check that templated webhooks can be called externally"):
+ response = requests.get("http://localhost:${toString forwardedPort}/hooks/echo-template")
+ print(f"Response code: {response.status_code}")
+ print("Response: %r" % response.content)
+
+ assert response.status_code == 200
+ assert response.content == b"Templates are working!"
+ '';
+}
diff --git a/pkgs/servers/http/webhook/default.nix b/pkgs/servers/http/webhook/default.nix
index c19866cf8d87..6daf06e117aa 100644
--- a/pkgs/servers/http/webhook/default.nix
+++ b/pkgs/servers/http/webhook/default.nix
@@ -1,6 +1,7 @@
{ lib
, buildGoModule
, fetchFromGitHub
+, nixosTests
}:
buildGoModule rec {
@@ -20,6 +21,8 @@ buildGoModule rec {
doCheck = false;
+ passthru.tests = { inherit (nixosTests) webhook; };
+
meta = with lib; {
description = "Incoming webhook server that executes shell commands";
homepage = "https://github.com/adnanh/webhook";