Commits vergleichen
4 Commits
| Autor | SHA1 | Datum | |
|---|---|---|---|
| 54b74d5db1 | |||
| 6ac280f178 | |||
| 5448cf0614 | |||
| d045847a59 |
@@ -0,0 +1,44 @@
|
||||
# Self-hosted TURN (coturn) for BizGaze Connect
|
||||
|
||||
Why: customers behind symmetric NAT / corporate firewalls / VPNs can't form a direct
|
||||
WebRTC path, so screen share blanks out and disconnects. A TURN relay fixes it. We host
|
||||
our own coturn on a VM we already own — flat cost, no per-GB billing.
|
||||
|
||||
## 1. VM prerequisites
|
||||
- A VM with a **public IP** (your data-center VM is fine).
|
||||
- A DNS A record, e.g. `turn.yourdomain.com` -> that public IP.
|
||||
- A TLS cert for that name (Let's Encrypt): `certbot certonly --standalone -d turn.yourdomain.com`
|
||||
|
||||
## 2. Open firewall ports (on the VM and any edge firewall)
|
||||
- `3478/udp` and `3478/tcp` (STUN/TURN)
|
||||
- `5349/tcp` (TURN over TLS) — and `443/tcp` if you enable alt-tls
|
||||
- `49152-65535/udp` (relay range)
|
||||
|
||||
## 3. Configure
|
||||
Edit `turnserver.conf`:
|
||||
- `external-ip=` your VM's public IP
|
||||
- `static-auth-secret=` a long random string (e.g. `openssl rand -hex 32`)
|
||||
- `realm=` your domain
|
||||
- `cert=` / `pkey=` paths to your Let's Encrypt cert
|
||||
|
||||
## 4. Run
|
||||
```
|
||||
docker compose up -d # uses docker-compose.yml here
|
||||
# or native: apt install coturn; copy this file to /etc/turnserver.conf; enable in /etc/default/coturn; systemctl enable --now coturn
|
||||
```
|
||||
|
||||
## 5. Point the app at it (production env)
|
||||
```
|
||||
TURN_URLS=turn:turn.yourdomain.com:3478,turn:turn.yourdomain.com:3478?transport=tcp,turns:turn.yourdomain.com:5349?transport=tcp
|
||||
TURN_SECRET=<the same static-auth-secret from turnserver.conf>
|
||||
TURN_TTL=86400
|
||||
```
|
||||
The app's `/api/ice` mints short-lived credentials from `TURN_SECRET` automatically — no
|
||||
permanent password is exposed, and outsiders can't reuse your relay. Restart the app.
|
||||
|
||||
## 6. Verify
|
||||
- `GET https://<app>/api/ice` should return a `turn:`/`turns:` entry with a username + credential.
|
||||
- Test page: https://webrtc.github.io/samples/src/content/peerconnection/trickle-ice/
|
||||
Add your `turns:turn.yourdomain.com:5349?transport=tcp` with the username/credential from
|
||||
`/api/ice`; you should see a candidate of type **relay**. If you do, restrictive networks
|
||||
are covered.
|
||||
@@ -0,0 +1,12 @@
|
||||
# Run coturn on your VM: docker compose up -d
|
||||
# host networking is required so the UDP relay port range works without per-port mapping.
|
||||
services:
|
||||
coturn:
|
||||
image: coturn/coturn:latest
|
||||
container_name: coturn
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
volumes:
|
||||
- ./turnserver.conf:/etc/coturn/turnserver.conf:ro
|
||||
- /etc/letsencrypt:/etc/letsencrypt:ro # TLS cert for turns:
|
||||
command: ["-c", "/etc/coturn/turnserver.conf"]
|
||||
@@ -0,0 +1,45 @@
|
||||
# coturn config for BizGaze Connect self-hosted TURN.
|
||||
# Put this on your VM (public IP) and run via Docker (see docker-compose.yml) or
|
||||
# native coturn (apt install coturn). Replace every CHANGE_ME / placeholder.
|
||||
|
||||
# --- listening ---
|
||||
listening-port=3478
|
||||
tls-listening-port=5349
|
||||
# If this VM has a spare 443, also exposing TURNS on 443 gives the best traversal
|
||||
# through strict corporate firewalls (uncomment + ensure nothing else uses 443):
|
||||
# alt-tls-listening-port=443
|
||||
|
||||
# Public address clients reach. If the VM has a 1:1 NAT, use external-ip=PUBLIC/PRIVATE.
|
||||
external-ip=CHANGE_ME_PUBLIC_IP
|
||||
|
||||
# Relay port range (open these UDP ports in the firewall too).
|
||||
min-port=49152
|
||||
max-port=65535
|
||||
|
||||
# --- auth: time-limited shared-secret credentials (matches the app's TURN_SECRET) ---
|
||||
use-auth-secret
|
||||
static-auth-secret=CHANGE_ME_LONG_RANDOM_SECRET
|
||||
realm=connect.yourdomain.com
|
||||
|
||||
# --- TLS (needed for turns: on 5349/443). Use a real cert for turn.yourdomain.com ---
|
||||
cert=/etc/letsencrypt/live/turn.yourdomain.com/fullchain.pem
|
||||
pkey=/etc/letsencrypt/live/turn.yourdomain.com/privkey.pem
|
||||
|
||||
# --- hardening ---
|
||||
fingerprint
|
||||
no-cli
|
||||
no-multicast-peers
|
||||
no-tcp-relay
|
||||
# Block relaying to private/internal ranges (prevents your relay being used to reach
|
||||
# your own LAN / cloud metadata — important SSRF protection):
|
||||
denied-peer-ip=0.0.0.0-0.255.255.255
|
||||
denied-peer-ip=10.0.0.0-10.255.255.255
|
||||
denied-peer-ip=100.64.0.0-100.127.255.255
|
||||
denied-peer-ip=169.254.0.0-169.254.255.255
|
||||
denied-peer-ip=172.16.0.0-172.31.255.255
|
||||
denied-peer-ip=192.168.0.0-192.168.255.255
|
||||
denied-peer-ip=::1
|
||||
denied-peer-ip=fc00::-fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff
|
||||
# Optional: cap per-session bandwidth (bytes/sec) to protect the VM, e.g. 700000 = ~5.6 Mbps
|
||||
# bps-capacity=0
|
||||
# total-quota=100
|
||||
@@ -68,7 +68,7 @@
|
||||
<script>
|
||||
let ICE={iceServers:[{urls:'stun:stun.l.google.com:19302'}]};
|
||||
const IS_MOBILE=/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile/i.test(navigator.userAgent||'');
|
||||
let __icePromise=Promise.resolve();try{__icePromise=fetch('/api/ice').then(r=>r.ok?r.json():null).then(c=>{if(c&&c.iceServers&&IS_MOBILE)ICE=c;}).catch(()=>{});}catch(_){}
|
||||
let __icePromise=Promise.resolve();try{__icePromise=fetch('/api/ice').then(r=>r.ok?r.json():null).then(c=>{if(c&&c.iceServers)ICE=c;}).catch(()=>{});}catch(_){}
|
||||
async function ensureIce(){try{await __icePromise;}catch(_){}return ICE;}
|
||||
function pEsc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}
|
||||
// When embedded in the home shell, tell the parent when a session is live so the
|
||||
|
||||
@@ -79,7 +79,7 @@ let ICE={iceServers:[{urls:'stun:stun.l.google.com:19302'}]};
|
||||
let SHARER_NAME='Customer';
|
||||
try{fetch('/api/me').then(r=>r.ok?r.json():null).then(m=>{if(m&&(m.name||m.email))SHARER_NAME=m.name||m.email;}).catch(()=>{});}catch(_){}
|
||||
const IS_MOBILE=/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile/i.test(navigator.userAgent||'');
|
||||
let __icePromise=Promise.resolve();try{__icePromise=fetch('/api/ice').then(r=>r.ok?r.json():null).then(c=>{if(c&&c.iceServers&&IS_MOBILE)ICE=c;}).catch(()=>{});}catch(_){}
|
||||
let __icePromise=Promise.resolve();try{__icePromise=fetch('/api/ice').then(r=>r.ok?r.json():null).then(c=>{if(c&&c.iceServers)ICE=c;}).catch(()=>{});}catch(_){}
|
||||
async function ensureIce(){try{await __icePromise;}catch(_){}return ICE;}
|
||||
function pEsc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}
|
||||
// When embedded in the home shell, tell the parent when a session is live so the
|
||||
@@ -168,11 +168,15 @@ async function startStreaming(){
|
||||
pc.ondatachannel=(ev)=>{ev.channel.onmessage=()=>{};};
|
||||
pc.ontrack=(ev)=>{ if(ev.track.kind==='audio'){ let a=document.getElementById('remoteAudio'); if(!a){a=document.createElement('audio');a.id='remoteAudio';a.autoplay=true;document.body.appendChild(a);} a.srcObject=ev.streams[0]; } };
|
||||
pc.onicecandidate=(ev)=>{if(ev.candidate)ws.send(JSON.stringify({type:'ice-candidate',sessionId,candidate:ev.candidate}));};
|
||||
pc.onconnectionstatechange=()=>{ if(pc&&pc.connectionState==='failed'){ try{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'customer-ended'}));}catch(_){} endShareSession('The connection was lost. Tap below for a new code if you still need help.'); } };
|
||||
pc.onconnectionstatechange=()=>{ if(!pc) return; if(pc.connectionState==='connected'){ clearTimeout(window.__connWatch); } if(pc.connectionState==='failed'){ clearTimeout(window.__connWatch); try{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'customer-ended'}));}catch(_){} endShareSession("Couldn't connect on this network — it may be blocking screen sharing. Try a different network (e.g. mobile data / hotspot), then tap below for a new code."); } };
|
||||
chatChannel=pc.createDataChannel('chat',{ordered:true});
|
||||
chatChannel.onmessage=(e)=>{try{addChat(JSON.parse(e.data));}catch(_){}};
|
||||
const offer=await pc.createOffer(); await pc.setLocalDescription(offer);
|
||||
ws.send(JSON.stringify({type:'offer',sessionId,sdp:pc.localDescription}));
|
||||
// Watchdog: if we can't establish the peer connection in time, show a clear reason
|
||||
// instead of a blank screen (covers networks with no usable path even via TURN).
|
||||
clearTimeout(window.__connWatch);
|
||||
window.__connWatch=setTimeout(()=>{ if(pc && pc.connectionState!=='connected' && !sessionOver){ try{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'customer-ended'}));}catch(_){} endShareSession("Couldn't connect on this network — it may be blocking screen sharing. Try a different network (e.g. mobile data / hotspot), then tap below for a new code."); } }, 20000);
|
||||
localStream.getVideoTracks()[0].onended=()=>{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'customer-ended'}));teardown();};
|
||||
}
|
||||
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
|
||||
@@ -37,6 +37,7 @@ const users = {
|
||||
},
|
||||
enableMfa: (id) => db.prepare('UPDATE users SET mfa_enabled=1 WHERE id=?').run(id),
|
||||
setName: (id, name) => db.prepare('UPDATE users SET name=? WHERE id=?').run(name, id),
|
||||
setRole: (id, role) => db.prepare('UPDATE users SET role=? WHERE id=?').run(role, id),
|
||||
setPassword: (id, hash, salt) => db.prepare('UPDATE users SET pw_hash=?, pw_salt=? WHERE id=?').run(hash, salt, id),
|
||||
setActive: (id, active) => db.prepare('UPDATE users SET active=? WHERE id=?').run(active ? 1 : 0, id),
|
||||
remove: (id) => db.prepare('DELETE FROM users WHERE id=?').run(id),
|
||||
|
||||
+41
-15
@@ -42,35 +42,50 @@ route('POST', '/api/mfa/enable', async (req, res) => {
|
||||
// Provision (or refresh) a local user from a successful BizGaze identity check.
|
||||
// The local row exists so sessions, audit, and team-scoped data work; BizGaze stays
|
||||
// the source of truth for credentials (the local password is random + unused).
|
||||
// Emails that must always be admins regardless of what BizGaze returns (safety net so an
|
||||
// admin can't be locked out of the report if BizGaze doesn't flag them isAdmin). Optional.
|
||||
const ADMIN_EMAILS = (process.env.ADMIN_EMAILS || '').split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
|
||||
function provisionFromBizgaze(email, bz) {
|
||||
const role = (bz.isAdmin || ADMIN_EMAILS.includes(String(email).toLowerCase())) ? 'admin' : 'technician';
|
||||
const existing = R.users.byEmail(email);
|
||||
if (!existing) {
|
||||
const team = R.teams.first() || R.teams.create('BizGaze');
|
||||
const { hash, salt } = A.hashPassword(A.token());
|
||||
const role = bz.isAdmin ? 'admin' : 'technician';
|
||||
const id = R.users.create({ tenantId: team.id, email, hash, salt, role, name: bz.name || null, mfaSecret: A.newMfaSecret() });
|
||||
audit({ team_id: team.id, user_id: id, user_email: email, action: 'sso_user_created', detail: 'via BizGaze' });
|
||||
return R.users.byId(id);
|
||||
}
|
||||
// BizGaze is the source of truth: keep name + role in sync on each login.
|
||||
if (bz.name && bz.name !== existing.name) R.users.setName(existing.id, bz.name);
|
||||
if (existing.role !== role) R.users.setRole(existing.id, role);
|
||||
return R.users.byId(existing.id);
|
||||
}
|
||||
|
||||
// Login: validates locally first (seeded/bootstrap accounts), then against BizGaze
|
||||
// (the identity provider) when BIZGAZE_LOGIN_URL is configured. Sets a session cookie.
|
||||
// Login: when BizGaze (BIZGAZE_LOGIN_URL) is configured it is the ONLY authority — the
|
||||
// credentials are verified against BizGaze and the user is provisioned/synced locally
|
||||
// (local passwords are not accepted). Without it (dev/tests) the local password is
|
||||
// checked. Sets a session cookie.
|
||||
route('POST', '/api/login', async (req, res) => {
|
||||
const { email, password, remember } = await readBody(req);
|
||||
if (!email || !password) return json(res, 400, { error: 'email and password required' });
|
||||
const existing = R.users.byEmail(email);
|
||||
if (existing && existing.active === 0) return json(res, 403, { error: 'This account has been deactivated' });
|
||||
|
||||
let u = (existing && A.verifyPassword(password, existing.pw_salt, existing.pw_hash)) ? existing : null;
|
||||
if (!u) {
|
||||
let u = null;
|
||||
if (BZ.isEnabled()) {
|
||||
// BizGaze is the identity provider: credentials are ALWAYS verified against BizGaze.
|
||||
// Local passwords are NOT accepted, so stale in-app accounts can't shadow a BizGaze
|
||||
// login and everyone provisions into the same tenant (admins then see all sessions).
|
||||
const bz = await BZ.validateLogin(email, password);
|
||||
if (bz.ok) u = provisionFromBizgaze(email, bz);
|
||||
else if (bz.error) return json(res, 503, { error: bz.error });
|
||||
if (bz.error) return json(res, 503, { error: bz.error });
|
||||
if (!bz.ok) return json(res, 401, { error: bz.message || 'Username or password do not match.' });
|
||||
u = provisionFromBizgaze(email, bz);
|
||||
if (u && u.active === 0) return json(res, 403, { error: 'This account has been deactivated' });
|
||||
} else {
|
||||
// No identity provider configured (local/dev/tests): verify the local password.
|
||||
u = (existing && A.verifyPassword(password, existing.pw_salt, existing.pw_hash)) ? existing : null;
|
||||
if (!u) return json(res, existing ? 401 : 404, { error: existing ? 'Incorrect password. Please try again.' : 'This email is not registered.' });
|
||||
}
|
||||
if (!u) return json(res, 401, { error: 'invalid credentials' });
|
||||
|
||||
const tok = A.token();
|
||||
const ttl = remember ? 1000 * 60 * 60 * 24 * 30 : SESSION_TTL; // 30 days if remembered, else 24h
|
||||
@@ -107,16 +122,24 @@ route('GET', '/api/setup-state', async (req, res) => {
|
||||
json(res, 200, { registrationOpen: !anyUser || process.env.ALLOW_REGISTRATION === '1' });
|
||||
});
|
||||
|
||||
// ICE servers for WebRTC. Always includes a public STUN; adds managed TURN if
|
||||
// configured via env (TURN_URLS comma-separated, TURN_USERNAME, TURN_CREDENTIAL).
|
||||
// ICE servers for WebRTC. Always includes a public STUN; adds our TURN relay if
|
||||
// configured. Two credential modes:
|
||||
// - Shared secret (recommended, coturn `use-auth-secret`): set TURN_SECRET and we mint
|
||||
// time-limited credentials per request (no permanent password is ever handed out, so
|
||||
// outsiders can't reuse your relay). Optional TURN_TTL seconds (default 24h).
|
||||
// - Static: set TURN_USERNAME + TURN_CREDENTIAL for a fixed long-term credential.
|
||||
route('GET', '/api/ice', async (req, res) => {
|
||||
const iceServers = [{ urls: 'stun:stun.l.google.com:19302' }];
|
||||
if (process.env.TURN_URLS) {
|
||||
iceServers.push({
|
||||
urls: process.env.TURN_URLS.split(',').map((u) => u.trim()).filter(Boolean),
|
||||
username: process.env.TURN_USERNAME || '',
|
||||
credential: process.env.TURN_CREDENTIAL || '',
|
||||
});
|
||||
const urls = process.env.TURN_URLS.split(',').map((u) => u.trim()).filter(Boolean);
|
||||
let username = process.env.TURN_USERNAME || '';
|
||||
let credential = process.env.TURN_CREDENTIAL || '';
|
||||
if (process.env.TURN_SECRET) {
|
||||
const ttl = parseInt(process.env.TURN_TTL || '86400', 10);
|
||||
username = String(Math.floor(Date.now() / 1000) + ttl); // coturn expects "<expiry>"
|
||||
credential = require('crypto').createHmac('sha1', process.env.TURN_SECRET).update(username).digest('base64');
|
||||
}
|
||||
iceServers.push({ urls, username, credential });
|
||||
}
|
||||
json(res, 200, { iceServers });
|
||||
});
|
||||
@@ -167,6 +190,9 @@ route('POST', '/api/users', async (req, res) => {
|
||||
const u = currentUser(req);
|
||||
if (!u) return json(res, 401, { error: 'unauthorized' });
|
||||
if (u.role !== 'admin') return json(res, 403, { error: 'only admins can add agents' });
|
||||
// With BizGaze as the identity provider, logins are created in BizGaze — not here.
|
||||
// (Creating local accounts is what previously shadowed BizGaze and split tenants.)
|
||||
if (BZ.isEnabled()) return json(res, 400, { error: 'Logins are managed in BizGaze. Add the user in BizGaze; they appear here on first sign-in.' });
|
||||
const { email, password, name, role } = await readBody(req);
|
||||
if (!email || !password) return json(res, 400, { error: 'email and temporary password required' });
|
||||
if (R.users.emailExists(email))
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env node
|
||||
// One-time PRODUCTION migration for "BizGaze-only logins".
|
||||
//
|
||||
// Deletes the in-app (pre-BizGaze) local accounts. Combined with the BizGaze-only login
|
||||
// change, every user then signs in through BizGaze and is provisioned into the same
|
||||
// tenant — which restores the admin's "see all sessions" report.
|
||||
//
|
||||
// A "pre-BizGaze" account = a user with NO 'sso_user_created' audit entry for its email
|
||||
// (i.e. created locally via register/console, not provisioned by a BizGaze login).
|
||||
//
|
||||
// SAFE BY DEFAULT: dry-run unless you pass --apply. BACK UP THE DB FIRST.
|
||||
// Dry run : node scripts/migrate-bizgaze-only.js
|
||||
// Apply : node scripts/migrate-bizgaze-only.js --apply
|
||||
// Honors DB_PATH (same env var the server uses).
|
||||
|
||||
const db = require('../db');
|
||||
const APPLY = process.argv.includes('--apply');
|
||||
|
||||
const tableExists = (name) => !!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(name);
|
||||
|
||||
const ssoEmails = new Set(
|
||||
db.prepare("SELECT DISTINCT lower(user_email) AS e FROM audit_log WHERE action='sso_user_created' AND user_email IS NOT NULL")
|
||||
.all().map((r) => r.e),
|
||||
);
|
||||
const users = db.prepare('SELECT id,email,name,role,team_id,active FROM users').all();
|
||||
const keep = users.filter((u) => ssoEmails.has(String(u.email).toLowerCase()));
|
||||
const remove = users.filter((u) => !ssoEmails.has(String(u.email).toLowerCase()));
|
||||
|
||||
console.log('=== Teams ===');
|
||||
for (const t of db.prepare('SELECT id,name FROM teams').all()) {
|
||||
const uc = db.prepare('SELECT COUNT(*) AS c FROM users WHERE team_id=?').get(t.id).c;
|
||||
console.log(` ${t.id} ${t.name} (${uc} users)`);
|
||||
}
|
||||
console.log('\n=== Users ===');
|
||||
console.log(` total: ${users.length} | BizGaze-provisioned (keep): ${keep.length} | local pre-BizGaze (delete): ${remove.length}`);
|
||||
console.log('\n KEEP (already BizGaze-provisioned):');
|
||||
keep.forEach((u) => console.log(` ${u.email} [${u.role}] team ${u.team_id}`));
|
||||
console.log('\n DELETE (local / pre-BizGaze):');
|
||||
remove.forEach((u) => console.log(` ${u.email} [${u.role}] team ${u.team_id}`));
|
||||
|
||||
if (!remove.length) { console.log('\nNothing to delete. Done.'); process.exit(0); }
|
||||
|
||||
if (!APPLY) {
|
||||
console.log('\nDRY RUN — no changes made. Re-run with --apply to delete the local accounts above.');
|
||||
console.log('After deletion, those users sign in via BizGaze and are recreated automatically.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const delAuth = db.prepare('DELETE FROM sessions_auth WHERE user_id=?');
|
||||
const delRefresh = tableExists('refresh_tokens') ? db.prepare('DELETE FROM refresh_tokens WHERE user_id=?') : null;
|
||||
const delUser = db.prepare('DELETE FROM users WHERE id=?');
|
||||
let deleted = 0;
|
||||
db.exec('BEGIN');
|
||||
try {
|
||||
for (const u of remove) {
|
||||
delAuth.run(u.id); // clear active sessions (FK) — also logs them out
|
||||
if (delRefresh) delRefresh.run(u.id);
|
||||
delUser.run(u.id);
|
||||
deleted++;
|
||||
}
|
||||
db.exec('COMMIT');
|
||||
} catch (e) {
|
||||
db.exec('ROLLBACK');
|
||||
console.error('FAILED — rolled back, no changes applied:', e.message);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`\nDONE. Deleted ${deleted} local account(s). They are recreated via BizGaze on next sign-in.`);
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren