diff options
-rw-r--r-- | .gitignore | 5 | ||||
-rw-r--r-- | Cargo.lock | 772 | ||||
-rw-r--r-- | Cargo.toml | 16 | ||||
-rw-r--r-- | README.md | 22 | ||||
-rw-r--r-- | examples/config.json | 20 | ||||
-rw-r--r-- | examples/sample_push_event.http | 35 | ||||
-rwxr-xr-x | examples/sample_push_event.sh | 14 | ||||
-rw-r--r-- | examples/sample_push_payload.json | 1 | ||||
-rw-r--r-- | examples/secret.txt | 1 | ||||
-rw-r--r-- | flake.lock | 27 | ||||
-rw-r--r-- | flake.nix | 331 | ||||
-rw-r--r-- | src/config.rs | 251 | ||||
-rw-r--r-- | src/main.rs | 97 | ||||
-rw-r--r-- | src/service.rs | 157 | ||||
-rw-r--r-- | src/systemd_socket.rs | 473 |
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()); + } +} |