Skip to content

wavelog/wavelog_worker

Repository files navigation

Important

This project is still under development. Do NOT use in production. Currently only the new Contesting supports this which is also still in development (wavelog/wavelog#3063)

Wavelog Worker

A generic WebSocket pub/sub broker written in Go. It receives events from PHP and forwards them to connected browsers. It has no knowledge of Wavelog-specific logic — topics, payloads, and their meaning are irrelevant to the Worker.

Documentation: https://docs.wavelog.org/wavelog-worker/


Concept

PHP (Wavelog)
    │
    │  POST /internal/publish  {topic, payload}
    ▼
Worker :9001  (internal port, accessible by PHP only)
    │
    │  WebSocket push  {type:"push", payload}
    ▼
Browser :9000/ws?topic=<topic>

PHP publishes an event → Worker immediately broadcasts it to all browsers subscribed to that topic. That's it. The Worker polls nothing, caches nothing, and knows nothing about the database schema.


Security Model

Two independent authentication layers:

1. PHP → Worker (internal)

All requests from PHP to the internal port :9001 are secured with the X-Worker-Secret header. This is a static shared secret (min. 32 characters) configured identically on both sides.

The internal port :9001 must never be publicly accessible (firewall/Docker network).

2. Browser → Worker (HMAC token)

The browser connects via WebSocket on port :9000. The connection is initially open — but the first frame must be an authentication frame:

{"type": "auth", "token": "<hmac-token>"}

If the Worker responds with {"type": "auth_ok"}, the connection is authenticated. On invalid token: {"type": "error", "code": "unauthorized"} and immediate disconnect.

The HMAC token is generated by PHP (never by the browser itself) and contains:

{"user_id": 1, "session_id": 42, "expires": 1748700000}

This JSON is hex-encoded and signed with HMAC-SHA256:

hex(json(claims)).hex(hmac-sha256(hex(claims), secret))

The Worker verifies the signature and expiry locally — no database query or HTTP callback to PHP required. The shared secret is sufficient.


Topic Registration

Before a browser can connect to a topic, PHP must register it with the Worker. This prevents arbitrary topics from being subscribed to.

POST :9001/internal/register
X-Worker-Secret: <secret>
Content-Type: application/json

{"topic": "session.42", "meta": {"require_token": true}}
Field Description
topic Freely chosen string, e.g. session.42, alerts.hb9hil
require_token true: browser must present a valid HMAC token. false: connection without token allowed (e.g. public feeds)

The topic registry is in-memory — all registered topics are lost on Worker restart. PHP re-registers topics as needed (e.g. on the next page load or when a publish returns 404).

Unregistration (optional, e.g. on session end):

POST :9001/internal/unregister
X-Worker-Secret: <secret>
Content-Type: application/json

{"topic": "session.42"}

Publishing Events

POST :9001/internal/publish
X-Worker-Secret: <secret>
Content-Type: application/json

{"topic": "session.42", "payload": {"event": "qso_updated"}}
  • Topic must be registered, otherwise: 404 topic not registered
  • On 404, PHP re-registers the topic and retries the publish once
  • payload is arbitrary JSON — the Worker forwards it unchanged

Status

GET :9001/internal/status
X-Worker-Secret: <secret>

Response (single instance):

{
  "status": "ok",
  "version": "0.1.0",
  "uptime": "3h22m",
  "registered_topics": 2,
  "active_topics": 1,
  "connected_clients": 3,
  "topic_list": ["session.42", "session.7"],
  "cluster_nodes": -1
}

cluster_nodes is -1 when Redis clustering is disabled. In cluster mode it returns the number of Worker instances currently subscribed to the Redis Pub/Sub channel (including this one).


Cluster Mode

By default each Worker instance is standalone. For high-availability or horizontal scaling, multiple instances can be connected via Redis Pub/Sub so that a publish to any one node is fan-out to all browsers across all nodes.

PHP
 │  POST /internal/publish
 ▼
Worker-1 :9001 ──── Redis (wavelog:events channel) ────► Worker-2 :9001
    │                                                         │
    │ WebSocket                                         WebSocket
    ▼                                                         ▼
 Browsers on node 1                                    Browsers on node 2

A publish to Worker-1 is forwarded via Redis Pub/Sub to Worker-2, which then broadcasts it to its own connected browsers. An origin_id in the envelope prevents double-delivery on the originating node.

Configuration

Add redis_url to config.yaml:

ws_port: 9000
internal_port: 9001
worker_secret: ""
redis_url: "redis://localhost:6379/2"  # DB 2 — avoid collision with other Redis users

The Redis URL follows the standard redis://[user:pass@]host:port/db format. Leave it empty (or omit the key) for single-instance mode.

Status in cluster mode

{
  "cluster_nodes": 3
}

cluster_nodes reflects the number of active subscribers on the Redis channel at query time. This is the authoritative live count — no separate discovery infrastructure needed.


PHP Integration

Config: application/config/worker.php

// Enable or disable the Worker integration entirely.
$config['worker_enabled'] = true;

// Internal URLs of wavelog_worker instances (PHP -> Worker, HTTP).
// Single instance: one entry. Cluster: one entry per node.
// PHP publishes to the first entry; the debug page shows status of all nodes.
$config['worker_urls'] = [
    'http://127.0.0.1:9001',
    // 'http://127.0.0.1:9011',  // second node in cluster mode
];

// Shared secret — must match worker_secret in config.yaml.
// Generate with: openssl rand -hex 32
$config['worker_secret'] = '<min. 32 characters>';

// Timeout for publish calls in seconds (float). Keep it short:
// a slow worker must not block QSO saves.
$config['worker_timeout'] = 1.0;

// Public WebSocket URL for the browser (Browser -> Worker).
// May differ from worker_urls when behind a reverse proxy.
// Format: ws://host:port or wss://host:port. Empty = no WebSocket in browser.
$config['worker_client_url'] = 'wss://example.org:9000';

worker_enabled = false disables all Worker calls without requiring URL/secret removal. Useful for temporarily disabling the feature without losing the configuration.

PHP always publishes to the first entry in worker_urls. In cluster mode Redis handles the fan-out to other nodes — PHP does not need to know all node URLs for publishing. The Wavelog debug page queries all configured URLs individually to show a per-node status overview.

Library: application/libraries/Worker_publisher.php

Provides three public methods:

$this->load->library('Worker_publisher');

// Register topic (on session creation / page load)
$this->worker_publisher->register_topic('session.42');

// Publish event (after any relevant change)
$this->worker_publisher->publish('session.42', ['event' => 'qso_updated']);

// Unregister topic (when session is deleted)
$this->worker_publisher->unregister_topic('session.42');

All three methods are fire-and-forget — a PHP operation never fails because of the Worker. If the Worker is not configured or worker_enabled is false, all methods are no-ops.

Errors are not silently swallowed: cURL errors (timeout, connection refused) and unexpected HTTP status codes are written to the CodeIgniter log via log_message('error', ...). This makes an unreachable Worker visible in the application log without interrupting the request flow.

Exception: a 404 on publish() is not an error — it means the Worker lost the topic after a restart. PHP automatically re-registers the topic and retries the publish once.

HMAC Token Generation

// In Paths.php — generates a signed token for the browser
$token = $this->paths->create_worker_token($session_id);
// Expiry: 24h, contains user_id + session_id

This token is passed to the browser via the PHP view (as a JS variable) and used during the WebSocket handshake.


Configuration (config.yaml)

ws_port: 9000        # browser-facing (public, optionally behind reverse proxy)
internal_port: 9001  # PHP-facing (internal only!)
worker_secret: ""    # min. 32 characters, generate with: openssl rand -hex 32

# Bind addresses (optional): restrict each listener to a specific IP.
# Empty or omitted = listen on all interfaces (0.0.0.0 + ::).
# Recommended: bind the internal port to localhost when PHP runs on the same host.
# ws_bind: "0.0.0.0"
# internal_bind: "127.0.0.1"

# Cluster mode (optional): connect multiple instances via Redis Pub/Sub.
# Leave empty or omit for single-instance mode.
# redis_url: "redis://localhost:6379/2"

Deployment

Docker Compose (single instance)

The Worker runs as its own container in the same Docker network as Wavelog. PHP reaches it via the internal container name; browsers connect through a reverse proxy.

wavelog-worker:
  image: ghcr.io/wavelog/wavelog_worker:latest
  ports:
    - "9000:9000"   # browser WebSocket (public)
    # do NOT publish port 9001 — internal only!

Docker Compose (cluster)

Run multiple Worker containers sharing the same Redis instance. All containers use the same config.yaml; only the ports differ on the host side. The internal port 9001 is not published — PHP and the debug page reach nodes via their container names.

wavelog-worker-1:
  image: ghcr.io/wavelog/wavelog_worker:latest
  ports:
    - "9000:9000"   # browser WebSocket (VIP / load balancer target)

wavelog-worker-2:
  image: ghcr.io/wavelog/wavelog_worker:latest
  # no host port needed — internal traffic only

wavelog-worker-3:
  image: ghcr.io/wavelog/wavelog_worker:latest

In worker.php, list all node URLs so the debug page can show individual node status:

$config['worker_urls'] = [
    'http://wavelog-worker-1:9001',
    'http://wavelog-worker-2:9001',
    'http://wavelog-worker-3:9001',
];

PHP publishes to the first URL. Redis distributes the event to the other nodes.

Binary + systemd

./wavelog_worker -config /etc/wavelog_worker/config.yaml

Restrict the internal port to localhost. The simplest way is to bind it directly in config.yaml:

internal_bind: "127.0.0.1"

Alternatively (or additionally) enforce it via firewall:

iptables -A INPUT -p tcp --dport 9001 ! -s 127.0.0.1 -j DROP

Worker Restart Behavior

The Worker loses all registered topics on restart (in-memory). This is not a problem because:

  1. publish() returns 404 when the topic is unknown
  2. PHP automatically re-registers the topic and retries the publish
  3. Browsers reconnect via WebSocket backoff — once the topic is re-registered, auth works again

No persistent storage needed. PHP is the source of truth.


Full Data Flow Example

1. PHP: register_topic("session.42")
   → POST :9001/internal/register
   → Worker stores topic in memory

2. PHP passes worker_client_url + HMAC token to browser (via view)

3. Browser: new WebSocket("wss://example.org:9000/ws?topic=session.42")
   → sends {"type":"auth","token":"<hmac>"}
   → Worker: signature ok, topic known, session_id matches
   → Worker: {"type":"auth_ok"}
   → browser is subscribed

4. PHP: something changes (QSO saved, etc.)
   → publish("session.42", {"event":"qso_updated"})
   → POST :9001/internal/publish
   → Worker broadcasts {"type":"push","payload":{"event":"qso_updated"}}
      to all subscribed browsers

   In cluster mode: Worker also publishes to Redis → other nodes broadcast
   to their own connected browsers.

5. Browser receives push → triggers AJAX request to PHP → UI updates

6. PHP: unregister_topic("session.42") when session ends
   → POST :9001/internal/unregister
   → Worker removes topic

Step 5 is intentional: the Worker carries only the signal, not the data. The browser always fetches current data directly from PHP via AJAX. The Worker needs no knowledge of Wavelog data.

About

go backend for websocket support - optional for high load installations

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors