Ubuntu 24.04 WebRTC lxc Intro Basics

From CompleteNoobs
Jump to navigation Jump to search
Please Select a Licence from the LICENCE_HEADERS page
And place at top of your page
If no Licence is Selected/Appended, Default will be CC0

Default Licence IF there is no Licence placed below this notice! When you edit this page, you agree to release your contribution under the CC0 Licence

LICENCE: More information about the cc0 licence can be found here:
https://creativecommons.org/share-your-work/public-domain/cc0

The person who associated a work with this deed has dedicated the work to the public domain by waiving all of his or her rights to the work worldwide under copyright law, including all related and neighboring rights, to the extent allowed by law.

You can copy, modify, distribute and perform the work, even for commercial purposes, all without asking permission.

Licence:

Statement of Purpose

The laws of most jurisdictions throughout the world automatically confer exclusive Copyright and Related Rights (defined below) upon the creator and subsequent owner(s) (each and all, an "owner") of an original work of authorship and/or a database (each, a "Work").

Certain owners wish to permanently relinquish those rights to a Work for the purpose of contributing to a commons of creative, cultural and scientific works ("Commons") that the public can reliably and without fear of later claims of infringement build upon, modify, incorporate in other works, reuse and redistribute as freely as possible in any form whatsoever and for any purposes, including without limitation commercial purposes. These owners may contribute to the Commons to promote the ideal of a free culture and the further production of creative, cultural and scientific works, or to gain reputation or greater distribution for their Work in part through the use and efforts of others.

For these and/or other purposes and motivations, and without any expectation of additional consideration or compensation, the person associating CC0 with a Work (the "Affirmer"), to the extent that he or she is an owner of Copyright and Related Rights in the Work, voluntarily elects to apply CC0 to the Work and publicly distribute the Work under its terms, with knowledge of his or her Copyright and Related Rights in the Work and the meaning and intended legal effect of CC0 on those rights.

1. Copyright and Related Rights. A Work made available under CC0 may be protected by copyright and related or neighboring rights ("Copyright and Related Rights"). Copyright and Related Rights include, but are not limited to, the following:

   the right to reproduce, adapt, distribute, perform, display, communicate, and translate a Work;
   moral rights retained by the original author(s) and/or performer(s);
   publicity and privacy rights pertaining to a person's image or likeness depicted in a Work;
   rights protecting against unfair competition in regards to a Work, subject to the limitations in paragraph 4(a), below;
   rights protecting the extraction, dissemination, use and reuse of data in a Work;
   database rights (such as those arising under Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, and under any national implementation thereof, including any amended or successor version of such directive); and
   other similar, equivalent or corresponding rights throughout the world based on applicable law or treaty, and any national implementations thereof.

2. Waiver. To the greatest extent permitted by, but not in contravention of, applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and unconditionally waives, abandons, and surrenders all of Affirmer's Copyright and Related Rights and associated claims and causes of action, whether now known or unknown (including existing as well as future claims and causes of action), in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each member of the public at large and to the detriment of Affirmer's heirs and successors, fully intending that such Waiver shall not be subject to revocation, rescission, cancellation, termination, or any other legal or equitable action to disrupt the quiet enjoyment of the Work by the public as contemplated by Affirmer's express Statement of Purpose.

3. Public License Fallback. Should any part of the Waiver for any reason be judged legally invalid or ineffective under applicable law, then the Waiver shall be preserved to the maximum extent permitted taking into account Affirmer's express Statement of Purpose. In addition, to the extent the Waiver is so judged Affirmer hereby grants to each affected person a royalty-free, non transferable, non sublicensable, non exclusive, irrevocable and unconditional license to exercise Affirmer's Copyright and Related Rights in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the "License"). The License shall be deemed effective as of the date CC0 was applied by Affirmer to the Work. Should any part of the License for any reason be judged legally invalid or ineffective under applicable law, such partial invalidity or ineffectiveness shall not invalidate the remainder of the License, and in such case Affirmer hereby affirms that he or she will not (i) exercise any of his or her remaining Copyright and Related Rights in the Work or (ii) assert any associated claims and causes of action with respect to the Work, in either case contrary to Affirmer's express Statement of Purpose.

4. Limitations and Disclaimers.

   No trademark or patent rights held by Affirmer are waived, abandoned, surrendered, licensed or otherwise affected by this document.
   Affirmer offers the Work as-is and makes no representations or warranties of any kind concerning the Work, express, implied, statutory or otherwise, including without limitation warranties of title, merchantability, fitness for a particular purpose, non infringement, or the absence of latent or other defects, accuracy, or the present or absence of errors, whether or not discoverable, all to the greatest extent permissible under applicable law.
   Affirmer disclaims responsibility for clearing rights of other persons that may apply to the Work or any use thereof, including without limitation any person's Copyright and Related Rights in the Work. Further, Affirmer disclaims responsibility for obtaining any necessary consents, permissions or other rights required for any use of the Work.
   Affirmer understands and acknowledges that Creative Commons is not a party to this document and has no duty or obligation with respect to this CC0 or use of the Work.

WebRTC Video Chat on Ubuntu 24.04 with LXC — Complete Beginner's Guide

  • This walk through is tested on a ubuntu-mate 24.04 host.
    • Guide written with help from claude.ai for Ubuntu 24.04 LTS (Noble Numbat). Node.js 20 LTS. Last verified March 2026


