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";