Skip to content

Commit 74a9a86

Browse files
committed
Support new private ips https
1 parent ac44dcb commit 74a9a86

7 files changed

Lines changed: 152 additions & 25 deletions

File tree

src/bridge/connection.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
*/
99

1010
import type { BrowserKind, Protocol } from './utils';
11-
import { detectBrowser, fetchWithTimeout, isLocalNetworkHost, resolveBaseUrl, resolveProtocol } from './utils';
11+
import { detectBrowser, fetchWithTimeout, ipToSslipHostname, isLocalNetworkHost, isRawPrivateIp, resolveBaseUrl, resolveProtocol } from './utils';
1212

1313
/**
1414
* Lightweight probe to check if an ECA server is listening on a given port.
@@ -23,14 +23,18 @@ import { detectBrowser, fetchWithTimeout, isLocalNetworkHost, resolveBaseUrl, re
2323
export async function probePort(
2424
host: string,
2525
port: number,
26-
preferredProtocol: Protocol = 'http',
26+
preferredProtocol: Protocol = 'https',
2727
): Promise<boolean> {
2828
const protocols: Protocol[] =
2929
preferredProtocol === 'https' ? ['https', 'http'] : ['http', 'https'];
3030

3131
const results = await Promise.allSettled(
3232
protocols.map(async (proto) => {
33-
const url = `${proto}://${host}:${port}/api/v1/health`;
33+
// For HTTPS to a raw private IP, use the sslip.io hostname so the TLS cert matches
34+
const effectiveHost = proto === 'https' && isRawPrivateIp(host)
35+
? ipToSslipHostname(`${host}:${port}`).split(':')[0]
36+
: host;
37+
const url = `${proto}://${effectiveHost}:${port}/api/v1/health`;
3438
await fetchWithTimeout(url, { mode: 'no-cors' }, 3_000);
3539
}),
3640
);

src/bridge/utils.ts

Lines changed: 64 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,58 @@ const PRIVATE_NETWORK_RE =
3939
/** Loopback addresses (127.x, localhost). */
4040
const LOOPBACK_RE = /^(127\.|localhost)/i;
4141

42+
/** sslip.io-style hostname under local.eca.dev with an embedded IP (e.g. 192-168-1-1.local.eca.dev). */
43+
const SSLIP_LOCAL_RE =
44+
/^(\d{1,3})-(\d{1,3})-(\d{1,3})-(\d{1,3})\.local\.eca\.dev$/i;
45+
46+
const SSLIP_DOMAIN = 'local.eca.dev';
47+
48+
/**
49+
* Extract the embedded IP from an sslip.io-style hostname.
50+
* E.g. "192-168-15-17.local.eca.dev" → "192.168.15.17"
51+
* Returns undefined if the hostname doesn't match.
52+
*/
53+
export function extractSslipIp(host: string): string | undefined {
54+
const match = SSLIP_LOCAL_RE.exec(host);
55+
if (!match) return undefined;
56+
return `${match[1]}.${match[2]}.${match[3]}.${match[4]}`;
57+
}
58+
59+
/**
60+
* Convert a raw IP to its sslip.io-style hostname under local.eca.dev.
61+
* E.g. "192.168.15.17" → "192-168-15-17.local.eca.dev"
62+
*
63+
* If `hostWithPort` contains a port (e.g. "192.168.1.42:7777"), the port
64+
* is preserved: "192-168-1-42.local.eca.dev:7777".
65+
*
66+
* Returns the input unchanged if it's not a raw IPv4 address.
67+
*/
68+
export function ipToSslipHostname(hostWithPort: string): string {
69+
const [hostPart, port] = hostWithPort.split(':');
70+
if (!/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostPart)) return hostWithPort;
71+
const sslipHost = `${hostPart.replace(/\./g, '-')}.${SSLIP_DOMAIN}`;
72+
return port ? `${sslipHost}:${port}` : sslipHost;
73+
}
74+
75+
/**
76+
* True when `host` is a raw private/loopback IPv4 (not already an sslip hostname).
77+
* Useful to decide whether to transform a user-entered host to sslip form.
78+
*/
79+
export function isRawPrivateIp(host: string): boolean {
80+
const hostPart = host.split(':')[0];
81+
return (PRIVATE_NETWORK_RE.test(hostPart) || LOOPBACK_RE.test(hostPart))
82+
&& !SSLIP_LOCAL_RE.test(hostPart);
83+
}
84+
4285
/**
4386
* Returns true when `host` targets a private/local network address.
87+
* Also recognises sslip.io-style hostnames under *.local.eca.dev
88+
* that embed a private IP (e.g. 192-168-15-17.local.eca.dev).
4489
* Used for protocol defaults and Chrome Local Network Access hints.
4590
*/
4691
export function isLocalNetworkHost(host: string): boolean {
47-
return PRIVATE_NETWORK_RE.test(host) || LOOPBACK_RE.test(host);
92+
const resolved = extractSslipIp(host) ?? host;
93+
return PRIVATE_NETWORK_RE.test(resolved) || LOOPBACK_RE.test(resolved);
4894
}
4995