This guide walks you through setting up a working browser-based WebRTC video/audio chat application. The signalling server will run inside an LXC container on your Ubuntu 24.04 host machine, and you will connect to it from your browser.


What You Will End Up With

  • An LXC container running Ubuntu 24.04
  • A Node.js signalling server inside that container
  • A static HTML/JS frontend served from the container
  • Two browser tabs (or two devices on your network) that can video/audio chat peer-to-peer


How It Works (Plain English)

WebRTC lets two browsers talk directly to each other for audio and video. But before they can do that, they need to find each other and agree on connection details. This is called signalling.

Think of it like two people wanting to call each other — they first need to exchange phone numbers through a third party. The signalling server is that third party. Once the call is set up, the signalling server steps aside and the browsers talk directly.

[Browser A] <--WebSocket--> [Signalling Server in LXC] <--WebSocket--> [Browser B]
                                       |
                            (only used during setup)
                                       |
[Browser A] <============ WebRTC peer-to-peer audio/video ============> [Browser B]

Part 1: Set Up the LXC Container

Step 1.1 — Install LXC on Your Host

Open a terminal on your Ubuntu 24.04 host and run:

  • NOTE: you can also install lxd with snap packages on ubuntu.
sudo apt update
sudo apt install lxc lxc-utils -y

Check it installed correctly:

lxc-checkconfig

You should see mostly enabled next to each item. A few missing items are normal and will not affect this guide.

Step 1.2 — Create the Container

Create a new Ubuntu 24.04 container called webrtc:

sudo lxc-create -n webrtc -t download -- -d ubuntu -r noble -a amd64

This downloads the Ubuntu 24.04 (Noble) image. It may take a minute or two.

Step 1.3 — Start the Container

sudo lxc-start -n webrtc

Check it is running:

sudo lxc-ls --fancy

You should see webrtc listed with state RUNNING.

Step 1.4 — Log Into the Container

sudo lxc-attach -n webrtc

Your prompt will change — you are now inside the container. Everything from here until told otherwise runs inside the container.

Step 1.5 — Update the Container

apt update && apt upgrade -y

Part 2: Install Node.js Inside the Container

Step 2.1 — Install Node.js

We will use the official NodeSource repository to get a recent version of Node.js:

apt install -y curl
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
apt install -y nodejs

Check the versions installed:

node -v
npm -v

You should see something like v20.x.x and 10.x.x. Any version of Node 18 or higher is fine.


Part 3: Create the Signalling Server

Step 3.1 — Create a Project Folder

mkdir /opt/webrtc
cd /opt/webrtc

Step 3.2 — Initialise the Node Project

npm init -y

This creates a package.json file. The -y flag just accepts all the defaults.

Step 3.3 — Install Dependencies

We need two packages:

  • express — a simple web server to serve the HTML frontend
  • socket.io — handles WebSocket connections for signalling
npm install express socket.io

Step 3.4 — Create the Server File

  • NOTE: nano will need to be installed in the container with apt install nano -y

Create the file:

nano server.js

Paste in the following code exactly. Use Ctrl+Shift+V to paste in the terminal:

const express = require('express');
const http = require('http');
const { Server } = require('socket.io');
const path = require('path');

const app = express();
const server = http.createServer(app);
const io = new Server(server);

// Serve the frontend HTML file
app.use(express.static(path.join(__dirname, 'public')));

// Keep track of who is in each room
const rooms = {};

io.on('connection', (socket) => {
  console.log('A user connected:', socket.id);

  // User wants to join a room
  socket.on('join', (room) => {
    socket.join(room);

    if (!rooms[room]) {
      rooms[room] = [];
    }

    // Tell the new user who is already in the room
    socket.emit('room-users', rooms[room]);

    // Add this user to the room list
    rooms[room].push(socket.id);

    console.log(`${socket.id} joined room: ${room}`);
  });

  // Relay a WebRTC offer to a specific user
  socket.on('offer', ({ to, offer }) => {
    io.to(to).emit('offer', { from: socket.id, offer });
  });

  // Relay a WebRTC answer to a specific user
  socket.on('answer', ({ to, answer }) => {
    io.to(to).emit('answer', { from: socket.id, answer });
  });

  // Relay ICE candidates between peers
  socket.on('ice-candidate', ({ to, candidate }) => {
    io.to(to).emit('ice-candidate', { from: socket.id, candidate });
  });

  // Clean up when a user disconnects
  socket.on('disconnect', () => {
    for (const room in rooms) {
      rooms[room] = rooms[room].filter((id) => id !== socket.id);
      // Tell others in the room this user left
      socket.to(room).emit('user-left', socket.id);
    }
    console.log('User disconnected:', socket.id);
  });
});

const PORT = 3000;
server.listen(PORT, '0.0.0.0', () => {
  console.log(`Signalling server running on port ${PORT}`);
});

Save and exit: press Ctrl+X, then Y, then Enter.

Step 3.5 — Create the Frontend

Create a folder for the HTML file:

mkdir public
nano public/index.html

