From 6b907e74db1f83678c2ee41a99826c1912f091de Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 28 Sep 2026 15:06:20 -0700 Subject: [PATCH] The standard mail ports as one table, above Listeners MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Network › Listeners opens with the ports people know: 25 for receiving mail, 587 and 465 for sending from mail apps, 443 and 80 for the web, and IMAP, POP3 and ManageSieve. Each row says what it's for, how it's encrypted and whether it's open, with a switch. - Opening one adds a listener for it on every address; closing removes its listeners. Closing 25 or 443 asks first, saying what stops. - IMAP, POP3 and ManageSieve, and the sending ports while legacy mail is off, belong to Security › Hardening, whose switches keep and restore their listeners; their rows link there. - Listeners on other ports are listed as sentences, and left alone. - A note says open means listening: the firewall and hosting provider decide whether the internet can reach it. --- src/components/layout/MainContent.tsx | 4 + src/features/ports/ListenersPage.tsx | 20 +++ src/features/ports/PortsTable.tsx | 246 ++++++++++++++++++++++++++ src/features/ports/services.test.ts | 47 +++++ src/features/ports/services.ts | 125 +++++++++++++ 5 files changed, 442 insertions(+) create mode 100644 src/features/ports/ListenersPage.tsx create mode 100644 src/features/ports/PortsTable.tsx create mode 100644 src/features/ports/services.test.ts create mode 100644 src/features/ports/services.ts diff --git a/src/components/layout/MainContent.tsx b/src/components/layout/MainContent.tsx index d1cdd3d..a2b499a 100644 --- a/src/components/layout/MainContent.tsx +++ b/src/components/layout/MainContent.tsx @@ -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 ; } + if (viewName === 'x:NetworkListener') { + return ; + } return ; } diff --git a/src/features/ports/ListenersPage.tsx b/src/features/ports/ListenersPage.tsx new file mode 100644 index 0000000..398c17b --- /dev/null +++ b/src/features/ports/ListenersPage.tsx @@ -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 ( +
+ setVersion((v) => v + 1)} /> + +
+ ); +} diff --git a/src/features/ports/PortsTable.tsx b/src/features/ports/PortsTable.tsx new file mode 100644 index 0000000..07670d0 --- /dev/null +++ b/src/features/ports/PortsTable.tsx @@ -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(null); + const [policy, setPolicy] = useState(null); + const [confirming, setConfirming] = useState(null); + const [busy, setBusy] = useState(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 = { + 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 ( + + + {s.port} + + {label[s.id]} + + {encryptionWords(on ? ls[0] : { tlsImplicit: s.implicitTls, useTls: s.useTls })} + + + {on ? t('ports.open', 'open') : t('ports.closed', 'closed')} + + + {onHardening(s) ? ( + + {t('ports.onHardening', 'Switched on Hardening')} + + ) : confirming === s.id ? ( + + {s.critical} + + + + ) : on ? ( + + ) : ( + + )} + + + ); + }; + + return ( +
+
+ +

{t('ports.title', 'The standard ports')}

+
+
+ + + + + + + + + + {SERVICES.map(row)} +
{t('ports.port', 'Port')}{t('ports.for', 'For')}{t('ports.encryption', 'Encryption')}{t('ports.state', 'State')} +
+
+ {custom.length > 0 && ( +
+

+ {t('ports.custom', 'Other listeners')} +

+
    + {custom.map((l) => ( +
  • + {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), + })} +
  • + ))} +
+
+ )} +

+ {t( + 'ports.reach', + 'Open here means the server listens. Whether the internet can reach it also depends on your firewall and hosting provider.', + )} +

+
+ ); +} diff --git a/src/features/ports/services.test.ts b/src/features/ports/services.test.ts new file mode 100644 index 0000000..88cae9c --- /dev/null +++ b/src/features/ports/services.test.ts @@ -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'); + }); +}); diff --git a/src/features/ports/services.ts b/src/features/ports/services.ts new file mode 100644 index 0000000..ab1b2e1 --- /dev/null +++ b/src/features/ports/services.ts @@ -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; + 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; + custom: ListenerRecord[]; +} { + const byService = new Map(); + 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 { + 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'; +} -- 2.54.0