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
8 changes: 8 additions & 0 deletions src/commands/open.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export const args = zod.tuple([
' wp, wordpress, web Open the site\n' +
' admin, wpadmin, dashboard Open the WordPress dashboard (auto-login)\n' +
' phpmyadmin, pma Open phpMyAdmin (auto-login)\n' +
' mail, mailpit Open the Mailpit email catcher\n' +
' plugins Open the plugins folder\n' +
' uploads, media Open the uploads folder\n' +
' data Open the Kiqr project data folder',
Expand All @@ -42,6 +43,8 @@ const ALIASES: Record<string, string> = {
web: 'wp',
phpmyadmin: 'phpmyadmin',
pma: 'phpmyadmin',
mail: 'mail',
mailpit: 'mail',
admin: 'admin',
wpadmin: 'admin',
dashboard: 'admin',
Expand Down Expand Up @@ -110,6 +113,11 @@ export default function Open({args}: Props) {
case 'phpmyadmin':
openTarget(`http://${phpMyAdminHostname}:5477`);
break;
case 'mail':
// Mailpit is an agent-level service shared by every project, reached
// at a fixed host rather than a per-project hostname.
openTarget('http://mail.lvh.me:5477');
break;
case 'admin':
if (lc?.login_secret) {
openTarget(`http://${hostname}:5477/wp-admin?kiqr_login=${lc.login_secret}`);
Expand Down
2 changes: 1 addition & 1 deletion src/commands/restart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {useRef, useState} from 'react';
import type {Step} from '../components/StepRunner.js';
import StepRunner from '../components/StepRunner.js';
import {ensureAgentRunning} from '../lib/agent.js';
import {writeApacheConf} from '../lib/apache-conf.js';
import {writeProjectCompose} from '../lib/compose.js';
import {readLocalConfig, readProjectConfig, writeLocalConfig} from '../lib/config.js';
import {
Expand All @@ -16,7 +17,6 @@ import {
import {buildProjectHostname} from '../lib/hostname.js';
import {validateWordPressPhp} from '../lib/image-tags.js';
import {writeMuPlugin} from '../lib/mu-plugin.js';
import {writeApacheConf} from '../lib/apache-conf.js';
import {
getProjectPluginsDir,
getProjectRuntimeDir,
Expand Down
2 changes: 1 addition & 1 deletion src/commands/up.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {useRef, useState} from 'react';
import type {Step} from '../components/StepRunner.js';
import StepRunner from '../components/StepRunner.js';
import {ensureAgentRunning} from '../lib/agent.js';
import {writeApacheConf} from '../lib/apache-conf.js';
import {writeProjectCompose} from '../lib/compose.js';
import {
projectConfigExists,
Expand All @@ -24,7 +25,6 @@ import {
import {buildProjectHostname} from '../lib/hostname.js';
import {validateWordPressPhp} from '../lib/image-tags.js';
import {writeMuPlugin} from '../lib/mu-plugin.js';
import {writeApacheConf} from '../lib/apache-conf.js';
import {
getProjectPluginsDir,
getProjectRuntimeDir,
Expand Down
23 changes: 22 additions & 1 deletion src/lib/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,19 @@ export const KIQR_NETWORK = 'kiqr';
export const AGENT_PROJECT = 'kiqr-agent';
export const AGENT_PORT = 5477;

export const AGENT_CONTAINERS = ['kiqr-traefik', 'kiqr-splash'] as const;
export const AGENT_CONTAINERS = ['kiqr-traefik', 'kiqr-splash', 'kiqr-mailpit'] as const;

const TRAEFIK_CONTAINER = 'kiqr-traefik';
const SPLASH_CONTAINER = 'kiqr-splash';
const MAILPIT_CONTAINER = 'kiqr-mailpit';

/**
* Mailpit catches every outgoing email from any kiqr project (WordPress is
* pointed at it over SMTP via the per-project mu-plugin) and exposes a web UI
* at `mail.lvh.me`. It listens for SMTP on 1025 and serves its web UI on 8025.
*/
const MAILPIT_HOST = 'mail.lvh.me';
const MAILPIT_WEB_PORT = 8025;

export interface AgentStatus {
running: boolean;
Expand Down Expand Up @@ -91,6 +100,18 @@ export function generateAgentCompose(agentDir: string): string {
networks: [KIQR_NETWORK],
restart: 'unless-stopped',
},
mailpit: {
image: 'axllent/mailpit:v1.30.1',
container_name: MAILPIT_CONTAINER,
labels: [
'traefik.enable=true',
`traefik.http.routers.kiqr-mailpit.rule=Host(\`${MAILPIT_HOST}\`)`,
'traefik.http.routers.kiqr-mailpit.entrypoints=web',
`traefik.http.services.kiqr-mailpit.loadbalancer.server.port=${MAILPIT_WEB_PORT}`,
],
networks: [KIQR_NETWORK],
restart: 'unless-stopped',
},
},
networks: {
[KIQR_NETWORK]: {
Expand Down
13 changes: 13 additions & 0 deletions src/lib/mu-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,19 @@ add_action('init', function () {
}
}, 0);

// Route all outgoing mail to the kiqr agent's Mailpit catcher over SMTP.
// The WordPress container shares the kiqr network, so it resolves the
// "kiqr-mailpit" container by name. Mailpit accepts unauthenticated,
// unencrypted SMTP on port 1025.
add_action('phpmailer_init', function ($phpmailer) {
$phpmailer->isSMTP();
$phpmailer->Host = 'kiqr-mailpit';
$phpmailer->Port = 1025;
$phpmailer->SMTPAuth = false;
$phpmailer->SMTPSecure = '';
$phpmailer->SMTPAutoTLS = false;
});

// Inject LiveReload script in development.
//
// The script src points at http://localhost on the host machine, which is only
Expand Down
11 changes: 5 additions & 6 deletions src/lib/share.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,7 @@ export const SHARE_URL_CONTAINER_PATH = '/tmp/kiqr-share-url';
export function resolveProjectContainerId(
projectId: string,
service: string,
exec: (cmd: string) => string = (cmd) =>
execSync(cmd, {stdio: 'pipe'}).toString(),
exec: (cmd: string) => string = (cmd) => execSync(cmd, {stdio: 'pipe'}).toString(),
): string | null {
try {
const out = exec(
Expand Down Expand Up @@ -126,13 +125,13 @@ export function writeShareUrlToContainer(
}
const id = resolveProjectContainerId(projectId, 'wordpress');
if (!id) {
onError?.(new Error(`Could not resolve WordPress container for project ${projectId}`));
onError?.(
new Error(`Could not resolve WordPress container for project ${projectId}`),
);
return;
}
try {
exec(
`docker exec ${id} sh -c 'printf %s ${url} > ${SHARE_URL_CONTAINER_PATH}'`,
);
exec(`docker exec ${id} sh -c 'printf %s ${url} > ${SHARE_URL_CONTAINER_PATH}'`);
} catch (err) {
onError?.(err);
}
Expand Down
6 changes: 3 additions & 3 deletions src/providers/BitnamiRuntimeProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,15 +81,15 @@ export class BitnamiRuntimeProvider implements RuntimeProvider {
"$$fwd_host = !empty($$_SERVER['HTTP_X_FORWARDED_HOST']) ? trim(explode(',', $$_SERVER['HTTP_X_FORWARDED_HOST'])[0]) : ''; " +
"$$fwd_proto = !empty($$_SERVER['HTTP_X_FORWARDED_PROTO']) ? strtolower(trim(explode(',', $$_SERVER['HTTP_X_FORWARDED_PROTO'])[0])) : ''; " +
"$$share_url = ($$fwd_proto === 'https' && is_readable('/tmp/kiqr-share-url')) ? trim(@file_get_contents('/tmp/kiqr-share-url')) : ''; " +
"$$share_parts = $$share_url ? parse_url($$share_url) : false; " +
'$$share_parts = $$share_url ? parse_url($$share_url) : false; ' +
"if (is_array($$share_parts) && !empty($$share_parts['host'])) { " +
" $$host = $$share_parts['host']; " +
" $$proto = strtolower($$share_parts['scheme'] ?? 'https'); " +
" $$_SERVER['HTTP_HOST'] = $$host; " +
"} else { " +
'} else { ' +
" $$host = $$fwd_host ?: ($$_SERVER['HTTP_HOST'] ?? 'localhost'); " +
" $$proto = ($$fwd_proto === 'https') ? 'https' : 'http'; " +
"} " +
'} ' +
"if ($$proto === 'https') { $$_SERVER['HTTPS'] = 'on'; $$_SERVER['SERVER_PORT'] = 443; } " +
"define('WP_HOME', $$proto . '://' . $$host); " +
"define('WP_SITEURL', $$proto . '://' . $$host);",
Expand Down
28 changes: 25 additions & 3 deletions tests/lib/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,7 @@ describe('generateAgentCompose', () => {
const parsed = YAML.parse(yaml);
const command = parsed.services.traefik.command as string[];
expect(
command.some(
(c) => c === '--entrypoints.web.forwardedHeaders.insecure=true',
),
command.some((c) => c === '--entrypoints.web.forwardedHeaders.insecure=true'),
).toBe(true);
});

Expand All @@ -76,6 +74,30 @@ describe('generateAgentCompose', () => {
const labels = parsed.services.splash.labels;
expect(labels.some((l: string) => l.includes('priority=1'))).toBe(true);
});

it('includes the Mailpit email catcher with a pinned image', () => {
const yaml = generateAgentCompose('/tmp/kiqr/agent');
const parsed = YAML.parse(yaml);
expect(parsed.services.mailpit).toBeDefined();
expect(parsed.services.mailpit.image).toBe('axllent/mailpit:v1.30.1');
expect(parsed.services.mailpit.container_name).toBe('kiqr-mailpit');
expect(parsed.services.mailpit.networks).toContain('kiqr');
expect(parsed.services.mailpit.restart).toBe('unless-stopped');
});

it('routes mail.lvh.me to the Mailpit web UI on port 8025', () => {
const yaml = generateAgentCompose('/tmp/kiqr/agent');
const parsed = YAML.parse(yaml);
const labels: string[] = parsed.services.mailpit.labels;
expect(labels).toContain('traefik.enable=true');
expect(labels).toContain(
'traefik.http.routers.kiqr-mailpit.rule=Host(`mail.lvh.me`)',
);
expect(labels).toContain('traefik.http.routers.kiqr-mailpit.entrypoints=web');
expect(labels).toContain(
'traefik.http.services.kiqr-mailpit.loadbalancer.server.port=8025',
);
});
});

describe('getAgentStatus', () => {
Expand Down
11 changes: 10 additions & 1 deletion tests/lib/mu-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ describe('writeMuPlugin', () => {
// production concern; in dev we want pages to render wherever requested.
const content = fs.readFileSync(writeMuPlugin(tmp), 'utf-8');
expect(content).toContain("remove_filter('template_redirect', 'redirect_canonical')");
expect(content).toContain("WPSEO_Frontend");
expect(content).toContain('WPSEO_Frontend');
});

it('rewrites absolute production URLs in the response body to the current dynamic URL', () => {
Expand All @@ -121,6 +121,15 @@ describe('writeMuPlugin', () => {
expect(content).toContain('Content-Type:');
});

it('routes outgoing mail to the kiqr-mailpit SMTP catcher via phpmailer_init', () => {
const content = fs.readFileSync(writeMuPlugin(tmp), 'utf-8');
expect(content).toContain("add_action('phpmailer_init'");
expect(content).toContain('$phpmailer->isSMTP()');
expect(content).toContain("$phpmailer->Host = 'kiqr-mailpit'");
expect(content).toContain('$phpmailer->Port = 1025');
expect(content).toContain('$phpmailer->SMTPAuth = false');
});

it('is deterministic across writes', () => {
const first = fs.readFileSync(writeMuPlugin(tmp), 'utf-8');
const second = fs.readFileSync(writeMuPlugin(tmp), 'utf-8');
Expand Down
2 changes: 1 addition & 1 deletion tests/lib/share.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ describe('writeShareUrlToContainer', () => {
});

describe('clearShareUrlFromContainer', () => {
it('is a no-op when no matching container is running (doesn\'t throw)', () => {
it("is a no-op when no matching container is running (doesn't throw)", () => {
// Same rationale as writeShareUrlToContainer: when the container is
// already gone there is nothing to clean and we mustn't fail the
// tunnel-exit path.
Expand Down
Loading