diff --git a/agent/src/apply.ts b/agent/src/apply.ts index c4da8f7f..fb26397e 100644 --- a/agent/src/apply.ts +++ b/agent/src/apply.ts @@ -39,9 +39,10 @@ export interface ManagedService { unit: string; /** Service group this unit belongs to (AGENT_SERVICES opt-in). */ group: ServiceGroup; - /** Unit whose ActiveState is reported at check-in when it differs from - * `unit` (Debian's postfix.service is a wrapper around postfix@-). */ - statusUnit?: string; + /** Overrides the state reported at check-in when `unit`'s own ActiveState + * can't answer (e.g. Debian's postfix.service/postfix@- split varies by + * release — see postfixReportedState in mail.ts). */ + status?: () => Promise; /** Render all managed files. Returns null when there is nothing to manage * yet (e.g. dnsmasq before the site exists). */ render(config: SiteConfig, agent: AgentConfig): Promise; diff --git a/agent/src/index.ts b/agent/src/index.ts index dde4a33d..eb5013ab 100644 --- a/agent/src/index.ts +++ b/agent/src/index.ts @@ -35,7 +35,7 @@ async function buildCheckinBody(cfg: AgentConfig, state: State): Promise = {}; for (const svc of enabledServices(cfg)) { serviceStatus[svc.unit] = { - state: await getServiceState(svc.statusUnit ?? svc.unit), + state: await (svc.status ? svc.status() : getServiceState(svc.unit)), lastApply: state.lastApply[svc.unit] ?? 'unknown', }; } diff --git a/agent/src/mail.ts b/agent/src/mail.ts index 5905d878..645b10da 100644 --- a/agent/src/mail.ts +++ b/agent/src/mail.ts @@ -24,6 +24,7 @@ import { enableAndStartServices, stopAndDisableServices, getServiceState, + getServiceSubState, reloadOrRestartService, restartService, } from './system'; @@ -158,6 +159,28 @@ function mailOf(config: SiteConfig): MailConfig | null { return config.mail ?? null; } +/** Which state to report for postfix (exported for tests). Debian <= 12 runs + * the real daemon on the postfix@- instance behind a RemainAfterExit + * postfix.service wrapper — the wrapper reads active (exited) even when the + * MTA is down. Debian 13 runs master directly in postfix.service and leaves + * postfix@- inactive. Prefer the instance when it reports anything + * meaningful; trust postfix.service only when it actually has a running + * main process. */ +export function pickPostfixState(instance: string, mainActive: string, mainSub: string): string { + if (instance !== 'inactive' && instance !== 'unknown') return instance; + if (mainActive === 'active' && mainSub === 'running') return 'active'; + return instance; +} + +async function postfixReportedState(): Promise { + const [instance, mainActive, mainSub] = await Promise.all([ + getServiceState('postfix@-'), + getServiceState('postfix'), + getServiceSubState('postfix'), + ]); + return pickPostfixState(instance, mainActive, mainSub); +} + export const mailServices: ManagedService[] = [ { unit: 'opendkim', @@ -215,9 +238,7 @@ export const mailServices: ManagedService[] = [ { unit: 'postfix', group: 'mail', - // Debian's postfix.service is a wrapper; the real daemon state lives on - // the postfix@- instance. - statusUnit: 'postfix@-', + status: postfixReportedState, async render(config, agent) { const mail = mailOf(config); if (!mail) return null; diff --git a/agent/src/system.ts b/agent/src/system.ts index bc8cc5d2..241db9f4 100644 --- a/agent/src/system.ts +++ b/agent/src/system.ts @@ -106,14 +106,14 @@ function unitName(unit: string): string { return unit.includes('.') ? unit : `${unit}.service`; } -/** systemd ActiveState for a unit (active, inactive, failed, ...). */ -export async function getServiceState(unit: string): Promise { +/** systemd unit property lookup shared by the state getters. */ +async function getUnitProperty(unit: string, property: string): Promise { try { const mgr = await getManager(); const path = await mgr.LoadUnit(unitName(unit)); const obj = await getBus().getProxyObject(SYSTEMD, path); const props = obj.getInterface(PROPERTIES_IFACE) as unknown as DbusProperties; - const state = await props.Get(UNIT_IFACE, 'ActiveState'); + const state = await props.Get(UNIT_IFACE, property); return state.value || 'unknown'; } catch { // Any D-Bus failure just means the state can't be determined right now. @@ -121,6 +121,18 @@ export async function getServiceState(unit: string): Promise { } } +/** systemd ActiveState for a unit (active, inactive, failed, ...). */ +export async function getServiceState(unit: string): Promise { + return getUnitProperty(unit, 'ActiveState'); +} + +/** systemd SubState for a unit (running, exited, dead, ...) — distinguishes + * a daemon that is actually running from a RemainAfterExit wrapper's + * active (exited). */ +export async function getServiceSubState(unit: string): Promise { + return getUnitProperty(unit, 'SubState'); +} + /** Enqueue a systemd job and wait for it to finish; throws unless the job * completes with result "done". */ async function runJob(method: 'ReloadOrRestartUnit' | 'RestartUnit' | 'StartUnit' | 'StopUnit', unit: string): Promise { diff --git a/agent/templates/mail/dovecot.conf.ejs b/agent/templates/mail/dovecot.conf.ejs index 9b1ad670..a1b964d5 100644 --- a/agent/templates/mail/dovecot.conf.ejs +++ b/agent/templates/mail/dovecot.conf.ejs @@ -29,7 +29,23 @@ local_name <%= d.name %> { # Accounts live in the manager's database (mail_accounts_v view, read-only # mail_dovecot user). Hash scheme is {ARGON2ID} (p=1). sql_driver = <%= db.dovecotDriver %> -<%= db.dovecotDriver %> <%= db.dovecotHost %> { +<%_ if (db.dovecotDriver === 'pgsql') { _%> +<%_ /* pgsql has no per-key settings — libpq key/values go in the + pgsql_parameters string list. */ _%> +pgsql <%= db.dovecotHost %> { + parameters { +<%_ if (db.dovecotParams.dbname) { _%> + dbname = <%= db.dovecotParams.dbname %> +<%_ } _%> + user = <%= db.dovecotUser %> +<%_ if (db.dovecotPassword) { _%> + password = <%= db.dovecotPassword %> +<%_ } _%> + } +} +<%_ } else { _%> +<%_ /* mysql has native mysql_dbname/mysql_user/mysql_password settings. */ _%> +mysql <%= db.dovecotHost %> { <%_ if (db.dovecotParams.dbname) { _%> dbname = <%= db.dovecotParams.dbname %> <%_ } _%> @@ -38,6 +54,7 @@ sql_driver = <%= db.dovecotDriver %> password = <%= db.dovecotPassword %> <%_ } _%> } +<%_ } _%> # Submission (protocol smtp, via Postfix SASL) requires can_send; IMAP stays # readable for accounts on domains that can no longer send. diff --git a/agent/test/mail-template.test.js b/agent/test/mail-template.test.js index 2e5e4cdd..4716a46f 100644 --- a/agent/test/mail-template.test.js +++ b/agent/test/mail-template.test.js @@ -107,6 +107,33 @@ test('dovecot.conf: SQL auth with the can_send smtp gate, per-domain certs, post assert.match(out, /auth_allow_cleartext = no/); }); +test('dovecot.conf: pgsql nests libpq key/values in parameters { } (Dovecot 2.4)', async () => { + const out = await render('mail/dovecot.conf.ejs'); + // pgsql has no per-key settings — a bare `dbname =` in the pgsql block is + // rejected by doveconf on Debian 13 (regression: "Unknown setting: dbname"). + assert.match(out, /pgsql \/var\/run\/postgresql \{\s*parameters \{[^}]*dbname = cc[^}]*user = mail_dovecot[^}]*\}/); + // Peer auth over the socket — no password line at all. + assert.doesNotMatch(out, /password =/); +}); + +test('dovecot.conf: pgsql password lands inside parameters when set', async () => { + const out = await render('mail/dovecot.conf.ejs', { + ...context, + db: { ...context.db, dovecotHost: 'db.example', dovecotPassword: 'hunter2' }, + }); + assert.match(out, /pgsql db\.example \{\s*parameters \{[^}]*password = hunter2[^}]*\}/); +}); + +test('dovecot.conf: mysql keeps its native dbname/user settings in the block', async () => { + const out = await render('mail/dovecot.conf.ejs', { + ...context, + db: { ...context.db, dovecotDriver: 'mysql', dovecotHost: '/var/run/mysqld/mysqld.sock', dovecotPassword: 'hunter2' }, + }); + assert.match(out, /sql_driver = mysql/); + assert.doesNotMatch(out, /parameters \{/); + assert.match(out, /mysql \/var\/run\/mysqld\/mysqld\.sock \{[^}]*dbname = cc[^}]*user = mail_dovecot[^}]*password = hunter2[^}]*\}/); +}); + test('opendkim: unix socket in the postfix chroot, state-dir tables, oversigned List-Unsubscribe', async () => { const conf = await render('mail/opendkim.conf.ejs'); assert.match(conf, /Socket\s+local:\/var\/spool\/postfix\/opendkim\/opendkim\.sock/); diff --git a/agent/test/postfix-status.test.js b/agent/test/postfix-status.test.js new file mode 100644 index 00000000..bf9c97f1 --- /dev/null +++ b/agent/test/postfix-status.test.js @@ -0,0 +1,32 @@ +/** + * Postfix check-in state decision (node --test). + * + * Debian <= 12: postfix.service is a RemainAfterExit wrapper (active/exited) + * and the real daemon lives on postfix@-. Debian 13: master runs directly in + * postfix.service and postfix@- stays inactive. pickPostfixState must report + * the truth on both layouts. + */ + +const test = require('node:test'); +const assert = require('node:assert'); +const { pickPostfixState } = require('../dist/mail.js'); + +test('Debian <= 12: instance unit state wins', () => { + assert.strictEqual(pickPostfixState('active', 'active', 'exited'), 'active'); + assert.strictEqual(pickPostfixState('failed', 'active', 'exited'), 'failed'); + assert.strictEqual(pickPostfixState('activating', 'active', 'exited'), 'activating'); +}); + +test('Debian <= 12: wrapper active (exited) does not mask a dead instance', () => { + assert.strictEqual(pickPostfixState('inactive', 'active', 'exited'), 'inactive'); +}); + +test('Debian 13: running master in postfix.service reports active', () => { + assert.strictEqual(pickPostfixState('inactive', 'active', 'running'), 'active'); + assert.strictEqual(pickPostfixState('unknown', 'active', 'running'), 'active'); +}); + +test('nothing running anywhere reports the instance state', () => { + assert.strictEqual(pickPostfixState('inactive', 'inactive', 'dead'), 'inactive'); + assert.strictEqual(pickPostfixState('unknown', 'unknown', 'unknown'), 'unknown'); +});