5096
/**
@@ -55,30 +101,42 @@ export function isLocalNetworkHost(host: string): boolean {
55101
* - `"private"` — RFC 1918 (10.x, 172.16-31.x, 192.168.x)
56102
* - `undefined` — public / not applicable
57103
*
104+
* Also handles sslip.io-style *.local.eca.dev hostnames by extracting
105+
* the embedded IP before classification.
106+
*
58107
* Chrome validates that the resolved IP matches the declared space;
59108
* a mismatch causes the request to fail.
60109
*/
61110
export function targetAddressSpace(host: string): string | undefined {
62-
if (LOOPBACK_RE.test(host)) return 'local';
63-
if (PRIVATE_NETWORK_RE.test(host)) return 'private';
111+
const resolved = extractSslipIp(host) ?? host;
112+
if (LOOPBACK_RE.test(resolved)) return 'local';
113+
if (PRIVATE_NETWORK_RE.test(resolved)) return 'private';
64114
return undefined;
65115
}
66116

67117
/**
68118
* Resolve the HTTP protocol for a given host string.
69119
* When an explicit protocol is provided it is used as-is;
70-
* otherwise private/loopback addresses → http, everything else → https.
120+
* otherwise defaults to HTTPS (ECA servers support TLS for private IPs
121+
* via *.local.eca.dev wildcard certs).
71122
*/
72123
export function resolveProtocol(host: string, protocol?: Protocol): Protocol {
73124
if (protocol) return protocol;
74-
return isLocalNetworkHost(host) ? 'http' : 'https';
125+
return 'https';
75126
}
76127

77128
/**
78129
* Build the base API URL for a host, e.g. "https://myhost:7888/api/v1".
130+
*
131+
* When connecting over HTTPS to a raw private IP, automatically rewrites
132+
* the host to its sslip.io hostname so the TLS certificate matches.
79133
*/
80134
export function resolveBaseUrl(host: string, protocol?: Protocol): string {
81-
return `${resolveProtocol(host, protocol)}://${host}/api/v1`;
135+
const proto = resolveProtocol(host, protocol);
136+
const effectiveHost = proto === 'https' && isRawPrivateIp(host)
137+
? ipToSslipHostname(host)
138+
: host;
139+
return `${proto}://${effectiveHost}/api/v1`;
82140
}
83141

84142
/**

src/pages/ConnectForm.css

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,39 @@
415415
cursor: not-allowed;
416416
}
417417

418+
/* ---- TLS / sslip.io info hint ---- */
419+
420+
.connect-sslip-hint {
421+
display: flex;
422+
align-items: flex-start;
423+
gap: 0.55rem;
424+
margin-top: 0.25rem;
425+
padding: 0.6rem 0.85rem;
426+
background: rgba(0, 165, 184, 0.06);
427+
border: 1px solid rgba(0, 165, 184, 0.18);
428+
border-radius: 0.6rem;
429+
color: #5cc8d4;
430+
font-size: 0.78rem;
431+
line-height: 1.55;
432+
text-align: left;
433+
}
434+
435+
.connect-sslip-hint-icon {
436+
flex-shrink: 0;
437+
font-size: 0.85rem;
438+
line-height: 1.35;
439+
}
440+
441+
.connect-sslip-hint code {
442+
background: rgba(0, 165, 184, 0.1);
443+
padding: 0.1em 0.35em;
444+
border-radius: 0.25em;
445+
font-size: 0.88em;
446+
color: #00c8dc;
447+
border: 1px solid rgba(0, 165, 184, 0.12);
448+
word-break: break-all;
449+
}
450+
418451
/* ---- Mixed-content warning banner ---- */
419452

