config: make alias sources Store authoritative
Debian Trixie package bundle / packages (push) Successful in 22m14s

This commit is contained in:
Mario Fetka
2026-07-27 21:56:27 +02:00
parent 2b78d16791
commit d297844207
9 changed files with 419 additions and 19 deletions
+11 -2
View File
@@ -59,8 +59,17 @@ way.
Aliases and virtual domains are split into suffixless JSON fragments below
`/etc/bongo/aliases.d`. Every fragment may contain `aliases`, `domains`, or
both. The required `default_config` supplies global aliases; a full source
address overrides one domain only:
both. Their complete source set is stored operatively in the internal
`alias-fragments` Store document; the directory is its editable mirror.
Alias changes made by an administrative command update the Store and mirror
as one transaction. At manager startup, only a strictly newer file or
directory timestamp is treated as an emergency edit. An equally dated or
newer Store source refreshes the directory, including additions and
deletions. `bongo-admin config sync` explicitly selects a validated direct
file edit even within the same timestamp second.
The required `default_config` supplies global aliases; a full source address
overrides one domain only:
```json
{
+5 -3
View File
@@ -92,9 +92,11 @@ configured `bongo` service account before accepting either HTTP listener.
Agent settings are operative suffixless JSON Store documents such as `smtp`,
`imap`, `queue`, `web`, `authentication`, and `manager`. Their declarative
counterparts live below `/etc/bongo/config.d`; alias fragments live below
`/etc/bongo/aliases.d`. The manager validates and imports the files only after
the Store starts, so agents always consume one coherent Store-facing model
and never fall back to a second runtime configuration source.
`/etc/bongo/aliases.d` and are represented operatively by one internal
`alias-fragments` Store source document. The manager reconciles the mirrors
only after the Store starts, compiles the authoritative alias source into its
per-domain delivery documents, and never lets an equally dated file override
the Store.
`bongo-admin config` reads the Store and writes Store plus files as one
rollback-capable operation. Use `bongo-admin config check` for a read-only
+1 -1
View File
@@ -10,7 +10,7 @@ configuration under `/etc`, not `/usr/etc`.
| --- | --- |
| `/etc/bongo` | Administrator-managed configuration and protected key directories. |
| `/etc/bongo/config.d` | Suffixless JSON mirrors for every operative Store configuration document. |
| `/etc/bongo/aliases.d` | Mergeable suffixless alias and virtual-domain JSON fragments. |
| `/etc/bongo/aliases.d` | Editable mirror of the Store-authoritative, mergeable suffixless alias and virtual-domain fragments. |
| `/etc/bongo/ssl.d` | TLS certificates and private keys managed by setup or an ACME deploy hook. |
| `/etc/bongo/dkim.d` | Per-domain DKIM keys and related material. |
| `/etc/bongo/dmarc.d` | DMARC setup state and report-processing policy. |
+3 -1
View File
@@ -101,7 +101,9 @@ admin changes, and rolls back prior writes if a later write fails.
`bongo-admin config sync` transactionally writes validated file changes and
new template defaults to the Store, then asks the running manager to reload.
Alias and virtual-domain fragments are kept separately in
`/etc/bongo/aliases.d` and are synchronized by the same manager operation.
`/etc/bongo/aliases.d`. Their operative `alias-fragments` Store source remains
authoritative; the same manager operation imports an explicitly newer or
forced emergency file edit and otherwise refreshes the fragment mirror.
Configuration names such as `smtp`, `imap`, `antispam`, `antivirus`, `web`,
and `authentication` have no `.json` suffix, although their contents are
+6 -2
View File
@@ -85,6 +85,8 @@ ValidConfigurationName(const char *name)
if (!strcmp(name, *cursor))
return 1;
}
if (!strcmp(name, "alias-fragments"))
return 1;
if (!strncmp(name, "aliases/", 8)) {
const unsigned char *alias = (const unsigned char *)name + 8;
size_t length = strlen((const char *)alias);
@@ -372,6 +374,7 @@ AccessConfiguration(const char *name, int replace)
char *original = NULL;
size_t length = 0;
uint64_t modified = 0;
int mirror;
int conn_started = 0;
int msg_started = 0;
int result = 1;
@@ -381,6 +384,7 @@ AccessConfiguration(const char *name, int replace)
name ? name : "(null)");
return 2;
}
mirror = !strchr(name, '/') && strcmp(name, "alias-fragments");
if (replace && ReadConfigurationInput(&content, &length) != 0)
return 1;
if (replace && (BongoJsonParseString(content, &root) != BONGO_JSON_OK ||
@@ -402,7 +406,7 @@ AccessConfiguration(const char *name, int replace)
goto done;
}
if (replace) {
if (!strchr(name, '/') &&
if (mirror &&
(!NMAPReadConfigFile(name, &original) || !original)) {
fprintf(stderr,
"bongo-admin: cannot snapshot configuration %s\n", name);
@@ -413,7 +417,7 @@ AccessConfiguration(const char *name, int replace)
name);
goto done;
}
if (!strchr(name, '/') &&
if (mirror &&
(!NMAPReadConfigFileModified(name, &modified) ||
!WriteConfigurationMirror(name, content, length, modified))) {
if (!original ||
+1 -1
View File
@@ -75,7 +75,7 @@ class ConfigurationModelTest(unittest.TestCase):
source = (SOURCE / "src" / "apps" / "manager" / "manager.c").read_text(
encoding="utf-8")
match = re.search(
r"SyncLocalConfiguration\(\).*?"
r"SyncLocalConfiguration\(BOOL forceLocal\).*?"
r"static const char \*const names\[\] = \{(.*?)NULL",
source, re.DOTALL)
self.assertIsNotNone(match)
+33
View File
@@ -59,6 +59,8 @@ class FakeStore(ConfigurationStore):
}
self.domain_aliases = {}
self.username_mappings = {}
self.alias_fragments = {}
self.fail_alias_fragments = False
self.fail_name = None
def read_store_document(self, name):
@@ -84,6 +86,19 @@ class FakeStore(ConfigurationStore):
return value, json_bytes(value)
return self.read_store_document(name)
def _helper(self, operation, name, content=None):
if name != "alias-fragments":
return super()._helper(operation, name, content)
if operation == "__config-replace":
if self.fail_alias_fragments:
self.fail_alias_fragments = False
raise ConfigurationError("injected alias Store failure")
self.alias_fragments = json.loads(content)
return b""
if operation == "__config-read":
return json_bytes(self.alias_fragments)
raise ConfigurationError("unsupported fake alias helper operation")
class FakeSocket:
def __init__(self, response):
@@ -450,6 +465,9 @@ class ConfigurationStorageTest(unittest.TestCase):
aliases = json.loads(
(self.paths.aliases_dir / "account-renames").read_text())
self.assertEqual(aliases["aliases"]["old-user"], "new-user")
self.assertEqual(
self.store.alias_fragments["account-renames"]["aliases"]
["old-user"], "new-user")
self.store.finish_retained_alias("101", True)
self.assertFalse(
(self.paths.lock.parent / "bongo-alias-rename-101").exists())
@@ -465,6 +483,21 @@ class ConfigurationStorageTest(unittest.TestCase):
self.store.prepare_retained_alias("old-user", "new-user", "102")
self.store.finish_retained_alias("102", False)
self.assertFalse((self.paths.aliases_dir / "account-renames").exists())
self.assertNotIn("account-renames", self.store.alias_fragments)
def test_retained_alias_store_failure_restores_file_and_transaction(self):
self.paths.aliases_dir.mkdir(parents=True)
(self.paths.aliases_dir / "default_config").write_bytes(json_bytes({
"aliases": {"postmaster": "admin"},
}))
self.store.fail_alias_fragments = True
with self.assertRaisesRegex(ConfigurationError,
"injected alias Store failure"):
self.store.prepare_retained_alias(
"old-user", "new-user", "105")
self.assertFalse((self.paths.aliases_dir / "account-renames").exists())
self.assertFalse(
(self.paths.lock.parent / "bongo-alias-rename-105").exists())
def test_retained_alias_prepare_rejects_conflicting_mapping(self):
self.paths.aliases_dir.mkdir(parents=True)
+338 -7
View File
@@ -801,7 +801,8 @@ done:
}
static BOOL
SyncConfigurationDocument(const char *storeName, const char *path)
SyncConfigurationDocument(const char *storeName, const char *path,
BOOL forceLocal)
{
char *local = NULL;
char *stored = NULL;
@@ -834,7 +835,7 @@ SyncConfigurationDocument(const char *storeName, const char *path)
result = SetConfigurationFileTime(path, &details, storeModified);
goto done;
}
if ((uint64_t)details.st_mtime > storeModified) {
if (forceLocal || (uint64_t)details.st_mtime > storeModified) {
if (!SyncConfigurationContent(storeName, path, local) ||
!NMAPReadConfigFileModified(storeName, &storeModified))
goto done;
@@ -878,6 +879,267 @@ ValidAliasConfigurationName(const char *name)
return TRUE;
}
static BOOL
ReadAliasSourceDirectory(BongoJsonNode **rootOut, char **contentOut,
uint64_t *modifiedOut)
{
DIR *directory = NULL;
struct dirent *entry;
struct stat directoryDetails;
BongoJsonObject *fragments = NULL;
BongoJsonNode *aggregate = NULL;
char *content = NULL;
uint64_t modified;
size_t total = 0;
BOOL foundDefault = FALSE;
BOOL result = FALSE;
if (lstat(XPL_DEFAULT_ALIASES_DIR, &directoryDetails) != 0 ||
!S_ISDIR(directoryDetails.st_mode)) {
fprintf(stderr, _("bongo-manager: invalid alias directory %s\n"),
XPL_DEFAULT_ALIASES_DIR);
goto done;
}
modified = (uint64_t)directoryDetails.st_mtime;
fragments = BongoJsonObjectNew();
if (!fragments)
goto done;
directory = opendir(XPL_DEFAULT_ALIASES_DIR);
if (!directory)
goto done;
while ((entry = readdir(directory)) != NULL) {
BongoJsonNode *fragment = NULL;
struct stat details;
char path[XPL_MAX_PATH + 1];
char *fragmentContent = NULL;
size_t fragmentLength;
if (!strcmp(entry->d_name, ".") || !strcmp(entry->d_name, ".."))
continue;
if (!ValidAliasConfigurationName(entry->d_name) ||
snprintf(path, sizeof(path), "%s/%s", XPL_DEFAULT_ALIASES_DIR,
entry->d_name) >= (int)sizeof(path) ||
!ReadConfigurationFile(path, &fragmentContent, &details) ||
BongoJsonParseString(fragmentContent, &fragment) != BONGO_JSON_OK ||
!fragment || fragment->type != BONGO_JSON_OBJECT) {
fprintf(stderr,
_("bongo-manager: invalid alias source fragment %s/%s\n"),
XPL_DEFAULT_ALIASES_DIR, entry->d_name);
if (fragment)
BongoJsonNodeFree(fragment);
if (fragmentContent)
MemFree(fragmentContent);
goto done;
}
fragmentLength = strlen(fragmentContent);
if (fragmentLength > MAX_LOCAL_CONFIG_SIZE - total) {
fprintf(stderr,
_("bongo-manager: alias source exceeds %u bytes\n"),
MAX_LOCAL_CONFIG_SIZE);
BongoJsonNodeFree(fragment);
MemFree(fragmentContent);
goto done;
}
total += fragmentLength;
MemFree(fragmentContent);
if ((uint64_t)details.st_mtime > modified)
modified = (uint64_t)details.st_mtime;
if (!strcmp(entry->d_name, "default_config"))
foundDefault = TRUE;
if (BongoJsonObjectPut(fragments, entry->d_name, fragment) !=
BONGO_JSON_OK) {
BongoJsonNodeFree(fragment);
goto done;
}
}
if (!foundDefault) {
fprintf(stderr, _("bongo-manager: missing %s/default_config\n"),
XPL_DEFAULT_ALIASES_DIR);
goto done;
}
aggregate = BongoJsonNodeNewObject(fragments);
if (!aggregate)
goto done;
fragments = NULL;
content = BongoJsonNodeToString(aggregate);
if (!content)
goto done;
*rootOut = aggregate;
*contentOut = content;
*modifiedOut = modified;
aggregate = NULL;
content = NULL;
result = TRUE;
done:
if (directory)
closedir(directory);
if (aggregate)
BongoJsonNodeFree(aggregate);
else if (fragments)
BongoJsonObjectFree(fragments);
if (content)
MemFree(content);
return result;
}
static BOOL
SetAliasSourceTimes(BongoJsonNode *root, uint64_t modified)
{
BongoJsonObjectIter iterator;
BongoJsonObject *fragments;
struct stat details;
char path[XPL_MAX_PATH + 1];
if (!root || root->type != BONGO_JSON_OBJECT)
return FALSE;
fragments = BongoJsonNodeAsObject(root);
for (BongoJsonObjectIterFirst(fragments, &iterator); iterator.key;
BongoJsonObjectIterNext(fragments, &iterator)) {
if (snprintf(path, sizeof(path), "%s/%s", XPL_DEFAULT_ALIASES_DIR,
iterator.key) >= (int)sizeof(path) ||
lstat(path, &details) != 0 || !S_ISREG(details.st_mode) ||
!SetConfigurationFileTime(path, &details, modified))
return FALSE;
}
if (lstat(XPL_DEFAULT_ALIASES_DIR, &details) != 0 ||
!S_ISDIR(details.st_mode) ||
!SetConfigurationFileTime(XPL_DEFAULT_ALIASES_DIR, &details, modified))
return FALSE;
return TRUE;
}
static BOOL
ValidateAliasSourceRoot(BongoJsonNode *root)
{
BongoJsonObjectIter iterator;
BongoJsonObject *fragments;
BOOL foundDefault = FALSE;
if (!root || root->type != BONGO_JSON_OBJECT)
return FALSE;
fragments = BongoJsonNodeAsObject(root);
for (BongoJsonObjectIterFirst(fragments, &iterator); iterator.key;
BongoJsonObjectIterNext(fragments, &iterator)) {
if (!ValidAliasConfigurationName(iterator.key) || !iterator.value ||
iterator.value->type != BONGO_JSON_OBJECT)
return FALSE;
if (!strcmp(iterator.key, "default_config"))
foundDefault = TRUE;
}
return foundDefault;
}
static BOOL
WriteAliasSourceDirectory(BongoJsonNode *root, uint64_t modified)
{
DIR *directory = NULL;
struct dirent *entry;
BongoJsonObjectIter iterator;
BongoJsonObject *fragments;
struct stat directoryDetails;
uid_t effectiveUser = geteuid();
int savedError = 0;
BOOL result = FALSE;
if (!ValidateAliasSourceRoot(root) ||
lstat(XPL_DEFAULT_ALIASES_DIR, &directoryDetails) != 0 ||
!S_ISDIR(directoryDetails.st_mode))
return FALSE;
fragments = BongoJsonNodeAsObject(root);
directory = opendir(XPL_DEFAULT_ALIASES_DIR);
if (!directory)
return FALSE;
while ((entry = readdir(directory)) != NULL) {
if (!strcmp(entry->d_name, ".") || !strcmp(entry->d_name, ".."))
continue;
if (!ValidAliasConfigurationName(entry->d_name)) {
fprintf(stderr, _("bongo-manager: unsupported alias file %s/%s\n"),
XPL_DEFAULT_ALIASES_DIR, entry->d_name);
goto done;
}
}
closedir(directory);
directory = NULL;
if (effectiveUser != 0 && XplSetEffectiveUserId(0) != 0)
goto done;
for (BongoJsonObjectIterFirst(fragments, &iterator); iterator.key;
BongoJsonObjectIterNext(fragments, &iterator)) {
struct stat details = directoryDetails;
char path[XPL_MAX_PATH + 1];
char *content;
if (snprintf(path, sizeof(path), "%s/%s", XPL_DEFAULT_ALIASES_DIR,
iterator.key) >= (int)sizeof(path))
goto done;
if (lstat(path, &details) != 0) {
if (errno != ENOENT)
goto done;
details = directoryDetails;
details.st_mode = S_IFREG | 0644;
} else if (!S_ISREG(details.st_mode)) {
errno = EINVAL;
goto done;
}
content = BongoJsonNodeToString(iterator.value);
if (!content)
goto done;
if (!WriteConfigurationFile(path, content, &details, modified)) {
MemFree(content);
goto done;
}
MemFree(content);
}
directory = opendir(XPL_DEFAULT_ALIASES_DIR);
if (!directory)
goto done;
while ((entry = readdir(directory)) != NULL) {
BongoJsonNode *unused = NULL;
char path[XPL_MAX_PATH + 1];
if (!strcmp(entry->d_name, ".") || !strcmp(entry->d_name, ".."))
continue;
if (BongoJsonObjectGet(fragments, entry->d_name, &unused) ==
BONGO_JSON_OK)
continue;
if (snprintf(path, sizeof(path), "%s/%s", XPL_DEFAULT_ALIASES_DIR,
entry->d_name) >= (int)sizeof(path) ||
unlink(path) != 0)
goto done;
}
closedir(directory);
directory = NULL;
if (!SetAliasSourceTimes(root, modified))
goto done;
{
int descriptor = open(XPL_DEFAULT_ALIASES_DIR,
O_RDONLY | O_DIRECTORY | O_CLOEXEC);
if (descriptor < 0 || fsync(descriptor) != 0) {
if (descriptor >= 0)
close(descriptor);
goto done;
}
close(descriptor);
}
result = TRUE;
done:
savedError = errno;
if (directory)
closedir(directory);
if (effectiveUser != 0 && XplSetEffectiveUserId(effectiveUser) != 0) {
savedError = errno;
result = FALSE;
}
errno = savedError;
if (!result)
fprintf(stderr,
_("bongo-manager: cannot refresh alias source from Store: %s\n"),
strerror(errno));
return result;
}
typedef struct {
const char *name;
BongoJsonObject *aliases;
@@ -1691,7 +1953,76 @@ done:
}
static BOOL
SyncLocalConfiguration()
SyncAliasSourceConfiguration(BOOL forceLocal)
{
BongoJsonNode *localRoot = NULL;
BongoJsonNode *storedRoot = NULL;
char *local = NULL;
char *stored = NULL;
char *canonicalStored = NULL;
uint64_t localModified = 0;
uint64_t storeModified = 0;
BOOL result = FALSE;
if (!ReadAliasSourceDirectory(&localRoot, &local, &localModified))
goto done;
if (!NMAPReadConfigFile("alias-fragments", &stored) || !stored) {
if (!SyncAliasConfiguration() ||
!NMAPReplaceConfigFile("alias-fragments", local, strlen(local)) ||
!NMAPReadConfigFileModified("alias-fragments", &storeModified) ||
!SetAliasSourceTimes(localRoot, storeModified))
goto done;
printf(_("bongo-manager: initialized authoritative alias fragments\n"));
result = TRUE;
goto done;
}
if (BongoJsonParseString(stored, &storedRoot) != BONGO_JSON_OK ||
!ValidateAliasSourceRoot(storedRoot) ||
!(canonicalStored = BongoJsonNodeToString(storedRoot)) ||
!NMAPReadConfigFileModified("alias-fragments", &storeModified)) {
fprintf(stderr,
_("bongo-manager: cannot read authoritative alias fragments\n"));
goto done;
}
if (BongoJsonNodeEqual(localRoot, storedRoot)) {
if (!SetAliasSourceTimes(localRoot, storeModified) ||
!SyncAliasConfiguration())
goto done;
result = TRUE;
goto done;
}
if (forceLocal || localModified > storeModified) {
if (!SyncAliasConfiguration() ||
!NMAPReplaceConfigFile("alias-fragments", local, strlen(local)) ||
!NMAPReadConfigFileModified("alias-fragments", &storeModified) ||
!SetAliasSourceTimes(localRoot, storeModified))
goto done;
printf(_("bongo-manager: applied emergency alias file changes to Store\n"));
result = TRUE;
} else {
if (!WriteAliasSourceDirectory(storedRoot, storeModified) ||
!SyncAliasConfiguration())
goto done;
printf(_("bongo-manager: refreshed alias files from the Store\n"));
result = TRUE;
}
done:
if (localRoot)
BongoJsonNodeFree(localRoot);
if (storedRoot)
BongoJsonNodeFree(storedRoot);
if (local)
MemFree(local);
if (stored)
MemFree(stored);
if (canonicalStored)
MemFree(canonicalStored);
return result;
}
static BOOL
SyncLocalConfiguration(BOOL forceLocal)
{
static const char *const names[] = {
"global", "queue", "smtp", "imap", "pop3", "sieve", "collector",
@@ -1705,10 +2036,10 @@ SyncLocalConfiguration()
if (snprintf(path, sizeof(path), "%s/%s", XPL_DEFAULT_CONFIG_DIR,
*name) >= (int)sizeof(path) ||
!SyncConfigurationDocument(*name, path))
!SyncConfigurationDocument(*name, path, forceLocal))
return FALSE;
}
return SyncAliasConfiguration();
return SyncAliasSourceConfiguration(forceLocal);
}
static void
@@ -2216,7 +2547,7 @@ main(int argc, char **argv)
goto err_handler;
}
StartStore();
if (!SyncLocalConfiguration()) {
if (!SyncLocalConfiguration(FALSE)) {
printf(_("bongo-manager: Couldn't apply local configuration\n"));
goto err_handler;
}
@@ -2244,7 +2575,7 @@ main(int argc, char **argv)
}
if (ReloadNow) {
if (!SyncLocalConfiguration()) {
if (!SyncLocalConfiguration(TRUE)) {
fprintf(stderr,
_("bongo-manager: configuration synchronization failed\n"));
ReloadNow = FALSE;
+21 -2
View File
@@ -376,6 +376,13 @@ class ConfigurationStore:
merged_domains[source_key] = (destination, fragment)
return aliases
def _write_alias_fragments_to_store(
self, fragments: Mapping[str, Mapping[str, Any]]) -> None:
self._helper(
"__config-replace", "alias-fragments",
json_bytes({name: dict(value)
for name, value in fragments.items()}))
def dmarc_report_users(self, hosted_domains: Iterable[str]) -> dict[str, str]:
"""Resolve each hosted domain's DMARC reporting alias for UI tasks."""
domains = [domain_to_ascii(str(domain)) for domain in hosted_domains]
@@ -618,7 +625,12 @@ class ConfigurationStore:
transaction, json_bytes(record),
_FileSnapshot(False, b"", 0o600, os.geteuid(),
os.getegid()))
self._write_alias_fragments_to_store(self.load_aliases())
except BaseException:
try:
transaction.unlink()
except FileNotFoundError:
pass
if changed:
_restore(target, before)
raise
@@ -654,10 +666,17 @@ class ConfigurationStore:
raise ConfigurationError(
"invalid alias rename rollback data") from error
current = _snapshot(target)
if not current.exists or current.content != expected:
before_matches = (
current.exists == before.exists and
(not current.exists or current.content == before.content)
)
if not before_matches and (
not current.exists or current.content != expected):
raise ConfigurationError(
"account rename aliases changed during the transaction")
_restore(target, before)
if not before_matches:
_restore(target, before)
self._write_alias_fragments_to_store(self.load_aliases())
transaction.unlink()
_fsync_directory(transaction.parent)
finally: