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'; +}