webhook_router/
router.rs

1// Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or
2// http://www.apache.org/licenses/LICENSE-2.0> or the MIT license
3// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your
4// option. This file may not be copied, modified, or distributed
5// except according to those terms.
6
7use std::path::PathBuf;
8use std::sync::RwLock;
9
10use http::{HeaderMap, Method, Request, Response, StatusCode};
11use log::{debug, error};
12use serde_json::Value;
13
14use crate::config::{Config, ConfigError};
15
16/// A router for the Iron framework.
17///
18/// Drops JSON objects which are received via `POST` into a directory using the current date as the
19/// filename.
20pub struct Router {
21    /// The path to the configuration file.
22    path: PathBuf,
23    /// The configuration for sorting objects based on their type.
24    config: RwLock<Config>,
25    /// The secrets for various projects.
26    secrets: RwLock<Value>,
27}
28
29impl Router {
30    /// Create a new router for a path.
31    pub fn new<P: Into<PathBuf>>(path: P) -> Result<Self, ConfigError> {
32        let path = path.into();
33        let config = Config::from_path(&path)?;
34
35        Ok(Self {
36            path,
37
38            secrets: RwLock::new(config.secrets()?),
39            config: RwLock::new(config),
40        })
41    }
42
43    /// Reload the filename.
44    fn reload(&self) -> http::Result<Response<String>> {
45        match Config::from_path(&self.path) {
46            Ok(config) => {
47                {
48                    let mut inner_config = self
49                        .config
50                        .write()
51                        .expect("expected to be able to get a write lock on the configuration");
52
53                    *inner_config = config;
54                }
55
56                self.reload_secrets()
57            },
58            Err(err) => {
59                error!("failed to load configuration: {:?}", err);
60
61                Response::builder()
62                    .status(StatusCode::NOT_ACCEPTABLE)
63                    .body(format!("{:?}", err))
64            },
65        }
66    }
67
68    /// Reload secrets.
69    fn reload_secrets(&self) -> http::Result<Response<String>> {
70        let config = self
71            .config
72            .read()
73            .expect("expected to be able to get a read lock on the configuration");
74
75        match config.secrets() {
76            Ok(secrets) => {
77                let mut inner_secrets = self
78                    .secrets
79                    .write()
80                    .expect("expected to be able to get a write lock on the secrets");
81
82                *inner_secrets = secrets;
83
84                Response::builder()
85                    .status(StatusCode::OK)
86                    .body(String::new())
87            },
88            Err(err) => {
89                error!("failed to load secrets: {:?}", err);
90
91                Response::builder()
92                    .status(StatusCode::NOT_ACCEPTABLE)
93                    .body(format!("{:?}", err))
94            },
95        }
96    }
97
98    /// Handle an object received over the given path.
99    fn handle_impl(
100        &self,
101        path: &str,
102        headers: &HeaderMap,
103        data: &[u8],
104        object: Value,
105    ) -> http::Result<Response<()>> {
106        let config = self
107            .config
108            .read()
109            .expect("expected to be able to get a read lock on the configuration");
110
111        if let Some(handler) = config.post_paths.get(path) {
112            let secret = {
113                let secrets = self
114                    .secrets
115                    .read()
116                    .expect("expected to be able to get a read lock on the secrets");
117
118                handler.lookup_secret(&secrets, &object).map(String::from)
119            };
120
121            if !handler.verify(headers, secret.as_ref().map(AsRef::as_ref), data) {
122                error!(
123                    target: "handler",
124                    "failed to verify the a webhook:\nheaders:\n{:?}\ndata:\n{}",
125                    headers,
126                    String::from_utf8_lossy(data),
127                );
128
129                return Response::builder()
130                    .status(StatusCode::NOT_ACCEPTABLE)
131                    .body(());
132            }
133
134            if let Some(kind) = handler.kind(headers, &object) {
135                if let Err(err) = handler.write_object(&kind, object.clone()) {
136                    error!(
137                        target: "handler",
138                        "failed to write the {} object {}: {:?}",
139                        kind,
140                        object,
141                        err,
142                    );
143
144                    // TODO: Should this return 500 Internal Server Error?
145                }
146            }
147
148            Response::builder().status(StatusCode::ACCEPTED).body(())
149        } else {
150            Response::builder().status(StatusCode::NOT_FOUND).body(())
151        }
152    }
153
154    /// Handle an incoming HTTP request.
155    ///
156    /// This ends up deserializing the data as JSON if it validates. It is passed in as bytes to
157    /// avoid forcing deserialization on the caller.
158    pub fn handle(&self, req: &Request<Vec<u8>>) -> Result<Response<String>, http::Error> {
159        let path = req.uri().path();
160
161        if path.is_empty() {
162            return Response::builder()
163                .status(StatusCode::NOT_FOUND)
164                .body(String::new());
165        }
166
167        // Remove the leading slash.
168        let path = &path[1..];
169
170        debug!(
171            target: "handler",
172            "got a {} request at {}",
173            req.method(),
174            path,
175        );
176
177        Ok(match *req.method() {
178            Method::PUT => {
179                if path == "__reload" {
180                    self.reload()?
181                } else if path == "__reload_secrets" {
182                    self.reload_secrets()?
183                } else {
184                    Response::builder()
185                        .status(StatusCode::NOT_FOUND)
186                        .body(String::new())?
187                }
188            },
189            Method::POST => {
190                let data = req.body();
191
192                serde_json::from_slice(data)
193                    .map(|object| {
194                        self.handle_impl(path, req.headers(), data, object)
195                            .map(|rsp| rsp.map(|()| String::new()))
196                    })
197                    .unwrap_or_else(|err| {
198                        Response::builder()
199                            .status(StatusCode::BAD_REQUEST)
200                            .body(format!("{:?}", err))
201                    })?
202            },
203            _ => {
204                Response::builder()
205                    .status(StatusCode::METHOD_NOT_ALLOWED)
206                    .body(String::new())?
207            },
208        })
209    }
210}
211
212#[cfg(test)]
213mod test {
214    use std::ffi::OsStr;
215    use std::fs::{self, DirEntry, File, OpenOptions};
216    use std::path::Path;
217
218    use http::{Method, Request, StatusCode};
219    use serde_json::{json, Value};
220
221    use crate::router::Router;
222    use crate::test_utils;
223
224    fn create_router(path: &Path, config: Value, secrets: Value) -> Router {
225        let (config_path, _) = test_utils::write_config_secrets(path, config, secrets);
226        Router::new(config_path).unwrap()
227    }
228
229    fn hook_files(path: &Path) -> Vec<DirEntry> {
230        let current_dir = OsStr::new(".");
231        let parent_dir = OsStr::new("..");
232        fs::read_dir(path)
233            .unwrap()
234            .map(Result::unwrap)
235            .filter(|entry| {
236                let file_name = entry.file_name();
237                file_name != current_dir && file_name != parent_dir
238            })
239            .collect()
240    }
241
242    #[test]
243    fn test_reload() {
244        let tempdir = test_utils::create_tempdir();
245        let router = create_router(tempdir.path(), json!({}), json!({}));
246
247        // Check that the test endpoint doesn't exist.
248        {
249            let hook = json!({});
250            let req = Request::post("/test")
251                .body(serde_json::to_vec(&hook).unwrap())
252                .unwrap();
253            let rsp = router.handle(&req).unwrap();
254
255            assert_eq!(rsp.status(), StatusCode::NOT_FOUND);
256            assert_eq!(rsp.body(), "");
257        }
258
259        // Rewrite the configuration.
260        let test_path = tempdir.path().join("test");
261        fs::create_dir(&test_path).unwrap();
262        {
263            let mut fout = OpenOptions::new().write(true).open(&router.path).unwrap();
264            let config = json!({
265                "post_paths": {
266                    "test": {
267                        "path": test_path.to_str().unwrap(),
268                        "filters": [],
269                    },
270                },
271            });
272            serde_json::to_writer(&mut fout, &config).unwrap();
273        }
274
275        // Reload the configuration.
276        {
277            let req = Request::put("/__reload").body(Vec::new()).unwrap();
278            let rsp = router.handle(&req).unwrap();
279
280            assert_eq!(rsp.status(), StatusCode::OK);
281            assert_eq!(rsp.body(), "");
282        }
283
284        // Retry the endpoint with success.
285        {
286            let hook = json!({});
287            let req = Request::post("/test")
288                .body(serde_json::to_vec(&hook).unwrap())
289                .unwrap();
290            let rsp = router.handle(&req).unwrap();
291
292            assert_eq!(rsp.status(), StatusCode::ACCEPTED);
293            assert_eq!(rsp.body(), "");
294        }
295
296        // Without filters, the hook directory should be empty.
297        {
298            let hook_files = hook_files(&test_path);
299            assert!(hook_files.is_empty());
300        }
301    }
302
303    #[test]
304    fn test_reload_error() {
305        let router = {
306            let tempdir = test_utils::create_tempdir();
307            create_router(tempdir.path(), json!({}), json!({}))
308        };
309        let req = Request::put("/__reload").body(Vec::new()).unwrap();
310        let rsp = router.handle(&req).unwrap();
311
312        assert_eq!(rsp.status(), StatusCode::NOT_ACCEPTABLE);
313        assert!(
314            rsp.body().contains("Read {"),
315            "Error response did not match: {}",
316            rsp.body(),
317        );
318    }
319
320    #[test]
321    fn test_reload_broken_secrets() {
322        let tempdir = test_utils::create_tempdir();
323        let router = create_router(tempdir.path(), json!({}), json!({}));
324
325        // Rewrite the secrets.
326        {
327            let config = router
328                .config
329                .read()
330                .expect("expected to be able to get a read lock on the configuration");
331            let secrets_path = config.secrets_path().unwrap();
332            File::create(secrets_path).unwrap();
333        }
334
335        // Reload the configuration.
336        let req = Request::put("/__reload").body(Vec::new()).unwrap();
337        let rsp = router.handle(&req).unwrap();
338
339        assert_eq!(rsp.status(), StatusCode::NOT_ACCEPTABLE);
340        assert!(
341            rsp.body().contains("while parsing a value"),
342            "Error response did not match: {}",
343            rsp.body(),
344        );
345    }
346
347    #[test]
348    fn test_reload_secrets_error() {
349        let router = {
350            let tempdir = test_utils::create_tempdir();
351            create_router(tempdir.path(), json!({}), json!({}))
352        };
353        let req = Request::put("/__reload_secrets").body(Vec::new()).unwrap();
354        let rsp = router.handle(&req).unwrap();
355
356        assert_eq!(rsp.status(), StatusCode::NOT_ACCEPTABLE);
357        assert!(
358            rsp.body().contains("Read {"),
359            "Error response did not match: {}",
360            rsp.body(),
361        );
362    }
363
364    #[test]
365    fn test_invalid_put() {
366        let tempdir = test_utils::create_tempdir();
367        let router = create_router(tempdir.path(), json!({}), json!({}));
368        let req = Request::put("/not_an_endpoint").body(Vec::new()).unwrap();
369        let rsp = router.handle(&req).unwrap();
370
371        assert_eq!(rsp.status(), StatusCode::NOT_FOUND);
372        assert_eq!(rsp.body(), "");
373    }
374
375    #[test]
376    fn test_invalid_methods() {
377        let tempdir = test_utils::create_tempdir();
378        let router = create_router(tempdir.path(), json!({}), json!({}));
379        let invalid_methods = [
380            Method::GET,
381            // Method::POST,
382            // Method::PUT,
383            Method::DELETE,
384            Method::HEAD,
385            Method::OPTIONS,
386            Method::CONNECT,
387            Method::PATCH,
388            Method::TRACE,
389        ];
390
391        for invalid_method in invalid_methods.iter().cloned() {
392            let mut req = Request::new(Vec::new());
393            *req.method_mut() = invalid_method;
394            let rsp = router.handle(&req).unwrap();
395
396            assert_eq!(rsp.status(), StatusCode::METHOD_NOT_ALLOWED);
397            assert_eq!(rsp.body(), "");
398        }
399    }
400
401    #[test]
402    fn test_bad_hook() {
403        let tempdir = test_utils::create_tempdir();
404        let router = create_router(tempdir.path(), json!({}), json!({}));
405
406        let req = Request::post("/test").body(Vec::new()).unwrap();
407        let rsp = router.handle(&req).unwrap();
408
409        assert_eq!(rsp.status(), StatusCode::BAD_REQUEST);
410        assert!(
411            rsp.body().contains("while parsing a value"),
412            "Error response did not match: {}",
413            rsp.body(),
414        );
415    }
416
417    #[test]
418    fn test_write_hook_error() {
419        let tempdir = test_utils::create_tempdir();
420        let test_path = tempdir.path().join("test");
421        // Don't create the directory to cause the write error.
422        let config = json!({
423            "post_paths": {
424                "test": {
425                    "path": test_path.to_str().unwrap(),
426                    "filters": [
427                        {
428                            "kind": "unknown",
429                        },
430                    ],
431                },
432            },
433        });
434        let router = create_router(tempdir.path(), config, json!({}));
435
436        let hook = json!({});
437        let req = Request::post("/test")
438            .body(serde_json::to_vec(&hook).unwrap())
439            .unwrap();
440        let rsp = router.handle(&req).unwrap();
441
442        assert_eq!(rsp.status(), StatusCode::ACCEPTED);
443        assert_eq!(rsp.body(), "");
444    }
445
446    #[test]
447    fn test_hook() {
448        let tempdir = test_utils::create_tempdir();
449        let test_path = tempdir.path().join("test");
450        fs::create_dir(&test_path).unwrap();
451        let config = json!({
452            "post_paths": {
453                "test": {
454                    "path": test_path.to_str().unwrap(),
455                    "filters": [
456                        {
457                            "kind": "unknown",
458                        },
459                    ],
460                },
461            },
462        });
463        let router = create_router(tempdir.path(), config, json!({}));
464
465        let hook = json!({});
466        let req = Request::post("/test")
467            .body(serde_json::to_vec(&hook).unwrap())
468            .unwrap();
469        let rsp = router.handle(&req).unwrap();
470
471        assert_eq!(rsp.status(), StatusCode::ACCEPTED);
472        assert_eq!(rsp.body(), "");
473
474        let hook_files = hook_files(&test_path);
475        assert_eq!(hook_files.len(), 1);
476
477        let path = hook_files[0].path();
478        let hook_contents = fs::read_to_string(path).unwrap();
479        let actual: Value = serde_json::from_str(&hook_contents).unwrap();
480
481        assert_eq!(
482            actual,
483            json!({
484                "kind": "unknown",
485                "data": {},
486            }),
487        );
488    }
489
490    #[test]
491    fn test_unverified_hook() {
492        let tempdir = test_utils::create_tempdir();
493        let test_path = tempdir.path().join("test");
494        fs::create_dir(&test_path).unwrap();
495        let config = json!({
496            "post_paths": {
497                "test": {
498                    "path": test_path.to_str().unwrap(),
499                    "filters": [
500                        {
501                            "kind": "unknown",
502                        },
503                    ],
504                    "verification": {
505                        "secret_key_lookup": "/secret",
506                        "verification_header": "X-Verify-Webhook",
507                        "compare": {
508                            "type": "token",
509                        },
510                    },
511                },
512            },
513        });
514        let secrets = json!({
515            "secret": "secret",
516        });
517        let router = create_router(tempdir.path(), config, secrets);
518
519        let hook = json!({});
520        let req = Request::post("/test")
521            .body(serde_json::to_vec(&hook).unwrap())
522            .unwrap();
523        let rsp = router.handle(&req).unwrap();
524
525        assert_eq!(rsp.status(), StatusCode::NOT_ACCEPTABLE);
526        assert_eq!(rsp.body(), "");
527
528        // With verification failing, the hook directory should be empty.
529        let hook_files = hook_files(&test_path);
530        assert!(hook_files.is_empty());
531    }
532
533    #[test]
534    fn test_verified_hook() {
535        let tempdir = test_utils::create_tempdir();
536        let test_path = tempdir.path().join("test");
537        fs::create_dir(&test_path).unwrap();
538        let config = json!({
539            "post_paths": {
540                "test": {
541                    "path": test_path.to_str().unwrap(),
542                    "filters": [
543                        {
544                            "kind": "unknown",
545                        },
546                    ],
547                    "verification": {
548                        "secret_key_lookup": "secret",
549                        "verification_header": "X-Verify-Webhook",
550                        "compare": {
551                            "type": "token",
552                        },
553                    },
554                },
555            },
556        });
557        let secrets = json!({
558            "secret": "secret",
559        });
560        let router = create_router(tempdir.path(), config, secrets);
561
562        let hook = json!({});
563        let req = Request::post("/test")
564            .header("X-Verify-Webhook", "secret")
565            .body(serde_json::to_vec(&hook).unwrap())
566            .unwrap();
567        let rsp = router.handle(&req).unwrap();
568
569        assert_eq!(rsp.status(), StatusCode::ACCEPTED);
570        assert_eq!(rsp.body(), "");
571
572        let hook_files = hook_files(&test_path);
573        assert_eq!(hook_files.len(), 1);
574
575        let path = hook_files[0].path();
576        let hook_contents = fs::read_to_string(path).unwrap();
577        let actual: Value = serde_json::from_str(&hook_contents).unwrap();
578
579        assert_eq!(
580            actual,
581            json!({
582                "kind": "unknown",
583                "data": {},
584            }),
585        );
586    }
587}