Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b585905d7 |
@@ -1,42 +0,0 @@
|
||||
# CI on the self-hosted Gitea, ported from .gitlab-ci.yml during the move off
|
||||
# GitLab (2026-09-22). Gitea reads .gitea/workflows and ignores .github/ once
|
||||
# this directory exists; .github/workflows stays as it was for GitHub.
|
||||
#
|
||||
# Every job runs in an image pinned by digest (tag in the trailing comment),
|
||||
# and the only action used is coffey-labs/actions/checkout pinned by SHA. The
|
||||
# instance resolves short `uses:` against itself, never GitHub, so nothing
|
||||
# unreviewed can be pulled in.
|
||||
#
|
||||
# Not ported, as on GitLab: publish.yml and release.yml still need doing.
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: docker
|
||||
container:
|
||||
image: rust:1-bookworm@sha256:93ce27a88655056a51dbdd8f5f2d7ddc071c7b0070fb288a37b5a285fc83971e # 1-bookworm
|
||||
# Both kept inside the workspace (a per-job volume on the project disk),
|
||||
# deliberately not on /tmp, which on this host is a tmpfs that a Rust
|
||||
# build of this size has filled before. There is no cache between runs
|
||||
# here -- the runner's cache server is off -- so every build is cold.
|
||||
env:
|
||||
CARGO_INCREMENTAL: "0"
|
||||
steps:
|
||||
- uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec
|
||||
- run: |
|
||||
echo "CARGO_HOME=$GITHUB_WORKSPACE/.cargo" >> "$GITHUB_ENV"
|
||||
echo "CARGO_TARGET_DIR=$GITHUB_WORKSPACE/target" >> "$GITHUB_ENV"
|
||||
- run: apt-get update -qq && apt-get install -y -qq --no-install-recommends clang >/dev/null
|
||||
- run: cargo build -p inbuxa --locked
|
||||
# --no-run: the workflow compiled every test target without running them,
|
||||
# which catches a test that no longer builds without paying for the suite.
|
||||
- run: cargo test --workspace --locked --no-run
|
||||
@@ -10,7 +10,6 @@ run.sh
|
||||
!.gitattributes
|
||||
!.github
|
||||
!.gitlab-ci.yml
|
||||
!.gitea
|
||||
CLAUDE.md
|
||||
|
||||
# The cutover rehearsal writes its fixture and state here.
|
||||
|
||||
@@ -47,6 +47,9 @@ pub struct Network {
|
||||
#[derive(Clone)]
|
||||
pub struct NetworkInfo {
|
||||
pub pacc: Pacc,
|
||||
/// inbuxa: the same document without IMAP, POP3, SMTP and ManageSieve,
|
||||
/// served while legacy protocols are off (legacy-protocols LP-7).
|
||||
pub pacc_jmap_only: Pacc,
|
||||
pub mxs: Vec<MailExchanger>,
|
||||
pub services: VecMap<ServiceProtocol, Service>,
|
||||
}
|
||||
@@ -320,11 +323,26 @@ impl Network {
|
||||
}
|
||||
}
|
||||
|
||||
let (prefix, suffix) = serde_json::to_string(&pacc)
|
||||
.unwrap_or_default()
|
||||
.rsplit_once(SPLIT_HERE)
|
||||
.map(|(prefix, suffix)| (prefix.to_string(), suffix.to_string()))
|
||||
.unwrap();
|
||||
let split = |pacc: &Configuration| {
|
||||
serde_json::to_string(pacc)
|
||||
.unwrap_or_default()
|
||||
.rsplit_once(SPLIT_HERE)
|
||||
.map(|(prefix, suffix)| Pacc {
|
||||
prefix: prefix.to_string(),
|
||||
suffix: suffix.to_string(),
|
||||
})
|
||||
.unwrap()
|
||||
};
|
||||
// inbuxa: legacy-protocols LP-7
|
||||
let pacc_jmap_only = {
|
||||
let mut pacc = pacc.clone();
|
||||
pacc.protocols.imap = None;
|
||||
pacc.protocols.pop3 = None;
|
||||
pacc.protocols.smtp = None;
|
||||
pacc.protocols.managesieve = None;
|
||||
split(&pacc)
|
||||
};
|
||||
let pacc = split(&pacc);
|
||||
let mut network = Network {
|
||||
node_id: bp.node_id() as u64,
|
||||
server_name: default_hostname.to_string(),
|
||||
@@ -339,7 +357,8 @@ impl Network {
|
||||
info: NetworkInfo {
|
||||
mxs: system.mail_exchangers.into_iter().collect(),
|
||||
services: system.services,
|
||||
pacc: Pacc { prefix, suffix },
|
||||
pacc,
|
||||
pacc_jmap_only,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
use crate::{Server, manager::application::Resource};
|
||||
use crate::{Server, manager::application::Resource, network::legacy::is_legacy_service};
|
||||
use quick_xml::Reader;
|
||||
use quick_xml::XmlVersion;
|
||||
use quick_xml::events::Event;
|
||||
@@ -55,7 +57,12 @@ impl Server {
|
||||
let _ = writeln!(&mut config, "\t\t<Account>");
|
||||
let _ = writeln!(&mut config, "\t\t\t<AccountType>email</AccountType>");
|
||||
let _ = writeln!(&mut config, "\t\t\t<Action>settings</Action>");
|
||||
// inbuxa: legacy-protocols LP-7
|
||||
let legacy_off = self.legacy_protocols_off().await?;
|
||||
for (protocol, service) in &self.core.network.info.services {
|
||||
if legacy_off && is_legacy_service(protocol) {
|
||||
continue;
|
||||
}
|
||||
let (protocol, ports) = match protocol {
|
||||
ServiceProtocol::Imap => ("IMAP", [143, 993]),
|
||||
ServiceProtocol::Pop3 => ("POP3", [110, 995]),
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
use crate::{Server, manager::application::Resource};
|
||||
use crate::{Server, manager::application::Resource, network::legacy::is_legacy_service};
|
||||
use registry::schema::enums::ServiceProtocol;
|
||||
use std::fmt::Write;
|
||||
use utils::url_params::UrlParams;
|
||||
@@ -28,6 +30,9 @@ impl Server {
|
||||
("%EMAILADDRESS%", default_host.as_str())
|
||||
};
|
||||
|
||||
// inbuxa: legacy-protocols LP-7
|
||||
let legacy_off = self.legacy_protocols_off().await?;
|
||||
|
||||
// Build XML response
|
||||
let mut config = String::with_capacity(1024);
|
||||
config.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
|
||||
@@ -40,6 +45,9 @@ impl Server {
|
||||
"\t\t<displayShortName>{domain}</displayShortName>"
|
||||
);
|
||||
for (protocol, service) in &self.core.network.info.services {
|
||||
if legacy_off && is_legacy_service(protocol) {
|
||||
continue;
|
||||
}
|
||||
let (protocol, tag, ports) = match protocol {
|
||||
ServiceProtocol::Smtp => ("smtp", "outgoingServer", [587, 465]),
|
||||
ServiceProtocol::Imap => ("imap", "incomingServer", [143, 993]),
|
||||
|
||||
@@ -2,9 +2,15 @@
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
use crate::{Server, config::network::Pacc, network::dkim::generate_dkim_dns_record};
|
||||
use crate::{
|
||||
Server,
|
||||
config::network::Pacc,
|
||||
network::{dkim::generate_dkim_dns_record, legacy::is_legacy_service},
|
||||
};
|
||||
use ahash::{AHashMap, AHashSet};
|
||||
use base64::{Engine, engine::general_purpose};
|
||||
use dns_update::{
|
||||
@@ -33,6 +39,8 @@ impl Server {
|
||||
let mut records = Vec::new();
|
||||
let network = &self.core.network;
|
||||
let default_host = network.server_name.as_str();
|
||||
// inbuxa: legacy-protocols LP-7
|
||||
let legacy_off = self.legacy_protocols_off().await?;
|
||||
let domain_name = domain.name.as_str();
|
||||
let domain_name_suffix = format!(".{domain_name}");
|
||||
|
||||
@@ -193,6 +201,25 @@ impl Server {
|
||||
ServiceProtocol::Smtp => [("submission", 587), ("submissions", 465)],
|
||||
};
|
||||
|
||||
// inbuxa: legacy-protocols LP-7. While they are off, every
|
||||
// name says "not offered" -- target "." (RFC 6186 section
|
||||
// 3.4) -- rather than vanishing, so a client that looks
|
||||
// is told, and an old record left in the zone is replaced.
|
||||
if legacy_off && is_legacy_service(protocol) {
|
||||
for (service_name, _) in services {
|
||||
records.push(NamedDnsRecord {
|
||||
name: format!("_{service_name}._tcp.{domain_name}."),
|
||||
record: DnsRecord::SRV(SRVRecord {
|
||||
target: ".".to_string(),
|
||||
priority: 0,
|
||||
weight: 0,
|
||||
port: 0,
|
||||
}),
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
for (is_tls, (service_name, port)) in services.into_iter().enumerate() {
|
||||
if is_tls == 1 || service.cleartext {
|
||||
records.push(NamedDnsRecord {
|
||||
@@ -277,6 +304,14 @@ impl Server {
|
||||
for (protocol, service) in &network.info.services {
|
||||
let hostname = service.hostname.as_deref().unwrap_or(default_host);
|
||||
if hostname.ends_with(&domain_name_suffix) || hostname == domain_name {
|
||||
// inbuxa: legacy-protocols LP-7. No TLS pin for a port
|
||||
// the switch has closed. Submission's port stays open
|
||||
// (the SMTP lock), so its record stays.
|
||||
if legacy_off
|
||||
&& matches!(protocol, ServiceProtocol::Imap | ServiceProtocol::Pop3)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let port = match protocol {
|
||||
ServiceProtocol::Imap => 993,
|
||||
ServiceProtocol::Pop3 => 995,
|
||||
@@ -382,6 +417,12 @@ impl Server {
|
||||
}
|
||||
|
||||
pub async fn get_pacc_for_domain(&self, domain_name: &str) -> trc::Result<String> {
|
||||
// inbuxa: legacy-protocols LP-7
|
||||
let pacc = if self.legacy_protocols_off().await? {
|
||||
&self.core.network.info.pacc_jmap_only
|
||||
} else {
|
||||
&self.core.network.info.pacc
|
||||
};
|
||||
self.get_directory_for_domain(domain_name)
|
||||
.await
|
||||
.caused_by(trc::location!())
|
||||
@@ -390,15 +431,9 @@ impl Server {
|
||||
.and_then(|directory| {
|
||||
directory
|
||||
.oidc_discovery_document()
|
||||
.map(|doc| self.core.network.info.pacc.build(&doc.url))
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
self.core
|
||||
.network
|
||||
.info
|
||||
.pacc
|
||||
.build(&self.core.network.http.url_https)
|
||||
.map(|doc| pacc.build(&doc.url))
|
||||
})
|
||||
.unwrap_or_else(|| pacc.build(&self.core.network.http.url_https))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,10 @@
|
||||
//! a legacy protocol is refused before any password is looked at, so a
|
||||
//! listener that exists by mistake still lets nobody in.
|
||||
//!
|
||||
//! And nothing advertises what is closed (LP-7): client configuration and
|
||||
//! the suggested DNS records leave the legacy services out, or mark them as
|
||||
//! not offered, while the switch is off.
|
||||
//!
|
||||
//! Nothing here touches the host's firewall, NAT port-forwards or any proxy
|
||||
//! (LP-20). The server stops answering; what still routes the port is the
|
||||
//! operator's to reconcile.
|
||||
@@ -31,6 +35,7 @@ use inbuxa_features::security::{
|
||||
listeners,
|
||||
protocol_policy::{self, ProtocolPolicy, SavedListener},
|
||||
};
|
||||
use registry::schema::enums::ServiceProtocol;
|
||||
use registry::types::{error::Error, id::ObjectId};
|
||||
use store::registry::bootstrap::Bootstrap;
|
||||
|
||||
@@ -302,6 +307,27 @@ impl Server {
|
||||
}
|
||||
}
|
||||
|
||||
/// The services mail apps sign in to, which the switch turns off: nothing may
|
||||
/// offer them while it is (LP-7). SMTP here is submission -- mail apps
|
||||
/// sending -- since inbound mail is never a configured service.
|
||||
pub fn is_legacy_service(protocol: &ServiceProtocol) -> bool {
|
||||
matches!(
|
||||
protocol,
|
||||
ServiceProtocol::Imap
|
||||
| ServiceProtocol::Pop3
|
||||
| ServiceProtocol::Smtp
|
||||
| ServiceProtocol::Managesieve
|
||||
)
|
||||
}
|
||||
|
||||
impl Server {
|
||||
/// Whether the server-wide switch is off, for the answers that must stop
|
||||
/// offering legacy services (LP-7). Read per answer, as sign-in reads it.
|
||||
pub async fn legacy_protocols_off(&self) -> trc::Result<bool> {
|
||||
Ok(self.protocol_policy().await?.legacy_protocols.is_disabled())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -349,6 +375,26 @@ mod tests {
|
||||
assert_eq!(err.value_as_str(trc::Key::AccountName), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_services_mail_apps_sign_in_to_are_legacy() {
|
||||
for protocol in [
|
||||
ServiceProtocol::Imap,
|
||||
ServiceProtocol::Pop3,
|
||||
ServiceProtocol::Smtp,
|
||||
ServiceProtocol::Managesieve,
|
||||
] {
|
||||
assert!(is_legacy_service(&protocol), "{protocol:?}");
|
||||
}
|
||||
for protocol in [
|
||||
ServiceProtocol::Jmap,
|
||||
ServiceProtocol::Caldav,
|
||||
ServiceProtocol::Carddav,
|
||||
ServiceProtocol::Webdav,
|
||||
] {
|
||||
assert!(!is_legacy_service(&protocol), "{protocol:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_domain_comes_from_the_name_given() {
|
||||
assert_eq!(domain_of(&basic("[email protected]")), Some("b.test".to_string()));
|
||||
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
q-OZe-InKnF24mlL56Vvt3m_IQNRybiN61MFxBSo0WY
|
||||
C32Zc43ANGr52j0cZkTq3IEPrGtbFUX0d2-R91noCho
|
||||
@@ -17,7 +17,9 @@ submission -- locked open -- is refused with the spec's words, with the right
|
||||
password and with a wrong one, and refusals never add up to a disconnect
|
||||
(LP-11). And that a normal IMAP sign-in works with the switch on, before and
|
||||
after. And that while it is off, no listener the switch would close can be
|
||||
created, or made by an update (LP-4, test 4).
|
||||
created, or made by an update (LP-4, test 4), and nothing advertises what is
|
||||
closed: autoconfig, autodiscover and PACC offer no IMAP, POP3 or submission,
|
||||
and the suggested zone marks their SRV names not offered (LP-7, test 5).
|
||||
|
||||
Passwords are generated into files under target/e2e and never printed.
|
||||
Everything is removed afterwards unless KEEP=1.
|
||||
@@ -189,6 +191,40 @@ def smtp_auths(port, user, passwords):
|
||||
return replies
|
||||
|
||||
|
||||
def advertised(admin, admin_pw):
|
||||
"""What each client-configuration answer and the suggested zone offer."""
|
||||
with urllib.request.urlopen(f"{HTTP}/mail/[email protected]",
|
||||
timeout=30) as resp:
|
||||
autoconfig = resp.read().decode()
|
||||
body = ('<?xml version="1.0" encoding="utf-8"?><Autodiscover xmlns="http://schemas.'
|
||||
'microsoft.com/exchange/autodiscover/outlook/requestschema/2006"><Request>'
|
||||
'<EMailAddress>[email protected]</EMailAddress><AcceptableResponseSchema>http://'
|
||||
'schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a'
|
||||
'</AcceptableResponseSchema></Request></Autodiscover>').encode()
|
||||
req = urllib.request.Request(f"{HTTP}/autodiscover/autodiscover.xml", data=body, method="POST")
|
||||
req.add_header("Content-Type", "text/xml")
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
autodiscover = resp.read().decode()
|
||||
with urllib.request.urlopen(f"{HTTP}/.well-known/user-agent-configuration.json",
|
||||
timeout=30) as resp:
|
||||
pacc = json.load(resp).get("protocols", {})
|
||||
got = one(admin, admin_pw, "x:Domain/get", {"ids": None, "properties": ["name", "dnsZoneFile"]})
|
||||
zone = next((d.get("dnsZoneFile") or "" for d in got[1].get("list", [])
|
||||
if d.get("name") == "legacy.test"), "")
|
||||
srv = {}
|
||||
for line in zone.splitlines():
|
||||
fields = line.split()
|
||||
if "SRV" in fields and fields[0].startswith("_"):
|
||||
srv[fields[0].split(".")[0] + "." + fields[0].split(".")[1]] = fields[-1]
|
||||
return {
|
||||
"autoconfig": {t for t in ("imap", "pop3", "smtp") if f'type="{t}"' in autoconfig},
|
||||
"autodiscover": {t for t in ("IMAP", "POP3", "SMTP") if f"<Type>{t}</Type>" in autodiscover},
|
||||
"pacc": {t for t in ("imap", "pop3", "smtp", "managesieve") if t in pacc},
|
||||
"jmap": "jmap" in pacc,
|
||||
"srv": srv,
|
||||
}
|
||||
|
||||
|
||||
def settle(port, want, tries=30):
|
||||
"""Wait for a port to reach the wanted state, so the check is not a race."""
|
||||
for _ in range(tries):
|
||||
@@ -243,6 +279,15 @@ def main():
|
||||
check(accepts(PORTS["submissions"]), "submission accepts before the switch")
|
||||
check(accepts(PORTS["smtp"]), "inbound SMTP accepts before the switch")
|
||||
|
||||
# What is advertised with the switch on -- the control for LP-7.
|
||||
before = advertised(admin, admin_pw)
|
||||
print(" advertised before:", {k: sorted(v) if isinstance(v, set) else v
|
||||
for k, v in before.items() if k != "srv"})
|
||||
check(before["autoconfig"] and before["autodiscover"],
|
||||
"autoconfig and autodiscover offer mail apps a server with the switch on")
|
||||
check(before["srv"].get("_imaps._tcp", ".") != ".",
|
||||
"the suggested zone offers IMAP with the switch on")
|
||||
|
||||
# A normal sign-in works with the switch on -- the control for LP-6.
|
||||
check(imap_login(PORTS["imap"], admin, admin_pw).startswith("OK"),
|
||||
"IMAP sign-in works with the switch on")
|
||||
@@ -295,6 +340,19 @@ def main():
|
||||
if not all(r == SMTP_REFUSAL for r in replies):
|
||||
print(" replies:", replies)
|
||||
|
||||
# Nothing advertises what is closed (LP-7, test 5).
|
||||
during = advertised(admin, admin_pw)
|
||||
check(not during["autoconfig"], "autoconfig offers no IMAP, POP3 or submission (LP-7)")
|
||||
check(not during["autodiscover"], "autodiscover offers no IMAP, POP3 or submission (LP-7)")
|
||||
check(not during["pacc"] and during["jmap"], "PACC offers JMAP and nothing legacy (LP-7)")
|
||||
names = ("_imap._tcp", "_imaps._tcp", "_pop3._tcp", "_pop3s._tcp",
|
||||
"_submission._tcp", "_submissions._tcp")
|
||||
offered = {n: t for n, t in during["srv"].items() if n in names and t != "."}
|
||||
check(not offered and "_imaps._tcp" in during["srv"],
|
||||
"the suggested zone marks the legacy SRV names not offered, target . (LP-7)")
|
||||
if offered or "_imaps._tcp" not in during["srv"]:
|
||||
print(" srv:", during["srv"])
|
||||
|
||||
# No listener the switch would close can be added while it is off (LP-4,
|
||||
# test 4), and the refusal names the policy.
|
||||
res = one(admin, admin_pw, "x:NetworkListener/set", {"create": {"m": {
|
||||
@@ -341,6 +399,10 @@ def main():
|
||||
check(policy["legacyProtocols"] == "enabled", "switch reads back enabled")
|
||||
check(not policy["savedListeners"], "savedListeners is empty again (LP-5)")
|
||||
|
||||
after = advertised(admin, admin_pw)
|
||||
check(after["autoconfig"] == before["autoconfig"] and after["srv"] == before["srv"],
|
||||
"autoconfig and the suggested zone offer them again once back on")
|
||||
|
||||
# And sign-in works again, with no restart.
|
||||
check(imap_login(PORTS["imap"], admin, admin_pw).startswith("OK"),
|
||||
"IMAP sign-in works again once the switch is back on")
|
||||
|
||||
Reference in New Issue
Block a user