The standard mail ports as one table, above Listeners #47
No files matched your search
@@ -17,6 +17,7 @@ import { DynamicForm } from '@/components/forms/DynamicForm';
|
||||
import { DynamicViewPage } from '@/components/views/DynamicViewPage';
|
||||
import { LoadingFallback } from '@/components/common/LoadingFallback';
|
||||
import { LegacyProtocolsBanner } from '@/features/hardening/LegacyProtocolsBanner';
|
||||
import { ListenersPage } from '@/features/ports/ListenersPage';
|
||||
import { MetricsPage } from '@/features/metricsConnect/MetricsPage';
|
||||
import { AlertsPage } from '@/features/alerts/AlertsPage';
|
||||
import { ReportsPage } from '@/features/reports/ReportsPage';
|
||||
@@ -367,5 +368,8 @@ function renderView(schema: Schema | null, viewName?: string, id?: string, secti
|
||||
if (viewName === 'x:Alert') {
|
||||
return <AlertsPage viewName={viewName} />;
|
||||
}
|
||||
if (viewName === 'x:NetworkListener') {
|
||||
return <ListenersPage viewName={viewName} />;
|
||||
}
|
||||
return <DynamicList viewName={viewName} />;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { DynamicList } from '@/components/lists/DynamicList';
|
||||
import { PortsTable } from './PortsTable';
|
||||
|
||||
/** Network › Listeners: the standard ports as a table, then every listener, refreshed on a change. */
|
||||
export function ListenersPage({ viewName }: { viewName: string }) {
|
||||
const [version, setVersion] = useState(0);
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PortsTable onChanged={() => setVersion((v) => v + 1)} />
|
||||
<DynamicList key={version} viewName={viewName} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* inbuxa: above Network › Listeners, the standard mail ports as one table
|
||||
* (settings-reorg, second wave): what each is for, its encryption, whether
|
||||
* it's open, and a switch. IMAP, POP3 and ManageSieve (and the sending
|
||||
* ports when legacy mail is off) are switched on Hardening, so their rows
|
||||
* link there. Custom listeners stay in the list below.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Loader2, Network } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { getAccountId, jmapGet, jmapSet } from '@/services/jmap/client';
|
||||
import { useAccountStore } from '@/stores/accountStore';
|
||||
import { useSchemaStore } from '@/stores/schemaStore';
|
||||
import type { JmapSetResponse } from '@/types/jmap';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
import { fetchProtocolPolicy, type ProtocolPolicy } from '@/features/hardening/protocolPolicy';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
classify,
|
||||
encryptionWords,
|
||||
listenerFor,
|
||||
portsOf,
|
||||
SERVICES,
|
||||
type ListenerRecord,
|
||||
type Service,
|
||||
} from './services';
|
||||
|
||||
const OBJECT = 'x:NetworkListener';
|
||||
|
||||
export function PortsTable({ onChanged }: { onChanged?: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const canCreate = useAccountStore((s) => s.hasObjectPermission('sysNetworkListener', 'Create'));
|
||||
const canDestroy = useAccountStore((s) => s.hasObjectPermission('sysNetworkListener', 'Destroy'));
|
||||
const hardeningSection = useSchemaStore((s) => s.viewToSection['CustomComponent/LegacyProtocols']) ?? 'Settings';
|
||||
const [listeners, setListeners] = useState<ListenerRecord[] | null>(null);
|
||||
const [policy, setPolicy] = useState<ProtocolPolicy | null>(null);
|
||||
const [confirming, setConfirming] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const [res] = await jmapGet(OBJECT, getAccountId(OBJECT), null, [
|
||||
'name',
|
||||
'protocol',
|
||||
'bind',
|
||||
'tlsImplicit',
|
||||
'useTls',
|
||||
]);
|
||||
setListeners(((res?.[1] as { list?: ListenerRecord[] })?.list ?? []) as ListenerRecord[]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
jmapGet(OBJECT, getAccountId(OBJECT), null, ['name', 'protocol', 'bind', 'tlsImplicit', 'useTls'])
|
||||
.then(
|
||||
([res]) => live && setListeners(((res?.[1] as { list?: ListenerRecord[] })?.list ?? []) as ListenerRecord[]),
|
||||
)
|
||||
.catch(() => undefined);
|
||||
// An older server has no protocol policy: then nothing is switched on Hardening.
|
||||
fetchProtocolPolicy()
|
||||
.then((p) => live && setPolicy(p))
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!listeners) return null;
|
||||
|
||||
const { byService, custom } = classify(listeners);
|
||||
const sendingOnHardening = policy?.legacyProtocols === 'disabled' && policy.closeSubmission;
|
||||
const onHardening = (s: Service) => s.hardening || (s.group === 'send' && sendingOnHardening);
|
||||
|
||||
const label: Record<string, string> = {
|
||||
smtp: t('ports.smtp', 'Receiving mail from other servers'),
|
||||
submission: t('ports.submission', 'Sending from mail apps'),
|
||||
submissions: t('ports.submissions', 'Sending from mail apps'),
|
||||
https: t('ports.https', 'Webmail, this console, JMAP apps, automatic setup'),
|
||||
http: t('ports.http', 'Redirect to HTTPS, and HTTP certificate checks'),
|
||||
imaps: t('ports.imaps', 'Reading mail in IMAP apps'),
|
||||
imap: t('ports.imap', 'Reading mail in IMAP apps'),
|
||||
pop3s: t('ports.pop3s', 'Downloading mail with POP3'),
|
||||
pop3: t('ports.pop3', 'Downloading mail with POP3'),
|
||||
sieve: t('ports.sieve', 'Editing filters from mail apps (ManageSieve)'),
|
||||
};
|
||||
|
||||
const turnOn = async (s: Service) => {
|
||||
setBusy(s.id);
|
||||
try {
|
||||
const [res] = await jmapSet(OBJECT, getAccountId(OBJECT), { create: { l: listenerFor(s) } });
|
||||
const body = res?.[1] as unknown as JmapSetResponse | undefined;
|
||||
const bad = body?.notCreated?.l;
|
||||
if (bad || !body?.created?.l) throw new Error(bad?.description ?? bad?.type ?? 'Not created');
|
||||
await load();
|
||||
onChanged?.();
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: t('ports.failed', 'Not changed'),
|
||||
description: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const turnOff = async (s: Service, ls: ListenerRecord[]) => {
|
||||
setBusy(s.id);
|
||||
setConfirming(null);
|
||||
try {
|
||||
const [res] = await jmapSet(OBJECT, getAccountId(OBJECT), { destroy: ls.map((l) => l.id) });
|
||||
const body = res?.[1] as unknown as JmapSetResponse | undefined;
|
||||
const bad = Object.values(body?.notDestroyed ?? {})[0];
|
||||
if (bad) throw new Error(bad.description ?? bad.type);
|
||||
await load();
|
||||
onChanged?.();
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: t('ports.failed', 'Not changed'),
|
||||
description: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const row = (s: Service) => {
|
||||
const ls = byService.get(s.id) ?? [];
|
||||
const on = ls.length > 0;
|
||||
return (
|
||||
<tr key={s.id} className="align-top">
|
||||
<td className="py-2 pr-3">
|
||||
<span className="font-mono">{s.port}</span>
|
||||
</td>
|
||||
<td className="py-2 pr-3">{label[s.id]}</td>
|
||||
<td className="py-2 pr-3 text-muted-foreground">
|
||||
{encryptionWords(on ? ls[0] : { tlsImplicit: s.implicitTls, useTls: s.useTls })}
|
||||
</td>
|
||||
<td className={cn('py-2 pr-3', on ? 'text-emerald-600 dark:text-emerald-400' : 'text-muted-foreground')}>
|
||||
{on ? t('ports.open', 'open') : t('ports.closed', 'closed')}
|
||||
</td>
|
||||
<td className="py-2 text-right">
|
||||
{onHardening(s) ? (
|
||||
<Link
|
||||
className="text-xs text-primary hover:underline"
|
||||
to={`/${hardeningSection}/CustomComponent/LegacyProtocols`}
|
||||
>
|
||||
{t('ports.onHardening', 'Switched on Hardening')}
|
||||
</Link>
|
||||
) : confirming === s.id ? (
|
||||
<span className="flex flex-wrap items-center justify-end gap-2 text-xs">
|
||||
<span className="text-destructive">{s.critical}</span>
|
||||
<Button type="button" size="sm" variant="destructive" className="h-7" onClick={() => void turnOff(s, ls)}>
|
||||
{t('ports.confirmOff', 'Close it')}
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="ghost" className="h-7" onClick={() => setConfirming(null)}>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
</span>
|
||||
) : on ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7"
|
||||
disabled={!canDestroy || busy !== null}
|
||||
onClick={() => (s.critical ? setConfirming(s.id) : void turnOff(s, ls))}
|
||||
>
|
||||
{busy === s.id && <Loader2 className="h-3 w-3 animate-spin" />}
|
||||
{t('ports.close', 'Close')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7"
|
||||
disabled={!canCreate || busy !== null}
|
||||
onClick={() => void turnOn(s)}
|
||||
>
|
||||
{busy === s.id && <Loader2 className="h-3 w-3 animate-spin" />}
|
||||
{t('ports.openIt', 'Open')}
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-xl border bg-card p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Network className="h-5 w-5 text-primary" />
|
||||
<p className="font-medium">{t('ports.title', 'The standard ports')}</p>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs text-muted-foreground">
|
||||
<th className="py-1 pr-3 font-medium">{t('ports.port', 'Port')}</th>
|
||||
<th className="py-1 pr-3 font-medium">{t('ports.for', 'For')}</th>
|
||||
<th className="py-1 pr-3 font-medium">{t('ports.encryption', 'Encryption')}</th>
|
||||
<th className="py-1 pr-3 font-medium">{t('ports.state', 'State')}</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">{SERVICES.map(row)}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{custom.length > 0 && (
|
||||
<div className="space-y-1 text-sm">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{t('ports.custom', 'Other listeners')}
|
||||
</p>
|
||||
<ul className="space-y-0.5">
|
||||
{custom.map((l) => (
|
||||
<li key={l.id}>
|
||||
{t('ports.customLine', '{{name}}: {{protocol}} on port {{ports}}, {{tls}}.', {
|
||||
name: l.name ?? l.id,
|
||||
protocol: l.protocol ?? 'smtp',
|
||||
ports: portsOf(l).join(', ') || '?',
|
||||
tls: encryptionWords(l),
|
||||
})}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
'ports.reach',
|
||||
'Open here means the server listens. Whether the internet can reach it also depends on your firewall and hosting provider.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { classify, encryptionWords, listenerFor, portOf, SERVICES } from './services';
|
||||
|
||||
describe('ports', () => {
|
||||
it('reads ports from socket addresses', () => {
|
||||
expect(portOf('[::]:25')).toBe(25);
|
||||
expect(portOf('0.0.0.0:587')).toBe(587);
|
||||
expect(portOf('localhost')).toBeNull();
|
||||
});
|
||||
|
||||
it('sorts listeners into services by protocol and port, and keeps the rest as custom', () => {
|
||||
const { byService, custom } = classify([
|
||||
{ id: 'a', protocol: 'smtp', bind: { '[::]:25': true } },
|
||||
{ id: 'b', protocol: 'smtp', bind: { '[::]:465': true }, tlsImplicit: true },
|
||||
{ id: 'c', protocol: 'http', bind: { '[::]:443': true, '0.0.0.0:443': true }, tlsImplicit: true },
|
||||
{ id: 'd', protocol: 'http', bind: { '127.0.0.1:8080': true } },
|
||||
{ id: 'e', protocol: 'imap', bind: { '[::]:25': true } },
|
||||
]);
|
||||
expect(byService.get('smtp')?.map((l) => l.id)).toEqual(['a']);
|
||||
expect(byService.get('submissions')?.map((l) => l.id)).toEqual(['b']);
|
||||
expect(byService.get('https')?.map((l) => l.id)).toEqual(['c']);
|
||||
expect(custom.map((l) => l.id)).toEqual(['d', 'e']);
|
||||
});
|
||||
|
||||
it('makes a listener for a service', () => {
|
||||
const s = SERVICES.find((x) => x.id === 'submissions')!;
|
||||
expect(listenerFor(s)).toEqual({
|
||||
name: 'submissions',
|
||||
protocol: 'smtp',
|
||||
bind: { '[::]:465': true },
|
||||
tlsImplicit: true,
|
||||
useTls: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('names the encryption', () => {
|
||||
expect(encryptionWords({ tlsImplicit: true })).toBe('encrypted from the start');
|
||||
expect(encryptionWords({ useTls: true })).toBe('STARTTLS');
|
||||
expect(encryptionWords({ useTls: false })).toBe('not encrypted');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* inbuxa: the standard mail ports as services (settings-reorg, second wave).
|
||||
* A listener is one x:NetworkListener: a protocol, the addresses it binds
|
||||
* and how TLS is used. The table sorts them into the services people know
|
||||
* by their port; anything else is a custom listener, left as it is.
|
||||
*
|
||||
* IMAP, POP3 and ManageSieve (and, when legacy mail is off entirely, the
|
||||
* sending ports) belong to Security › Hardening, whose switches take their
|
||||
* listeners away and put them back (legacy-protocols spec). The table
|
||||
* shows their state and points there instead of switching them itself.
|
||||
*/
|
||||
|
||||
export type ServiceId =
|
||||
'smtp' | 'submission' | 'submissions' | 'https' | 'http' | 'imaps' | 'imap' | 'pop3s' | 'pop3' | 'sieve';
|
||||
|
||||
export interface Service {
|
||||
id: ServiceId;
|
||||
port: number;
|
||||
protocol: string;
|
||||
implicitTls: boolean;
|
||||
/** Whether STARTTLS is offered (useTls without implicit TLS). */
|
||||
useTls: boolean;
|
||||
group: 'receive' | 'send' | 'web' | 'read';
|
||||
/** Switched on Hardening, not here. */
|
||||
hardening?: boolean;
|
||||
/** Turning it off breaks something people rely on: ask first. */
|
||||
critical?: string;
|
||||
}
|
||||
|
||||
export const SERVICES: Service[] = [
|
||||
{
|
||||
id: 'smtp',
|
||||
port: 25,
|
||||
protocol: 'smtp',
|
||||
implicitTls: false,
|
||||
useTls: true,
|
||||
group: 'receive',
|
||||
critical: 'Other servers can no longer deliver mail to you.',
|
||||
},
|
||||
{ id: 'submission', port: 587, protocol: 'smtp', implicitTls: false, useTls: true, group: 'send' },
|
||||
{ id: 'submissions', port: 465, protocol: 'smtp', implicitTls: true, useTls: true, group: 'send' },
|
||||
{
|
||||
id: 'https',
|
||||
port: 443,
|
||||
protocol: 'http',
|
||||
implicitTls: true,
|
||||
useTls: true,
|
||||
group: 'web',
|
||||
critical: 'The webmail, this console, JMAP apps and automatic setup stop working.',
|
||||
},
|
||||
{ id: 'http', port: 80, protocol: 'http', implicitTls: false, useTls: false, group: 'web' },
|
||||
{ id: 'imaps', port: 993, protocol: 'imap', implicitTls: true, useTls: true, group: 'read', hardening: true },
|
||||
{ id: 'imap', port: 143, protocol: 'imap', implicitTls: false, useTls: true, group: 'read', hardening: true },
|
||||
{ id: 'pop3s', port: 995, protocol: 'pop3', implicitTls: true, useTls: true, group: 'read', hardening: true },
|
||||
{ id: 'pop3', port: 110, protocol: 'pop3', implicitTls: false, useTls: true, group: 'read', hardening: true },
|
||||
{
|
||||
id: 'sieve',
|
||||
port: 4190,
|
||||
protocol: 'manageSieve',
|
||||
implicitTls: false,
|
||||
useTls: true,
|
||||
group: 'read',
|
||||
hardening: true,
|
||||
},
|
||||
];
|
||||
|
||||
export interface ListenerRecord {
|
||||
id: string;
|
||||
name?: string;
|
||||
protocol?: string;
|
||||
bind?: Record<string, boolean>;
|
||||
tlsImplicit?: boolean;
|
||||
useTls?: boolean;
|
||||
}
|
||||
|
||||
/** The port of a socket address: "[::]:25", "0.0.0.0:587", "127.0.0.1:8080". */
|
||||
export function portOf(address: string): number | null {
|
||||
const m = /:(\d+)$/.exec(address.trim());
|
||||
return m ? Number(m[1]) : null;
|
||||
}
|
||||
|
||||
export function portsOf(l: ListenerRecord): number[] {
|
||||
return Object.keys(l.bind ?? {})
|
||||
.map(portOf)
|
||||
.filter((p): p is number => p !== null);
|
||||
}
|
||||
|
||||
/** Which listeners serve which standard service, and which are custom. */
|
||||
export function classify(listeners: ListenerRecord[]): {
|
||||
byService: Map<ServiceId, ListenerRecord[]>;
|
||||
custom: ListenerRecord[];
|
||||
} {
|
||||
const byService = new Map<ServiceId, ListenerRecord[]>();
|
||||
const custom: ListenerRecord[] = [];
|
||||
for (const l of listeners) {
|
||||
const ports = portsOf(l);
|
||||
const s = SERVICES.find((x) => x.protocol === (l.protocol ?? 'smtp') && ports.includes(x.port));
|
||||
if (s) byService.set(s.id, [...(byService.get(s.id) ?? []), l]);
|
||||
else custom.push(l);
|
||||
}
|
||||
return { byService, custom };
|
||||
}
|
||||
|
||||
/** A new listener for a standard service, on every address. */
|
||||
export function listenerFor(s: Service): Record<string, unknown> {
|
||||
return {
|
||||
name: s.id,
|
||||
protocol: s.protocol,
|
||||
bind: { [`[::]:${s.port}`]: true },
|
||||
tlsImplicit: s.implicitTls,
|
||||
useTls: s.useTls,
|
||||
};
|
||||
}
|
||||
|
||||
export function encryptionWords(l: { tlsImplicit?: boolean; useTls?: boolean }): string {
|
||||
if (l.tlsImplicit) return 'encrypted from the start';
|
||||
if (l.useTls === false) return 'not encrypted';
|
||||
return 'STARTTLS';
|
||||
}
|
||||
Reference in new issue
Block a user