Services

Các broker realtimeRealtime brokers

Bốn tiến trình Node.js giữ kết nối TLS-TCP lâu dài với EA MT5 và vps-agent, ghi trạng thái vào Redis và đẩy sự kiện cho trình duyệt. Trang này mô tả protocol, xác thực, key Redis, biến môi trường, cách deploy, kiểm tra và xử lý sự cố của từng broker. Four Node.js processes that hold long-lived TLS-TCP connections with MT5 EAs and vps-agent, write state to Redis and push events to browsers. This page covers each broker's protocol, auth, Redis keys, environment variables, deployment, verification and troubleshooting.

  • Cập nhậtUpdated 13/09/2026
  • NguồnSource monitor/TickBroker/ · monitor/AccountStatusBroker/ · monitor/VpsStreamBroker/ · monitor/EaLinkBroker/ · monitor/README.html · deploy.sh

So sánh bốn brokerThe four brokers compared

TickBrokerAccountStatusBrokerVpsStreamBrokerEaLinkBroker
ContainerContainerfxconsole-tick-brokerfxconsole-account-status-brokerfxconsole-vps-stream-brokerfxconsole-ea-link-broker
Cổng TLS-TCPTLS-TCP port9600960296059607
Cổng WebSocketWebSocket port9601 (/ws)9603 (/status-ws)9606 (/vps-ws, chỉ nội bộ)9606 (/vps-ws, internal only)—
ClientClientTickStreamerPub EAAccountStatusUpdater EAvps-agent qua stunnelvps-agent via stunnelEA dùng Include/EaLink.mqh (GoldenRobber 1.42+)EAs using Include/EaLink.mqh (GoldenRobber 1.42+)
Khung dữ liệuFramingDòng HELLO pipe + JSON LinesPipe HELLO line + JSON LinesDòng HELLO pipe (v2) + JSON LinesPipe HELLO line (v2) + JSON LinesNDJSON {"t":…}NDJSON {"t":…}
AuthSecret chung TICK_BROKER_SECRETShared secret TICK_BROKER_SECRETSecret chung BROKER_SECRETShared secret BROKER_SECRETvpses.secret_key từng VPS (MySQL)Per-VPS vpses.secret_key (MySQL)Challenge-response HMAC, key theo product lấy từ LaravelChallenge-response HMAC, per-product key from Laravel
Không có certNo cert presentChạy TCP thường (MQL5 cần TLS)Falls back to plain TCP (MQL5 needs TLS)Chạy TCP thườngFalls back to plain TCPFATALFATAL
MySQL—Tuỳ chọn (settings)Optional (settings)Bắt buộc (vpses, vps_commands, settings)Required (vpses, vps_commands, settings)Tuỳ chọn nhưng nên có (settings)Optional but recommended (settings)
Setting đọc từ DBSettings read from DB—broker_admin_secret, hb_interval_secs, stream_cadence_msbroker_admin_secret, broker_internal_secretbroker_internal_secret
Gọi LaravelCalls Laravel——POST /api/internal/broker/command-ackPOST /api/internal/ea-link/key
Redis subscribeRedis subscriptions—report:requestlaravel-database-vps:cmd, laravel-database-vps:statePSUBSCRIBE *ea:link:cfg
Buffer / dòng tối đaMax buffer / line64 KiB1 MiB256 KiB / dòng 64 KiB256 KiB / 64 KiB line64 KiB / dòng 8 KiB64 KiB / 8 KiB line

Điểm chungShared patterns

  • Mỗi broker là một stack docker-compose riêng, join network ngoài fxconsole_default để gọi fxconsole-redis và fxconsole-web. Luôn bật stack fxconsole trước.
  • Dockerfile node:20-alpine + openssl; lệnh khởi động node gen-cert.js "${CERT_HOST:-localhost}" && node broker.js. Lần boot đầu sinh certs/server.key + certs/server.cert self-signed, RSA 2048, hạn 10 năm (3650 ngày), CN = CERT_HOST. Cert có sẵn thì bỏ qua. Volume ./certs phải ghi được.
  • Đổi CN: docker compose down, xoá certs/, docker compose up -d.
  • REDIS_PREFIX (mặc định laravel-database-) phải trùng redis.options.prefix của Laravel. TickBroker ghi key không prefix.
  • Broker có đọc setting: thứ tự MySQL settings → Redis ${REDIS_PREFIX}app_config:<key> → env, cache 30s mỗi key, lỗi DB thử lại sau 5s, NULL/rỗng = chưa đặt (từ commit cb272d5).
  • Chạy node broker.js trực tiếp thì .env không được đọc — đặt biến bằng PowerShell, ví dụ $env:REDIS_URL="redis://127.0.0.1:6379"; node broker.js.
  • MT5: SocketConnect() chỉ nối được host có trong Tools → Options → Expert Advisors → Allow WebRequest for listed URL.
  • Each broker is its own docker-compose stack joined to the external fxconsole_default network to reach fxconsole-redis and fxconsole-web. Always bring the fxconsole stack up first.
  • Dockerfile node:20-alpine + openssl; start command node gen-cert.js "${CERT_HOST:-localhost}" && node broker.js. First boot writes a self-signed certs/server.key + certs/server.cert, RSA 2048, valid 10 years (3650 days), CN = CERT_HOST. Existing certs are kept. The ./certs volume must be writable.
  • Changing the CN: docker compose down, delete certs/, docker compose up -d.
  • REDIS_PREFIX (default laravel-database-) must match Laravel's redis.options.prefix. TickBroker writes unprefixed keys.
  • Brokers that read settings use MySQL settings → Redis ${REDIS_PREFIX}app_config:<key> → env, 30s per-key cache, DB failures retried after 5s, NULL/blank = not set (since commit cb272d5).
  • Running node broker.js directly does not read .env — set variables in PowerShell, e.g. $env:REDIS_URL="redis://127.0.0.1:6379"; node broker.js.
  • MT5: SocketConnect() only reaches hosts listed in Tools → Options → Expert Advisors → Allow WebRequest for listed URL.

DeployDeploy

# Cả hệ thống (fxconsole + 4 broker)
bash deploy.sh

# Một broker
cd monitor/fxconsole && docker compose up -d     # tạo network fxconsole_default
cd ../<Broker>
cp .env.example .env                            # điền secret + DB_*
docker compose up -d --build
docker compose logs -f
# Whole system (fxconsole + 4 brokers)
bash deploy.sh

# A single broker
cd monitor/fxconsole && docker compose up -d     # creates fxconsole_default
cd ../<Broker>
cp .env.example .env                            # fill secrets + DB_*
docker compose up -d --build
docker compose logs -f

deploy.sh gọi compose_up cho AccountStatusBroker, TickBroker, VpsStreamBroker, EaLinkBroker. Trước mỗi up, ensure_env: (1) chưa có .env → tạo từ .env.example, mọi giá trị change_me… để trống (không bao giờ boot bằng secret đoán được), chmod 600; (2) nếu .env.example có DB_HOST → điền DB_* từ monitor/fxconsole/.env (DB_DATABASE→DB_NAME, DB_USERNAME→DB_USER); file có sẵn chỉ điền ô trống, không ghi đè. Cuối script in danh sách ô cần điền tay.

deploy.sh calls compose_up for AccountStatusBroker, TickBroker, VpsStreamBroker, EaLinkBroker. Before each up, ensure_env: (1) no .env → creates it from .env.example with every change_me… value left empty (never boot with a guessable secret), chmod 600; (2) if .env.example has DB_HOST → fills DB_* from monitor/fxconsole/.env (DB_DATABASE→DB_NAME, DB_USERNAME→DB_USER); an existing file only gets empty keys filled, never overwritten. The script ends by listing fields to fill by hand.

FirewallFirewall

Mở cổng TCP của broker ở OVH Network Firewall (nếu bật) và port forward trên IPFire WAN:<port> → <LAN IP docker host>:<port>. Client trong LAN cần DNS hairpin (IPFire Network → Edit Hosts) để phân giải hostname về IP nội bộ.

Open the broker's TCP port on the OVH Network Firewall (if enabled) and port-forward on IPFire WAN:<port> → <docker host LAN IP>:<port>. LAN clients need DNS hairpin (IPFire Network → Edit Hosts) so the hostname resolves to the internal IP.

TickBroker (9600 / 9601)(9600 / 9601)

Nhận tick realtime, danh sách symbol và lịch kinh tế MT5 từ EA TickStreamerPub; ghi Redis Stream cho trang Charts và fan-out tick qua WebSocket. Nguồn: monitor/TickBroker/broker.js.

Receives realtime ticks, symbol lists and the MT5 economic calendar from the TickStreamerPub EA; writes Redis Streams for the Charts page and fans ticks out over WebSocket. Source: monitor/TickBroker/broker.js.

Protocol publisher (TLS-TCP 9600)Publisher protocol (TLS-TCP 9600)

Dòng đầu tiên: HELLO|tickpub|<account_id>|<shared_secret>|<broker_name>. Sai secret hoặc sai định dạng → broker trả ERR|auth rồi đóng. Tên broker lấy từ HELLO và gắn cho cả kết nối (tối đa 64 ký tự, | và xuống dòng bị thay bằng dấu cách). Sau đó là JSON Lines:

First line: HELLO|tickpub|<account_id>|<shared_secret>|<broker_name>. Wrong secret or format → the broker writes ERR|auth and closes. The broker name comes from HELLO and is pinned for the connection (max 64 chars; | and newlines become spaces). Then JSON Lines:

FrameXử lýHandling
{"type":"tick","s":"XAUUSD","b":2380.1,"a":2380.3,"t":<ms>}Ghi stream + hash giá mới nhất, broadcast cho subscriber (broker, symbol). Symbol tối đa 32 ký tự; b/a/t phải là số.Writes the stream + latest-quote hash, broadcasts to (broker, symbol) subscribers. Symbol max 32 chars; b/a/t must be numbers.
{"type":"symbols","data":["XAUUSD","EURUSD",…]}Đăng ký broker + danh sách symbol.Registers the broker + its symbol list.
{"type":"calendar","seq":k,"total":n,"tzoff":s,"mode":"full|delta","events":[…]}Snapshot lịch kinh tế; xem bên dưới.Economic calendar snapshot; see below.
NOOPKeep-alive, bỏ qua.Keep-alive, ignored.
  • Mỗi event lịch có id (value id MT5, ổn định theo lần công bố), eid (event id, ổn định theo chỉ số), cur, ctry, code (ISO 3166 alpha-2), imp (0 none / 1 low / 2 moderate / 3 high), name, time (unix giây), actual, forecast, prev, unit, mult. Tối đa 4000 event một snapshot.
  • seq/total tuỳ chọn: TickStreamerPub ≥ 1.02 chia cửa sổ nhiều ngày thành nhiều frame vì 1 dòng bị giới hạn bởi buffer 64 KiB. Broker ghép theo từng kết nối và chỉ ghi Redis khi đủ mọi phần; seq 0 đến lại thì bỏ phần đang ghép; thiếu phần giữa thì bỏ chu kỳ đó. Bản 1.01 gửi 1 frame không có hai field này → coi là snapshot 1 phần.
  • tzoff (≥ 1.03): số giây giờ server trade chạy trước GMT. time là datetime MQL5 theo đồng hồ server chứ không phải UTC, nên lệch đúng bằng giá trị này. Không gửi → null, consumer không sửa được và phải ghi rõ.
  • mode (≥ 1.04): full thay toàn bộ snapshot; delta (mỗi ~30s, chỉ vài giờ quanh hiện tại) được merge vào snapshot theo id (hoặc time|cur|name khi không có id). Frame không có mode luôn là full. Delta chỉ log khi BROKER_DEBUG=1.
  • Each calendar event has id (MT5 value id, stable per release), eid (event id, stable per indicator), cur, ctry, code (ISO 3166 alpha-2), imp (0 none / 1 low / 2 moderate / 3 high), name, time (unix seconds), actual, forecast, prev, unit, mult. At most 4000 events per snapshot.
  • seq/total are optional: TickStreamerPub ≥ 1.02 splits a multi-day window across frames because one line is capped by the 64 KiB buffer. The broker reassembles per connection and writes Redis only when every part has arrived; a new seq 0 discards the buffered parts; a missing middle part skips that cycle. Version 1.01 sends one frame without these fields → treated as a one-part snapshot.
  • tzoff (≥ 1.03): seconds the trade server's clock runs ahead of GMT. time is an MQL5 datetime on the server clock, not UTC, so it is off by exactly this much. Not sent → null; consumers cannot correct and must say so.
  • mode (≥ 1.04): full replaces the snapshot; delta (every ~30s, only a few hours around now) is merged into the snapshot by id (or time|cur|name without an id). Frames without mode are always full. Deltas are logged only with BROKER_DEBUG=1.

WebSocket (9601, nginx /ws)WebSocket (9601, nginx /ws)

HướngDirectionFrame
client → serverclient → server{"action":"subscribe","broker":"Vantage","symbol":"XAUUSD"} · {"action":"unsubscribe","broker":…,"symbol":…}
server → clientserver → client{"br":"Vantage","s":"XAUUSD","b":2380.1,"a":2380.3,"t":<ms>}

Subscribe xong nhận ngay tick cuối (XREVRANGE … COUNT 1). WS không có auth. Server ping mỗi 30s, socket không pong bị terminate.

After subscribing, the client immediately gets the last tick (XREVRANGE … COUNT 1). The WS has no auth. The server pings every 30s and terminates sockets that don't pong.

Redis

KeyNội dungContents
ticks:<broker>:<symbol>Stream XADD … MAXLEN ~ 100000, field b, a, t. Lỗi ghi → thử lại 1 lần sau 50ms rồi bỏ tick (broadcast vẫn đi).Stream XADD … MAXLEN ~ 100000, fields b, a, t. Write error → one retry after 50ms, then the tick is dropped (broadcast still goes out).
brokersSet tên brokerSet of broker names
broker:<broker>:symbolsSet symbol của brokerSet of the broker's symbols
broker_latest:<broker>Hash symbol → "<bid>:<ask>:<time_ms>" cho watchlist (1 HGETALL)Hash symbol → "<bid>:<ask>:<time_ms>" for the watchlist (one HGETALL)
calendar:<broker>JSON {broker, t, tzoff, events}, đọc bởi calendar:sync và analysis:runJSON {broker, t, tzoff, events}, read by calendar:sync and analysis:run
calendar:<broker>:rev"<written-ms>:<tzoff>" để fxconsole hỏi "có đổi không" mà không parse snapshot vài trăm KB"<written-ms>:<tzoff>" so fxconsole can ask "anything changed?" without parsing a snapshot of a few hundred KB

Biến môi trườngEnvironment

BiếnVariableMặc địnhDefaultÝ nghĩaMeaning
TICK_BROKER_SECRETbắt buộcrequiredTrùng InpSharedSecret của TickStreamerPub. Trống → FATAL, thoát.Equals InpSharedSecret on TickStreamerPub. Empty → FATAL, exit.
REDIS_URLredis://127.0.0.1:6379 (compose: redis://fxconsole-redis:6379)(compose: redis://fxconsole-redis:6379)Redis
BROKER_HOST / BROKER_PORT0.0.0.0 / 9600Listener TLS-TCP cho EATLS-TCP listener for EAs
WS_HOST / WS_PORT0.0.0.0 / 9601Listener WebSocket cho trình duyệtBrowser WebSocket listener
BROKER_DEBUG01 = log mọi subscribe, dòng lạ, delta lịch1 = log every subscribe, unknown lines, calendar deltas
CERT_HOSTfxconsole.gamelau.uk (compose)(compose)CN cert; phải trùng InpBrokerHost của EA — MT5 gửi làm SNI và từ chối handshake nếu CN khác.Cert CN; must match the EA's InpBrokerHost — MT5 sends it as SNI and rejects the handshake on a CN mismatch.

LuồngFlow

sequenceDiagram
  participant P as TickStreamerPub EA
  participant T as TickBroker 9600/9601
  participant R as Redis
  participant W as Trình duyệt /charts
  P->>T: TLS + HELLO tickpub account secret broker
  P->>T: symbols [...]
  W->>T: WS subscribe broker + symbol
  T->>R: XREVRANGE lấy tick cuối
  T-->>W: tick cuối
  loop Mỗi tick
    P->>T: tick s b a t
    T->>R: XADD ticks + HSET broker_latest
    T-->>W: br s b a t
  end
  P->>T: calendar seq 0..n-1
  T->>R: SET calendar:broker + rev
sequenceDiagram
  participant P as TickStreamerPub EA
  participant T as TickBroker 9600/9601
  participant R as Redis
  participant W as Browser /charts
  P->>T: TLS + HELLO tickpub account secret broker
  P->>T: symbols [...]
  W->>T: WS subscribe broker + symbol
  T->>R: XREVRANGE last tick
  T-->>W: last tick
  loop Every tick
    P->>T: tick s b a t
    T->>R: XADD ticks + HSET broker_latest
    T-->>W: br s b a t
  end
  P->>T: calendar seq 0..n-1
  T->>R: SET calendar:broker + rev

Kiểm tra và xử lý sự cốVerify and troubleshoot

# TLS mở từ ngoài
openssl s_client -connect fxconsole.gamelau.uk:9600 -servername fxconsole.gamelau.uk

# Log (mong đợi: "TickBroker pub TCP listening on 0.0.0.0:9600 (TLS)")
cd monitor/TickBroker && docker compose logs -f tick-broker

# Tick cuối và watchlist của một broker
docker exec -it fxconsole-redis redis-cli XREVRANGE "ticks:<broker>:XAUUSD" + - COUNT 1
docker exec -it fxconsole-redis redis-cli HGETALL "broker_latest:<broker>"
# TLS reachable from outside
openssl s_client -connect fxconsole.gamelau.uk:9600 -servername fxconsole.gamelau.uk

# Logs (expect: "TickBroker pub TCP listening on 0.0.0.0:9600 (TLS)")
cd monitor/TickBroker && docker compose logs -f tick-broker

# Last tick and watchlist for one broker
docker exec -it fxconsole-redis redis-cli XREVRANGE "ticks:<broker>:XAUUSD" + - COUNT 1
docker exec -it fxconsole-redis redis-cli HGETALL "broker_latest:<broker>"
Triệu chứngSymptomNguyên nhân / cách xử lýCause / fix
FATAL: TICK_BROKER_SECRET is emptyChưa điền secret trong .env.Secret not set in .env.
TLS: OFF — run 'node gen-cert.js'Không có cert → broker chạy TCP thường, MQL5 SocketTlsSend không nối được. Kiểm tra volume certs/.No cert → plain TCP, MQL5 SocketTlsSend cannot connect. Check the certs/ volume.
EA nhận ERR|authEA receives ERR|authInpSharedSecret khác TICK_BROKER_SECRET, hoặc HELLO thiếu tên broker.InpSharedSecret differs from TICK_BROKER_SECRET, or HELLO lacks a broker name.
buffer overflow — disconnectingMột dòng > 64 KiB (thường là frame lịch của EA < 1.02). Nâng cấp TickStreamerPub để chia frame.A line > 64 KiB (usually a calendar frame from EA < 1.02). Upgrade TickStreamerPub so it splits frames.
Handshake TLS thất bại trên MT5TLS handshake fails in MT5CERT_HOST khác InpBrokerHost: xoá certs/server.*, sửa CERT_HOST, restart.CERT_HOST differs from InpBrokerHost: delete certs/server.*, fix CERT_HOST, restart.
getaddrinfo ENOTFOUND fxconsole-redisChưa join fxconsole_default: bật stack fxconsole trước.Not on fxconsole_default: bring the fxconsole stack up first.

AccountStatusBroker (9602 / 9603)(9602 / 9603)

Nhận trạng thái tài khoản từ EA AccountStatusUpdater qua TLS-TCP (transport TCP_SOCKET / HYBRID), ghi Redis đúng schema của App\Services\AccountStatusStore::upsert để các endpoint polling của dashboard vẫn trả dữ liệu mới, và fan-out qua WebSocket cho dashboard chế độ "Stream (socket)". Đây là đường song song, không thay HTTP: insert lot_history và EquityAlertService vẫn cần HTTP control plane (HYBRID). Nguồn: monitor/AccountStatusBroker/broker.js.

Receives account status from the AccountStatusUpdater EA over TLS-TCP (TCP_SOCKET / HYBRID transports), writes Redis with exactly the schema of App\Services\AccountStatusStore::upsert so the dashboard's polling endpoints stay fresh, and fans out over WebSocket to dashboards in "Stream (socket)" mode. It is a parallel path, not a replacement: lot_history inserts and EquityAlertService still need the HTTP control plane (HYBRID). Source: monitor/AccountStatusBroker/broker.js.

HELLO v2HELLO v2

HELLO|statuspub|v2|<composite_id>|<secret>|<broker_name>
  • composite_id = slug(ACCOUNT_COMPANY)::slug(ACCOUNT_SERVER)::login, regex ^[a-z0-9-]+::[a-z0-9-]+::[A-Za-z0-9_-]+$, tối đa 160 ký tự. account_id = đoạn cuối ([A-Za-z0-9_-], ≤ 32) để giữ key Redis theo login.
  • secret phải bằng BROKER_SECRET; tên broker ≤ 64 ký tự.
  • Sai bất kỳ điều gì → broker ghi dòng thô ERR|auth_or_unsupported_hello_version rồi đóng. Không có frame hello_ok.
  • HELLO hợp lệ: nếu có socket cũ cùng composite_id thì đóng socket cũ; broker gửi ngay stream_on (nếu đang có người xem) hoặc stream_off.
  • composite_id = slug(ACCOUNT_COMPANY)::slug(ACCOUNT_SERVER)::login, regex ^[a-z0-9-]+::[a-z0-9-]+::[A-Za-z0-9_-]+$, max 160 chars. account_id = the last segment ([A-Za-z0-9_-], ≤ 32) so Redis stays keyed by login.
  • secret must equal BROKER_SECRET; broker name ≤ 64 chars.
  • Anything wrong → the broker writes the raw line ERR|auth_or_unsupported_hello_version and closes. There is no hello_ok frame.
  • Valid HELLO: any older socket with the same composite_id is closed; the broker immediately sends stream_on (if someone is watching) or stream_off.
Legacy

HELLO 5 phần HELLO|statuspub|<account_id>|<shared_secret>|<broker_name> của EA trước 1.10 bị từ chối (hard cutover, không tương thích ngược). Comment đầu file broker.js vẫn mô tả dạng cũ này; code dùng v2.

The 5-token HELLO HELLO|statuspub|<account_id>|<shared_secret>|<broker_name> used by EAs before 1.10 is rejected (hard cutover, no backward compatibility). The header comment of broker.js still describes that old form; the code uses v2.

Frame sau HELLOFrames after HELLO

HướngDirFrameKhi nào / xử lýWhen / handling
EA→SJSON trạng thái đầy đủ (không có t)Full status JSON (no t)Khi stream bật. Cần account_id + secret_key; account_id khác HELLO → bỏ (chống giả mạo). Ghi Redis + fan-out WS.While streaming. Needs account_id + secret_key; an account_id different from HELLO is dropped (anti-spoofing). Writes Redis + WS fan-out.
EA→S{"t":"hb","composite_id","secret_key","status","equity","balance","buy_orders","buy_lot_total","sell_orders","sell_lot_total","ts"}Khi stream tắt, mỗi hb_interval_secs. composite_id phải trùng HELLO. Merge vào dòng Redis cũ (giữ positions/experts/history), không fan-out.While stream is off, every hb_interval_secs. composite_id must match HELLO. Merged into the existing Redis row (keeps positions/experts/history), not fanned out.
EA→SNOOPBỏ quaIgnored
S→EA{"t":"stream_on","cadence_ms":N}Có subscriber đầu tiên (0 → N) hoặc lúc HELLO khi đang có người xem. cadence_ms = stream_cadence_ms.First subscriber arrives (0 → N) or at HELLO while someone watches. cadence_ms = stream_cadence_ms.
S→EA{"t":"stream_off","hb_interval_secs":N}Subscriber cuối rời (N → 0) hoặc lúc HELLO khi không ai xem. Mang theo nhịp HB để admin chỉnh không cần restart EA.Last subscriber leaves (N → 0) or at HELLO while nobody watches. Carries the HB cadence so admins can retune without restarting EAs.
S→EA{"t":"report_request","from_ts","to_ts"}Laravel publish report:request {composite_id, from_ts, to_ts}; EA không kết nối → bỏ qua, EA sẽ thấy pending_report qua HTTP. Không phụ thuộc stream on/off.Laravel publishes report:request {composite_id, from_ts, to_ts}; EA not connected → skipped, the EA sees pending_report over HTTP instead. Independent of stream on/off.

Cổng gate là toàn cục: tổng subscriber (per-key + admin) chuyển 0 → N thì mọi EA đang kết nối stream đầy đủ, N → 0 thì mọi EA về HB. Chỉ gửi ở cạnh chuyển trạng thái. Lý do: subscribe hiện tại là kiểu wildcard (theo key / admin), admin đang xem dashboard thường muốn mọi tài khoản live; lọc theo tài khoản để sau. Không có ping tầng ứng dụng — chỉ TCP keep-alive 15s.

The gate is global: when total subscribers (per-key + admin) go 0 → N every connected EA streams full payloads; N → 0 sends every EA back to HB. Frames are sent only on the edges. Rationale: subscriptions today are wildcard-style (per key / admin), and an admin on the dashboard usually wants all accounts live; per-account gating is a later refinement. There is no application-level ping — only 15s TCP keep-alive.

WebSocket (9603, nginx /status-ws)WebSocket (9603, nginx /status-ws)

Client gửiClient sendsKết quảResult
{"action":"subscribe","secret_key":"…"}Secret key vừa là auth vừa là phạm vi: nhận snapshot mọi tài khoản trong account_status_keys:{secret_key}, rồi mọi push có secret_key trùng (kể cả tài khoản online sau). Thiếu key → {"error":"missing_secret_key"}.The secret key is both auth and scope: snapshots for every account in account_status_keys:{secret_key}, then every push with a matching secret_key (including accounts that come online later). Missing key → {"error":"missing_secret_key"}.
{"action":"subscribe_all","admin_token":"…"}Firehose admin: token phải bằng broker_admin_secret (DB → Redis → env), sai → {"error":"admin_unauthorized"}. Snapshot mọi tài khoản (qua account_status_all_secret_keys, rỗng thì SCAN).Admin firehose: token must equal broker_admin_secret (DB → Redis → env); wrong → {"error":"admin_unauthorized"}. Snapshots every account (via account_status_all_secret_keys, SCAN if empty).
{"action":"unsubscribe"}Huỷ cả hai loại. Mỗi socket giữ tối đa một loại; subscribe lại là đổi loại.Drops both kinds. A socket holds at most one kind; resubscribing swaps it.

Frame server gửi có cùng dạng với /dashboard/accounts/{id}/data: dòng Redis + last_payload_at/updated_at định dạng dd-MM-yyyy HH:mm:ss theo TZ container, broker_time_at theo đồng hồ broker, kèm positions, experts, today_history. Nén permessage-deflate (level 3, bỏ qua frame < 256 byte). Ping 30s.

Server frames have the same shape as /dashboard/accounts/{id}/data: the Redis row + last_payload_at/updated_at formatted dd-MM-yyyy HH:mm:ss in the container TZ, broker_time_at on the broker clock, plus positions, experts, today_history. permessage-deflate (level 3, frames < 256 bytes uncompressed). 30s ping.

Redis

Key (thêm REDIS_PREFIX)Key (with REDIS_PREFIX)Nội dungContents
account_status:{account_id}Dòng trạng thái (timestamp unix), TTL 48h. broker_slug do Laravel đặt được giữ lại.Status row (unix timestamps), TTL 48h. The Laravel-stamped broker_slug is carried forward.
account_positions:{id}, account_experts:{id}, account_today_history:{id}Mảng JSON, TTL 48hJSON arrays, TTL 48h
account_status_keys:{secret_key}, account_status_all_secret_keysSet index, TTL 48h làm mới mỗi pushIndex sets, TTL 48h refreshed on every push
app_config:broker_admin_secret, app_config:hb_interval_secs, app_config:stream_cadence_msMirror do Laravel ghi (tầng 2)Mirrors written by Laravel (tier 2)
Channel report:request (không prefix)Channel report:request (unprefixed){composite_id, from_ts, to_ts}

Biến môi trườngEnvironment

BiếnVariableMặc địnhDefaultÝ nghĩaMeaning
BROKER_SECRETbắt buộcrequiredTrùng InpBrokerSecret của EA (fxconsole hiển thị qua BROKER_STREAM_SECRET). Trống → FATAL.Equals the EA's InpBrokerSecret (shown in fxconsole via BROKER_STREAM_SECRET). Empty → FATAL.
BROKER_ADMIN_SECRETtrốngemptyTầng cuối cho token firehose. Trống ở mọi tầng = tắt firehose admin.Last tier for the firehose token. Empty at every tier = admin firehose off.
DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASSWORDtrống, port 3306empty, port 3306Tuỳ chọn: MySQL của fxconsole để đọc bảng settings. DB_HOST trống = chỉ Redis mirror + env (hành vi cũ). Log khởi động: settings DB: on (…) / off.Optional: fxconsole MySQL to read the settings table. Empty DB_HOST = Redis mirror + env only (legacy). Startup log: settings DB: on (…) / off.
REDIS_URL, REDIS_PREFIXredis://127.0.0.1:6379, laravel-database-Compose: redis://fxconsole-redis:6379. Đổi APP_NAME của Laravel thì phải sửa prefix ở cả hai phía.Compose: redis://fxconsole-redis:6379. If Laravel's APP_NAME changes, update the prefix on both sides.
BROKER_HOST/BROKER_PORT, WS_HOST/WS_PORT0.0.0.0/9602, 0.0.0.0/9603Tránh trùng TickBroker 9600/9601Keep clear of TickBroker 9600/9601
HB_INTERVAL_SECS15Mặc định nhịp HB khi DB/Redis chưa có hb_interval_secs. Phải đủ ngắn để bộ phát hiện "lost" của dashboard không báo nhầm.HB cadence default when DB/Redis have no hb_interval_secs. Keep it short enough that the dashboard's lost detector doesn't misfire.
STREAM_CADENCE_MS900Mặc định nhịp stream; 0 hợp lệ (mỗi tick, giới hạn bởi InpMinPushIntervalMs).Stream cadence default; 0 is valid (per tick, capped by InpMinPushIntervalMs).
BROKER_DEBUG01 = log mọi frame, subscribe, broadcast stream_on/off1 = log every frame, subscribe, stream_on/off broadcast
CERT_HOSTfxconsole.gamelau.uk (compose)(compose)Phải trùng InpBrokerHost của EA (SNI).Must match the EA's InpBrokerHost (SNI).
TZAsia/Ho_Chi_Minh (compose)(compose)Trùng TZ của fxconsole-app để chuỗi thời gian khớp giữa polling và WS.Matches fxconsole-app so time strings agree between polling and WS.

LuồngFlow