Paste in the following:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
  <title>WebRTC Chat</title>
  <style>
    * { box-sizing: border-box; margin: 0; padding: 0; }
    body { font-family: sans-serif; background: #1a1a2e; color: #eee; display: flex; flex-direction: column; align-items: center; padding: 20px; }
    h1 { margin-bottom: 20px; color: #e94560; }
    #join-area { margin-bottom: 20px; display: flex; gap: 10px; }
    input { padding: 10px; border-radius: 6px; border: none; font-size: 1rem; width: 200px; }
    button { padding: 10px 20px; background: #e94560; color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 1rem; }
    button:hover { background: #c73652; }
    #videos { display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; }
    video { width: 320px; height: 240px; background: #000; border-radius: 8px; border: 2px solid #e94560; }
    #status { margin-top: 15px; font-size: 0.9rem; color: #aaa; }
  </style>
</head>
<body>
  <h1>WebRTC Chat</h1>
  <div id="join-area">
    <input id="room-input" type="text" placeholder="Enter room name" value="room1" />
    <button onclick="joinRoom()">Join Room</button>
  </div>
  <div id="videos">
    <video id="local-video" autoplay muted playsinline></video>
  </div>
  <p id="status">Enter a room name and click Join.</p>

  <!-- Socket.io client is served automatically by the server -->
  <script src="/socket.io/socket.io.js"></script>
  <script>
    const socket = io();
    let localStream;
    const peers = {}; // peerId -> RTCPeerConnection

    // STUN server config — Google's free public STUN server
    const iceConfig = {
      iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
    };

    async function joinRoom() {
      const room = document.getElementById('room-input').value.trim();
      if (!room) return alert('Please enter a room name');

      document.getElementById('status').textContent = 'Getting camera and microphone...';

      try {
        localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
        document.getElementById('local-video').srcObject = localStream;
      } catch (err) {
        alert('Could not access camera/microphone: ' + err.message);
        return;
      }

      document.getElementById('status').textContent = `Joining room: ${room}`;
      socket.emit('join', room);
    }

    // Server tells us who is already in the room
    socket.on('room-users', async (users) => {
      document.getElementById('status').textContent = `In room. ${users.length} other(s) here.`;
      // Send an offer to each existing user
      for (const userId of users) {
        await createOffer(userId);
      }
    });

    // A remote peer sent us an offer
    socket.on('offer', async ({ from, offer }) => {
      const pc = createPeerConnection(from);
      await pc.setRemoteDescription(new RTCSessionDescription(offer));
      const answer = await pc.createAnswer();
      await pc.setLocalDescription(answer);
      socket.emit('answer', { to: from, answer });
    });

    // A remote peer accepted our offer
    socket.on('answer', async ({ from, answer }) => {
      const pc = peers[from];
      if (pc) await pc.setRemoteDescription(new RTCSessionDescription(answer));
    });

    // ICE candidate from a remote peer
    socket.on('ice-candidate', async ({ from, candidate }) => {
      const pc = peers[from];
      if (pc && candidate) {
        try { await pc.addIceCandidate(new RTCIceCandidate(candidate)); } catch (e) {}
      }
    });

    // A user left the room
    socket.on('user-left', (userId) => {
      if (peers[userId]) {
        peers[userId].close();
        delete peers[userId];
      }
      const el = document.getElementById('video-' + userId);
      if (el) el.remove();
      document.getElementById('status').textContent = 'A user left the room.';
    });

    function createPeerConnection(peerId) {
      const pc = new RTCPeerConnection(iceConfig);
      peers[peerId] = pc;

      // Add our local tracks so the remote peer gets our video/audio
      localStream.getTracks().forEach((track) => pc.addTrack(track, localStream));

      // When we get an ICE candidate, send it to the remote peer
      pc.onicecandidate = ({ candidate }) => {
        if (candidate) socket.emit('ice-candidate', { to: peerId, candidate });
      };

      // When we receive a remote track, display it
      pc.ontrack = ({ streams }) => {
        let videoEl = document.getElementById('video-' + peerId);
        if (!videoEl) {
          videoEl = document.createElement('video');
          videoEl.id = 'video-' + peerId;
          videoEl.autoplay = true;
          videoEl.playsInline = true;
          document.getElementById('videos').appendChild(videoEl);
        }
        videoEl.srcObject = streams[0];
      };

      return pc;
    }

    async function createOffer(peerId) {
      const pc = createPeerConnection(peerId);
      const offer = await pc.createOffer();
      await pc.setLocalDescription(offer);
      socket.emit('offer', { to: peerId, offer });
    }
  </script>
</body>
</html>

Save and exit: Ctrl+X, Y, Enter.


Part 4: Run the Server

Step 4.1 — Test it Manually First

Inside the container, run:

node /opt/webrtc/server.js

You should see:

Signalling server running on port 3000

Press Ctrl+C to stop it for now. We will set it up to run automatically in the next step.

Step 4.2 — Find the Container's IP Address

You need this to access the server from your host browser. Open a second terminal on your host (not inside the container) and run:

sudo lxc-ls --fancy

Look for your webrtc container and note the IP address in the IPV4 column. It will look something like 10.0.3.15.

Step 4.3 — Set Up Auto-start with systemd

We want the server to start automatically when the container boots. Back inside the container, create a systemd service file:

nano /etc/systemd/system/webrtc.service

Paste in:

[Unit]
Description=WebRTC Signalling Server
After=network.target

[Service]
Type=simple
WorkingDirectory=/opt/webrtc
ExecStart=/usr/bin/node /opt/webrtc/server.js
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target

Save and exit. Then enable and start it:

systemctl daemon-reload
systemctl enable webrtc
systemctl start webrtc

Check it is running:

systemctl status webrtc

You should see active (running) in green.


Part 5: Configure the LXC Container to Auto-start

We also want the container itself to start when your host boots.

Exit the container first (type exit or press Ctrl+D to return to your host terminal), then run:

sudo nano /var/lib/lxc/webrtc/config

Add these two lines at the bottom of the file:

lxc.start.auto = 1
lxc.start.delay = 5

Save and exit. Now the container (and the signalling server inside it) will start automatically on boot.


Part 6: Open the App in Your Browser

Step 6.1 — Open the App

On your host machine, open a browser and go to:

http://<container-ip>:3000

Replace <container-ip> with the IP address you noted in Step 4.2. For example:

http://10.0.3.15:3000

You should see the WebRTC Chat page.

Step 6.2 — Test With Two Browser Tabs

  1. Open the app in Tab 1. Type a room name (e.g. room1) and click Join Room. Allow camera and microphone access when the browser asks.
  2. Open the app in Tab 2 (you can use a private/incognito window to get a separate camera stream). Type the same room name and click Join Room.

The two tabs should now connect and you will see two video feeds.

NOTE:If you see Could not access camera/microphone: Cannot read properties of undefined (reading 'getUserMedia')
  • NOTE: if you see Could not access camera/microphone: Cannot read properties of undefined (reading 'getUserMedia') Then you need to go to step 7 and setup HTTPS

This is the HTTPS issue mentioned in Part 7. Browsers block camera/microphone access on plain http:// from non-localhost addresses — navigator.mediaDevices is simply undefined on insecure origins.

Since you're accessing via the container's IP (e.g. http://10.0.3.15:3000), the browser sees it as insecure and disables the API entirely.

Quickest fix — use mkcert on your host:While you choose — here's a quick workaround you can do right now with zero config, if you just want to test immediately:

Option: Chrome flag to allow insecure origins

Open Chrome and go to: chrome://flags/#unsafely-treat-insecure-origin-as-secure

Paste your container URL (e.g. http://10.0.3.15:3000) into the text box and enable it

Relaunch Chrome when prompted

This tells Chrome to treat that specific IP as secure, enabling getUserMedia. Only use this for local dev/testing — never on a production machine. Firefox equivalent: go to about:config, search for media.devices.insecure.enabled and set it to true. Once you pick your preferred HTTPS method above I'll give you the exact step-by-step commands to do it properly inside your LXC container.


Step 6.3 — Test From Another Device on Your Network

On any other device connected to the same Wi-Fi or LAN, open a browser and go to the same URL:

http://<container-ip>:3000

Join the same room name and you should connect peer-to-peer with the other browser.

Note: Camera and microphone access in browsers requires either localhost or a secure HTTPS connection. For testing on the same machine, http://localhost works fine. For other devices on your network, you may need to set up HTTPS (see Part 7).


Part 7: (Optional) Enable HTTPS for Other Devices

Browsers block camera/microphone access on plain HTTP from non-localhost addresses. To use this from other devices on your network, you have two options:

Option A — Use a Self-Signed Certificate (Quick)

Inside the container:

apt install -y openssl
cd /opt/webrtc
openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 365 -nodes -subj "/CN=localhost"

Edit server.js and replace the http section:

const https = require('https');
const fs = require('fs');

const server = https.createServer({
  key: fs.readFileSync('/opt/webrtc/key.pem'),
  cert: fs.readFileSync('/opt/webrtc/cert.pem'),
}, app);

Appended server.js file:

const express = require('express');
const https = require('https');
const { Server } = require('socket.io');
const path = require('path');

const app = express();
const fs = require('fs');

const server = https.createServer({
  key: fs.readFileSync('/opt/webrtc/key.pem'),
  cert: fs.readFileSync('/opt/webrtc/cert.pem'),
}, app);
const io = new Server(server);

// Serve the frontend HTML file
app.use(express.static(path.join(__dirname, 'public')));

// Keep track of who is in each room
const rooms = {};

io.on('connection', (socket) => {
  console.log('A user connected:', socket.id);

  // User wants to join a room
  socket.on('join', (room) => {
    socket.join(room);

    if (!rooms[room]) {
      rooms[room] = [];
    }

    // Tell the new user who is already in the room
    socket.emit('room-users', rooms[room]);

    // Add this user to the room list
    rooms[room].push(socket.id);

    console.log(`${socket.id} joined room: ${room}`);
  });

  // Relay a WebRTC offer to a specific user
  socket.on('offer', ({ to, offer }) => {
    io.to(to).emit('offer', { from: socket.id, offer });
  });

  // Relay a WebRTC answer to a specific user
  socket.on('answer', ({ to, answer }) => {
    io.to(to).emit('answer', { from: socket.id, answer });
  });

  // Relay ICE candidates between peers
  socket.on('ice-candidate', ({ to, candidate }) => {
    io.to(to).emit('ice-candidate', { from: socket.id, candidate });
  });

  // Clean up when a user disconnects
  socket.on('disconnect', () => {
    for (const room in rooms) {
      rooms[room] = rooms[room].filter((id) => id !== socket.id);
      // Tell others in the room this user left
      socket.to(room).emit('user-left', socket.id);
    }
    console.log('User disconnected:', socket.id);
  });
});

const PORT = 3000;
server.listen(PORT, '0.0.0.0', () => {
  console.log(`Signalling server running on port ${PORT}`);
});

Change const http = require('http'); and const server = http.createServer(app); lines accordingly.

Restart the service:

systemctl restart webrtc

Access via:

https://<container-ip>:3000

Your browser will warn about the self-signed certificate — click Advanced and Proceed to continue.

Option B — Use mkcert (Trusted Certificate, Recommended)

mkcert creates locally trusted certificates without browser warnings.

On your host machine:

sudo apt install mkcert
mkcert -install
mkcert <container-ip>

This creates <container-ip>.pem and <container-ip>-key.pem. Copy them into the container and follow the same server.js edits as Option A.


Part 8: Useful Commands Reference

Managing the Container

Command What it does
sudo lxc-start -n webrtc Start the container
sudo lxc-stop -n webrtc Stop the container
sudo lxc-attach -n webrtc Open a shell inside the container
sudo lxc-ls --fancy List containers and their status/IP

Managing the Server (run inside the container)

Command What it does
systemctl status webrtc Check if the server is running
systemctl restart webrtc Restart the server
systemctl stop webrtc Stop the server
journalctl -u webrtc -f Watch live server logs

Adding a ChatBox

  • Added:
    • A name input on the join screen so people aren't just "Anonymous"
    • A chat panel on the right with message bubbles (your messages on the right, others on the left)
    • Automatic link detection — any URL typed becomes a clickable link
    • Enter to send, Shift+Enter for a newline
    • A 500 character counter that turns red when you're close to the limit
    • System messages when users join/leave
    • A live peer count header showing how many others are in the room
    • The textarea auto-resizes as you type


  • Edit public/index.html

Appended public/index.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
  <title>WebRTC Chat</title>
  <link rel="preconnect" href="https://fonts.googleapis.com">
  <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;600&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet">
  <style>
    :root {
      --bg:       #0d0f14;
      --surface:  #161920;
      --border:   #252932;
      --accent:   #4ade80;
      --accent2:  #22d3ee;
      --muted:    #4b5263;
      --text:     #e2e8f0;
      --subtext:  #8892a4;
      --self-msg: #1a2e22;
      --other-msg:#161d2e;
      --danger:   #f87171;
    }

    * { box-sizing: border-box; margin: 0; padding: 0; }

    body {
      font-family: 'IBM Plex Sans', sans-serif;
      background: var(--bg);
      color: var(--text);
      height: 100dvh;
      display: flex;
      flex-direction: column;
      overflow: hidden;
    }

    /* ── Top bar ── */
    header {
      display: flex;
      align-items: center;
      gap: 12px;
      padding: 12px 20px;
      border-bottom: 1px solid var(--border);
      background: var(--surface);
      flex-shrink: 0;
    }
    header h1 {
      font-family: 'IBM Plex Mono', monospace;
      font-size: 1rem;
      color: var(--accent);
      letter-spacing: 0.05em;
    }
    #room-badge {
      font-family: 'IBM Plex Mono', monospace;
      font-size: 0.75rem;
      color: var(--subtext);
      background: var(--border);
      padding: 2px 10px;
      border-radius: 999px;
      display: none;
    }
    #status-dot {
      width: 8px; height: 8px;
      border-radius: 50%;
      background: var(--muted);
      margin-left: auto;
      flex-shrink: 0;
      transition: background 0.3s;
    }
    #status-dot.live { background: var(--accent); box-shadow: 0 0 6px var(--accent); }

    /* ── Main layout ── */
    main {
      display: flex;
      flex: 1;
      overflow: hidden;
    }

    /* ── Join screen ── */
    #join-screen {
      display: flex;
      flex-direction: column;
      align-items: center;
      justify-content: center;
      gap: 16px;
      flex: 1;
      padding: 40px 20px;
    }
    #join-screen h2 {
      font-family: 'IBM Plex Mono', monospace;
      font-size: 1.4rem;
      color: var(--accent);
    }
    #join-screen p { color: var(--subtext); font-size: 0.9rem; text-align: center; max-width: 340px; }
    .field-group {
      display: flex;
      flex-direction: column;
      gap: 8px;
      width: 100%;
      max-width: 320px;
    }
    .field-group label { font-size: 0.8rem; color: var(--subtext); font-family: 'IBM Plex Mono', monospace; }
    input[type="text"] {
      padding: 10px 14px;
      background: var(--surface);
      border: 1px solid var(--border);
      border-radius: 6px;
      color: var(--text);
      font-family: 'IBM Plex Sans', sans-serif;
      font-size: 0.95rem;
      width: 100%;
      transition: border-color 0.2s;
      outline: none;
    }
    input[type="text"]:focus { border-color: var(--accent); }
    button.primary {
      padding: 11px 24px;
      background: var(--accent);
      color: #0d0f14;
      border: none;
      border-radius: 6px;
      cursor: pointer;
      font-family: 'IBM Plex Mono', monospace;
      font-weight: 600;
      font-size: 0.9rem;
      width: 100%;
      max-width: 320px;
      transition: opacity 0.2s, transform 0.1s;
    }
    button.primary:hover { opacity: 0.85; }
    button.primary:active { transform: scale(0.98); }

    /* ── App layout (after join) ── */
    #app { display: none; flex: 1; overflow: hidden; }
    #app.visible { display: flex; }

    /* ── Video panel ── */
    #video-panel {
      display: flex;
      flex-direction: column;
      gap: 0;
      background: #0a0c10;
      border-right: 1px solid var(--border);
      overflow-y: auto;
      flex-shrink: 0;
      width: 340px;
    }
    @media (max-width: 700px) {
      #app.visible { flex-direction: column; }
      #video-panel { width: 100%; max-height: 220px; flex-direction: row; overflow-x: auto; overflow-y: hidden; border-right: none; border-bottom: 1px solid var(--border); }
    }
    .video-wrapper {
      position: relative;
      background: #000;
    }
    .video-wrapper video {
      width: 100%;
      display: block;
      aspect-ratio: 4/3;
      object-fit: cover;
    }
    .video-label {
      position: absolute;
      bottom: 6px; left: 8px;
      font-family: 'IBM Plex Mono', monospace;
      font-size: 0.7rem;
      color: #fff;
      background: rgba(0,0,0,0.55);
      padding: 2px 7px;
      border-radius: 4px;
    }

    /* ── Chat panel ── */
    #chat-panel {
      display: flex;
      flex-direction: column;
      flex: 1;
      overflow: hidden;
    }

    #chat-header {
      padding: 10px 16px;
      border-bottom: 1px solid var(--border);
      font-family: 'IBM Plex Mono', monospace;
      font-size: 0.78rem;
      color: var(--subtext);
      background: var(--surface);
      flex-shrink: 0;
    }

    #messages {
      flex: 1;
      overflow-y: auto;
      padding: 16px;
      display: flex;
      flex-direction: column;
      gap: 10px;
      scroll-behavior: smooth;
    }
    #messages::-webkit-scrollbar { width: 4px; }
    #messages::-webkit-scrollbar-track { background: transparent; }
    #messages::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }

    /* System messages */
    .msg-system {
      text-align: center;
      font-size: 0.75rem;
      color: var(--muted);
      font-family: 'IBM Plex Mono', monospace;
      padding: 2px 0;
    }

    /* Chat bubbles */
    .msg-bubble {
      display: flex;
      flex-direction: column;
      gap: 3px;
      max-width: 85%;
    }
    .msg-bubble.self { align-self: flex-end; align-items: flex-end; }
    .msg-bubble.other { align-self: flex-start; align-items: flex-start; }

    .msg-meta {
      font-size: 0.7rem;
      color: var(--subtext);
      font-family: 'IBM Plex Mono', monospace;
      padding: 0 4px;
    }
    .msg-bubble.self .msg-meta { color: var(--accent); }

    .msg-text {
      padding: 9px 13px;
      border-radius: 12px;
      font-size: 0.9rem;
      line-height: 1.5;
      word-break: break-word;
    }
    .msg-bubble.self .msg-text {
      background: var(--self-msg);
      border: 1px solid rgba(74,222,128,0.2);
      border-bottom-right-radius: 3px;
    }
    .msg-bubble.other .msg-text {
      background: var(--other-msg);
      border: 1px solid var(--border);
      border-bottom-left-radius: 3px;
    }

    /* Links inside messages */
    .msg-text a {
      color: var(--accent2);
      text-decoration: underline;
      text-underline-offset: 2px;
      word-break: break-all;
    }
    .msg-text a:hover { opacity: 0.8; }

    /* ── Input bar ── */
    #input-bar {
      display: flex;
      gap: 8px;
      padding: 12px 16px;
      border-top: 1px solid var(--border);
      background: var(--surface);
      flex-shrink: 0;
    }
    #msg-input {
      flex: 1;
      padding: 10px 14px;
      background: var(--bg);
      border: 1px solid var(--border);
      border-radius: 8px;
      color: var(--text);
      font-family: 'IBM Plex Sans', sans-serif;
      font-size: 0.9rem;
      outline: none;
      resize: none;
      height: 42px;
      max-height: 120px;
      overflow-y: auto;
      transition: border-color 0.2s;
    }
    #msg-input:focus { border-color: var(--accent); }
    #send-btn {
      padding: 0 16px;
      background: var(--accent);
      color: #0d0f14;
      border: none;
      border-radius: 8px;
      cursor: pointer;
      font-family: 'IBM Plex Mono', monospace;
      font-weight: 600;
      font-size: 0.85rem;
      flex-shrink: 0;
      transition: opacity 0.2s;
    }
    #send-btn:hover { opacity: 0.85; }
    #char-count {
      font-size: 0.7rem;
      color: var(--muted);
      font-family: 'IBM Plex Mono', monospace;
      align-self: flex-end;
      padding-bottom: 4px;
      flex-shrink: 0;
      width: 36px;
      text-align: right;
    }
    #char-count.warn { color: var(--danger); }
  </style>
