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
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}`);
}
}
}

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]));
// 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);
} 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;
Comment on lines +73 to +75
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}`);
}
}

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