1#![doc = include_str!("../../../docs/operating/webhooks.md")]
52
53use std::io::Write;
54use std::net::{IpAddr, ToSocketAddrs};
55use std::path::{Path, PathBuf};
56use std::sync::atomic::{AtomicU64, Ordering};
57use std::sync::mpsc::{Receiver, RecvTimeoutError, SyncSender, TrySendError};
58use std::sync::Arc;
59use std::time::{Duration, SystemTime};
60
61pub const QUEUE_CAPACITY: usize = 256;
65
66const MAX_ATTEMPTS: u32 = 3;
68
69const RETRY_BACKOFF: Duration = Duration::from_millis(100);
73
74const IDLE_POLL: Duration = Duration::from_millis(200);
78
79const REQUEST_TIMEOUT_SECS: u64 = 10;
82
83#[derive(Debug, Clone)]
89pub struct RefEvent {
90 pub key: String,
94 pub old: Option<String>,
96 pub new: Option<String>,
98 pub seq: u64,
100 pub entry: String,
103 pub actor: String,
105 pub key_id: Option<String>,
107}
108
109impl RefEvent {
110 fn repo(&self) -> Option<&str> {
113 self.key.split_once(':').map(|(repo, _)| repo)
114 }
115
116 fn refname(&self) -> &str {
119 self.key
120 .split_once(':')
121 .map_or(self.key.as_str(), |(_, r)| r)
122 }
123
124 fn body(&self) -> String {
125 serde_json::json!({
126 "format_version": 1,
127 "event": "ref-landed",
128 "repo": self.repo(),
129 "ref": self.refname(),
130 "ref_key": self.key,
131 "old": self.old,
132 "new": self.new,
133 "seq": self.seq,
134 "entry": self.entry,
135 "actor": self.actor,
136 "key_id": self.key_id,
137 })
138 .to_string()
139 }
140}
141
142#[derive(Debug, Clone, PartialEq, Eq)]
144struct Subscription {
145 pattern: String,
147 url: String,
148 secret: String,
149 allow_private: bool,
150}
151
152impl Subscription {
153 fn matches(&self, key: &str) -> bool {
156 match self.pattern.strip_suffix('*') {
157 Some(prefix) => key.starts_with(prefix),
158 None => key == self.pattern,
159 }
160 }
161}
162
163fn parse_line(line: &str) -> Result<Subscription, String> {
165 let mut fields = line.split_whitespace();
166 let (Some(pattern), Some(url), Some(secret)) = (fields.next(), fields.next(), fields.next())
167 else {
168 return Err("each line is `<repo:refname> <url> <secret> [allow-private]`".to_string());
169 };
170 let mut allow_private = false;
171 for extra in fields {
172 match extra {
173 "allow-private" => allow_private = true,
174 other => return Err(format!("unknown option `{other}`")),
175 }
176 }
177 if secret.contains(['"', '\\']) || secret.chars().any(char::is_control) {
182 return Err("a secret may not contain a quote, a backslash or a control character".into());
183 }
184 if !url.starts_with("http://") && !url.starts_with("https://") {
185 return Err("a target url must start with http:// or https://".to_string());
186 }
187 Ok(Subscription {
188 pattern: pattern.to_string(),
189 url: url.to_string(),
190 secret: secret.to_string(),
191 allow_private,
192 })
193}
194
195fn load(path: &Path) -> Result<Vec<Subscription>, String> {
201 let text = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
202 let mut subscriptions = Vec::new();
203 for (number, line) in text.lines().enumerate() {
204 let line = line.trim();
205 if line.is_empty() || line.starts_with('#') {
206 continue;
207 }
208 subscriptions.push(parse_line(line).map_err(|e| format!("line {}: {e}", number + 1))?);
209 }
210 Ok(subscriptions)
211}
212
213#[derive(Debug, PartialEq, Eq)]
216struct Target {
217 host: String,
218 port: u16,
219 addr: IpAddr,
220 https: bool,
221}
222
223fn is_private_address(addr: IpAddr) -> bool {
230 match addr {
231 IpAddr::V4(v4) => {
232 let octets = v4.octets();
233 v4.is_loopback()
234 || v4.is_private()
235 || v4.is_link_local()
236 || v4.is_unspecified()
237 || v4.is_broadcast()
238 || v4.is_multicast()
239 || v4.is_documentation()
240 || (octets[0] == 100 && (64..128).contains(&octets[1]))
242 || octets[0] == 0
244 }
245 IpAddr::V6(v6) => {
246 if let Some(v4) = v6.to_ipv4_mapped() {
247 return is_private_address(IpAddr::V4(v4));
248 }
249 let segments = v6.segments();
250 v6.is_loopback()
251 || v6.is_unspecified()
252 || v6.is_multicast()
253 || segments[0] & 0xfe00 == 0xfc00
255 || segments[0] & 0xffc0 == 0xfe80
257 }
258 }
259}
260
261fn vet(url: &str, allow_private: bool) -> Result<Target, String> {
268 let (https, rest) = match url.split_once("://") {
269 Some(("https", rest)) => (true, rest),
270 Some(("http", rest)) => (false, rest),
271 _ => return Err("a target url must start with http:// or https://".to_string()),
272 };
273 let authority = rest
274 .split(['/', '?', '#'])
275 .next()
276 .unwrap_or_default()
277 .to_string();
278 if authority.is_empty() {
279 return Err("the target url names no host".to_string());
280 }
281 if authority.contains('@') {
285 return Err("a target url may not carry credentials".to_string());
286 }
287 let (host, port) = match authority.strip_prefix('[') {
288 Some(bracketed) => {
290 let (address, tail) = bracketed
291 .split_once(']')
292 .ok_or_else(|| "unterminated [ipv6] host".to_string())?;
293 let port = match tail.strip_prefix(':') {
294 Some(port) => port
295 .parse::<u16>()
296 .map_err(|_| "the target url has a bad port".to_string())?,
297 None => default_port(https),
298 };
299 (address.to_string(), port)
300 }
301 None => match authority.rsplit_once(':') {
302 Some((host, port)) => (
303 host.to_string(),
304 port.parse::<u16>()
305 .map_err(|_| "the target url has a bad port".to_string())?,
306 ),
307 None => (authority.clone(), default_port(https)),
308 },
309 };
310 if host.is_empty() {
311 return Err("the target url names no host".to_string());
312 }
313 let resolved: Vec<IpAddr> = (host.as_str(), port)
314 .to_socket_addrs()
315 .map_err(|e| format!("{host} does not resolve: {e}"))?
316 .map(|socket| socket.ip())
317 .collect();
318 let Some(addr) = resolved.first().copied() else {
319 return Err(format!("{host} resolves to no address"));
320 };
321 if !allow_private {
322 if let Some(refused) = resolved.iter().find(|ip| is_private_address(**ip)) {
323 return Err(format!(
324 "{host} resolves to {refused}, which is loopback, private, link-local or \
325 otherwise internal; add `allow-private` to this subscription if that is \
326 deliberate"
327 ));
328 }
329 }
330 if !https && !addr.is_loopback() {
333 return Err(format!(
334 "{host} is not loopback, so this subscription must use https: an http delivery \
335 carries the subscription secret in clear"
336 ));
337 }
338 Ok(Target {
339 host,
340 port,
341 addr,
342 https,
343 })
344}
345
346fn resolve_form(addr: IpAddr) -> String {
349 match addr {
350 IpAddr::V4(v4) => v4.to_string(),
351 IpAddr::V6(v6) => format!("[{v6}]"),
352 }
353}
354
355fn default_port(https: bool) -> u16 {
356 if https {
357 443
358 } else {
359 80
360 }
361}
362
363#[derive(Clone)]
366pub struct Hooks {
367 tx: SyncSender<RefEvent>,
368 dropped: Arc<AtomicU64>,
369}
370
371impl Hooks {
372 pub fn start(config: PathBuf, log: PathBuf) -> Result<Self, String> {
382 let subscriptions = load(&config)?;
383 let count = subscriptions.len();
384 let (tx, rx) = std::sync::mpsc::sync_channel(QUEUE_CAPACITY);
385 let dropped = Arc::new(AtomicU64::new(0));
386 let worker = Worker {
387 rx,
388 mtime: std::fs::metadata(&config).and_then(|m| m.modified()).ok(),
389 config,
390 subscriptions,
391 log,
392 dropped: Arc::clone(&dropped),
393 reported_drops: 0,
394 };
395 std::thread::Builder::new()
396 .name("choir-hooks".to_string())
397 .spawn(move || worker.run())
398 .map_err(|e| format!("could not start the webhook delivery thread: {e}"))?;
399 eprintln!("webhooks enabled ({count} subscriptions)");
400 Ok(Self { tx, dropped })
401 }
402
403 pub fn offer(&self, event: RefEvent) {
408 match self.tx.try_send(event) {
409 Ok(()) => {}
410 Err(TrySendError::Full(_) | TrySendError::Disconnected(_)) => {
411 self.dropped.fetch_add(1, Ordering::Relaxed);
412 }
413 }
414 }
415
416 #[must_use]
420 pub fn dropped(&self) -> u64 {
421 self.dropped.load(Ordering::Relaxed)
422 }
423}
424
425struct Worker {
426 rx: Receiver<RefEvent>,
427 config: PathBuf,
428 mtime: Option<SystemTime>,
429 subscriptions: Vec<Subscription>,
430 log: PathBuf,
431 dropped: Arc<AtomicU64>,
432 reported_drops: u64,
433}
434
435impl Worker {
436 fn run(mut self) {
437 loop {
438 match self.rx.recv_timeout(IDLE_POLL) {
439 Ok(event) => {
440 self.refresh();
441 self.dispatch(&event);
442 self.report_drops();
443 }
444 Err(RecvTimeoutError::Timeout) => self.report_drops(),
445 Err(RecvTimeoutError::Disconnected) => {
447 self.report_drops();
448 return;
449 }
450 }
451 }
452 }
453
454 fn refresh(&mut self) {
463 let mtime = std::fs::metadata(&self.config)
464 .and_then(|m| m.modified())
465 .ok();
466 if mtime.is_none() || mtime == self.mtime {
467 return;
468 }
469 self.mtime = mtime;
470 match load(&self.config) {
471 Ok(subscriptions) => {
472 eprintln!("webhooks: reloaded ({} subscriptions)", subscriptions.len());
473 self.subscriptions = subscriptions;
474 }
475 Err(e) => eprintln!("webhooks: file unusable, keeping previous: {e}"),
476 }
477 }
478
479 fn dispatch(&mut self, event: &RefEvent) {
480 let matched: Vec<Subscription> = self
481 .subscriptions
482 .iter()
483 .filter(|subscription| subscription.matches(&event.key))
484 .cloned()
485 .collect();
486 for subscription in matched {
487 self.deliver(event, &subscription);
488 }
489 }
490
491 fn deliver(&mut self, event: &RefEvent, subscription: &Subscription) {
492 let target = match vet(&subscription.url, subscription.allow_private) {
493 Ok(target) => target,
494 Err(reason) => {
495 self.record(serde_json::json!({
496 "format_version": 1,
497 "event": "refused",
498 "seq": event.seq,
499 "entry": event.entry,
500 "ref_key": event.key,
501 "url": subscription.url,
502 "reason": reason,
503 }));
504 return;
505 }
506 };
507 let body = event.body();
508 for attempt in 1..=MAX_ATTEMPTS {
509 let outcome = post(&target, subscription, &body, &self.log);
510 let delivered = matches!(&outcome, Ok(status) if (200..300).contains(status));
511 let record = match &outcome {
512 Ok(status) if (200..300).contains(status) => serde_json::json!({
513 "format_version": 1,
514 "event": "delivered",
515 "seq": event.seq,
516 "entry": event.entry,
517 "ref_key": event.key,
518 "url": subscription.url,
519 "attempt": attempt,
520 "status": status,
521 }),
522 Ok(status) => serde_json::json!({
523 "format_version": 1,
524 "event": "failed",
525 "seq": event.seq,
526 "entry": event.entry,
527 "ref_key": event.key,
528 "url": subscription.url,
529 "attempt": attempt,
530 "status": status,
531 "final": attempt == MAX_ATTEMPTS,
532 }),
533 Err(error) => serde_json::json!({
534 "format_version": 1,
535 "event": "failed",
536 "seq": event.seq,
537 "entry": event.entry,
538 "ref_key": event.key,
539 "url": subscription.url,
540 "attempt": attempt,
541 "error": error,
542 "final": attempt == MAX_ATTEMPTS,
543 }),
544 };
545 self.record(record);
546 self.report_drops();
552 if delivered {
553 return;
554 }
555 if attempt < MAX_ATTEMPTS {
556 std::thread::sleep(RETRY_BACKOFF * attempt);
557 }
558 }
559 }
560
561 fn report_drops(&mut self) {
568 let dropped = self.dropped.load(Ordering::Relaxed);
569 if dropped == self.reported_drops {
570 return;
571 }
572 let since = dropped - self.reported_drops;
573 self.reported_drops = dropped;
574 self.record(serde_json::json!({
575 "format_version": 1,
576 "event": "dropped",
577 "dropped": since,
578 "dropped_total": dropped,
579 "queue_capacity": QUEUE_CAPACITY,
580 }));
581 eprintln!(
582 "webhooks: dropped {since} event(s) with a full queue ({dropped} total); a receiver \
583 is slower than this node produces refs"
584 );
585 }
586
587 fn record(&self, value: serde_json::Value) {
588 if let Some(parent) = self.log.parent() {
589 let _ = std::fs::create_dir_all(parent);
590 }
591 let appended = std::fs::OpenOptions::new()
592 .create(true)
593 .append(true)
594 .open(&self.log)
595 .and_then(|mut file| writeln!(file, "{value}"));
596 if let Err(e) = appended {
597 eprintln!("webhooks: could not write {}: {e}", self.log.display());
601 }
602 }
603}
604
605fn post(
613 target: &Target,
614 subscription: &Subscription,
615 body: &str,
616 log: &Path,
617) -> Result<u16, String> {
618 let scratch = log.parent().unwrap_or_else(|| Path::new("."));
619 let body_path = scratch.join(format!("hook-delivery-{}.json", std::process::id()));
620 write_private(&body_path, body).map_err(|e| format!("could not stage the payload: {e}"))?;
621 let mut command = std::process::Command::new("curl");
622 command
623 .args([
624 "-s",
625 "-w",
626 "\n%{http_code}",
627 "-o",
628 "/dev/null",
629 "-X",
630 "POST",
631 "--max-redirs",
634 "0",
635 "--proto",
636 "=http,https",
637 "--max-time",
638 &REQUEST_TIMEOUT_SECS.to_string(),
639 "--resolve",
642 &format!(
643 "{}:{}:{}",
644 target.host,
645 target.port,
646 resolve_form(target.addr)
647 ),
648 "-H",
649 "Content-Type: application/json",
650 "--config",
651 "-",
652 "--data-binary",
653 ])
654 .arg(format!("@{}", body_path.display()))
655 .arg("--url")
656 .arg(&subscription.url)
657 .stdin(std::process::Stdio::piped())
658 .stdout(std::process::Stdio::piped())
659 .stderr(std::process::Stdio::null());
662 let result = run(&mut command, &subscription.secret);
663 let _ = std::fs::remove_file(&body_path);
664 result
665}
666
667fn run(command: &mut std::process::Command, secret: &str) -> Result<u16, String> {
668 let mut child = command
669 .spawn()
670 .map_err(|_| "could not start curl".to_string())?;
671 let config = format!("header = \"X-Choir-Hook-Secret: {secret}\"\n");
674 child
675 .stdin
676 .take()
677 .expect("piped curl stdin")
678 .write_all(config.as_bytes())
679 .map_err(|_| "could not configure curl".to_string())?;
680 let output = child
681 .wait_with_output()
682 .map_err(|_| "could not wait for curl".to_string())?;
683 if !output.status.success() {
684 return Err(format!(
685 "curl exited {}",
686 output.status.code().unwrap_or(-1)
687 ));
688 }
689 let text = String::from_utf8_lossy(&output.stdout);
690 let (_, status) = text
691 .rsplit_once('\n')
692 .ok_or_else(|| "curl returned no HTTP status".to_string())?;
693 status
694 .trim()
695 .parse::<u16>()
696 .map_err(|_| "curl returned an invalid HTTP status".to_string())
697}
698
699fn write_private(path: &Path, contents: &str) -> std::io::Result<()> {
702 std::fs::write(path, contents)?;
703 #[cfg(unix)]
704 {
705 use std::os::unix::fs::PermissionsExt;
706 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
707 }
708 Ok(())
709}
710
711#[cfg(test)]
712mod tests {
713 use super::*;
714
715 fn subscription(pattern: &str) -> Subscription {
716 Subscription {
717 pattern: pattern.to_string(),
718 url: "https://example.invalid/hook".to_string(),
719 secret: "s".to_string(),
720 allow_private: false,
721 }
722 }
723
724 #[test]
725 fn a_trailing_star_is_a_prefix_and_anything_else_is_exact() {
726 let exact = subscription("owner/repo:refs/heads/main");
727 assert!(exact.matches("owner/repo:refs/heads/main"));
728 assert!(!exact.matches("owner/repo:refs/heads/main-2"));
729 assert!(!exact.matches("other/repo:refs/heads/main"));
730
731 let prefix = subscription("owner/repo:refs/heads/*");
732 assert!(prefix.matches("owner/repo:refs/heads/main"));
733 assert!(prefix.matches("owner/repo:refs/heads/feature/x"));
734 assert!(!prefix.matches("owner/repo:refs/tags/v1"));
735 assert!(!prefix.matches("other/repo:refs/heads/main"));
736 }
737
738 #[test]
739 fn a_line_needs_a_pattern_a_url_and_a_secret() {
740 let parsed = parse_line("o/r:refs/heads/main https://example.invalid/h abc123").unwrap();
741 assert_eq!(parsed.url, "https://example.invalid/h");
742 assert_eq!(parsed.secret, "abc123");
743 assert!(!parsed.allow_private);
744
745 assert!(parse_line("o/r:refs/heads/main https://example.invalid/h").is_err());
746 assert!(parse_line("o/r:refs/heads/main ftp://example.invalid/h s").is_err());
747 assert!(parse_line("o/r:x https://example.invalid/h s allow-everything").is_err());
748 assert!(parse_line("o/r:x https://example.invalid/h se\"cret").is_err());
749 }
750
751 #[test]
752 fn allow_private_is_the_only_way_to_reach_an_internal_address() {
753 assert!(
754 parse_line("o/r:x http://localhost:1/h s allow-private")
755 .unwrap()
756 .allow_private
757 );
758 assert!(vet("http://127.0.0.1:9/hook", false).is_err());
759 assert!(vet("http://127.0.0.1:9/hook", true).is_ok());
760 }
761
762 #[test]
763 fn the_metadata_service_and_its_neighbours_are_internal() {
764 for address in [
766 "169.254.169.254", "127.0.0.1",
768 "10.0.0.1",
769 "192.168.1.1",
770 "172.16.0.1",
771 "100.64.0.1", "0.0.0.0",
773 "::1",
774 "fe80::1",
775 "fd00::1",
776 "::ffff:127.0.0.1", ] {
778 let addr: IpAddr = address.parse().expect("test address parses");
779 assert!(is_private_address(addr), "{address} should be internal");
780 }
781 for address in ["1.1.1.1", "93.184.216.34", "2606:4700:4700::1111"] {
782 let addr: IpAddr = address.parse().expect("test address parses");
783 assert!(!is_private_address(addr), "{address} should be reachable");
784 }
785 }
786
787 #[test]
788 fn a_non_loopback_target_may_not_carry_the_secret_in_clear() {
789 let refused = vet("http://10.0.0.1/hook", true).unwrap_err();
792 assert!(refused.contains("https"), "{refused}");
793 }
794
795 #[test]
796 fn a_url_is_split_into_a_host_and_a_port_before_it_is_vetted() {
797 let target = vet("http://127.0.0.1:8080/deep/path?query=1", true).unwrap();
798 assert_eq!(target.host, "127.0.0.1");
799 assert_eq!(target.port, 8080);
800 assert!(!target.https);
801
802 let bracketed = vet("http://[::1]:9000/hook", true).unwrap();
803 assert_eq!(bracketed.host, "::1");
804 assert_eq!(bracketed.port, 9000);
805
806 assert!(vet("https://user:pass@example.invalid/h", false).is_err());
807 assert!(vet("gopher://example.invalid/h", false).is_err());
808 }
809}