</head>
<body>

<header>
  <h1>// webrtc</h1>
  <span id="room-badge"></span>
  <div id="status-dot" title="Connecting..."></div>
</header>

<!-- Join screen -->
<div id="join-screen">
  <h2>join a room</h2>
  <p>Enter your name and a room name. Anyone with the same room name will connect with you.</p>
  <div class="field-group">
    <label>YOUR NAME</label>
    <input id="name-input" type="text" placeholder="e.g. Alice" maxlength="24" />
  </div>
  <div class="field-group">
    <label>ROOM NAME</label>
    <input id="room-input" type="text" placeholder="e.g. room1" value="room1" maxlength="32" />
  </div>
  <button class="primary" onclick="joinRoom()">Join Room →</button>
</div>

<!-- App (shown after joining) -->
<main>
  <div id="app">

    <!-- Left: videos -->
    <div id="video-panel">
      <div class="video-wrapper" id="local-wrapper">
        <video id="local-video" autoplay muted playsinline></video>
        <div class="video-label" id="local-label">you</div>
      </div>
    </div>

    <!-- Right: chat -->
    <div id="chat-panel">
      <div id="chat-header">MESSAGES — <span id="peer-count">0 others in room</span></div>
      <div id="messages">
        <div class="msg-system">Chat is end-to-end via your server. Video is peer-to-peer.</div>
      </div>
      <div id="input-bar">
        <textarea id="msg-input" placeholder="Type a message… (Enter to send, Shift+Enter for newline)" rows="1"></textarea>
        <span id="char-count">500</span>
        <button id="send-btn" onclick="sendMessage()">Send</button>
      </div>
    </div>

  </div>
