pijul_hooks/
lib.rs

1//! # Hooks for the Pijul Nest
2//!
3//! This crate can be used to write web servers that can receive
4//! requests from nest.pijul.com. It takes care of all the
5//! necessary authentication and parsing.
6//!
7//! Here is an example client and server:
8//!
9//! ```
10//! extern crate futures;
11//! use futures::Future;
12//! use pijul_hooks::*;
13//! tokio::run(futures::lazy(move || {
14//!     let secret = "ce sera notre petit secret";
15//!     let port = 9812;
16//!     let addr = ([127, 0, 0, 1], port).into();
17//!     let make_service = move || {
18//!         hyper::service::service_fn(move |req| {
19//!             parse_request(req, &secret).map(|hook| {
20//!                 hook.unwrap();
21//!                 let ok = hyper::Body::from("Ok");
22//!                 hyper::Response::new(ok)
23//!             })
24//!         })
25//!     };
26//!     let (tx, rx) = futures::sync::oneshot::channel::<()>();
27//!     let server = hyper::Server::bind(&addr).serve(make_service);
28//!     hyper::rt::spawn(server.with_graceful_shutdown(rx).map_err(|e| {
29//!         eprintln!("server error: {}", e);
30//!     }));
31//!     (Hook {
32//!         url: format!("http://!127.0.0.1:{}/", port).parse().unwrap(),
33//!         secret: secret.to_string(),
34//!     })
35//!     .run(&HookContent::Discussion {
36//!         repository_owner: "owner".to_string(),
37//!         repository_name: "name".to_string(),
38//!         discussion_number: 1234,
39//!         title: "title".to_string(),
40//!         author: "author".to_string(),
41//!     })
42//!     .map_err(|e| eprintln!("error: {:?}", e))
43//!     .map(|_| tx.send(()).unwrap())
44//! }))
45//! ```
46
47#[macro_use]
48extern crate serde_derive;
49extern crate futures;
50extern crate hex;
51extern crate hyper;
52extern crate openssl;
53extern crate reqwest;
54extern crate serde_json;
55#[cfg(tests)]
56extern crate tokio;
57use futures::future::Either;
58use futures::{Future, Stream};
59
60#[derive(Debug, Serialize, Deserialize)]
61pub enum HookContent {
62    Discussion {
63        repository_owner: String,
64        repository_name: String,
65        discussion_number: u32,
66        title: String,
67        author: String,
68    },
69    NewPatches {
70        repository_owner: String,
71        repository_name: String,
72        pusher: String,
73        patches: Vec<Patch>,
74    },
75    PatchesApplied {
76        repository_owner: String,
77        repository_name: String,
78        merged_by: String,
79        title: String,
80        author: String,
81        patches: Vec<Patch>,
82    },
83}
84
85#[derive(Debug, Serialize, Deserialize)]
86pub struct Patch {
87    pub hash: String,
88    pub authors: Vec<String>,
89    pub url: String,
90    pub name: String,
91}
92
93#[derive(Debug)]
94pub struct Hook {
95    pub url: reqwest::Url,
96    pub secret: String,
97}
98
99impl Hook {
100    /// Send a hook contents to the URL described by `self`, signed
101    /// with the secret in `self`.
102    pub fn run(
103        self,
104        contents: &HookContent,
105    ) -> impl Future<Item = reqwest::async::Response, Error = reqwest::Error> {
106        use hex::encode;
107        let body = serde_json::to_string(contents).unwrap();
108        let host = if let (Some(host), Some(port)) = (self.url.host(), self.url.port()) {
109            Some(format!("{}:{}", host, port))
110        } else {
111            None
112        };
113        let client = reqwest::async::Client::new().post(self.url);
114        let client = if let Some(ref host) = host {
115            client.header(reqwest::header::HOST, host.as_str())
116        } else {
117            client
118        };
119        let signature = {
120            use openssl::hash::MessageDigest;
121            use openssl::pkey::PKey;
122            use openssl::sign::Signer;
123            let pkey = PKey::hmac(self.secret.as_bytes()).unwrap();
124            let mut signer = Signer::new(MessageDigest::sha256(), &pkey).unwrap();
125            signer.update(body.as_bytes()).unwrap();
126            signer.sign_to_vec().unwrap()
127        };
128        println!("sig: {:?}", signature);
129        let s = "sha256=".to_string() + &encode(&signature);
130        let client = client.header("X-Nest-Event-Signature", s.as_str());
131
132        client.body(body).send()
133    }
134}
135
136/// Handle a hook request, verifying the signature. The future
137/// returned by this function yields `None` if the signature is
138/// invalid.
139pub fn parse_request(
140    req: hyper::Request<hyper::Body>,
141    secret: &str,
142) -> impl Future<Item = Option<HookContent>, Error = hyper::Error> {
143    use hex::decode;
144    use openssl::hash::MessageDigest;
145    use openssl::pkey::PKey;
146    use openssl::sign::Signer;
147    let pkey = PKey::hmac(secret.as_bytes()).unwrap();
148    let (digest, sig) = if let Some(sig) = req.headers().get("X-Nest-Event-Signature") {
149        if let Ok(sig) = std::str::from_utf8(sig.as_bytes()) {
150            let mut sp = sig.split("=");
151            match (sp.next(), sp.next().and_then(|sig| decode(sig).ok())) {
152                (Some("sha256"), Some(sig)) => (MessageDigest::sha256(), sig),
153                _ => return Either::B(futures::finished(None)),
154            }
155        } else {
156            return Either::B(futures::finished(None));
157        }
158    } else {
159        return Either::B(futures::finished(None));
160    };
161    println!("received: {:?}", sig);
162    Either::A(req.into_body().concat2().map(move |body| {
163        let mut signer = Signer::new(digest, &pkey).unwrap();
164        signer.update(body.as_ref()).unwrap();
165        if let Ok(hmac) = signer.sign_to_vec() {
166            if openssl::memcmp::eq(&hmac, &sig) {
167                if let Ok(s) = std::str::from_utf8(&body) {
168                    return serde_json::from_str(s).ok();
169                }
170            }
171        }
172        None
173    }))
174}