Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
346864c
mail: DB schema, SQL views, and roles script (#67)
cmyers-mieweb Oct 8, 2026
3e668bc
mail: /api/v1/mail-accounts resource (#67)
cmyers-mieweb Oct 8, 2026
d95d2a8
mail: per-domain enablement, DKIM keys, and Check DNS (#67)
cmyers-mieweb Oct 8, 2026
733d303
mail: mail-host claim and agent snapshot section (#67)
cmyers-mieweb Oct 8, 2026
6156cd1
mail: one-click unsubscribe - keys, tokens, suppressions, public /u (…
cmyers-mieweb Oct 8, 2026
ce21fc0
mail: OpenAPI spec for mail endpoints (#67)
cmyers-mieweb Oct 8, 2026
e5baba4
agent: service groups via AGENT_SERVICES (#67)
cmyers-mieweb Oct 8, 2026
fed174f
agent: mail service group - Postfix/Dovecot/OpenDKIM + mail helper (#67)
cmyers-mieweb Oct 8, 2026
c2f3b10
mail: packaging and manager image (#67)
cmyers-mieweb Oct 8, 2026
ec8cd7c
mail: dev stack (#67)
cmyers-mieweb Oct 8, 2026
897cca2
mail UI: email accounts page with suppressions (#67)
cmyers-mieweb Oct 8, 2026
21e61d9
mail UI: domain enablement, agents mail-host, settings (#67)
cmyers-mieweb Oct 8, 2026
616a583
mail: docs (#67)
cmyers-mieweb Oct 8, 2026
ea39ace
mail: manager-side review fixes (PR #495)
cmyers-mieweb Oct 12, 2026
ff9da06
mail: opensource-mail-helper is its own component, as a milter (PR #495)
cmyers-mieweb Oct 12, 2026
2100142
agent: only stop mail units this agent started (PR #495)
cmyers-mieweb Oct 13, 2026
f9b7766
Use manager URL for unsubscribe links
cmyers-mieweb Oct 26, 2026
28fbc0e
Update database host configuration logic
cmyers-mieweb Oct 26, 2026
d95389c
Reject null sender on submission
Copilot Oct 26, 2026
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
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
.DEFAULT_GOAL := help

COMPONENTS := agent mie-opensource-landing manager-control-program create-a-container
COMPONENTS := agent mail-helper mie-opensource-landing manager-control-program create-a-container
PACKAGER ?= deb

# Forwarded to every component Makefile.
Expand Down
10 changes: 10 additions & 0 deletions agent/.fpm
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,15 @@
--depends ca-certificates
--depends ssl-cert
--depends dnsmasq
--deb-suggests postfix
--deb-suggests postfix-pgsql
--deb-suggests postfix-mysql
--deb-suggests dovecot-core
--deb-suggests dovecot-imapd
--deb-suggests dovecot-lmtpd
--deb-suggests dovecot-pgsql
--deb-suggests dovecot-mysql
--deb-suggests opendkim
--deb-suggests opensource-mail-helper
--deb-no-default-config-files
--after-install contrib/postinstall.sh
76 changes: 70 additions & 6 deletions agent/src/apply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,33 +10,52 @@ import fs from 'fs';
import path from 'path';
import { execFileSync } from 'child_process';
import ejs from 'ejs';
import { reloadOrRestartService, restartService, sighupService } from './system';
import { chownByName, reloadOrRestartService, restartService, sighupService } from './system';
import { log, commandOutput } from './log';
import type { AgentConfig } from './config';
import type { AgentConfig, ServiceGroup } from './config';
import type { ApplyResult, SiteConfig } from './types';

const TEMPLATES_DIR = path.join(__dirname, '..', 'templates');

/** Binaries each service group needs; missing ones are reported at check-in
* (the manager refuses the mail-host claim until they're installed). */
export const GROUP_BINARIES: Record<ServiceGroup, string[]> = {
nginx: ['nginx'],
dnsmasq: ['dnsmasq'],
mail: ['postfix', 'dovecot', 'opendkim'],
};

interface RenderedFile {
dest: string;
content: string;
/** File mode for atomic writes (e.g. 0o600 for configs holding secrets). */
mode?: number;
/** `user:group` to chown to after writing (e.g. DKIM keys → opendkim). */
owner?: string;
}

export interface ManagedService {
/** systemd unit name (also the key reported at check-in). */
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;
/** 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>;
/** Commands run after staging files, before `test` (e.g. `postmap -F` to
* compile the SNI hash map). A failure rolls the files back. */
postWrite?: string[][];
/** Command that validates the staged config before it is kept. */
test?: string[];
/** Reload/restart after a successful apply. */
reload(changedFiles: string[]): Promise<void>;
}

function renderTemplate(template: string, data: object): Promise<string> {
/** Render a template from the shared templates dir (used by mail.ts too). */
export function renderTemplate(template: string, data: object): Promise<string> {
return ejs.renderFile(path.join(TEMPLATES_DIR, template), data);
}

Expand All @@ -51,6 +70,7 @@ function run(cmd: string[]): string {
export const services: ManagedService[] = [
{
unit: 'nginx',
group: 'nginx',
async render(config, agent) {
return [{
dest: '/etc/nginx/nginx.conf',
Expand All @@ -73,6 +93,7 @@ export const services: ManagedService[] = [
},
{
unit: 'dnsmasq',
group: 'dnsmasq',
async render(config) {
const site = config.site;
// Skip dnsmasq management until the site's DHCP/DNS settings are fully
Expand Down Expand Up @@ -112,12 +133,28 @@ function readIfExists(file: string): string | null {

// Write via temp file + rename so a crash mid-write can never leave a
// truncated config on disk.
function writeFileAtomic(dest: string, content: string, mode?: number): void {
function writeFileAtomic(dest: string, content: string, mode?: number, owner?: string): void {
const tmp = `${dest}.tmp-${process.pid}`;
fs.writeFileSync(tmp, content, mode !== undefined ? { mode } : {});
if (owner) chownByName(tmp, owner);
fs.renameSync(tmp, dest);
}

/** Re-apply desired mode/owner to files whose content already matches. */
function reconcileAttributes(svc: ManagedService, files: { dest: string; mode?: number; owner?: string }[]): void {
for (const f of files) {
try {
if (f.mode !== undefined && (fs.statSync(f.dest).mode & 0o7777) !== f.mode) {
fs.chmodSync(f.dest, f.mode);
log.info(`${svc.unit}: fixed mode on ${f.dest}`);
}
if (f.owner) chownByName(f.dest, f.owner);
} catch (err) {
log.warn(`${svc.unit}: could not reconcile attributes on ${f.dest}: ${(err as Error).message}`);
}
Comment on lines +152 to +154
}
}

export async function applyService(
svc: ManagedService,
config: SiteConfig,
Expand All @@ -133,28 +170,55 @@ export async function applyService(
const current = new Map(files.map((f) => [f.dest, readIfExists(f.dest)]));
const changed = files.filter((f) => current.get(f.dest) !== f.content).map((f) => f.dest);
if (changed.length === 0) {
// Contents match, but desired mode/owner must still be reconciled — a
// DKIM key or helper config accidentally left world-readable would
// otherwise stay exposed forever.
reconcileAttributes(svc, files);
log.debug(`${svc.unit}: config unchanged, nothing to do`);
return 'success';
}

log.info(`${svc.unit}: ${changed.length} file(s) changed, applying: ${changed.join(', ')}`);

const modeByDest = new Map(files.map((f) => [f.dest, f.mode]));
const ownerByDest = new Map(files.map((f) => [f.dest, f.owner]));
Comment thread
Copilot marked this conversation as resolved.
Resolved
// Stage the new files (previous contents kept in memory for rollback).
for (const f of files) {
fs.mkdirSync(path.dirname(f.dest), { recursive: true });
writeFileAtomic(f.dest, f.content, f.mode);
writeFileAtomic(f.dest, f.content, f.mode, f.owner);
log.debug(`${svc.unit}: wrote ${f.dest}`);
}

const rollback = () => {
for (const [dest, prev] of current) {
if (prev === null) fs.rmSync(dest, { force: true });
else writeFileAtomic(dest, prev, modeByDest.get(dest));
else writeFileAtomic(dest, prev, modeByDest.get(dest), ownerByDest.get(dest));
}
// Post-write commands may have produced sidecar artifacts from the new
// sources (e.g. postmap compiling sni_map.db) — regenerate them from the
// restored files so no stale compiled state survives the rollback.
for (const cmd of svc.postWrite ?? []) {
try {
run(cmd);
} catch (err) {
log.error(`${svc.unit}: post-write command failed during rollback (\`${cmd.join(' ')}\`): ${(err as Error).message}`);
}
}
log.debug(`${svc.unit}: rolled back to previous config`);
};

for (const cmd of svc.postWrite ?? []) {
try {
run(cmd);
Comment thread
Copilot marked this conversation as resolved.
Resolved
} catch (err) {
rollback();
log.error(`${svc.unit}: post-write command failed (\`${cmd.join(' ')}\`), rolled back: ${(err as Error).message}`);
const output = commandOutput(err);
if (output) log.error(`${svc.unit}: post-write output:\n${output}`);
return 'failure';
}
}

if (svc.test) {
const testCmd = svc.test.join(' ');
log.debug(`${svc.unit}: testing config with \`${testCmd}\``);
Expand Down
22 changes: 22 additions & 0 deletions agent/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,33 @@

import { setLogLevel, type LogLevel } from './log';

/** Service groups the agent can run. `mail` is opt-in via AGENT_SERVICES. */
export const KNOWN_SERVICE_GROUPS = ['nginx', 'dnsmasq', 'mail'] as const;
export type ServiceGroup = (typeof KNOWN_SERVICE_GROUPS)[number];

const DEFAULT_SERVICE_GROUPS: ServiceGroup[] = ['nginx', 'dnsmasq'];

export interface AgentConfig {
siteId: number;
managerUrl: string;
apiKey?: string;
stateDir: string;
logLevel: LogLevel;
/** Enabled service groups (AGENT_SERVICES; unset means nginx,dnsmasq). */
services: ServiceGroup[];
}

function parseServiceGroups(raw: string | undefined): ServiceGroup[] {
if (!raw || !raw.trim()) return [...DEFAULT_SERVICE_GROUPS];
const groups: ServiceGroup[] = [];
for (const part of raw.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean)) {
if ((KNOWN_SERVICE_GROUPS as readonly string[]).includes(part)) {
if (!groups.includes(part as ServiceGroup)) groups.push(part as ServiceGroup);
} else {
throw new Error(`AGENT_SERVICES contains unknown service group "${part}" (known: ${KNOWN_SERVICE_GROUPS.join(', ')})`);
}
}
return groups;
}

export function loadConfig(env: NodeJS.ProcessEnv = process.env): AgentConfig {
Expand All @@ -26,5 +47,6 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AgentConfig {
// Set by systemd from StateDirectory=; fallback for manual runs.
stateDir: env.STATE_DIRECTORY || '/var/lib/opensource-agent',
logLevel,
services: parseServiceGroups(env.AGENT_SERVICES),
};
}
57 changes: 50 additions & 7 deletions agent/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,22 +13,29 @@
import os from 'os';
import { loadConfig, type AgentConfig } from './config';
import { State } from './state';
import { getPrimaryIpv4, getServiceState, disconnectSystemBus } from './system';
import { getPrimaryIpv4, getServiceState, findMissingBinaries, disconnectSystemBus } from './system';
import { checkin } from './api';
import { services, applyService } from './apply';
import { services, GROUP_BINARIES, applyService, type ManagedService } from './apply';
import { mailServices, ensureMailProvisioned, syncMailUnits, stopMailUnitsIfRunning, gcMailboxes, missingMailHelper } from './mail';
import { reconcileVolumes } from './volumes';
import { log } from './log';
import type { CheckinRequest, ServiceStatus } from './types';
import type { CheckinRequest, ServiceStatus, SiteConfig } from './types';

// Safety cap: a flapping server-side config can't keep a single run alive
// forever; the timer starts a fresh run 30s later anyway.
const MAX_PASSES = 5;

const ALL_SERVICES: ManagedService[] = [...services, ...mailServices];

function enabledServices(cfg: AgentConfig): ManagedService[] {
return ALL_SERVICES.filter((svc) => cfg.services.includes(svc.group));
}

async function buildCheckinBody(cfg: AgentConfig, state: State): Promise<CheckinRequest> {
const serviceStatus: Record<string, ServiceStatus> = {};
for (const svc of services) {
for (const svc of enabledServices(cfg)) {
serviceStatus[svc.unit] = {
state: await getServiceState(svc.unit),
state: await getServiceState(svc.statusUnit ?? svc.unit),
lastApply: state.lastApply[svc.unit] ?? 'unknown',
};
}
Expand All @@ -38,6 +45,12 @@ async function buildCheckinBody(cfg: AgentConfig, state: State): Promise<Checkin
currentTime: Math.floor(Date.now() / 1000),
ipv4Address: getPrimaryIpv4(),
services: serviceStatus,
enabledServices: cfg.services,
missingBinaries: [
...findMissingBinaries(cfg.services.flatMap((g) => GROUP_BINARIES[g])),
// The helper is its own package under /opt, not a PATH binary.
...(cfg.services.includes('mail') && missingMailHelper() ? ['opensource-mail-helper'] : []),
],
};
// Report any not-yet-delivered volume results (persisted across runs). Only
// include the field when there is something to report so the manager isn't
Expand All @@ -48,10 +61,35 @@ async function buildCheckinBody(cfg: AgentConfig, state: State): Promise<Checkin
return body;
}

/** Post-apply mail housekeeping: unit enable/start (holder) or stop/disable
* (claim lost / group off), then mailbox GC. Runs even when the `mail` group
* is NOT enabled — removing `mail` from AGENT_SERVICES must still stop a
* mail stack this agent started (and ONLY one it started: state.mailManaged
* guards against touching a host's own unrelated postfix/dovecot).
* Failures are logged, not fatal — the next timer run retries. */
async function syncMailLifecycle(cfg: AgentConfig, config: SiteConfig, state: State): Promise<void> {
try {
if (cfg.services.includes('mail')) {
const mailManaged = await syncMailUnits(config, state.mailManaged);
if (mailManaged !== state.mailManaged) {
state.mailManaged = mailManaged;
state.save();
}
if (config.mail) gcMailboxes(config.mail);
} else if (state.mailManaged) {
await stopMailUnitsIfRunning();
state.mailManaged = false;
state.save();
}
} catch (err) {
log.error(`mail: unit lifecycle sync failed: ${(err as Error).message}`);
Comment on lines +84 to +85
}
}

async function main(): Promise<void> {
const cfg = loadConfig();
const state = State.load(cfg.stateDir);
log.info(`agent starting: siteId=${cfg.siteId}, manager=${cfg.managerUrl}`);
log.info(`agent starting: siteId=${cfg.siteId}, manager=${cfg.managerUrl}, services=${cfg.services.join(',')}`);
log.debug(`state dir=${cfg.stateDir}, saved etag=${state.etag ?? '(none)'}`);
if (Object.keys(state.pendingVolumeResults).length > 0) {
log.debug(`carrying ${Object.keys(state.pendingVolumeResults).length} pending volume result(s) from a prior run`);
Expand All @@ -77,9 +115,14 @@ async function main(): Promise<void> {
}

log.info(`check-in: new config received (etag=${result.etag ?? '(none)'}), applying`);
for (const svc of services) {
// Mail dirs must exist before the mail configs reference them.
if (cfg.services.includes('mail') && result.config.mail) {
ensureMailProvisioned(cfg);
}
for (const svc of enabledServices(cfg)) {
state.lastApply[svc.unit] = await applyService(svc, result.config, cfg);
}
await syncMailLifecycle(cfg, result.config, state);

// Ensure volume directories exist from the new snapshot; stash results in
// persistent state so they are reported on the next check-in and survive a
Expand Down
Loading
Loading