sequenceDiagram
  participant E as AccountStatusUpdater EA
  participant S as AccountStatusBroker 9602/9603
  participant R as Redis
  participant D as Dashboard /status-ws
  participant L as Laravel
  E->>S: TLS + HELLO statuspub v2 composite_id secret broker
  S-->>E: stream_off hb_interval_secs
  loop Không ai xem
    E->>S: t=hb (balance, equity, lot)
    S->>R: merge account_status
  end
  D->>S: subscribe secret_key hoặc subscribe_all
  S-->>D: snapshot từ Redis
  S-->>E: stream_on cadence_ms (gửi mọi EA)
  loop Đang có người xem
    E->>S: JSON trạng thái đầy đủ
    S->>R: SETEX account_status + positions
    S-->>D: frame cho dashboard
  end
  L->>R: PUBLISH report:request
  R-->>S: composite_id from_ts to_ts
  S-->>E: report_request
  E->>L: POST /api/account-status (chunk báo cáo)
sequenceDiagram
  participant E as AccountStatusUpdater EA
  participant S as AccountStatusBroker 9602/9603
  participant R as Redis
  participant D as Dashboard /status-ws
  participant L as Laravel
  E->>S: TLS + HELLO statuspub v2 composite_id secret broker
  S-->>E: stream_off hb_interval_secs
  loop Nobody watching
    E->>S: t=hb (balance, equity, lots)
    S->>R: merge account_status
  end
  D->>S: subscribe secret_key or subscribe_all
  S-->>D: snapshot from Redis
  S-->>E: stream_on cadence_ms (sent to every EA)
  loop Someone watching
    E->>S: full status JSON
    S->>R: SETEX account_status + positions
    S-->>D: dashboard frame
  end
  L->>R: PUBLISH report:request
  R-->>S: composite_id from_ts to_ts
  S-->>E: report_request
  E->>L: POST /api/account-status (report chunk)

Kiểm tra và xử lý sự cốVerify and troubleshoot

openssl s_client -connect fxconsole.gamelau.uk:9602 -servername fxconsole.gamelau.uk
cd monitor/AccountStatusBroker && docker compose logs -f account-status-broker
# Mong đợi:
#   settings DB: on (<host>:<port>/<db>)
#   AccountStatusBroker pub TCP listening on 0.0.0.0:9602 (TLS)
#   Subscribed to Redis channel: report:request
#   [acct=<login>] statuspub connected composite=<composite_id> broker="…" stream=off
docker exec -it fxconsole-redis redis-cli GET "laravel-database-account_status:<login>"
openssl s_client -connect fxconsole.gamelau.uk:9602 -servername fxconsole.gamelau.uk
cd monitor/AccountStatusBroker && docker compose logs -f account-status-broker
# Expect:
#   settings DB: on (<host>:<port>/<db>)
#   AccountStatusBroker pub TCP listening on 0.0.0.0:9602 (TLS)
#   Subscribed to Redis channel: report:request
#   [acct=<login>] statuspub connected composite=<composite_id> broker="…" stream=off
docker exec -it fxconsole-redis redis-cli GET "laravel-database-account_status:<login>"
Triệu chứngSymptomNguyên nhân / cách xử lýCause / fix
EA nhận ERR|auth_or_unsupported_hello_version, panel EA không báo lý doEA gets ERR|auth_or_unsupported_hello_version, EA panel shows no reasonInpBrokerSecret ≠ BROKER_SECRET, EA trước 1.10 (HELLO cũ), hoặc composite_id sai định dạng. Dòng này không phải JSON nên EA không hiểu (xem hạn chế).InpBrokerSecret ≠ BROKER_SECRET, EA older than 1.10 (old HELLO), or a malformed composite_id. The line is not JSON so the EA doesn't understand it (see limitations).
payload account_id mismatch (…); dropping / hb composite_id mismatchPayload không khớp HELLO của socket đó (EA dùng lại socket sau khi đổi tài khoản MT5). Gỡ và gắn lại EA.Payload doesn't match the socket's HELLO (EA reused a socket after switching MT5 accounts). Remove and re-attach the EA.
buffer overflow — disconnectingHơn 1 MiB chưa có xuống dòng (rất nhiều position).Over 1 MiB without a newline (a very large number of positions).
Dashboard admin nhận admin_unauthorizedAdmin dashboard gets admin_unauthorizedbroker_admin_secret trống ở mọi tầng, hoặc DB/Redis không đọc được và env khác giá trị trên web. Xem log settings lookup (…) failed.broker_admin_secret empty at every tier, or DB/Redis unreadable and the env differs from the web value. Look for settings lookup (…) failed.
Dashboard báo "lost" khi không ai xemDashboard shows "lost" while nobody watcheshb_interval_secs quá dài so với ngưỡng lost (interval × staleness_multiplier).hb_interval_secs too long for the lost threshold (interval × staleness_multiplier).

VpsStreamBroker (9605 / 9606)(9605 / 9606)

Kênh TLS-TCP lâu dài giữa vps-agent (HeartbeatAgent, qua stunnel trên VPS) và fxconsole, thay HTTPS polling cho heartbeat và đẩy lệnh; thêm WebSocket cho trang /vps và /deployments. Nguồn: monitor/VpsStreamBroker/broker.js, monitor/VpsStreamBroker/README.md. Phía agent: VPS agent.

Long-lived TLS-TCP channel between vps-agent (HeartbeatAgent, through stunnel on the VPS) and fxconsole, replacing HTTPS polling for heartbeats and command push; plus a WebSocket for the /vps and /deployments pages. Source: monitor/VpsStreamBroker/broker.js, monitor/VpsStreamBroker/README.md. Agent side: VPS agent.

Vì sao có broker nàyWhy this broker exists

Trước đây agent poll /api/vps/heartbeat + /api/vps/poll-commands qua HTTPS mỗi 5 giây. Ở quy mô 100+ VPS: mỗi VPS tốn ~1.5 KB/poll × 12 poll/phút ≈ 18 KB/phút chỉ để báo còn sống; mỗi heartbeat là một PHP-FPM worker khởi động full Laravel; admin bấm Deploy thì agent biết sau tối đa một chu kỳ poll. Broker giữ một TLS handshake duy nhất rồi trao đổi frame nhỏ (~80 byte).

Hai kênh tách biệt: stream chỉ mang heartbeat, con trỏ lệnh, ACK sự kiện (không có bí mật ngoài secret trong HELLO, được TLS bảo vệ); HTTPS fxconsole mang payload nhạy cảm (mật khẩu MT5 đã giải mã, zip package, thông tin broker) — Crypt::decryptString chỉ chạy trong Laravel. Broker chết → agent quay về HTTPS polling, không gãy gì.

Agents used to poll /api/vps/heartbeat + /api/vps/poll-commands over HTTPS every 5 seconds. At 100+ VPSs: ~1.5 KB/poll × 12 polls/min ≈ 18 KB/min per VPS just to say "alive"; every heartbeat boots a full Laravel app in a PHP-FPM worker; a Deploy click reaches the agent only after up to one poll interval. The broker does one TLS handshake and then exchanges tiny frames (~80 bytes).

Two separate channels: the stream carries only heartbeats, command pointers and event ACKs (no secrets besides the TLS-protected secret in HELLO); fxconsole HTTPS carries sensitive payloads (decrypted MT5 password, package zip, broker credentials) — Crypt::decryptString runs only in Laravel. If the broker dies the agent falls back to HTTPS polling; nothing breaks.

HTTPS polling (cũ)HTTPS polling (old)VpsStreamBrokerΔ
Độ trễ lệnh (bấm → agent nhận)Command latency (click → agent)~5s (xấu nhất 10s)~5s (worst 10s)<500msnhanh hơn ~10–20×~10–20× faster
Băng thông heartbeat / VPSHeartbeat bandwidth / VPS~18 KB/phút~18 KB/min~1 KB/phút~1 KB/minít hơn ~18×~18× less
PHP-FPM worker / giây (50 VPS)PHP-FPM workers / s (50 VPS)~10~0 (bỏ qua Laravel)~0 (bypasses Laravel)giải phóng workerfrees workers
TLS handshake / giờ / VPSTLS handshakes / hour / VPS~1 (keep-alive nginx)~1 (nginx keep-alive)~0 (kết nối lâu dài)~0 (persistent)không đáng kểnegligible

Số liệu đo thực tế sau khi triển khai: độ trễ lệnh từ ~5s xuống <500ms; UI chuyển queued → in_progress → success dưới 1s sau khi bấm Deploy.

Measured after rollout: command latency from ~5s to <500ms; the UI moves queued → in_progress → success within 1s of clicking Deploy.

Protocol agent (NDJSON)Agent protocol (NDJSON)

