summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore5
-rw-r--r--Cargo.lock772
-rw-r--r--Cargo.toml16
-rw-r--r--README.md22
-rw-r--r--examples/config.json20
-rw-r--r--examples/sample_push_event.http35
-rwxr-xr-xexamples/sample_push_event.sh14
-rw-r--r--examples/sample_push_payload.json1
-rw-r--r--examples/secret.txt1
-rw-r--r--flake.lock27
-rw-r--r--flake.nix331
-rw-r--r--src/config.rs251
-rw-r--r--src/main.rs97
-rw-r--r--src/service.rs157
-rw-r--r--src/systemd_socket.rs473
15 files changed, 2222 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..3216eb7
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+/target
+
+/local
+
+.nixos-test-history
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644
index 0000000..217aec5
--- /dev/null
+++ b/Cargo.lock
@@ -0,0 +1,772 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 3
+
+[[package]]
+name = "addr2line"
+version = "0.24.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f5fb1d8e4442bd405fdfd1dacb42792696b0cf9cb15882e5d097b742a676d375"
+dependencies = [
+ "gimli",
+]
+
+[[package]]
+name = "adler2"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627"
+
+[[package]]
+name = "atomic-waker"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+
+[[package]]
+name = "autocfg"
+version = "1.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26"
+
+[[package]]
+name = "backtrace"
+version = "0.3.74"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a"
+dependencies = [
+ "addr2line",
+ "cfg-if",
+ "libc",
+ "miniz_oxide",
+ "object",
+ "rustc-demangle",
+ "windows-targets",
+]
+
+[[package]]
+name = "bitflags"
+version = "2.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de"
+
+[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "bytes"
+version = "1.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "428d9aa8fbc0670b7b8d6030a7fadd0f86151cae55e4dbbece15f3780a3dfaf3"
+
+[[package]]
+name = "cfg-if"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
+
+[[package]]
+name = "cfg_aliases"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
+
+[[package]]
+name = "cpufeatures"
+version = "0.2.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "608697df725056feaccfa42cffdaeeec3fccc4ffc38358ecd19b243e716a78e0"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crypto-common"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer",
+ "crypto-common",
+ "subtle",
+]
+
+[[package]]
+name = "equivalent"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5"
+
+[[package]]
+name = "fnv"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+
+[[package]]
+name = "futures-channel"
+version = "0.3.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78"
+dependencies = [
+ "futures-core",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d"
+
+[[package]]
+name = "futures-sink"
+version = "0.3.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5"
+
+[[package]]
+name = "futures-task"
+version = "0.3.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004"
+
+[[package]]
+name = "futures-util"
+version = "0.3.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "pin-project-lite",
+ "pin-utils",
+]
+
+[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
+[[package]]
+name = "gimli"
+version = "0.31.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32085ea23f3234fc7846555e85283ba4de91e21016dc0455a16286d87a292d64"
+
+[[package]]
+name = "h2"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "524e8ac6999421f49a846c2d4411f337e53497d8ec55d67753beffa43c5d9205"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "fnv",
+ "futures-core",
+ "futures-sink",
+ "http",
+ "indexmap",
+ "slab",
+ "tokio",
+ "tokio-util",
+ "tracing",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.14.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
+
+[[package]]
+name = "hermit-abi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024"
+
+[[package]]
+name = "hmac"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
+dependencies = [
+ "digest",
+]
+
+[[package]]
+name = "http"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258"
+dependencies = [
+ "bytes",
+ "fnv",
+ "itoa",
+]
+
+[[package]]
+name = "http-body"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
+dependencies = [
+ "bytes",
+ "http",
+]
+
+[[package]]
+name = "http-body-util"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f"
+dependencies = [
+ "bytes",
+ "futures-util",
+ "http",
+ "http-body",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "httparse"
+version = "1.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fcc0b4a115bf80b728eb8ea024ad5bd707b615bfed49e0665b6e0f86fd082d9"
+
+[[package]]
+name = "httpdate"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
+
+[[package]]
+name = "hyper"
+version = "1.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "50dfd22e0e76d0f662d429a5f80fcaf3855009297eab6a0a9f8543834744ba05"
+dependencies = [
+ "bytes",
+ "futures-channel",
+ "futures-util",
+ "h2",
+ "http",
+ "http-body",
+ "httparse",
+ "httpdate",
+ "itoa",
+ "pin-project-lite",
+ "smallvec",
+ "tokio",
+ "want",
+]
+
+[[package]]
+name = "hyper-util"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41296eb09f183ac68eec06e03cdbea2e759633d4067b2f6552fc2e009bcad08b"
+dependencies = [
+ "bytes",
+ "futures-channel",
+ "futures-util",
+ "http",
+ "http-body",
+ "hyper",
+ "pin-project-lite",
+ "socket2",
+ "tokio",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "indexmap"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68b900aa2f7301e21c36462b170ee99994de34dff39a4a6a528e80e7376d07e5"
+dependencies = [
+ "equivalent",
+ "hashbrown",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b"
+
+[[package]]
+name = "lazy_static"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
+
+[[package]]
+name = "libc"
+version = "0.2.159"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "561d97a539a36e26a9a5fad1ea11a3039a67714694aaa379433e580854bc3dc5"
+
+[[package]]
+name = "lock_api"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17"
+dependencies = [
+ "autocfg",
+ "scopeguard",
+]
+
+[[package]]
+name = "memchr"
+version = "2.7.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
+
+[[package]]
+name = "memoffset"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "miniz_oxide"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2d80299ef12ff69b16a84bb182e3b9df68b5a91574d3d4fa6e41b65deec4df1"
+dependencies = [
+ "adler2",
+]
+
+[[package]]
+name = "mio"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec"
+dependencies = [
+ "hermit-abi",
+ "libc",
+ "wasi",
+ "windows-sys",
+]
+
+[[package]]
+name = "nix"
+version = "0.29.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
+dependencies = [
+ "bitflags",
+ "cfg-if",
+ "cfg_aliases",
+ "libc",
+ "memoffset",
+]
+
+[[package]]
+name = "object"
+version = "0.36.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "084f1a5821ac4c651660a94a7153d27ac9d8a53736203f58b31945ded098070a"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92"
+
+[[package]]
+name = "parking_lot"
+version = "0.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "windows-targets",
+]
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02"
+
+[[package]]
+name = "pin-utils"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.86"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.5.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "355ae415ccd3a04315d3f8246e86d67689ea74d88d915576e1589a351062a13b"
+dependencies = [
+ "bitflags",
+]
+
+[[package]]
+name = "rustc-demangle"
+version = "0.1.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f"
+
+[[package]]
+name = "ryu"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f"
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "serde"
+version = "1.0.210"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8e3592472072e6e22e0a54d5904d9febf8508f65fb8552499a1abc7d1078c3a"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.210"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "243902eda00fad750862fc144cea25caca5e20d615af0a81bee94ca738f1df1f"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.128"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ff5456707a1de34e7e37f2a6fd3d3f808c318259cbd01ab6377795054b483d8"
+dependencies = [
+ "itoa",
+ "memchr",
+ "ryu",
+ "serde",
+]
+
+[[package]]
+name = "sha2"
+version = "0.10.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "slab"
+version = "0.4.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "smallvec"
+version = "1.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67"
+
+[[package]]
+name = "socket2"
+version = "0.5.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce305eb0b4296696835b71df73eb912e0f1ffd2556a501fcede6e0c50349191c"
+dependencies = [
+ "libc",
+ "windows-sys",
+]
+
+[[package]]
+name = "subtle"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+
+[[package]]
+name = "syn"
+version = "2.0.79"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "89132cd0bf050864e1d38dc3bbc07a0eb8e7530af26344d3d2bbbef83499f590"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "tokio"
+version = "1.40.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2b070231665d27ad9ec9b8df639893f46727666c6767db40317fbe920a5d998"
+dependencies = [
+ "backtrace",
+ "bytes",
+ "libc",
+ "mio",
+ "parking_lot",
+ "pin-project-lite",
+ "signal-hook-registry",
+ "socket2",
+ "tokio-macros",
+ "windows-sys",
+]
+
+[[package]]
+name = "tokio-macros"
+version = "2.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tokio-util"
+version = "0.7.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61e7c3654c13bcd040d4a03abee2c75b1d14a37b423cf5a813ceae1cc903ec6a"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "futures-sink",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "tower-service"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
+
+[[package]]
+name = "tracing"
+version = "0.1.40"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef"
+dependencies = [
+ "pin-project-lite",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54"
+dependencies = [
+ "once_cell",
+]
+
+[[package]]
+name = "try-lock"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
+
+[[package]]
+name = "typenum"
+version = "1.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe"
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "want"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
+dependencies = [
+ "try-lock",
+]
+
+[[package]]
+name = "wasi"
+version = "0.11.0+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
+
+[[package]]
+name = "webhook-listener"
+version = "0.1.0"
+dependencies = [
+ "hmac",
+ "http-body-util",
+ "hyper",
+ "hyper-util",
+ "lazy_static",
+ "nix",
+ "serde",
+ "serde_json",
+ "sha2",
+ "tokio",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
+dependencies = [
+ "windows-targets",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm",
+ "windows_aarch64_msvc",
+ "windows_i686_gnu",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc",
+ "windows_x86_64_gnu",
+ "windows_x86_64_gnullvm",
+ "windows_x86_64_msvc",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..e00120b
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,16 @@
+[package]
+name = "webhook-listener"
+version = "0.1.0"
+edition = "2021"
+
+[dependencies]
+hyper = { version = "1", features = ["full"] }
+tokio = { version = "1", features = ["full"] }
+http-body-util = "0.1"
+hyper-util = { version = "0.1", features = ["full"] }
+hmac = "0.12.1"
+sha2 = "0.10.8"
+nix = { version = "0.29.0", features = ["socket", "fs", "ioctl", "process", "net"] }
+lazy_static = "1.5.0"
+serde = { version = "1.0.210", features = ["serde_derive"] }
+serde_json = "1.0.128"
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..244f12f
--- /dev/null
+++ b/README.md
@@ -0,0 +1,22 @@
+# Webhook listener
+
+A webserver which runs commands when it receives webhook events from GitHub.
+
+## Local development
+
+To test the server in development, run:
+
+```sh
+$ rm -f /tmp/webhook-listener.sock
+$ nix develop --command systemfd --socket unix::/tmp/webhook-listener.sock -- target/debug/webhook-listener
+```
+
+Then, in another terminal, run this command to send a sample event:
+
+```sh
+$ examples/sample_push_event.sh
+```
+
+`sample.http` contains a sample request signed with the key `mysecret`. The
+payload from that request is found in `sample_push_payload.json` which is what
+the above script sends.
diff --git a/examples/config.json b/examples/config.json
new file mode 100644
index 0000000..6cbdade
--- /dev/null
+++ b/examples/config.json
@@ -0,0 +1,20 @@
+{
+ "secret_file": "./examples/secret.txt",
+
+ "commands": [
+ {
+ "event": "ping",
+ "command": "/bin/echo",
+ "args": [
+ "Got ping event!!"
+ ]
+ },
+ {
+ "event": "push",
+ "command": "/bin/echo",
+ "args": [
+ "Got push event!!"
+ ]
+ }
+ ]
+}
diff --git a/examples/sample_push_event.http b/examples/sample_push_event.http
new file mode 100644
index 0000000..1dc7733
--- /dev/null
+++ b/examples/sample_push_event.http
@@ -0,0 +1,35 @@
+POST / HTTP/1.1
+host: 127.0.0.1:3000
+connection: keep-alive
+accept: */*
+user-agent: GitHub-Hookshot/140ebc7
+max-forwards: 10
+x-github-delivery: 7543c24a-7d8f-11ef-9348-3b472df99429
+x-github-event: push
+x-github-hook-id: 504318719
+x-github-hook-installation-target-id: 864484957
+x-github-hook-installation-target-type: repository
+x-hub-signature: sha1=c7ef177a009e1f520dde1223db1ae4daaa17e3c6
+x-hub-signature-256: sha256=fa6bf4d04aa55738642d45048fab71e48fc0539bcbd8fdce03baee7445c3d2c6
+x-arr-log-id: 18da17d1-a836-45fe-9727-e5f965891cac
+client-ip: 140.82.115.62:37220
+x-client-ip: 140.82.115.62
+disguised-host: smee.io
+x-site-deployment-id: smee-io-production
+was-default-hostname: smee-io-production.azurewebsites.net
+x-forwarded-proto: https
+x-appservice-proto: https
+x-arr-ssl: 2048|256|CN=GeoTrust Global TLS RSA4096 SHA256 2022 CA1, O="DigiCert, Inc.", C=US|CN=smee.io
+x-forwarded-tlsversion: 1.3
+x-forwarded-for: 140.82.115.62:37220
+x-original-url: /t0jT9nlFA215qVm6
+x-waws-unencoded-url: /t0jT9nlFA215qVm6
+x-client-port: 37220
+content-type: application/json
+timestamp: 1727773997174
+accept-language: *
+sec-fetch-mode: cors
+accept-encoding: gzip, deflate
+content-length: 7413
+
+{"ref":"refs/heads/main","before":"407e6275cc14390538c216a05049cc363c800af9","after":"06fa4075dc962e265c7d0f4d00d6eea5e390ac65","repository":{"id":864484957,"node_id":"R_kgDOM4b-XQ","name":"webhook-test","full_name":"linnnus/webhook-test","private":true,"owner":{"name":"linnnus","email":"[email protected]","login":"linnnus","id":64274485,"node_id":"MDQ6VXNlcjY0Mjc0NDg1","avatar_url":"https://avatars.githubusercontent.com/u/64274485?v=4","gravatar_id":"","url":"https://api.github.com/users/linnnus","html_url":"https://github.com/linnnus","followers_url":"https://api.github.com/users/linnnus/followers","following_url":"https://api.github.com/users/linnnus/following{/other_user}","gists_url":"https://api.github.com/users/linnnus/gists{/gist_id}","starred_url":"https://api.github.com/users/linnnus/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/linnnus/subscriptions","organizations_url":"https://api.github.com/users/linnnus/orgs","repos_url":"https://api.github.com/users/linnnus/repos","events_url":"https://api.github.com/users/linnnus/events{/privacy}","received_events_url":"https://api.github.com/users/linnnus/received_events","type":"User","site_admin":false},"html_url":"https://github.com/linnnus/webhook-test","description":"A GitHub repository I can use to test GitHub's webhook functionality","fork":false,"url":"https://github.com/linnnus/webhook-test","forks_url":"https://api.github.com/repos/linnnus/webhook-test/forks","keys_url":"https://api.github.com/repos/linnnus/webhook-test/keys{/key_id}","collaborators_url":"https://api.github.com/repos/linnnus/webhook-test/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/linnnus/webhook-test/teams","hooks_url":"https://api.github.com/repos/linnnus/webhook-test/hooks","issue_events_url":"https://api.github.com/repos/linnnus/webhook-test/issues/events{/number}","events_url":"https://api.github.com/repos/linnnus/webhook-test/events","assignees_url":"https://api.github.com/repos/linnnus/webhook-test/assignees{/user}","branches_url":"https://api.github.com/repos/linnnus/webhook-test/branches{/branch}","tags_url":"https://api.github.com/repos/linnnus/webhook-test/tags","blobs_url":"https://api.github.com/repos/linnnus/webhook-test/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/linnnus/webhook-test/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/linnnus/webhook-test/git/refs{/sha}","trees_url":"https://api.github.com/repos/linnnus/webhook-test/git/trees{/sha}","statuses_url":"https://api.github.com/repos/linnnus/webhook-test/statuses/{sha}","languages_url":"https://api.github.com/repos/linnnus/webhook-test/languages","stargazers_url":"https://api.github.com/repos/linnnus/webhook-test/stargazers","contributors_url":"https://api.github.com/repos/linnnus/webhook-test/contributors","subscribers_url":"https://api.github.com/repos/linnnus/webhook-test/subscribers","subscription_url":"https://api.github.com/repos/linnnus/webhook-test/subscription","commits_url":"https://api.github.com/repos/linnnus/webhook-test/commits{/sha}","git_commits_url":"https://api.github.com/repos/linnnus/webhook-test/git/commits{/sha}","comments_url":"https://api.github.com/repos/linnnus/webhook-test/comments{/number}","issue_comment_url":"https://api.github.com/repos/linnnus/webhook-test/issues/comments{/number}","contents_url":"https://api.github.com/repos/linnnus/webhook-test/contents/{+path}","compare_url":"https://api.github.com/repos/linnnus/webhook-test/compare/{base}...{head}","merges_url":"https://api.github.com/repos/linnnus/webhook-test/merges","archive_url":"https://api.github.com/repos/linnnus/webhook-test/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/linnnus/webhook-test/downloads","issues_url":"https://api.github.com/repos/linnnus/webhook-test/issues{/number}","pulls_url":"https://api.github.com/repos/linnnus/webhook-test/pulls{/number}","milestones_url":"https://api.github.com/repos/linnnus/webhook-test/milestones{/number}","notifications_url":"https://api.github.com/repos/linnnus/webhook-test/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/linnnus/webhook-test/labels{/name}","releases_url":"https://api.github.com/repos/linnnus/webhook-test/releases{/id}","deployments_url":"https://api.github.com/repos/linnnus/webhook-test/deployments","created_at":1727520330,"updated_at":"2024-09-28T10:45:50Z","pushed_at":1727524056,"git_url":"git://github.com/linnnus/webhook-test.git","ssh_url":"[email protected]:linnnus/webhook-test.git","clone_url":"https://github.com/linnnus/webhook-test.git","svn_url":"https://github.com/linnnus/webhook-test","homepage":null,"size":0,"stargazers_count":0,"watchers_count":0,"language":null,"has_issues":true,"has_projects":true,"has_downloads":true,"has_wiki":false,"has_pages":false,"has_discussions":false,"forks_count":0,"mirror_url":null,"archived":false,"disabled":false,"open_issues_count":0,"license":null,"allow_forking":true,"is_template":false,"web_commit_signoff_required":false,"topics":[],"visibility":"private","forks":0,"open_issues":0,"watchers":0,"default_branch":"main","stargazers":0,"master_branch":"main"},"pusher":{"name":"linnnus","email":"[email protected]"},"sender":{"login":"linnnus","id":64274485,"node_id":"MDQ6VXNlcjY0Mjc0NDg1","avatar_url":"https://avatars.githubusercontent.com/u/64274485?v=4","gravatar_id":"","url":"https://api.github.com/users/linnnus","html_url":"https://github.com/linnnus","followers_url":"https://api.github.com/users/linnnus/followers","following_url":"https://api.github.com/users/linnnus/following{/other_user}","gists_url":"https://api.github.com/users/linnnus/gists{/gist_id}","starred_url":"https://api.github.com/users/linnnus/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/linnnus/subscriptions","organizations_url":"https://api.github.com/users/linnnus/orgs","repos_url":"https://api.github.com/users/linnnus/repos","events_url":"https://api.github.com/users/linnnus/events{/privacy}","received_events_url":"https://api.github.com/users/linnnus/received_events","type":"User","site_admin":false},"created":false,"deleted":false,"forced":false,"base_ref":null,"compare":"https://github.com/linnnus/webhook-test/compare/407e6275cc14...06fa4075dc96","commits":[{"id":"06fa4075dc962e265c7d0f4d00d6eea5e390ac65","tree_id":"35037f100f45d4854269ee454e12e7ade22db510","distinct":true,"message":"fix everything","timestamp":"2024-09-28T13:47:35+02:00","url":"https://github.com/linnnus/webhook-test/commit/06fa4075dc962e265c7d0f4d00d6eea5e390ac65","author":{"name":"Linnnus","email":"[email protected]","username":"linnnus"},"committer":{"name":"Linnnus","email":"[email protected]","username":"linnnus"},"added":[],"removed":[],"modified":["README.md"]}],"head_commit":{"id":"06fa4075dc962e265c7d0f4d00d6eea5e390ac65","tree_id":"35037f100f45d4854269ee454e12e7ade22db510","distinct":true,"message":"fix everything","timestamp":"2024-09-28T13:47:35+02:00","url":"https://github.com/linnnus/webhook-test/commit/06fa4075dc962e265c7d0f4d00d6eea5e390ac65","author":{"name":"Linnnus","email":"[email protected]","username":"linnnus"},"committer":{"name":"Linnnus","email":"[email protected]","username":"linnnus"},"added":[],"removed":[],"modified":["README.md"]}} \ No newline at end of file
diff --git a/examples/sample_push_event.sh b/examples/sample_push_event.sh
new file mode 100755
index 0000000..362d621
--- /dev/null
+++ b/examples/sample_push_event.sh
@@ -0,0 +1,14 @@
+#!/bin/sh
+
+# Expects service to be listening on socket
+# Expects to be run from project root
+# Payload signed with 'mysecret'
+
+curl --unix-socket /tmp/websocket-listener.sock http://localhost/ \
+ -X POST \
+ --data @./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' \
+ -v
diff --git a/examples/sample_push_payload.json b/examples/sample_push_payload.json
new file mode 100644
index 0000000..bffdcd0
--- /dev/null
+++ b/examples/sample_push_payload.json
@@ -0,0 +1 @@
+{"ref":"refs/heads/main","before":"407e6275cc14390538c216a05049cc363c800af9","after":"06fa4075dc962e265c7d0f4d00d6eea5e390ac65","repository":{"id":864484957,"node_id":"R_kgDOM4b-XQ","name":"webhook-test","full_name":"linnnus/webhook-test","private":true,"owner":{"name":"linnnus","email":"[email protected]","login":"linnnus","id":64274485,"node_id":"MDQ6VXNlcjY0Mjc0NDg1","avatar_url":"https://avatars.githubusercontent.com/u/64274485?v=4","gravatar_id":"","url":"https://api.github.com/users/linnnus","html_url":"https://github.com/linnnus","followers_url":"https://api.github.com/users/linnnus/followers","following_url":"https://api.github.com/users/linnnus/following{/other_user}","gists_url":"https://api.github.com/users/linnnus/gists{/gist_id}","starred_url":"https://api.github.com/users/linnnus/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/linnnus/subscriptions","organizations_url":"https://api.github.com/users/linnnus/orgs","repos_url":"https://api.github.com/users/linnnus/repos","events_url":"https://api.github.com/users/linnnus/events{/privacy}","received_events_url":"https://api.github.com/users/linnnus/received_events","type":"User","site_admin":false},"html_url":"https://github.com/linnnus/webhook-test","description":"A GitHub repository I can use to test GitHub's webhook functionality","fork":false,"url":"https://github.com/linnnus/webhook-test","forks_url":"https://api.github.com/repos/linnnus/webhook-test/forks","keys_url":"https://api.github.com/repos/linnnus/webhook-test/keys{/key_id}","collaborators_url":"https://api.github.com/repos/linnnus/webhook-test/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/linnnus/webhook-test/teams","hooks_url":"https://api.github.com/repos/linnnus/webhook-test/hooks","issue_events_url":"https://api.github.com/repos/linnnus/webhook-test/issues/events{/number}","events_url":"https://api.github.com/repos/linnnus/webhook-test/events","assignees_url":"https://api.github.com/repos/linnnus/webhook-test/assignees{/user}","branches_url":"https://api.github.com/repos/linnnus/webhook-test/branches{/branch}","tags_url":"https://api.github.com/repos/linnnus/webhook-test/tags","blobs_url":"https://api.github.com/repos/linnnus/webhook-test/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/linnnus/webhook-test/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/linnnus/webhook-test/git/refs{/sha}","trees_url":"https://api.github.com/repos/linnnus/webhook-test/git/trees{/sha}","statuses_url":"https://api.github.com/repos/linnnus/webhook-test/statuses/{sha}","languages_url":"https://api.github.com/repos/linnnus/webhook-test/languages","stargazers_url":"https://api.github.com/repos/linnnus/webhook-test/stargazers","contributors_url":"https://api.github.com/repos/linnnus/webhook-test/contributors","subscribers_url":"https://api.github.com/repos/linnnus/webhook-test/subscribers","subscription_url":"https://api.github.com/repos/linnnus/webhook-test/subscription","commits_url":"https://api.github.com/repos/linnnus/webhook-test/commits{/sha}","git_commits_url":"https://api.github.com/repos/linnnus/webhook-test/git/commits{/sha}","comments_url":"https://api.github.com/repos/linnnus/webhook-test/comments{/number}","issue_comment_url":"https://api.github.com/repos/linnnus/webhook-test/issues/comments{/number}","contents_url":"https://api.github.com/repos/linnnus/webhook-test/contents/{+path}","compare_url":"https://api.github.com/repos/linnnus/webhook-test/compare/{base}...{head}","merges_url":"https://api.github.com/repos/linnnus/webhook-test/merges","archive_url":"https://api.github.com/repos/linnnus/webhook-test/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/linnnus/webhook-test/downloads","issues_url":"https://api.github.com/repos/linnnus/webhook-test/issues{/number}","pulls_url":"https://api.github.com/repos/linnnus/webhook-test/pulls{/number}","milestones_url":"https://api.github.com/repos/linnnus/webhook-test/milestones{/number}","notifications_url":"https://api.github.com/repos/linnnus/webhook-test/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/linnnus/webhook-test/labels{/name}","releases_url":"https://api.github.com/repos/linnnus/webhook-test/releases{/id}","deployments_url":"https://api.github.com/repos/linnnus/webhook-test/deployments","created_at":1727520330,"updated_at":"2024-09-28T10:45:50Z","pushed_at":1727524056,"git_url":"git://github.com/linnnus/webhook-test.git","ssh_url":"[email protected]:linnnus/webhook-test.git","clone_url":"https://github.com/linnnus/webhook-test.git","svn_url":"https://github.com/linnnus/webhook-test","homepage":null,"size":0,"stargazers_count":0,"watchers_count":0,"language":null,"has_issues":true,"has_projects":true,"has_downloads":true,"has_wiki":false,"has_pages":false,"has_discussions":false,"forks_count":0,"mirror_url":null,"archived":false,"disabled":false,"open_issues_count":0,"license":null,"allow_forking":true,"is_template":false,"web_commit_signoff_required":false,"topics":[],"visibility":"private","forks":0,"open_issues":0,"watchers":0,"default_branch":"main","stargazers":0,"master_branch":"main"},"pusher":{"name":"linnnus","email":"[email protected]"},"sender":{"login":"linnnus","id":64274485,"node_id":"MDQ6VXNlcjY0Mjc0NDg1","avatar_url":"https://avatars.githubusercontent.com/u/64274485?v=4","gravatar_id":"","url":"https://api.github.com/users/linnnus","html_url":"https://github.com/linnnus","followers_url":"https://api.github.com/users/linnnus/followers","following_url":"https://api.github.com/users/linnnus/following{/other_user}","gists_url":"https://api.github.com/users/linnnus/gists{/gist_id}","starred_url":"https://api.github.com/users/linnnus/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/linnnus/subscriptions","organizations_url":"https://api.github.com/users/linnnus/orgs","repos_url":"https://api.github.com/users/linnnus/repos","events_url":"https://api.github.com/users/linnnus/events{/privacy}","received_events_url":"https://api.github.com/users/linnnus/received_events","type":"User","site_admin":false},"created":false,"deleted":false,"forced":false,"base_ref":null,"compare":"https://github.com/linnnus/webhook-test/compare/407e6275cc14...06fa4075dc96","commits":[{"id":"06fa4075dc962e265c7d0f4d00d6eea5e390ac65","tree_id":"35037f100f45d4854269ee454e12e7ade22db510","distinct":true,"message":"fix everything","timestamp":"2024-09-28T13:47:35+02:00","url":"https://github.com/linnnus/webhook-test/commit/06fa4075dc962e265c7d0f4d00d6eea5e390ac65","author":{"name":"Linnnus","email":"[email protected]","username":"linnnus"},"committer":{"name":"Linnnus","email":"[email protected]","username":"linnnus"},"added":[],"removed":[],"modified":["README.md"]}],"head_commit":{"id":"06fa4075dc962e265c7d0f4d00d6eea5e390ac65","tree_id":"35037f100f45d4854269ee454e12e7ade22db510","distinct":true,"message":"fix everything","timestamp":"2024-09-28T13:47:35+02:00","url":"https://github.com/linnnus/webhook-test/commit/06fa4075dc962e265c7d0f4d00d6eea5e390ac65","author":{"name":"Linnnus","email":"[email protected]","username":"linnnus"},"committer":{"name":"Linnnus","email":"[email protected]","username":"linnnus"},"added":[],"removed":[],"modified":["README.md"]}} \ No newline at end of file
diff --git a/examples/secret.txt b/examples/secret.txt
new file mode 100644
index 0000000..7776823
--- /dev/null
+++ b/examples/secret.txt
@@ -0,0 +1 @@
+mysecret \ No newline at end of file
diff --git a/flake.lock b/flake.lock
new file mode 100644
index 0000000..3172d40
--- /dev/null
+++ b/flake.lock
@@ -0,0 +1,27 @@
+{
+ "nodes": {
+ "nixpkgs": {
+ "locked": {
+ "lastModified": 1727798627,
+ "narHash": "sha256-TUakuERJe6qhRjuE29EDxSUGnvHWE5UgL5XaRVpvf70=",
+ "owner": "NixOS",
+ "repo": "nixpkgs",
+ "rev": "9133b9f5554437083a6618581eeae88c53593b0d",
+ "type": "github"
+ },
+ "original": {
+ "owner": "NixOS",
+ "ref": "master",
+ "repo": "nixpkgs",
+ "type": "github"
+ }
+ },
+ "root": {
+ "inputs": {
+ "nixpkgs": "nixpkgs"
+ }
+ }
+ },
+ "root": "root",
+ "version": 7
+}
diff --git a/flake.nix b/flake.nix
new file mode 100644
index 0000000..036e356
--- /dev/null
+++ b/flake.nix
@@ -0,0 +1,331 @@
+{
+ description = "A NixOS service to start a systemd unit on GitHub pushes";
+
+ inputs = {
+ # We need unstable for support for running NixOS tests on MacOS.
+ # See: <https://github.com/NixOS/nixpkgs/commit/b8698cd8d62c42cf3e2b3a95224c57173b73e494>
+ nixpkgs.url = "github:NixOS/nixpkgs/master";
+ };
+
+ outputs = { self, nixpkgs }:
+ let
+ # Generate a version number based on flake modification.
+ lastModifiedDate = self.lastModifiedDate or self.lastModified or "19700101";
+ version = "${builtins.substring 0 8 lastModifiedDate}-${self.shortRev or "dirty"}";
+
+ # Helper function to generate an attrset '{ x86_64-linux = f "x86_64-linux"; ... }'.
+ supportedSystems = [ "aarch64-darwin" "x86_64-linux" "aarch64-linux" ];
+ forAllSystems = f: nixpkgs.lib.genAttrs supportedSystems (system: f system);
+
+ # Nixpkgs instantiated for supported system types.
+ nixpkgsFor = forAllSystems (system: import nixpkgs {
+ inherit system; overlays = builtins.attrValues self.overlays;
+ });
+ 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
+ '';
+ }
+ )
+ { };
+ };
+
+ packages = forAllSystems (system: rec {
+ inherit (nixpkgsFor.${system}) webhook-listener;
+ default = webhook-listener;
+ });
+
+ devShells = forAllSystems (system: {
+ default = nixpkgsFor.${system}.mkShell {
+ inputsFrom = [ self.packages.${system}.webhook-listener ];
+
+ packages = [
+ # Systemfd is useful for testing systemd socket activation.
+ nixpkgsFor.${system}.systemfd
+ ];
+
+ shellHook = ''
+ set -o vi
+
+ export RUST_BACKTRACE=1
+ '';
+ };
+ });
+
+ 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-file = 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_file" = cfg.secret-file;
+ "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}";
+ };
+ };
+ };
+ };
+
+ default = self.nixosModules.webhook-listener;
+ };
+
+ checks = forAllSystems (system:
+ let
+ pkgs = nixpkgsFor.${system};
+ lib = pkgs.lib;
+ nixos-lib = import "${pkgs.path}/nixos/lib" { };
+ in
+ {
+ # Run the Cargo tests.
+ webhook-listener = pkgs.webhook-listener.overrideAttrs (_: {
+ doCheck = true;
+ });
+
+ vm-test = (nixos-lib.runTest {
+ hostPkgs = pkgs;
+
+ # This speeds up the evaluation by skipping evaluating documentation (optional)
+ defaults.documentation.enable = lib.mkDefault false;
+
+ # 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-file = 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).
+ '';
+ }
+ ];
+ });
+ });
+ };
+}
+
diff --git a/src/config.rs b/src/config.rs
new file mode 100644
index 0000000..e70043e
--- /dev/null
+++ b/src/config.rs
@@ -0,0 +1,251 @@
+use std::path::{Path, PathBuf};
+use std::fs::{self, File};
+use std::io;
+use std::fmt::{self, Display};
+use serde::Deserialize;
+
+/// All the application configuration is stored in this structure.
+#[derive(PartialEq, Clone, Debug)]
+pub struct Config {
+ /// The secret string shared with GitHub that is used to verify signed requests.
+ pub secret: String,
+
+ /// Event-command pairs. Each element of this array should be matched (and optionally executed)
+ /// against the commands in gaide.
+ pub commands: Vec<Command>,
+}
+
+impl Config {
+ pub fn from_path<P: AsRef<Path>>(path: P) -> Result<Self, ConfigError> {
+ let raw_config = RawConfig::from_path(path)?;
+ let secret = fs::read_to_string(raw_config.secret_file)?;
+ Ok(Config {
+ secret,
+ commands: raw_config.commands,
+ })
+ }
+}
+
+/// This struct reflects the actual JSON on disk. It is further processed before being returned to
+/// the rest of the application.
+#[derive(Deserialize, Clone, Debug, PartialEq)]
+struct RawConfig {
+ /// Path to file containing the secret that was shared with GitHub.
+ secret_file: PathBuf,
+
+ /// Event-command pairs.
+ commands: Vec<Command>,
+}
+
+/// Represents an event-command pair. The command is run whenever the given event is received from
+/// GitHub's API.
+#[derive(Deserialize, Clone, Debug, PartialEq)]
+pub struct Command {
+ /// The name of an event from the GitHub API. A full list of events can be found in [GitHub's
+ /// documenation][gh-events].
+ ///
+ /// [gh-events]: https://docs.github.com/en/webhooks/webhook-events-and-payloads
+ pub event: String,
+
+ /// Path to the program to be executed when [`event`](event) occurs.
+ pub command: String,
+
+ /// Additional arguments to bass to [`command`](command).
+ #[serde(default)]
+ pub args: Vec<String>,
+}
+
+/*
+/// Serde helper which disallows empty strings for [`PathBuf`s](std::path::PathBuf). Based on [this
+/// StackOverflow post][so].
+///
+/// [so]: https://stackoverflow.com/a/46755370
+fn string_as_nonempty_pathbuf<'de, D>(deserializer: D) -> Result<PathBuf, D::Error>
+where
+ D: Deserializer<'de>
+{
+ let raw: &str = Deserialize::deserialize(deserializer)?;
+ if raw.is_empty() {
+ Err(de::Error::custom("path cannot be empty"))
+ } else {
+ Ok(PathBuf::from(raw))
+ }
+}
+*/
+
+/// Errors that can occur when reading configuration.
+#[derive(Debug)]
+pub enum ConfigError {
+ /// An IO error occured, such as failing to read the file.
+ Io(io::Error),
+ /// Decoding the file failed, e.g. if JSON is missing comma.
+ SerdeError(serde_json::Error),
+}
+
+impl From<io::Error> for ConfigError {
+ fn from(e: io::Error) -> ConfigError {
+ ConfigError::Io(e)
+ }
+}
+
+impl From<serde_json::Error> for ConfigError {
+ fn from(e: serde_json::Error) -> ConfigError {
+ use serde_json::error::Category;
+ match e.classify() {
+ Category::Io => ConfigError::Io(e.into()),
+ _ => ConfigError::SerdeError(e),
+ }
+ }
+}
+
+impl Display for ConfigError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> {
+ match self {
+ ConfigError::Io(e) => write!(f, "io error: {}", e),
+ ConfigError::SerdeError(e) => write!(f, "decoding error: {}", e),
+ }
+ }
+}
+
+impl RawConfig {
+ pub fn from_path<P: AsRef<Path>>(path: P) -> Result<Self, ConfigError> {
+ let file = File::open(path.as_ref())?;
+ let config: Self = serde_json::from_reader(file)?;
+ config.validate()?;
+ Ok(config)
+ }
+
+ #[allow(dead_code)] // Useful for tests.
+ pub(self) fn from_str(s: &str) -> Result<Self, ConfigError> {
+ let config: Self = serde_json::from_str(s)?;
+ config.validate()?;
+ Ok(config)
+ }
+
+ fn validate(&self) -> Result<(), ConfigError> {
+ if self.secret_file.is_relative() {
+ eprintln!("warning: configuration key `.secret_file` is relative path. This will be resolved relative to server's CWD at runtime which is most likely not what you want.");
+ // " <- Fix shitty Vim syntax highlighting
+ }
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{Config, Command, RawConfig, ConfigError};
+ use std::path::Path;
+
+ macro_rules! assert_matches {
+ ( $e:expr , $pat:pat ) => {
+ assert_matches!($e, $pat => ())
+ };
+ ( $e:expr , $pat:pat => $c:expr ) => {
+ match $e {
+ $pat => $c,
+ ref e => panic!("assertion failed: `{:?}` does not match `{}`",
+ e, stringify!($pat))
+ }
+ };
+ }
+
+ macro_rules! assert_contains {
+ ( $a:expr , $b:expr ) => {
+ let a_string: String = $a.to_string();
+ let b_string: String = $b.to_string();
+
+ if !a_string.contains(&b_string) {
+ panic!("assertion failed: expected {:?} to contain {:?}", a_string, b_string)
+ }
+ };
+ }
+
+ #[test]
+ fn load_valid_raw_config() {
+ let config_json = r#"
+ {
+ "secret_file": "/path/to/secret.txt",
+
+ "commands": [
+ {
+ "event": "ping",
+ "command": "/usr/bin/handle-ping",
+ "args": []
+ }
+ ]
+ }
+ "#;
+ let parsed_config = RawConfig::from_str(config_json).expect("valid config");
+ let expected_config = RawConfig {
+ secret_file: Path::new("/path/to/secret.txt").to_path_buf(),
+ commands: vec![
+ Command {
+ event: "ping".to_string(),
+ command: "/usr/bin/handle-ping".to_string(),
+ args: vec![],
+ },
+ ],
+ };
+ assert_eq!(parsed_config, expected_config);
+ }
+
+ #[test]
+ fn args_are_optional() {
+ let command_json = r#"
+ {
+ "event": "ping",
+ "command": "/usr/bin/handle-ping"
+ }
+ "#;
+ let parsed_command: Command = serde_json::from_str(command_json)
+ .expect("valid configuration");
+ let expected_command = Command {
+ event: "ping".to_string(),
+ command: "/usr/bin/handle-ping".to_string(),
+ args: vec![],
+ };
+ assert_eq!(expected_command, parsed_command);
+ }
+
+ #[test]
+ fn invalid_json_gives_error() {
+ // This JSON has a trailing comma, which isn't allowed.
+ let config_json = r#"
+ {
+ "secret_file": "blah",
+ "commands": [],
+ }
+ "#;
+ let result = RawConfig::from_str(config_json);
+ let err = assert_matches!(result, Err(ConfigError::SerdeError(e)) => e);
+ assert_eq!(err.line(), 5);
+ assert_eq!(err.column(), 13);
+ assert!(err.is_syntax());
+ }
+
+ #[test]
+ fn read_valid_config() {
+ let parse_result = Config::from_path("examples/config.json");
+ let parsed_config = assert_matches!(parse_result, Ok(c @ Config { .. }) => c);
+ let expected_config = Config {
+ secret: "mysecret".to_string(),
+ commands: vec![
+ Command {
+ event: "ping".to_string(),
+ command: "/bin/echo".to_string(),
+ args: vec![
+ "Got ping event!!".to_string()
+ ],
+ },
+ Command {
+ event: "push".to_string(),
+ command: "/bin/echo".to_string(),
+ args: vec![
+ "Got push event!!".to_string()
+ ],
+ },
+ ],
+ };
+ assert_eq!(parsed_config, expected_config);
+ }
+}
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..c09aa46
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,97 @@
+// The systemd_socket module contains a lot of dead code which is only used in tests, but which I
+// would like to keep up to date in case I need the module for another project.
+#[allow(dead_code)]
+
+mod systemd_socket;
+mod service;
+mod config;
+
+use hyper::Request;
+use hyper::server::conn::http1;
+use hyper::service::service_fn;
+use hyper_util::rt::TokioIo;
+
+use std::os::unix::net::UnixListener as StdUnixListener;
+use tokio::net::UnixListener as TokioUnixListener;
+use std::io;
+use std::process;
+use std::path::Path;
+use std::env;
+
+fn load_config() -> config::Config {
+ let args = env::args().collect::<Vec<_>>();
+ if args.len() != 2 {
+ eprintln!("Too {} command line arguments", if args.len() < 2 { "few" } else { "many" });
+ eprintln!("Usage: {} <path/to/config.json>", args[0]);
+ process::exit(1);
+ }
+
+ let config_path = Path::new(&args[1]);
+ match config::Config::from_path(config_path) {
+ Ok(config) => config,
+ Err(e) => {
+ eprintln!("Error reading configuration: {}", e);
+ process::exit(1);
+ },
+ }
+}
+
+fn get_listener_from_systemd() -> io::Result<TokioUnixListener> {
+ let mut fds = systemd_socket::listen_fds(true).unwrap_or(vec![]);
+ if fds.len() != 1 {
+ eprintln!("Too {} sockets passed from systemd", if fds.len() < 1 { "few" } else { "many" });
+ eprintln!("This tool only works with systemd socket activation.");
+ process::exit(1);
+ }
+ let fd = fds.remove(0);
+
+
+ #[cfg(not(target_vendor = "apple"))] // See note in `is_socket_unix`.
+ {
+ use nix::sys::socket::SockType;
+
+ if !systemd_socket::is_socket_unix(&fd, Some(SockType::Stream), Some(true), None)
+ .unwrap_or(false)
+ {
+ eprintln!("The socket from systemd is not a streaming UNIX socket");
+ process::exit(1);
+ }
+ }
+
+ let std_listener = StdUnixListener::from(fd);
+ std_listener.set_nonblocking(true)?; // Required by tokio::net::UnixListener::from_std().
+
+ let tokio_listener = TokioUnixListener::from_std(std_listener)?;
+ Ok(tokio_listener)
+}
+
+#[tokio::main]
+async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
+ let config = load_config();
+
+ let listener = get_listener_from_systemd()?;
+
+ // We start a loop to continuously accept incoming connections
+ loop {
+ let (stream, _) = listener.accept().await.expect("accepting new connection");
+ let io = TokioIo::new(stream);
+ let cfg = config.clone();
+
+ // Spawn a tokio task to serve multiple connections concurrently.
+ tokio::task::spawn(async move {
+ let service = service_fn(|req: Request<hyper::body::Incoming>| {
+ service::router(req, &cfg)
+ });
+
+ let conn = http1::Builder::new()
+ // On OSX, disabling keep alive prevents serve_connection from
+ // blocking and later returning an `Err` derived from `ENOTCONN`.
+ .keep_alive(false)
+ .serve_connection(io, service);
+
+ if let Err(err) = conn.await {
+ eprintln!("Error serving connection: {:?}", err);
+ }
+ });
+ }
+}
diff --git a/src/service.rs b/src/service.rs
new file mode 100644
index 0000000..a261bdf
--- /dev/null
+++ b/src/service.rs
@@ -0,0 +1,157 @@
+//! This module contains the service that is being served with Hyper (our HTTP server library). The
+//! functions in here are responsible for taking requests from the GitHub API and producing
+//! responses.
+
+use crate::config::{self, Config};
+
+use http_body_util::{combinators::BoxBody, BodyExt, Full, Empty};
+use hyper::body::{Body, Bytes};
+use hyper::header::{HeaderMap, HeaderValue};
+use hyper::{Request, Response, Method, StatusCode};
+
+use hmac::{Hmac, Mac};
+use sha2::Sha256;
+use std::num::ParseIntError;
+
+use tokio::process::Command;
+use tokio::io::AsyncWriteExt;
+use std::io;
+use std::process::{ExitStatus, Stdio};
+
+/// Alias for hasher implementing HMAC-SHA256.
+type HmacSha256 = Hmac<Sha256>;
+
+/// Dispatches HTTP requests to different handlers, returning their result.
+pub async fn router(
+ req: Request<hyper::body::Incoming>,
+ config: &Config,
+) -> Result<Response<BoxBody<Bytes, hyper::Error>>, hyper::Error> {
+ match (req.method(), req.uri().path()) {
+ (&Method::POST, "/") => handle_webhook_post(req, config).await,
+ _ => Ok(empty_res(StatusCode::NOT_FOUND)),
+ }
+}
+
+async fn handle_webhook_post(
+ req: Request<hyper::body::Incoming>,
+ config: &Config,
+) -> Result<Response<BoxBody<Bytes, hyper::Error>>, hyper::Error> {
+ let (head, body) = req.into_parts();
+
+ // Extract the event type early on. This allows us to exit before doing expensive signature
+ // checking, if the header is missing or invalid ASCII.
+ let event = match head.headers.get("X-GitHub-event").map(HeaderValue::to_str) {
+ Some(Ok(event)) => event,
+ Some(Err(_)) => return Ok(full_res("Invalid ASCII in header: X-GitHub-Event", StatusCode::BAD_REQUEST)),
+ None => return Ok(full_res("Missing header: X-GitHub-Event", StatusCode::BAD_REQUEST)),
+ };
+
+ // Read entire body into `Bytes`. We have to set an upper limit to protect the server from
+ // massive allocations.
+ let upper = body.size_hint().upper().unwrap_or(u64::MAX);
+ if upper > 1024 * 64 {
+ eprintln!("Rejecting request because payload is too large.");
+ return Ok(full_res("Body too big", StatusCode::PAYLOAD_TOO_LARGE));
+ }
+ let body = body.collect().await?.to_bytes();
+
+ // Now that we have read the entire body, we should validate the signature before proceeding.
+ if !validate_request(&config.secret, &head.headers, &body) {
+ eprintln!("Rejecting request becuase signature is missing or invaldi");
+ return Ok(full_res("Missing or invalid signature", StatusCode::BAD_REQUEST));
+ }
+
+ for command in &config.commands {
+ if command.event == event {
+ let command_clone = command.clone();
+ let body_clone = body.clone();
+ tokio::spawn(async move {
+ match run_command(&command_clone, body_clone.as_ref()).await {
+ Ok(s) => match s.code() {
+ Some(code) => println!("Command finished with exit code {}: {:?}", code, command_clone),
+ None => println!("Command finished without exit code: {:?}", command_clone),
+ },
+ Err(e) => eprintln!("Failed to spawn command: {:?}\nerror: {}", command_clone, e),
+ }
+ });
+ }
+ }
+
+ Ok(empty_res(StatusCode::NO_CONTENT))
+}
+
+async fn run_command(command: &config::Command, body: &[u8]) -> io::Result<ExitStatus> {
+ let mut child = Command::new(&command.command)
+ .stdin(Stdio::piped()) // We will feed the event data through stdin.
+ .stdout(Stdio::inherit())
+ .stderr(Stdio::inherit())
+ .args(&command.args)
+ .spawn()?;
+
+ // Feed data through stdin. Sure hope whatever a "deadlock" is doesn't happen here.
+ let mut child_stdin = child.stdin.take().expect("child has stdin");
+ child_stdin.write_all(body).await?;
+ drop(child_stdin);
+
+ Ok(child.wait().await?)
+}
+
+/// Utility to create an empty response.
+fn empty_res(status: StatusCode) -> Response<BoxBody<Bytes, hyper::Error>> {
+ let body = Empty::<Bytes>::new()
+ .map_err(|never| match never {})
+ .boxed();
+
+ let mut response = Response::new(body);
+ *response.status_mut() = status;
+ response
+}
+
+/// Utility to create a full (i.e. with content) response.
+fn full_res<T: Into<Bytes>>(
+ chunk: T,
+ status: StatusCode,
+) -> Response<BoxBody<Bytes, hyper::Error>> {
+ let body = Full::new(chunk.into())
+ .map_err(|never| match never {})
+ .boxed();
+
+ let mut response = Response::new(body);
+ *response.status_mut() = status;
+ response
+}
+
+/// Decodes a string slice into a string of bytes.
+///
+/// Implementation taken from [this stackoverflow post](https://stackoverflow.com/a/52992629).
+fn decode_hex(s: &str) -> Result<Vec<u8>, ParseIntError> {
+ (0..s.len())
+ .step_by(2)
+ .map(|i| u8::from_str_radix(&s[i..i + 2], 16))
+ .collect()
+}
+
+/// Validates the signature that GitHub attaches to events.
+///
+///
+fn validate_request(secret: &String, headers: &HeaderMap<HeaderValue>, body: &Bytes) -> bool {
+ // To verify the authenticity of the event, GitHub attaches a signature of the payload to
+ // every request. We extract the header. The header value will look something like this:
+ //
+ // x-hub-signature-256: sha256=6803d2a3e495fc4bd286d428ea4b794476a1ff1b72bbea4dfafd2477d5d89188
+ let maybe_signature = headers
+ .get("x-hub-signature-256")
+ .and_then(|hv| hv.to_str().ok()) // HeaderValue => &str
+ .and_then(|s| s.strip_prefix("sha256=")) // sha256=2843i4aklds... => 2843i4aklds...
+ .and_then(|s| decode_hex(s).ok()); // &str -> vec<u8>
+ let signature = match maybe_signature {
+ Some(s) => s,
+ None => return false, // Missing or invalid signature
+ };
+
+ // Now we independantly calculate a signature of the payload we just read, using the secret. If
+ // Github computed the signature with the same secret, we should be all good.
+ let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap();
+ mac.update(&body);
+ mac.verify_slice(&signature).is_ok()
+}
diff --git a/src/systemd_socket.rs b/src/systemd_socket.rs
new file mode 100644
index 0000000..043fd8e
--- /dev/null
+++ b/src/systemd_socket.rs
@@ -0,0 +1,473 @@
+//! `systemd_socket` implements the daemon side of the socket activation. The interface is similar
+//! to the one provided by the systemd/sd-daemon library, but adjusted for easier usage in rust. It
+//! relies on `nix` for all low-level operations. All checks are ported from the systemd code.
+//!
+//! Enums required for socket type (`SockType`) and address family (`AddressFamily`) are reexported
+//! from nix.
+//!
+//! The library is based on [rust-systemd](https://github.com/jmesmon/rust-systemd) by Cody P
+//! Schafer, but it does not require any extra libraries and works on rust stable.
+
+// I'm hoping to bring this module with me to other packages, so let's just allow all the functions
+// which _are_ useful, just not for this project. That's why there are a lot of `allow(dead_code)`
+// in this module.
+
+use nix::fcntl;
+use nix::libc;
+use nix::sys::socket::{self, SockaddrLike};
+use nix::sys::stat;
+use nix::unistd::Pid;
+use std::collections::HashMap;
+use std::convert::From;
+use std::env;
+use std::error::Error as StdError;
+use std::fmt;
+use std::num::ParseIntError;
+use std::os::unix::io::{OwnedFd, RawFd};
+use std::os::fd::{AsFd, AsRawFd, FromRawFd};
+use std::path;
+
+pub use nix::sys::socket::SockType;
+pub use nix::sys::socket::AddressFamily;
+
+const VAR_FDS: &'static str = "LISTEN_FDS";
+const VAR_NAMES: &'static str = "LISTEN_FDNAMES";
+const VAR_PID: &'static str = "LISTEN_PID";
+
+#[derive(Debug, PartialEq)]
+pub enum Error {
+ Var(env::VarError),
+ Parse(ParseIntError),
+ DifferentProcess,
+ InvalidVariableValue,
+ Nix(nix::Error),
+}
+
+impl fmt::Display for Error {
+ fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+ write!(f, "{}", self)
+ }
+}
+
+impl StdError for Error {
+ fn description(&self) -> &str {
+ match self {
+ &Error::InvalidVariableValue => "Environment variable could not be parsed",
+ &Error::DifferentProcess =>
+ "Environment variables are meant for a different process (pid mismatch)",
+ &Error::Var(_) => "Required environment variable missing or unreadable",
+ &Error::Parse(_) => "Could not parse number in 'LISTEN_FDS'",
+ &Error::Nix(_) => "Calling system function on socket failed",
+ }
+ }
+
+ fn cause(&self) -> Option<&dyn StdError> {
+ match self {
+ &Error::Var(ref e) => Some(e),
+ &Error::Parse(ref e) => Some(e),
+ &Error::Nix(ref e) => Some(e),
+ _ => None,
+ }
+ }
+}
+
+impl From<env::VarError> for Error {
+ fn from(e: env::VarError) -> Error {
+ Error::Var(e)
+ }
+}
+
+impl From<ParseIntError> for Error {
+ fn from(e: ParseIntError) -> Error {
+ Error::Parse(e)
+ }
+}
+
+impl From<nix::Error> for Error {
+ fn from(e: nix::Error) -> Error {
+ Error::Nix(e)
+ }
+}
+
+/// Encapsulates the possible failure modes of local functions.
+pub type Result<T> = std::result::Result<T, Error>;
+
+/// Number of the first passed file descriptor
+const LISTEN_FDS_START: RawFd = 3;
+
+fn unset_all_env() {
+ env::remove_var(VAR_PID);
+ env::remove_var(VAR_FDS);
+ env::remove_var(VAR_NAMES);
+}
+
+/// Returns the file descriptors passed in by init process. Removes the `$LISTEN_FDS` and
+/// `$LISTEN_PID` variables from the environment if `unset_environment` is `true`.
+pub fn listen_fds(unset_environment: bool) -> Result<Vec<OwnedFd>> {
+ let pid_str = env::var(VAR_PID)?;
+ let pid_raw: libc::pid_t = pid_str.parse()?;
+ let pid = Pid::from_raw(pid_raw);
+
+ if pid != nix::unistd::getpid() {
+ return Err(Error::DifferentProcess);
+ }
+
+ let fds_str = env::var(VAR_FDS)?;
+ let fds: libc::c_int = fds_str.parse()?;
+
+ if fds < 0 {
+ return Err(Error::InvalidVariableValue);
+ }
+
+ for fd in LISTEN_FDS_START..(LISTEN_FDS_START+fds) {
+ fcntl::fcntl(fd, fcntl::FcntlArg::F_SETFD(fcntl::FdFlag::FD_CLOEXEC))?;
+ }
+
+ if unset_environment {
+ unset_all_env();
+ }
+ let fd_vec: Vec<_> = (LISTEN_FDS_START .. (LISTEN_FDS_START+fds))
+ .map(|fd| unsafe { OwnedFd::from_raw_fd(fd) })
+ .collect();
+ Ok(fd_vec)
+}
+
+/// Returns file descriptors with names. Removes the `$LISTEN_FDS` and `$LISTEN_PID` variables from
+/// the environment if `unset_environment` is `true`.
+#[allow(unused)]
+pub fn listen_fds_with_names(unset_environment: bool) -> Result<HashMap<String, OwnedFd>> {
+ let names_str = env::var(VAR_NAMES)?;
+ let names: Vec<&str> = names_str.split(':').collect();
+
+ let fds: Vec<OwnedFd> = listen_fds(unset_environment)?;
+ if fds.len() != names.len() {
+ return Err(Error::InvalidVariableValue);
+ }
+
+ let mut map = HashMap::new();
+ for (name, fd) in names.into_iter().zip(fds) {
+ map.insert(name.to_string(), fd);
+ }
+ Ok(map)
+}
+
+/// Identifies whether the passed file descriptor is a FIFO. If a path is
+/// supplied, the file descriptor must also match the path.
+#[allow(unused)]
+pub fn is_fifo<T: AsRawFd>(fd: T, path: Option<&str>) -> Result<bool> {
+ let fs = stat::fstat(fd.as_raw_fd())?;
+ let mode = stat::SFlag::from_bits_truncate(fs.st_mode);
+ if !mode.contains(stat::SFlag::S_IFIFO) {
+ return Ok(false);
+ }
+ if let Some(path_str) = path {
+ let path_stat = match stat::stat(path::Path::new(path_str)) {
+ Ok(x) => x,
+ Err(_) => {return Ok(false)},
+ };
+ return Ok(path_stat.st_dev == fs.st_dev && path_stat.st_ino == fs.st_ino);
+ }
+ Ok(true)
+}
+
+/// Identifies whether the passed file descriptor is a special character device.
+/// If a path is supplied, the file descriptor must also match the path.
+#[allow(unused)]
+pub fn is_special<T: AsRawFd>(fd: T, path: Option<&str>) -> Result<bool> {
+ let fs = stat::fstat(fd.as_raw_fd())?;
+ let mode = stat::SFlag::from_bits_truncate(fs.st_mode);
+ if !mode.contains(stat::SFlag::S_IFREG) && !mode.contains(stat::SFlag::S_IFCHR) {
+ // path not comparable
+ return Ok(true);
+ }
+
+ if let Some(path_str) = path {
+ let path_stat = match stat::stat(path::Path::new(path_str)) {
+ Ok(x) => x,
+ Err(_) => {return Ok(false)},
+ };
+
+ let path_mode = stat::SFlag::from_bits_truncate(path_stat.st_mode);
+ if (mode & path_mode).contains(stat::SFlag::S_IFREG) {
+ return Ok(path_stat.st_dev == fs.st_dev && path_stat.st_ino == fs.st_ino);
+ }
+
+ if (mode & path_mode).contains(stat::SFlag::S_IFCHR) {
+ return Ok(path_stat.st_rdev == fs.st_rdev);
+ }
+
+ return Ok(false);
+ }
+
+ Ok(true)
+}
+
+/// Do checks common to all socket verification functions. (type, listening state)
+#[allow(unused)]
+fn is_socket_internal<T: AsFd>(fd: &T, socktype: Option<SockType>,
+ listening: Option<bool>) -> Result<bool> {
+ /*if fd < 0 {
+ return Err(Error::InvalidFdValue);
+ }*/
+
+ let fs = stat::fstat(fd.as_fd().as_raw_fd())?;
+ let mode = stat::SFlag::from_bits_truncate(fs.st_mode);
+ if !mode.contains(stat::SFlag::S_IFSOCK) {
+ return Ok(false);
+ }
+ if let Some(val) = socktype {
+ let typ: SockType = socket::getsockopt(&fd, socket::sockopt::SockType)?;
+ if typ != val {
+ return Ok(false);
+ }
+ }
+
+ if let Some(val) = listening {
+ // This is broken on MacOS, as according to [getsockopt(2)] and [this stackoverflow
+ // anser][so], `SO_ACCEPTCONN` is not
+ // supported at the `SOL_SOCKET` level. I assume this also applies to other platforms using
+ // the Darwin kernel, i.e. all Apple's platfroms.
+ //
+ // [getsockopt(2)]: https://developer.apple.com/library/archive/documentation/System/Conceptual/ManPages_iPhoneOS/man2/getsockopt.2.html
+ // [so]: https://stackoverflow.com/a/75943802
+ if cfg!(target_vendor = "apple") {
+ todo!("Getting listening state is not implemented on Apple's Darwin kernel");
+ }
+
+ let acc = socket::getsockopt(&fd, socket::sockopt::AcceptConn)?;
+ if acc != val {
+ return Ok(false);
+ }
+ }
+
+ Ok(true)
+}
+
+/// Identifies whether the passed file descriptor is a socket. If family,
+/// type, and listening state are supplied, they must match as well.
+#[allow(unused)]
+pub fn is_socket<T: AsFd>(fd: &T, family: Option<AddressFamily>, socktype: Option<SockType>,
+ listening: Option<bool>) -> Result<bool> {
+ if !is_socket_internal(fd, socktype, listening)? {
+ return Ok(false);
+ }
+
+ if let Some(f) = family {
+ let sock_addr: socket::SockaddrStorage = socket::getsockname(fd.as_fd().as_raw_fd())?;
+ let sock_family = sock_addr.family().unwrap();
+ if sock_family != f {
+ return Ok(false);
+ }
+ }
+
+ Ok(true)
+}
+
+/// Identifies whether the passed file descriptor is an Internet socket. If family, type, listening
+/// state, and/or port are supplied, they must match as well.
+pub fn is_socket_inet<T: AsFd>(fd: &T, family: Option<AddressFamily>, socktype: Option<SockType>,
+ listening: Option<bool>, port: Option<u16>) -> Result<bool> {
+ if !is_socket_internal(fd, socktype, listening)? {
+ return Ok(false);
+ }
+
+ let sock_addr: socket::SockaddrStorage = socket::getsockname(fd.as_fd().as_raw_fd())?;
+ let sock_family = sock_addr.family().unwrap();
+ if sock_family != AddressFamily::Inet && sock_family != AddressFamily::Inet6 {
+ return Ok(false);
+ }
+
+ if let Some(val) = family {
+ if sock_family != val {
+ return Ok(false);
+ }
+ }
+
+ if let Some(expected_port) = port {
+ let port = match sock_family {
+ socket::AddressFamily::Inet => sock_addr.as_sockaddr_in().unwrap().port(),
+ socket::AddressFamily::Inet6 => sock_addr.as_sockaddr_in6().unwrap().port(),
+ _ => unreachable!(),
+ };
+ if port != expected_port {
+ return Ok(false);
+ }
+ }
+
+ Ok(true)
+}
+
+/// Identifies whether the passed file descriptor is an AF_UNIX socket. If type are supplied, it
+/// must match as well. Path checking is currently unsupported and will be ignored
+#[allow(unused)]
+pub fn is_socket_unix<T: AsFd>(fd: &T, socktype: Option<SockType>, listening: Option<bool>,
+ path: Option<&str>) -> Result<bool> {
+ if !is_socket_internal(fd, socktype, listening)? {
+ return Ok(false);
+ }
+
+ let sock_addr: socket::SockaddrStorage = socket::getsockname(fd.as_fd().as_raw_fd())?;
+ let sock_family = sock_addr.family().unwrap();
+ if sock_family != AddressFamily::Unix {
+ return Ok(false);
+ }
+
+ if let Some(_val) = path {
+ // TODO: unsupported
+ }
+
+ Ok(true)
+}
+
+// TODO
+///// Identifies whether the passed file descriptor is a POSIX message queue. If a
+///// path is supplied, it will also verify the name.
+//pub fn is_mq(fd: RawFd, path: Option<&str>) -> Result<bool> {
+//}
+
+#[cfg(test)]
+mod tests {
+ use ::nix;
+ use ::lazy_static::lazy_static;
+ use ::std::env;
+ use ::std::os::unix::io::OwnedFd;
+ use ::std::os::fd::{AsRawFd, FromRawFd, RawFd};
+ use ::std::sync::{Mutex,MutexGuard};
+ use ::std::mem;
+
+ // Even with one -j1, cargo runs multiple tests at once. That doesn't work with environment
+ // variables, or specific socket ordering, so mutexes are required.
+ lazy_static! {
+ static ref LOCK: Mutex<()> = Mutex::new(());
+ }
+
+ fn lock_env<'a>() -> MutexGuard<'a, ()> {
+ // SAFETY: We can ignore `PoisonError`s since the ressource we are locking is just `()`.
+ // See: <https://stackoverflow.com/a/51694631>.
+ LOCK.lock().unwrap_or_else(|e| e.into_inner())
+ }
+
+ fn set_current_pid() {
+ let pid = nix::unistd::getpid();
+ env::set_var(super::VAR_PID, format!("{}", pid));
+ }
+
+ /// Create a new socket with the given `family` and `typ`e.
+ ///
+ /// This function is used by the `is_*` tests, so it returns an owned ressource (as opposed to
+ /// [`create_socket_with_fd`](self::create_socket_with_fd)).
+ fn create_socket(family: super::AddressFamily, typ: super::SockType) -> OwnedFd {
+ nix::sys::socket::socket(family, typ, nix::sys::socket::SockFlag::empty(), None).unwrap()
+ }
+
+ /// Create a new socket with the given `family` and `typ`e, asserting that it gets assigned a
+ /// specific fd.
+ ///
+ /// This function is used to simulate Systemd opening a socket for us, so the actual ressource
+ /// is ["forgotten"](std::mem::forget).
+ fn create_socket_with_fd(no: nix::libc::c_int, family: super::AddressFamily, typ: super::SockType) {
+ debug_assert!(no > 0, "Valid file descriptors are always positive");
+
+ // Allocate a socket. During normal operation, this would be done by Systemd before our
+ // program was even started.
+ let fd = create_socket(family, typ);
+ assert_eq!(fd.as_raw_fd(), no, "Expected new socket to have fd {} but got {}", no, fd.as_raw_fd());
+
+ // We don't want Rust to manage the ressource for us (YET), as this function is supposed to
+ // mimic how Systemd would file descriptors to us.
+ mem::forget(fd);
+ }
+
+ /// Returns a file descriptor for a regular file.
+ fn open_file() -> OwnedFd {
+ let path = ::std::path::Path::new("/etc/hosts");
+ let fd = nix::fcntl::open(path, nix::fcntl::OFlag::O_RDONLY, nix::sys::stat::Mode::empty()).unwrap();
+ unsafe { OwnedFd::from_raw_fd(fd) }
+ }
+
+ #[test]
+ fn listen_fds_success() {
+ let _l = lock_env();
+ set_current_pid();
+ let _fd = create_socket_with_fd(3, super::AddressFamily::Inet, super::SockType::Stream);
+ env::set_var(super::VAR_FDS, "1");
+ let fds = super::listen_fds(true).unwrap();
+ assert_eq!(fds.len(), 1);
+ assert_eq!(fds[0].as_raw_fd(), 3);
+ }
+
+ #[test]
+ fn names() {
+ let _l = lock_env();
+ set_current_pid();
+ env::set_var(super::VAR_FDS, "2");
+ env::set_var(super::VAR_NAMES, "a:b");
+ let _fd1 = create_socket_with_fd(3, super::AddressFamily::Inet, super::SockType::Stream);
+ let _fd2 = create_socket_with_fd(4, super::AddressFamily::Inet, super::SockType::Stream);
+ let fds = super::listen_fds_with_names(true).unwrap();
+ assert_eq!(fds.len(), 2);
+ assert_eq!(fds["a"].as_raw_fd(), 3);
+ assert_eq!(fds["b"].as_raw_fd(), 4);
+ }
+
+ #[test]
+ fn listen_fds_cleans() {
+ let _l = lock_env();
+ set_current_pid();
+ env::set_var(super::VAR_FDS, "0");
+ super::listen_fds(false).unwrap();
+ assert_eq!(env::var(super::VAR_FDS), Ok("0".into()));
+ super::listen_fds(true).unwrap();
+ assert_eq!(env::var(super::VAR_FDS), Err(env::VarError::NotPresent));
+ assert_eq!(env::var(super::VAR_PID), Err(env::VarError::NotPresent));
+ assert_eq!(env::var(super::VAR_NAMES), Err(env::VarError::NotPresent));
+ }
+
+ #[test]
+ fn is_socket() {
+ let _l = lock_env();
+
+ let fd = create_socket(super::AddressFamily::Inet, super::SockType::Stream);
+ assert!(super::is_socket(&fd, None, None, None).unwrap());
+ #[cfg(not(target_vendor = "apple"))]
+ assert!(super::is_socket(&fd, Some(super::AddressFamily::Inet),
+ Some(super::SockType::Stream), Some(false)).unwrap());
+ #[cfg(target_vendor = "apple")]
+ assert!(super::is_socket(&fd, Some(super::AddressFamily::Inet),
+ Some(super::SockType::Stream), None).unwrap());
+
+ let fd = open_file();
+ assert!(!super::is_socket(&fd, None, None, None).unwrap());
+ }
+
+ #[test]
+ fn is_socket_inet() {
+ let _l = lock_env();
+ let fd = create_socket(super::AddressFamily::Inet, super::SockType::Stream);
+ assert!(super::is_socket_inet(&fd, None, None, None, None).unwrap());
+ #[cfg(not(target_vendor = "apple"))]
+ assert!(super::is_socket_inet(&fd, Some(super::AddressFamily::Inet),
+ Some(super::SockType::Stream), Some(false), None).unwrap());
+ #[cfg(target_vendor = "apple")]
+ assert!(super::is_socket_inet(&fd, Some(super::AddressFamily::Inet),
+ Some(super::SockType::Stream), None, None).unwrap());
+
+ let fd = open_file();
+ assert!(!super::is_socket_inet(&fd, None, None, None, None).unwrap());
+ }
+
+ #[test]
+ fn is_socket_unix() {
+ let _l = lock_env();
+ let fd = create_socket(super::AddressFamily::Unix, super::SockType::Stream);
+ assert!(super::is_socket_unix(&fd, None, None, None).unwrap());
+ #[cfg(not(target_vendor = "apple"))]
+ assert!(super::is_socket_unix(&fd, Some(super::SockType::Stream),
+ Some(false), None).unwrap());
+ #[cfg(target_vendor = "apple")]
+ assert!(super::is_socket_unix(&fd, Some(super::SockType::Stream), None, None).unwrap());
+
+ let fd = open_file();
+ assert!(!super::is_socket_unix(&fd, None, None, None).unwrap());
+ }
+}