diff --git a/client/components/NetworkForm.vue b/client/components/NetworkForm.vue index d1481cbe2..f839bfd4d 100644 --- a/client/components/NetworkForm.vue +++ b/client/components/NetworkForm.vue @@ -81,6 +81,20 @@ /> +
+ +
+ +
+
@@ -486,6 +500,7 @@ import { watch, } from "vue"; import { + defaultPort, displayName, parseCommands, presenceModeOf, @@ -499,7 +514,7 @@ import {describeServiceCheck} from "../js/irc/services"; import {servicesCheck} from "../js/helpers/operRequest"; import {isOperOn} from "../js/helpers/operMenu"; import {askNativeAlertPermission, nativeAlertsAvailable} from "../js/helpers/nativeAlerts"; -import {isDesktopShell} from "../js/helpers/shell"; +import {isDesktopShell, isShell} from "../js/helpers/shell"; import {useStore} from "../js/store"; import webpush from "../js/webpush"; import {useI18n} from "../js/i18n"; @@ -806,6 +821,14 @@ export default defineComponent({ watch( () => props.defaults.tls, (isSecureChecked) => { + if (props.defaults.transport === "tcp") { + if (props.defaults.port === defaultPort(!isSecureChecked, "tcp")) { + props.defaults.port = defaultPort(isSecureChecked, "tcp"); + } + + return; + } + const ports = [8067, 8443]; const newPort = isSecureChecked ? 1 : 0; @@ -815,6 +838,23 @@ export default defineComponent({ } ); + // IRC over TCP: only the app shell can open a raw socket. + const canUseTcp = isShell(); + const connection = computed({ + get: () => (props.defaults.transport === "tcp" ? "tcp" : "websocket"), + set(value: string) { + const tcp = value === "tcp"; + const tls = props.defaults.tls; + const from = tcp ? (tls ? 8443 : 8067) : defaultPort(tls, "tcp"); + + if (props.defaults.port === from) { + props.defaults.port = tcp ? defaultPort(tls, "tcp") : tls ? 8443 : 8067; + } + + props.defaults.transport = tcp ? "tcp" : "websocket"; + }, + }); + const formData = (): SavedNetwork => { const data: SavedNetwork = { ...props.defaults, @@ -887,6 +927,8 @@ export default defineComponent({ presenceHint, pushInfo, desktopShell: isDesktopShell(), + canUseTcp, + connection, renewPush, onNotifyToggle, SERVICES_PROFILES, diff --git a/client/components/Windows/Connect.vue b/client/components/Windows/Connect.vue index 8a6f059f6..9fad58605 100644 --- a/client/components/Windows/Connect.vue +++ b/client/components/Windows/Connect.vue @@ -157,6 +157,20 @@ />
+
+ +
+ +
+
@@ -413,6 +427,7 @@ import {brandingFeatures, expandNick, nickFromAccount} from "../../js/branding"; import {autoconnectSavedNetworks, createNetwork} from "../../js/irc/manager"; import * as saved from "../../js/irc/saved-networks"; import {defaultPort, SavedNetwork} from "../../js/irc/saved-networks"; +import {isShell} from "../../js/helpers/shell"; import {mergeJoinLists} from "../../js/helpers/linkTarget"; import {askNativeAlertPermission, nativeAlertsAvailable} from "../../js/helpers/nativeAlerts"; import {router, switchToChannel} from "../../js/router"; @@ -486,9 +501,25 @@ export default defineComponent({ const pinServer = () => { if (hostLocked) { Object.assign(form, server); + form.transport = "websocket"; // the deploy's server is a WebSocket one } }; + // IRC over TCP: only the app shell can open a raw socket. + const canUseTcp = isShell(); + const connection = computed({ + get: () => form.transport ?? "websocket", + set(value: string) { + const next = value === "tcp" ? "tcp" : "websocket"; + + if (form.port === defaultPort(form.tls, form.transport)) { + form.port = defaultPort(form.tls, next); + } + + form.transport = next; + }, + }); + // The sign-in panel replaces the whole form; `brandingFeatures` only // reports it when there is a `defaultNetwork` to sign in to, and pins // the server when it does. @@ -540,6 +571,9 @@ export default defineComponent({ form.host = net.host; form.port = net.port; form.tls = net.tls; + + form.transport = net.transport ?? "websocket"; + form.nick = net.nick; form.join = net.join; form.sasl = net.sasl; @@ -636,8 +670,8 @@ export default defineComponent({ watch( () => form.tls, (useTls) => { - if (form.port === defaultPort(!useTls)) { - form.port = defaultPort(useTls); + if (form.port === defaultPort(!useTls, form.transport)) { + form.port = defaultPort(useTls, form.transport); } } ); @@ -750,6 +784,8 @@ export default defineComponent({ form, store, t, + canUseTcp, + connection, ariaLabel, serverAddressLabel, serverPortLabel, diff --git a/client/js/i18n/call-sites.ts b/client/js/i18n/call-sites.ts index a9b279f50..a46f84c9c 100644 --- a/client/js/i18n/call-sites.ts +++ b/client/js/i18n/call-sites.ts @@ -166,6 +166,9 @@ export const STATIC_CALL_SITES = new Set([ "connect.connectingAs", "connect.connectingTo", "connect.connectingToAttempt", + "connect.connection", + "connect.connection.tcp", + "connect.connection.websocket", "connect.disconnected", "connect.enabledCaps", "connect.enterPassword", @@ -212,6 +215,7 @@ export const STATIC_CALL_SITES = new Set([ "connect.stsReconnect", "connect.stsUpgrade", "connect.submit", + "connect.tcpNeedsApp", "connect.title", "connect.useTls", "connect.user", @@ -238,6 +242,8 @@ export const STATIC_CALL_SITES = new Set([ "disconnect.hint.plainCauses", "disconnect.hint.registration", "disconnect.hint.sessionConflict", + "disconnect.hint.tcpPlainCauses", + "disconnect.hint.tcpTlsCauses", "disconnect.hint.wssCauses", "disconnect.registeringClosed", "disconnect.registeringClosedNotReconnecting", diff --git a/client/js/irc/client.ts b/client/js/irc/client.ts index c28ce3589..7339720ee 100644 --- a/client/js/irc/client.ts +++ b/client/js/irc/client.ts @@ -143,8 +143,10 @@ import { policyHost, refreshPolicy, StsUpgrade, + stsHost, upgradeOptions, } from "./sts"; +import {shellSocketCtor, type SocketBridge} from "./shellSocket"; import {channelOrder, get as getSavedNetwork, NetworkCursor, setCursor} from "./saved-networks"; import {noteServiceReply, resetServiceReplies} from "./services"; import {dropQuietLine, isRepeatedMissingBot} from "./quiet"; @@ -268,6 +270,12 @@ export interface IrcClientOptions extends ConnectOptions { ids?: IdAllocator; /** Build the transport (tests inject a fake); defaults to `new WsTransport(opts)`. */ transportFactory?: (opts: TransportOptions) => Transport; + /** + * The app shell's bridge, for `transport: "tcp"` (shellSocket.ts). The + * manager passes `window.seanceShell`; without one (a browser) a TCP + * network does not connect. + */ + socketBridge?: SocketBridge; /** The server's flood rule as we model it (fakelag.ts); ircu's by default. */ fakeLag?: FakeLagModel; reconnect?: ReconnectOptions; @@ -582,7 +590,7 @@ export class IrcClient { this.networkName = hostnameOf(host); this.bus = options.bus ?? socket; this.ids = options.ids ?? sharedIds; - this.url = buildUrl(host, options.port, options.tls); + this.url = buildUrl(host, options.port, options.tls, options.transport); this.transport = this.createTransport(); this.unsubscribeTransport = this.transport.on((ev) => this.onTransportEvent(ev)); @@ -814,7 +822,8 @@ export class IrcClient { proxyPort: 0, proxyUsername: "", proxyPassword: "", - hasSTSPolicy: getPolicy(o.host) !== undefined, + transport: o.transport ?? "websocket", + hasSTSPolicy: getPolicy(stsHost(o)) !== undefined, }; } @@ -835,6 +844,17 @@ export class IrcClient { this.settleClose(); // before quitting is reset: it was our QUIT this.applyPendingOptions(); + + if (this.options.transport === "tcp" && !this.options.socketBridge) { + // No app shell, no raw socket: say so rather than dial and retry forever. + this.pushMessage( + this.lobby, + {type: MessageType.ERROR, text: t("connect.tcpNeedsApp")}, + true + ); + return; + } + this.applyStsPolicy(); this.quitting = false; this.closeHintShown = false; @@ -907,6 +927,7 @@ export class IrcClient { host: next.host, port: next.port, tls: next.tls, + transport: next.transport, join: next.join, sasl: next.sasl, saslAccount: next.saslAccount, @@ -945,7 +966,12 @@ export class IrcClient { private endpointChanged(next: IrcClientOptions): boolean { const o = this.options; - return next.host !== o.host || next.port !== o.port || next.tls !== o.tls; + return ( + next.host !== o.host || + next.port !== o.port || + next.tls !== o.tls || + (next.transport === "tcp") !== (o.transport === "tcp") + ); } /** Swap in settings edited while connected, if any (before a connect). */ @@ -986,6 +1012,14 @@ export class IrcClient { subprotocols: ["text.ircv3.net", "binary.ircv3.net"], }; + if (this.options.transport === "tcp" && this.options.socketBridge) { + transportOptions.WebSocketImpl = shellSocketCtor(this.options.socketBridge, { + host: tcpHostOf(this.options.host), + port: this.options.port, + tls: this.options.tls, + }); + } + if (this.options.reconnect) { transportOptions.reconnect = this.options.reconnect; } @@ -1000,7 +1034,7 @@ export class IrcClient { this.settleClose(); // the old transport's close event will not reach us this.unsubscribeTransport(); this.options = {...next}; - this.url = buildUrl(next.host, next.port, next.tls); + this.url = buildUrl(next.host, next.port, next.tls, next.transport); this.transport = this.createTransport(); this.unsubscribeTransport = this.transport.on((ev) => this.onTransportEvent(ev)); } @@ -1052,7 +1086,7 @@ export class IrcClient { const value = parseStsValue(raw); if (this.options.tls) { - applyDuration(this.options.host, this.options.port, value); + applyDuration(stsHost(this.options), this.options.port, value); return; } @@ -1068,7 +1102,11 @@ export class IrcClient { // secure endpoint; an edit to another host is left alone. const pending = this.pendingOptions; - if (pending && policyHost(pending.host) === policyHost(this.options.host)) { + if ( + pending && + (pending.transport === "tcp") === (this.options.transport === "tcp") && + policyHost(pending.host) === policyHost(this.options.host) + ) { this.pendingOptions = {...pending, ...secure}; } @@ -1628,7 +1666,7 @@ export class IrcClient { this.authtoken.clear(); if (this.options.tls) { - refreshPolicy(this.options.host); + refreshPolicy(stsHost(this.options)); } resetBatches(this); @@ -3646,7 +3684,12 @@ function hostnameOf(host: string): string { return slash === -1 ? stripped : stripped.slice(0, slash); } -export function buildUrl(host: string, port: number, tls: boolean): string { +export function buildUrl( + host: string, + port: number, + tls: boolean, + transport?: ConnectOptions["transport"] +): string { const stripped = host.trim().replace(/^(?:wss?|https?|ircs?):\/\//i, ""); const slash = stripped.indexOf("/"); let hostname = slash === -1 ? stripped : stripped.slice(0, slash); @@ -3656,9 +3699,19 @@ export function buildUrl(host: string, port: number, tls: boolean): string { hostname = `[${hostname}]`; // bare IPv6 literal } + if (transport === "tcp") { + return `${tls ? "ircs" : "irc"}://${hostname}:${port}`; // no path over TCP + } + return `${tls ? "wss" : "ws"}://${hostname}:${port}${path}`; } +/** The host to dial over TCP: no scheme, path or IPv6 brackets. */ +export function tcpHostOf(host: string): string { + const name = hostnameOf(host.trim()); + return name.startsWith("[") && name.endsWith("]") ? name.slice(1, -1) : name; +} + /** The cursor stored for `uuid`, if the network was saved and has one. */ function savedCursor(uuid: string): NetworkCursor | undefined { try { diff --git a/client/js/irc/disconnect.ts b/client/js/irc/disconnect.ts index 997107d7c..0a7888a74 100644 --- a/client/js/irc/disconnect.ts +++ b/client/js/irc/disconnect.ts @@ -83,6 +83,16 @@ export function describeClose(ctx: CloseContext): CloseReport { const text = retrying ? t("disconnect.couldNotConnect", vars) : t("disconnect.couldNotConnectNotReconnecting", vars); + // IRC over TCP (the app shell): no browser rules apply, and an + // untrusted certificate is not the user's to accept in a tab. + if (ctx.url.startsWith("ircs:")) { + return {text, hint: t("disconnect.hint.tcpTlsCauses")}; + } + + if (ctx.url.startsWith("irc:")) { + return {text, hint: t("disconnect.hint.tcpPlainCauses")}; + } + const secure = ctx.url.startsWith("wss:"); if (!secure && ctx.pageProtocol === "https:") { diff --git a/client/js/irc/manager.ts b/client/js/irc/manager.ts index 6bc07a094..39b2af899 100644 --- a/client/js/irc/manager.ts +++ b/client/js/irc/manager.ts @@ -30,6 +30,7 @@ import * as saved from "./saved-networks"; import type {SavedNetwork} from "./saved-networks"; import type {ConnectOptions} from "./types"; import type {GlobalModeState} from "../helpers/dnd"; +import {shellBridge} from "../helpers/shell"; /** The global do-not-disturb as client/js/dnd.ts last set it; new clients start with it. */ const globalDnd: {active: boolean; until: number | null} = {active: false, until: null}; @@ -73,18 +74,10 @@ function allNetworks() { function rememberNetwork(options: CreateNetworkOptions): SavedNetwork { const uuid = options.uuid || - saved.findMatching(options.host, options.port, options.nick)?.uuid || + saved.findMatching(options.host, options.port, options.nick, options.transport)?.uuid || saved.newUuid(); const existing = saved.get(uuid); - const merged: Record = {...(existing ?? {}), uuid}; - - for (const [key, value] of Object.entries(options)) { - if (value !== undefined) { - merged[key] = value; - } - } - - const entry = saved.normalize(merged); + const entry = saved.normalize(saved.mergeOptions(existing, uuid, options)); if (!entry) { throw new Error("createNetwork: missing uuid"); @@ -110,6 +103,8 @@ export function createNetwork(options: CreateNetworkOptions): IrcClient { client = new IrcClient({ ...saved.toConnectOptions(entry), uuid: entry.uuid, + // The app shell's raw sockets, for a network set to IRC over TCP. + socketBridge: shellBridge() ?? undefined, highlights: highlightKeywords, networksForInit: allNetworks, setMuteStatus, diff --git a/client/js/irc/saved-networks.ts b/client/js/irc/saved-networks.ts index 1bd6f80a5..4c7a3173e 100644 --- a/client/js/irc/saved-networks.ts +++ b/client/js/irc/saved-networks.ts @@ -97,7 +97,11 @@ export function useStorageBackend(next: StorageBackend | null): void { } /** The WebSocket ports we suggest: 9998 for wss://, 8067 for ws://. */ -export function defaultPort(tls: boolean): number { +export function defaultPort(tls: boolean, transport?: ConnectOptions["transport"]): number { + if (transport === "tcp") { + return tls ? 6697 : 6667; + } + return tls ? 9998 : 8067; } @@ -163,9 +167,9 @@ function asBoolean(value: unknown): boolean { return s !== "" && s !== "0" && s !== "false" && s !== "off"; } -function asPort(value: unknown, tls: boolean): number { +function asPort(value: unknown, tls: boolean, transport?: ConnectOptions["transport"]): number { const port = typeof value === "number" ? value : parseInt(asString(value), 10); - return Number.isInteger(port) && port > 0 && port <= 65535 ? port : defaultPort(tls); + return Number.isInteger(port) && port > 0 && port <= 65535 ? port : defaultPort(tls, transport); } /** A stored / incoming cursor, or undefined when there is nothing usable in it. */ @@ -248,12 +252,15 @@ export function normalize(raw: Record): SavedNetwork | undefine const tls = raw.tls === undefined ? true : asBoolean(raw.tls); const sasl = asString(raw.sasl) === "plain" ? "plain" : ""; const rememberPassword = asBoolean(raw.rememberPassword); + // Only `tcp` is stored; anything else (the form's `websocket`) is the default. + const transport = raw.transport === "tcp" ? "tcp" : undefined; const net: SavedNetwork = { uuid, name: asString(raw.name), host: asString(raw.host), - port: asPort(raw.port, tls), + port: asPort(raw.port, tls, transport), tls, + ...(transport ? {transport} : {}), nick: asString(raw.nick), join: asJoin(raw.join), sasl, @@ -392,6 +399,7 @@ export function toConnectOptions(net: SavedNetwork): ConnectOptions { host: net.host, port: net.port, tls: net.tls, + ...(net.transport ? {transport: net.transport} : {}), nick: net.nick, join: net.join, sasl: net.sasl, @@ -562,7 +570,12 @@ export function lastUsed(): SavedNetwork | undefined { * An existing entry for the same server and nick, so re-typing a network * into the connect form keeps its uuid (and the preferences keyed by it). */ -export function findMatching(host: string, port: number, nick: string): SavedNetwork | undefined { +export function findMatching( + host: string, + port: number, + nick: string, + transport?: ConnectOptions["transport"] +): SavedNetwork | undefined { const h = hostnameOf(host).toLowerCase(); const n = nick.trim().toLowerCase(); @@ -570,6 +583,24 @@ export function findMatching(host: string, port: number, nick: string): SavedNet (net) => hostnameOf(net.host).toLowerCase() === h && net.port === port && - net.nick.toLowerCase() === n + net.nick.toLowerCase() === n && + (net.transport === "tcp") === (transport === "tcp") ); } + +/** The stored entry with the connect options laid over it (undefined options keep the stored value). */ +export function mergeOptions( + existing: SavedNetwork | undefined, + uuid: string, + options: Partial & Record +): Record { + const merged: Record = {...(existing ?? {}), uuid}; + + for (const [key, value] of Object.entries(options)) { + if (value !== undefined) { + merged[key] = value; + } + } + + return merged; +} diff --git a/client/js/irc/shellSocket.ts b/client/js/irc/shellSocket.ts new file mode 100644 index 000000000..0892a1beb --- /dev/null +++ b/client/js/irc/shellSocket.ts @@ -0,0 +1,274 @@ +/** + * ShellSocket — a raw TCP/TLS IRC connection made by the app shell + * (shells/tauri/src-tauri/src/socket.rs), dressed as the part of a WebSocket + * that {@link WsTransport} uses ({@link LineSocket}). IRC over TCP carries the + * same lines as IRC over WebSocket, so everything above the socket — the + * retry schedule, the probe, PING, the line cap — is shared unchanged. + * + * The shell is reached through a {@link SocketBridge} (`window.seanceShell` + * in the app), passed in so this module stays DOM-free and runs under mocha. + * + * The contract WsTransport relies on: + * - every event is asynchronous; `open` comes after `readyState` is OPEN; + * - calls to the shell run in order per socket (open, sends, close): each + * bridge call is its own IPC request and two may otherwise overtake each + * other, which would reorder a pipelined registration or QUIT-then-close; + * - a failure fires `error` (carrying the shell's reason) then one `close`, + * marked `final` when the shell says a retry cannot mend it; + * - `close()` from the page fires its own `close` event, once: the shell + * says nothing about a socket the page closed. + */ + +import type {LineSocket, LineSocketCtor} from "./transport"; + +/** The shell bridge, as `client/js/helpers/shell.ts`'s `SeanceShell` has it. */ +export interface SocketBridge { + call(plugin: string, method: string, options?: unknown): Promise; + listen(plugin: string, event: string, cb: (data: any) => void): void; +} + +/** Where to dial. `host` is a bare name or IP literal (no brackets, no path). */ +export interface TcpEndpoint { + host: string; + port: number; + tls: boolean; +} + +const PLUGIN = "Socket"; + +/** The shell's close code for a failure a retry cannot mend (socket.rs `FINAL`). */ +export const FINAL_CLOSE = 4001; + +type Listeners = Record<"open" | "message" | "error" | "close", Array<(ev: any) => void>>; + +/** Live sockets per bridge, by id; the bridge's events are routed through here. */ +const routes = new WeakMap>(); + +function routesFor(bridge: SocketBridge): Map { + const map = routes.get(bridge); + + if (map) { + return map; + } + + const sockets = new Map(); + routes.set(bridge, sockets); + const route = (data: any, fn: (s: ShellSocket, d: any) => void) => { + const socket = typeof data?.id === "string" ? sockets.get(data.id) : undefined; + + if (socket) { + fn(socket, data); + } + }; + bridge.listen(PLUGIN, "open", (data) => route(data, (s) => s.onShellOpen())); + bridge.listen(PLUGIN, "lines", (data) => + route(data, (s, d) => s.onShellLines(Array.isArray(d.lines) ? d.lines : [])) + ); + bridge.listen(PLUGIN, "close", (data) => + route(data, (s, d) => + s.onShellClose( + typeof d.code === "number" ? d.code : 1006, + typeof d.reason === "string" ? d.reason : "" + ) + ) + ); + return sockets; +} + +function messageOf(err: unknown): string { + const msg = (err as {message?: unknown} | null)?.message; + return typeof msg === "string" && msg.length > 0 ? msg : String(err); +} + +function newId(): string { + return globalThis.crypto.randomUUID(); +} + +/** + * This page load's token, sent with every open: after a reload the shell + * refuses an open from the page before, which can still be in flight. + */ +const PAGE = newId(); + +export class ShellSocket implements LineSocket { + readonly CONNECTING = 0; + readonly OPEN = 1; + readonly CLOSING = 2; + readonly CLOSED = 3; + readonly protocol = ""; + binaryType = "arraybuffer"; + readonly id: string; + + private state = 0; + private readonly listeners: Listeners = {open: [], message: [], error: [], close: []}; + private readonly sockets: Map; + /** The shell calls, in order: open, then sends, then close. */ + private chain: Promise; + /** Lines sent this tick, to go out as one call. */ + private outbox: string[] = []; + private flushQueued = false; + + constructor( + private readonly bridge: SocketBridge, + endpoint: TcpEndpoint, + id: string = newId() + ) { + this.id = id; + this.sockets = routesFor(bridge); + this.sockets.set(id, this); + const options = { + id, + page: PAGE, + host: endpoint.host, + port: endpoint.port, + tls: endpoint.tls, + }; + let opened: Promise; + + try { + opened = bridge.call(PLUGIN, "open", options); + } catch (err: unknown) { + // A bridge that throws at once fails like one that rejects. + opened = Promise.reject(err instanceof Error ? err : new Error(messageOf(err))); + } + + this.chain = opened.catch((err: unknown) => this.onShellClose(1006, messageOf(err))); + } + + get readyState(): number { + return this.state; + } + + addEventListener(type: "open", listener: () => void): void; + addEventListener(type: "message", listener: (ev: {data: unknown}) => void): void; + addEventListener(type: "error", listener: (ev: unknown) => void): void; + addEventListener( + type: "close", + listener: (ev: {code: number; reason: string; wasClean: boolean; final?: boolean}) => void + ): void; + addEventListener(type: keyof Listeners, listener: (ev: any) => void): void { + this.listeners[type].push(listener); + } + + send(line: string): void { + if (this.state !== this.OPEN) { + throw new Error("ShellSocket: not open"); + } + + this.outbox.push(line); + + if (!this.flushQueued) { + this.flushQueued = true; + queueMicrotask(() => this.flush()); + } + } + + close(): void { + if (this.state === this.CLOSING || this.state === this.CLOSED) { + return; + } + + const wasOpen = this.state === this.OPEN; + this.flush(); // lines sent this tick (a QUIT) go before the close + this.state = this.CLOSING; + this.enqueue(() => this.bridge.call(PLUGIN, "close", {id: this.id}), false); + queueMicrotask(() => + this.finish(wasOpen ? 1000 : 1006, wasOpen ? "" : "closed while connecting") + ); + } + + /** @internal The shell connected. */ + onShellOpen(): void { + if (this.state !== this.CONNECTING) { + return; + } + + this.state = this.OPEN; + this.fire("open", undefined); + } + + /** @internal Lines from the server. */ + onShellLines(lines: unknown[]): void { + for (const line of lines) { + if (this.state !== this.OPEN) { + return; // a listener closed the socket mid-batch + } + + if (typeof line === "string") { + this.fire("message", {data: line}); + } + } + } + + /** @internal The shell lost the socket, or refused a call. */ + onShellClose(code: number, reason: string): void { + if (this.state === this.CLOSING || this.state === this.CLOSED) { + return; // a page close fires its own event + } + + if (code !== 1000) { + this.fire("error", {message: reason || "connection failed"}); + } + + this.finish(code, reason); + } + + private flush(): void { + this.flushQueued = false; + + if (this.outbox.length === 0 || this.state !== this.OPEN) { + this.outbox = []; + return; + } + + const lines = this.outbox; + this.outbox = []; + this.enqueue(() => this.bridge.call(PLUGIN, "send", {id: this.id, lines}), true); + } + + private enqueue(call: () => Promise, failCloses: boolean): void { + this.chain = this.chain.then(call).catch((err: unknown) => { + if (failCloses) { + this.onShellClose(1006, messageOf(err)); + } + }); + } + + private finish(code: number, reason: string): void { + if (this.state === this.CLOSED) { + return; + } + + this.state = this.CLOSED; + this.sockets.delete(this.id); + this.fire("close", { + code, + reason, + wasClean: code === 1000, + ...(code === FINAL_CLOSE ? {final: true} : {}), + }); + } + + private fire(type: K, ev: unknown): void { + for (const listener of [...this.listeners[type]]) { + // One listener's throw must not cost the rest of a read's lines; + // it is rethrown on its own so it is still reported. + try { + listener(ev); + } catch (err: unknown) { + queueMicrotask(() => { + throw err; + }); + } + } + } +} + +/** A socket constructor for {@link WsTransport}, bound to one endpoint; the URL it is given is only for show. */ +export function shellSocketCtor(bridge: SocketBridge, endpoint: TcpEndpoint): LineSocketCtor { + return class extends ShellSocket { + constructor(_url: string, _protocols?: string | string[]) { + super(bridge, endpoint); + } + }; +} diff --git a/client/js/irc/sts.ts b/client/js/irc/sts.ts index 103d37e3d..cfd70b95d 100644 --- a/client/js/irc/sts.ts +++ b/client/js/irc/sts.ts @@ -50,6 +50,16 @@ export function policyHost(host: string): string { return hostnameOf(host).toLowerCase(); } +/** + * The host name STS policies are stored under for a connection: the host for + * WebSocket, `tcp:host` for IRC over TCP — the two reach one server on + * different secure ports, so a policy learned on one says nothing about the + * other's port. + */ +export function stsHost(opts: Pick): string { + return opts.transport === "tcp" ? `tcp:${hostnameOf(opts.host)}` : opts.host; +} + /** Parse `port=6697,duration=300,preload`; unknown keys ignored, bad numbers dropped. */ export function parseStsValue(value: string): StsValue { const result: StsValue = {preload: false}; @@ -250,7 +260,7 @@ export function upgradeOptions(opts: T, now: number = return opts; } - const policy = getPolicy(opts.host, now); + const policy = getPolicy(stsHost(opts), now); if (!policy) { return opts; diff --git a/client/js/irc/transport.ts b/client/js/irc/transport.ts index a8c2761cd..c7783dc62 100644 --- a/client/js/irc/transport.ts +++ b/client/js/irc/transport.ts @@ -31,6 +31,31 @@ * the foreground poke leaves them be, and only Connect now cuts them. */ +/** + * The part of `WebSocket` that {@link WsTransport} uses, and all a socket + * underneath it has to provide: the browser's `WebSocket` is one, the app + * shell's TCP socket (shellSocket.ts) another. + */ +export interface LineSocket { + readonly readyState: number; + readonly OPEN: number; + readonly protocol: string; + binaryType: string; + addEventListener(type: "open", listener: () => void): void; + addEventListener(type: "message", listener: (ev: {data: unknown}) => void): void; + addEventListener(type: "error", listener: (ev: unknown) => void): void; + addEventListener( + type: "close", + // `final`: a failure the next dial would repeat (ShellSocket's + // untrusted certificate); no retry. A WebSocket never sets it. + listener: (ev: {code: number; reason: string; wasClean: boolean; final?: boolean}) => void + ): void; + send(data: string): void; + close(code?: number, reason?: string): void; +} + +export type LineSocketCtor = new (url: string, protocols?: string | string[]) => LineSocket; + export interface ReconnectOptions { enabled: boolean; initialDelayMs: number; @@ -51,8 +76,8 @@ export interface TransportOptions { * used to drop the connection on frames >= 528 bytes, #98, fixed 2026-08-28). */ maxLineBytes?: number; - /** WebSocket constructor to use instead of `globalThis.WebSocket`. */ - WebSocketImpl?: typeof WebSocket; + /** Socket constructor to use instead of `globalThis.WebSocket`. */ + WebSocketImpl?: LineSocketCtor; } /** Why a retry waits, when it is not the schedule's own wait. */ @@ -117,7 +142,7 @@ export class WsTransport { Pick; private readonly listeners = new Set<(ev: TransportEvent) => void>(); private readonly decoder = new TextDecoder(); - private ws: WebSocket | null = null; + private ws: LineSocket | null = null; private _state: TransportState = "closed"; private attempt = 0; private timer: ReturnType | null = null; @@ -183,14 +208,14 @@ export class WsTransport { this.hold = false; // A superseded socket's open says nothing about this one. this.openedAt = null; - const Impl = this.opts.WebSocketImpl ?? globalThis.WebSocket; + const Impl: LineSocketCtor | undefined = this.opts.WebSocketImpl ?? globalThis.WebSocket; if (typeof Impl !== "function") { throw new Error("WsTransport: no WebSocket implementation available"); } this._state = "connecting"; - let ws: WebSocket; + let ws: LineSocket; try { ws = new Impl(this.opts.url, this.opts.subprotocols); @@ -222,20 +247,20 @@ export class WsTransport { this.emit({type: "open", subprotocol: ws.protocol}); } }); - ws.addEventListener("message", (ev: MessageEvent) => { + ws.addEventListener("message", (ev) => { if (ws === this.ws) { this.handleMessage(ev.data); } }); - ws.addEventListener("error", (ev: Event) => { + ws.addEventListener("error", (ev) => { if (ws === this.ws) { this.emit({type: "error", message: errorMessage(ev)}); } }); - ws.addEventListener("close", (ev: CloseEvent) => { + ws.addEventListener("close", (ev) => { if (ws === this.ws) { this.ws = null; - this.handleClosed(ev.code, ev.reason, ev.wasClean); + this.handleClosed(ev.code, ev.reason, ev.wasClean, undefined, ev.final === true); } }); } @@ -396,7 +421,8 @@ export class WsTransport { code: number, reason: string, wasClean: boolean, - cause?: "timeout" | "probe" + cause?: "timeout" | "probe", + final = false ): void { // A socket that dies while we are probing it (the foreground poke // asked "are you alive?" and the OS answered with the close, or @@ -438,7 +464,7 @@ export class WsTransport { } this.openedAt = null; - const retry = !this.closedByUs && this.opts.reconnect.enabled; + const retry = !this.closedByUs && !final && this.opts.reconnect.enabled; let delayMs: number | undefined; let why: RetryReason | undefined; diff --git a/client/js/irc/types.ts b/client/js/irc/types.ts index 66b2b6825..caeeb9fd6 100644 --- a/client/js/irc/types.ts +++ b/client/js/irc/types.ts @@ -40,6 +40,13 @@ export type ConnectOptions = { host: string; port: number; tls: boolean; + /** + * `tcp`: plain IRC over TCP (TLS when `tls`), made by the app shell + * (shellSocket.ts); unavailable in a browser. `websocket` or absent: IRC + * over WebSocket. The forms send `websocket` to switch back (an absent + * option keeps the stored value); a saved entry never holds it. + */ + transport?: "tcp" | "websocket"; nick: string; /** Comma-separated channel list, optionally with keys (`#a key, #b`). */ join: string; diff --git a/client/locales/en.json b/client/locales/en.json index 5f1fb734b..0fa533e65 100644 --- a/client/locales/en.json +++ b/client/locales/en.json @@ -208,6 +208,9 @@ "connect.connectingAs": "Connecting as {nick} to {network}…", "connect.connectingTo": "Connecting to {host}:{port}…", "connect.connectingToAttempt": "Connecting to {host}:{port}… (attempt {attempt})", + "connect.connection": "Connection", + "connect.connection.tcp": "IRC over TCP", + "connect.connection.websocket": "WebSocket", "connect.disconnected": "Disconnected.", "connect.enabledCaps": "Enabled capabilities: {caps}", "connect.enterPassword": "Enter the password for {account} to connect.", @@ -254,6 +257,7 @@ "connect.stsReconnect": "Server requires a secure connection (STS): reconnecting on port {port}…", "connect.stsUpgrade": "Upgrading to TLS on port {port} (STS policy)", "connect.submit": "Connect", + "connect.tcpNeedsApp": "IRC over TCP works only in the app. A browser can connect over WebSocket only.", "connect.title": "Connect to IRC", "connect.useTls": "Use secure connection (TLS)", "connect.user": "User", @@ -280,6 +284,8 @@ "disconnect.hint.plainCauses": "The browser does not reveal why. Likely causes: the server is down, the port is wrong, or it does not accept WebSocket connections on that port.", "disconnect.hint.registration": "The WebSocket opened but the server dropped it before registration finished - check that this port really speaks IRC over WebSocket, and look for a server notice above for the reason.", "disconnect.hint.sessionConflict": "This account is already connected on this network - another tab, window or device is using it. Close that client; this tab then connects on its next retry (a dead connection is reclaimed within about a minute).", + "disconnect.hint.tcpPlainCauses": "Likely causes: the server is down, the port is wrong, or something on the network blocks the connection.", + "disconnect.hint.tcpTlsCauses": "Likely causes: the server is down, the port is wrong, or its certificate is not trusted. A server with a self-signed certificate can only be reached without TLS for now.", "disconnect.hint.wssCauses": "The browser does not reveal why. Likely causes: the server is down, the port is wrong, or its TLS certificate is not trusted — for a self-signed certificate, open {url} in a new tab, accept the warning, then reconnect.", "disconnect.registeringClosed": "Connection to {host} closed during IRC registration{detail}.", "disconnect.registeringClosedNotReconnecting": "Connection to {host} closed during IRC registration{detail}. Not reconnecting.", diff --git a/client/locales/messages.pot b/client/locales/messages.pot index 0690faf14..43558f13a 100644 --- a/client/locales/messages.pot +++ b/client/locales/messages.pot @@ -1012,6 +1012,25 @@ msgctxt "connect.connectingToAttempt" msgid "Connecting to {host}:{port}… (attempt {attempt})" msgstr "" +#. Label of the select choosing how the app reaches the IRC server: over WebSocket or over a raw TCP connection. Shown only in the app. +#: client/components/Windows/Connect.vue +#: client/components/NetworkForm.vue +msgctxt "connect.connection" +msgid "Connection" +msgstr "" + +#. Option of the Connection select: a classic IRC connection over raw TCP, ports 6667 or 6697. Only the app can make it. +#: client/components/Windows/Connect.vue +msgctxt "connect.connection.tcp" +msgid "IRC over TCP" +msgstr "" + +#. Option of the Connection select: IRC carried over a WebSocket, what browsers use. +#: client/components/Windows/Connect.vue +msgctxt "connect.connection.websocket" +msgid "WebSocket" +msgstr "" + #. Lobby status line when the user's own /quit closed the connection on purpose. #: client/js/irc/client.ts msgctxt "connect.disconnected" @@ -1293,6 +1312,12 @@ msgctxt "connect.submit" msgid "Connect" msgstr "" +#. Lobby error when a network set to IRC over TCP is opened in a web browser, which cannot open raw TCP connections. +#: client/js/irc/client.ts +msgctxt "connect.tcpNeedsApp" +msgid "IRC over TCP works only in the app. A browser can connect over WebSocket only." +msgstr "" + #. Heading of the connect form (features.signIn off). #: client/js/branding.ts msgctxt "connect.title" @@ -1449,6 +1474,18 @@ msgctxt "disconnect.hint.sessionConflict" msgid "This account is already connected on this network - another tab, window or device is using it. Close that client; this tab then connects on its next retry (a dead connection is reclaimed within about a minute)." msgstr "" +#. Hint after a plain (no TLS) raw TCP connection failed to open, in the app. +#: client/js/irc/disconnect.ts +msgctxt "disconnect.hint.tcpPlainCauses" +msgid "Likely causes: the server is down, the port is wrong, or something on the network blocks the connection." +msgstr "" + +#. Hint after a TLS connection over raw TCP (not WebSocket) failed to open, in the app. +#: client/js/irc/disconnect.ts +msgctxt "disconnect.hint.tcpTlsCauses" +msgid "Likely causes: the server is down, the port is wrong, or its certificate is not trusted. A server with a self-signed certificate can only be reached without TLS for now." +msgstr "" + #. Follow-up hint under a failed secure (wss://) dial. {url} is the network's address with https: in place of wss:, for accepting a self-signed certificate once. #: client/js/irc/disconnect.ts msgctxt "disconnect.hint.wssCauses" diff --git a/client/locales/qqx.json b/client/locales/qqx.json index 472cebd15..ee3159642 100644 --- a/client/locales/qqx.json +++ b/client/locales/qqx.json @@ -208,6 +208,9 @@ "connect.connectingAs": "‫ċöññéċţïñġ àś {nick} ţö {network}…ċöññéċţïñġ àś {nick} ţö {network}…‬", "connect.connectingTo": "‫ċöññéċţïñġ ţö {host}:{port}…ċöññéċţïñġ ţö {host}:{port}…‬", "connect.connectingToAttempt": "‫ċöññéċţïñġ ţö {host}:{port}… )àţţéṁṗţ {attempt}(ċöññéċţïñġ ţö {host}:{port}… )àţţéṁṗţ {attempt}(‬", + "connect.connection": "‫ċöññéċţïöñċöññéċţïöñ‬", + "connect.connection.tcp": "‫ïŕċ öṽéŕ ţċṗïŕċ öṽéŕ ţċṗ‬", + "connect.connection.websocket": "‫ẃéḃśöċķéţẃéḃśöċķéţ‬", "connect.disconnected": "‫ḋïśċöññéċţéḋ.ḋïśċöññéċţéḋ.‬", "connect.enabledCaps": "‫éñàḃľéḋ ċàṗàḃïľïţïéś: {caps}éñàḃľéḋ ċàṗàḃïľïţïéś: {caps}‬", "connect.enterPassword": "‫éñţéŕ ţḣé ṗàśśẃöŕḋ ḟöŕ {account} ţö ċöññéċţ.éñţéŕ ţḣé ṗàśśẃöŕḋ ḟöŕ {account} ţö ċöññéċţ.‬", @@ -253,6 +256,7 @@ "connect.stsReconnect": "‫śéŕṽéŕ ŕéq́üïŕéś à śéċüŕé ċöññéċţïöñ )śţś(: ŕéċöññéċţïñġ öñ ṗöŕţ {port}…śéŕṽéŕ ŕéq́üïŕéś à śéċüŕé ċöññéċţïöñ )śţś(: ŕéċöññéċţïñġ öñ ṗöŕţ {port}…‬", "connect.stsUpgrade": "‫üṗġŕàḋïñġ ţö ţľś öñ ṗöŕţ {port} )śţś ṗöľïċý(üṗġŕàḋïñġ ţö ţľś öñ ṗöŕţ {port} )śţś ṗöľïċý(‬", "connect.submit": "‫ċöññéċţċöññéċţ‬", + "connect.tcpNeedsApp": "‫ïŕċ öṽéŕ ţċṗ ẃöŕķś öñľý ïñ ţḣé àṗṗ. à ḃŕöẃśéŕ ċàñ ċöññéċţ öṽéŕ ẃéḃśöċķéţ öñľý.ïŕċ öṽéŕ ţċṗ ẃöŕķś öñľý ïñ ţḣé àṗṗ. à ḃŕöẃśéŕ ċàñ ċöññéċţ öṽéŕ ẃéḃśöċķéţ öñľý.‬", "connect.title": "‫ċöññéċţ ţö ïŕċċöññéċţ ţö ïŕċ‬", "connect.useTls": "‫üśé śéċüŕé ċöññéċţïöñ )ţľś(üśé śéċüŕé ċöññéċţïöñ )ţľś(‬", "connect.user": "‫üśéŕüśéŕ‬", @@ -279,6 +283,8 @@ "disconnect.hint.plainCauses": "‫ţḣé ḃŕöẃśéŕ ḋöéś ñöţ ŕéṽéàľ ẃḣý. ľïķéľý ċàüśéś: ţḣé śéŕṽéŕ ïś ḋöẃñ, ţḣé ṗöŕţ ïś ẃŕöñġ, öŕ ïţ ḋöéś ñöţ àċċéṗţ ẃéḃśöċķéţ ċöññéċţïöñś öñ ţḣàţ ṗöŕţ.ţḣé ḃŕöẃśéŕ ḋöéś ñöţ ŕéṽéàľ ẃḣý. ľïķéľý ċàüśéś: ţḣé śéŕṽéŕ ïś ḋöẃñ, ţḣé ṗöŕţ ïś ẃŕöñġ, öŕ ïţ ḋöéś ñöţ àċċéṗţ ẃéḃśöċķéţ ċöññéċţïöñś öñ ţḣàţ ṗöŕţ.‬", "disconnect.hint.registration": "‫ţḣé ẃéḃśöċķéţ öṗéñéḋ ḃüţ ţḣé śéŕṽéŕ ḋŕöṗṗéḋ ïţ ḃéḟöŕé ŕéġïśţŕàţïöñ ḟïñïśḣéḋ - ċḣéċķ ţḣàţ ţḣïś ṗöŕţ ŕéàľľý śṗéàķś ïŕċ öṽéŕ ẃéḃśöċķéţ, àñḋ ľööķ ḟöŕ à śéŕṽéŕ ñöţïċé àḃöṽé ḟöŕ ţḣé ŕéàśöñ.ţḣé ẃéḃśöċķéţ öṗéñéḋ ḃüţ ţḣé śéŕṽéŕ ḋŕöṗṗéḋ ïţ ḃéḟöŕé ŕéġïśţŕàţïöñ ḟïñïśḣéḋ - ċḣéċķ ţḣàţ ţḣïś ṗöŕţ ŕéàľľý śṗéàķś ïŕċ öṽéŕ ẃéḃśöċķéţ, àñḋ ľööķ ḟöŕ à śéŕṽéŕ ñöţïċé àḃöṽé ḟöŕ ţḣé ŕéàśöñ.‬", "disconnect.hint.sessionConflict": "‫ţḣïś àċċöüñţ ïś àľŕéàḋý ċöññéċţéḋ öñ ţḣïś ñéţẃöŕķ - àñöţḣéŕ ţàḃ, ẃïñḋöẃ öŕ ḋéṽïċé ïś üśïñġ ïţ. ċľöśé ţḣàţ ċľïéñţ; ţḣïś ţàḃ ţḣéñ ċöññéċţś öñ ïţś ñéx́ţ ŕéţŕý )à ḋéàḋ ċöññéċţïöñ ïś ŕéċľàïṁéḋ ẃïţḣïñ àḃöüţ à ṁïñüţé(.ţḣïś àċċöüñţ ïś àľŕéàḋý ċöññéċţéḋ öñ ţḣïś ñéţẃöŕķ - àñöţḣéŕ ţàḃ, ẃïñḋöẃ öŕ ḋéṽïċé ïś üśïñġ ïţ. ċľöśé ţḣàţ ċľïéñţ; ţḣïś ţàḃ ţḣéñ ċöññéċţś öñ ïţś ñéx́ţ ŕéţŕý )à ḋéàḋ ċöññéċţïöñ ïś ŕéċľàïṁéḋ ẃïţḣïñ àḃöüţ à ṁïñüţé(.‬", + "disconnect.hint.tcpPlainCauses": "‫ľïķéľý ċàüśéś: ţḣé śéŕṽéŕ ïś ḋöẃñ, ţḣé ṗöŕţ ïś ẃŕöñġ, öŕ śöṁéţḣïñġ öñ ţḣé ñéţẃöŕķ ḃľöċķś ţḣé ċöññéċţïöñ.ľïķéľý ċàüśéś: ţḣé śéŕṽéŕ ïś ḋöẃñ, ţḣé ṗöŕţ ïś ẃŕöñġ, öŕ śöṁéţḣïñġ öñ ţḣé ñéţẃöŕķ ḃľöċķś ţḣé ċöññéċţïöñ.‬", + "disconnect.hint.tcpTlsCauses": "‫ľïķéľý ċàüśéś: ţḣé śéŕṽéŕ ïś ḋöẃñ, ţḣé ṗöŕţ ïś ẃŕöñġ, öŕ ïţś ċéŕţïḟïċàţé ïś ñöţ ţŕüśţéḋ. à śéŕṽéŕ ẃïţḣ à śéľḟ-śïġñéḋ ċéŕţïḟïċàţé ċàñ öñľý ḃé ŕéàċḣéḋ ẃïţḣöüţ ţľś ḟöŕ ñöẃ.ľïķéľý ċàüśéś: ţḣé śéŕṽéŕ ïś ḋöẃñ, ţḣé ṗöŕţ ïś ẃŕöñġ, öŕ ïţś ċéŕţïḟïċàţé ïś ñöţ ţŕüśţéḋ. à śéŕṽéŕ ẃïţḣ à śéľḟ-śïġñéḋ ċéŕţïḟïċàţé ċàñ öñľý ḃé ŕéàċḣéḋ ẃïţḣöüţ ţľś ḟöŕ ñöẃ.‬", "disconnect.hint.wssCauses": "‫ţḣé ḃŕöẃśéŕ ḋöéś ñöţ ŕéṽéàľ ẃḣý. ľïķéľý ċàüśéś: ţḣé śéŕṽéŕ ïś ḋöẃñ, ţḣé ṗöŕţ ïś ẃŕöñġ, öŕ ïţś ţľś ċéŕţïḟïċàţé ïś ñöţ ţŕüśţéḋ — ḟöŕ à śéľḟ-śïġñéḋ ċéŕţïḟïċàţé, öṗéñ {url} ïñ à ñéẃ ţàḃ, àċċéṗţ ţḣé ẃàŕñïñġ, ţḣéñ ŕéċöññéċţ.ţḣé ḃŕöẃśéŕ ḋöéś ñöţ ŕéṽéàľ ẃḣý. ľïķéľý ċàüśéś: ţḣé śéŕṽéŕ ïś ḋöẃñ, ţḣé ṗöŕţ ïś ẃŕöñġ, öŕ ïţś ţľś ċéŕţïḟïċàţé ïś ñöţ ţŕüśţéḋ — ḟöŕ à śéľḟ-śïġñéḋ ċéŕţïḟïċàţé, öṗéñ {url} ïñ à ñéẃ ţàḃ, àċċéṗţ ţḣé ẃàŕñïñġ, ţḣéñ ŕéċöññéċţ.‬", "disconnect.registeringClosed": "‫ċöññéċţïöñ ţö {host} ċľöśéḋ ḋüŕïñġ ïŕċ ŕéġïśţŕàţïöñ{detail}.ċöññéċţïöñ ţö {host} ċľöśéḋ ḋüŕïñġ ïŕċ ŕéġïśţŕàţïöñ{detail}.‬", "disconnect.registeringClosedNotReconnecting": "‫ċöññéċţïöñ ţö {host} ċľöśéḋ ḋüŕïñġ ïŕċ ŕéġïśţŕàţïöñ{detail}. ñöţ ŕéċöññéċţïñġ.ċöññéċţïöñ ţö {host} ċľöśéḋ ḋüŕïñġ ïŕċ ŕéġïśţŕàţïöñ{detail}. ñöţ ŕéċöññéċţïñġ.‬", diff --git a/docs/resources/client-features.md b/docs/resources/client-features.md index bff9bc475..f0e6d85b6 100644 --- a/docs/resources/client-features.md +++ b/docs/resources/client-features.md @@ -108,6 +108,10 @@ How the parts of the client behave and why, moved out of `CLAUDE.md` on 2026-11- **Lua scripting** (`docs/resources/lua-scripting.md`; the user's guide to writing scripts, every example run against the sandbox, is `docs/areas/scripting/`) — off unless Settings → Scripts → Enable scripting (`irc/scripting/enabled.ts`; while off the Fengari chunk is never fetched). `irc/scripting/engine.ts` (`LuaSandbox`) opens only base/coroutine/table/string/math/utf8 (never `luaL_openlibs`: its `require` brought `io`/`os`/`debug` back), text-only `load`, no `string.dump`, a capped `string.rep`, a 100k-instruction hook that coroutines inherit and that a `pcall` loop cannot outlast, and one environment per script with a read-only `seance` bound to its id (storage and errors credited to the script that ran), its own read-only views of the standard library, its own `_G` and `load`, and a locked string metatable; every handler gets its own copy of the event; each handler and command runs through the host's `run` (a fresh budget for it and for the loop after it; `MAX_EVENT_INSTRUCTIONS` per event; at most `MAX_HANDLERS` per event and `MAX_COMMANDS`; an event runs the handlers it started with); a failing script command is never sent raw; the dispatch tables live in the bootstrap chunk's locals; pattern matching has a step budget (200k `match` calls per call, a `gmatch` step on its own) patched into `fengari/src/lstrlib.js` at build time by `scripts/fengari-match-budget.js` (a transform in `vite.config.mts`), which fails the build if Fengari's source moved — mocha loads Fengari unpatched, so `test/scripts/fengari-match-budget.ts` runs the patched source and `test/tests/build.ts` checks the chunk; JS exceptions in the VM become Lua errors (`lua_atnativeerror`) and error objects are described without `__tostring`, since either used to leave the state dead for every script. Every event carries `networkUuid`, which `seance.reply`/`seance.send` route by (never by name, never falling back to another network); the manager's `generation` keeps a sandbox that loaded after a switch-off or a newer reload from being installed; "Check syntax" only compiles (`checkSyntax`). `irc/scripting/manager.ts` routes events (the handlers call `scriptManager.dispatch*`, never during a replay) and sends: a typed command's lines at once, an event handler's through `IrcClient.background` (at most `MAX_QUEUED_LINES` waiting), and a script's own echo never wakes its `message` handlers. `irc/scripting/storage.ts` caps the source (16k characters each, 256k the library) and `seance.storage` (16k per script, 128k together) and reads every write back (`ScriptStorageError`). A settings restore brings scripts and the switch back off (`settingsBackup.ts` `disarmScripts`). UI: the Settings → Scripts tab (`Settings/Scripts.vue`, `/settings/scripts`: the switch and the library) and its editor (`Settings/ScriptEdit.vue`, `/settings/scripts/`, a child route: an overlay one layer above the settings modal, z-index 30 in `#viewport`, wider than it, closed by Done/✕/Escape back to the list; the code is CodeMirror 6 with the Lua mode, `helpers/luaEditor.ts`, its own lazy chunk `js/code-editor..js`, tokens as the `tok-*` classes the chat's Prism blocks use so every theme's `--tok-*` palette applies, a plain textarea until the chunk loads), helpers in `irc/scripting/library.ts`; no sidebar icon. `/lua` runs a snippet. Tests `test/irc/scripting.ts`; browser checks `tools/scenarios/scripting-editor.mjs`, `test-lua-live.mjs` (two bots, every event a script can take, in `SEANCE_IRC_CHANNEL`, default `#seance-testing`, which must be empty: the first bot takes ops to kick, voice and set the topic; `SEANCE_IRC_URL`). +## IRC over TCP (the app only) + +A network can be reached over plain TCP or TLS instead of WebSocket, in the app shell only: a browser cannot open a raw socket (Direct Sockets is for Isolated Web Apps on managed ChromeOS). `ConnectOptions.transport: "tcp"` (absent = WebSocket; default ports 6667/6697, `saved-networks.ts` `defaultPort`) is set by the "Connection" select in `Connect.vue` and `NetworkForm.vue`, shown only inside a shell; `mergeOptions` lets the connect form switch a saved TCP network back, and `findMatching` keys on it. `WsTransport` keeps its whole policy and takes the socket through `TransportOptions.WebSocketImpl`, typed as `LineSocket` (the part of `WebSocket` it uses): `client.ts` `createTransport` hands it `shellSocket.ts` `ShellSocket`, bound to the endpoint, which calls the shell's `Socket` plugin (`shells/tauri/src-tauri/src/socket.rs`) through `IrcClientOptions.socketBridge` (`manager.ts` passes `window.seanceShell`). ShellSocket keeps one promise chain of shell calls per socket (open, the lines of a tick as one send, close), fires its own close on `close()`, and error-then-close on a failure with the shell's reason; a page token with every open lets the shell refuse an open from the page before a reload. Without a bridge (a browser) a TCP network refuses to connect (`connect.tcpNeedsApp`). The shell does framing (CRLF out; lines split, 16 KiB cap, UTF-8 else Latin-1 in), TLS (rustls on ring, Mozilla's roots from `webpki-roots`; no pinning, so a self-signed server is reachable without TLS only) and the limits a page-reachable raw socket needs: the Fetch spec's bad ports less the IRC ones, no empty line or CR/LF/NUL, only registration commands until the server's first line looks like IRC (a prefix or tags, or `PING`/`NOTICE`/`ERROR` with a parameter; else the socket closes), a 512-line queue, 10 s per write, 16 sockets and 30 opens a minute; a page load closes them after a 1 s grace for the `pagehide` QUIT. A failure the next dial would repeat (an untrusted certificate, a server that does not speak IRC) closes with `FINAL_CLOSE` (4001): ShellSocket marks that close `final` and `WsTransport` does not retry it. The page may call the shell's one command, `seance_call`, from the `main` window only (`build.rs` `AppManifest`, `capabilities/default.json`). STS policies are kept per transport (`sts.ts` `stsHost`: `tcp:host`), since one server's WebSocket and IRC TLS ports differ; a WebSocket policy does not upgrade a first plain-TCP dial. `disconnect.ts` gives TCP its own hints. Tests `test/irc/shellSocket.ts` (also under `WsTransport`), the `transport` cases in `test/irc/{client,saved-networks,sts,disconnect}.ts`, and `cargo test` in `shells/tauri/src-tauri` (framing, decoding, the send and first-line gates, ports, a dial to a local socket). + ## Translation On-device translation of what a channel reads and of the composer's drafts. The long form is `docs/resources/translation.md` (engines, router, worker, weights, devices, licences) and the design `docs/projects/client-translation.md`; the per-device choice is `docs/projects/translation-devices.md`. diff --git a/shells/tauri/.gitignore b/shells/tauri/.gitignore index 06ee7dc08..039f61f6c 100644 --- a/shells/tauri/.gitignore +++ b/shells/tauri/.gitignore @@ -4,3 +4,6 @@ src-tauri/target/ # carries over); never edited by hand. src-tauri/icons/ src-tauri/gen/schemas/ + +# Generated by tauri-build from build.rs AppManifest. +src-tauri/permissions/autogenerated/ diff --git a/shells/tauri/src-tauri/Cargo.lock b/shells/tauri/src-tauri/Cargo.lock index 1f99500b6..afb2271b9 100644 --- a/shells/tauri/src-tauri/Cargo.lock +++ b/shells/tauri/src-tauri/Cargo.lock @@ -2908,6 +2908,20 @@ dependencies = [ "web-sys", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rust-ini" version = "0.21.3" @@ -2946,6 +2960,41 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -3029,6 +3078,7 @@ dependencies = [ "objc2-core-foundation", "objc2-foundation", "objc2-web-kit", + "rustls", "serde", "serde_json", "swift-rs", @@ -3039,7 +3089,10 @@ dependencies = [ "tauri-plugin-opener", "tauri-plugin-single-instance", "tauri-plugin-window-state", + "tokio", + "tokio-rustls", "webkit2gtk", + "webpki-roots", "zbus", ] @@ -3382,6 +3435,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "swift-rs" version = "1.0.9" @@ -3972,9 +4031,31 @@ dependencies = [ "mio", "pin-project-lite", "socket2", + "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.20" @@ -4223,6 +4304,12 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -4485,6 +4572,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-roots" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.39.1" @@ -4684,7 +4780,16 @@ version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" dependencies = [ - "windows-targets", + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", ] [[package]] @@ -4702,13 +4807,29 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", ] [[package]] @@ -4735,42 +4856,90 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + [[package]] name = "windows_aarch64_msvc" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + [[package]] name = "windows_i686_gnu" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + [[package]] name = "windows_i686_msvc" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + [[package]] name = "windows_x86_64_gnu" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + [[package]] name = "windows_x86_64_msvc" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "winnow" version = "0.5.40" @@ -4989,6 +5158,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + [[package]] name = "zerotrie" version = "0.2.5" diff --git a/shells/tauri/src-tauri/Cargo.toml b/shells/tauri/src-tauri/Cargo.toml index c785c484b..f34fd1ba9 100644 --- a/shells/tauri/src-tauri/Cargo.toml +++ b/shells/tauri/src-tauri/Cargo.toml @@ -22,6 +22,12 @@ tauri-plugin-deep-link = "=2.6.3" tauri-plugin-opener = "=2.7.2" serde = { version = "1", features = ["derive"] } serde_json = "1" +# IRC over TCP (src/socket.rs). rustls on ring, not the default aws-lc-rs, +# so no cmake/NASM in the Windows and phone builds; Mozilla's roots, built in. +tokio = { version = "=1.54.1", features = ["net", "io-util", "sync", "time", "macros"] } +tokio-rustls = { version = "=0.26.6", default-features = false, features = ["ring", "logging", "tls12"] } +rustls = { version = "=0.23.46", default-features = false, features = ["ring", "std", "logging", "tls12"] } +webpki-roots = "=1.0.11" [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-single-instance = "=2.5.5" diff --git a/shells/tauri/src-tauri/build.rs b/shells/tauri/src-tauri/build.rs index 78f38526f..c07d6bb4e 100644 --- a/shells/tauri/src-tauri/build.rs +++ b/shells/tauri/src-tauri/build.rs @@ -39,7 +39,14 @@ fn main() { println!("cargo:rerun-if-changed=swift/"); } - tauri_build::build() + // The page may call `seance_call` alone, and only from the windows + // capabilities/default.json names: without a list, Tauri lets every + // window call every registered command. + tauri_build::try_build( + tauri_build::Attributes::new() + .app_manifest(tauri_build::AppManifest::new().commands(&["seance_call"])), + ) + .expect("tauri build") } /// Tauri's Swift API without its build products and tests. diff --git a/shells/tauri/src-tauri/capabilities/default.json b/shells/tauri/src-tauri/capabilities/default.json index ce3dc5c59..81a45ea81 100644 --- a/shells/tauri/src-tauri/capabilities/default.json +++ b/shells/tauri/src-tauri/capabilities/default.json @@ -1,7 +1,7 @@ { "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", - "description": "The app's own page. It reaches the shell through the seance_call command alone (src/lib.rs); no Tauri plugin API is exposed to it.", + "description": "The app's own page. It reaches the shell through the seance_call command alone (src/lib.rs, restricted to it in build.rs); no Tauri plugin API is exposed to it.", "windows": ["main"], - "permissions": [] + "permissions": ["allow-seance-call"] } diff --git a/shells/tauri/src-tauri/src/lib.rs b/shells/tauri/src-tauri/src/lib.rs index 2c7597f0b..744088694 100644 --- a/shells/tauri/src-tauri/src/lib.rs +++ b/shells/tauri/src-tauri/src/lib.rs @@ -2,7 +2,8 @@ // on the phones. The window loads the built SPA from the // bundle — `public/`, compiled in by `frontendDist` — and the page speaks IRC // over WebSocket itself; the shell adds a window, links, the badge and the -// platform's close behaviour. +// platform's close behaviour, and raw TCP/TLS sockets for networks with no +// WebSocket listener (src/socket.rs). // // The page reaches the shell through `window.seanceShell` (the init script // below), the contract client/js/helpers/shell.ts reads in every shell: @@ -16,6 +17,7 @@ mod alerts; mod apple; #[cfg(mobile)] mod mobile; +mod socket; use serde::Serialize; use serde_json::{json, Value}; @@ -42,7 +44,9 @@ const PLATFORM: &str = "ios"; /// object is frozen and neither it nor the emit hook can be replaced, so a /// page script cannot get between the page and the shell. It can still call /// the hook and fake an event, because `emit()` evaluates in the page's own -/// world; events carry nothing the page could not do itself. A retained +/// world; events carry nothing the page could not do itself. Calls are +/// another matter: `Socket` opens raw connections, which no web page can, +/// and so enforces its own limits (src/socket.rs). A retained /// event nobody listens to yet waits for its first listener. fn init_script(version: &str) -> String { format!( @@ -251,6 +255,7 @@ async fn seance_call( smoke::report(window.app_handle(), &options); Ok(json!({})) } + ("Socket", _) => socket::call(window.app_handle(), &method, &options), _ => native(window.app_handle(), &plugin, &method, &options).await, } } @@ -403,6 +408,8 @@ fn create_main(app: &AppHandle) -> tauri::Result { if started { handle.state::>().lock().unwrap().listening = false; + // The old page's connections go with it. + handle.state::().close_all(&handle); } let waiting = { @@ -573,6 +580,7 @@ pub fn run() { listening: false, })) .manage(Mutex::new(Held::default())) + .manage(socket::Sockets::default()) .invoke_handler(tauri::generate_handler![seance_call]) .setup(|app| { let handle = app.handle().clone(); diff --git a/shells/tauri/src-tauri/src/socket.rs b/shells/tauri/src-tauri/src/socket.rs new file mode 100644 index 000000000..8bb4b52af --- /dev/null +++ b/shells/tauri/src-tauri/src/socket.rs @@ -0,0 +1,791 @@ +// Raw TCP/TLS IRC connections for the page (client/js/irc/shellSocket.ts), +// for networks with no WebSocket listener. The page's WsTransport keeps its +// whole connection policy; this only moves lines: CRLF framing out, lines +// split and decoded in, TLS against Mozilla's roots (webpki-roots). +// +// Unlike the shell's other calls this does something a web page cannot, so +// every limit is enforced here, not trusted to the page, to keep it an IRC +// client and not a general one for whatever listens on the LAN: +// - ports on the Fetch spec's bad-ports list (SMTP, POP, IMAP, …) are refused; +// - a sent line may not be empty or hold CR, LF or NUL; +// - until the server has said something that looks like IRC, only the +// registration commands may be sent, and a first line that does not look +// like IRC (an HTTP status, a Redis `-ERR`) closes the socket; +// - lines, queues and writes are capped; at most MAX_SOCKETS at once, and +// MAX_OPENS a minute (no scanning the LAN through it). +// +// `Socket.open {id, page, host, port, tls}` → events `open {id}`, `lines +// {id, lines}` (one per read), `close {id, code, reason}`: 1000 when the +// server ended the connection, FINAL (4001) for a failure a retry cannot +// mend (an untrusted certificate, a server that is not IRC), 1006 for any +// other. `Socket.send {id, +// lines}`, once open. `Socket.close {id}`: the queued lines are written (a +// QUIT gets out), the socket shut, and no event follows. A page load closes +// every socket, and an open from the page before it (`page`, a token per +// page load) is refused. + +use serde_json::{json, Value}; +use std::collections::{HashMap, HashSet, VecDeque}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::{Duration, Instant}; +use tauri::{AppHandle, Manager}; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; +use tokio::net::TcpStream; +use tokio::sync::mpsc; + +use crate::ShellError; + +const PLUGIN: &str = "Socket"; +const MAX_SOCKETS: usize = 16; +/// Opens allowed per OPEN_WINDOW: enough for every network's quick retries +/// after a network change, too few for the page to scan a LAN with. +const MAX_OPENS: usize = 30; +const OPEN_WINDOW: Duration = Duration::from_secs(60); +/// Queued outbound lines per socket; past it the socket is closed. +const SEND_QUEUE: usize = 512; +/// A sent line, before CRLF. The page caps lines at 500 bytes already. +const MAX_SEND_LINE: usize = 4096; +/// A received line; an ircd's own limit is 8191 + 512 bytes of tags. +const MAX_LINE: usize = 16 * 1024; +const ADDRESS_TIMEOUT: Duration = Duration::from_secs(5); +const DIAL_DEADLINE: Duration = Duration::from_secs(20); +const DRAIN_TIMEOUT: Duration = Duration::from_secs(2); +const PAGE_GONE_GRACE: Duration = Duration::from_secs(1); +/// One write; a server that stops reading must not hold the socket forever. +const WRITE_TIMEOUT: Duration = Duration::from_secs(10); +/// What may be sent before the server has said anything IRC-like. PING is +/// the page's liveness probe, QUIT a disconnect before any reply. +const REGISTRATION: &[&str] = &[ + "CAP", + "PASS", + "NICK", + "USER", + "AUTHENTICATE", + "PING", + "QUIT", +]; + +/// The Fetch spec's bad ports (https://fetch.spec.whatwg.org/#port-blocking), +/// less the IRC ones on it (6665–6669, 6679, 6697). +const BAD_PORTS: &[u16] = &[ + 0, 1, 7, 9, 11, 13, 15, 17, 19, 20, 21, 22, 23, 25, 37, 42, 43, 53, 69, 77, 79, 87, 95, 101, + 102, 103, 104, 109, 110, 111, 113, 115, 117, 119, 123, 135, 137, 139, 143, 161, 179, 389, 427, + 465, 512, 513, 514, 515, 526, 530, 531, 532, 540, 548, 554, 556, 563, 587, 601, 636, 989, 990, + 993, 995, 1719, 1720, 1723, 2049, 3659, 4045, 4190, 5060, 5061, 6000, 6566, 10080, +]; + +/// A live socket: its queue of lines to write — dropping the sender is how +/// a socket is closed, its task drains what is queued and ends — and what +/// its task has seen. +struct Entry { + tx: mpsc::Sender, + /// Connected (the page may send). + open: Arc, + /// The server's first line looked like IRC (any command may be sent). + greeted: Arc, +} + +#[derive(Default)] +struct State { + sockets: HashMap, + /// The token of the page load that is opening sockets. + page: Option, + /// Tokens of earlier page loads, whose late opens are refused. + retired: HashSet, + /// The page before a reload's sockets, for PAGE_GONE_GRACE: WebKit + /// starts the new load before the old page's `pagehide`, whose QUIT + /// must still get out. They are closed nowhere but here; what they + /// still read reaches no page route. + retiring: HashMap, + /// When the last MAX_OPENS opens were accepted. + opens: VecDeque, +} + +#[derive(Default)] +pub struct Sockets(Mutex); + +impl Sockets { + /// A page load: the old page's sockets go with it, after a grace for + /// the QUIT its `pagehide` sends. + pub fn close_all(&self, app: &AppHandle) { + let mut state = self.0.lock().unwrap(); + let old: Vec = state.sockets.keys().cloned().collect(); + let sockets = std::mem::take(&mut state.sockets); + state.retiring.extend(sockets); + + if let Some(page) = state.page.take() { + state.retired.insert(page); + } + + if old.is_empty() { + return; + } + + let app = app.clone(); + tauri::async_runtime::spawn(async move { + tokio::time::sleep(PAGE_GONE_GRACE).await; + let sockets = app.state::(); + let mut state = sockets.0.lock().unwrap(); + + for id in old { + state.retiring.remove(&id); + } + }); + } +} + +fn refuse(message: impl Into) -> ShellError { + ShellError { + message: message.into(), + code: "refused".into(), + } +} + +fn str_option<'a>(options: &'a Value, key: &str) -> Option<&'a str> { + options.get(key).and_then(Value::as_str) +} + +pub fn call(app: &AppHandle, method: &str, options: &Value) -> Result { + let sockets = app.state::(); + let id = str_option(options, "id") + .filter(|id| !id.is_empty() && id.len() <= 64) + .ok_or_else(|| refuse("no socket id"))? + .to_owned(); + + match method { + "open" => { + let page = str_option(options, "page") + .filter(|p| !p.is_empty() && p.len() <= 64) + .ok_or_else(|| refuse("no page token"))? + .to_owned(); + let host = str_option(options, "host") + .map(clean_host) + .unwrap_or_default(); + let port = options.get("port").and_then(Value::as_u64).unwrap_or(0); + let tls = options.get("tls").and_then(Value::as_bool).unwrap_or(false); + + if host.is_empty() || host.len() > 255 { + return Err(refuse("no host")); + } + + let port = u16::try_from(port) + .ok() + .filter(|p| port_allowed(*p)) + .ok_or_else(|| refuse(format!("port {port} is not allowed")))?; + let (tx, rx) = mpsc::channel(SEND_QUEUE); + let open = Arc::new(AtomicBool::new(false)); + let greeted = Arc::new(AtomicBool::new(false)); + + { + let mut state = sockets.0.lock().unwrap(); + + // An open the page before a reload sent, handled after it. + if state.retired.contains(&page) { + return Err(refuse("the page that opened this is gone")); + } + + state.page = Some(page); + + if state.sockets.contains_key(&id) { + return Err(refuse("socket id in use")); + } + + if state.sockets.len() >= MAX_SOCKETS { + return Err(refuse("too many connections")); + } + + let now = Instant::now(); + + while state + .opens + .front() + .is_some_and(|at| now.duration_since(*at) >= OPEN_WINDOW) + { + state.opens.pop_front(); + } + + if state.opens.len() >= MAX_OPENS { + return Err(refuse("too many connections opened; trying again shortly")); + } + + state.opens.push_back(now); + + // In the map before the task starts: a close that comes + // while it dials finds it, and the dial is abandoned. + state.sockets.insert( + id.clone(), + Entry { + tx: tx.clone(), + open: open.clone(), + greeted: greeted.clone(), + }, + ); + } + + let task = Task { + app: app.clone(), + id, + marker: tx.downgrade(), + open, + greeted, + }; + drop(tx); + tauri::async_runtime::spawn(task.run(host, port, tls, rx)); + Ok(json!({})) + } + "send" => { + let lines = options + .get("lines") + .and_then(Value::as_array) + .ok_or_else(|| refuse("no lines"))?; + let (tx, greeted) = { + let state = sockets.0.lock().unwrap(); + let entry = state + .sockets + .get(&id) + .or_else(|| state.retiring.get(&id)) + .ok_or_else(|| refuse("no such socket"))?; + + if !entry.open.load(Ordering::SeqCst) { + return Err(refuse("not connected yet")); + } + + (entry.tx.clone(), entry.greeted.load(Ordering::SeqCst)) + }; + + for line in lines { + let line = line.as_str().ok_or_else(|| refuse("a line is not text"))?; + let checked = check_send_line(line).and_then(|()| { + if greeted || is_registration(line) { + Ok(()) + } else { + Err("only registration before the server has answered") + } + }); + + if let Err(reason) = checked { + end_socket(app, &id, None, 1006, reason); + return Err(refuse(reason)); + } + + if tx.try_send(line.to_owned()).is_err() { + end_socket(app, &id, None, 1006, "send queue full"); + return Err(refuse("send queue full")); + } + } + + Ok(json!({})) + } + "close" => { + let mut state = sockets.0.lock().unwrap(); + state.sockets.remove(&id); + state.retiring.remove(&id); + Ok(json!({})) + } + _ => Err(ShellError { + message: format!("{PLUGIN}.{method} is not implemented"), + code: "unimplemented".into(), + }), + } +} + +/// Close a socket the page did not ask to close, and tell the page why. +/// `owner`: the socket's own task ending it (the server, the network), which +/// says nothing if the page closed it meanwhile or a newer page owns the id. +/// None: the shell refusing a send; a page that is gone hears nothing. +fn end_socket( + app: &AppHandle, + id: &str, + owner: Option<&mpsc::Sender>, + code: u16, + reason: &str, +) { + let sockets = app.state::(); + let mut state = sockets.0.lock().unwrap(); + + if owner.is_none() { + state.retiring.remove(id); + } + + let ours = state + .sockets + .get(id) + .is_some_and(|entry| owner.map_or(true, |tx| entry.tx.same_channel(tx))); + + if ours { + state.sockets.remove(id); + drop(state); + crate::emit( + app, + PLUGIN, + "close", + json!({"id": id, "code": code, "reason": reason}), + false, + ); + } +} + +trait Stream: AsyncRead + AsyncWrite + Send + Unpin {} +impl Stream for T {} + +/// One socket's task: dial, then move lines both ways until either side ends. +struct Task { + app: AppHandle, + id: String, + /// Recognises our map entry; it stops upgrading once the page closed us. + marker: mpsc::WeakSender, + open: Arc, + greeted: Arc, +} + +impl Task { + async fn run(self, host: String, port: u16, tls: bool, mut rx: mpsc::Receiver) { + let dial = tokio::time::timeout(DIAL_DEADLINE, dial(&host, port, tls)); + let stream = tokio::select! { + result = dial => match result { + Ok(Ok(stream)) => stream, + Ok(Err((code, reason))) => return self.end(code, &reason), + Err(_) => return self.end(1006, "connect timed out"), + }, + // Closed by the page while dialling (`send` refuses lines until + // open, so nothing but the close can arrive): give up quietly. + _ = rx.recv() => return, + }; + + self.open.store(true, Ordering::SeqCst); + crate::emit(&self.app, PLUGIN, "open", json!({ "id": self.id }), false); + let (mut reader, mut writer) = tokio::io::split(stream); + let mut buf: Vec = Vec::new(); + let mut chunk = vec![0u8; 16 * 1024]; + + loop { + tokio::select! { + read = reader.read(&mut chunk) => match read { + Ok(0) => return self.end(1000, "the server closed the connection"), + Ok(n) => { + buf.extend_from_slice(&chunk[..n]); + + match take_lines(&mut buf) { + Ok(lines) if lines.is_empty() => {} + Ok(lines) => { + if !self.greeted.load(Ordering::SeqCst) { + if !looks_like_irc(&lines[0]) { + return self.end(FINAL, "the server does not speak IRC"); + } + + self.greeted.store(true, Ordering::SeqCst); + } + + crate::emit(&self.app, PLUGIN, "lines", json!({"id": self.id, "lines": lines}), false); + } + Err(reason) => return self.end(1006, reason), + } + } + // Most ircds drop the connection without TLS's close_notify. + Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => { + return self.end(1000, "the server closed the connection") + } + Err(e) => return self.end(1006, &e.to_string()), + }, + line = rx.recv() => { + // Closed by the page (or a page load) once our marker is + // dead: whatever is still queued (a QUIT) gets the drain's + // time, all of it, then the socket is shut, quietly. + let Some(line) = line else { + return drain(&mut writer, Vec::new()).await; + }; + + if self.marker.upgrade().is_none() { + let mut rest = vec![line]; + + while let Ok(more) = rx.try_recv() { + rest.push(more); + } + + return drain(&mut writer, rest).await; + } + + match tokio::time::timeout(WRITE_TIMEOUT, write_line(&mut writer, line)).await { + Ok(Ok(())) => {} + Ok(Err(e)) => return self.end(1006, &e.to_string()), + Err(_) => return self.end(1006, "the server stopped reading"), + } + } + } + } + } + + /// The socket ended on its own: tell the page, unless it closed us. + fn end(&self, code: u16, reason: &str) { + if let Some(tx) = self.marker.upgrade() { + end_socket(&self.app, &self.id, Some(&tx), code, reason); + } + } +} + +async fn write_line(writer: &mut W, line: String) -> std::io::Result<()> { + let mut bytes = line.into_bytes(); + bytes.extend_from_slice(b"\r\n"); + writer.write_all(&bytes).await +} + +/// Write what is left, flush and shut down, all within DRAIN_TIMEOUT. +async fn drain(writer: &mut W, lines: Vec) { + let _ = tokio::time::timeout(DRAIN_TIMEOUT, async { + for line in lines { + if write_line(writer, line).await.is_err() { + return; + } + } + + let _ = writer.flush().await; + let _ = writer.shutdown().await; + }) + .await; +} + +/// A close code of the application range (as WebSocket's 4000–4999): the +/// page does not retry it, since the next dial would fail the same way. +const FINAL: u16 = 4001; + +/// Dial `host`; a failure is its close code and reason. +async fn dial(host: &str, port: u16, tls: bool) -> Result, (u16, String)> { + let addrs: Vec<_> = tokio::net::lookup_host((host, port)) + .await + .map_err(|e| (1006, format!("cannot resolve {host}: {e}")))? + .collect(); + let mut last = format!("{host} has no address"); + let mut tcp = None; + + // One address at a time, each with its own timeout: a dead IPv6 route + // must not use up the whole wait before IPv4 is tried. + for addr in addrs { + match tokio::time::timeout(ADDRESS_TIMEOUT, TcpStream::connect(addr)).await { + Ok(Ok(stream)) => { + tcp = Some(stream); + break; + } + Ok(Err(e)) => last = e.to_string(), + Err(_) => last = format!("{addr} timed out"), + } + } + + let tcp = tcp.ok_or((1006, last))?; + let _ = tcp.set_nodelay(true); + + if !tls { + return Ok(Box::new(tcp)); + } + + let name = rustls::pki_types::ServerName::try_from(host.to_owned()) + .map_err(|_| (FINAL, format!("{host} is not a valid TLS server name")))?; + let stream = tls_connector() + .map_err(|reason| (1006, reason))? + .connect(name, tcp) + .await + .map_err(tls_failure)?; + Ok(Box::new(stream)) +} + +/// A failed handshake, in words a user can act on. A certificate that is +/// not trusted now will not be on the next dial: final. +fn tls_failure(e: std::io::Error) -> (u16, String) { + let detail = e.to_string(); + + match e + .get_ref() + .and_then(|inner| inner.downcast_ref::()) + { + Some(rustls::Error::InvalidCertificate(cert)) => { + use rustls::CertificateError as C; + // The plain reason, not rustls's debug text (`Other(OtherError(…))`). + let why = match cert { + C::Expired | C::ExpiredContext { .. } => ": it has expired", + C::NotValidForName | C::NotValidForNameContext { .. } => { + ": it is for a different name" + } + C::UnknownIssuer => ": it is signed by an unknown authority", + // The device's clock may be what is wrong: worth retrying. + C::NotValidYet | C::NotValidYetContext { .. } => { + return ( + 1006, + "the server's certificate is not valid yet: is this device's clock right?" + .into(), + ); + } + _ => ": it may be self-signed", + }; + ( + FINAL, + format!("the server's certificate is not trusted{why}"), + ) + } + _ => (1006, format!("TLS failed: {detail}")), + } +} + +/// Built once: Mozilla's roots on the ring provider. +fn tls_connector() -> Result { + static CONFIG: OnceLock, String>> = OnceLock::new(); + let config = CONFIG.get_or_init(|| { + let roots = rustls::RootCertStore { + roots: webpki_roots::TLS_SERVER_ROOTS.to_vec(), + }; + rustls::ClientConfig::builder_with_provider(Arc::new( + rustls::crypto::ring::default_provider(), + )) + .with_safe_default_protocol_versions() + .map(|builder| Arc::new(builder.with_root_certificates(roots).with_no_client_auth())) + .map_err(|e| e.to_string()) + }); + config.clone().map(tokio_rustls::TlsConnector::from) +} + +/// `[::1]` → `::1`; anything else as given, trimmed. +fn clean_host(host: &str) -> String { + let host = host.trim(); + host.strip_prefix('[') + .and_then(|h| h.strip_suffix(']')) + .unwrap_or(host) + .to_owned() +} + +fn port_allowed(port: u16) -> bool { + !BAD_PORTS.contains(&port) +} + +fn check_send_line(line: &str) -> Result<(), &'static str> { + if line.is_empty() { + Err("an empty line") + } else if line.bytes().any(|b| b == b'\r' || b == b'\n' || b == 0) { + Err("a line may not hold CR, LF or NUL") + } else if line.len() > MAX_SEND_LINE { + Err("line too long") + } else { + Ok(()) + } +} + +/// The command of a line the page sends (no tags or prefix from a client). +fn command_of(line: &str) -> &str { + line.split(' ').next().unwrap_or("") +} + +fn is_registration(line: &str) -> bool { + let command = command_of(line); + REGISTRATION.iter().any(|c| c.eq_ignore_ascii_case(command)) +} + +/// A server's first line: tags, a prefix (an ircd prefixes its numerics), +/// or a command an ircd sends unprompted. An HTTP status line, a Redis +/// `-ERR` or an SMTP `220` greeting is none of these. +fn looks_like_irc(line: &str) -> bool { + if line.starts_with('@') || line.starts_with(':') { + return true; + } + + // Without a prefix, only what a server says before it knows us, and + // with a parameter: a bare `ERROR` is memcached's answer to anything. + let command = command_of(line); + let has_param = line + .split_once(' ') + .is_some_and(|(_, rest)| !rest.trim().is_empty()); + has_param + && ["PING", "NOTICE", "ERROR"] + .iter() + .any(|c| c.eq_ignore_ascii_case(command)) +} + +/// The complete lines in `buf` (LF-terminated, a trailing CR stripped, empty +/// ones dropped), decoded; the unterminated rest stays in `buf`. A line over +/// MAX_LINE, finished or not, is an error. +fn take_lines(buf: &mut Vec) -> Result, &'static str> { + let mut lines = Vec::new(); + let mut start = 0; + + while let Some(at) = buf[start..].iter().position(|&b| b == b'\n') { + let end = start + at; + let mut line = &buf[start..end]; + + if let [rest @ .., b'\r'] = line { + line = rest; + } + + if line.len() > MAX_LINE { + return Err("line too long"); + } + + if !line.is_empty() { + lines.push(decode(line)); + } + + start = end + 1; + } + + buf.drain(..start); + + // The rest may still end in the CR of a CRLF split across reads. + if buf.len() > MAX_LINE + 1 { + return Err("line too long"); + } + + Ok(lines) +} + +/// UTF-8, or Latin-1 for a line that is not (older networks and clients). +fn decode(bytes: &[u8]) -> String { + match std::str::from_utf8(bytes) { + Ok(text) => text.to_owned(), + Err(_) => bytes.iter().map(|&b| b as char).collect(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn splits_crlf_and_lf_and_keeps_the_rest() { + let mut buf = b"PING :a\r\nNOTICE x :y\n\r\n:part".to_vec(); + assert_eq!( + take_lines(&mut buf).unwrap(), + vec!["PING :a", "NOTICE x :y"] + ); + assert_eq!(buf, b":part"); + buf.extend_from_slice(b"ial\r\n"); + assert_eq!(take_lines(&mut buf).unwrap(), vec![":partial"]); + assert!(buf.is_empty()); + } + + #[test] + fn refuses_overlong_lines_finished_or_not() { + let mut long = vec![b'a'; MAX_LINE + 2]; + assert!(take_lines(&mut long.clone()).is_err()); + long.truncate(MAX_LINE + 1); + long.push(b'\n'); + assert!(take_lines(&mut long).is_err()); + let mut ok = vec![b'a'; MAX_LINE]; + ok.extend_from_slice(b"\r\n"); + assert_eq!(take_lines(&mut ok).unwrap()[0].len(), MAX_LINE); + } + + #[test] + fn decodes_utf8_or_latin1() { + assert_eq!(decode("café".as_bytes()), "café"); + assert_eq!(decode(b"caf\xe9"), "café"); + assert_eq!(decode(b"\x01ACTION waves\x01"), "\u{1}ACTION waves\u{1}"); + } + + #[test] + fn checks_sent_lines() { + assert!(check_send_line("PRIVMSG #a :hi").is_ok()); + assert!(check_send_line("PRIVMSG #a :hi\r\nQUIT").is_err()); + assert!(check_send_line("a\0b").is_err()); + assert!(check_send_line("").is_err()); + assert!(check_send_line(&"a".repeat(MAX_SEND_LINE + 1)).is_err()); + } + + #[test] + fn allows_irc_ports_and_refuses_bad_ones() { + for port in [6665, 6667, 6669, 6679, 6697, 7000, 994, 443, 8067] { + assert!(port_allowed(port), "{port}"); + } + + for port in [0, 22, 25, 110, 143, 587, 993] { + assert!(!port_allowed(port), "{port}"); + } + } + + #[test] + fn lets_only_registration_out_before_the_server_answers() { + for line in [ + "CAP LS 302", + "NICK me", + "USER me 0 * :me", + "AUTHENTICATE PLAIN", + "ping :probe", + "QUIT :bye", + "PASS x", + ] { + assert!(is_registration(line), "{line}"); + } + + for line in [ + "GET / HTTP/1.1", + "POST /containers/create HTTP/1.1", + "SET k v", + "PRIVMSG #a :x", + "Host: x", + ] { + assert!(!is_registration(line), "{line}"); + } + } + + #[test] + fn knows_an_irc_server_by_its_first_line() { + for line in [ + ":irc.test NOTICE * :*** Looking up", + "@time=x :s 001 me :hi", + "NOTICE AUTH :hi", + "PING :123", + "ERROR :Closing link", + ] { + assert!(looks_like_irc(line), "{line}"); + } + + for line in [ + "HTTP/1.1 400 Bad Request", + "-ERR unknown command", + "+OK", + "SSH-2.0-OpenSSH_9.6", + "220 smtp ready ESMTP", + "ERROR", + "ERROR ", + "CAP LS 302", + ] { + assert!(!looks_like_irc(line), "{line}"); + } + } + + #[test] + fn strips_ipv6_brackets() { + assert_eq!(clean_host(" [::1] "), "::1"); + assert_eq!(clean_host("irc.example.org"), "irc.example.org"); + } + + #[tokio::test] + async fn dials_plain_tcp_and_frames_lines() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = tokio::spawn(async move { + let (mut sock, _) = listener.accept().await.unwrap(); + sock.write_all(b":srv 001 me :hi\r\n").await.unwrap(); + let mut got = vec![0u8; 64]; + let n = sock.read(&mut got).await.unwrap(); + got.truncate(n); + got + }); + let mut stream = dial("127.0.0.1", port, false).await.unwrap(); + let mut buf = Vec::new(); + let mut chunk = [0u8; 64]; + let n = stream.read(&mut chunk).await.unwrap(); + buf.extend_from_slice(&chunk[..n]); + assert_eq!(take_lines(&mut buf).unwrap(), vec![":srv 001 me :hi"]); + stream.write_all(b"NICK me\r\n").await.unwrap(); + assert_eq!(server.await.unwrap(), b"NICK me\r\n"); + } + + #[tokio::test] + async fn reports_a_refused_dial() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + drop(listener); + assert!(dial("127.0.0.1", port, false).await.is_err()); + } + + #[test] + fn tls_config_builds_with_ring() { + assert!(tls_connector().is_ok()); + } +} diff --git a/test/irc/client.ts b/test/irc/client.ts index 49e7dc972..b0ffdc208 100644 --- a/test/irc/client.ts +++ b/test/irc/client.ts @@ -1,13 +1,19 @@ import {expect} from "chai"; import sinon from "sinon"; import socket from "../../client/js/socket"; -import {IrcClient, IrcClientOptions, parseJoinList, buildUrl} from "../../client/js/irc/client"; +import { + IrcClient, + IrcClientOptions, + parseJoinList, + buildUrl, + tcpHostOf, +} from "../../client/js/irc/client"; import {IdAllocator} from "../../client/js/irc/ids"; import * as saved from "../../client/js/irc/saved-networks"; import {registerBusHandlers} from "../../client/js/irc/bus"; import {utf8ByteLength} from "../../client/js/irc/message"; import type {Transport} from "../../client/js/irc/types"; -import type {TransportEvent, TransportState} from "../../client/js/irc/transport"; +import type {TransportEvent, TransportOptions, TransportState} from "../../client/js/irc/transport"; import {CTCP_REPLY_INTERVAL_MS} from "../../client/js/irc/handlers/privmsg"; import {ChanState, ChanType} from "../../shared/types/chan"; import {MessageType, SharedMsg} from "../../shared/types/msg"; @@ -1204,6 +1210,46 @@ describe("IrcClient", function () { expect(payloads("init")).to.have.length(2); }); + it("refuses IRC over TCP without the app shell, and does not dial", function () { + const h = setup({transport: "tcp", port: 6697}); + h.client.connect(); + expect(h.transport.connectCalls).to.equal(0); + expect(messages().some((m) => /works only in the app/.test(m.text ?? ""))).to.equal( + true + ); + }); + + it("gives IRC over TCP the shell's socket, and rebuilds the transport when the type changes", function () { + const seen: TransportOptions[] = []; + const bridge = {call: () => Promise.resolve({}), listen() {}}; + const h = setup({ + transport: "tcp", + port: 6697, + socketBridge: bridge, + transportFactory(opts) { + seen.push(opts); + return new FakeTransport(); + }, + }); + expect(seen[0].url).to.equal("ircs://irc.test:6697"); + expect(seen[0].WebSocketImpl).to.be.a("function"); + + // Only the type changes: that alone needs a new transport. + h.client.applySettings({ + host: "irc.test", + port: 6697, + tls: true, + nick: "alice", + join: "#seance", + sasl: "", + saslAccount: "", + saslPassword: "", + }); + expect(seen).to.have.length(2); + expect(seen[1].url).to.equal("wss://irc.test:6697/"); + expect(seen[1].WebSocketImpl).to.equal(undefined); + }); + it("tears down at once when new settings swap the transport right after disconnect()", function () { const made: FakeTransport[] = []; const h = setup({ @@ -1484,6 +1530,15 @@ describe("IrcClient", function () { expect(buildUrl("::1", 8443, true)).to.equal("wss://[::1]:8443/"); }); + it("builds irc:// and ircs:// URLs for IRC over TCP, and bare hosts to dial", function () { + expect(buildUrl("irc.example.org/ws", 6697, true, "tcp")).to.equal( + "ircs://irc.example.org:6697" + ); + expect(buildUrl("::1", 6667, false, "tcp")).to.equal("irc://[::1]:6667"); + expect(tcpHostOf(" ircs://irc.example.org/x ")).to.equal("irc.example.org"); + expect(tcpHostOf("[::1]")).to.equal("::1"); + }); + it("parses the join list with keys and missing prefixes", function () { expect(parseJoinList("#a key, b ,&c, #A")).to.deep.equal([ {name: "#a", key: "key"}, diff --git a/test/irc/disconnect.ts b/test/irc/disconnect.ts index 21d2cf872..786a86fbd 100644 --- a/test/irc/disconnect.ts +++ b/test/irc/disconnect.ts @@ -35,6 +35,31 @@ describe("describeClose", function () { expect(report.hint).to.contain("open https://irc.example.org:8443/ in a new tab"); }); + it("gives IRC over TCP its own hints, with no browser advice", function () { + const tls = describeClose({ + ...base, + url: "ircs://irc.example.org:6697", + host: "irc.example.org", + phase: "connecting", + pageProtocol: "https:", + errorMessage: "invalid peer certificate: UnknownIssuer", + }); + + expect(tls.text).to.contain("(invalid peer certificate: UnknownIssuer)"); + expect(tls.hint).to.contain("self-signed"); + expect(tls.hint).to.not.contain("new tab"); + + const plain = describeClose({ + ...base, + url: "irc://irc.example.org:6667", + phase: "connecting", + pageProtocol: "https:", + }); + + expect(plain.hint).to.contain("blocks the connection"); + expect(plain.hint).to.not.contain("ws://"); + }); + it("includes an informative transport error but not the browser stock one", function () { const informative = describeClose({ ...base, diff --git a/test/irc/saved-networks.ts b/test/irc/saved-networks.ts index 3dccd97d7..0ff8ced7d 100644 --- a/test/irc/saved-networks.ts +++ b/test/irc/saved-networks.ts @@ -78,6 +78,60 @@ describe("saved-networks", function () { expect(saved.list()).to.deep.equal([]); }); + it("keeps IRC over TCP through save, edit and connect options", function () { + saved.save({...entry(), transport: "tcp", port: 6697}); + expect(saved.get(entry().uuid)?.transport).to.equal("tcp"); + expect(saved.toConnectOptions(saved.get(entry().uuid)!)).to.include({ + transport: "tcp", + port: 6697, + }); + + // The form sends `websocket` to go back; the stored `tcp` must not survive the merge. + const back = saved.fromForm( + {uuid: entry().uuid, transport: "websocket", tls: "on"}, + saved.get(entry().uuid) + ); + expect(back).to.not.have.property("transport"); + expect(saved.toConnectOptions(back)).to.not.have.property("transport"); + + // Anything but `tcp` is the WebSocket default. + expect(saved.normalize({...entry(), transport: "carrier pigeon"})).to.not.have.property( + "transport" + ); + }); + + it("lets the connect form switch a saved TCP network back to WebSocket", function () { + const tcp = saved.normalize({...entry(), transport: "tcp", port: 6697})!; + const back = {host: tcp.host, port: 8443, tls: true, transport: "websocket" as const}; + expect(saved.normalize(saved.mergeOptions(tcp, tcp.uuid, back))).to.not.have.property( + "transport" + ); + // Options that do not name the connection type keep the stored one. + expect(saved.normalize(saved.mergeOptions(tcp, tcp.uuid, {nick: "bob"}))).to.include({ + transport: "tcp", + nick: "bob", + }); + }); + + it("keeps a TCP and a WebSocket entry for one host and port apart", function () { + saved.save({...entry(), uuid: "ws", port: 6697}); + saved.save({...entry(), uuid: "tcp", port: 6697, transport: "tcp"}); + expect(saved.findMatching("irc.example.org", 6697, "alice")?.uuid).to.equal("ws"); + expect(saved.findMatching("irc.example.org", 6697, "alice", "websocket")?.uuid).to.equal( + "ws" + ); + expect(saved.findMatching("irc.example.org", 6697, "alice", "tcp")?.uuid).to.equal("tcp"); + }); + + it("has TCP default ports beside the WebSocket ones", function () { + expect(saved.defaultPort(true, "tcp")).to.equal(6697); + expect(saved.defaultPort(false, "tcp")).to.equal(6667); + expect(saved.defaultPort(true)).to.equal(9998); + expect( + saved.normalize({...entry(), transport: "tcp", tls: false, port: ""})?.port + ).to.equal(6667); + }); + it("saves and reads back an entry under thelounge.networks", function () { saved.save(entry()); diff --git a/test/irc/shellSocket.ts b/test/irc/shellSocket.ts new file mode 100644 index 000000000..21d156b93 --- /dev/null +++ b/test/irc/shellSocket.ts @@ -0,0 +1,379 @@ +import {expect} from "chai"; +import { + FINAL_CLOSE, + ShellSocket, + SocketBridge, + shellSocketCtor, +} from "../../client/js/irc/shellSocket"; +import {WsTransport, TransportEvent} from "../../client/js/irc/transport"; + +/** A stand-in for `window.seanceShell`'s Socket plugin, driven by the tests. */ +class FakeBridge implements SocketBridge { + calls: {method: string; options: any}[] = []; + /** Methods that reject, with this message. */ + refuse = new Map(); + /** While set, calls wait until release() — to show they still run in order. */ + private gate: Promise | null = null; + private open: (() => void) | null = null; + private listeners = new Map void>(); + + hold(): void { + this.gate = new Promise((resolve) => (this.open = resolve)); + } + + release(): void { + this.open?.(); + this.gate = null; + } + + async call(plugin: string, method: string, options?: unknown): Promise { + expect(plugin).to.equal("Socket"); + const gate = this.gate; + + if (gate && method === "open") { + await gate; + } + + this.calls.push({method, options}); + const refusal = this.refuse.get(method); + + if (refusal) { + throw Object.assign(new Error(refusal), {code: "refused"}); + } + + return {}; + } + + listen(plugin: string, event: string, cb: (data: any) => void): void { + expect(plugin).to.equal("Socket"); + this.listeners.set(event, cb); + } + + emit(event: string, data: unknown): void { + this.listeners.get(event)?.(data); + } + + methods(): string[] { + return this.calls.map((c) => c.method); + } +} + +const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); + +function record(socket: ShellSocket): string[] { + const log: string[] = []; + socket.addEventListener("open", () => log.push(`open:${socket.readyState}`)); + socket.addEventListener("message", (ev) => log.push(`line:${String(ev.data)}`)); + socket.addEventListener("error", (ev) => + log.push(`error:${(ev as {message: string}).message}`) + ); + socket.addEventListener("close", (ev) => + log.push(`close:${ev.code}:${ev.reason}:${ev.wasClean}`) + ); + return log; +} + +const endpoint = {host: "irc.example.org", port: 6697, tls: true}; + +describe("ShellSocket", function () { + it("opens, delivers lines and sends in one call per tick", async function () { + const bridge = new FakeBridge(); + const socket = new ShellSocket(bridge, endpoint, "a"); + const log = record(socket); + await tick(); + expect(bridge.calls[0]).to.deep.equal({ + method: "open", + options: { + id: "a", + page: bridge.calls[0].options.page, + host: "irc.example.org", + port: 6697, + tls: true, + }, + }); + expect(socket.readyState).to.equal(socket.CONNECTING); + + bridge.emit("open", {id: "a"}); + bridge.emit("lines", {id: "a", lines: [":srv 001 me :hi", ":srv 002 me :x"]}); + expect(log).to.deep.equal(["open:1", "line::srv 001 me :hi", "line::srv 002 me :x"]); + + socket.send("NICK me"); + socket.send("USER me 0 * :me"); + await tick(); + expect(bridge.calls[1]).to.deep.equal({ + method: "send", + options: {id: "a", lines: ["NICK me", "USER me 0 * :me"]}, + }); + }); + + it("sends one page token with every open", function () { + const bridge = new FakeBridge(); + new ShellSocket(bridge, endpoint, "p1"); + new ShellSocket(bridge, endpoint, "p2"); + return tick().then(() => { + const [a, b] = bridge.calls.map((c) => c.options.page as string); + expect(a).to.match(/^[0-9a-f-]{36}$/); + expect(b).to.equal(a); + }); + }); + + it("fails like a refusal when the bridge throws at once", async function () { + const bridge = new FakeBridge(); + bridge.call = () => { + throw new Error("no IPC"); + }; + const socket = new ShellSocket(bridge, endpoint, "t"); + const log = record(socket); + await tick(); + expect(log).to.deep.equal(["error:no IPC", "close:1006:no IPC:false"]); + }); + + it("delivers the rest of a batch when a listener throws", function () { + const bridge = new FakeBridge(); + const socket = new ShellSocket(bridge, endpoint, "x"); + const got: string[] = []; + socket.addEventListener("message", (ev) => { + if (ev.data === "boom") { + throw new Error("listener failed"); + } + + got.push(String(ev.data)); + }); + bridge.emit("open", {id: "x"}); + // The throw is rethrown in a microtask; catch the scheduling instead. + const rethrows: Array<() => void> = []; + const real = globalThis.queueMicrotask; + globalThis.queueMicrotask = (fn) => rethrows.push(fn); + + try { + bridge.emit("lines", {id: "x", lines: ["a", "boom", "b"]}); + } finally { + globalThis.queueMicrotask = real; + } + + expect(() => rethrows.forEach((fn) => fn())).to.throw("listener failed"); + expect(got).to.deep.equal(["a", "b"]); + }); + + it("throws on send before it is open", function () { + const socket = new ShellSocket(new FakeBridge(), endpoint, "b"); + expect(() => socket.send("NICK me")).to.throw(/not open/); + }); + + it("keeps sends behind a slow open and a close behind the sends", async function () { + const bridge = new FakeBridge(); + bridge.hold(); + const socket = new ShellSocket(bridge, endpoint, "c"); + // The shell's open event can come before its call resolves. + bridge.emit("open", {id: "c"}); + socket.send("QUIT :bye"); + socket.close(); + bridge.release(); + await tick(); + expect(bridge.methods()).to.deep.equal(["open", "send", "close"]); + expect(bridge.calls[1].options.lines).to.deep.equal(["QUIT :bye"]); + }); + + it("fires its own close once when the page closes it", async function () { + const bridge = new FakeBridge(); + const socket = new ShellSocket(bridge, endpoint, "d"); + const log = record(socket); + bridge.emit("open", {id: "d"}); + socket.close(); + socket.close(); + expect(socket.readyState).to.equal(socket.CLOSING); + await tick(); + // A late close from the shell, or more lines, change nothing. + bridge.emit("lines", {id: "d", lines: ["late"]}); + bridge.emit("close", {id: "d", code: 1006, reason: "reset"}); + expect(log).to.deep.equal(["open:1", "close:1000::true"]); + expect(socket.readyState).to.equal(socket.CLOSED); + }); + + it("closes with 1006 when the page closes it while connecting, and ignores a late open", async function () { + const bridge = new FakeBridge(); + const socket = new ShellSocket(bridge, endpoint, "e"); + const log = record(socket); + socket.close(); + await tick(); + bridge.emit("open", {id: "e"}); + expect(log).to.deep.equal(["close:1006:closed while connecting:false"]); + }); + + it("reports a lost socket as error then close, with the shell's reason", function () { + const bridge = new FakeBridge(); + const socket = new ShellSocket(bridge, endpoint, "f"); + const log = record(socket); + bridge.emit("open", {id: "f"}); + bridge.emit("close", {id: "f", code: 1006, reason: "connection reset"}); + bridge.emit("close", {id: "f", code: 1006, reason: "again"}); + expect(log).to.deep.equal([ + "open:1", + "error:connection reset", + "close:1006:connection reset:false", + ]); + }); + + it("reports an orderly EOF as a clean close, with no error", function () { + const bridge = new FakeBridge(); + const socket = new ShellSocket(bridge, endpoint, "g"); + const log = record(socket); + bridge.emit("open", {id: "g"}); + bridge.emit("close", {id: "g", code: 1000, reason: ""}); + expect(log).to.deep.equal(["open:1", "close:1000::true"]); + }); + + it("marks a failure the shell calls final", function () { + const bridge = new FakeBridge(); + const socket = new ShellSocket(bridge, endpoint, "fin"); + const closes: unknown[] = []; + socket.addEventListener("close", (ev) => closes.push(ev)); + bridge.emit("close", {id: "fin", code: FINAL_CLOSE, reason: "certificate"}); + expect(closes).to.deep.equal([ + {code: FINAL_CLOSE, reason: "certificate", wasClean: false, final: true}, + ]); + }); + + it("turns a refused open into error then close", async function () { + const bridge = new FakeBridge(); + bridge.refuse.set("open", "port 25 is not allowed"); + const socket = new ShellSocket(bridge, endpoint, "h"); + const log = record(socket); + await tick(); + expect(log).to.deep.equal([ + "error:port 25 is not allowed", + "close:1006:port 25 is not allowed:false", + ]); + }); + + it("turns a refused send into error then close", async function () { + const bridge = new FakeBridge(); + bridge.refuse.set("send", "queue full"); + const socket = new ShellSocket(bridge, endpoint, "i"); + const log = record(socket); + bridge.emit("open", {id: "i"}); + socket.send("PRIVMSG #a :x"); + await tick(); + expect(log).to.deep.equal(["open:1", "error:queue full", "close:1006:queue full:false"]); + }); + + it("routes events by id and ignores unknown ids", function () { + const bridge = new FakeBridge(); + const one = new ShellSocket(bridge, endpoint, "one"); + const two = new ShellSocket(bridge, endpoint, "two"); + const logOne = record(one); + const logTwo = record(two); + bridge.emit("open", {id: "two"}); + bridge.emit("open", {id: "nobody"}); + bridge.emit("lines", {lines: ["no id"]}); + expect(logOne).to.deep.equal([]); + expect(logTwo).to.deep.equal(["open:1"]); + }); + + it("makes random ids when none is given", function () { + const bridge = new FakeBridge(); + const a = new ShellSocket(bridge, endpoint); + const b = new ShellSocket(bridge, endpoint); + expect(a.id).to.match(/^[0-9a-f-]{36}$/); + expect(a.id).to.not.equal(b.id); + }); + + describe("under WsTransport", function () { + it("registers, answers PING and closes on request", async function () { + const bridge = new FakeBridge(); + const transport = new WsTransport({ + url: "ircs://irc.example.org:6697", + WebSocketImpl: shellSocketCtor(bridge, endpoint), + reconnect: { + enabled: false, + initialDelayMs: 1, + maxDelayMs: 1, + factor: 1, + jitter: false, + }, + }); + const events: TransportEvent[] = []; + transport.on((ev) => events.push(ev)); + transport.connect(); + await tick(); + const id = bridge.calls[0].options.id as string; + expect(bridge.calls[0].options.host).to.equal("irc.example.org"); + + bridge.emit("open", {id}); + expect(transport.state).to.equal("open"); + transport.send("CAP LS 302"); + bridge.emit("lines", {id, lines: ["PING :abc"]}); + await tick(); + // The PONG and the line sent before it go out in order. + expect(bridge.calls[1].options.lines).to.deep.equal(["CAP LS 302", "PONG :abc"]); + + transport.close(); + await tick(); + expect(bridge.methods()).to.deep.equal(["open", "send", "close"]); + expect(transport.state).to.equal("closed"); + expect(events.map((e) => e.type)).to.include.members(["open", "line", "close"]); + const close = events.find((e) => e.type === "close") as {willReconnect: boolean}; + expect(close.willReconnect).to.equal(false); + }); + + it("retries a lost dial but not a final one", async function () { + for (const [code, retries] of [ + [1006, true], + [FINAL_CLOSE, false], + ] as const) { + const bridge = new FakeBridge(); + const transport = new WsTransport({ + url: "ircs://irc.example.org:6697", + WebSocketImpl: shellSocketCtor(bridge, endpoint), + reconnect: { + enabled: true, + initialDelayMs: 60000, + maxDelayMs: 60000, + factor: 1, + jitter: false, + }, + }); + const events: TransportEvent[] = []; + transport.on((ev) => events.push(ev)); + transport.connect(); + await tick(); + bridge.emit("close", {id: bridge.calls[0].options.id, code, reason: "x"}); + const close = events.find((e) => e.type === "close") as {willReconnect: boolean}; + expect(close.willReconnect, String(code)).to.equal(retries); + expect(transport.state).to.equal(retries ? "reconnect-wait" : "closed"); + transport.close(); + } + }); + + it("abandons a dial: the old socket is closed in the shell, its late open ignored", async function () { + const bridge = new FakeBridge(); + const transport = new WsTransport({ + url: "irc://irc.example.org:6667", + WebSocketImpl: shellSocketCtor(bridge, {...endpoint, port: 6667, tls: false}), + reconnect: { + enabled: false, + initialDelayMs: 1, + maxDelayMs: 1, + factor: 1, + jitter: false, + }, + }); + transport.connect(); + await tick(); + const first = bridge.calls[0].options.id as string; + transport.redial(); + await tick(); + const opens = bridge.calls.filter((c) => c.method === "open"); + expect(opens).to.have.length(2); + expect( + bridge.calls.some((c) => c.method === "close" && c.options.id === first) + ).to.equal(true); + bridge.emit("open", {id: first}); + expect(transport.state).to.equal("connecting"); + bridge.emit("open", {id: opens[1].options.id}); + expect(transport.state).to.equal("open"); + transport.close(); + await tick(); + }); + }); +}); diff --git a/test/irc/sts.ts b/test/irc/sts.ts index 82773fd09..952426e99 100644 --- a/test/irc/sts.ts +++ b/test/irc/sts.ts @@ -256,6 +256,26 @@ describe("STS", function () { }); }); + describe("transport", function () { + it("keeps TCP and WebSocket policies for one host apart", function () { + expect(sts.stsHost({host: "irc.example.org"})).to.equal("irc.example.org"); + expect(sts.stsHost({host: "irc.example.org/ws", transport: "tcp"})).to.equal( + "tcp:irc.example.org" + ); + sts.setPolicy("irc.example.org", {port: 8443, expiresAt: T0 + 1000, preload: false}); + const tcp = {...BASE, transport: "tcp" as const, port: 6667}; + expect(sts.upgradeOptions(tcp)).to.equal(tcp); + + sts.setPolicy("tcp:irc.example.org", { + port: 6697, + expiresAt: T0 + 1000, + preload: false, + }); + expect(sts.upgradeOptions(tcp)).to.deep.equal({...tcp, tls: true, port: 6697}); + expect(sts.upgradeOptions(BASE)).to.deep.equal({...BASE, tls: true, port: 8443}); + }); + }); + describe("IrcClient", function () { it("on an insecure connection, sts port= reconnects securely exactly once", function () { const h = setup();