HướngDirFrameGhi chúNotes
A→S{"t":"hello","v":1,"vps_id":42,"secret":"…","agent_v":"1.2.30","ts":…}Trong 5s sau connect. Broker tra vpses.secret_key (unique) ra vps_id; vps_id gửi lên được chấp nhận nhưng bỏ qua. Cache secret 60s (cả kết quả âm).Within 5s of connecting. The broker resolves vps_id from vpses.secret_key (unique); a sent vps_id is accepted but ignored. Secret cache 60s (negative results too).
S→A{"t":"hello_ok","vps_id":42,"server_t":…} + {"t":"stream_on"} / {"t":"stream_off"}Socket cũ cùng vps_id bị đóng (superseded).An older socket for the same vps_id is closed (superseded).
S→A{"t":"hello_err","reason":…}bad_format · bad_secret · timeout · already_hello
A→S{"t":"hb","cpu":12.4,"ram":34.0,"disk":78.2,"net":5.1,"accts":7,"logins":[…],"ts":…}SETEX vps:agent:{id}:heartbeat (TTL HEARTBEAT_TTL); có logins[] (agent ≥ v1.3.0-phase4k) → UPDATE vpses SET account_logins, account_count; fan-out vps_hb.SETEX vps:agent:{id}:heartbeat (TTL HEARTBEAT_TTL); with logins[] (agent ≥ v1.3.0-phase4k) → UPDATE vpses SET account_logins, account_count; fans out vps_hb.
S→A{"t":"hb_ok","ts":…,"interval":N}interval chỉ có khi Redis có vps:agent:{id}:interval → agent hot-reload nhịp heartbeat.interval only when Redis has vps:agent:{id}:interval → the agent hot-reloads its heartbeat cadence.
S→A{"t":"cmd","cmd_id":1234,"kind":"add_account","ts":…}Con trỏ lệnh; CmdAgent kéo payload qua GET /api/vps/cmd/{id}/payload.Command pointer; CmdAgent pulls the payload via GET /api/vps/cmd/{id}/payload.
A→S{"t":"cmd_ack","cmd_id":1234,"ts":…}Chỉ log (debug), không ghi DB.Logged only (debug), no DB write.
A→S{"t":"event","cmd_id":1234,"phase":"finished","status":"success","error":"…","log":"…","ts":…}Chỉ xử lý phase=finished; status khác success → failed. Xem bên dưới.Only phase=finished is handled; any status other than success → failed. See below.
S→A / A→S{"t":"ping","ts":…} / {"t":"pong","ts":…}Socket im lặng PING_INTERVAL → ping; không có phản hồi trong PING_TIMEOUT → đóng ping_timeout. Quét mỗi 5s.Idle for PING_INTERVAL → ping; no reply within PING_TIMEOUT → closed with ping_timeout. Sweep every 5s.
S→A{"t":"bye","reason":"shutdown"}Khi broker tắt.On broker shutdown.
  • Event kết thúc: broker POST /api/internal/broker/command-ack (X-Broker-Secret, body {vps_id, cmd_id, success, log, error}) để Laravel chạy cascade (vps_commands, mt5_accounts, observer → vps:state, email, migration bước 2; handler idempotent). Callback lỗi → broker UPDATE vps_commands trực tiếp (chỉ khi chưa terminal) và log cảnh báo "mt5_accounts NOT cascaded -- check broker_internal_secret and fxconsole reachability": tài khoản sẽ kẹt pending. Luôn broadcast vps_cmd cho admin.
  • Gate stream: tổng subscriber WS (admin + per-key) 0 → N gửi stream_on cho mọi agent (payload đầy đủ WMI + logins), N → 0 gửi stream_off. Chỉ đổi nội dung frame, không đổi nhịp.
  • Đẩy lệnh: Laravel PUBLISH laravel-database-vps:cmd {"vps_id","cmd_id","kind"} (VpsCommand::booted() trên mỗi insert). Agent không kết nối → không làm gì; agent nhận qua HTTPS.
  • Terminal events: the broker calls POST /api/internal/broker/command-ack (X-Broker-Secret, body {vps_id, cmd_id, success, log, error}) so Laravel runs the cascade (vps_commands, mt5_accounts, observers → vps:state, emails, migration stage 2; the handler is idempotent). If the callback fails, the broker runs UPDATE vps_commands itself (only when not yet terminal) and warns "mt5_accounts NOT cascaded -- check broker_internal_secret and fxconsole reachability": the account stays stuck at pending. vps_cmd is always broadcast to admins.
  • Stream gate: total WS subscribers (admin + per-key) 0 → N sends stream_on to every agent (full WMI payload + logins), N → 0 sends stream_off. Only frame contents change, not cadence.
  • Command push: Laravel PUBLISH laravel-database-vps:cmd {"vps_id","cmd_id","kind"} (VpsCommand::booted() on every insert). Agent not connected → nothing happens; the agent gets it over HTTPS.

WebSocket trình duyệt (9606, nginx /vps-ws)Browser WebSocket (9606, nginx /vps-ws)

Cổng 9606 chỉ expose trong network docker; trình duyệt đi wss://<host>/vps-ws → openresty (cert thật) → nginx fxconsole-web → broker (proxy_ssl_verify off). Cùng dạng subscribe với AccountStatusBroker nên hook useStreamSocket dùng chung.

Port 9606 is only exposed on the docker network; browsers use wss://<host>/vps-ws → openresty (real cert) → fxconsole-web nginx → broker (proxy_ssl_verify off). Same subscribe shape as AccountStatusBroker, so the useStreamSocket hook is shared.

FrameAi nhậnWho receives it
{"action":"subscribe_all","admin_token":"<broker_admin_secret>"}Admin: mọi sự kiện. Sai token → {"error":"admin_unauthorized"}. Không có snapshot ban đầu (trang Inertia đã có state).Admins: every event. Wrong token → {"error":"admin_unauthorized"}. No initial snapshot (the Inertia page already has state).
{"action":"subscribe","secret_key":"<users.mt5_secret_key>"}User thường: vps_hb, vps_state kind vps, và vps_state kind account của chính mình. Thiếu key → missing_secret_key.Regular users: vps_hb, vps_state kind vps, and their own vps_state kind account. Missing key → missing_secret_key.
{"t":"vps_hb","vps_id","cpu_pct","ram_pct","disk_pct","net_pct","accts","agent_v","ts"}Admin + mọi subscriber per-keyAdmins + every per-key subscriber
{"t":"vps_cmd","vps_id","cmd_id","status","error","ts"}Chỉ adminAdmins only
{"t":"vps_state","kind":"vps"|"account"|"command","id","status",…}Admin: tất cả. vps → mọi per-key. account → chỉ chủ (broker xoá field secret_key trước khi gửi).Admins: all. vps → every per-key sub. account → owner only (the broker strips the secret_key field before sending).
Model LaravelLaravel modelHookChannelPayload
Vpsupdated (đổi status)updated (status changed)vps:state{kind:"vps", id, status}
Mt5Accountcreated + updated (đổi status)created + updated (status changed)vps:state{kind:"account", id, vps_id, login, status, event, error}
VpsCommandcreatedvps:cmd{vps_id, cmd_id, kind}
VpsCommandupdated (đổi status)updated (status changed)vps:state{kind:"command", id, vps_id, mt5_account_id, type, status, error}

Mọi publisher nuốt lỗi Redis ở mức WARN: broker sập thì UI về polling, request không lỗi. /vps và /deployments mặc định Live (WS) cho admin; chấm xanh = đang nhận frame, vàng nhấp nháy = đang kết nối; user không có token admin chỉ có 5s/10s/30s/60s/Off.

Every publisher swallows Redis errors at WARN: if the broker is down the UI degrades to polling and requests don't fail. /vps and /deployments default to Live (WS) for admins; a green dot = frames flowing, pulsing amber = connecting; users without the admin token only get 5s/10s/30s/60s/Off.

Biến môi trườngEnvironment

BiếnVariableMặc địnhDefaultÝ nghĩaMeaning
BROKER_HOST / BROKER_PORT0.0.0.0 / 9605Listener TLS cho agent (khác 9602)TLS listener for agents (distinct from 9602)
WS_HOST / WS_PORT0.0.0.0 / 9606WebSocket trình duyệt (nội bộ)Browser WebSocket (internal)
BROKER_ADMIN_SECRETtrốngemptyTầng cuối cho token firehose (sau DB, Redis). Thường dùng chung với AccountStatusBroker.Last tier for the firehose token (after DB, Redis). Usually shared with AccountStatusBroker.
BROKER_INTERNAL_URLhttp://fxconsole-webnginx cổng 80 nội bộ — không phải 8080 của host, không phải fxconsole-app (php-fpm). Broker ở host khác thì dùng URL https public. Trống = tắt callback (dev) → deploy kẹt pending.nginx on internal port 80 — not the host's 8080, not fxconsole-app (php-fpm). For a broker on another host use the public https URL. Empty = callback off (dev) → deploys stick at pending.
BROKER_INTERNAL_SECRETtrốngemptyTầng cuối cho X-Broker-Secret (sau DB settings.broker_internal_secret, Redis). Cache 30s.Last tier for X-Broker-Secret (after DB settings.broker_internal_secret, Redis). 30s cache.
BROKER_INTERNAL_TIMEOUT_MS5000Timeout HTTP callbackCallback HTTP timeout
REDIS_URL, REDIS_PREFIXredis://127.0.0.1:6379, laravel-database-Compose: redis://fxconsole-redis:6379Compose: redis://fxconsole-redis:6379
DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASSWORDcode: 127.0.0.1, 3306, fxconsole, root, trốngcode: 127.0.0.1, 3306, fxconsole, root, emptyBắt buộc: MySQL của fxconsole (vpses, vps_commands, settings). Copy từ monitor/fxconsole/.env.Required: fxconsole MySQL (vpses, vps_commands, settings). Copy from monitor/fxconsole/.env.
CERT_HOSTfxconsole.gamelau.uk (compose)(compose)CN cert; stunnel của agent kiểm tra (checkHost + CAfile), nên khác với EA MT5, CN ở đây có ý nghĩa.Cert CN; the agent's stunnel verifies it (checkHost + CAfile), so unlike MT5 EAs the CN does matter here.
BROKER_DEBUG01 = log HELLO, heartbeat, cmd push, đóng socket1 = log HELLO, heartbeats, cmd pushes, disconnects
PING_INTERVAL / PING_TIMEOUT30 / 10 (s)Giữ NAT, phát hiện socket nửa mởKeeps NAT warm, detects half-open sockets
HEARTBEAT_TTL15 (s)TTL key heartbeat; dashboard coi VPS "lost" khi key mất. Mặc định = 3 × nhịp agent thường gặp.Heartbeat key TTL; the dashboard treats a VPS as "lost" when the key is gone. Default = 3 × the usual agent cadence.
TZAsia/Ho_Chi_Minh (compose)(compose)Log cùng giờ với stackAligns log timestamps with the stack

LuồngFlow

sequenceDiagram
  participant H as HeartbeatAgent (stunnel 127.0.0.1:9605)
  participant V as VpsStreamBroker 9605/9606
  participant R as Redis
  participant L as Laravel
  participant C as CmdAgent
  participant B as Trình duyệt /vps-ws
  H->>V: hello secret agent_v
  V-->>H: hello_ok vps_id + stream_off
  B->>V: subscribe_all admin_token
  V-->>H: stream_on
  loop Mỗi heartbeat_interval
    H->>V: hb cpu ram disk net accts logins
    V->>R: SETEX vps:agent:id:heartbeat 15s
    V-->>H: hb_ok interval
    V-->>B: vps_hb
  end
  L->>R: PUBLISH vps:cmd vps_id cmd_id kind
  R-->>V: tin nhắn vps:cmd
  V-->>H: cmd cmd_id kind
  H->>C: IPC 127.0.0.1:9606
  C->>L: GET /api/vps/cmd/id/payload (HTTPS)
  C->>H: IPC ack
  H->>V: event finished success
  V->>L: POST /api/internal/broker/command-ack
  V-->>B: vps_cmd
