New-install privacy defaults, and expired bans purged daily #85
@@ -14,7 +14,7 @@ use aws_lc_rs::{
|
|||||||
use registry::{
|
use registry::{
|
||||||
schema::{
|
schema::{
|
||||||
enums::*,
|
enums::*,
|
||||||
prelude::{ObjectType, SocketAddr},
|
prelude::{Object, ObjectType, SocketAddr},
|
||||||
structs::*,
|
structs::*,
|
||||||
},
|
},
|
||||||
types::{duration::Duration, error::Error, list::List, map::Map},
|
types::{duration::Duration, error::Error, list::List, map::Map},
|
||||||
@@ -388,6 +388,28 @@ async fn insert_safe_defaults(bp: &mut Bootstrap) -> trc::Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inbuxa: personal-data catalog, defaults D2, D3, D4 and D6 (settled
|
||||||
|
// 2026-09-28): privacy-leaning values, for new installs only. A server
|
||||||
|
// with roles is not new, and keeps its settings whether saved or left at
|
||||||
|
// the default. Each singleton is read, changed and written back whole, so
|
||||||
|
// anything already in it stays.
|
||||||
|
#[cfg(not(feature = "test_mode"))]
|
||||||
|
if bp.registry.count_object(ObjectType::Role).await? == 0 {
|
||||||
|
let mut security = bp.setting_infallible::<Security>().await;
|
||||||
|
let mut classifier = bp.setting_infallible::<SpamClassifier>().await;
|
||||||
|
let mut pyzor = bp.setting_infallible::<SpamPyzor>().await;
|
||||||
|
let mut retention = bp.setting_infallible::<DataRetention>().await;
|
||||||
|
new_install_privacy_defaults(&mut security, &mut classifier, &mut pyzor, &mut retention);
|
||||||
|
for object in [
|
||||||
|
Object::from(security),
|
||||||
|
classifier.into(),
|
||||||
|
pyzor.into(),
|
||||||
|
retention.into(),
|
||||||
|
] {
|
||||||
|
bp.registry.write(RegistryWrite::insert(&object)).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if bp.registry.count_object(ObjectType::Role).await? == 0 {
|
if bp.registry.count_object(ObjectType::Role).await? == 0 {
|
||||||
let permissions = DefaultPermissions::default();
|
let permissions = DefaultPermissions::default();
|
||||||
let mut role_ids = Vec::with_capacity(4);
|
let mut role_ids = Vec::with_capacity(4);
|
||||||
@@ -560,3 +582,81 @@ async fn insert_safe_defaults(bp: &mut Bootstrap) -> trc::Result<()> {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// inbuxa: the new-install values of defaults D2, D3, D4 and D6 from the
|
||||||
|
/// personal-data catalog spec. Automatic IP bans expire after 30 days instead
|
||||||
|
/// of never; spam training samples are kept 90 days instead of 180; Pyzor,
|
||||||
|
/// which sends a digest of each message's text to a public server, is off;
|
||||||
|
/// delivery history is kept 14 days instead of 30.
|
||||||
|
fn new_install_privacy_defaults(
|
||||||
|
security: &mut Security,
|
||||||
|
classifier: &mut SpamClassifier,
|
||||||
|
pyzor: &mut SpamPyzor,
|
||||||
|
retention: &mut DataRetention,
|
||||||
|
) {
|
||||||
|
const DAY: u64 = 24 * 60 * 60 * 1000;
|
||||||
|
let ban_period = Some(Duration::from_millis(30 * DAY));
|
||||||
|
security.auth_ban_period = ban_period;
|
||||||
|
security.abuse_ban_period = ban_period;
|
||||||
|
security.loiter_ban_period = ban_period;
|
||||||
|
security.scan_ban_period = ban_period;
|
||||||
|
classifier.hold_samples_for = Duration::from_millis(90 * DAY);
|
||||||
|
pyzor.enable = false;
|
||||||
|
retention.hold_traces_for = Some(Duration::from_millis(14 * DAY));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const DAY: u64 = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_installs_get_the_privacy_defaults() {
|
||||||
|
let (mut security, mut classifier, mut pyzor, mut retention) = (
|
||||||
|
Security::default(),
|
||||||
|
SpamClassifier::default(),
|
||||||
|
SpamPyzor::default(),
|
||||||
|
DataRetention::default(),
|
||||||
|
);
|
||||||
|
// What an install gets without them: bans that never lift, 180-day
|
||||||
|
// samples, Pyzor on, 30-day traces.
|
||||||
|
assert_eq!(security.auth_ban_period, None);
|
||||||
|
assert!(pyzor.enable);
|
||||||
|
|
||||||
|
new_install_privacy_defaults(&mut security, &mut classifier, &mut pyzor, &mut retention);
|
||||||
|
|
||||||
|
for period in [
|
||||||
|
security.auth_ban_period,
|
||||||
|
security.abuse_ban_period,
|
||||||
|
security.loiter_ban_period,
|
||||||
|
security.scan_ban_period,
|
||||||
|
] {
|
||||||
|
assert_eq!(period.map(|p| p.into_inner().as_millis() as u64), Some(30 * DAY));
|
||||||
|
}
|
||||||
|
assert_eq!(classifier.hold_samples_for.into_inner().as_millis() as u64, 90 * DAY);
|
||||||
|
assert!(!pyzor.enable);
|
||||||
|
assert_eq!(
|
||||||
|
retention.hold_traces_for.map(|p| p.into_inner().as_millis() as u64),
|
||||||
|
Some(14 * DAY)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn everything_else_in_the_settings_stays() {
|
||||||
|
let mut retention = DataRetention {
|
||||||
|
archive_deleted_items_for: Some(Duration::from_millis(7 * DAY)),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let before = retention.clone();
|
||||||
|
new_install_privacy_defaults(
|
||||||
|
&mut Security::default(),
|
||||||
|
&mut SpamClassifier::default(),
|
||||||
|
&mut SpamPyzor::default(),
|
||||||
|
&mut retention,
|
||||||
|
);
|
||||||
|
assert_eq!(retention.archive_deleted_items_for, before.archive_deleted_items_for);
|
||||||
|
assert_eq!(retention.hold_metrics_for, before.hold_metrics_for);
|
||||||
|
assert_eq!(retention.expunge_trash_after, before.expunge_trash_after);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -426,6 +426,37 @@ impl Server {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Server {
|
||||||
|
/// inbuxa: personal-data catalog, D2: removes bans whose period is over.
|
||||||
|
/// They already stop blocking when they expire, and go when settings are
|
||||||
|
/// next loaded; the daily clean-up makes sure a server that seldom
|
||||||
|
/// reloads doesn't keep them.
|
||||||
|
pub async fn purge_expired_blocked_ips(&self) -> trc::Result<()> {
|
||||||
|
let now = now() as i64;
|
||||||
|
let mut expired = Vec::new();
|
||||||
|
for ip in self.registry().list::<BlockedIp>().await? {
|
||||||
|
if ip.object.expires_at.as_ref().is_some_and(|at| at.timestamp() <= now) {
|
||||||
|
let address = ip.object.address.clone();
|
||||||
|
let object = Object {
|
||||||
|
inner: ip.object.into(),
|
||||||
|
revision: ip.revision,
|
||||||
|
};
|
||||||
|
self.registry()
|
||||||
|
.write(RegistryWrite::delete_object(ip.id, &object))
|
||||||
|
.await?;
|
||||||
|
expired.push(trc::Value::from(address.into_inner().0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !expired.is_empty() {
|
||||||
|
trc::event!(
|
||||||
|
Security(trc::SecurityEvent::IpBlockExpired),
|
||||||
|
Details = expired
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl BlockedIps {
|
impl BlockedIps {
|
||||||
pub async fn parse(bp: &mut Bootstrap) -> Self {
|
pub async fn parse(bp: &mut Bootstrap) -> Self {
|
||||||
let mut ips = Self::default();
|
let mut ips = Self::default();
|
||||||
|
|||||||
@@ -47353,7 +47353,9 @@ impl Default for WebHook {
|
|||||||
level: TracingLevel::Info,
|
level: TracingLevel::Info,
|
||||||
lossy: false,
|
lossy: false,
|
||||||
events: Default::default(),
|
events: Default::default(),
|
||||||
events_policy: EventPolicy::Exclude,
|
// inbuxa: personal-data catalog, D7: a new webhook sends nothing
|
||||||
|
// until its events are chosen
|
||||||
|
events_policy: EventPolicy::Include,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -269,6 +269,11 @@ async fn store_maintenance(
|
|||||||
trc::error!(err.details("Failed to re-apply account locks"));
|
trc::error!(err.details("Failed to re-apply account locks"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inbuxa: personal-data catalog, D2: bans past their period go
|
||||||
|
if let Err(err) = server.purge_expired_blocked_ips().await {
|
||||||
|
trc::error!(err.details("Failed to purge expired IP bans"));
|
||||||
|
}
|
||||||
|
|
||||||
// inbuxa: AU-7: audit records past their retention go; a
|
// inbuxa: AU-7: audit records past their retention go; a
|
||||||
// failure leaves them for the next run
|
// failure leaves them for the next run
|
||||||
if let Err(err) = server.audit_purge().await {
|
if let Err(err) = server.audit_purge().await {
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ These live in inbuxa's own key space (`SUBSPACE_INBUXA`) unless noted.
|
|||||||
| OAuth codes and tokens | credential | holder | `x:OidcProvider.*Expiry` | tokens are sealed and stateless (not stored); codes in the in-memory store with TTL | in-memory store | tenant | `http/src/auth/oauth/auth.rs`, `token.rs` | codes 10 min |
|
| OAuth codes and tokens | credential | holder | `x:OidcProvider.*Expiry` | tokens are sealed and stateless (not stored); codes in the in-memory store with TTL | in-memory store | tenant | `http/src/auth/oauth/auth.rs`, `token.rs` | codes 10 min |
|
||||||
| Rate-limit state | network, identifier (login names) | holder, correspondent | `x:Http.rateLimit*`, `x:Imap.maxRequestRate`, `x:Security.*BanRate` | the rate's period | in-memory store | server | `common/src/auth/rate_limit.rs`, `network/security.rs` | on |
|
| Rate-limit state | network, identifier (login names) | holder, correspondent | `x:Http.rateLimit*`, `x:Imap.maxRequestRate`, `x:Security.*BanRate` | the rate's period | in-memory store | server | `common/src/auth/rate_limit.rs`, `network/security.rs` | on |
|
||||||
| Greylist | identifier (sender/recipient pairs, plain) | correspondent, holder | `x:SpamSettings.greylistFor` | that period | in-memory store | server | `smtp/src/inbound/rcpt.rs` | **off** |
|
| Greylist | identifier (sender/recipient pairs, plain) | correspondent, holder | `x:SpamSettings.greylistFor` | that period | in-memory store | server | `smtp/src/inbound/rcpt.rs` | **off** |
|
||||||
| Automatic IP bans (`x:BlockedIp`) | network | correspondent, holder | `x:Security.authBanRate`, `abuseBanRate`, `loiterBanRate`, `scanBanRate` (on); `*BanPeriod` (**no default**) | **unbounded: a ban with no period never expires, and no purge of expired bans was found**; each ban is also an audit record | data store (registry) | server | `common/src/network/security.rs` `block_ip` | **collected, permanent** |
|
| Automatic IP bans (`x:BlockedIp`) | network | correspondent, holder | `x:Security.authBanRate`, `abuseBanRate`, `loiterBanRate`, `scanBanRate` (on); `*BanPeriod` (**no default**) | **unbounded: a ban with no period never expires**; an expired ban's record goes when settings next load; each ban is also an audit record | data store (registry) | server | `common/src/network/security.rs` `block_ip` | **collected, permanent** |
|
||||||
| Allowed IPs | network | administrator's choice | manual; `expiresAt` | optional | data store | server | registry | none |
|
| Allowed IPs | network | administrator's choice | manual; `expiresAt` | optional | data store | server | registry | none |
|
||||||
|
|
||||||
### 2.6 Spam filter and AI
|
### 2.6 Spam filter and AI
|
||||||
@@ -197,8 +197,10 @@ not a judgment; what to do about each is John's call.
|
|||||||
2. **Log files are never deleted.** Daily rotation opens a new file; nothing
|
2. **Log files are never deleted.** Daily rotation opens a new file; nothing
|
||||||
removes old ones, and no logrotate configuration ships. At the default
|
removes old ones, and no logrotate configuration ships. At the default
|
||||||
level every in-session line carries the client IP.
|
level every in-session line carries the client IP.
|
||||||
3. **Automatic IP bans are permanent.** No `*BanPeriod` has a default, and no
|
3. **Automatic IP bans are permanent.** No `*BanPeriod` has a default, so a
|
||||||
purge of expired `x:BlockedIp` records was found.
|
ban never expires. (Corrected 2026-09-28: a ban that does expire stops
|
||||||
|
blocking, and its record is deleted when settings are next loaded, in
|
||||||
|
`BlockedIps::parse`; the investigation missed that path.)
|
||||||
4. **Some records outlive the account.** Deleting an account doesn't clear
|
4. **Some records outlive the account.** Deleting an account doesn't clear
|
||||||
inbuxa's own key space: legacy-protocol last use, account locks, masked
|
inbuxa's own key space: legacy-protocol last use, account locks, masked
|
||||||
address records and audit records stay (audit records by design).
|
address records and audit records stay (audit records by design).
|
||||||
@@ -394,6 +396,17 @@ default; fails on a stale entry.
|
|||||||
Phase 3; existing servers keep their settings. D7 is also covered by the bug
|
Phase 3; existing servers keep their settings. D7 is also covered by the bug
|
||||||
fix for finding 1 (Settled 6).
|
fix for finding 1 (Settled 6).
|
||||||
|
|
||||||
|
**As built (2026-09-28).** D2, D3, D4, D6 are written on first boot of a new
|
||||||
|
install only (no roles yet), each singleton read and written back whole
|
||||||
|
(`manager/defaults.rs`, `new_install_privacy_defaults`); expired bans are
|
||||||
|
also purged daily (`purge_expired_blocked_ips`). D7 changes the default for
|
||||||
|
webhooks created from now on; stored webhooks keep theirs (the registry
|
||||||
|
stores every field). **Held:** D1, because `x:TracerLog` is also stored
|
||||||
|
inside `x:Bootstrap` with fields after it, so adding a field changes that
|
||||||
|
object's stored format; a fork-owned setting is proposed instead, for John
|
||||||
|
to decide. D5, because the spam-rules loader it touches is being reworked
|
||||||
|
by the v0.16.24 import.
|
||||||
|
|
||||||
| # | Change | Trade-off |
|
| # | Change | Trade-off |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| D1 | A **log retention** setting on `x:TracerLog` (delete rotated files older than N days), default 30 days for new installs | Needs code (a new field, so a schema edit); older logs gone for troubleshooting; operators wanting longer set it |
|
| D1 | A **log retention** setting on `x:TracerLog` (delete rotated files older than N days), default 30 days for new installs | Needs code (a new field, so a schema edit); older logs gone for troubleshooting; operators wanting longer set it |
|
||||||
|
|||||||
Binary file not shown.
@@ -1 +1 @@
|
|||||||
-jadTddv9zRK0gp8fBFYRmhwmXopxPxF2yjc86bSKRM
|
MiWRzsz0AHdRshG_8JimktRqBO_pHGAUBHFcfV5jwI4
|
||||||
@@ -221,6 +221,21 @@ pub async fn test(test: &mut TestServer) {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
// inbuxa: personal-data catalog, D2: the daily clean-up removes the
|
||||||
|
// expired ban's record, without waiting for settings to reload
|
||||||
|
test.server.purge_expired_blocked_ips().await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
admin
|
||||||
|
.registry_query_ids(
|
||||||
|
ObjectType::BlockedIp,
|
||||||
|
[(Property::Address, "10.0.0.2")],
|
||||||
|
Vec::<&str>::new(),
|
||||||
|
)
|
||||||
|
.await,
|
||||||
|
Vec::<Id>::new(),
|
||||||
|
"the expired ban's record is gone"
|
||||||
|
);
|
||||||
|
|
||||||
// Make sure the IP remains unblocked after reload
|
// Make sure the IP remains unblocked after reload
|
||||||
admin.registry_create_object(Action::ReloadBlockedIps).await;
|
admin.registry_create_object(Action::ReloadBlockedIps).await;
|
||||||
validate_password_with_ip(
|
validate_password_with_ip(
|
||||||
|
|||||||
Reference in New Issue
Block a user