Merge branch 'malte/updater' into 'main'

Implement process to update certificates in the store

See merge request husk-project/husk-milter!11
This commit is contained in:
Malte Meiboom
2026-08-13 10:01:32 +00:00
18 changed files with 1085 additions and 96 deletions
Generated
+43 -3
View File
@@ -99,7 +99,7 @@ checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
dependencies = [ dependencies = [
"base64ct", "base64ct",
"blake2", "blake2",
"cpufeatures", "cpufeatures 0.2.17",
"password-hash", "password-hash",
] ]
@@ -333,6 +333,17 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "chacha20"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"rand_core 0.10.1",
]
[[package]] [[package]]
name = "chrono" name = "chrono"
version = "0.4.45" version = "0.4.45"
@@ -447,6 +458,15 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "cpufeatures"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "crc32fast" name = "crc32fast"
version = "1.5.0" version = "1.5.0"
@@ -1055,6 +1075,7 @@ dependencies = [
"cfg-if", "cfg-if",
"libc", "libc",
"r-efi 6.0.0", "r-efi 6.0.0",
"rand_core 0.10.1",
] ]
[[package]] [[package]]
@@ -1270,6 +1291,8 @@ dependencies = [
"lettre", "lettre",
"log", "log",
"log4rs", "log4rs",
"rand 0.10.2",
"reqwest",
"sequoia-cert-store", "sequoia-cert-store",
"sequoia-directories", "sequoia-directories",
"sequoia-keystore", "sequoia-keystore",
@@ -1614,7 +1637,7 @@ version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653"
dependencies = [ dependencies = [
"cpufeatures", "cpufeatures 0.2.17",
] ]
[[package]] [[package]]
@@ -2290,6 +2313,17 @@ dependencies = [
"rand_core 0.9.5", "rand_core 0.9.5",
] ]
[[package]]
name = "rand"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
dependencies = [
"chacha20",
"getrandom 0.4.3",
"rand_core 0.10.1",
]
[[package]] [[package]]
name = "rand_chacha" name = "rand_chacha"
version = "0.3.1" version = "0.3.1"
@@ -2328,6 +2362,12 @@ dependencies = [
"getrandom 0.3.4", "getrandom 0.3.4",
] ]
[[package]]
name = "rand_core"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]] [[package]]
name = "rayon" name = "rayon"
version = "1.12.0" version = "1.12.0"
@@ -2940,7 +2980,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"cpufeatures", "cpufeatures 0.2.17",
"digest", "digest",
] ]
+3
View File
@@ -21,6 +21,9 @@ indymilter = "0.3.0"
lettre = "0.11.22" lettre = "0.11.22"
log = "0.4.30" log = "0.4.30"
log4rs = "1.3.0" log4rs = "1.3.0"
rand = "0.10.2"
# the reqwest version has to be the same sequoia-net reexports!
reqwest = { version = "0.12", features = ["socks"] }
sequoia-cert-store = "0.7.3" sequoia-cert-store = "0.7.3"
sequoia-directories = "0.1.0" sequoia-directories = "0.1.0"
sequoia-keystore = "0.7.2" sequoia-keystore = "0.7.2"
+49 -2
View File
@@ -1,13 +1,60 @@
# #
# Husk config file # Husk config file
# #
connection = "localhost:3000"
# The directory for anything Sequoia PGP related (keystore, certstore).
#
# default: /var/lib/husk
sequoia_home = "/tmp/sq_home" sequoia_home = "/tmp/sq_home"
#keyservers = [ "hkps://keys.openpgp.org/", "hkps://mail-api.proton.me", "hkps://keys.mailvelope.com", "hkps://keyserver.ubuntu.com", "hkps://sks.pod01.fleetstreetops.com" ] [milter]
# IP and port the milter will listen on.
#
# no default, must be specified.
connection = "localhost:3000"
# Keyservers to use for key lookup.
#
# Example:
# keyservers = [ "hkps://keys.openpgp.org/", "hkps://mail-api.proton.me", "hkps://keys.mailvelope.com", "hkps://keyserver.ubuntu.com", "hkps://sks.pod01.fleetstreetops.com" ]
#
# The default is the empty list, which prevents keyserver lookups.
keyservers = [ "hkp://example.com" ] keyservers = [ "hkp://example.com" ]
# Location of the pid file of the milter.
#
# default: /var/lib/husk/husk.pid
# Subject header lines are protected (exchanged).
#
# Specify your replacement string:
# subject_replacement = "Encrypted message"
#
# default: "..."
# Tor usage: If configured, the milter will use Tor via the specified
# sock5 proxy.
#
# use_tor_via = "socks5://localhost:9050/"
#
# default: unconfigured, meaning: no usage of Tor.
[updater]
# The interval between two update requests for a certificate are
# random.
#
# wait_min and wait_max specify the range for that randomness.
# defaults:
# wait_min = 5
# wait_max = 3600
wait_min = 1
wait_max = 2
# Tor usage of the updater process. Semantics (and default) are the same
# as with the milter.
use_tor_via = "socks5://localhost:9050/"
[logging.appenders.console] [logging.appenders.console]
kind = "console" kind = "console"
[logging.appenders.console.encoder] [logging.appenders.console.encoder]
-16
View File
@@ -1,16 +0,0 @@
appenders:
stdout_appender:
kind: console
encoder:
pattern: "{h({d(%Y-%m-%d %H:%M:%S)(utc)} - {l}: {m}{n})}"
file_appender:
kind: file
path: "/tmp/husk.log"
encoder:
pattern: "{h({d(%Y-%m-%d %H:%M:%S)(utc)} - {l}: {m}{n})}"
root:
level: debug
appenders:
- stdout_appender
+2 -1
View File
@@ -61,7 +61,8 @@ RUN apt-get update -yqq && \
sq \ sq \
bind9-host \ bind9-host \
inotify-tools \ inotify-tools \
mailutils mailutils \
tor
RUN adduser --shell /bin/bash --disabled-password --gecos "" hockeypuck RUN adduser --shell /bin/bash --disabled-password --gecos "" hockeypuck
+1 -1
View File
@@ -12,7 +12,7 @@ authenticate $KARL_FPR
create_unix_user laura create_unix_user laura
echo -e "To: karl@example.com, Laura <laura@example.com>\nSubject: Testmail\n\nTestmail." | sendmail -t echo -e "To: karl@example.com, Laura <laura@example.com>\nSubject: Testmail\n\nTestmail." | sendmail -t
sleep 1 sleep 2
R=1 R=1
if [ $(expect_mail karl encrypted) == 0 ] ; then if [ $(expect_mail karl encrypted) == 0 ] ; then
+37
View File
@@ -34,6 +34,7 @@ pub enum HuskSubcommands {
Introducer(IntroducerCommand), Introducer(IntroducerCommand),
Locals(LocalsCommand), Locals(LocalsCommand),
Signkey(SignkeyCommand), Signkey(SignkeyCommand),
Updater(UpdaterCommand),
} }
// Daemon subcommands // Daemon subcommands
@@ -72,6 +73,42 @@ pub struct DaemonStartCommand {
background: bool, background: bool,
} }
// Updater subcommands
#[derive(Parser, Debug)]
#[clap(
name = "updater",
about = "Manage the updater process.",
subcommand_required = true,
arg_required_else_help = true,
disable_colored_help = true,
disable_version_flag = true,
)]
pub struct UpdaterCommand {
#[clap(subcommand)]
pub subcommand: UpdaterSubcommand,
}
#[derive(Debug, Subcommand)]
pub enum UpdaterSubcommand {
Start(UpdaterStartCommand),
Stop,
Status
}
#[derive(Parser, Debug)]
#[clap(
name = "daemon",
about = "Start the updater.",
)]
pub struct UpdaterStartCommand {
#[clap(
long = "background",
help = "Start the updater as a background process.",
default_value_t = false,
)]
background: bool,
}
// Introducer subcommands // Introducer subcommands
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
+4
View File
@@ -7,6 +7,7 @@ use crate::cli::cli_args::{CliArgs, HuskSubcommands};
use crate::config::HuskConfigContainer; use crate::config::HuskConfigContainer;
pub mod daemon; pub mod daemon;
pub mod updater;
pub mod introducer; pub mod introducer;
pub mod locals; pub mod locals;
pub mod signkey; pub mod signkey;
@@ -48,6 +49,9 @@ pub async fn dispatch(cli: CliArgs, config: HuskConfigContainer) -> Result<()> {
HuskSubcommands::Signkey(subcmd) => { HuskSubcommands::Signkey(subcmd) => {
commands::signkey::dispatch(subcmd, config).await?; commands::signkey::dispatch(subcmd, config).await?;
} }
HuskSubcommands::Updater(subcmd) => {
commands::updater::dispatch(subcmd, config).await?;
}
} }
Ok(()) Ok(())
+1 -1
View File
@@ -4,7 +4,7 @@ use crate::cli::cli_args::{DaemonCommand, DaemonSubcommand};
use crate::common::process_descr::ProcessDescr; use crate::common::process_descr::ProcessDescr;
use crate::config::HuskConfigContainer; use crate::config::HuskConfigContainer;
use crate::config::HuskConfig; use crate::config::HuskConfig;
use crate::Daemon; use crate::daemon::Daemon;
pub async fn dispatch(cmd: DaemonCommand, config: HuskConfigContainer) -> Result<()> { pub async fn dispatch(cmd: DaemonCommand, config: HuskConfigContainer) -> Result<()> {
+49
View File
@@ -0,0 +1,49 @@
//! Updater first level dispatcher
use anyhow::Result;
use crate::cli::cli_args::{UpdaterCommand, UpdaterSubcommand};
use crate::common::process_descr::ProcessDescr;
use crate::config::HuskConfigContainer;
use crate::config::HuskConfig;
use crate::updater::Updater;
pub async fn dispatch(cmd: UpdaterCommand, config: HuskConfigContainer) -> Result<()> {
let c: HuskConfig = config.clone().into();
match cmd.subcommand {
UpdaterSubcommand::Start(_sub_cmd) => {
// Write the pid file.
let pid_file_name = c.get_updater_pid_file();
ProcessDescr::create(pid_file_name.clone())?;
let result_code = Updater::run(config).await;
// and remove it when the daemon terminated.
let _ = ProcessDescr::remove(pid_file_name);
return result_code;
},
UpdaterSubcommand::Stop => {
let process_descr = ProcessDescr::load(c.get_updater_pid_file())?;
if process_descr.send_sig_interrupt().is_ok() {
let _ = ProcessDescr::remove(process_descr.file_location);
}
},
UpdaterSubcommand::Status => {
if let Ok(process_descr) = ProcessDescr::load(c.get_updater_pid_file()) {
if process_descr.is_running() {
println!("Running, with pid: {}", process_descr.pid);
} else {
println!("Not running, but stale pid file detected: {}",
c.get_updater_pid_file());
}
} else {
println!("Not running");
}
}
}
Ok(())
}
+120 -41
View File
@@ -22,6 +22,7 @@ use sequoia_openpgp::packet::prelude::SignatureBuilder;
use sequoia_openpgp::parse::{PacketParser, PacketParserResult, Parse}; use sequoia_openpgp::parse::{PacketParser, PacketParserResult, Parse};
use sequoia_openpgp::policy::StandardPolicy; use sequoia_openpgp::policy::StandardPolicy;
use sequoia_openpgp::serialize::stream::{Message, Armorer, Encryptor, Signer, LiteralWriter}; use sequoia_openpgp::serialize::stream::{Message, Armorer, Encryptor, Signer, LiteralWriter};
use sequoia_openpgp::serialize::stream::padding::Padder;
use sequoia_openpgp::{Fingerprint, Cert, Packet}; use sequoia_openpgp::{Fingerprint, Cert, Packet};
use sequoia_openpgp::types::SignatureType; use sequoia_openpgp::types::SignatureType;
use sequoia_cert_store::{Store, CertStore, LazyCert, StoreUpdate}; use sequoia_cert_store::{Store, CertStore, LazyCert, StoreUpdate};
@@ -30,6 +31,7 @@ use sequoia_wot::{self as wot, CertSynopsis};
use wot::store::Store as _; use wot::store::Store as _;
use wot::{Depth, Path}; use wot::{Depth, Path};
use crate::config::TorConnectorType;
use crate::types::errors::HuskError; use crate::types::errors::HuskError;
use crate::types::husk_context::HuskContext; use crate::types::husk_context::HuskContext;
use crate::types::introducer::Introducer; use crate::types::introducer::Introducer;
@@ -96,40 +98,80 @@ const USER_AGENT: &'static str = concat!("husk/", env!("CARGO_PKG_VERSION"));
const CONNECT_TIMEOUT: Duration = Duration::new(5, 0); const CONNECT_TIMEOUT: Duration = Duration::new(5, 0);
const REQUEST_TIMEOUT: Duration = Duration::new(5, 0); const REQUEST_TIMEOUT: Duration = Duration::new(5, 0);
/// Try to fetch certificates for `email` from online sources (keyservers, wkd, #[derive(Debug, Clone)]
pub enum OnlineQuery {
Fingerprint(Fingerprint),
Email(String),
}
/// Try to fetch certificates for email from online sources (keyservers, wkd,
/// dane). /// dane).
pub async fn lookup_certificates<'hc>(context: &HuskContext<'hc>, email: &str) pub async fn lookup_email<'hc>(context: &HuskContext<'hc>, email: &str)
-> anyhow::Result<Vec<Cert>> {
query_online_sources(context, OnlineQuery::Email(email.into())).await
}
/// Try to fetch certificates for fingerprint from keyservers. wkd
/// and dane can only be queried for email addresses.
pub async fn lookup_fingerprint<'hc>(context: &HuskContext<'hc>, fpr: &str)
-> anyhow::Result<Vec<Cert>> {
let fpr = fpr.parse::<Fingerprint>()?;
query_online_sources(context, OnlineQuery::Fingerprint(fpr)).await
}
/// Try to fetch certificates for query from online sources (keyservers, wkd,
/// dane).
async fn query_online_sources<'hc>(context: &HuskContext<'hc>, query: OnlineQuery)
-> anyhow::Result<Vec<Cert>> { -> anyhow::Result<Vec<Cert>> {
log::debug!("remote lookup for {}", email); log::debug!("remote lookup for {:?}", query);
let mut jobs = JoinSet::new(); let mut jobs = JoinSet::new();
let http_client = sequoia_net::reqwest::Client::builder() let mut http_client_builder = sequoia_net::reqwest::Client::builder()
.user_agent(USER_AGENT) .user_agent(USER_AGENT)
.connect_timeout(CONNECT_TIMEOUT) .connect_timeout(CONNECT_TIMEOUT)
.timeout(REQUEST_TIMEOUT) .timeout(REQUEST_TIMEOUT);
.build()?;
if let TorConnectorType::SocksProxy(socks_url) = &context.use_tor_via {
// Configure the use of a tor proxy.
let mut nonce: [u8; 4] = [0; 4];
sequoia_openpgp::crypto::random(&mut nonce[..])?;
let nonce: String = sequoia_openpgp::fmt::hex::encode(&nonce);
let url: String = format!("socks5h://anonymous:{}@{}", nonce, socks_url);
http_client_builder = http_client_builder
.proxy(sequoia_net::reqwest::Proxy::all(url)?);
}
let http_client = http_client_builder.build()?;
// local function to check certificates for usability // local function to check certificates for usability
let collect_certs = |certs: Vec<Result<Cert, anyhow::Error>>, email: String, policy: &StandardPolicy| { let collect_certs = |certs: Vec<Result<Cert, anyhow::Error>>, query: OnlineQuery, policy: &StandardPolicy| {
let mut result = Vec::new(); let mut result = Vec::new();
let mut seen: HashSet<Fingerprint> = HashSet::new(); let mut seen: HashSet<Fingerprint> = HashSet::new();
for cert in certs.into_iter().flatten() { for cert in certs.into_iter().flatten() {
if let Ok(vc) = cert.with_policy(policy, None) { if let Ok(vc) = cert.with_policy(policy, None) {
// check if the returned certificate contains a userid with the let mut found = false;
// email address in question. match query.clone() {
let mut userid_found = false; OnlineQuery::Email(email) => {
for userid in vc.userids() { // check if the returned certificate contains a userid with the
if let Ok(Some(u)) = userid.userid().email() { // email address in question.
if u == email { for userid in vc.userids() {
userid_found = true; if let Ok(Some(u)) = userid.userid().email() {
if u == email {
found = true;
}
}
} }
},
OnlineQuery::Fingerprint(f) => {
found = cert.fingerprint() == f;
} }
} }
if userid_found { if found {
// check if the certificate can be used (for encryption). // check if the certificate can be used (for encryption).
vc.keys() vc.keys()
.supported() .supported()
@@ -156,43 +198,72 @@ pub async fn lookup_certificates<'hc>(context: &HuskContext<'hc>, email: &str)
if let Ok(ks) = KeyServer::with_client(keyserver.as_str(), http_client.clone()) { if let Ok(ks) = KeyServer::with_client(keyserver.as_str(), http_client.clone()) {
let e = email.to_string();
let p = StandardPolicy::new(); let p = StandardPolicy::new();
let query = query.clone();
jobs.spawn(async move { jobs.spawn(async move {
let mut partial_result = Vec::new(); let mut partial_result = Vec::new();
if let Ok(certs) = ks.search(e.as_str()).await { let q = query.clone();
partial_result = collect_certs(certs, e, &p); match query {
OnlineQuery::Email(e) => {
match ks.search(e.as_str()).await {
Ok(certs) => {
partial_result = collect_certs(certs, q, &p);
}
Err(err) => {
log::debug!("Error: {} {}", e, err.to_string());
}
}
/*
if let Ok(certs) = ks.search(e.as_str()).await {
partial_result = collect_certs(certs, q, &p);
}
*/
partial_result
},
OnlineQuery::Fingerprint(f) => {
if let Ok(certs) = ks.get(f).await {
partial_result = collect_certs(certs, q, &p);
}
partial_result
},
} }
partial_result
}); });
} }
} }
// WKD // WKD and DANE need an email address
let e = email.to_string(); if let OnlineQuery::Email(_) = query {
let p = StandardPolicy::new(); let p = StandardPolicy::new();
jobs.spawn(async move { let q = query.clone();
let mut partial_result = Vec::new();
if let Ok(certs) = wkd::get(&http_client.clone(), e.as_str()).await { // WKD
partial_result = collect_certs(certs, e, &p); jobs.spawn(async move {
} let mut partial_result = Vec::new();
partial_result
});
// DANE if let OnlineQuery::Email(e) = q.clone() {
let e = email.to_string(); if let Ok(certs) = wkd::get(&http_client.clone(), e.as_str()).await {
let p = StandardPolicy::new(); partial_result = collect_certs(certs, q, &p);
jobs.spawn(async move { }
let mut partial_result = Vec::new(); }
partial_result
});
if let Ok(certs) = dane::get(e.as_str()).await { // DANE
partial_result = collect_certs(certs, e, &p); let p = StandardPolicy::new();
} let q = query.clone();
partial_result jobs.spawn(async move {
}); let mut partial_result = Vec::new();
if let OnlineQuery::Email(e) = q {
if let Ok(certs) = dane::get(e.as_str()).await {
partial_result = collect_certs(certs, query, &p);
}
}
partial_result
});
}
// join the partial results. As the same certificate might be returned by // join the partial results. As the same certificate might be returned by
// several sources, dedup and merge the certificates. // several sources, dedup and merge the certificates.
@@ -240,7 +311,7 @@ pub async fn get_certificates<'hc>(context: &HuskContext<'hc>, email: &str)
introducer.cert.self_signed_userids().map(|u| u.userid()).collect::<Vec<_>>()); introducer.cert.self_signed_userids().map(|u| u.userid()).collect::<Vec<_>>());
if introducer.can_introduce(format!("<{}>", email).as_str()) { if introducer.can_introduce(format!("<{}>", email).as_str()) {
log::debug!(" introducer regex matches!"); log::debug!(" introducer regex matches!");
match lookup_certificates(context, email).await { match lookup_email(context, email).await {
Ok(certs) => { Ok(certs) => {
for cert in certs { for cert in certs {
if introducer.is_introducing(&cert) { if introducer.is_introducing(&cert) {
@@ -412,9 +483,14 @@ pub fn is_encrypted(body: &Bytes) -> bool {
pub fn encrypt(context: &HuskContext<'_>, body: &[u8], recipients: &Vec<Recipient>) pub fn encrypt(context: &HuskContext<'_>, body: &[u8], recipients: &Vec<Recipient>)
-> anyhow::Result<Bytes> { -> anyhow::Result<Bytes> {
log::debug!("encrypt for {}", recipients.iter()
.map(|r| r.email.clone())
.collect::<Vec<String>>()
.join(", "));
let mut keys = Vec::new(); let mut keys = Vec::new();
for rcpt in recipients { for rcpt in recipients {
log::debug!("Check recipient {}", rcpt.email);
if rcpt.can_encrypt() { if rcpt.can_encrypt() {
for cert in &rcpt.certs { for cert in &rcpt.certs {
cert.with_policy(&context.policy, None)? cert.with_policy(&context.policy, None)?
@@ -434,6 +510,7 @@ pub fn encrypt(context: &HuskContext<'_>, body: &[u8], recipients: &Vec<Recipien
let message = Message::new(&mut sink); let message = Message::new(&mut sink);
let message = Armorer::new(message).build()?; let message = Armorer::new(message).build()?;
let mut message = Encryptor::for_recipients(message, keys).build()?; let mut message = Encryptor::for_recipients(message, keys).build()?;
message = Padder::new(message).build()?;
if let Some(key) = &context.signing_key { if let Some(key) = &context.signing_key {
log::debug!("use signing key {}", key.fingerprint()); log::debug!("use signing key {}", key.fingerprint());
message = Signer::new(message, key.clone())?.build()?; message = Signer::new(message, key.clone())?.build()?;
@@ -451,6 +528,7 @@ pub fn encrypt(context: &HuskContext<'_>, body: &[u8], recipients: &Vec<Recipien
Ok(Bytes::from(sink)) Ok(Bytes::from(sink))
} else { } else {
log::debug!("not keys at all");
Err(HuskError::NoEncryptionKeys.into()) Err(HuskError::NoEncryptionKeys.into())
} }
} }
@@ -887,7 +965,8 @@ jk6lh01Ej9QTGQ==
introducers: Vec::new(), introducers: Vec::new(),
keyservers: Vec::new(), keyservers: Vec::new(),
subject_replacement: "...".into(), subject_replacement: "...".into(),
mail: MailContext::new() mail: MailContext::new(),
use_tor_via: crate::config::TorConnectorType::NotUsed,
}; };
// store the local trust root // store the local trust root
+138 -17
View File
@@ -9,17 +9,65 @@ use toml;
use log4rs; use log4rs;
use crate::types::errors::HuskError; use crate::types::errors::HuskError;
use crate::types::defaults::CONNECTION;
use crate::types::defaults::CONSOLE_LOGGING; use crate::types::defaults::CONSOLE_LOGGING;
use crate::types::defaults::UPDATER_PID_FILE_LOCATION;
use crate::types::defaults::UPDATER_STATE_FILE_LOCATION;
use crate::types::defaults::UPDATER_WAIT_MIN;
use crate::types::defaults::UPDATER_WAIT_MAX;
use crate::types::defaults::PID_FILE_LOCATION; use crate::types::defaults::PID_FILE_LOCATION;
use crate::types::defaults::SUBJECT_REPLACEMENT; use crate::types::defaults::SUBJECT_REPLACEMENT;
#[derive(Deserialize, Debug, Clone)] #[derive(Deserialize, Debug, Clone)]
pub struct HuskConfig { pub enum TorConnectorType {
NotUsed,
Errornous,
SocksProxy(String),
Arti,
}
impl From<Option<String>> for TorConnectorType {
fn from(value: Option<String>) -> Self {
match value {
Some(v) => {
if v.eq_ignore_ascii_case("arti") {
TorConnectorType::Arti
} else {
if let Some(url) = v.strip_prefix("socks5://") {
TorConnectorType::SocksProxy(url.to_string())
} else {
TorConnectorType::Errornous
}
}
},
None => TorConnectorType::NotUsed,
}
}
}
#[derive(Deserialize, Debug, Clone)]
pub struct MilterConfig {
pub connection: String, pub connection: String,
pub sequoia_home: String, pub pid_file: Option<String>,
pub keyservers: Option<Vec<String>>, pub keyservers: Option<Vec<String>>,
pub subject_replacement: Option<String>, pub subject_replacement: Option<String>,
use_tor_via: Option<String>,
}
#[derive(Deserialize, Debug, Clone)]
pub struct UpdaterConfig {
pub wait_min: Option<u32>,
pub wait_max: Option<u32>,
pub pid_file: Option<String>, pub pid_file: Option<String>,
pub state_file: Option<String>,
use_tor_via: Option<String>,
}
#[derive(Deserialize, Debug, Clone)]
pub struct HuskConfig {
pub sequoia_home: String,
milter: Option<MilterConfig>,
updater: Option<UpdaterConfig>,
logging: Option<toml::Value>, logging: Option<toml::Value>,
} }
@@ -64,26 +112,101 @@ impl HuskConfig {
Ok(()) Ok(())
} }
pub fn get_keyservers(&self) -> Vec<String> { pub fn get_connection(&self) -> String {
if let Some(kss) = &self.keyservers { if let Some(milter_config) = &self.milter {
kss.clone() milter_config.connection.clone()
} else { } else {
Vec::new() CONNECTION.to_string()
} }
} }
pub fn get_subject_replacement(&self) -> String { pub fn get_milter_tor_usage(&self) -> TorConnectorType {
if let Some(replacement) = &self.subject_replacement { if let Some(milter_config) = &self.milter {
replacement.clone() milter_config.use_tor_via.clone().into()
} else { } else {
SUBJECT_REPLACEMENT.to_string() TorConnectorType::NotUsed
} }
} }
pub fn get_keyservers(&self) -> Vec<String> {
if let Some(milter_config) = &self.milter {
if let Some(kss) = &milter_config.keyservers {
return kss.clone();
}
}
Vec::new()
}
pub fn get_subject_replacement(&self) -> String {
if let Some(milter_config) = &self.milter {
if let Some(replacement) = &milter_config.subject_replacement {
return replacement.clone();
}
}
SUBJECT_REPLACEMENT.to_string()
}
pub fn get_pid_file(&self) -> String { pub fn get_pid_file(&self) -> String {
if let Some(pid_file) = &self.pid_file { if let Some(milter_config) = &self.milter {
pid_file.clone() if let Some(pid_file) = &milter_config.pid_file {
return pid_file.clone();
}
}
PID_FILE_LOCATION.to_string()
}
pub fn get_updater_pid_file(&self) -> String {
if let Some(updater_config) = &self.updater {
if let Some(pid_file) = &updater_config.pid_file {
pid_file.clone()
} else {
UPDATER_PID_FILE_LOCATION.to_string()
}
} else { } else {
PID_FILE_LOCATION.to_string() UPDATER_PID_FILE_LOCATION.to_string()
}
}
pub fn get_updater_state_file(&self) -> String {
if let Some(updater_config) = &self.updater {
if let Some(state_file) = &updater_config.state_file {
state_file.clone()
} else {
UPDATER_STATE_FILE_LOCATION.to_string()
}
} else {
UPDATER_STATE_FILE_LOCATION.to_string()
}
}
pub fn get_updater_min_wait(&self) -> u32 {
if let Some(updater_config) = &self.updater {
if let Some(wait_min) = &updater_config.wait_min {
*wait_min
} else {
UPDATER_WAIT_MIN
}
} else {
UPDATER_WAIT_MIN
}
}
pub fn get_updater_max_wait(&self) -> u32 {
if let Some(updater_config) = &self.updater {
if let Some(wait_max) = &updater_config.wait_max {
*wait_max
} else {
UPDATER_WAIT_MAX
}
} else {
UPDATER_WAIT_MAX
}
}
pub fn get_updater_tor_usage(&self) -> TorConnectorType {
if let Some(updater_config) = &self.updater {
updater_config.use_tor_via.clone().into()
} else {
TorConnectorType::NotUsed
} }
} }
} }
@@ -107,12 +230,10 @@ mod tests {
#[test] #[test]
fn subject_line() { fn subject_line() {
let config = HuskConfig { let config = HuskConfig {
connection: "".into(),
sequoia_home: "".into(), sequoia_home: "".into(),
keyservers: None,
subject_replacement: None,
logging: None, logging: None,
pid_file: None, milter: None,
updater: None,
}; };
assert_eq!(config.get_subject_replacement(), defaults::SUBJECT_REPLACEMENT); assert_eq!(config.get_subject_replacement(), defaults::SUBJECT_REPLACEMENT);
+2 -1
View File
@@ -33,7 +33,7 @@ impl Daemon {
let config: HuskConfig = config_container.clone().into(); let config: HuskConfig = config_container.clone().into();
let listener = TcpListener::bind(&config.connection) let listener = TcpListener::bind(&config.get_connection())
.await .await
.expect(" opening milter socket"); .expect(" opening milter socket");
@@ -116,6 +116,7 @@ impl Daemon {
// setup Context // setup Context
match HuskContext::new(&config) { match HuskContext::new(&config) {
Ok(mut context) => { Ok(mut context) => {
context.for_milter(&config);
context.set_sender(sender.unwrap()); context.set_sender(sender.unwrap());
cx.data = Some(context); cx.data = Some(context);
Status::Continue Status::Continue
+2 -1
View File
@@ -17,7 +17,7 @@ use config::{HuskConfig, HuskConfigContainer};
pub mod types; pub mod types;
pub mod mail; pub mod mail;
pub mod daemon; pub mod daemon;
use daemon::Daemon; pub mod updater;
pub mod common; pub mod common;
pub fn main() -> anyhow::Result<()> { pub fn main() -> anyhow::Result<()> {
@@ -26,6 +26,7 @@ pub fn main() -> anyhow::Result<()> {
// XXX keep this in sync with cli/cli_args.rs // XXX keep this in sync with cli/cli_args.rs
for arg in std::env::args() { for arg in std::env::args() {
if arg == "daemon" && daemonize == 0 { daemonize = 1; } if arg == "daemon" && daemonize == 0 { daemonize = 1; }
if arg == "updater" && daemonize == 0 { daemonize = 1; }
if arg == "start" && daemonize == 1 { daemonize = 2; } if arg == "start" && daemonize == 1 { daemonize = 2; }
if arg == "--background" && daemonize == 2 { daemonize = 3; } if arg == "--background" && daemonize == 2 { daemonize = 3; }
} }
+9 -1
View File
@@ -1,12 +1,20 @@
//! Defaults //! Defaults
/// Default host and port of the milter.
pub const CONNECTION: &str = "localhost:3000";
pub const CONFIG_FILE_LOCATION: &str = "/etc/husk/config.toml"; pub const CONFIG_FILE_LOCATION: &str = "/etc/husk/config.toml";
/// If encrypting a mail, the subject is moved into the encrypted body /// If encrypting a mail, the subject is moved into the encrypted body
/// and replaced by `SUBJECT_REPLACEMENT` (unless configured otherwise). /// and replaced by `SUBJECT_REPLACEMENT` (unless configured otherwise).
pub const SUBJECT_REPLACEMENT: &str = "..."; pub const SUBJECT_REPLACEMENT: &str = "...";
pub const PID_FILE_LOCATION: &str = "/run/husk/pid"; pub const PID_FILE_LOCATION: &str = "/var/lib/husk/husk.pid";
pub const UPDATER_PID_FILE_LOCATION: &str = "/var/lib/husk/updater.pid";
pub const UPDATER_STATE_FILE_LOCATION: &str = "/var/lib/husk/updater.state";
pub const UPDATER_WAIT_MIN: u32 = 5;
pub const UPDATER_WAIT_MAX: u32 = 3600;
pub const FOOTER: &str = "---\nProcessed by Husk at {date}.\n"; pub const FOOTER: &str = "---\nProcessed by Husk at {date}.\n";
pub const FOOTER_RECIPIENTS: &str = "This mail was also send to {rcpts}.\n"; pub const FOOTER_RECIPIENTS: &str = "This mail was also send to {rcpts}.\n";
+34 -11
View File
@@ -17,7 +17,7 @@ use sequoia_cert_store::CertStore;
use sequoia_directories::Home; use sequoia_directories::Home;
use sequoia_keystore::Key; use sequoia_keystore::Key;
use crate::config::HuskConfig; use crate::config::{HuskConfig, TorConnectorType};
use crate::common::crypto; use crate::common::crypto;
use crate::types::mail_context::MailContext; use crate::types::mail_context::MailContext;
@@ -32,12 +32,22 @@ pub struct HuskContext<'hc> {
pub keyservers: Vec<String>, pub keyservers: Vec<String>,
pub subject_replacement: String, pub subject_replacement: String,
pub mail: MailContext<'hc>, pub mail: MailContext<'hc>,
pub use_tor_via: TorConnectorType,
} }
impl<'hc> HuskContext<'hc> { impl<'hc> HuskContext<'hc> {
/// Create a new HuskContext instance.
pub fn new(config: &HuskConfig) -> anyhow::Result<HuskContext<'hc>> { pub fn new(config: &HuskConfig) -> anyhow::Result<HuskContext<'hc>> {
Self::create(config, true)
}
/// Create a new HuskContext without determine the signing key
pub fn without_signkey(config: &HuskConfig) -> anyhow::Result<HuskContext<'hc>> {
Self::create(config, false)
}
/// Create a new HuskContext instance.
pub fn create(config: &HuskConfig, with_signkey: bool) -> anyhow::Result<HuskContext<'hc>> {
let sequoia_home = Home::new(PathBuf::from(&config.sequoia_home))?; let sequoia_home = Home::new(PathBuf::from(&config.sequoia_home))?;
let cert_store_base = sequoia_home.data_dir(sequoia_directories::Component::CertD); let cert_store_base = sequoia_home.data_dir(sequoia_directories::Component::CertD);
@@ -48,16 +58,20 @@ impl<'hc> HuskContext<'hc> {
let local_trust_root = crypto::get_local_trust_root(&cert_store)?; let local_trust_root = crypto::get_local_trust_root(&cert_store)?;
let introducers = crypto::get_introducers(&cert_store, &policy, local_trust_root.fingerprint()); let introducers = crypto::get_introducers(&cert_store, &policy, local_trust_root.fingerprint());
let signing_key = match crypto::get_signing_key(sequoia_home, &cert_store, &policy, &local_trust_root) { let signing_key = if with_signkey {
Ok(Some(cert)) => Some(cert), match crypto::get_signing_key(sequoia_home, &cert_store, &policy, &local_trust_root) {
Ok(None) => { Ok(Some(cert)) => Some(cert),
log::warn!("No signing key found"); Ok(None) => {
None log::warn!("No signing key found");
}, None
Err(e) => { },
log::error!("Error while fetching signing key: {}", e); Err(e) => {
None log::error!("Error while fetching signing key: {}", e);
None
}
} }
} else {
None
}; };
Ok(HuskContext { Ok(HuskContext {
@@ -69,6 +83,7 @@ impl<'hc> HuskContext<'hc> {
keyservers: config.get_keyservers(), keyservers: config.get_keyservers(),
subject_replacement: config.get_subject_replacement(), subject_replacement: config.get_subject_replacement(),
mail: MailContext::new(), mail: MailContext::new(),
use_tor_via: TorConnectorType::NotUsed,
}) })
} }
@@ -96,4 +111,12 @@ impl<'hc> HuskContext<'hc> {
self.mail.add_recipient(rcpt, certs) self.mail.add_recipient(rcpt, certs)
} }
pub fn for_milter(&mut self, config: &HuskConfig) {
self.use_tor_via = config.get_milter_tor_usage();
}
pub fn for_updater(&mut self, config: &HuskConfig) {
self.use_tor_via = config.get_updater_tor_usage();
}
} }
+118
View File
@@ -0,0 +1,118 @@
//! Husk certificate updater
use std::path::Path;
use std::sync::Arc;
use sequoia_cert_store::LazyCert;
use sequoia_cert_store::Store;
use sequoia_cert_store::StoreUpdate;
use sequoia_cert_store::store::MergePublicCollectStats;
use tokio::time::sleep;
use tokio::time::Duration;
use crate::config::HuskConfig;
use crate::config::HuskConfigContainer;
use crate::common::crypto;
use crate::types::husk_context::HuskContext;
use state::UpdaterState;
pub mod state;
pub struct Updater { }
impl Updater {
async fn delay(min: u32, max: u32) {
let val = rand::random_range(min..max);
log::debug!("updater sleeping for {} secs", val);
sleep(Duration::from_secs(val as u64)).await;
}
pub async fn run(config_container: HuskConfigContainer) -> anyhow::Result<()> {
let c: HuskConfig = config_container.into();
let mut stay = true;
let state_file = c.get_updater_state_file();
let state_path = Path::new(&state_file);
while stay {
Self::delay(c.get_updater_min_wait(), c.get_updater_max_wait()).await;
let mut updater_state = UpdaterState::thaw(state_path)
.await
.inspect_err(|_| { stay = false; })?;
let mut context = HuskContext::without_signkey(&c)?;
context.for_updater(&c);
updater_state.refresh(&context);
if let Some(fpr) = updater_state.next() {
log::debug!("try to update {}", fpr.to_hex());
// get local cert from cert store to get the user emails
let mut emails = Vec::new();
if let Ok(local_certs) = context.cert_store.lookup_by_cert(&fpr.clone().into()) {
local_certs.iter()
.map(|c| c.with_policy(&context.policy, None))
.filter_map(|c| c.ok())
.filter(|c|
c.keys()
.supported()
.alive()
.revoked(false)
.for_transport_encryption()
.count() > 0)
.for_each(|c|
c.userids()
.map(|u| u.userid().email())
.filter_map(|u| if let Ok(e) = u { e } else { None })
.for_each(|e| emails.push(e.to_string()))
);
}
log::debug!("with emails: {:?}", emails);
let stats = MergePublicCollectStats::new();
let mut certs = crypto::lookup_fingerprint(&context, fpr.to_hex().as_str()).await?;
for email in emails {
crypto::lookup_email(&context, email.as_str()).await?.iter()
.for_each(|c| {
if c.fingerprint() == fpr {
certs.push(c.clone());
}
});
}
if !certs.is_empty() {
for cert in certs {
let cert = Arc::new(LazyCert::from_cert(cert));
if context.cert_store.update_by(cert, &stats).is_ok() {
updater_state.mark_processed(&fpr, stats.updated_certs() > 0);
} else {
updater_state.incr_retry_count(&fpr);
}
}
} else {
// no updates found
updater_state.mark_processed(&fpr, false);
}
UpdaterState::freeze(state_path, &updater_state)
.await
.inspect_err(|_| { stay = false; })?;
} else {
// finished update round
let (processed, updated) = updater_state.summarize();
log::info!("Updater finished round: {} certificates processed, {} updated",
processed,
updated);
// remove state from disk
UpdaterState::remove(state_path)
.await
.inspect_err(|_| { stay = false; })?;
}
}
Ok(())
}
}
+473
View File
@@ -0,0 +1,473 @@
//! manage the state of certificates while updating
use std::{fmt, fs};
use std::collections::BTreeMap;
use std::fs::OpenOptions;
use std::io::{Read, Write};
use std::path::Path;
#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;
use chrono::{Local, NaiveDateTime};
use rand::prelude::*;
use sequoia_openpgp::Fingerprint;
use sequoia_openpgp::types::RevocationStatus;
use sequoia_openpgp::parse::buffered_reader::File;
use sequoia_cert_store::Store;
use thiserror::Error;
use crate::types::husk_context::HuskContext;
#[derive(Error, Debug)]
pub enum UpdaterError {
#[error("IO Error {0}")]
IoError(String),
#[error("Start time missing")]
StartTimeMissing,
#[error("Wrong record size {0}")]
WrongRecordSize(usize),
#[error("Data missing")]
DataMissing,
#[error("Broken persisted cert state: {0}")]
BrokenState(String),
#[error("File not found {0}")]
FileNotFound(String),
}
const MAX_RETRY: u32 = 3;
const DATETIME_PATTERN: &str = "%Y-%m-%d %H:%M:%S";
#[derive(Debug, PartialEq)]
pub struct CertState {
fpr: Fingerprint,
processed: bool,
updated: bool,
// XXX: do we really want to retry within the same update round?
retry_count: u32,
}
impl fmt::Display for CertState {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
writeln!(f, "{}:{}:{}:{}",
self.fpr,
self.processed,
self.updated,
self.retry_count)
}
}
impl TryFrom<&str> for CertState {
type Error = UpdaterError;
fn try_from(line: &str) -> Result<Self, UpdaterError> {
let line = line.trim();
let parts: Vec<&str> = line.split(":").collect();
if parts.len() != 4 {
return Err(UpdaterError::WrongRecordSize(parts.len()));
}
let fpr = parts[0]
.parse::<Fingerprint>()
.map_err(|_e| UpdaterError::BrokenState(parts[0].to_string()))?;
let processed = parts[1]
.parse::<bool>()
.map_err(|_e| UpdaterError::BrokenState(parts[1].to_string()))?;
let updated = parts[2]
.parse::<bool>()
.map_err(|_e| UpdaterError::BrokenState(parts[2].to_string()))?;
let retry_count = parts[3]
.parse::<u32>()
.map_err(|_e| UpdaterError::BrokenState(parts[3].to_string()))?;
Ok(CertState {
fpr,
processed,
updated,
retry_count,
})
}
}
impl TryFrom<String> for CertState {
type Error = UpdaterError;
fn try_from(line: String) -> Result<Self, UpdaterError> {
line.as_str().try_into()
}
}
impl CertState {
pub fn new(fpr: Fingerprint) -> Self {
Self {
fpr,
processed: false,
updated: false,
retry_count: 0,
}
}
/// Check if this certificate is already processed.
pub fn need_processing(&self) -> bool {
!(self.processed || (self.retry_count >= MAX_RETRY))
}
/// Mark this certificate as processed - this doesn't mean that
/// there was an actual update.
pub fn mark_processed(&mut self) {
self.processed = true;
}
/// Mark this certificate as updated - this doesn't mark the
/// certificate as processed.
pub fn mark_updated(&mut self) {
self.updated = true;
}
/// Increment the retry count.
pub fn incr_retry_count(&mut self) {
self.retry_count += 1;
}
}
#[derive(Debug, PartialEq)]
pub struct UpdaterState {
started: NaiveDateTime,
certs: BTreeMap<Fingerprint, CertState>,
}
impl TryFrom<String> for UpdaterState {
type Error = UpdaterError;
fn try_from(data: String) -> Result<Self, UpdaterError> {
let mut result = BTreeMap::new();
let mut lines = data.lines();
let started = lines
.next()
.and_then(|l|
NaiveDateTime::parse_from_str(l, DATETIME_PATTERN).ok());
if started.is_none() {
return Err(UpdaterError::DataMissing);
}
for line in lines {
if line.is_empty() { continue; }
let cert_state: CertState = line.try_into()?;
result.insert(cert_state.fpr.clone(), cert_state);
}
Ok(UpdaterState {
started: started.unwrap(),
certs: result
})
}
}
impl TryFrom<Vec<u8>> for UpdaterState {
type Error = UpdaterError;
fn try_from(data: Vec<u8>) -> Result<Self, UpdaterError> {
Self::try_from(String::from_utf8_lossy(&data[..]).to_string())
}
}
impl fmt::Display for UpdaterState {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
writeln!(f, "{}", self.started.format(DATETIME_PATTERN))?;
for cert in self.certs.values() {
write!(f, "{}", cert)?;
}
Ok(())
}
}
impl Default for UpdaterState {
fn default() -> Self {
Self::new()
}
}
impl UpdaterState {
pub fn new() -> Self {
Self {
started: Local::now().naive_local(),
certs: BTreeMap::new()
}
}
/// Get the next certificate from the updater, which still needs
/// processing - this includes retries.
pub fn next(&mut self) -> Option<Fingerprint> {
let set: Vec<&CertState> = self.certs.values()
.filter(|c| c.need_processing())
.collect();
let mut rng = rand::rng();
set.choose(&mut rng)
.and_then(|c| Some(c.fpr.clone()))
}
/// Mark the certificate (identified by its fingerprint) as processed. Also
/// mark it as updated if `updated` is true.
pub fn mark_processed(&mut self, fpr: &Fingerprint, updated: bool) {
if let Some(entry) = self.certs.get_mut(fpr) {
entry.mark_processed();
if updated {
entry.mark_updated();
}
}
}
/// Increment the retry count of the certificate (identified by its
/// fingerprint).
pub fn incr_retry_count(&mut self, fpr: &Fingerprint) {
if let Some(entry) = self.certs.get_mut(fpr) {
entry.incr_retry_count();
}
}
/// Scan the certificate store for new certificates and add them to the
/// state (as unprocessed).
pub fn refresh(&mut self, context: &HuskContext<'_>) {
let count = self.certs.keys().count();
context.cert_store.certs()
.filter(|c| {
if let Ok(vc) = c.with_policy(&context.policy, None) {
// Ignore revoked certificates.
if let RevocationStatus::Revoked(_) = vc.revocation_status() {
return false;
}
// Ignore non exportable certificates, this includes internal
// certificates like the trust root or shadow CAs.
vc.cert().exportable()
} else {
// Certificate is invalid under the current policy.
false
}
})
.map(|c| c.fingerprint())
.for_each(|fpr| {
if ! self.certs.contains_key(&fpr) {
self.certs.insert(fpr.clone(), CertState::new(fpr));
}
});
log::debug!("refresh from {} to {}",
count,
self.certs.keys().count());
}
/// Recreate the state from file.
pub async fn thaw(file: &Path) -> Result<Self, UpdaterError> {
if !file.exists() {
log::debug!("file not existing: {:?}", file);
if let Some(parent) = file.parent() {
log::debug!("assert parent directory");
std::fs::create_dir_all(parent)
.map_err(|e| UpdaterError::IoError(e.to_string()))?;
}
return Ok(Self::new());
}
let meta = file.metadata()
.map_err(|e| UpdaterError::IoError(e.to_string()))?;
let updater_state = if meta.len() == 0 {
// no previous state
Self::new()
} else {
let mut handle = File::open(file)
.map_err(|e| UpdaterError::IoError(e.to_string()))?;
let mut content = Vec::with_capacity(meta.len() as usize);
handle.read_to_end(&mut content)
.map_err(|e| UpdaterError::IoError(e.to_string()))?;
content.try_into()?
};
Ok(updater_state)
}
/// Persist state to file.
pub async fn freeze(file: &Path, updater_state: &UpdaterState) -> Result<(), UpdaterError> {
// freeze comes after thaw, so we assume that the parent directory
// exists.
let mut options = OpenOptions::new();
options.write(true)
.create(true)
.truncate(true);
#[cfg(unix)]
options.mode(0o600);
let mut handle = options.open(file)
.map_err(|e| UpdaterError::IoError(e.to_string()))?;
handle.write_all(updater_state.to_string().as_bytes())
.map_err(|e| UpdaterError::IoError(e.to_string()))?;
Ok(())
}
/// Remove the state file.
pub async fn remove(file: &Path) -> Result<(), UpdaterError> {
if file.exists() {
fs::remove_file(file)
.map_err(|e| UpdaterError::IoError(e.to_string()))?;
}
Ok(())
}
/// Create some statistics.
pub fn summarize(&self) -> (usize, usize) {
let mut processed = 0;
let mut updated = 0;
for cert in self.certs.values() {
if cert.processed { processed += 1; }
if cert.updated { updated += 1; }
}
(processed, updated)
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use crate::updater::state::{DATETIME_PATTERN, UpdaterError};
use chrono::NaiveDateTime;
use sequoia_openpgp::Fingerprint;
use super::{CertState, UpdaterState};
#[test]
fn serialize_deserialize( ) -> Result<(), UpdaterError> {
let cert_state = CertState {
fpr: "E936CB3BBCECC7F53769C87C006ECEC2B1DFFB68"
.parse::<Fingerprint>()
.map_err(|_e| UpdaterError::BrokenState("Fingerprint wrong in test".into()))?,
processed: false,
updated: false,
retry_count: 0,
};
let serialized: String = format!("{}", cert_state);
let deserialized: CertState = serialized.try_into()?;
assert_eq!(cert_state, deserialized);
let cert_state = CertState {
fpr: "E936CB3BBCECC7F53769C87C006ECEC2B1DFFB68"
.parse::<Fingerprint>()
.map_err(|_e| UpdaterError::BrokenState("Fingerprint wrong in test".into()))?,
processed: false,
updated: true,
retry_count: 3,
};
let serialized: String = format!("{}", cert_state);
eprintln!("{}", serialized);
let deserialized: CertState = serialized.try_into()?;
assert_eq!(cert_state, deserialized);
Ok(())
}
#[test]
fn serialize_deserialize_store( ) -> Result<(), UpdaterError> {
let store = UpdaterState {
started: NaiveDateTime::parse_from_str("2026-07-11 15:06:10", DATETIME_PATTERN)
.map_err(|_e| UpdaterError::BrokenState("in test".into()))?,
certs: [
CertState {
fpr: "E936CB3BBCECC7F53769C87C006ECEC2B1DFFB68"
.parse::<Fingerprint>()
.map_err(|_e| UpdaterError::BrokenState("Fingerprint wrong in test".into()))?,
processed: false,
updated: true,
retry_count: 3,
},
CertState {
fpr: "E936CB3BBCECC7F53769C87C006ECEC2B1DFFB69"
.parse::<Fingerprint>()
.map_err(|_e| UpdaterError::BrokenState("Fingerprint wrong in test".into()))?,
processed: false,
updated: true,
retry_count: 3,
},
CertState {
fpr: "E936CB3BBCECC7F53769C87C006ECEC2B1DFFB6A"
.parse::<Fingerprint>()
.map_err(|_e| UpdaterError::BrokenState("Fingerprint wrong in test".into()))?,
processed: false,
updated: true,
retry_count: 1,
}
].into_iter()
.map(|c| (c.fpr.clone(), c))
.collect::<BTreeMap<Fingerprint, CertState>>()
};
let serialized = store.to_string();
eprintln!("{}", serialized);
let deserialized: UpdaterState = serialized.try_into()?;
assert_eq!(store, deserialized);
Ok(())
}
#[test]
fn update_turnaround() -> Result<(), UpdaterError> {
let mut store = UpdaterState {
started: NaiveDateTime::parse_from_str("2026-07-11 15:06:10", DATETIME_PATTERN)
.map_err(|_e| UpdaterError::BrokenState("in test".into()))?,
certs: [
CertState {
fpr: "E936CB3BBCECC7F53769C87C006ECEC2B1DFFB68"
.parse::<Fingerprint>()
.map_err(|_e| UpdaterError::BrokenState("Fingerprint wrong in test".into()))?,
processed: false,
updated: false,
retry_count: 0,
},
CertState {
fpr: "E936CB3BBCECC7F53769C87C006ECEC2B1DFFB69"
.parse::<Fingerprint>()
.map_err(|_e| UpdaterError::BrokenState("Fingerprint wrong in test".into()))?,
processed: false,
updated: false,
retry_count: 0,
},
CertState {
fpr: "E936CB3BBCECC7F53769C87C006ECEC2B1DFFB6A"
.parse::<Fingerprint>()
.map_err(|_e| UpdaterError::BrokenState("Fingerprint wrong in test".into()))?,
processed: false,
updated: false,
retry_count: 0,
}
].into_iter()
.map(|c| (c.fpr.clone(), c))
.collect::<BTreeMap<Fingerprint, CertState>>()
};
let mut break_counter = 0;
// this loop has to end
while let Some(fpr) = store.next() {
assert!(break_counter < store.certs.len());
break_counter += 1;
store.mark_processed(&fpr, false);
}
Ok(())
}
}