Secure Wi-Fi bridge
Give your Wi-Fi reader a secure wss:// address that works from any network — including this app on https.
Why you need this
Browsers can't talk to a Wi-Fi reader directly, and on a secure page they block local ws:// addresses. The bridge is a small helper you run on a laptop, PC or Raspberry Pi on the reader's Wi-Fi. It forwards the connection and, through a free secure tunnel, gives you a wss:// address the app can use from anywhere.
Let AI set this up
Your pairing details are created and saved, one command is written for you, and the app then finds your bridge by itself and remembers it.
Sign in and the assistant will set the bridge up, store your details on your account, and explain anything that goes wrong.
Sign inOr set it up yourself
Install the two tools
Node.js 18 or newer — from nodejs.org.
cloudflared — the free tunnel tool. On macOS run this in Terminal; on Windows use winget install --id Cloudflare.cloudflared; otherwise grab it from the Cloudflare downloads page.
brew install cloudflaredSave the bridge script
Download it — or copy it into a new file called obd-bridge.mjs in any folder.
#!/usr/bin/env node
/**
* C15-OBD secure bridge — WebSocket ⇄ raw TCP for Wi-Fi ELM327 adapters.
*
* Browsers can't open a raw TCP socket to a Wi-Fi reader, and an https page also
* blocks local ws:// addresses. This bridge runs on a laptop / Raspberry Pi on the
* same Wi-Fi as the reader, listens for a WebSocket, and forwards it to the reader's
* TCP port. It starts its own secure tunnel, so the app gets a wss:// address that
* works from any network.
*
* npm install ws
* node obd-bridge.mjs --pair ABC123XYZ0 --token 1a2b3c4d5e6f # paired with the app
* node obd-bridge.mjs # manual: prints the token
* node obd-bridge.mjs --target 192.168.0.11:35000 # a different reader address
* node obd-bridge.mjs --no-tunnel # skip the tunnel
* node obd-bridge.mjs --cert cert.pem --key key.pem # serve wss:// directly
*
* With --pair, the bridge finds the reader, opens a tunnel, and reports the address
* to the app, which then connects by itself and remembers the setup.
* Get the pairing code and token from the app's Secure Bridge page.
*/
import { createServer as createHttpServer } from 'node:http';
import { createServer as createHttpsServer } from 'node:https';
import { connect as tcpConnect } from 'node:net';
import { spawn } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { randomBytes } from 'node:crypto';
import * as wsModule from 'ws';
// ws exposes its server class as an ESM named export, and also as a property on the
// CommonJS default export — accept either so the script runs on any Node version.
const WebSocketServer =
wsModule.WebSocketServer ||
(wsModule.default && (wsModule.default.WebSocketServer || wsModule.default.Server));
if (!WebSocketServer) {
console.error('Could not load the "ws" package. Run "npm install ws" in this folder, then try again.');
process.exit(1);
}
const argv = process.argv.slice(2);
const arg = (name, fallback) => {
const i = argv.indexOf('--' + name);
return i >= 0 && argv[i + 1] ? argv[i + 1] : fallback;
};
const hasFlag = name => argv.includes('--' + name);
const stamp = () => new Date().toISOString().slice(11, 19);
const log = (...parts) => console.log('[' + stamp() + ']', ...parts);
// Addresses Wi-Fi ELM327 readers commonly listen on — tried in order when no --target is given.
const READER_CANDIDATES = [
'192.168.0.10:35000',
'192.168.0.10:23',
'192.168.0.11:35000',
'192.168.0.11:23',
'192.168.1.5:35000',
'192.168.4.1:35000',
'192.168.4.1:23',
'10.0.0.10:35000',
];
const DEFAULT_API = 'https://c15-obd.base44.app';
const api = (process.env.OBD_API || arg('api', DEFAULT_API)).replace(/\/+$/, '');
const pairCode = (process.env.OBD_PAIR || arg('pair', '')).trim().toUpperCase();
const port = Number(process.env.OBD_PORT || arg('port', 8080));
const token = process.env.OBD_TOKEN || arg('token', randomBytes(6).toString('hex'));
const certPath = process.env.OBD_CERT || arg('cert', '');
const keyPath = process.env.OBD_KEY || arg('key', '');
const useTls = Boolean(certPath && keyPath);
const noTunnel = hasFlag('no-tunnel') || process.env.OBD_NO_TUNNEL === '1';
function probeTcp(hostPort, timeout = 900) {
return new Promise(resolve => {
const host = hostPort.replace(/:\d+$/, '');
const tcpPort = Number(hostPort.includes(':') ? hostPort.split(':').pop() : 35000);
const socket = tcpConnect({ host, port: tcpPort });
const finish = ok => {
clearTimeout(timer);
try { socket.destroy(); } catch {}
resolve(ok);
};
const timer = setTimeout(() => finish(false), timeout);
socket.on('connect', () => finish(true));
socket.on('error', () => finish(false));
});
}
async function findReader() {
for (const candidate of READER_CANDIDATES) {
log('Looking for a reader at ' + candidate + ' …');
if (await probeTcp(candidate)) return candidate;
}
return '';
}
const explicitTarget = process.env.OBD_TARGET || arg('target', '');
let target = explicitTarget;
if (!target) {
target = await findReader();
if (target) log('Found a reader at ' + target + ' — using it.');
else {
target = READER_CANDIDATES[0];
log('Nothing answered on the usual addresses — assuming ' + target + '. Set --target if your reader uses another address.');
}
}
const targetHost = target.replace(/:\d+$/, '');
const targetPort = Number(target.includes(':') ? target.split(':').pop() : 35000);
function requestHandler(req, res) {
const headers = { 'content-type': 'application/json', 'access-control-allow-origin': '*' };
if (req.url && req.url.startsWith('/healthz')) {
res.writeHead(200, headers);
res.end(JSON.stringify({ ok: true, bridge: 'c15-obd', target }));
return;
}
res.writeHead(200, headers);
res.end(JSON.stringify({ ok: true, bridge: 'c15-obd', message: 'Connect with a WebSocket and ?token=...' }));
}
const server = useTls
? createHttpsServer({ cert: readFileSync(certPath), key: readFileSync(keyPath) }, requestHandler)
: createHttpServer(requestHandler);
const wss = new WebSocketServer({ noServer: true });
server.on('upgrade', (req, socket) => {
let allowed = false;
try {
allowed = new URL(req.url || '/', 'http://localhost').searchParams.get('token') === token;
} catch {
allowed = false;
}
if (!allowed) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
log('Rejected a connection: missing or wrong token.');
return;
}
wss.handleUpgrade(req, socket, Buffer.alloc(0), ws => wss.emit('connection', ws));
});
wss.on('connection', ws => {
log('App connected — opening TCP ' + targetHost + ':' + targetPort);
const tcp = tcpConnect({ host: targetHost, port: targetPort });
tcp.setNoDelay(true);
let open = false;
const giveUp = setTimeout(() => {
if (open) return;
log('The reader did not answer at ' + targetHost + ':' + targetPort + '. Is the bridge device on the reader\'s Wi-Fi?');
try { tcp.destroy(); } catch {}
try { ws.close(); } catch {}
}, 6000);
tcp.on('connect', () => {
open = true;
clearTimeout(giveUp);
log('Reader socket open — live data can flow now.');
});
tcp.on('data', chunk => { if (ws.readyState === ws.OPEN) ws.send(chunk.toString('latin1')); });
tcp.on('error', error => {
clearTimeout(giveUp);
log('Reader error: ' + error.message);
try { ws.close(); } catch {}
});
tcp.on('close', () => {
log('Reader socket closed.');
if (ws.readyState === ws.OPEN) ws.close();
});
ws.on('message', data => { if (open) tcp.write(data); });
ws.on('close', () => { log('App disconnected.'); tcp.destroy(); });
ws.on('error', () => { tcp.destroy(); });
});
// Opens a free Cloudflare quick tunnel and returns the public https address.
function startTunnel() {
const tunnelArgs = ['tunnel', '--url', 'http://localhost:' + port];
// A copy of cloudflared sitting next to this script is used if the system one is missing,
// which is what makes phone-only setups (Termux) and portable folders work.
const localBinary = process.platform === 'win32' ? '.\\cloudflared' : './cloudflared';
const attempts = [
{ cmd: 'cloudflared', args: tunnelArgs },
{ cmd: localBinary, args: tunnelArgs },
{ cmd: 'npx', args: ['-y', 'cloudflared@latest', ...tunnelArgs] },
];
return new Promise(resolve => {
let index = 0;
let done = false;
let settled = false;
const finish = url => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve(url);
};
const timer = setTimeout(() => finish(''), 30000);
const readChunk = chunk => {
const match = String(chunk).match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/i);
if (match) finish(match[0]);
};
const attempt = () => {
if (settled) return;
if (index >= attempts.length) return finish('');
const { cmd, args } = attempts[index++];
log('Starting a secure tunnel with "' + cmd + '" …');
// Windows needs the shell to resolve npx/cloudflared wrappers; elsewhere spawn resolves PATH directly.
const child = spawn(cmd, args, { shell: process.platform === 'win32', stdio: ['ignore', 'pipe', 'pipe'] });
let advanced = false;
const next = () => {
if (advanced || settled) return;
advanced = true;
log('"' + cmd + '" is not available on this computer.');
attempt();
};
child.stdout.on('data', readChunk);
child.stderr.on('data', readChunk);
child.on('error', next);
child.on('exit', next);
};
attempt();
});
}
async function registerWithApp(publicUrl) {
if (!pairCode) return false;
const payload = {
action: 'register',
code: pairCode,
url: publicUrl,
target: targetHost + ':' + targetPort,
local_url: (useTls ? 'wss' : 'ws') + '://localhost:' + port + '/?token=' + token,
};
try {
const response = await fetch(api + '/functions/bridge-registry', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await response.json().catch(() => ({}));
if (!response.ok || data.error) {
log('The app refused the bridge registration: ' + (data.error || response.status));
return false;
}
return true;
} catch (error) {
log('Could not reach the app to register this bridge: ' + error.message);
return false;
}
}
function printManualAddress() {
console.log(' Next step — make it secure from a second terminal:');
console.log(' cloudflared tunnel --url http://localhost:' + port);
console.log('');
console.log(' It prints an https://<something>.trycloudflare.com address.');
console.log(' Use that host in the app, with wss:// and your token:');
console.log(' wss://<something>.trycloudflare.com/?token=' + token);
console.log('');
}
server.listen(port, async () => {
const scheme = useTls ? 'wss' : 'ws';
console.log('');
console.log(' C15-OBD secure bridge is running');
console.log(' --------------------------------');
console.log(' Reader target : ' + targetHost + ':' + targetPort);
console.log(' Access token : ' + token);
console.log(' Local address : ' + scheme + '://localhost:' + port + '/?token=' + token);
if (useTls) console.log(' TLS : ' + certPath);
console.log('');
let publicUrl = '';
if (!noTunnel) publicUrl = await startTunnel();
const secureAddress = publicUrl ? publicUrl.replace(/^https:/i, 'wss:') + '/?token=' + token : '';
if (secureAddress) {
console.log(' Secure address: ' + secureAddress);
console.log('');
}
if (!pairCode) {
if (secureAddress) console.log(' Copy the secure address above into the app\'s Secure Bridge page.');
else printManualAddress();
} else if (secureAddress) {
const paired = await registerWithApp(secureAddress);
if (paired) {
console.log(' Paired — the app will find this bridge by itself and remember it.');
console.log(' Open C15-OBD and press connect; leave this window running.');
setInterval(() => registerWithApp(secureAddress), 60000);
} else {
console.log(' Pairing did not go through, so paste the secure address above into the app.');
}
console.log('');
} else {
console.log(' No tunnel could be started, so a secure app page cannot reach this bridge.');
console.log(' Install cloudflared:');
console.log(' https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/');
console.log(' Then run, in a second terminal:');
console.log(' cloudflared tunnel --url http://localhost:' + port);
console.log(' and paste the wss:// address it prints into the app, with your token.');
console.log('');
}
console.log(' Keep this window open while diagnosing. Press Ctrl+C to stop.');
console.log('');
});Start the bridge
In the folder you saved it in. Keep this window open.
npm install ws
node obd-bridge.mjsIt prints your access token — you'll need it in step 5. If your reader isn't at the usual address, add it: node obd-bridge.mjs --target 192.168.0.11:35000.
Make it secure with a tunnel
Open a second terminal window and run:
cloudflared tunnel --url http://localhost:8080Copy the https://something.trycloudflare.com address it prints, swap https for wss, and add your token:
wss://something.trycloudflare.com/?token=YOUR-TOKENConnect from the app
Paste the address from step 4 and connect. The app remembers it and tries it first from now on.
Currently saved: ws://192.168.0.10:35000
Nothing connecting?
Paste what the terminal window on your computer says and the assistant will tell you exactly what to change.
Your token and bridge address are hidden before anything is sent.
Keep it safe
- The tunnel address is reachable from the internet, so your token is what protects it — don't share the full address with anyone.
- Anyone with both can read your engine data and send diagnostic commands to your car, so stop the bridge (Ctrl+C) when you're done.
- Turn off the tunnel when you're not diagnosing — the free tunnel address changes every time you start it.