420453
.mixed-content-warning {

src/pages/ConnectForm.tsx

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { useEffect, useMemo, useRef, useState } from 'react';
1+
import { useMemo, useRef, useState } from 'react';
22
import { getMixedContentWarning } from '../bridge/connection';
33
import type { Protocol } from '../bridge/utils';
4+
import { ipToSslipHostname, isRawPrivateIp } from '../bridge/utils';
45
import { CodeRain } from '../components/CodeRain';
56
import './ConnectForm.css';
67

@@ -33,21 +34,19 @@ export function ConnectForm({ onConnect, onDiscover, error, isConnecting, discov
3334
const [autoDiscover, setAutoDiscover] = useState(true);
3435
const userToggledProtocol = useRef(false);
3536

36-
// Auto-detect HTTP for private/local network addresses
37-
useEffect(() => {
38-
if (userToggledProtocol.current) return;
39-
const h = host.trim();
40-
const isPrivate =
41-
/^(192\.168\.|10\.|172\.(1[6-9]|2\d|3[01])\.|127\.|localhost)/i.test(h);
42-
setProtocol(isPrivate ? 'http' : 'https');
43-
}, [host]);
44-
4537
// Proactive warning for Firefox/Safari when HTTPS→HTTP to private IP
4638
const mixedContentWarning = useMemo(
4739
() => getMixedContentWarning(host.trim(), protocol),
4840
[host, protocol],
4941
);
5042

43+
// Show the resolved sslip.io hostname when connecting over HTTPS to a raw private IP
44+
const sslipHint = useMemo(() => {
45+
const h = host.trim();
46+
if (protocol !== 'https' || !h || !isRawPrivateIp(h)) return null;
47+
return ipToSslipHostname(h);
48+
}, [host, protocol]);
49+
5150
const handleSubmit = (e: React.FormEvent) => {
5251
e.preventDefault();
5352
const trimmedHost = host.trim();
@@ -151,6 +150,13 @@ export function ConnectForm({ onConnect, onDiscover, error, isConnecting, discov
151150
</div>
152151
)}
153152

153+
{sslipHint && (
154+
<div className="connect-sslip-hint">
155+
<span className="connect-sslip-hint-icon">🔒</span>
156+
<span>Will connect via <code>{sslipHint}</code></span>
157+
</div>
158+
)}
159+
154160
{mixedContentWarning && (
155161
<div className="mixed-content-warning">
156162
<span className="mixed-content-warning-icon"></span>

src/pages/ConnectionBar.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import type { WorkspaceFolder } from '../bridge/types';
99
import type { Protocol } from '../bridge/utils';
10+
import { extractSslipIp } from '../bridge/utils';
1011
import type { SessionStatus } from './RemoteSession';
1112

1213
// ---------------------------------------------------------------------------
@@ -123,9 +124,18 @@ function getWorkspaceLabel(
123124
return { name, fullPath };
124125
}
125126

126-
/** Truncate a host string to fit in the tab bar. */
127+
/**
128+
* Format a host string for the tab bar.
129+
* Converts sslip.io hostnames back to their embedded IP for readability
130+
* (e.g. "192-168-1-42.local.eca.dev:7777" → "192.168.1.42:7777").
131+
*/
127132
function formatHost(host: string): string {
128133
const clean = host.replace(/^https?:\/\//, '');
134+
const [hostPart, port] = clean.split(':');
135+
const ip = extractSslipIp(hostPart);
136+
if (ip) {
137+
return port ? `${ip}:${port}` : ip;
138+
}
129139
return clean.length > 28 ? clean.slice(0, 26) + '…' : clean;
130140
}
131141

src/pages/RemoteProduct.tsx

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { getMixedContentErrorHint, probePort, testConnection } from '../bridge/c
1616
import type { WebBridge } from '../bridge/transport';
1717
import type { ChatEntry, WorkspaceFolder } from '../bridge/types';
1818
import type { Protocol } from '../bridge/utils';
19+
import { ipToSslipHostname, isRawPrivateIp } from '../bridge/utils';
1920
import { ChatSidebar, ChatSidebarToggle } from '../components/ChatSidebar';
2021
import {
2122
consumeDeepLink,
@@ -123,15 +124,20 @@ export function RemoteProduct() {
123124
setFormConnecting(true);
124125
setFormError(null);
125126

127+
// Rewrite raw private IPs to sslip.io hostname for HTTPS connections
128+
const effectiveHost = protocol === 'https' && isRawPrivateIp(host)
129+
? ipToSslipHostname(host)
130+
: host;
131+
126132
try {
127-
const error = await testConnection(host, password, protocol);
133+
const error = await testConnection(effectiveHost, password, protocol);
128134
if (error) {
129135
setFormError(error);
130136
return;
131137
}
132138

133139
// If a connection to this host already exists, switch to it
134-
const existing = entries.find((e) => e.host === host);
140+
const existing = entries.find((e) => e.host === effectiveHost);
135141
if (existing) {
136142
// Update password/protocol in case they changed
137143
if (existing.password !== password || existing.protocol !== protocol) {
@@ -145,7 +151,7 @@ export function RemoteProduct() {
145151
}
146152

147153
const id = crypto.randomUUID();
148-
setEntries((prev) => [...prev, { id, host, password, protocol, status: 'idle' }]);
154+
setEntries((prev) => [...prev, { id, host: effectiveHost, password, protocol, status: 'idle' }]);
149155
setActiveId(id);
150156
setShowForm(false);
151157
} catch {
@@ -197,10 +203,12 @@ export function RemoteProduct() {
197203
// Create a connection entry for each found port, select the latest (highest port)
198204
let latestId: string | null = null;
199205
let latestPort = -1;
206+
const shouldRewrite = protocol === 'https' && isRawPrivateIp(host);
200207
setEntries((prev) => {
201208
const next = [...prev];
202209
for (const port of progress.found) {
203-
const hostWithPort = `${host}:${port}`;
210+
const rawHostWithPort = `${host}:${port}`;
211+
const hostWithPort = shouldRewrite ? ipToSslipHostname(rawHostWithPort) : rawHostWithPort;
204212
const existing = next.find((e) => e.host === hostWithPort);
205213
if (existing) {
206214
// Update credentials if needed

src/pages/RemoteSession.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { getMixedContentErrorHint } from '../bridge/connection';
44
import { WebBridge } from '../bridge/transport';
55
import type { ReconnectionState } from '../bridge/types';
66
import type { Protocol } from '../bridge/utils';
7+
import { extractSslipIp } from '../bridge/utils';
78
import WebviewApp from '@webview/App';
89
import './RemoteSession.css';
910

@@ -181,12 +182,19 @@ export function RemoteSession({ host, password, protocol, lastChatId, onStatusCh
181182
}
182183

183184
// --- Connecting state ---
185+
// Show the embedded IP instead of the full sslip hostname for readability
186+
const [hostPart, port] = host.split(':');
187+
const displayIp = extractSslipIp(hostPart);
188+
const displayHost = displayIp
189+
? (port ? `${displayIp}:${port}` : displayIp)
190+
: host;
191+
184192
return (
185193
<SessionErrorBoundary>
186194
<div className="remote-session-status">
187195
<div className="remote-session-connecting">
188196
<div className="remote-session-spinner" />
189-
<span>Connecting to {host}</span>
197+
<span>Connecting to {displayHost}</span>
190198
</div>
191199
</div>
192200
</SessionErrorBoundary>

0 commit comments

Comments
 (0)