</main>

<script src="/socket.io/socket.io.js"></script>
<script>
  const socket = io();
  let localStream;
  let myName = 'Anonymous';
  let currentRoom = '';
  let peerCount = 0;
  const peers = {};
  const MAX_MSG = 500;

  const iceConfig = {
    iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
  };

  // ── Helpers ──────────────────────────────────────────────

  // Detect URLs in text and wrap them in <a> tags
  function linkify(text) {
    const escaped = text
      .replace(/&/g, '&')
      .replace(/</g, '<')
      .replace(/>/g, '>');
    const urlRegex = /(https?:\/\/[^\s<>"]+)/g;
    return escaped.replace(urlRegex, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>');
  }

  function addMessage({ name, message, isSelf, isSystem }) {
    const container = document.getElementById('messages');

    if (isSystem) {
      const el = document.createElement('div');
      el.className = 'msg-system';
      el.textContent = message;
      container.appendChild(el);
    } else {
      const bubble = document.createElement('div');
      bubble.className = 'msg-bubble ' + (isSelf ? 'self' : 'other');

      const now = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
      const meta = document.createElement('div');
      meta.className = 'msg-meta';
      meta.textContent = (isSelf ? 'you' : name) + ' · ' + now;

      const body = document.createElement('div');
      body.className = 'msg-text';
      // Linkify and preserve newlines
      body.innerHTML = linkify(message).replace(/\n/g, '<br>');

      bubble.appendChild(meta);
      bubble.appendChild(body);
      container.appendChild(bubble);
    }

    // Auto-scroll to bottom
    container.scrollTop = container.scrollHeight;
  }

  function updatePeerCount() {
    document.getElementById('peer-count').textContent =
      peerCount === 0 ? 'no others in room yet'
      : peerCount === 1 ? '1 other in room'
      : `${peerCount} others in room`;
  }

  // ── Join ─────────────────────────────────────────────────

  async function joinRoom() {
    const nameVal = document.getElementById('name-input').value.trim();
    const roomVal = document.getElementById('room-input').value.trim();

    if (!roomVal) { alert('Please enter a room name'); return; }
    myName = nameVal || 'Anonymous';
    currentRoom = roomVal;

    try {
      localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
      document.getElementById('local-video').srcObject = localStream;
      document.getElementById('local-label').textContent = myName + ' (you)';
    } catch (err) {
      alert('Could not access camera/microphone: ' + err.message);
      return;
    }

    document.getElementById('join-screen').style.display = 'none';
    document.getElementById('app').classList.add('visible');
    document.getElementById('room-badge').textContent = '# ' + currentRoom;
    document.getElementById('room-badge').style.display = '';
    document.getElementById('status-dot').classList.add('live');

    socket.emit('join', currentRoom);
    addMessage({ isSystem: true, message: `You joined #${currentRoom} as "${myName}"` });
    updatePeerCount();
  }

  // ── Chat ─────────────────────────────────────────────────

  function sendMessage() {
    const input = document.getElementById('msg-input');
    const text = input.value.trim();
    if (!text || !currentRoom) return;
    if (text.length > MAX_MSG) { alert(`Max ${MAX_MSG} characters`); return; }

    socket.emit('chat-message', { room: currentRoom, message: text, name: myName });
    addMessage({ name: myName, message: text, isSelf: true });

    input.value = '';
    input.style.height = '42px';
    document.getElementById('char-count').textContent = MAX_MSG;
    document.getElementById('char-count').classList.remove('warn');
  }

  socket.on('chat-message', ({ name, message }) => {
    addMessage({ name, message, isSelf: false });
  });

  // Enter to send, Shift+Enter for newline
  document.getElementById('msg-input').addEventListener('keydown', (e) => {
    if (e.key === 'Enter' && !e.shiftKey) {
      e.preventDefault();
      sendMessage();
    }
  });

  // Char counter + auto-resize textarea
  document.getElementById('msg-input').addEventListener('input', function () {
    const remaining = MAX_MSG - this.value.length;
    const counter = document.getElementById('char-count');
    counter.textContent = remaining;
    counter.classList.toggle('warn', remaining < 50);
    this.style.height = '42px';
    this.style.height = Math.min(this.scrollHeight, 120) + 'px';
  });

  // ── WebRTC ───────────────────────────────────────────────

  socket.on('room-users', async (users) => {
    peerCount = users.length;
    updatePeerCount();
    for (const userId of users) {
      await createOffer(userId);
    }
  });

  socket.on('offer', async ({ from, offer }) => {
    const pc = createPeerConnection(from);
    await pc.setRemoteDescription(new RTCSessionDescription(offer));
    const answer = await pc.createAnswer();
    await pc.setLocalDescription(answer);
    socket.emit('answer', { to: from, answer });
  });

  socket.on('answer', async ({ from, answer }) => {
    const pc = peers[from];
    if (pc) await pc.setRemoteDescription(new RTCSessionDescription(answer));
  });

  socket.on('ice-candidate', async ({ from, candidate }) => {
    const pc = peers[from];
    if (pc && candidate) {
      try { await pc.addIceCandidate(new RTCIceCandidate(candidate)); } catch (e) {}
    }
  });

  socket.on('user-left', (userId) => {
    if (peers[userId]) { peers[userId].close(); delete peers[userId]; }
    const el = document.getElementById('video-' + userId);
    if (el) el.closest('.video-wrapper').remove();
    peerCount = Math.max(0, peerCount - 1);
    updatePeerCount();
    addMessage({ isSystem: true, message: 'A user left the room.' });
  });

  function createPeerConnection(peerId) {
    const pc = new RTCPeerConnection(iceConfig);
    peers[peerId] = pc;

    localStream.getTracks().forEach((track) => pc.addTrack(track, localStream));

    pc.onicecandidate = ({ candidate }) => {
      if (candidate) socket.emit('ice-candidate', { to: peerId, candidate });
    };

    pc.ontrack = ({ streams }) => {
      let wrapper = document.getElementById('wrapper-' + peerId);
      if (!wrapper) {
        wrapper = document.createElement('div');
        wrapper.className = 'video-wrapper';
        wrapper.id = 'wrapper-' + peerId;

        const vid = document.createElement('video');
        vid.id = 'video-' + peerId;
        vid.autoplay = true;
        vid.playsInline = true;

        const label = document.createElement('div');
        label.className = 'video-label';
        label.textContent = 'peer';

        wrapper.appendChild(vid);
        wrapper.appendChild(label);
        document.getElementById('video-panel').appendChild(wrapper);

        peerCount = Object.keys(peers).length;
        updatePeerCount();
      }
      document.getElementById('video-' + peerId).srcObject = streams[0];
    };

    return pc;
  }

  async function createOffer(peerId) {
    const pc = createPeerConnection(peerId);
    const offer = await pc.createOffer();
    await pc.setLocalDescription(offer);
    socket.emit('offer', { to: peerId, offer });
  }
</script>
</body>
</html>


  • Edit server.js

Appended server.js

const express = require('express');
const https = require('https');
const { Server } = require('socket.io');
const path = require('path');

const app = express();
const fs = require('fs');

const server = https.createServer({
  key: fs.readFileSync('/opt/webrtc/key.pem'),
  cert: fs.readFileSync('/opt/webrtc/cert.pem'),
}, app);
const io = new Server(server);

// Serve the frontend HTML file
app.use(express.static(path.join(__dirname, 'public')));

// Keep track of who is in each room
const rooms = {};

io.on('connection', (socket) => {
  console.log('A user connected:', socket.id);

  // User wants to join a room
  socket.on('join', (room) => {
    socket.join(room);

    if (!rooms[room]) {
      rooms[room] = [];
    }

    // Tell the new user who is already in the room
    socket.emit('room-users', rooms[room]);

    // Add this user to the room list
    rooms[room].push(socket.id);

    console.log(`${socket.id} joined room: ${room}`);
  });

  // Relay a WebRTC offer to a specific user
  socket.on('offer', ({ to, offer }) => {
    io.to(to).emit('offer', { from: socket.id, offer });
  });

  // Relay a WebRTC answer to a specific user
  socket.on('answer', ({ to, answer }) => {
    io.to(to).emit('answer', { from: socket.id, answer });
  });

  // Relay ICE candidates between peers
  socket.on('ice-candidate', ({ to, candidate }) => {
    io.to(to).emit('ice-candidate', { from: socket.id, candidate });
  });

  // ---- CHAT ----
  // Relay a chat message to everyone else in the room
  socket.on('chat-message', ({ room, message, name }) => {
    // Broadcast to everyone in the room EXCEPT the sender
    socket.to(room).emit('chat-message', {
      from: socket.id,
      name: name || 'Anonymous',
      message,
      time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
    });
  });
  // ---- END CHAT ----

  // Clean up when a user disconnects
  socket.on('disconnect', () => {
    for (const room in rooms) {
      rooms[room] = rooms[room].filter((id) => id !== socket.id);
      // Tell others in the room this user left
      socket.to(room).emit('user-left', socket.id);
    }
    console.log('User disconnected:', socket.id);
  });
});

const PORT = 3000;
server.listen(PORT, '0.0.0.0', () => {
  console.log(`Signalling server running on port ${PORT}`);
});


  • Restart webrtc: systemctl restart webrtc

Test - Should be working

Troubleshooting

"Cannot connect to the page"

  • Make sure the container is running: sudo lxc-ls --fancy
  • Make sure the service is running: attach to the container and run systemctl status webrtc
  • Double-check the IP address — it can change after a container restart. Assign a static IP via LXC config if needed.

"Camera/Microphone access denied"

  • On non-localhost addresses, browsers require HTTPS. See Part 7.
  • Make sure you clicked Allow (not Block) when the browser asked for permissions.
  • Try in a different browser, or clear site permissions in browser settings.

Two tabs connect but no video appears

  • This usually means ICE negotiation is failing. For local testing this should not happen.
  • If testing across networks (not just local LAN), you will need a TURN server (e.g. coturn). This is beyond the scope of this guide.

Port 3000 not reachable

  • Check no firewall is blocking it on the host: sudo ufw status
  • LXC containers on the default lxcbr0 bridge are reachable from the host by default. No port forwarding should be needed for local access.