flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/services/webhooks/src/deliver.rs

174 lines7,210 bytesCodeBlame
1//! What a delivery is made of, apart from sending it: the payload, the
2//! signature, when to try again, and which addresses may be sent to.
3
4use g1t_contracts::events::Event;
5use g1t_contracts::webhooks::EVENT_TYPES;
6use serde_json::{Value, json};
7
8/// How long to wait after each failed attempt before the next, so a
9/// delivery gets six attempts over about seven and a half hours.
10pub const RETRY_WAITS_SECONDS: [u64; 5] = [60, 5 * 60, 30 * 60, 2 * 60 * 60, 5 * 60 * 60];
11
12/// When to try again after `attempts` attempts, in seconds from now, or
13/// `None` when it has had them all.
14pub fn retry_after(attempts: u32) -> Option<u64> {
15 RETRY_WAITS_SECONDS.get(attempts.checked_sub(1)? as usize).copied()
16}
17
18/// Whether a webhook that wants `wanted` is sent an event of type `kind`.
19pub fn wants(wanted: &[String], kind: &str) -> bool {
20 wanted.iter().any(|event| event == "*" || event == kind)
21}
22
23/// Event types as given, checked and in catalogue order. `["*"]` when none
24/// or all are given. `Err` names the first that is not one.
25pub fn tidy_events(given: &[String]) -> Result<Vec<String>, String> {
26 if given.is_empty() || given.iter().any(|event| event == "*") {
27 return Ok(vec!["*".to_owned()]);
28 }
29 if let Some(unknown) = given.iter().find(|event| !EVENT_TYPES.contains(&event.as_str())) {
30 return Err(format!("There is no event called {unknown}."));
31 }
32 Ok(EVENT_TYPES
33 .iter()
34 .filter(|kind| given.iter().any(|event| event == *kind))
35 .map(|kind| (*kind).to_owned())
36 .collect())
37}
38
39/// What is sent for an event: the event as the bus has it, with the
40/// repository, the workspace and whoever caused it named. Its keys are in
41/// `snake_case`, as everything g1t sends out is (see `g1t_kit::wire`).
42pub fn payload(event: &Event, workspace: &str, repo: Option<(&str, &str)>, actor_name: Option<&str>) -> Value {
43 g1t_kit::wire::snake_case(json!({
44 "id": event.id,
45 "type": event.kind,
46 "time": event.time,
47 "workspace": workspace,
48 "repository": repo.map(|(id, full_name)| json!({ "id": id, "full_name": full_name })),
49 "actor": event.actor.as_ref().map(|id| json!({ "id": id, "username": actor_name })),
50 "data": event.data,
51 }))
52}
53
54/// What is sent to check a webhook works.
55pub fn ping(hook_id: &str, url: &str, events: &[String], time: &str) -> Value {
56 json!({
57 "type": "ping",
58 "time": time,
59 "hook": { "id": hook_id, "url": url, "events": events },
60 "message": "g1t will send this webhook's events here.",
61 })
62}
63
64/// The signature header's value: the body's HMAC-SHA256 under the secret.
65pub fn signature(secret: &str, body: &str) -> String {
66 format!("sha256={}", g1t_secrets::hmac_sha256_hex(secret, body))
67}
68
69/// Whether g1t may send to `url`: HTTPS, to a public host. `Err` says why
70/// not.
71pub fn check_url(url: &str) -> Result<(), String> {
72 let Some(rest) = url.strip_prefix("https://") else {
73 return Err("Webhooks are sent over HTTPS: the address must start with https://.".to_owned());
74 };
75 if url.len() > 2000 {
76 return Err("That address is too long.".to_owned());
77 }
78 let authority = rest.split(['/', '?', '#']).next().unwrap_or_default();
79 let host = authority.rsplit('@').next().unwrap_or_default();
80 let host = host.strip_prefix('[').map_or_else(
81 || host.split(':').next().unwrap_or_default(),
82 |v6| v6.split(']').next().unwrap_or_default(),
83 );
84 let host = host.to_ascii_lowercase();
85 if host.is_empty() || !host.contains(['.', ':']) {
86 return Err("The address needs a host g1t can reach on the internet.".to_owned());
87 }
88 let private_name = host == "localhost"
89 || [".localhost", ".local", ".internal", ".lan", ".home.arpa"]
90 .iter()
91 .any(|suffix| host.ends_with(suffix));
92 let private_ip = host.parse::<std::net::IpAddr>().is_ok_and(|ip| match ip {
93 std::net::IpAddr::V4(v4) => {
94 v4.is_private() || v4.is_loopback() || v4.is_link_local() || v4.is_unspecified() || v4.is_broadcast() || v4.octets()[0] == 100 && (64..128).contains(&v4.octets()[1])
95 }
96 std::net::IpAddr::V6(v6) => v6.is_loopback() || v6.is_unspecified() || (v6.segments()[0] & 0xfe00) == 0xfc00 || (v6.segments()[0] & 0xffc0) == 0xfe80,
97 });
98 if private_name || private_ip {
99 return Err("Webhooks go to public addresses, not private or local ones.".to_owned());
100 }
101 Ok(())
102}
103
104#[cfg(test)]
105mod tests {
106 use super::*;
107
108 #[test]
109 fn retries_wait_longer_each_time_then_stop() {
110 assert_eq!(retry_after(1), Some(60));
111 assert_eq!(retry_after(2), Some(300));
112 assert_eq!(retry_after(5), Some(18_000));
113 assert_eq!(retry_after(6), None);
114 assert_eq!(retry_after(0), None);
115 }
116
117 #[test]
118 fn events_are_checked_and_ordered() {
119 let given = vec!["pull.merged".to_owned(), "git.push".to_owned()];
120 assert_eq!(tidy_events(&given).unwrap(), vec!["git.push", "pull.merged"]);
121 assert_eq!(tidy_events(&[]).unwrap(), vec!["*"]);
122 assert!(tidy_events(&["pull.exploded".to_owned()]).is_err());
123 assert!(wants(&["*".to_owned()], "issue.opened"));
124 assert!(!wants(&["git.push".to_owned()], "issue.opened"));
125 }
126
127 #[test]
128 fn only_public_https_addresses_are_allowed() {
129 assert!(check_url("https://hooks.example.com/g1t").is_ok());
130 assert!(check_url("https://example.com:8443/hook?x=1").is_ok());
131 for url in [
132 "http://example.com/hook",
133 "https://localhost/hook",
134 "https://127.0.0.1/hook",
135 "https://10.0.0.5/hook",
136 "https://192.168.1.2:8080/hook",
137 "https://169.254.169.254/latest",
138 "https://100.64.0.1/hook",
139 "https://[::1]/hook",
140 "https://[fd00::1]/hook",
141 "https://printer.local/hook",
142 "https://intranet/hook",
143 "https://user@10.0.0.1/hook",
144 ] {
145 assert!(check_url(url).is_err(), "{url}");
146 }
147 }
148
149 #[test]
150 fn payloads_are_snake_case() {
151 let event = Event {
152 id: "evt_1".to_owned(),
153 kind: "pull.merged".to_owned(),
154 source: "work".to_owned(),
155 time: "2026-10-04T16:00:00Z".to_owned(),
156 repo_id: Some("rep_1".to_owned()),
157 actor: Some("usr_1".to_owned()),
158 data: json!({ "pullId": "pul_1", "repoId": "rep_1", "supersededBy": null, "inputs": { "dryRun": true } }),
159 };
160 let sent = payload(&event, "acme", Some(("rep_1", "acme/rocket")), Some("syntaqx"));
161 assert_eq!(sent["repository"]["full_name"], "acme/rocket");
162 assert_eq!(sent["data"]["pull_id"], "pul_1");
163 assert!(sent["data"].get("superseded_by").is_some());
164 assert_eq!(sent["data"]["inputs"]["dryRun"], true);
165 assert!(g1t_kit::wire::camel_case_keys(&sent).is_empty());
166 }
167
168 #[test]
169 fn the_signature_is_the_bodys_hmac() {
170 let signature = signature("shh", "{\"a\":1}");
171 assert!(signature.starts_with("sha256="));
172 assert!(g1t_secrets::signed("shh", "{\"a\":1}", &signature));
173 }
174}