sequenceDiagram
  participant H as HeartbeatAgent (stunnel 127.0.0.1:9605)
  participant V as VpsStreamBroker 9605/9606
  participant R as Redis
  participant L as Laravel
  participant C as CmdAgent
  participant B as Browser /vps-ws
  H->>V: hello secret agent_v
  V-->>H: hello_ok vps_id + stream_off
  B->>V: subscribe_all admin_token
  V-->>H: stream_on
  loop Every heartbeat_interval
    H->>V: hb cpu ram disk net accts logins
    V->>R: SETEX vps:agent:id:heartbeat 15s
    V-->>H: hb_ok interval
    V-->>B: vps_hb
  end
  L->>R: PUBLISH vps:cmd vps_id cmd_id kind
  R-->>V: vps:cmd message
  V-->>H: cmd cmd_id kind
  H->>C: IPC 127.0.0.1:9606
  C->>L: GET /api/vps/cmd/id/payload (HTTPS)
  C->>H: IPC ack
  H->>V: event finished success
  V->>L: POST /api/internal/broker/command-ack
  V-->>B: vps_cmd

Cổng 9606 trong IPC của agent (HeartbeatAgent ↔ CmdAgent trên 127.0.0.1) và cổng WS 9606 của broker là hai thứ khác nhau, trên hai máy khác nhau.Port 9606 in the agent IPC (HeartbeatAgent ↔ CmdAgent on 127.0.0.1) and the broker's WS port 9606 are two different things on two different machines.

