diff --git a/contrib/testing/smtp-idna-check.py b/contrib/testing/smtp-idna-check.py index f38e586..fa3f006 100755 --- a/contrib/testing/smtp-idna-check.py +++ b/contrib/testing/smtp-idna-check.py @@ -96,10 +96,25 @@ def read_configuration(name: str) -> dict: def replace_configuration(name: str, value: dict) -> None: + serialized = json.dumps( + value, ensure_ascii=False, separators=(",", ":") + ) SMTP07.run( [SMTP07.ADMIN, "__config-replace", name], - input_text=json.dumps(value, separators=(",", ":")), + input_text=serialized, ) + if "/" not in name: + path = Path("/etc/bongo/config.d") / name + try: + mirrored = json.loads(path.read_bytes()) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error: + raise SMTP26Error( + f"configuration mirror {path} is not valid UTF-8 JSON" + ) from error + if mirrored != value: + raise SMTP26Error( + f"configuration mirror {path} does not match the Store update" + ) def restart_bongo(host: str, port: int) -> None: diff --git a/docs/administration.md b/docs/administration.md index 1df94d3..0abb586 100644 --- a/docs/administration.md +++ b/docs/administration.md @@ -44,14 +44,13 @@ is the administrator- and configuration-management-friendly representation. `config check` validates those local files without changing the Store; `config sync` validates them and asks the running manager to apply them. The manager reconciles the same mirror at startup. It uses the Store object's -existing Unix modification time as the common revision: a newer valid local -file is an intentional emergency edit and is imported, while a newer Store -document refreshes the file. Store mirror timestamps have zero nanoseconds, -so an editor save in the same second is still recognized as a local edit; an -otherwise equal timestamp belongs to the primary Store. `config sync` explicitly -selects the validated local files when an administrator needs to force an -emergency edit into the Store. Listener changes still require the affected -agents to be restarted. +existing Unix modification time as the common revision: a valid local file +whose whole-second timestamp is strictly newer is an intentional emergency +edit and is imported, while a newer or equally dated Store document refreshes +the file. This tie-break deliberately keeps the Store authoritative; use +`config sync` to force a valid local edit made within the same timestamp +second into the Store. Store mirror timestamps have zero nanoseconds. +Listener changes still require the affected agents to be restarted. If a local mirror is missing but its operative Store document still exists, `config sync` reconstructs the file from the Store before committing the diff --git a/src/apps/admin/admin.c b/src/apps/admin/admin.c index cecee67..ee0aa46 100644 --- a/src/apps/admin/admin.c +++ b/src/apps/admin/admin.c @@ -28,12 +28,14 @@ #include #include +#include #include #include #include #include #include #include +#include #include #include #include @@ -273,12 +275,103 @@ ReadConfigurationInput(char **content, size_t *length) return 0; } +static int +WriteConfigurationMirror(const char *name, const char *content, size_t length, + uint64_t modified) +{ + char path[XPL_MAX_PATH + 1]; + char temporary[XPL_MAX_PATH + 32]; + struct stat details; + struct timespec times[2]; + const char *cursor = content; + size_t remaining = length; + int descriptor = -1; + int directory = -1; + int saved_error = 0; + int result = 0; + + temporary[0] = '\0'; + /* Alias Store documents are a compiled per-domain view of aliases.d, + * rather than a one-to-one local document. Public alias operations + * therefore update aliases.d transactionally before the manager compiles + * that view. */ + if (strchr(name, '/')) + return 1; + if (snprintf(path, sizeof(path), "%s/%s", XPL_DEFAULT_CONFIG_DIR, name) >= + (int)sizeof(path) || + snprintf(temporary, sizeof(temporary), "%s.tmp.XXXXXX", path) >= + (int)sizeof(temporary)) { + errno = ENAMETOOLONG; + goto done; + } + if (lstat(path, &details) != 0 || !S_ISREG(details.st_mode)) { + fprintf(stderr, + "bongo-admin: configuration mirror is missing or unsafe: %s\n", + path); + errno = EINVAL; + goto done; + } + times[0] = details.st_atim; + times[1].tv_sec = (time_t)modified; + times[1].tv_nsec = 0; + if ((uint64_t)times[1].tv_sec != modified || + (descriptor = mkstemp(temporary)) < 0) + goto done; + while (remaining) { + ssize_t written = write(descriptor, cursor, remaining); + + if (written < 0) { + if (errno == EINTR) + continue; + goto done; + } + cursor += written; + remaining -= (size_t)written; + } + if (fchmod(descriptor, details.st_mode & 0777) != 0 || + fchown(descriptor, details.st_uid, details.st_gid) != 0 || + futimens(descriptor, times) != 0 || fsync(descriptor) != 0) + goto done; + if (close(descriptor) != 0) { + descriptor = -1; + goto done; + } + descriptor = -1; + if (rename(temporary, path) != 0) + goto done; + temporary[0] = '\0'; + result = 1; + directory = open(XPL_DEFAULT_CONFIG_DIR, + O_RDONLY | O_DIRECTORY | O_CLOEXEC); + if (directory < 0 || fsync(directory) != 0) + fprintf(stderr, + "bongo-admin: warning: cannot sync configuration directory: %s\n", + strerror(errno)); + +done: + saved_error = errno; + if (descriptor >= 0) + close(descriptor); + if (directory >= 0) + close(directory); + if (!result && temporary[0]) + unlink(temporary); + errno = saved_error; + if (!result) + fprintf(stderr, + "bongo-admin: cannot update configuration mirror %s: %s\n", + path, strerror(errno)); + return result; +} + static int AccessConfiguration(const char *name, int replace) { BongoJsonNode *root = NULL; char *content = NULL; + char *original = NULL; size_t length = 0; + uint64_t modified = 0; int conn_started = 0; int msg_started = 0; int result = 1; @@ -309,11 +402,27 @@ AccessConfiguration(const char *name, int replace) goto done; } if (replace) { + if (!strchr(name, '/') && + (!NMAPReadConfigFile(name, &original) || !original)) { + fprintf(stderr, + "bongo-admin: cannot snapshot configuration %s\n", name); + goto done; + } if (!NMAPReplaceConfigFile(name, content, length)) { fprintf(stderr, "bongo-admin: cannot replace configuration %s\n", name); goto done; } + if (!strchr(name, '/') && + (!NMAPReadConfigFileModified(name, &modified) || + !WriteConfigurationMirror(name, content, length, modified))) { + if (!original || + !NMAPReplaceConfigFile(name, original, strlen(original))) + fprintf(stderr, + "bongo-admin: CRITICAL: cannot roll back configuration %s\n", + name); + goto done; + } openlog("bongo-admin", LOG_PID, LOG_AUTHPRIV); syslog(LOG_NOTICE, "administrator replaced Bongo configuration %s", name); @@ -334,6 +443,8 @@ AccessConfiguration(const char *name, int replace) done: if (root) BongoJsonNodeFree(root); + if (original) + MemFree(original); if (content) { if (replace) free(content); diff --git a/src/apps/manager/manager.c b/src/apps/manager/manager.c index f398c7c..3b536bb 100644 --- a/src/apps/manager/manager.c +++ b/src/apps/manager/manager.c @@ -834,9 +834,7 @@ SyncConfigurationDocument(const char *storeName, const char *path) result = SetConfigurationFileTime(path, &details, storeModified); goto done; } - if ((uint64_t)details.st_mtime > storeModified || - ((uint64_t)details.st_mtime == storeModified && - details.st_mtim.tv_nsec != 0)) { + if ((uint64_t)details.st_mtime > storeModified) { if (!SyncConfigurationContent(storeName, path, local) || !NMAPReadConfigFileModified(storeName, &storeModified)) goto done;