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

228 lines9,512 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Webhooks: every event, to your own addresses, signed and retried1//! 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
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API40/// 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`).
Webhooks: every event, to your own addresses, signed and retried42pub fn payload(event: &Event, workspace: &str, repo: Option<(&str, &str)>, actor_name: Option<&str>) -> Value {
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API43 g1t_kit::wire::snake_case(json!({
Webhooks: every event, to your own addresses, signed and retried44 "id": event.id,
45 "type": event.kind,
46 "time": event.time,
47 "workspace": workspace,
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API48 "repository": repo.map(|(id, full_name)| json!({ "id": id, "full_name": full_name })),
Webhooks: every event, to your own addresses, signed and retried49 "actor": event.actor.as_ref().map(|id| json!({ "id": id, "username": actor_name })),
50 "data": event.data,
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API51 }))
Webhooks: every event, to your own addresses, signed and retried52}
53
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look54/// The repository a repository event names, as `(namespace, name)`, where
55/// it is now: for when repos no longer shows it (it was deleted or purged).
56pub fn named_in(event: &Event) -> Option<(String, String)> {
57 let text = |key: &str| event.data[key].as_str().filter(|value| !value.is_empty()).map(str::to_owned);
58 match event.kind.as_str() {
59 "repo.renamed" => Some((text("namespace")?, text("to")?)),
60 "repo.transferred" => Some((text("to")?, text("name")?)),
61 kind if kind.starts_with("repo.") => Some((text("namespace")?, text("name")?)),
62 _ => None,
63 }
64}
65
Webhooks: every event, to your own addresses, signed and retried66/// What is sent to check a webhook works.
67pub fn ping(hook_id: &str, url: &str, events: &[String], time: &str) -> Value {
68 json!({
69 "type": "ping",
70 "time": time,
71 "hook": { "id": hook_id, "url": url, "events": events },
72 "message": "g1t will send this webhook's events here.",
73 })
74}
75
76/// The signature header's value: the body's HMAC-SHA256 under the secret.
77pub fn signature(secret: &str, body: &str) -> String {
78 format!("sha256={}", g1t_secrets::hmac_sha256_hex(secret, body))
79}
80
81/// Whether g1t may send to `url`: HTTPS, to a public host. `Err` says why
82/// not.
83pub fn check_url(url: &str) -> Result<(), String> {
84 let Some(rest) = url.strip_prefix("https://") else {
85 return Err("Webhooks are sent over HTTPS: the address must start with https://.".to_owned());
86 };
87 if url.len() > 2000 {
88 return Err("That address is too long.".to_owned());
89 }
90 let authority = rest.split(['/', '?', '#']).next().unwrap_or_default();
91 let host = authority.rsplit('@').next().unwrap_or_default();
92 let host = host.strip_prefix('[').map_or_else(
93 || host.split(':').next().unwrap_or_default(),
94 |v6| v6.split(']').next().unwrap_or_default(),
95 );
96 let host = host.to_ascii_lowercase();
97 if host.is_empty() || !host.contains(['.', ':']) {
98 return Err("The address needs a host g1t can reach on the internet.".to_owned());
99 }
100 let private_name = host == "localhost"
101 || [".localhost", ".local", ".internal", ".lan", ".home.arpa"]
102 .iter()
103 .any(|suffix| host.ends_with(suffix));
104 let private_ip = host.parse::<std::net::IpAddr>().is_ok_and(|ip| match ip {
105 std::net::IpAddr::V4(v4) => {
106 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])
107 }
108 std::net::IpAddr::V6(v6) => v6.is_loopback() || v6.is_unspecified() || (v6.segments()[0] & 0xfe00) == 0xfc00 || (v6.segments()[0] & 0xffc0) == 0xfe80,
109 });
110 if private_name || private_ip {
111 return Err("Webhooks go to public addresses, not private or local ones.".to_owned());
112 }
113 Ok(())
114}
115
116#[cfg(test)]
117mod tests {
118 use super::*;
119
120 #[test]
121 fn retries_wait_longer_each_time_then_stop() {
122 assert_eq!(retry_after(1), Some(60));
123 assert_eq!(retry_after(2), Some(300));
124 assert_eq!(retry_after(5), Some(18_000));
125 assert_eq!(retry_after(6), None);
126 assert_eq!(retry_after(0), None);
127 }
128
129 #[test]
130 fn events_are_checked_and_ordered() {
131 let given = vec!["pull.merged".to_owned(), "git.push".to_owned()];
132 assert_eq!(tidy_events(&given).unwrap(), vec!["git.push", "pull.merged"]);
133 assert_eq!(tidy_events(&[]).unwrap(), vec!["*"]);
134 assert!(tidy_events(&["pull.exploded".to_owned()]).is_err());
135 assert!(wants(&["*".to_owned()], "issue.opened"));
136 assert!(!wants(&["git.push".to_owned()], "issue.opened"));
137 }
138
139 #[test]
140 fn only_public_https_addresses_are_allowed() {
141 assert!(check_url("https://hooks.example.com/g1t").is_ok());
142 assert!(check_url("https://example.com:8443/hook?x=1").is_ok());
143 for url in [
144 "http://example.com/hook",
145 "https://localhost/hook",
146 "https://127.0.0.1/hook",
147 "https://10.0.0.5/hook",
148 "https://192.168.1.2:8080/hook",
149 "https://169.254.169.254/latest",
150 "https://100.64.0.1/hook",
151 "https://[::1]/hook",
152 "https://[fd00::1]/hook",
153 "https://printer.local/hook",
154 "https://intranet/hook",
155 "https://user@10.0.0.1/hook",
156 ] {
157 assert!(check_url(url).is_err(), "{url}");
158 }
159 }
160
161 #[test]
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API162 fn payloads_are_snake_case() {
163 let event = Event {
164 id: "evt_1".to_owned(),
165 kind: "pull.merged".to_owned(),
166 source: "work".to_owned(),
167 time: "2026-10-04T16:00:00Z".to_owned(),
168 repo_id: Some("rep_1".to_owned()),
169 actor: Some("usr_1".to_owned()),
170 data: json!({ "pullId": "pul_1", "repoId": "rep_1", "supersededBy": null, "inputs": { "dryRun": true } }),
171 };
172 let sent = payload(&event, "acme", Some(("rep_1", "acme/rocket")), Some("syntaqx"));
173 assert_eq!(sent["repository"]["full_name"], "acme/rocket");
174 assert_eq!(sent["data"]["pull_id"], "pul_1");
175 assert!(sent["data"].get("superseded_by").is_some());
176 assert_eq!(sent["data"]["inputs"]["dryRun"], true);
177 assert!(g1t_kit::wire::camel_case_keys(&sent).is_empty());
178 }
179
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look180 fn repo_event(kind: &str, data: Value) -> Event {
181 Event {
182 id: "evt_1".to_owned(),
183 kind: kind.to_owned(),
184 source: "repos".to_owned(),
185 time: "2026-10-05T16:00:00Z".to_owned(),
186 repo_id: Some("rep_1".to_owned()),
187 actor: None,
188 data,
189 }
190 }
191
192 #[test]
193 fn repository_events_name_where_it_is_now() {
194 let named = |kind: &str, data: Value| named_in(&repo_event(kind, data));
195 let at = |namespace: &str, name: &str| Some((namespace.to_owned(), name.to_owned()));
196 assert_eq!(named("repo.renamed", json!({ "namespace": "acme", "from": "old", "to": "new" })), at("acme", "new"));
197 assert_eq!(named("repo.transferred", json!({ "name": "web", "from": "a", "to": "b" })), at("b", "web"));
198 assert_eq!(named("repo.purged", json!({ "repoId": "rep_1", "namespace": "acme", "name": "web" })), at("acme", "web"));
199 assert_eq!(named("repo.deleted", json!({ "repoId": "rep_1", "namespace": "", "name": "web" })), None);
200 assert_eq!(named("issue.opened", json!({ "namespace": "acme", "name": "web" })), None);
201 }
202
203 #[test]
204 fn repository_lifecycle_events_can_be_chosen() {
205 for kind in [
206 "repo.updated",
207 "repo.visibility_changed",
208 "repo.renamed",
209 "repo.transferred",
210 "repo.archived",
211 "repo.unarchived",
212 "repo.deleted",
213 "repo.restored",
214 "repo.purged",
215 "repo.default_branch_changed",
216 "branch.renamed",
217 ] {
218 assert_eq!(tidy_events(&[kind.to_owned()]).unwrap(), vec![kind], "{kind}");
219 }
220 }
221
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API222 #[test]
Webhooks: every event, to your own addresses, signed and retried223 fn the_signature_is_the_bodys_hmac() {
224 let signature = signature("shh", "{\"a\":1}");
225 assert!(signature.starts_with("sha256="));
226 assert!(g1t_secrets::signed("shh", "{\"a\":1}", &signature));
227 }
228}