Cài đặt lần đầuFirst-time setup

  1. Bật fxconsole (cd monitor/fxconsole && docker compose up -d) để có network fxconsole_default.Bring fxconsole up (cd monitor/fxconsole && docker compose up -d) so fxconsole_default exists.
  2. cp .env.example .env, điền CERT_HOST, DB_* (copy từ fxconsole), REDIS_URL.cp .env.example .env, fill CERT_HOST, DB_* (copied from fxconsole), REDIS_URL.
  3. docker compose up -d → log mong đợi: Generating self-signed cert for "…", VpsStreamBroker listening on 0.0.0.0:9605 (TLS), Subscribed to laravel-database-vps:cmd (channels=1), VpsStreamBroker WS listening on 0.0.0.0:9606 (wss://).docker compose up -d → expected log: Generating self-signed cert for "…", VpsStreamBroker listening on 0.0.0.0:9605 (TLS), Subscribed to laravel-database-vps:cmd (channels=1), VpsStreamBroker WS listening on 0.0.0.0:9606 (wss://).
  4. Mở TCP/9605 ở OVH + IPFire (WAN:9605 → <LAN IP>:9605), DNS hairpin cho LAN.Open TCP/9605 on OVH + IPFire (WAN:9605 → <LAN IP>:9605), DNS hairpin for the LAN.
  5. Trên /admin/settings: VpsStreamBroker hostname = CERT_HOST, port = 9605, connection string để trống. Là setting DB, sửa trên web không cần restart; chỉ ảnh hưởng lần cài agent tiếp theo.On /admin/settings: VpsStreamBroker hostname = CERT_HOST, port = 9605, connection string blank. DB-backed, editable live; only affects the next agent install.
  6. Cert CA cho agent: compose của fxconsole mount ../VpsStreamBroker/certs read-only vào fxconsole-app tại /broker-certs, nên GET /api/agent/broker-config tự phục vụ cert. Cách thủ công cũ (vẫn chạy được):Agent CA cert: fxconsole's compose mounts ../VpsStreamBroker/certs read-only into fxconsole-app at /broker-certs, so GET /api/agent/broker-config serves the cert automatically. The older manual way (still works):
docker cp fxconsole-vps-stream-broker:/app/certs/server.cert /tmp/broker-ca.cert
docker cp /tmp/broker-ca.cert fxconsole-app:/tmp/broker-ca.cert
docker exec fxconsole-app php artisan vps-stream-broker:cert-import /tmp/broker-ca.cert

Trên mỗi VPS Windows, prepare-vm.ps1 cài stunnel 5.78 (C:\VpsAgent\stunnel), gọi /api/agent/broker-config với X-Vps-Secret, ghi broker-ca.cert + stunnel.conf (client, accept 127.0.0.1:9605, verify=2 + checkHost), tạo shortcut Startup và smoke test Test-NetConnection 127.0.0.1 -Port 9605. Cờ -SkipBroker bỏ phần stunnel. Chi tiết: VPS agent.

On each Windows VPS, prepare-vm.ps1 installs stunnel 5.78 (C:\VpsAgent\stunnel), calls /api/agent/broker-config with X-Vps-Secret, writes broker-ca.cert + stunnel.conf (client, accept 127.0.0.1:9605, verify=2 + checkHost), adds a Startup shortcut and smoke-tests Test-NetConnection 127.0.0.1 -Port 9605. -SkipBroker skips the stunnel part. Details: VPS agent.

Kiểm traVerify

# Từ ngoài LAN: phải in cert rồi treo (chờ HELLO). Ctrl-D để thoát
openssl s_client -connect fxconsole.gamelau.uk:9605 -servername fxconsole.gamelau.uk

# Giả lập lệnh (log "cmd push …" chỉ hiện khi BROKER_DEBUG=1)
docker exec -it fxconsole-redis redis-cli \
  PUBLISH laravel-database-vps:cmd '{"vps_id":42,"cmd_id":99999,"kind":"test"}'

# Heartbeat của agent (làm mới theo nhịp agent, hết hạn 15s sau lần ghi cuối)
docker exec -it fxconsole-redis redis-cli GET laravel-database-vps:agent:42:heartbeat

# Heartbeat giả: /vps/1 hiện số giả, 15s sau quay về số trong DB
docker exec -it fxconsole-redis redis-cli SETEX "laravel-database-vps:agent:1:heartbeat" 15 \
  '{"vps_id":1,"cpu_pct":42.0,"ram_pct":12.3,"disk_pct":67.0,"net_pct":1.1,"accts":3,"agent_v":"fake","ts":1717400000}'

# Theo dõi sự kiện trạng thái
docker exec -it fxconsole-redis redis-cli SUBSCRIBE laravel-database-vps:state
# From outside the LAN: prints the cert then hangs (waiting for HELLO). Ctrl-D to quit
openssl s_client -connect fxconsole.gamelau.uk:9605 -servername fxconsole.gamelau.uk

# Fake a command (the "cmd push …" log appears only with BROKER_DEBUG=1)
docker exec -it fxconsole-redis redis-cli \
  PUBLISH laravel-database-vps:cmd '{"vps_id":42,"cmd_id":99999,"kind":"test"}'

# Agent heartbeat (refreshes at the agent cadence, expires 15s after the last write)
docker exec -it fxconsole-redis redis-cli GET laravel-database-vps:agent:42:heartbeat

# Fake heartbeat: /vps/1 shows the fake numbers, reverts to DB values after 15s
docker exec -it fxconsole-redis redis-cli SETEX "laravel-database-vps:agent:1:heartbeat" 15 \
  '{"vps_id":1,"cpu_pct":42.0,"ram_pct":12.3,"disk_pct":67.0,"net_pct":1.1,"accts":3,"agent_v":"fake","ts":1717400000}'

# Watch state events
docker exec -it fxconsole-redis redis-cli SUBSCRIBE laravel-database-vps:state

Smoke test WS: mở /vps bằng admin ở 2 tab, bấm Disable một VPS ở tab 1 → badge tab 2 đổi trong ~100ms. Nếu phải chờ poll 5s: kiểm tra block /vps-ws trong nginx, log WS listening trên 9606, tab Network → WS có frame subscribe_all không bị trả lỗi, và SUBSCRIBE laravel-database-vps:state.

WS smoke test: open /vps as admin in two tabs, click Disable on a VPS in tab 1 → tab 2's badge flips within ~100ms. If it waits for the 5s poll: check the nginx /vps-ws block, the WS listening log on 9606, the Network → WS tab for a subscribe_all frame with no error back, and SUBSCRIBE laravel-database-vps:state.

Xử lý sự cốTroubleshooting

Triệu chứngSymptomNguyên nhân / cách xử lýCause / fix
FATAL: certs/server.{key,cert} missingSinh cert lần đầu lỗi, thường do volume certs/ không ghi được.First-boot cert generation failed, usually a non-writable certs/ volume.
stunnel báo lỗi verify certstunnel cert verification failsCERT_HOST khác hostname agent dùng: xoá certs/, sửa CERT_HOST, restart, rồi chạy lại prepare-vm.ps1 trên VPS.CERT_HOST differs from the hostname the agent uses: delete certs/, fix CERT_HOST, restart, then re-run prepare-vm.ps1 on the VPS.
getaddrinfo ENOTFOUND fxconsole-redisChưa join fxconsole_default.Not on fxconsole_default.
resolveVpsBySecret: ER_ACCESS_DENIED_ERRORDB_USER/DB_PASSWORD khác fxconsole.DB_USER/DB_PASSWORD differ from fxconsole.
hello_err reason=bad_secretsecret_key trong vps-agent.ini khác vpses.secret_key (xem "Show secret" trên trang VPS).secret_key in vps-agent.ini differs from vpses.secret_key (see "Show secret" on the VPS page).
Deploy xong nhưng tài khoản kẹt pending; log fallback inline; mt5_accounts NOT cascadedDeploy finished but the account is stuck at pending; log fallback inline; mt5_accounts NOT cascadedCallback /api/internal/broker/command-ack hỏng: BROKER_INTERNAL_URL sai, fxconsole-web chết, hoặc broker_internal_secret trống/khác. Log laravel ack callback non-2xx / failed.The /api/internal/broker/command-ack callback fails: wrong BROKER_INTERNAL_URL, fxconsole-web down, or broker_internal_secret empty/mismatched. Look for laravel ack callback non-2xx / failed.
VPS "lost" dù agent chạyVPS "lost" while the agent runsKey heartbeat không có → agent chưa nối broker (đang HTTPS) hoặc nhịp agent > HEARTBEAT_TTL.No heartbeat key → the agent isn't on the broker (still HTTPS) or its cadence exceeds HEARTBEAT_TTL.
Tài liệu cũ trong thư mục brokerStale docs in the broker folder

monitor/VpsStreamBroker/README.md còn mục "Phase 4: not yet" và "agent chưa mở socket" — cả 8 phase đã triển khai (bảng "What's next" cuối cùng file ghi đúng). README và .env.example ghi token admin/internal "live value comes from Redis" — từ cb272d5 broker đọc bảng settings trước. Để xoay cert: down, xoá certs/, up -d, chạy lại installer trên mọi VPS; nếu đổi CERT_HOST thì cập nhật cả VPS_BROKER_HOST, setting "VpsStreamBroker hostname" và DNS.

monitor/VpsStreamBroker/README.md still has "Phase 4: not yet" and "the agent doesn't open a socket" — all 8 phases have shipped (the final "What's next" table in that file is correct). The README and .env.example say the admin/internal tokens' "live value comes from Redis" — since cb272d5 the broker reads the settings table first. Cert rotation: down, delete certs/, up -d, re-run the installer on every VPS; if CERT_HOST changes also update VPS_BROKER_HOST, the "VpsStreamBroker hostname" setting and DNS.

Kênh TLS-TCP "EA Link" giữa EA MT5 (GoldenRobber 1.42+ và các product sau) và fxconsole. Mỗi EA đang chạy giữ một kết nối. Admin đổi config hot-reload → Laravel publish tín hiệu Redis → broker đẩy frame cfg nhỏ tới đúng socket trong vài mili giây → EA kéo config mới qua heartbeat HTTPS. Broker cũng giữ presence trong Redis (EA nào đang kết nối). Nguồn: monitor/EaLinkBroker/broker.js, monitor/EaLinkBroker/README.md, EA-HOTRELOAD-STANDARD.md §9, §15.3.

The "EA Link" TLS-TCP channel between MT5 EAs (GoldenRobber 1.42+ and future products) and fxconsole. Each running EA keeps one connection. An admin changes hot-reload config → Laravel publishes a Redis signal → the broker pushes a tiny cfg frame to the matching sockets within milliseconds → the EA pulls the new config over its HTTPS heartbeat. The broker also keeps presence in Redis (which EAs are connected). Source: monitor/EaLinkBroker/broker.js, monitor/EaLinkBroker/README.md, EA-HOTRELOAD-STANDARD.md §9, §15.3.

Không có giá trị config trên socketNo config values on this socket

MQL5 SocketTlsHandshake() không kiểm tra certificate server, nên mọi thứ gửi qua đây phải vô hại nếu bị nghe lén hoặc giả mạo. Broker chỉ báo "có config rev N, hãy kéo về". Giá trị config, token và lệnh giao dịch chỉ đi qua HTTPS có kiểm chứng chỉ. Lệnh điều khiển mở rộng sau này cũng phải theo cách "báo id → EA kéo HTTPS".

MQL5 SocketTlsHandshake() does not verify the server certificate, so anything sent here must be harmless if intercepted or spoofed. The broker only says "config rev N is available, go pull it". Config values, tokens and trading commands travel only over certificate-verified HTTPS. Future control frames must follow the same "announce id → EA pulls over HTTPS" rule.

NDJSON UTF-8, một object một dòng, kết thúc \n. Dòng tối đa 8 KiB (dài hơn → bye line_too_long), buffer tối đa 64 KiB mỗi client. t lạ bị bỏ qua; hơn 5 frame không parse được → bye bad_frames.

NDJSON UTF-8, one object per line, \n terminated. Max line 8 KiB (longer → bye line_too_long), max buffer 64 KiB per client. Unknown t values are ignored; more than 5 unparseable frames → bye bad_frames.

HướngDirFrameKhi nàoWhen
S→EA{"t":"challenge","v":1,"nonce":"<32 hex>","server_t":<unix>}Ngay sau TLS handshakeRight after the TLS handshake
EA→S{"t":"hello","v":1,"slug","composite_id","magic","node_id","inst","ea","ea_ver","nonce","sig"}Trong HELLO_GRACE_MS (5s)Within HELLO_GRACE_MS (5s)
S→EA{"t":"hello_ok","hb_sec":20,"server_t":<unix>}Xác thực OKAuth success
S→EA{"t":"hello_err","reason":"…"} rồi đóngthen closeXác thực lỗiAuth failure
EA→S{"t":"hb"}Mỗi ~20sEvery ~20s
S→EA{"t":"hb_ok"}Trả lời mọi hbReply to every hb
S→EA{"t":"cfg","rev":<int>}Laravel publish thay đổi config cho EA này (rev = mili giây)Laravel published a config change for this EA (rev = milliseconds)
EA→S{"t":"cfg_ack","rev":<int>,"ok":true|false}Sau khi kéo/áp config (chỉ log; ok=false log WARN)After pulling/applying (logged only; ok=false logs WARN)
S→EA{"t":"bye","reason":"…"} rồi đóngthen closeServer chủ động đóngServer-initiated close
Field HELLOHELLO fieldQuy tắcRuleVí dụExample
slug^[a-z0-9][a-z0-9-]{0,63}$goldenrobber
composite_id^[a-z0-9-]+::[a-z0-9-]+::[A-Za-z0-9_-]+$, tối đa 160max 160vantage::vantagemarkets-live-3::25938032
magic^[0-9]{1,20}$ (chuỗi)^[0-9]{1,20}$ (string)21478308
node_id^[A-Za-z0-9_.:-]{1,128}$auto-634649047
inst^[0-9-]{1,24}$ (EA dùng ChartID())^[0-9-]{1,24}$ (EAs use ChartID())131234567890
noncePhải bằng nonce của challengeMust equal the challenge nonce
sigHMAC hex (xem dưới)Hex HMAC (below)
ea, ea_verTự do, chỉ để logFree text, logged onlyGoldenRobber, 1.42-b1
key = link key of the slug (64 hex chars) = hex(sha256(secret_token))
msg = "ealink1|" + nonce + "|" + slug + "|" + composite_id + "|" + magic + "|" + node_id + "|" + inst
sig = hex( HMAC-SHA256( key = UTF-8 bytes of the 64-char hex STRING, msg ) )

HMAC key là chuỗi hex dạng byte, không phải 32 byte đã giải mã. Token thô không bao giờ đi qua socket; broker cũng chỉ giữ sha256(token). So sánh chữ ký dùng timingSafeEqual.

The HMAC key is the hex string as bytes, not the 32 decoded bytes. The raw token never crosses the socket; the broker itself only holds sha256(token). Signatures are compared with timingSafeEqual.

Tra link key (broker → Laravel):

Link-key lookup (broker → Laravel):

POST ${BROKER_INTERNAL_URL}/api/internal/ea-link/key
X-Broker-Secret: <broker_internal_secret>
{"slug":"goldenrobber"}

200 {"ok":true,"ea_id":7,"key":"<64 hex>"}   → cached KEY_CACHE_SEC
404                                          → unknown_ea, cached 10s
other / timeout                              → auth_unavailable, not cached

Các lookup đồng thời cho cùng slug dùng chung một request: broker restart với 100 EA của một product chỉ tốn 1 call. Secret đọc từ MySQL settings (key = broker_internal_secret, cần DB_*) → Redis ${REDIS_PREFIX}app_config:broker_internal_secret (timeout 1s) → env BROKER_INTERNAL_SECRET, cache 30s. Cùng secret với VpsStreamBroker. Nếu HELLO đang chờ lookup, hạn HELLO được gia hạn thêm BROKER_INTERNAL_TIMEOUT_MS + 1s.

Concurrent lookups for the same slug share one request, so a broker restart with 100 EAs of one product costs one call. The secret is read from MySQL settings (key = broker_internal_secret, needs DB_*) → Redis ${REDIS_PREFIX}app_config:broker_internal_secret (1s timeout) → env BROKER_INTERNAL_SECRET, 30s cache. Same secret as VpsStreamBroker. While a HELLO waits on the lookup, its deadline is extended by BROKER_INTERNAL_TIMEOUT_MS + 1s.

hello_err

bad_formatField sai quy tắc hoặc nonce không khớpA field failed validation or the nonce echo doesn't match
unknown_eaLaravel trả 404 cho slug (cache âm 10s)Laravel returned 404 for the slug (negative cache 10s)
auth_unavailableKhông gọi được Laravel / mã khác 200-404 / body sai / không có secret. EA thử lại có backoff.Laravel unreachable / non-200/404 / bad body / no secret. The EA retries with backoff.
bad_sigHMAC sai (sai token hoặc sai bố cục message)HMAC mismatch (wrong token or message layout)
timeoutKhông có HELLO trong HELLO_GRACE_MSNo HELLO within HELLO_GRACE_MS

bye

supersededCùng topic + cùng node_id#inst kết nối lạiSame topic + same node_id#inst reconnected
timeoutIm lặng quá HB_TIMEOUT_SEC (quét mỗi 5s)Silent for HB_TIMEOUT_SEC (5s sweep)
bad_frames> 5 frame hỏng> 5 bad frames
line_too_long, buffer_overflowVượt 8 KiB / 64 KiBOver 8 KiB / 64 KiB
shutdownBroker tắt; presence được HDELBroker stopping; presence HDEL'd

Registry: topic = ea/{slug}/{composite_id}/{magic}; trong topic mỗi socket có khoá {node_id}#{inst}. Kết nối lại cùng khoá thay socket cũ; khác inst (leader/follower, nhiều chart) thì cùng tồn tại và đều nhận cfg. Phía EA (Include/EaLink.mqh): connect timeout 2s, handshake phải xong trong 10s, backoff 2→4→8…60s ±20% jitter (reset sau 30s UP ổn định), bad_sig/unknown_ea chờ 5 phút.

Registry: topic = ea/{slug}/{composite_id}/{magic}; inside a topic each socket is keyed by {node_id}#{inst}. A reconnect with the same key replaces the old socket; a different inst (leader/follower, several charts) co-exists and every one receives cfg. EA side (Include/EaLink.mqh): 2s connect timeout, handshake must finish within 10s, backoff 2→4→8…60s ±20% jitter (reset after 30s stable UP), bad_sig/unknown_ea wait 5 minutes.

LoạiKindKey / channelKey / channelGhi chúNotes
PresenceHSET ${REDIS_PREFIX}ea_link:presence:{slug}:{composite_id}:{magic} fieldfield {node_id}#{inst} = unix giâysec, EXPIRE 90Ghi khi hello_ok và mỗi hb; HDEL khi đóng (trừ khi bị superseded). fxconsole coi field < 60s là live → badge Link.Written on hello_ok and every hb; HDEL on close (except when superseded). fxconsole treats fields < 60s as live → Link badge.
Tín hiệu configConfig signalPSUBSCRIBE *ea:link:cfgKhớp cả kênh có prefix (laravel-database-ea:link:cfg) lẫn không prefix. Payload {"slug","targets":[{"composite_id","magic"}],"rev"}; tối đa 10000 target; magic số hoặc chuỗi đều nhận.Matches both the prefixed (laravel-database-ea:link:cfg) and bare channel. Payload {"slug","targets":[{"composite_id","magic"}],"rev"}; up to 10000 targets; magic as number or string.
BiếnVariableMặc địnhDefaultÝ nghĩaMeaning
BROKER_HOST / BROKER_PORT0.0.0.0 / 9607Khác 9602 và 9605/9606Distinct from 9602 and 9605/9606
REDIS_URLredis://127.0.0.1:6379 (compose: redis://fxconsole-redis:6379)(compose: redis://fxconsole-redis:6379)Redis
REDIS_PREFIXlaravel-database-Trùng redis.options.prefix của LaravelMust match Laravel redis.options.prefix
BROKER_INTERNAL_URLhttp://fxconsole-webnginx cổng 80 nội bộ (không phải 8080, không phải fxconsole-app)nginx on internal port 80 (not 8080, not fxconsole-app)
BROKER_INTERNAL_SECRETtrốngemptyTầng cuối (sau DB settings và Redis mirror)Last resort (after DB settings and the Redis mirror)
BROKER_INTERNAL_TIMEOUT_MS3000Timeout lookup keyKey-lookup timeout
DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASSWORDtrống, 3306empty, 3306MySQL fxconsole (DB_NAME = DB_DATABASE, DB_USER = DB_USERNAME của fxconsole). DB_HOST trống = bỏ qua DB. Nên có để auth sống sót sau deploy / Redis restart.fxconsole MySQL (DB_NAME = fxconsole DB_DATABASE, DB_USER = DB_USERNAME). Empty DB_HOST = skip DB. Recommended so auth survives deploys / Redis restarts.
CERT_HOSTlocalhost (compose: fxconsole.gamelau.uk)localhost (compose: fxconsole.gamelau.uk)CN cert, chỉ để tham khảo (MT5 không verify)Cert CN, informational (MT5 doesn't verify)
BROKER_DEBUG01 = log challenge / hb / cfg_ack / đóng trước auth1 = log challenge / hb / cfg_ack / pre-auth closes
HB_TIMEOUT_SEC60Đóng socket đã auth im lặng quá lâu (EA hb mỗi 20s)Closes an authenticated socket that stays silent (EA hb every 20s)
HELLO_GRACE_MS5000Thời gian từ TLS connect tới HELLO hợp lệTime from TLS connect to a valid HELLO
KEY_CACHE_SEC60Đổi token áp dụng cho kết nối mới trong khoảng nàyA token rotation reaches new connections within this window
TZAsia/Ho_Chi_MinhGiờ logLog timezone
sequenceDiagram
  participant E as EA (CEaLink)
  participant K as EaLinkBroker 9607
  participant L as Laravel
  participant R as Redis
  E->>K: TLS connect
  K-->>E: challenge nonce
  E->>K: hello slug composite_id magic node_id inst nonce sig
  K->>L: POST /api/internal/ea-link/key (X-Broker-Secret)
  L-->>K: key = sha256(secret_token)
  K->>K: kiểm tra HMAC
  K-->>E: hello_ok hb_sec=20
  K->>R: HSET presence, EXPIRE 90
  loop Mỗi 20s
    E->>K: hb
    K-->>E: hb_ok
    K->>R: HSET presence
  end
  L->>R: PUBLISH ea:link:cfg slug targets rev
  R-->>K: pmessage
  K-->>E: cfg rev
  E->>L: heartbeat HTTPS, kéo config
  E->>K: cfg_ack rev ok
sequenceDiagram
  participant E as EA (CEaLink)
  participant K as EaLinkBroker 9607
  participant L as Laravel
  participant R as Redis
  E->>K: TLS connect
  K-->>E: challenge nonce
  E->>K: hello slug composite_id magic node_id inst nonce sig
  K->>L: POST /api/internal/ea-link/key (X-Broker-Secret)
  L-->>K: key = sha256(secret_token)
  K->>K: verify HMAC
  K-->>E: hello_ok hb_sec=20
  K->>R: HSET presence, EXPIRE 90
  loop Every 20s
    E->>K: hb
    K-->>E: hb_ok
    K->>R: HSET presence
  end
  L->>R: PUBLISH ea:link:cfg slug targets rev
  R-->>K: pmessage
  K-->>E: cfg rev
  E->>L: HTTPS heartbeat pulls config
  E->>K: cfg_ack rev ok
cd monitor/fxconsole && docker compose up -d        # tạo network fxconsole_default
cd ../EaLinkBroker
cp .env.example .env                                # điền DB_*; BROKER_INTERNAL_SECRET tuỳ chọn
docker compose up -d --build
docker compose logs -f ea-link-broker
# Mong đợi:
#   EaLinkBroker listening on 0.0.0.0:9607 (TLS) hb_timeout=60s prefix="laravel-database-"
#   PSubscribed to *ea:link:cfg (subscriptions=1)

# TLS + challenge từ ngoài (sau 5s nhận hello_err timeout rồi đóng)
openssl s_client -connect fxconsole.gamelau.uk:9607 -quiet

# Auth + hb + cfg bằng client kèm theo (hoặc: docker compose exec ea-link-broker node test-client.js …)
node test-client.js --host fxconsole.gamelau.uk --port 9607 \
  --slug goldenrobber --token '<secret>' \
  --composite vantage::vantagemarkets-live-3::25938032 --magic 21478308 \
  --node ops-test --inst 1
# (--key <64hex> truyền thẳng link key thay cho token)

# Shell khác: bắn tín hiệu
docker exec -it fxconsole-redis redis-cli PUBLISH laravel-database-ea:link:cfg \
  '{"slug":"goldenrobber","targets":[{"composite_id":"vantage::vantagemarkets-live-3::25938032","magic":"21478308"}],"rev":1}'
# Client in {"t":"cfg","rev":1} và trả cfg_ack; broker log "cfg rev=1 slug=goldenrobber targets=1 delivered=1"

# Presence
docker exec -it fxconsole-redis redis-cli HGETALL \
  'laravel-database-ea_link:presence:goldenrobber:vantage::vantagemarkets-live-3::25938032:21478308'
cd monitor/fxconsole && docker compose up -d        # creates fxconsole_default first
cd ../EaLinkBroker
cp .env.example .env                                # fill DB_*; BROKER_INTERNAL_SECRET optional
docker compose up -d --build
docker compose logs -f ea-link-broker
# Expect:
#   EaLinkBroker listening on 0.0.0.0:9607 (TLS) hb_timeout=60s prefix="laravel-database-"
#   PSubscribed to *ea:link:cfg (subscriptions=1)

# TLS + challenge from outside (after 5s you get hello_err timeout and the connection closes)
openssl s_client -connect fxconsole.gamelau.uk:9607 -quiet

# Full auth + hb + cfg with the bundled client (or: docker compose exec ea-link-broker node test-client.js …)
node test-client.js --host fxconsole.gamelau.uk --port 9607 \
  --slug goldenrobber --token '<secret>' \
  --composite vantage::vantagemarkets-live-3::25938032 --magic 21478308 \
  --node ops-test --inst 1
# (--key <64hex> passes the link key directly instead of the token)

# In another shell: fire a signal
docker exec -it fxconsole-redis redis-cli PUBLISH laravel-database-ea:link:cfg \
  '{"slug":"goldenrobber","targets":[{"composite_id":"vantage::vantagemarkets-live-3::25938032","magic":"21478308"}],"rev":1}'
# The client prints {"t":"cfg","rev":1} and answers cfg_ack; broker logs "cfg rev=1 slug=goldenrobber targets=1 delivered=1"

# Presence
docker exec -it fxconsole-redis redis-cli HGETALL \
  'laravel-database-ea_link:presence:goldenrobber:vantage::vantagemarkets-live-3::25938032:21478308'

Restart: docker compose restart ea-link-broker. EA nhận bye shutdown, kết nối lại có backoff và không mất gì — config luôn lấy lại được qua heartbeat HTTPS.

Restart: docker compose restart ea-link-broker. EAs get bye shutdown, reconnect with backoff and lose nothing — config is always recoverable over the HTTPS heartbeat.

Log / triệu chứngLog / symptomÝ nghĩa / nguyên nhânMeaning / likely cause
[ea=goldenrobber/vantage::…/21478308] connected inst=node#inst ea=GoldenRobber ver=1.42-b1 …EA đã xác thựcEA authenticated
[ea=…] disconnected inst=… (peer_closed|timeout|superseded|…)Socket đóngSocket closed
cfg rev=… slug=… targets=N delivered=MFan-out tín hiệu. delivered=0 chỉ nghĩa là EA đích không kết nối (vẫn nhận config ở heartbeat HTTPS). Nếu EA đang online mà vẫn 0: slug / composite_id / magic trong publish không khớp chính xác HELLO (hoa thường, magic khác số).Signal fan-out. delivered=0 just means the target isn't connected (it still gets config on the HTTPS heartbeat). If the EA is online and it's still 0: slug / composite_id / magic in the publish don't exactly match the HELLO (case, a different magic).
hello rejected: bad_sig — … cho 1 EAfor one EAEA đó có token cũ/saiThat EA has a stale/wrong token
Mọi EA auth_unavailable; log [ealink] key lookup slug=… failed / no broker internal secretAll EAs auth_unavailable; log [ealink] key lookup slug=… failed / no broker internal secretBROKER_INTERNAL_URL sai, fxconsole-web chết, hoặc không tìm thấy secret (DB_* thiếu/không nối được, Redis mirror trống, env trống). Log khởi động có settings DB: on/off.BROKER_INTERNAL_URL wrong, fxconsole-web down, or no secret found (DB_* missing/unreachable, Redis mirror empty, env empty). The startup log shows settings DB: on/off.
unknown_eaSlug chưa đăng ký trên fxconsole (hoặc token product trống)Slug not registered in fxconsole (or the product token is empty)
FATAL: certs/server.{key,cert} missingVolume certs/ không ghi được, openssl lỗi lúc boot đầucerts/ volume not writable, openssl failed at first boot
Redis main error: getaddrinfo ENOTFOUND fxconsole-redisKhông ở network fxconsole_default: bật fxconsole trướcNot on fxconsole_default: bring fxconsole up first

Firewall: mở TCP/9607 ở OVH, port forward WAN:9607 → <LAN IP docker host>:9607 ở IPFire, và thêm hostname broker vào danh sách Allow WebRequest trên mọi terminal (cùng danh sách mà heartbeat HTTPS của EA đã cần).

Firewall: accept TCP/9607 on OVH, port-forward WAN:9607 → <docker host LAN IP>:9607 on IPFire, and add the broker hostname to Allow WebRequest on every terminal (the same list the EA's HTTPS heartbeat already needs).