Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 43 additions & 1 deletion client/components/NetworkForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,20 @@
/>
</div>
</div>
<div v-if="canUseTcp" class="connect-row">
<label for="connect:transport">{{ t("connect.connection") }}</label>
<div class="input-wrap">
<select
id="connect:transport"
v-model="connection"
class="input"
name="transport"
>
<option value="websocket">{{ t("connect.connection.websocket") }}</option>
<option value="tcp">{{ t("connect.connection.tcp") }}</option>
</select>
</div>
</div>
<div class="connect-row">
<label></label>
<div class="input-wrap">
Expand Down Expand Up @@ -486,6 +500,7 @@ import {
watch,
} from "vue";
import {
defaultPort,
displayName,
parseCommands,
presenceModeOf,
Expand All @@ -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";
Expand Down Expand Up @@ -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;

Expand All @@ -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,
Expand Down Expand Up @@ -887,6 +927,8 @@ export default defineComponent({
presenceHint,
pushInfo,
desktopShell: isDesktopShell(),
canUseTcp,
connection,
renewPush,
onNotifyToggle,
SERVICES_PROFILES,
Expand Down
40 changes: 38 additions & 2 deletions client/components/Windows/Connect.vue
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,20 @@
/>
</div>
</div>
<div v-if="!hostLocked && canUseTcp" class="connect-row">
<label for="connect:transport">{{ t("connect.connection") }}</label>
<div class="input-wrap">
<select
id="connect:transport"
v-model="connection"
class="input"
name="transport"
>
<option value="websocket">{{ t("connect.connection.websocket") }}</option>
<option value="tcp">{{ t("connect.connection.tcp") }}</option>
</select>
</div>
</div>
<div v-if="!hostLocked" class="connect-row">
<label></label>
<div class="input-wrap">
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
}
);
Expand Down Expand Up @@ -750,6 +784,8 @@ export default defineComponent({
form,
store,
t,
canUseTcp,
connection,
ariaLabel,
serverAddressLabel,
serverPortLabel,
Expand Down
6 changes: 6 additions & 0 deletions client/js/i18n/call-sites.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,9 @@ export const STATIC_CALL_SITES = new Set<string>([
"connect.connectingAs",
"connect.connectingTo",
"connect.connectingToAttempt",
"connect.connection",
"connect.connection.tcp",
"connect.connection.websocket",
"connect.disconnected",
"connect.enabledCaps",
"connect.enterPassword",
Expand Down Expand Up @@ -212,6 +215,7 @@ export const STATIC_CALL_SITES = new Set<string>([
"connect.stsReconnect",
"connect.stsUpgrade",
"connect.submit",
"connect.tcpNeedsApp",
"connect.title",
"connect.useTls",
"connect.user",
Expand All @@ -238,6 +242,8 @@ export const STATIC_CALL_SITES = new Set<string>([
"disconnect.hint.plainCauses",
"disconnect.hint.registration",
"disconnect.hint.sessionConflict",
"disconnect.hint.tcpPlainCauses",
"disconnect.hint.tcpTlsCauses",
"disconnect.hint.wssCauses",
"disconnect.registeringClosed",
"disconnect.registeringClosedNotReconnecting",
Expand Down
69 changes: 61 additions & 8 deletions client/js/irc/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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));

Expand Down Expand Up @@ -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,
};
}

Expand All @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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). */
Expand Down Expand Up @@ -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;
}
Expand All @@ -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));
}
Expand Down Expand Up @@ -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;
}

Expand All @@ -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};
}

Expand Down Expand Up @@ -1628,7 +1666,7 @@ export class IrcClient {
this.authtoken.clear();

if (this.options.tls) {
refreshPolicy(this.options.host);
refreshPolicy(stsHost(this.options));
}

resetBatches(this);
Expand Down Expand Up @@ -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);
Expand All @@ -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 {
Expand Down
10 changes: 10 additions & 0 deletions client/js/irc/disconnect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:") {
Expand Down
Loading
Loading