From 703cd8f2369a63682452b3d40c2681a99e242014 Mon Sep 17 00:00:00 2001 From: Louis Vallat Date: Mon, 14 Feb 2022 19:16:41 +0100 Subject: [PATCH] Basic logic for TLSA auto updating, should work but untested. Signed-off-by: Louis Vallat --- Cargo.toml | 5 +- src/main.rs | 121 ++++++++++++++++++++++++++++++------------------- src/records.rs | 49 +++++++++++++++++++- src/utils.rs | 52 ++++++++++++++++++++- 4 files changed, 174 insertions(+), 53 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index e346613..5662ca3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,10 +6,9 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -inotify = "0.10" -hyper = { version = "0.14", features = ["full"] } +notify = "4.0.17" tokio = { version = "1", features = ["full"] } -tokio-test = "0.4.2" +hyper = { version = "0.14", features = ["full"] } hyper-tls = "0.5.0" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/src/main.rs b/src/main.rs index e8bbd70..3c3c8b0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,70 +1,97 @@ use hyper_tls::HttpsConnector; -use hyper::Client; +use hyper::{Client, client::HttpConnector}; use walkdir::WalkDir; -use crate::{utils::{OVHClient, get_delta}, records::{get_all_records_from_zone, refresh_zone, get_records_from_zone, Record}}; +use crate::{ + utils::{OVHClient, get_delta, get_subdomain_zone_domain_from_pem, compute_certificate}, + records::{get_all_records_from_zone, flush_tlsa_record_for_subdomain}}; use publicsuffix::List; -use std::fs; +use notify::{Watcher, RecursiveMode, RawEvent, raw_watcher, Op}; +use std::{sync::mpsc::{channel, Receiver}, env}; mod utils; mod records; -#[tokio::main] -async fn main() { - let client = Client::builder().build::<_, hyper::Body>(HttpsConnector::new()); - let list = List::fetch().unwrap(); +async fn scan_and_update_whole_folder(base_cert_dir: &str, list: &List, + ovh_client: &OVHClient, + client: &Client>) { let interesting_records = vec!["A", "AAAA", "MX", "CNAME"]; - - let base_cert_dir = "/etc/nginx/certs/"; - let mut ovh_client = OVHClient { - app_key: "".to_string(), - app_secret: "".to_string(), - consumer_key: "".to_string(), - endpoint: "https://eu.api.ovh.com/1.0".to_string(), - delta: 0 - }; - - ovh_client.delta = get_delta(&ovh_client, &client).await; - println!("Delta time is {}", ovh_client.delta); - println!("Sentinel started."); - for entry in WalkDir::new(base_cert_dir).into_iter().filter_map(|e| e.ok()) { if !entry.path().ends_with("cert.pem") { continue; } println!("Found certificate! Located at '{}'.", entry.path().display()); - let domain = entry.path().parent().unwrap() - .strip_prefix(base_cert_dir).unwrap().to_str().unwrap(); - let parsed_domain = list.parse_domain(domain).unwrap(); - let zone = parsed_domain.root().unwrap(); - let subdomain = domain.strip_suffix(zone).unwrap().strip_suffix(".").unwrap_or(""); + let (subdomain, zone, domain) = get_subdomain_zone_domain_from_pem(entry.path(), base_cert_dir, list); + let (subdomain, zone, domain) = (subdomain.as_str(), zone.as_str(), domain.as_str()); println!("Computing domain '{}', which has domain '{}' and subdomain '{}'.", domain, zone, subdomain); let records = get_all_records_from_zone(&ovh_client, &client, zone, subdomain) .await; if !records.iter().any(|r| interesting_records.contains(&r.field_type.as_str())) { - println!("\tDomain '{}' has no known interesting record. Skipping.", domain); - let tlsa_records_mx = get_records_from_zone(&ovh_client, &client, zone, "TLSA", format!("_587._tcp{}{}", if subdomain.is_empty() { "" } else { "." }, subdomain).as_str()).await; - let tlsa_records_site = get_records_from_zone(&ovh_client, &client, zone, "TLSA", format!("_443._tcp{}{}", if subdomain.is_empty() { "" } else { "." }, subdomain).as_str()).await; - println!("\tFound {} tlsa records associated with this domain.", tlsa_records_site.len() + tlsa_records_mx.len()); - // DELETE DANE RECORD IF FOUND ANY + println!("\tDomain '{}' has no known interesting record. Flushing.", domain); + flush_tlsa_record_for_subdomain(&ovh_client, &client, zone, subdomain).await; println!(); continue; } - let certificate = fs::read_to_string(entry.path().to_str().unwrap()).expect("Something went wrong reading the file"); - let x509cert = openssl::x509::X509::from_pem(certificate.as_bytes()).unwrap(); - if records.iter().any(|r| r.field_type == "A" || r.field_type == "AAAA" || r.field_type == "CNAME") { - println!("\tDomain '{}' is associated with website.", domain); - let tlsa_records = get_records_from_zone(&ovh_client, &client, zone, "TLSA", format!("_443._tcp{}{}", if subdomain.is_empty() { "" } else { "." }, subdomain).as_str()).await; - println!("\tFound {} tlsa records associated with domain '{}'.", tlsa_records.len(), format!("_443._tcp.{}", subdomain).as_str()); - // PUT IF NOT MATCHING - // POST IF NONE - } - if records.iter().any(|r| r.field_type == "MX" && r.sub_domain == subdomain) { - println!("\tDomain '{}' is associated with mail.", domain); - let tlsa_records = get_records_from_zone(&ovh_client, &client, zone, "TLSA", format!("_587._tcp{}{}", if subdomain.is_empty() { "" } else { "." }, subdomain).as_str()).await; - println!("\tFound {} tlsa records associated with this domain.", tlsa_records.len()); - // PUT IF NOT MATCHING - // POST IF NONE - } + compute_certificate(ovh_client, client, &entry.path(), base_cert_dir, list).await; println!(); } } +async fn watch_folder(base_cert_dir: &str, ovh_client: &OVHClient, list: &List, + client: &Client>, + rx: &Receiver) { + loop { + match rx.recv() { + Ok(RawEvent{path: Some(path), op: Ok(op), cookie: _c}) => { + if !path.ends_with("cert.pem") + || (op != Op::CLOSE_WRITE && op != Op::REMOVE) { continue; } + if op == Op::CLOSE_WRITE { + println!("Certificate '{}' modified or created. Updating.", path.display()); + compute_certificate(ovh_client, client, &path.as_path(), base_cert_dir, list).await; + } + if op == Op::REMOVE { + println!("Certificate '{}' deleted. Flushing.", path.display()); + let (subdomain, zone, _) = + get_subdomain_zone_domain_from_pem(&path.as_path(), base_cert_dir, list); + let (subdomain, zone) = (subdomain.as_str(), zone.as_str()); + flush_tlsa_record_for_subdomain(ovh_client, client, zone, + subdomain).await; + } + println!(); + }, + Ok(event) => println!("broken event: {:?}", event), + Err(e) => println!("watch error: {:?}", e), + } + } +} + +#[tokio::main] +async fn main() { + let client = Client::builder().build::<_, hyper::Body>(HttpsConnector::new()); + let list = List::fetch().unwrap(); + + let base_cert_dir = "/etc/nginx/certs/"; + let mut ovh_client = OVHClient { + app_key: env::var("OVH_APP_KEY") + .expect("Missing key value 'OVH_APP_KEY'."), + app_secret: env::var("OVH_APP_SECRET") + .expect("Missing key value 'OVH_APP_SECRET'."), + consumer_key: env::var("OVH_CONSUMER_KEY") + .expect("Missing key value 'OVH_CONSUMER_KEY'."), + endpoint: env::var("OVH_API_ENDPOINT") + .expect("Missing key value 'OVH_API_ENDPOINT'."), + delta: 0 + }; + + let (tx, rx) = channel(); + let mut watcher = raw_watcher(tx).unwrap(); + watcher.watch(base_cert_dir, RecursiveMode::Recursive).unwrap(); + ovh_client.delta = get_delta(&ovh_client, &client).await; + println!("Delta time is {}", ovh_client.delta); + println!("Starting initialization procedure."); + + scan_and_update_whole_folder(base_cert_dir, &list, &ovh_client, + &client).await; + + println!("Initializing sequence finished. Entering sentinel mode."); + watch_folder(base_cert_dir, &ovh_client, &list, &client, &rx).await; +} + diff --git a/src/records.rs b/src/records.rs index d425a26..176ebb1 100644 --- a/src/records.rs +++ b/src/records.rs @@ -2,7 +2,7 @@ use hyper::{Method, Client, client::HttpConnector}; use hyper_tls::HttpsConnector; use serde::{Deserialize, Serialize}; use serde_json::{from_str, json}; -use crate::utils::{OVHClient, build_request, body_to_str}; +use crate::utils::{OVHClient, build_request, body_to_str, get_tlsa_subdomain}; #[derive(Clone, Debug, Deserialize, Serialize)] pub struct Record { @@ -94,9 +94,54 @@ pub async fn delete_record_from_zone(ovh_client: &OVHClient, pub async fn refresh_zone(ovh_client: &OVHClient, client: &Client>, zone: &str) { - let req = build_request(ovh_client, &Method::DELETE, + let req = build_request(ovh_client, &Method::POST, format!("/domain/zone/{}/refresh", zone).as_str(), ""); let res = client.request(req).await.unwrap(); assert!(res.status().is_success()); } + +pub async fn flush_tlsa_record_for_subdomain(ovh_client: &OVHClient, + client: &Client>, + zone: &str, subdomain: &str) { + let mut tlsa = get_records_from_zone(ovh_client, client, zone, "TLSA", + get_tlsa_subdomain(subdomain, 25, "tcp") + .as_str()).await; + tlsa.append(&mut get_records_from_zone(ovh_client, client, zone, "TLSA", + get_tlsa_subdomain(subdomain, 443, "tcp") + .as_str()).await); + for record in tlsa { + delete_record_from_zone(ovh_client, client, zone, record.id).await; + } + refresh_zone(ovh_client, client, zone).await; +} + +pub async fn update_tlsa_for_subdomain(ovh_client: &OVHClient, + client: &Client>, + zone: &str, subdomain: &str, hash: &str, port: u32, protocol: &str) { + let mut tlsa = get_records_from_zone(ovh_client, client, zone, "TLSA", + get_tlsa_subdomain(subdomain, port, protocol) + .as_str()).await; + if tlsa.is_empty() { + add_record_to_zone(ovh_client, client, zone, &Record { + sub_domain: get_tlsa_subdomain(subdomain, port, protocol), + target: format!("3 1 1 {}", hash).to_string(), + field_type: "TLSA".to_string(), + ttl: 60, + id: 0 + }).await; + } else { + let (mut first, others) = tlsa.split_first_mut().unwrap(); + let target = format!("3 1 1 {}", hash).to_string(); + if first.target != target || first.ttl != 60 { + first.target = target; + first.ttl = 60; + update_record_in_zone(ovh_client, client, zone, first).await; + } + for record in others { + delete_record_from_zone(ovh_client, client, zone, record.id).await; + } + } + refresh_zone(ovh_client, client, zone).await; +} + diff --git a/src/utils.rs b/src/utils.rs index f8ae862..21b693e 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,6 +1,9 @@ -use std::time::{UNIX_EPOCH, SystemTime}; +use openssl::{x509::X509, hash::{hash, MessageDigest}}; +use publicsuffix::List; +use std::{fmt::Write, path::Path, time::{UNIX_EPOCH, SystemTime}}; use hyper_tls::HttpsConnector; use hyper::{Request, Method, Body, body, Client, client::HttpConnector}; +use crate::records::{get_all_records_from_zone, update_tlsa_for_subdomain}; pub struct OVHClient { pub app_key: String, @@ -14,6 +17,15 @@ pub async fn body_to_str(res: Body) -> String { return String::from_utf8(body::to_bytes(res).await.unwrap().to_vec()).unwrap(); } +pub fn get_subdomain_zone_domain_from_pem(pem: &Path, base_cert_dir: &str, list: &List) -> (String, String, String) { + let domain = pem.parent().unwrap() + .strip_prefix(base_cert_dir).unwrap().to_str().unwrap(); + let parsed_domain = list.parse_domain(domain).unwrap(); + let zone = parsed_domain.root().unwrap(); + let subdomain = domain.strip_suffix(zone).unwrap().strip_suffix(".").unwrap_or(""); + return (subdomain.to_string(), zone.to_string(), domain.to_string()); +} + pub fn get_signature(ovh_client: &OVHClient, method: &Method, query: &str, body: &str) -> String { let stringapi = format!("{}+{}+{}+{}+{}+{}", ovh_client.app_secret, ovh_client.consumer_key, method.as_str(), query, body, @@ -46,3 +58,41 @@ pub async fn get_delta(ovh_client: &OVHClient, client: &Client().unwrap(); return SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() as i64 - b; } + +pub fn get_hash_from_cert(path: &str) -> String { + let certificate = std::fs::read_to_string(path).expect("Something went wrong reading certificate file."); + let public_key = X509::from_pem(certificate.as_bytes()).unwrap().public_key().unwrap(); + let hashed = hash(MessageDigest::sha256(), &public_key.public_key_to_der().unwrap()).unwrap(); + let mut res = String::with_capacity(64); + for byte in &*hashed { + write!(&mut res, "{:02x}", byte).unwrap(); + } + return res; +} + +pub fn get_tlsa_subdomain(subdomain: &str, port: u32, protocol: &str) -> String { + return format!("_{}._{}{}{}", + port, protocol, + if subdomain.is_empty() { "" } else { "." }, + subdomain); +} + +pub async fn compute_certificate(ovh_client: &OVHClient, + client: &Client>, + path: &Path, base_cert_dir: &str, list: &List) { + let (subdomain, zone, domain) = get_subdomain_zone_domain_from_pem(path, base_cert_dir, list); + let (subdomain, zone, domain) = (subdomain.as_str(), zone.as_str(), domain.as_str()); + let records = get_all_records_from_zone(&ovh_client, &client, zone, subdomain).await; + let hash = get_hash_from_cert(path.to_str().unwrap()); + if records.iter().any(|r| r.field_type == "A" || r.field_type == "AAAA" + || r.field_type == "CNAME") { + println!("\tDomain '{}' is associated with website. Updating.", domain); + update_tlsa_for_subdomain(&ovh_client, &client, zone, subdomain, + hash.as_str(), 443, "tcp").await; + } + if records.iter().any(|r| r.field_type == "MX" && r.sub_domain == subdomain) { + println!("\tDomain '{}' is associated with mail. Updating.", domain); + update_tlsa_for_subdomain(&ovh_client, &client, zone, subdomain, + hash.as_str(), 25, "tcp").await; + } +}