Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
# local dev TLS certificates (generated by scripts/setup-dev-certs.sh)
/certs

# dependencies
/node_modules
/.pnp
Expand Down
18 changes: 14 additions & 4 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,14 +48,24 @@ Now every time you commit the lint and format commands will run automatically.

### Testing Environment

For testing features locally, we provide a complete IRC testing stack with Docker Compose:
For testing features locally, we provide a complete IRC testing stack with Docker Compose (ergo IRC server + 3 bots over TLS).

#### Start Testing Stack (IRC Server + 3 Bots)
#### First-time setup (once per machine)

Install [mkcert](https://github.com/FiloSottile/mkcert), then:
```bash
npm run gen-certs
```
This installs the local CA into your OS trust store and writes a `.env` file used by compose.

#### Start the stack
```bash
# in one terminal
npm run dev
# in another terminal
docker-compose --profile testing up -d
npm run run-dev-stack
```

Now you can connect to `ws://localhost:8097` with any nickname and check the `#test` channel.
To stop: `npm run stop-dev-stack`

Connect with `wss://localhost:8097` (browser/WebView) or `ircs://localhost:6697` (Tauri native TCP) and join `#test`.
29 changes: 27 additions & 2 deletions compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,43 @@ services:
timeout: 10s
retries: 3

gen-certs:
image: alpine:3.20
volumes:
- ${MKCERT_CAROOT:?Run ./scripts/setup-dev-certs.sh first}:/caroot:ro
- ./certs:/certs
command:
- sh
- -c
- |
openssl x509 -noout -in /certs/server.pem 2>/dev/null && exit 0
apk add --no-cache openssl -q
openssl genrsa -out /certs/server-key.pem 2048
openssl req -new -key /certs/server-key.pem -out /tmp/csr.pem -subj "/CN=localhost"
printf '[SAN]\nsubjectAltName=DNS:localhost,IP:127.0.0.1,IP:::1\n' > /tmp/ext.cnf
openssl x509 -req -in /tmp/csr.pem \
-CA /caroot/rootCA.pem -CAkey /caroot/rootCA-key.pem -set_serial 1 \
-out /certs/server.pem -days 825 -sha256 -extfile /tmp/ext.cnf -extensions SAN
profiles:
Comment on lines +16 to +33
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

Alpine Linux 3.20 openssl binary included by default

💡 Result:

No—Alpine Linux 3.20 does not include the openssl binary by default in the minimal root filesystem used for containers (and thus the official alpine:3.20 Docker image). The default mini-rootfs package set is busybox alpine-baselayout alpine-keys alpine-release apk-tools musl-utils (no openssl) [1].

If you need the openssl command, install it explicitly:

apk add openssl

The openssl package is available in Alpine 3.20’s main repo (package name openssl) [2].

Sources: [1], [2]


Early-exit guard can miss existing keys and always regenerates on fresh images.

The guard tries to use openssl x509 before OpenSSL is installed, so it always fails silently on fresh Alpine containers and regenerates unnecessarily. Additionally, it only checks server.pem, so a missing server-key.pem on container reuse could go undetected. Check both files exist before regenerating instead.

🔧 Suggested guard that checks both files before regenerating
-        openssl x509 -noout -in /certs/server.pem 2>/dev/null && exit 0
+        if [ -s /certs/server.pem ] && [ -s /certs/server-key.pem ]; then
+          exit 0
+        fi
         apk add --no-cache openssl -q
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@compose.yaml` around lines 16 - 33, The current guard in the gen-certs
service uses `openssl x509` before OpenSSL is installed and only checks
server.pem, causing unnecessary regeneration; change the command start to first
check for the presence of both /certs/server.pem and /certs/server-key.pem
(e.g., test that both files exist) and exit 0 if they do, and only then proceed
to apk add openssl and the CSR/certificate generation; update the gen-certs
command block to perform the existence check for both files before attempting
OpenSSL operations.

- testing

# TODO - Add IRC daemon + backend etc
ircd:
init: true
# TODO: Use our unrealircd custom image instead
image: ghcr.io/ergochat/ergo:master
container_name: ergo
ports:
- '8097:8097'
- '6667:6667'
- '6667:6667' # plain IRC (bots)
- '6697:6697' # IRC over TLS — ircs://localhost:6697 (Tauri native TCP)
- '8097:8097' # WebSocket over TLS — wss://localhost:8097 (browser / WebView)
restart: unless-stopped
depends_on:
gen-certs:
condition: service_completed_successfully
volumes:
- ./docker/ergo.yaml:/ircd/ircd.yaml
- ./certs:/ircd/certs:ro
profiles:
- testing

Expand Down
55 changes: 15 additions & 40 deletions docker/ergo.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -34,49 +34,24 @@ server:

# addresses to listen on
listeners:
# The standard plaintext port for IRC is 6667. Allowing plaintext over the
# public Internet poses serious security and privacy issues. Accordingly,
# we recommend using plaintext only on local (loopback) interfaces:
# "127.0.0.1:6667": # (loopback ipv4, localhost-only)
# "[::1]:6667": # (loopback ipv6, localhost-only)
# If you need to serve plaintext on public interfaces, comment out the above
# two lines and uncomment the line below (which listens on all interfaces):
# Plain IRC — used by Docker-internal bot connections only
":6667":
# Alternately, if you have a TLS certificate issued by a recognized CA,
# you can configure port 6667 as an STS-only listener that only serves
# "redirects" to the TLS port, but doesn't allow chat. See the manual
# for details.

# The standard SSL/TLS port for IRC is 6697. This will listen on all interfaces:
# ":6697":
# # this is a standard TLS configuration with a single certificate;
# # see the manual for instructions on how to configure SNI
# tls:
# cert: fullchain.pem
# key: privkey.pem
# # 'proxy' should typically be false. It's for cloud load balancers that
# # always send a PROXY protocol header ahead of the connection. See the
# # manual ("Reverse proxies") for more details.
# proxy: false
# # set the minimum TLS version:
# min-tls-version: 1.2

# Example of a Unix domain socket for proxying:
# "/tmp/ergo_sock":

# Example of a Tor listener: any connection that comes in on this listener will
# be considered a Tor connection. It is strongly recommended that this listener
# *not* be on a public interface --- it should be on 127.0.0.0/8 or unix domain:
# "/hidden_service_sockets/ergo_tor_sock":
# tor: true

# Example of a WebSocket listener:

# IRC over TLS (ircs://) — used by Tauri native TCP connections
# Requires certs generated by: scripts/setup-dev-certs.sh
":6697":
tls:
cert: /ircd/certs/server.pem
key: /ircd/certs/server-key.pem
min-tls-version: 1.2

# WebSocket over TLS (wss://) — used by browser / Tauri WebView
":8097":
websocket: true
# This is non TLS if disabled
# tls:
# cert: fullchain.pem
# key: privkey.pem
tls:
cert: /ircd/certs/server.pem
key: /ircd/certs/server-key.pem
min-tls-version: 1.2

# sets the permissions for Unix listen sockets. on a typical Linux system,
# the default is 0775 or 0755, which prevents other users/groups from connecting
Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:debug": "vitest --inspect-brk --browser --no-file-parallelism",
"gen-certs": "bash scripts/setup-dev-certs.sh",
"run-dev-stack": "docker compose --profile testing up --remove-orphans",
"stop-dev-stack": "docker compose --profile testing down --remove-orphans",
"tauri": "tauri"
},
"dependencies": {
Expand Down
12 changes: 12 additions & 0 deletions scripts/setup-dev-certs.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
#!/usr/bin/env bash
set -euo pipefail

# One-time per machine: installs mkcert CA into the OS trust store and writes
# MKCERT_CAROOT to .env so compose can mount it for cert generation.
# Requires mkcert: brew install mkcert | choco install mkcert

command -v mkcert >/dev/null || { echo "mkcert not found"; exit 1; }

mkcert -install
echo "MKCERT_CAROOT=$(mkcert -CAROOT)" > "$(dirname "$0")/../.env"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

.env overwrite destroys existing developer environment variables.

The > redirect replaces the entire .env file. Any pre-existing variables (API keys, feature flags, etc.) in .env are silently deleted. Use a targeted sed-in-place update or at minimum >> + a deduplication guard.

🛡️ Proposed fix — upsert only the MKCERT_CAROOT line
-echo "MKCERT_CAROOT=$(mkcert -CAROOT)" > "$(dirname "$0")/../.env"
+ENV_FILE="$(dirname "$0")/../.env"
+CAROOT_LINE="MKCERT_CAROOT=$(mkcert -CAROOT)"
+if [ -f "$ENV_FILE" ] && grep -q '^MKCERT_CAROOT=' "$ENV_FILE"; then
+  sed -i "s|^MKCERT_CAROOT=.*|${CAROOT_LINE}|" "$ENV_FILE"
+else
+  echo "$CAROOT_LINE" >> "$ENV_FILE"
+fi
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
echo "MKCERT_CAROOT=$(mkcert -CAROOT)" > "$(dirname "$0")/../.env"
ENV_FILE="$(dirname "$0")/../.env"
CAROOT_LINE="MKCERT_CAROOT=$(mkcert -CAROOT)"
if [ -f "$ENV_FILE" ] && grep -q '^MKCERT_CAROOT=' "$ENV_FILE"; then
sed -i "s|^MKCERT_CAROOT=.*|${CAROOT_LINE}|" "$ENV_FILE"
else
echo "$CAROOT_LINE" >> "$ENV_FILE"
fi
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/setup-dev-certs.sh` at line 11, The current echo command in
scripts/setup-dev-certs.sh overwrites the entire .env; change it to an "upsert"
that updates or adds only the MKCERT_CAROOT variable instead of truncating the
file. Replace the single-line echo that writes MKCERT_CAROOT with logic that
detects and replaces an existing MKCERT_CAROOT entry (e.g., using sed -i to
substitute the line for MKCERT_CAROOT) and, if not present, appends the
MKCERT_CAROOT=... line; ensure the code references the same mkcert -CAROOT
invocation and writes to "$(dirname "$0")/../.env" so existing environment
variables are preserved.

echo "Done. Run: docker compose --profile testing up -d"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Completion message doesn't match CONTRIBUTING.md.

The script prints docker compose --profile testing up -d, but CONTRIBUTING.md tells developers to run npm run run-dev-stack. Align to avoid confusion.

📝 Proposed fix
-echo "Done. Run: docker compose --profile testing up -d"
+echo "Done. Run: npm run run-dev-stack"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
echo "Done. Run: docker compose --profile testing up -d"
echo "Done. Run: npm run run-dev-stack"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/setup-dev-certs.sh` at line 12, Update the completion echo in the
setup-dev-certs.sh script so the user instruction matches CONTRIBUTING.md:
replace the current echo message that reads "Done. Run: docker compose --profile
testing up -d" with one that says "Done. Run: npm run run-dev-stack" (or
otherwise mirrors the exact phrasing in CONTRIBUTING.md) to avoid confusing
developers; locate and edit the echo statement in scripts/setup-dev-certs.sh.

2 changes: 1 addition & 1 deletion src/components/ui/AddServerModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ export const AddServerModal: React.FC = () => {
const port = Number.parseInt(serverPort, 10);
// Remove any existing protocol prefix from serverHost
const cleanHost = serverHost.replace(
/^(https?|wss?|ircs?|irc):\/\//,
/^(https?|wss|ircs?|irc):\/\//,
"",
);

Expand Down
56 changes: 2 additions & 54 deletions src/components/ui/LinkSecurityWarningModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ const SingleWarningModal: React.FC<WarningModalProps> = ({
const [timerExpired, setTimerExpired] = useState(false);
const [isScrolledToBottom, setIsScrolledToBottom] = useState(false);
const [hasScrollbar, setHasScrollbar] = useState(false);
const [skipLocalhostWarning, setSkipLocalhostWarning] = useState(false);
const [skipLinkSecurityWarning, setSkipLinkSecurityWarning] = useState(false);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const timerStartedRef = useRef(false);
Expand Down Expand Up @@ -82,8 +81,6 @@ const SingleWarningModal: React.FC<WarningModalProps> = ({
const serverName =
server.name || serverConfig?.name || server.host || "Unknown Server";
const securityLevel = server.linkSecurity || 0;
const isLocalhost =
server.host === "localhost" || server.host === "127.0.0.1";
const isLinkSecurityWarning =
server.linkSecurity !== undefined && server.linkSecurity < 2;

Expand Down Expand Up @@ -111,8 +108,6 @@ const SingleWarningModal: React.FC<WarningModalProps> = ({
if (s.id === serverId) {
return {
...s,
...(skipLocalhostWarning &&
isLocalhost && { skipLocalhostWarning: true }),
...(skipLinkSecurityWarning &&
isLinkSecurityWarning && { skipLinkSecurityWarning: true }),
};
Expand Down Expand Up @@ -224,38 +219,6 @@ const SingleWarningModal: React.FC<WarningModalProps> = ({

{/* Security Issues List */}
<div className="space-y-3">
{isLocalhost && (
<div className="bg-yellow-500 bg-opacity-10 border border-yellow-500 border-opacity-30 rounded p-3">
<div className="flex items-start gap-2">
<FaExclamationTriangle className="text-yellow-500 text-sm mt-0.5 flex-shrink-0" />
<div className="space-y-1">
<p className="text-sm font-semibold text-yellow-200">
Unencrypted Connection (Localhost)
</p>
<p className="text-xs text-yellow-100">
This connection uses an unencrypted WebSocket (
<code className="bg-black bg-opacity-30 px-1 rounded">
ws://
</code>
) instead of secure WebSocket (
<code className="bg-black bg-opacity-30 px-1 rounded">
wss://
</code>
). While localhost connections are typically safe for
development, any communication could be visible to
others on your local network or to malicious software
running on your computer.
</p>
<p className="text-xs text-yellow-100">
<strong>Risk:</strong> Messages, passwords, and
authentication tokens could be intercepted by network
sniffers or malware on your local machine.
</p>
</div>
</div>
</div>
)}

{isLinkSecurityWarning && (
<div className="bg-orange-500 bg-opacity-10 border border-orange-500 border-opacity-30 rounded p-3">
<div className="flex items-start gap-2">
Expand Down Expand Up @@ -302,7 +265,7 @@ const SingleWarningModal: React.FC<WarningModalProps> = ({
</div>
)}

{!isLocalhost && !isLinkSecurityWarning && (
{!isLinkSecurityWarning && (
<div className="bg-yellow-500 bg-opacity-10 border border-yellow-500 border-opacity-30 rounded p-3">
<p className="text-sm text-yellow-200">
<strong>⚠️ Security Risk!</strong> This connection may be
Expand All @@ -313,33 +276,18 @@ const SingleWarningModal: React.FC<WarningModalProps> = ({
</div>

{/* Recommendation */}
{(isLocalhost || isLinkSecurityWarning) && (
{isLinkSecurityWarning && (
<div className="bg-blue-500 bg-opacity-10 border border-blue-500 border-opacity-30 rounded p-3">
<p className="text-xs text-blue-200">
<strong>💡 Recommendation:</strong> Only proceed if you trust
this server and understand the risks. Avoid sharing sensitive
information or passwords over this connection.
{isLocalhost &&
" For production use, configure your server with SSL/TLS and use wss:// instead of ws://."}
</p>
</div>
)}

{/* Checkboxes for remembering choice */}
<div className="space-y-2">
{isLocalhost && (
<label className="flex items-center gap-2 text-sm text-discord-text-muted cursor-pointer hover:text-discord-text">
<input
type="checkbox"
checked={skipLocalhostWarning}
onChange={(e) => setSkipLocalhostWarning(e.target.checked)}
className="rounded border-discord-dark-200"
/>
<span>
Don't warn me about localhost connections for this server
</span>
</label>
)}
{isLinkSecurityWarning && (
<label className="flex items-center gap-2 text-sm text-discord-text-muted cursor-pointer hover:text-discord-text">
<input
Expand Down
21 changes: 13 additions & 8 deletions src/lib/ircClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -476,8 +476,7 @@ export class IRCClient {

// Create a new connection promise and store it
const connectionPromise = new Promise<Server>((resolve, reject) => {
// for local testing and automated tests, if domain is localhost or 127.0.0.1 use ws instead of wss
let protocol = ["localhost", "127.0.0.1"].includes(host) ? "ws" : "wss";
let protocol: "wss" | "ircs" | "irc" = "wss";
let actualHost = host;
let actualPort = port;

Expand All @@ -488,13 +487,19 @@ export class IRCClient {
protocol = parsed.scheme;
actualHost = parsed.host;
actualPort = parsed.port;
} else if (host.startsWith("ws://") || host.startsWith("wss://")) {
// Parse ws/wss URLs
const urlMatch = host.match(/^(wss?):\/\/([^:]+)(?::(\d+))?/);
} else if (host.startsWith("wss://")) {
// Parse wss:// URLs
const urlMatch = host.match(/^wss:\/\/([^:]+)(?::(\d+))?/);
if (urlMatch) {
protocol = urlMatch[1] as "ws" | "wss";
actualHost = urlMatch[2];
actualPort = urlMatch[3] ? Number.parseInt(urlMatch[3], 10) : port;
actualHost = urlMatch[1];
actualPort = urlMatch[2] ? Number.parseInt(urlMatch[2], 10) : port;
}
} else if (host.startsWith("ws://")) {
// Upgrade legacy ws:// to wss:// — unencrypted WebSockets are no longer supported
const urlMatch = host.match(/^ws:\/\/([^:]+)(?::(\d+))?/);
if (urlMatch) {
actualHost = urlMatch[1];
actualPort = urlMatch[2] ? Number.parseInt(urlMatch[2], 10) : port;
}
Comment on lines +490 to 503
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

[^:]+ host regex breaks for IPv6 and produces a misleading error when urlMatch is null.

Two related issues in the new wss:// and ws:// branches:

  1. IPv6: [^:]+ captures only up to the first colon, so wss://[::1]:8097 yields actualHost = "[". The PR's own local-dev instructions mention ::1 as a valid localhost alias.

  2. Null-match fallback: when urlMatch is null (e.g. host = "wss://"), actualHost is left as the full original string, producing a double-scheme URL (wss://wss://:port) that generates a confusing error message downstream.

🐛 Proposed fix — robust URL parsing for both branches
-} else if (host.startsWith("wss://")) {
-  // Parse wss:// URLs
-  const urlMatch = host.match(/^wss:\/\/([^:]+)(?::(\d+))?/);
-  if (urlMatch) {
-    actualHost = urlMatch[1];
-    actualPort = urlMatch[2] ? Number.parseInt(urlMatch[2], 10) : port;
-  }
-} else if (host.startsWith("ws://")) {
-  // Upgrade legacy ws:// to wss:// — unencrypted WebSockets are no longer supported
-  const urlMatch = host.match(/^ws:\/\/([^:]+)(?::(\d+))?/);
-  if (urlMatch) {
-    actualHost = urlMatch[1];
-    actualPort = urlMatch[2] ? Number.parseInt(urlMatch[2], 10) : port;
-  }
-}
+} else if (host.startsWith("wss://") || host.startsWith("ws://")) {
+  if (host.startsWith("ws://")) {
+    // Upgrade legacy ws:// to wss:// — unencrypted WebSockets are no longer supported
+    console.warn(`[ircClient] Upgrading ws:// to wss:// for: ${host}`);
+  }
+  try {
+    // Use the URL API for robust parsing (handles IPv6, paths, etc.)
+    const parsed = new URL(host.startsWith("ws://") ? host.replace(/^ws:/, "wss:") : host);
+    actualHost = parsed.hostname; // strips brackets from IPv6
+    actualPort = parsed.port ? Number.parseInt(parsed.port, 10) : port;
+  } catch {
+    throw new Error(`Invalid WebSocket URL: ${host}`);
+  }
+}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
} else if (host.startsWith("wss://")) {
// Parse wss:// URLs
const urlMatch = host.match(/^wss:\/\/([^:]+)(?::(\d+))?/);
if (urlMatch) {
protocol = urlMatch[1] as "ws" | "wss";
actualHost = urlMatch[2];
actualPort = urlMatch[3] ? Number.parseInt(urlMatch[3], 10) : port;
actualHost = urlMatch[1];
actualPort = urlMatch[2] ? Number.parseInt(urlMatch[2], 10) : port;
}
} else if (host.startsWith("ws://")) {
// Upgrade legacy ws:// to wss:// — unencrypted WebSockets are no longer supported
const urlMatch = host.match(/^ws:\/\/([^:]+)(?::(\d+))?/);
if (urlMatch) {
actualHost = urlMatch[1];
actualPort = urlMatch[2] ? Number.parseInt(urlMatch[2], 10) : port;
}
} else if (host.startsWith("wss://") || host.startsWith("ws://")) {
if (host.startsWith("ws://")) {
// Upgrade legacy ws:// to wss:// — unencrypted WebSockets are no longer supported
console.warn(`[ircClient] Upgrading ws:// to wss:// for: ${host}`);
}
try {
// Use the URL API for robust parsing (handles IPv6, paths, etc.)
const parsed = new URL(host.startsWith("ws://") ? host.replace(/^ws:/, "wss:") : host);
actualHost = parsed.hostname; // strips brackets from IPv6
actualPort = parsed.port ? Number.parseInt(parsed.port, 10) : port;
} catch {
throw new Error(`Invalid WebSocket URL: ${host}`);
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/ircClient.ts` around lines 490 - 503, The host parsing in the
host.startsWith("wss://") and host.startsWith("ws://") branches fails for IPv6
and leaves actualHost unchanged when the regex doesn't match; replace the
fragile regex logic with robust URL parsing using the URL API (e.g. construct
new URL(host) inside a try/catch), then set actualHost = url.hostname and
actualPort = url.port ? Number(url.port) : port; for the "ws://" branch ensure
you still upgrade to wss by using the parsed URL and adjusting the protocol if
needed; in the catch/fallback, explicitly handle invalid/empty hosts (throw or
return a clear error) instead of leaving actualHost as the original string so
you don't produce double-scheme URLs—modify the code around the
host.startsWith("wss://") and host.startsWith("ws://") branches and the
variables urlMatch/actualHost/actualPort accordingly.

}

Expand Down
2 changes: 1 addition & 1 deletion src/lib/socket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ export class WebSocketWrapper implements ISocket {
}

export function createSocket(url: string): ISocket {
if (url.startsWith("ws://") || url.startsWith("wss://")) {
if (url.startsWith("wss://")) {
return new WebSocketWrapper(url);
}
if (url.startsWith("irc://") || url.startsWith("ircs://")) {
Expand Down
23 changes: 5 additions & 18 deletions src/store/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ function generateDeterministicId(serverId: string, name: string): string {
function normalizeHost(host: string): string {
if (host.includes("://")) {
// Extract hostname from URL format
const withoutProtocol = host.replace(/^(irc|ircs|ws|wss):\/\//, "");
const withoutProtocol = host.replace(/^(irc|ircs|wss):\/\//, "");
return withoutProtocol.split(":")[0]; // Get just hostname, strip port if present
}
return host;
Expand All @@ -61,15 +61,8 @@ function ensureUrlFormat(host: string, port: number): string {
if (host.includes("://")) {
return host; // Already in URL format
}
// Convert old hostname-only format to URL
const isLocalhost =
host === "localhost" || host === "127.0.0.1" || host === "::1";
const scheme = isLocalhost
? "ws"
: port === 6697 || port === 9999 || port === 443 || port === 993
? "wss"
: "ws";
return `${scheme}://${host}:${port}`;
// Convert old hostname-only format to URL — always wss://
return `wss://${host}:${port}`;
}

// Types for batch event processing
Expand Down Expand Up @@ -5899,13 +5892,9 @@ ircClient.on("CAP LS", ({ serverId, cliCaps }) => {
return { servers: updatedServers };
});

// Check for insecure connection and show warning modal
// Show warning modal for low UnrealIRCd link-security value
const currentState = useStore.getState();
const currentServer = currentState.servers.find((s) => s.id === serverId);
const isLocalhost =
currentServer &&
(currentServer.host === "localhost" ||
currentServer.host === "127.0.0.1");
const hasLowLinkSecurity = linkSecurityValue < 2;

// Check if we should show warning based on individual skip preferences
Expand All @@ -5918,12 +5907,10 @@ ircClient.on("CAP LS", ({ serverId, cliCaps }) => {
)
: undefined;

const shouldWarnLocalhost =
isLocalhost && !serverConfig?.skipLocalhostWarning;
const shouldWarnLinkSecurity =
hasLowLinkSecurity && !serverConfig?.skipLinkSecurityWarning;

if (shouldWarnLocalhost || shouldWarnLinkSecurity) {
if (shouldWarnLinkSecurity) {
useStore.setState((state) => {
// Check if warning already exists for this server
const existingWarning = state.ui.linkSecurityWarnings.find(
Expand Down
Loading