Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions agent/src/apply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>;
/** 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<RenderedFile[] | null>;
Expand Down
2 changes: 1 addition & 1 deletion agent/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ async function buildCheckinBody(cfg: AgentConfig, state: State): Promise<Checkin
const serviceStatus: Record<string, ServiceStatus> = {};
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',
};
}
Expand Down
27 changes: 24 additions & 3 deletions agent/src/mail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
enableAndStartServices,
stopAndDisableServices,
getServiceState,
getServiceSubState,
reloadOrRestartService,
restartService,
} from './system';
Expand Down Expand Up @@ -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<string> {
const [instance, mainActive, mainSub] = await Promise.all([
getServiceState('postfix@-'),
getServiceState('postfix'),
getServiceSubState('postfix'),
]);
return pickPostfixState(instance, mainActive, mainSub);
}

export const mailServices: ManagedService[] = [
{
unit: 'opendkim',
Expand Down Expand Up @@ -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;
Expand Down
18 changes: 15 additions & 3 deletions agent/src/system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,21 +106,33 @@ 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<string> {
/** systemd unit property lookup shared by the state getters. */
async function getUnitProperty(unit: string, property: string): Promise<string> {
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.
return 'unknown';
}
}

/** systemd ActiveState for a unit (active, inactive, failed, ...). */
export async function getServiceState(unit: string): Promise<string> {
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<string> {
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<void> {
Expand Down
19 changes: 18 additions & 1 deletion agent/templates/mail/dovecot.conf.ejs
Original file line number Diff line number Diff line change
Expand Up @@ -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 %>
<%_ } _%>
Expand All @@ -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.
Expand Down
27 changes: 27 additions & 0 deletions agent/test/mail-template.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
Expand Down
32 changes: 32 additions & 0 deletions agent/test/postfix-status.test.js
Original file line number Diff line number Diff line change
@@ -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');
});
Loading