1use std::{
2 io::{self, Read},
3 net::Ipv4Addr,
4 path::{Path, PathBuf},
5 sync::{Arc, Mutex},
6};
7
8use lazy_static::lazy_static;
9use serde::Serialize;
10
11pub fn parse_http_response_example(data: &str) -> http::Response<String> {
12 let mut lines = data.lines();
13
14 let status = {
15 let status_line = lines.next().unwrap().trim();
16 let (version, status) = status_line.split_once(' ').unwrap();
17
18 if version != "HTTP/1.1" {
19 panic!("Expected HTTP/1.1, got {version}");
20 }
21
22 let (code, _reason) = status.split_once(' ').unwrap();
23 http::StatusCode::from_u16(code.parse().expect("status code is u16"))
24 .expect("known status code")
25 };
26
27 let mut headers = http::HeaderMap::new();
28
29 for line in lines.by_ref() {
30 if line.is_empty() {
31 break;
32 } else {
33 let (name, value) = line
34 .trim()
35 .split_once(": ")
36 .expect("Header delimiter is ':'");
37 headers.append(
38 http::header::HeaderName::from_bytes(name.as_bytes()).unwrap(),
39 value.parse().expect("valid header value"),
40 );
41 }
42 }
43
44 let body: String = lines.collect();
45 let mut response = http::Response::new(body);
46 *response.headers_mut() = headers;
47 *response.status_mut() = status;
48 *response.version_mut() = http::Version::HTTP_11;
49 response
50}
51
52pub fn read_bytes<P: AsRef<Path>>(path: P) -> io::Result<Vec<u8>> {
53 let mut rdr = io::BufReader::new(std::fs::File::open(path)?);
54 let mut buf = Vec::new();
55 rdr.read_to_end(&mut buf)?;
56 Ok(buf)
57}
58
59pub fn read_string<P: AsRef<Path>>(path: P) -> io::Result<String> {
60 let mut rdr = io::BufReader::new(std::fs::File::open(path)?);
61 let mut buf = String::new();
62 rdr.read_to_string(&mut buf)?;
63 Ok(buf)
64}
65
66const PEBBLE_DIRECTORY: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../pebble/");
67
68lazy_static! {
69 static ref PEBBLE: Mutex<Arc<Pebble>> = Mutex::new(Arc::new(Pebble::create()));
70}
71
72#[derive(Debug)]
73pub struct Pebble {
74 directory: PathBuf,
75}
76
77impl Pebble {
78 #[allow(clippy::new_without_default)]
79
80 pub fn new() -> Arc<Self> {
81 let pebble = PEBBLE.lock().unwrap();
82 if Arc::strong_count(&pebble) == 1 {
83 pebble.start();
84 }
85 pebble.clone()
86 }
87
88 fn create() -> Self {
89 let pebble_directory = std::fs::canonicalize(PEBBLE_DIRECTORY).expect("valid pebble path");
90 Pebble {
91 directory: pebble_directory,
92 }
93 }
94
95 fn start(&self) {
96 let output = std::process::Command::new("docker")
97 .arg("compose")
98 .args([
99 "up",
100 "--detach",
101 "--remove-orphans",
102 "--renew-anon-volumes",
103 "--wait",
104 ])
105 .current_dir(&self.directory)
106 .output()
107 .expect("able to spawn docker compose command");
108
109 if !output.status.success() {
110 panic!("Failed to start a pebble server");
111 }
112 }
113
114 pub fn certificate(&self) -> reqwest::Certificate {
115 let cert = self.directory.join("pebble.minica.pem");
116
117 reqwest::Certificate::from_pem(&read_bytes(cert).unwrap()).expect("valid pebble root CA")
118 }
119
120 pub async fn dns_a(&self, host: &str, addresses: &[Ipv4Addr]) {
121 #[derive(Debug, Serialize)]
122 struct PebbleDNSRecord {
123 host: String,
124 addresses: Vec<String>,
125 }
126
127 let chall_setup = PebbleDNSRecord {
128 host: host.to_owned(),
129 addresses: addresses.iter().map(|ip| ip.to_string()).collect(),
130 };
131
132 let resp = reqwest::Client::new()
133 .post("http://localhost:8055/add-a")
134 .json(&chall_setup)
135 .send()
136 .await
137 .expect("connect to pebble");
138 match resp.error_for_status_ref() {
139 Ok(_) => {}
140 Err(_) => {
141 eprintln!("Request:");
142 eprintln!("{}", serde_json::to_string(&chall_setup).unwrap());
143 eprintln!("ERROR:");
144 eprintln!("Status: {:?}", resp.status().canonical_reason());
145 eprintln!(
146 "{}",
147 resp.text().await.expect("get response body from pebble")
148 );
149 panic!("Failed to update challenge server");
150 }
151 }
152 }
153
154 pub async fn dns01(&self, host: &str, value: &str) {
155 #[derive(Debug, Serialize)]
156 struct Dns01TXT {
157 host: String,
158 value: String,
159 }
160
161 let chall_setup = Dns01TXT {
162 host: host.to_owned(),
163 value: value.to_owned(),
164 };
165
166 tracing::trace!(
167 "Challenge Setup:\n{}",
168 serde_json::to_string(&chall_setup).unwrap()
169 );
170
171 let resp = reqwest::Client::new()
172 .post("http://localhost:8055/set-txt")
173 .json(&chall_setup)
174 .send()
175 .await
176 .expect("connect to pebble");
177 match resp.error_for_status_ref() {
178 Ok(_) => {}
179 Err(_) => {
180 eprintln!("Request:");
181 eprintln!("{}", serde_json::to_string(&chall_setup).unwrap());
182 eprintln!("ERROR:");
183 eprintln!("Status: {:?}", resp.status().canonical_reason());
184 eprintln!(
185 "{}",
186 resp.text().await.expect("get response body from pebble")
187 );
188 panic!("Failed to update challenge server");
189 }
190 }
191 }
192
193 pub async fn http01(&self, token: &str, content: &str) {
194 #[derive(Debug, Serialize)]
195 struct Http01ChallengeSetup {
196 token: String,
197 content: String,
198 }
199
200 let chall_setup = Http01ChallengeSetup {
201 token: token.to_owned(),
202 content: content.to_owned(),
203 };
204
205 tracing::trace!(
206 "Challenge Setup:\n{}",
207 serde_json::to_string(&chall_setup).unwrap()
208 );
209
210 let resp = reqwest::Client::new()
211 .post("http://localhost:8055/add-http01")
212 .json(&chall_setup)
213 .send()
214 .await
215 .expect("connect to pebble");
216 match resp.error_for_status_ref() {
217 Ok(_) => {}
218 Err(_) => {
219 eprintln!("Request:");
220 eprintln!("{}", serde_json::to_string(&chall_setup).unwrap());
221 eprintln!("ERROR:");
222 eprintln!("Status: {:?}", resp.status().canonical_reason());
223 eprintln!(
224 "{}",
225 resp.text().await.expect("get response body from pebble")
226 );
227 panic!("Failed to update challenge server");
228 }
229 }
230 }
231
232 pub fn down(self: &Arc<Self>) {
233 let pebble = PEBBLE.lock().unwrap();
234 if Arc::strong_count(self) == 2 {
235 self.down_internal();
236 }
237 drop(pebble)
238 }
239
240 fn down_internal(&self) {
241 let output = std::process::Command::new("docker")
242 .arg("compose")
243 .args(["down", "--remove-orphans", "--volumes", "--timeout", "10"])
244 .current_dir(&self.directory)
245 .output()
246 .expect("able to spawn docker compose command");
247
248 if !output.status.success() {
249 panic!("Failed to stop a pebble server");
250 }
251 }
252}