From 9b650ba0d74f1cd4c60dce418ef5da6ce59e4140 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 17:54:04 +0000 Subject: [PATCH 1/2] Server password (PASS) for bouncers without client SASL ZNC 1.9 and older offer no SASL to clients and answer a client that sends no PASS with 464, then wait; ZNC 1.10 takes SASL only with saslplainauth loaded, which only new installs get by default. Seance never sent PASS, so it could not log in to them at all. - ConnectOptions.serverPassword, sent as `PASS :` after CAP LS and before NICK/USER; nothing is sent when it is empty. Independent of SASL: both go out when both are set. - Stored under the SASL password's rules: on disk only with rememberPassword, with hasServerPassword kept so needsPassword() sends a link or an autoconnect back to the connect form; stripped from a settings backup unless passwords are kept; never from a URL; carried to the push worker's stash under the same rule, and the worker sends it too. - Edit form: the switch off clears it; on with the field blank keeps the stored one, or the one a live client holds in memory. - 464 before 001 stops the connection instead of retrying: the lobby says whether a password was missing or refused, and the entry stops connecting by itself. After 001 a 464 (OPER) stays an ordinary error. - Forms: a "This server needs a password" switch and field in the connect form (hidden when the deploy locks the host) and in network settings; "Remember password" shows for either password. --- client/components/NetworkForm.vue | 73 ++++++++++++++++++----- client/components/Windows/Connect.vue | 80 ++++++++++++++++++++++--- client/js/boot.ts | 2 +- client/js/helpers/linkTarget.ts | 3 +- client/js/helpers/settingsBackup.ts | 11 ++-- client/js/i18n/call-sites.ts | 7 +++ client/js/irc/client.ts | 43 ++++++++++++++ client/js/irc/handlers/numerics.ts | 7 +++ client/js/irc/manager.ts | 22 ++++--- client/js/irc/saved-networks.ts | 61 +++++++++++++++++-- client/js/irc/types.ts | 6 ++ client/js/webpush.ts | 3 +- client/locales/en.json | 7 +++ client/locales/messages.pot | 45 ++++++++++++++ client/locales/qqx.json | 7 +++ client/service-worker.js | 7 +++ docs/resources/client-features.md | 4 ++ test/helpers/settingsBackup.ts | 15 +++++ test/irc/saved-networks.ts | 64 ++++++++++++++++++++ test/irc/server-password.ts | 84 +++++++++++++++++++++++++++ 20 files changed, 509 insertions(+), 42 deletions(-) create mode 100644 test/irc/server-password.ts diff --git a/client/components/NetworkForm.vue b/client/components/NetworkForm.vue index d1481cbe2..3fa1dd39a 100644 --- a/client/components/NetworkForm.vue +++ b/client/components/NetworkForm.vue @@ -17,7 +17,7 @@ +
+ +
+
+
+
+ + + + +
+
+ +
+ +
+
+ + + + +
+ +
+ +
+
@@ -457,6 +501,7 @@ export default defineComponent({ const serverAddressLabel = computed(() => t("connect.serverAddress")); const serverPortLabel = computed(() => t("connect.serverPort")); const channelsPlaceholder = computed(() => t("connect.channelsPlaceholder")); + const serverPasswordHelp = computed(() => t("form.serverPasswordHelp")); const tls = network?.tls ?? defaults?.tls ?? true; // The server the deploy points at. Pinned when the host is locked. const server = { @@ -474,6 +519,7 @@ export default defineComponent({ sasl: defaults?.sasl === "plain" ? "plain" : "", saslAccount: defaults?.saslAccount || "", saslPassword: defaults?.saslPassword || "", + serverPassword: "", }); // `lockHost` hides the server fields; `allowCustomServer: false` does the @@ -503,6 +549,7 @@ export default defineComponent({ const rememberMe = ref(false); const showSasl = ref(false); + const showServerPassword = ref(false); const rememberPassword = ref(false); const autoconnect = ref(false); /** Push defaults to on for a new network: the flag means "register when @@ -535,6 +582,7 @@ export default defineComponent({ const submitted = ref(null); const notice = ref(""); const passwordInput = ref(null); + const serverPasswordInput = ref(null); const prefill = (net: SavedNetwork) => { form.host = net.host; @@ -546,6 +594,8 @@ export default defineComponent({ form.saslAccount = net.saslAccount; form.saslPassword = net.saslPassword; showSasl.value = net.sasl === "plain"; + form.serverPassword = net.serverPassword ?? ""; + showServerPassword.value = !!net.hasServerPassword; rememberPassword.value = !!net.rememberPassword; autoconnect.value = !!net.autoconnect; pushEnabled.value = net.pushEnabled !== false; @@ -567,7 +617,7 @@ export default defineComponent({ const savedLink = savedLinkUuid ? saved.get(savedLinkUuid) : undefined; const linkIgnored = firstParam(props.queryParams?.linkIgnored); const fromLink = isTruthyParam(props.queryParams?.fromLink); - let focusPassword = false; + let focusPassword: "sasl" | "server" | false = false; if (savedLink) { prefill(savedLink); @@ -579,7 +629,10 @@ export default defineComponent({ if (savedLink.sasl === "plain" && !savedLink.saslPassword) { notice.value = t("connect.enterPassword", {account: savedLink.saslAccount}); - focusPassword = true; + focusPassword = "sasl"; + } else if (savedLink.hasServerPassword && !savedLink.serverPassword) { + notice.value = t("connect.enterServerPassword", {host: savedLink.host}); + focusPassword = "server"; } } else if (hasConnectParams) { applyQueryParams(form, props.queryParams); @@ -660,6 +713,7 @@ export default defineComponent({ const client = createNetwork({ ...submitted.value, uuid: (signInMode ? signInUuid() : selectedUuid.value) ?? undefined, + hasServerPassword: !!submitted.value.serverPassword, pushEnabled: pushEnabled.value, notifyEnabled: notifyEnabled.value, ...options, @@ -713,8 +767,13 @@ export default defineComponent({ form.saslPassword = ""; } + if (!showServerPassword.value) { + form.serverPassword = ""; + } + start({ - rememberPassword: showSasl.value && rememberPassword.value, + rememberPassword: + (showSasl.value || showServerPassword.value) && rememberPassword.value, autoconnect: autoconnect.value, }); }; @@ -741,8 +800,10 @@ export default defineComponent({ onMounted(() => { // A link to a saved network whose password was not remembered: // everything is filled in but the password, so put the cursor there. - if (focusPassword) { + if (focusPassword === "sasl") { passwordInput.value?.focus(); + } else if (focusPassword === "server") { + serverPasswordInput.value?.focus(); } }); @@ -764,6 +825,8 @@ export default defineComponent({ networkLabel, showSavedNetworks, showSasl, + showServerPassword, + serverPasswordHelp, rememberPassword, autoconnect, pushEnabled, @@ -775,6 +838,7 @@ export default defineComponent({ linkNotice, fromLink, passwordInput, + serverPasswordInput, onSubmit, cancelLink, }; diff --git a/client/js/boot.ts b/client/js/boot.ts index 8e0f76bd6..8983168ef 100644 --- a/client/js/boot.ts +++ b/client/js/boot.ts @@ -323,7 +323,7 @@ async function handleQueryParams( // The connection would need a password nobody stored: back to the // form, pre-filled from the saved entry, to type it. - if (!live?.isConnected && entry.sasl === "plain" && !entry.saslPassword) { + if (!live?.isConnected && saved.needsPassword(entry)) { await router.push({ name: "Connect", query: {savedLink: entry.uuid, join: suggestion.join, fromLink: "1"}, diff --git a/client/js/helpers/linkTarget.ts b/client/js/helpers/linkTarget.ts index 935272230..793e82f9c 100644 --- a/client/js/helpers/linkTarget.ts +++ b/client/js/helpers/linkTarget.ts @@ -16,7 +16,8 @@ * names some other host → refuse it, with a message; * - anything else → the connect form, pre-filled, for the user to approve. * - * Secrets never ride along: a URL cannot supply `saslPassword`, and + * Secrets never ride along: a URL cannot supply `saslPassword` or + * `serverPassword` (the allow-list below), and * `autoconnect` is not honoured from URLs at all — the user must click. * * Kept free of Vue/store/DOM imports so mocha covers it diff --git a/client/js/helpers/settingsBackup.ts b/client/js/helpers/settingsBackup.ts index 041d6223b..211950ddf 100644 --- a/client/js/helpers/settingsBackup.ts +++ b/client/js/helpers/settingsBackup.ts @@ -127,7 +127,7 @@ function storedBackupKeys(): string[] { } export interface CollectOptions { - /** Keep `saslPassword` on the saved networks; off strips it. */ + /** Keep `saslPassword` and `serverPassword` on the saved networks; off strips them. */ includePasswords?: boolean; /** The live settings object; storage only holds `settings` once one has * been changed, so the caller passes the store's, defaults and all. */ @@ -166,8 +166,9 @@ export function collectBackup(options: CollectOptions = {}): SettingsBackup { return net; } - const {saslPassword, ...rest} = net as Record; + const {saslPassword, serverPassword, ...rest} = net as Record; void saslPassword; + void serverPassword; return {...rest, rememberPassword: false}; }); } @@ -202,8 +203,10 @@ export function hasPasswords(backup: SettingsBackup): boolean { (net) => typeof net === "object" && net !== null && - typeof (net as Record).saslPassword === "string" && - (net as Record).saslPassword !== "" + ["saslPassword", "serverPassword"].some((key) => { + const value = (net as Record)[key]; + return typeof value === "string" && value !== ""; + }) ) ); } diff --git a/client/js/i18n/call-sites.ts b/client/js/i18n/call-sites.ts index a9b279f50..9bbc148df 100644 --- a/client/js/i18n/call-sites.ts +++ b/client/js/i18n/call-sites.ts @@ -169,6 +169,7 @@ export const STATIC_CALL_SITES = new Set([ "connect.disconnected", "connect.enabledCaps", "connect.enterPassword", + "connect.enterServerPassword", "connect.guestNickRequired", "connect.guestSubmit", "connect.guestTitle", @@ -205,6 +206,9 @@ export const STATIC_CALL_SITES = new Set([ "connect.saslTrustedGaveUpHint", "connect.server", "connect.serverAddress", + "connect.serverPasswordHint", + "connect.serverPasswordNeeded", + "connect.serverPasswordRefused", "connect.serverPort", "connect.signInIntro", "connect.signInSubmit", @@ -314,6 +318,9 @@ export const STATIC_CALL_SITES = new Set([ "form.save", "form.server", "form.serverAddress", + "form.serverPassword", + "form.serverPasswordHelp", + "form.serverPasswordToggle", "form.serverPort", "form.servicesAccount", "form.servicesAccountPlaceholder", diff --git a/client/js/irc/client.ts b/client/js/irc/client.ts index c28ce3589..9f4890f94 100644 --- a/client/js/irc/client.ts +++ b/client/js/irc/client.ts @@ -294,6 +294,8 @@ export interface IrcClientOptions extends ConnectOptions { * way back to the connect screen to fix them. */ onSaslRejected?: () => void; + /** The server password was missing or refused (464 before 001): same effect. */ + onServerPasswordRejected?: () => void; } /** What to try after a SASL login the deploy insists on did not happen. */ @@ -911,6 +913,8 @@ export class IrcClient { sasl: next.sasl, saslAccount: next.saslAccount, saslPassword: next.saslPassword, + // Undefined: configured but not kept on disk, so keep the one in memory. + serverPassword: next.serverPassword ?? this.options.serverPassword, }; const state = this.transport.state; @@ -1202,6 +1206,11 @@ export class IrcClient { this.send(line); } + // Before NICK/USER, as registration requires; trailing, so spaces survive. + if (this.options.serverPassword) { + this.send(trailingLine("PASS", [this.options.serverPassword])); + } + this.send(formatLine({command: "NICK", params: [this.nick]})); this.send(trailingLine("USER", [this.ident, "0", "*", this.options.realname || this.nick])); } @@ -1343,6 +1352,40 @@ export class IrcClient { return true; } + /** + * 464 before 001: the server (often a bouncer: ZNC answers a client with + * no PASS this way, then waits) wants a server password, or refused the + * one sent. Every connection would get the same answer, so this one + * stops, says why, and the entry stops connecting by itself. After 001 a + * 464 is an ordinary error (OPER with the wrong password). + */ + serverPasswordFailed(): void { + if (this.welcomed || this.isQuitting) { + return; + } + + const host = this.options.host; + const sent = !!this.options.serverPassword; + this.pushMessage( + this.lobby, + { + type: MessageType.ERROR, + text: sent + ? t("connect.serverPasswordRefused", {host}) + : t("connect.serverPasswordNeeded", {host}), + }, + true + ); + this.pushMessage(this.lobby, {text: t("connect.serverPasswordHint")}, true); + this.closeExplained = true; + this.bus.dispatch("network:aborted", { + network: this.uuid, + reason: sent ? "server password refused" : "server password required", + }); + this.options.onServerPasswordRejected?.(); + this.disconnect(sent ? "Server password refused" : "Server password required"); + } + /** Whether a SASL failure of this kind is worth another connection (see saslFailed). */ private saslRetries(cause: SaslFailureCause, trusted: boolean): boolean { switch (cause) { diff --git a/client/js/irc/handlers/numerics.ts b/client/js/irc/handlers/numerics.ts index 98ad708c8..223fc6373 100644 --- a/client/js/irc/handlers/numerics.ts +++ b/client/js/irc/handlers/numerics.ts @@ -261,6 +261,12 @@ export const numericError: Handler = (client, msg) => { pushError(client, error, error.channel); }; +/** ERR_PASSWDMISMATCH: shown as any error; before 001 it also ends the connect. */ +const passwordMismatch: Handler = (client, msg) => { + numericError(client, msg); + client.serverPasswordFailed(); +}; + export default { "001": welcome, "002": infoLine, @@ -284,4 +290,5 @@ export default { "396": hostHidden, "432": badNick, "433": badNick, + "464": passwordMismatch, }; diff --git a/client/js/irc/manager.ts b/client/js/irc/manager.ts index 6bc07a094..87f6116fa 100644 --- a/client/js/irc/manager.ts +++ b/client/js/irc/manager.ts @@ -54,6 +54,7 @@ export type CreateNetworkOptions = ConnectOptions & | "pushEnabled" | "notifyEnabled" | "commands" + | "hasServerPassword" > >; @@ -101,6 +102,15 @@ function rememberNetwork(options: CreateNetworkOptions): SavedNetwork { return entry; } +/** A refused login: the saved entry stops connecting at page load (see createNetwork). */ +function stopAutoconnect(uuid: string): void { + const current = saved.get(uuid); + + if (current?.autoconnect) { + saved.save({...current, autoconnect: false}); + } +} + /** Create (or reuse) the client for these options, save the config and connect. */ export function createNetwork(options: CreateNetworkOptions): IrcClient { const entry = rememberNetwork(options); @@ -125,13 +135,8 @@ export function createNetwork(options: CreateNetworkOptions): IrcClient { // credentials would be refused again at every page load, and a // network that never registers is not one the user can get back // to the connect screen from. - onSaslRejected() { - const current = saved.get(entry.uuid); - - if (current?.autoconnect) { - saved.save({...current, autoconnect: false}); - } - }, + onSaslRejected: () => stopAutoconnect(entry.uuid), + onServerPasswordRejected: () => stopAutoconnect(entry.uuid), }); clients.set(entry.uuid, client); // Load the network's ignore list now, which mirrors it for the push @@ -193,7 +198,8 @@ export function autoconnectSavedNetworks(): IrcClient[] { continue; } - if (entry.sasl === "plain" && !entry.saslPassword) { + // A password it needs was not kept: it waits for the connect form. + if (saved.needsPassword(entry)) { continue; } diff --git a/client/js/irc/saved-networks.ts b/client/js/irc/saved-networks.ts index 1bd6f80a5..a29bde7cb 100644 --- a/client/js/irc/saved-networks.ts +++ b/client/js/irc/saved-networks.ts @@ -7,8 +7,10 @@ * because network settings and sort, mute and collapse preferences are keyed * by it (see docs/resources/bus-contract.md §"Store expectations"). * - * The SASL password is only written to disk when `rememberPassword` is set; - * otherwise `saslPassword` is stripped on save and the user is asked again. + * The SASL and server passwords are only written to disk when + * `rememberPassword` is set; otherwise `saslPassword` and `serverPassword` + * are stripped on save and the user is asked again (`hasServerPassword` + * remembers that there was one to ask for). * * Kept free of store imports so it runs under mocha; tests swap the storage * backend with {@link useStorageBackend}. @@ -51,8 +53,11 @@ export type SavedNetwork = ConnectOptions & { name: string; /** Connect at startup without visiting the connect form. */ autoconnect?: boolean; - /** Persist `saslPassword`. Off by default so the password never hits disk. */ + /** Persist `saslPassword` and `serverPassword`. Off by default so no password hits disk. */ rememberPassword?: boolean; + /** A server password is configured, even when it was not kept on disk: + * the connect form asks for it again (see {@link needsPassword}). */ + hasServerPassword?: boolean; /** Register this device's Web Push subscription with this network * (`draft/webpush`). Entries stored before the flag existed read as * enabled — push was unconditional then. */ @@ -266,6 +271,17 @@ export function normalize(raw: Record): SavedNetwork | undefine commands: parseCommands(raw.commands), }; + // A line break or NUL would end the PASS line early (wire.ts refuses it). + const serverPassword = asString(raw.serverPassword).replace(/[\r\n\0]/g, ""); + + if (serverPassword) { + net.serverPassword = serverPassword; + } + + if (serverPassword || asBoolean(raw.hasServerPassword)) { + net.hasServerPassword = true; + } + if (typeof raw.lastUsed === "number" && Number.isFinite(raw.lastUsed)) { net.lastUsed = raw.lastUsed; } @@ -360,7 +376,14 @@ export function fromForm(data: Record, existing?: SavedNetwork) } } - for (const flag of ["tls", "autoconnect", "rememberPassword", "pushEnabled", "notifyEnabled"]) { + for (const flag of [ + "tls", + "autoconnect", + "rememberPassword", + "pushEnabled", + "notifyEnabled", + "hasServerPassword", + ]) { if (!(flag in data)) { base[flag] = false; } @@ -371,6 +394,14 @@ export function fromForm(data: Record, existing?: SavedNetwork) base.saslPassword = existing.saslPassword; } + // The server password's switch off clears it; on with the field blank + // keeps the stored one, as for SASL. + if (!base.hasServerPassword) { + delete base.serverPassword; + } else if (asString(data.serverPassword) === "" && existing?.serverPassword) { + base.serverPassword = existing.serverPassword; + } + // The services block travels whole: the key present with nothing in it // is "None" (the loop above keeps the stored one for a missing key). if ("services" in data) { @@ -397,9 +428,23 @@ export function toConnectOptions(net: SavedNetwork): ConnectOptions { sasl: net.sasl, saslAccount: net.saslAccount, saslPassword: net.saslPassword, + // Configured but not on disk: undefined, so a live client keeps the + // one it was given (IrcClient.applySettings). + serverPassword: net.serverPassword ?? (net.hasServerPassword ? undefined : ""), }; } +/** + * The entry cannot connect as saved: a password it needs was not kept on + * disk (`rememberPassword` off), so it waits for the connect form. + */ +export function needsPassword(net: SavedNetwork): boolean { + return ( + (net.sasl === "plain" && !net.saslPassword) || + (!!net.hasServerPassword && !net.serverPassword) + ); +} + // --------------------------------------------------------------- storage function read(): SavedNetwork[] { @@ -438,7 +483,13 @@ function write(entries: SavedNetwork[]): void { /** Never let a password reach disk unless the user asked for it. */ function forStorage(net: SavedNetwork): SavedNetwork { - return net.rememberPassword ? {...net} : {...net, saslPassword: ""}; + if (net.rememberPassword) { + return {...net}; + } + + const {serverPassword: _dropped, ...rest} = net; + void _dropped; + return {...rest, saslPassword: ""}; } function byRecency(a: SavedNetwork, b: SavedNetwork): number { diff --git a/client/js/irc/types.ts b/client/js/irc/types.ts index 66b2b6825..17a3d9c99 100644 --- a/client/js/irc/types.ts +++ b/client/js/irc/types.ts @@ -47,6 +47,12 @@ export type ConnectOptions = { sasl: "" | "plain" | "external"; saslAccount: string; saslPassword: string; + /** + * Server password, sent as `PASS` before `NICK`/`USER` when non-empty. + * Bouncers that predate client SASL want it (ZNC: `user/network:password`); + * independent of SASL, and both are sent when both are set. + */ + serverPassword?: string; /** * Drop the connection when a configured SASL login does not succeed, * instead of carrying on unauthenticated. Unset means "ask the deploy": diff --git a/client/js/webpush.ts b/client/js/webpush.ts index 39c2e6bbe..7c21c4a86 100644 --- a/client/js/webpush.ts +++ b/client/js/webpush.ts @@ -1552,7 +1552,7 @@ if (typeof indexedDB !== "undefined") { * that announced a key or holds an entry, its uuid, connection details and * the key its subscription was made against — that is how a worker * deep-links a notification, relays a reply to this page, and renews its - * subscription. The SASL password rides along ONLY for networks that + * subscription. The SASL password (and a server password) rides along ONLY for networks that * remember it (it never hits IndexedDB otherwise); that is what lets a * worker send a reply, mute, or renew over its own connection when no page * is alive. Rewritten on every connect so a password remembered later, or @@ -1572,6 +1572,7 @@ async function writeStash(): Promise { tls: net.tls, saslAccount: net.saslAccount, saslPassword: net.rememberPassword === true ? net.saslPassword : undefined, + serverPassword: net.rememberPassword === true ? net.serverPassword : undefined, vapid: subs[net.uuid]?.vapid ?? announcedKey(net.uuid) ?? null, })); diff --git a/client/locales/en.json b/client/locales/en.json index 5f1fb734b..ddab00f94 100644 --- a/client/locales/en.json +++ b/client/locales/en.json @@ -211,6 +211,7 @@ "connect.disconnected": "Disconnected.", "connect.enabledCaps": "Enabled capabilities: {caps}", "connect.enterPassword": "Enter the password for {account} to connect.", + "connect.enterServerPassword": "Enter the server password for {host}.", "connect.guestNickRequired": "Choose a nick to connect as a guest.", "connect.guestSubmit": "Connect as guest", "connect.guestTitle": "No account?", @@ -247,6 +248,9 @@ "connect.saslTrustedGaveUpHint": "This login has worked here before, so it is kept: connecting again tries it once more. If the password has changed, update it in this network's settings.", "connect.server": "Server", "connect.serverAddress": "Server address", + "connect.serverPasswordHint": "Set the server password in this network's settings. For ZNC it is username/network:password.", + "connect.serverPasswordNeeded": "{host} needs a server password before you can connect.", + "connect.serverPasswordRefused": "{host} refused the server password.", "connect.serverPort": "Server port", "connect.signInIntro": "", "connect.signInSubmit": "Sign in", @@ -356,6 +360,9 @@ "form.save": "Save network", "form.server": "Server", "form.serverAddress": "Server address", + "form.serverPassword": "Server password", + "form.serverPasswordHelp": "Only if the server or bouncer asks for one. For ZNC, enter username/network:password.", + "form.serverPasswordToggle": "This server needs a password", "form.serverPort": "Server port", "form.servicesAccount": "Account service", "form.servicesAccountPlaceholder": "The bot that holds accounts", diff --git a/client/locales/messages.pot b/client/locales/messages.pot index 0690faf14..1ffae3444 100644 --- a/client/locales/messages.pot +++ b/client/locales/messages.pot @@ -1030,6 +1030,12 @@ msgctxt "connect.enterPassword" msgid "Enter the password for {account} to connect." msgstr "" +#. Connect form notice when a link opens a saved network whose server password was not remembered. {host} is the server's host name. +#: client/components/Windows/Connect.vue +msgctxt "connect.enterServerPassword" +msgid "Enter the server password for {host}." +msgstr "" + #. Error notice shown under the guest sign-in form when the guest nick field was left empty or invalid. #: client/components/Windows/Connect.vue:672 msgctxt "connect.guestNickRequired" @@ -1251,6 +1257,24 @@ msgctxt "connect.serverAddress" msgid "Server address" msgstr "" +#. Lobby hint after connect.serverPasswordNeeded / connect.serverPasswordRefused. 'username/network:password' is literal syntax: keep it untranslated. +#: client/js/irc/client.ts +msgctxt "connect.serverPasswordHint" +msgid "Set the server password in this network's settings. For ZNC it is username/network:password." +msgstr "" + +#. Lobby error: the server answered 464 (password required) during registration and no server password was set; the connection stopped. {host} is the configured host name. +#: client/js/irc/client.ts +msgctxt "connect.serverPasswordNeeded" +msgid "{host} needs a server password before you can connect." +msgstr "" + +#. Lobby error: the server answered 464 (password mismatch) to the server password that was sent; the connection stopped. {host} is the configured host name. +#: client/js/irc/client.ts +msgctxt "connect.serverPasswordRefused" +msgid "{host} refused the server password." +msgstr "" + #. aria-label of the numeric port input next to the server host field on the connect form. #: client/components/Windows/Connect.vue:156 msgctxt "connect.serverPort" @@ -1905,6 +1929,27 @@ msgctxt "form.serverAddress" msgid "Server address" msgstr "" +#. Label of the server password field (IRC PASS), separate from the services account password. +#: client/components/NetworkForm.vue +#: client/components/Windows/Connect.vue +msgctxt "form.serverPassword" +msgid "Server password" +msgstr "" + +#. Tooltip on the server password switch. 'username/network:password' is literal syntax the user types: keep it untranslated. +#: client/components/NetworkForm.vue +#: client/components/Windows/Connect.vue +msgctxt "form.serverPasswordHelp" +msgid "Only if the server or bouncer asks for one. For ZNC, enter username/network:password." +msgstr "" + +#. Switch on the connect form and in a network's settings that shows the server password field (sent as PASS, before the login). Bouncers such as ZNC ask for it. +#: client/components/NetworkForm.vue +#: client/components/Windows/Connect.vue +msgctxt "form.serverPasswordToggle" +msgid "This server needs a password" +msgstr "" + #. aria-label of the numeric port input next to the server host field on the network edit form. #: client/components/NetworkForm.vue:76 msgctxt "form.serverPort" diff --git a/client/locales/qqx.json b/client/locales/qqx.json index 472cebd15..d0d0b3d92 100644 --- a/client/locales/qqx.json +++ b/client/locales/qqx.json @@ -211,6 +211,7 @@ "connect.disconnected": "‫ḋïśċöññéċţéḋ.ḋïśċöññéċţéḋ.‬", "connect.enabledCaps": "‫éñàḃľéḋ ċàṗàḃïľïţïéś: {caps}éñàḃľéḋ ċàṗàḃïľïţïéś: {caps}‬", "connect.enterPassword": "‫éñţéŕ ţḣé ṗàśśẃöŕḋ ḟöŕ {account} ţö ċöññéċţ.éñţéŕ ţḣé ṗàśśẃöŕḋ ḟöŕ {account} ţö ċöññéċţ.‬", + "connect.enterServerPassword": "‫éñţéŕ ţḣé śéŕṽéŕ ṗàśśẃöŕḋ ḟöŕ {host}.éñţéŕ ţḣé śéŕṽéŕ ṗàśśẃöŕḋ ḟöŕ {host}.‬", "connect.guestNickRequired": "‫ċḣööśé à ñïċķ ţö ċöññéċţ àś à ġüéśţ.ċḣööśé à ñïċķ ţö ċöññéċţ àś à ġüéśţ.‬", "connect.guestSubmit": "‫ċöññéċţ àś ġüéśţċöññéċţ àś ġüéśţ‬", "connect.guestTitle": "‫ñö àċċöüñţ?ñö àċċöüñţ?‬", @@ -247,6 +248,9 @@ "connect.saslTrustedGaveUpHint": "‫ţḣïś ľöġïñ ḣàś ẃöŕķéḋ ḣéŕé ḃéḟöŕé, śö ïţ ïś ķéṗţ: ċöññéċţïñġ àġàïñ ţŕïéś ïţ öñċé ṁöŕé. ïḟ ţḣé ṗàśśẃöŕḋ ḣàś ċḣàñġéḋ, üṗḋàţé ïţ ïñ ţḣïś ñéţẃöŕķ'ś śéţţïñġś.ţḣïś ľöġïñ ḣàś ẃöŕķéḋ ḣéŕé ḃéḟöŕé, śö ïţ ïś ķéṗţ: ċöññéċţïñġ àġàïñ ţŕïéś ïţ öñċé ṁöŕé. ïḟ ţḣé ṗàśśẃöŕḋ ḣàś ċḣàñġéḋ, üṗḋàţé ïţ ïñ ţḣïś ñéţẃöŕķ'ś śéţţïñġś.‬", "connect.server": "‫śéŕṽéŕśéŕṽéŕ‬", "connect.serverAddress": "‫śéŕṽéŕ àḋḋŕéśśśéŕṽéŕ àḋḋŕéśś‬", + "connect.serverPasswordHint": "‫śéţ ţḣé śéŕṽéŕ ṗàśśẃöŕḋ ïñ ţḣïś ñéţẃöŕķ'ś śéţţïñġś. ḟöŕ źñċ ïţ ïś üśéŕñàṁé/ñéţẃöŕķ:ṗàśśẃöŕḋ.śéţ ţḣé śéŕṽéŕ ṗàśśẃöŕḋ ïñ ţḣïś ñéţẃöŕķ'ś śéţţïñġś. ḟöŕ źñċ ïţ ïś üśéŕñàṁé/ñéţẃöŕķ:ṗàśśẃöŕḋ.‬", + "connect.serverPasswordNeeded": "‫{host} ñééḋś à śéŕṽéŕ ṗàśśẃöŕḋ ḃéḟöŕé ýöü ċàñ ċöññéċţ.{host} ñééḋś à śéŕṽéŕ ṗàśśẃöŕḋ ḃéḟöŕé ýöü ċàñ ċöññéċţ.‬", + "connect.serverPasswordRefused": "‫{host} ŕéḟüśéḋ ţḣé śéŕṽéŕ ṗàśśẃöŕḋ.{host} ŕéḟüśéḋ ţḣé śéŕṽéŕ ṗàśśẃöŕḋ.‬", "connect.serverPort": "‫śéŕṽéŕ ṗöŕţśéŕṽéŕ ṗöŕţ‬", "connect.signInSubmit": "‫śïġñ ïñśïġñ ïñ‬", "connect.signInTitle": "‫śïġñ ïñśïġñ ïñ‬", @@ -355,6 +359,9 @@ "form.save": "‫śàṽé ñéţẃöŕķśàṽé ñéţẃöŕķ‬", "form.server": "‫śéŕṽéŕśéŕṽéŕ‬", "form.serverAddress": "‫śéŕṽéŕ àḋḋŕéśśśéŕṽéŕ àḋḋŕéśś‬", + "form.serverPassword": "‫śéŕṽéŕ ṗàśśẃöŕḋśéŕṽéŕ ṗàśśẃöŕḋ‬", + "form.serverPasswordHelp": "‫öñľý ïḟ ţḣé śéŕṽéŕ öŕ ḃöüñċéŕ àśķś ḟöŕ öñé. ḟöŕ źñċ, éñţéŕ üśéŕñàṁé/ñéţẃöŕķ:ṗàśśẃöŕḋ.öñľý ïḟ ţḣé śéŕṽéŕ öŕ ḃöüñċéŕ àśķś ḟöŕ öñé. ḟöŕ źñċ, éñţéŕ üśéŕñàṁé/ñéţẃöŕķ:ṗàśśẃöŕḋ.‬", + "form.serverPasswordToggle": "‫ţḣïś śéŕṽéŕ ñééḋś à ṗàśśẃöŕḋţḣïś śéŕṽéŕ ñééḋś à ṗàśśẃöŕḋ‬", "form.serverPort": "‫śéŕṽéŕ ṗöŕţśéŕṽéŕ ṗöŕţ‬", "form.servicesAccount": "‫àċċöüñţ śéŕṽïċéàċċöüñţ śéŕṽïċé‬", "form.servicesAccountPlaceholder": "‫ţḣé ḃöţ ţḣàţ ḣöľḋś àċċöüñţśţḣé ḃöţ ţḣàţ ḣöľḋś àċċöüñţś‬", diff --git a/client/service-worker.js b/client/service-worker.js index e9e2d3e5e..e49a9c738 100644 --- a/client/service-worker.js +++ b/client/service-worker.js @@ -856,6 +856,13 @@ function swIrcOpen(net, pre, onReady, optional = []) { ws.onopen = () => { ws.send("CAP LS 302"); + + // A server password the network remembers (the stash carries it + // under the same rule as the SASL password); the page refuses CR/LF/NUL. + if (net.serverPassword) { + ws.send("PASS :" + net.serverPassword); + } + ws.send("NICK " + nick); ws.send("USER seance-sw 0 * :seance service worker"); }; diff --git a/docs/resources/client-features.md b/docs/resources/client-features.md index bff9bc475..e760b5259 100644 --- a/docs/resources/client-features.md +++ b/docs/resources/client-features.md @@ -88,6 +88,10 @@ How the parts of the client behave and why, moved out of `CLAUDE.md` on 2026-11- **File uploads** — `client/js/upload.ts` (`Uploader`, Vue-free behind the `UploadHost` interface, `test/irc/upload.ts`) posts straight to the deploy's `uploads` endpoint from `config.json` — unless the network advertises its own host (`draft/FILEHOST` ISUPPORT → `IrcClient.filehostUrl()`, kept on `serverOptions.FILEHOST`): then `UploadHost.filehost()` wins and `uploadFilehost` does a `TOKEN GENERATE FILEHOST #chan` (`irc/authtoken.ts` `TokenRequests`, answered by `handlers/token.ts` / `FAIL TOKEN` in `standard-replies.ts`) followed by a raw `POST` with `Authorization: Bearer` expecting `201` + `Location`; `test/irc/authtoken.ts`. `upload.ts` must stay free of `irc/manager` (it pulls in `document`), so `ChatInput.vue` hands `clientForNetwork` to `upload.mounted`. Every way in (drop, paste, the paperclip's `#upload-input`) lands in `triggerUpload`, which checks size/type, then **asks before sending**: `UploadHost.confirm(files)` → the `upload:confirm` eventbus request that `UploadPreview.vue` (mounted in `App.vue`) answers with the files the user kept. Progress is real (`XMLHttpRequest` via `xhrFetch`, wrapped into a `Response`; `UploadHost.progress` → `store.state.uploadProgress` → the `.upload-bar` strip above the input in `ChatInput.vue`, with cancel = `Uploader.abort()`, which is silent) — **but an upload listener forces a CORS preflight**, which curl-oriented uploaders (litterbox: `OPTIONS` → 405) refuse, so `xhrSend` retries a request that died before any progress as a plain POST and `Uploader.progressBlocked` remembers the endpoint for the page (indeterminate bar); a preset or deploy that knows better sets `uploads.progress: false` (litterbox does) and no preflight is ever tried. Never add headers or listeners to the upload request without checking the endpoint answers `OPTIONS`. The "remove metadata" setting re-encodes through a canvas, which keeps one frame, so `metadataPlan()` exempts GIF/SVG and anything `helpers/animatedImage.ts` sniffs as animated (WebP `VP8X` flag, PNG `acTL`, AVIF `avis`; `test/helpers/animatedImage.ts`); a re-encoded file takes the blob's real type and a matching extension (`fileNameForType`). See `docs/resources/branding.md` § Uploads. +## Server password + +**Server password** — an optional `serverPassword` on a saved network (`ConnectOptions`, `irc/types.ts`), sent as `PASS :` after `CAP LS 302` and before `NICK`/`USER` (`IrcClient.onOpen`); nothing is sent when it is empty. It is for bouncers that predate client SASL: ZNC 1.9 and older answer a client with no PASS with `464` and wait (ZNC's form is `user/network:password`), and ZNC 1.10 takes SASL only with `saslplainauth` loaded. It is independent of SASL; both go out when both are set. It follows the SASL password's storage rules: on disk only with `rememberPassword` (`saved-networks.ts` `forStorage`), with `hasServerPassword` kept so `needsPassword()` sends a link or an autoconnect back to the connect form; stripped from a settings backup unless passwords are kept; never from a URL (`linkTarget.ts`); carried to the push worker's stash under the same rule (`service-worker.js` sends it before `NICK`). In the edit form the switch off clears it, and on with the field blank keeps the stored (or in-memory) one (`fromForm`, `toConnectOptions` → `undefined`, `IrcClient.applySettings`). A `464` before `001` stops the connection instead of retrying (`IrcClient.serverPasswordFailed`, `handlers/numerics.ts`): the lobby says whether a password was missing or refused, and the entry stops connecting by itself; after `001` a 464 (OPER) is an ordinary error. Tests: `test/irc/server-password.ts`, `test/irc/saved-networks.ts`, `test/helpers/settingsBackup.ts`. + ## Settings backup **Settings backup** — Settings → General → Backup and restore exports a gzipped JSON envelope (`.seance-settings`, `CompressionStream`, no dependency) of the preference keys in localStorage — `settings` (the store's live object, since the key is only written once a setting changes), saved networks (passwords stripped unless the box is ticked), sort orders, mutes, ignore lists, trusted media, recent reactions, command aliases, per-channel translation state (`thelounge.translate`), oper notice routing (`thelounge.oper`), do not disturb (`thelounge.dnd`), Lua scripts (`thelounge.scripts`, restored switched off) — and restoring one replaces exactly those keys and reloads (the file records the platform it was made on, and `matchSystemTextSize`/`matchSystemAppearance`, whose defaults differ between the shells and the web, take the restoring platform's default when they held only the source's: `helpers/platformDefaults.ts`); the envelope's `version` is `2` and `KEY_SINCE` says which key arrived when, so an older file leaves a key it could never have carried (`thelounge.translate`) alone rather than clearing it, and a newer file is refused (`thelounge.oper` and `thelounge.scripts` came upstream without a bump and count from 1); `thelounge.sts`, `thelounge.push*`, `thelounge.mentions`, `thelounge.querylog.*` and `thelounge.state.*` are never carried or written. `client/js/helpers/settingsBackup.ts` is Vue-free (`test/helpers/settingsBackup.ts`); browser check `tools/scenarios/settings-backup.mjs`. See `docs/resources/settings-backup.md`. diff --git a/test/helpers/settingsBackup.ts b/test/helpers/settingsBackup.ts index 83bf7de33..8535c8da4 100644 --- a/test/helpers/settingsBackup.ts +++ b/test/helpers/settingsBackup.ts @@ -142,6 +142,21 @@ describe("settings backup (helpers/settingsBackup.ts)", function () { expect(keptNets[0].saslPassword).to.equal("hunter2"); }); + it("strips a server password too, and counts it as a password", function () { + store.set( + "thelounge.networks", + JSON.stringify([{uuid: "z", host: "znc.example", serverPassword: "bob/rig:pw"}]) + ); + + const stripped = collectBackup(); + const nets = stripped.entries["thelounge.networks"] as Record[]; + expect(nets[0]).to.not.have.property("serverPassword"); + expect(hasPasswords(stripped)).to.equal(false); + + const kept = collectBackup({includePasswords: true}); + expect(hasPasswords(kept)).to.equal(true); + }); + it("takes the live settings over the stored ones", function () { seed(); const backup = collectBackup({settings: {theme: "day", coloredNicks: true}}); diff --git a/test/irc/saved-networks.ts b/test/irc/saved-networks.ts index 3dccd97d7..1c6f808d2 100644 --- a/test/irc/saved-networks.ts +++ b/test/irc/saved-networks.ts @@ -421,6 +421,7 @@ describe("saved-networks", function () { "sasl", "saslAccount", "saslPassword", + "serverPassword", "tls", ]); expect(saved.displayName(entry())).to.equal("irc.example.org"); @@ -534,4 +535,67 @@ describe("saved-networks", function () { expect(saved.get(net.uuid)?.presenceMode).to.equal(undefined); expect(saved.presenceModeOf(saved.get(net.uuid))).to.equal("global"); }); + describe("server password", function () { + it("is kept on disk only with rememberPassword, and marked when it is not", function () { + saved.save(entry({serverPassword: "bob/rig:pw", rememberPassword: true})); + expect(saved.get(entry().uuid)?.serverPassword).to.equal("bob/rig:pw"); + expect(saved.get(entry().uuid)?.hasServerPassword).to.equal(true); + expect(saved.needsPassword(saved.get(entry().uuid) as SavedNetwork)).to.equal(false); + + saved.save(entry({serverPassword: "bob/rig:pw", rememberPassword: false})); + const raw = backend.get(saved.STORAGE_KEY) as string; + expect(raw).to.not.include("bob/rig:pw"); + const stored = saved.get(entry().uuid) as SavedNetwork; + expect(stored).to.not.have.property("serverPassword"); + expect(stored.hasServerPassword).to.equal(true); + expect(saved.needsPassword(stored)).to.equal(true); + }); + + it("is absent from an entry that never had one", function () { + const net = saved.normalize(entry()) as SavedNetwork; + expect(net).to.not.have.property("serverPassword"); + expect(net).to.not.have.property("hasServerPassword"); + expect(saved.needsPassword(net)).to.equal(false); + expect(saved.toConnectOptions(net).serverPassword).to.equal(""); + }); + + it("drops CR, LF and NUL, which would end the PASS line", function () { + const net = saved.normalize(entry({serverPassword: "a\r\nb\0c"})) as SavedNetwork; + expect(net.serverPassword).to.equal("abc"); + }); + + it("is undefined in the connect options when configured but not kept", function () { + const net = saved.normalize(entry({hasServerPassword: true})) as SavedNetwork; + expect(saved.toConnectOptions(net).serverPassword).to.equal(undefined); + expect( + saved.toConnectOptions( + saved.normalize(entry({serverPassword: "x"})) as SavedNetwork + ).serverPassword + ).to.equal("x"); + }); + + it("edit form: the switch off clears it, on and blank keeps it", function () { + const existing = saved.normalize( + entry({serverPassword: "old", rememberPassword: true}) + ) as SavedNetwork; + + const kept = saved.fromForm( + {...existing, hasServerPassword: true, serverPassword: ""}, + existing + ); + expect(kept.serverPassword).to.equal("old"); + + const changed = saved.fromForm( + {...existing, hasServerPassword: true, serverPassword: "new"}, + existing + ); + expect(changed.serverPassword).to.equal("new"); + + const {hasServerPassword: _off, ...withoutSwitch} = existing; + void _off; + const cleared = saved.fromForm({...withoutSwitch, serverPassword: "old"}, existing); + expect(cleared).to.not.have.property("serverPassword"); + expect(cleared).to.not.have.property("hasServerPassword"); + }); + }); }); diff --git a/test/irc/server-password.ts b/test/irc/server-password.ts new file mode 100644 index 000000000..d80c06215 --- /dev/null +++ b/test/irc/server-password.ts @@ -0,0 +1,84 @@ +import {expect} from "chai"; +import sinon from "sinon"; +import {register, setup} from "./support"; + +describe("server password (PASS)", function () { + it("goes out after CAP LS and before NICK and USER", function () { + const h = setup({serverPassword: "bob/rig:pass word"}); + h.client.connect(); + h.transport.open(); + + expect(h.transport.sent).to.deep.equal([ + "CAP LS 302", + "PASS :bob/rig:pass word", + "NICK alice", + "USER alice 0 * :alice", + ]); + }); + + it("is not sent when empty", function () { + const h = setup({serverPassword: ""}); + h.client.connect(); + h.transport.open(); + + expect(h.transport.sent.some((l) => l.startsWith("PASS"))).to.equal(false); + }); + + it("464 during registration stops the connection and says why", function () { + const rejected = sinon.spy(); + const h = setup({onServerPasswordRejected: rejected}); + h.client.connect(); + h.transport.open(); + h.sent(); + h.transport.line(":irc.znc.in 464 alice :Password required"); + + expect(h.sent()).to.deep.equal(["QUIT :Server password required"]); + expect(rejected.calledOnce).to.equal(true); + expect(h.payloads("network:aborted")).to.have.length(1); + const texts = h.messages().map((m) => m.text ?? ""); + expect(texts.some((t) => /needs a server password/.test(t))).to.equal(true); + expect(h.transport.state).to.equal("closed"); + }); + + it("464 to a password that was sent says it was refused", function () { + const h = setup({serverPassword: "wrong"}); + h.client.connect(); + h.transport.open(); + h.transport.line(":irc.znc.in 464 alice :Invalid Password"); + + const texts = h.messages().map((m) => m.text ?? ""); + expect(texts.some((t) => /refused the server password/.test(t))).to.equal(true); + expect(h.transport.sent).to.include("QUIT :Server password refused"); + }); + + it("464 after 001 is an ordinary error (OPER with the wrong password)", function () { + const rejected = sinon.spy(); + const h = setup({onServerPasswordRejected: rejected}); + register(h); + h.transport.line(":irc.test 464 alice :Password incorrect"); + + expect(rejected.called).to.equal(false); + expect(h.payloads("network:aborted")).to.have.length(0); + expect(h.sent().some((l) => l.startsWith("QUIT"))).to.equal(false); + expect(h.lastMessage().error).to.equal("password_mismatch"); + }); + + it("an edit that leaves it undefined keeps the one in memory", function () { + const h = setup({serverPassword: "kept"}); + h.client.applySettings({ + host: "irc.test", + port: 8443, + tls: true, + nick: "alice", + join: "#seance", + sasl: "", + saslAccount: "", + saslPassword: "", + serverPassword: undefined, + }); + h.client.connect(); + h.transport.open(); + + expect(h.transport.sent).to.include("PASS :kept"); + }); +}); From 2b6032d7f636020edd96758440a776890b8266b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 18:32:04 +0000 Subject: [PATCH 2/2] Server password: drop the push worker's copy (YAGNI) The worker logs in only for push (draft/webpush), and none of the servers that want a server password offers a push Seance uses: ZNC has none, and soju's is soju.im/webpush. Docs say where to add it when one does. --- client/js/webpush.ts | 3 +-- client/service-worker.js | 7 ------- docs/resources/client-features.md | 2 +- 3 files changed, 2 insertions(+), 10 deletions(-) diff --git a/client/js/webpush.ts b/client/js/webpush.ts index 7c21c4a86..39c2e6bbe 100644 --- a/client/js/webpush.ts +++ b/client/js/webpush.ts @@ -1552,7 +1552,7 @@ if (typeof indexedDB !== "undefined") { * that announced a key or holds an entry, its uuid, connection details and * the key its subscription was made against — that is how a worker * deep-links a notification, relays a reply to this page, and renews its - * subscription. The SASL password (and a server password) rides along ONLY for networks that + * subscription. The SASL password rides along ONLY for networks that * remember it (it never hits IndexedDB otherwise); that is what lets a * worker send a reply, mute, or renew over its own connection when no page * is alive. Rewritten on every connect so a password remembered later, or @@ -1572,7 +1572,6 @@ async function writeStash(): Promise { tls: net.tls, saslAccount: net.saslAccount, saslPassword: net.rememberPassword === true ? net.saslPassword : undefined, - serverPassword: net.rememberPassword === true ? net.serverPassword : undefined, vapid: subs[net.uuid]?.vapid ?? announcedKey(net.uuid) ?? null, })); diff --git a/client/service-worker.js b/client/service-worker.js index e49a9c738..e9e2d3e5e 100644 --- a/client/service-worker.js +++ b/client/service-worker.js @@ -856,13 +856,6 @@ function swIrcOpen(net, pre, onReady, optional = []) { ws.onopen = () => { ws.send("CAP LS 302"); - - // A server password the network remembers (the stash carries it - // under the same rule as the SASL password); the page refuses CR/LF/NUL. - if (net.serverPassword) { - ws.send("PASS :" + net.serverPassword); - } - ws.send("NICK " + nick); ws.send("USER seance-sw 0 * :seance service worker"); }; diff --git a/docs/resources/client-features.md b/docs/resources/client-features.md index e760b5259..ee7fec871 100644 --- a/docs/resources/client-features.md +++ b/docs/resources/client-features.md @@ -90,7 +90,7 @@ How the parts of the client behave and why, moved out of `CLAUDE.md` on 2026-11- ## Server password -**Server password** — an optional `serverPassword` on a saved network (`ConnectOptions`, `irc/types.ts`), sent as `PASS :` after `CAP LS 302` and before `NICK`/`USER` (`IrcClient.onOpen`); nothing is sent when it is empty. It is for bouncers that predate client SASL: ZNC 1.9 and older answer a client with no PASS with `464` and wait (ZNC's form is `user/network:password`), and ZNC 1.10 takes SASL only with `saslplainauth` loaded. It is independent of SASL; both go out when both are set. It follows the SASL password's storage rules: on disk only with `rememberPassword` (`saved-networks.ts` `forStorage`), with `hasServerPassword` kept so `needsPassword()` sends a link or an autoconnect back to the connect form; stripped from a settings backup unless passwords are kept; never from a URL (`linkTarget.ts`); carried to the push worker's stash under the same rule (`service-worker.js` sends it before `NICK`). In the edit form the switch off clears it, and on with the field blank keeps the stored (or in-memory) one (`fromForm`, `toConnectOptions` → `undefined`, `IrcClient.applySettings`). A `464` before `001` stops the connection instead of retrying (`IrcClient.serverPasswordFailed`, `handlers/numerics.ts`): the lobby says whether a password was missing or refused, and the entry stops connecting by itself; after `001` a 464 (OPER) is an ordinary error. Tests: `test/irc/server-password.ts`, `test/irc/saved-networks.ts`, `test/helpers/settingsBackup.ts`. +**Server password** — an optional `serverPassword` on a saved network (`ConnectOptions`, `irc/types.ts`), sent as `PASS :` after `CAP LS 302` and before `NICK`/`USER` (`IrcClient.onOpen`); nothing is sent when it is empty. It is for bouncers that predate client SASL: ZNC 1.9 and older answer a client with no PASS with `464` and wait (ZNC's form is `user/network:password`), and ZNC 1.10 takes SASL only with `saslplainauth` loaded. It is independent of SASL; both go out when both are set. It follows the SASL password's storage rules: on disk only with `rememberPassword` (`saved-networks.ts` `forStorage`), with `hasServerPassword` kept so `needsPassword()` sends a link or an autoconnect back to the connect form; stripped from a settings backup unless passwords are kept; never from a URL (`linkTarget.ts`). The push worker's own connection does not send it: none of the servers that need one offers a push Seance uses (`draft/webpush`); add it to the stash (`webpush.ts` `writeStash`) and `swIrcOpen` when one does. In the edit form the switch off clears it, and on with the field blank keeps the stored (or in-memory) one (`fromForm`, `toConnectOptions` → `undefined`, `IrcClient.applySettings`). A `464` before `001` stops the connection instead of retrying (`IrcClient.serverPasswordFailed`, `handlers/numerics.ts`): the lobby says whether a password was missing or refused, and the entry stops connecting by itself; after `001` a 464 (OPER) is an ordinary error. Tests: `test/irc/server-password.ts`, `test/irc/saved-networks.ts`, `test/helpers/settingsBackup.ts`. ## Settings backup