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.
So sánh bốn brokerThe four brokers compared
| TickBroker | AccountStatusBroker | VpsStreamBroker | EaLinkBroker | |
|---|---|---|---|---|
| ContainerContainer | fxconsole-tick-broker | fxconsole-account-status-broker | fxconsole-vps-stream-broker | fxconsole-ea-link-broker |
| Cổng TLS-TCPTLS-TCP port | 9600 | 9602 | 9605 | 9607 |
| Cổng WebSocketWebSocket port | 9601 (/ws) | 9603 (/status-ws) | 9606 (/vps-ws, chỉ nội bộ)9606 (/vps-ws, internal only) | — |
| ClientClient | TickStreamerPub EA | AccountStatusUpdater EA | vps-agent qua stunnelvps-agent via stunnel | EA dùng Include/EaLink.mqh (GoldenRobber 1.42+)EAs using Include/EaLink.mqh (GoldenRobber 1.42+) |
| Khung dữ liệuFraming | Dòng HELLO pipe + JSON LinesPipe HELLO line + JSON Lines | Dòng HELLO pipe (v2) + JSON LinesPipe HELLO line (v2) + JSON Lines | NDJSON {"t":…} | NDJSON {"t":…} |
| Auth | Secret chung TICK_BROKER_SECRETShared secret TICK_BROKER_SECRET | Secret chung BROKER_SECRETShared secret BROKER_SECRET | vpses.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 present | Chạy TCP thường (MQL5 cần TLS)Falls back to plain TCP (MQL5 needs TLS) | Chạy TCP thườngFalls back to plain TCP | FATAL | FATAL |
| 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_ms | broker_admin_secret, broker_internal_secret | broker_internal_secret |
| Gọi LaravelCalls Laravel | — | — | POST /api/internal/broker/command-ack | POST /api/internal/ea-link/key |
| Redis subscribeRedis subscriptions | — | report:request | laravel-database-vps:cmd, laravel-database-vps:state | PSUBSCRIBE *ea:link:cfg |
| Buffer / dòng tối đaMax buffer / line | 64 KiB | 1 MiB | 256 KiB / dòng 64 KiB256 KiB / 64 KiB line | 64 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ọifxconsole-redisvàfxconsole-web. Luôn bật stack fxconsole trước. - Dockerfile
node:20-alpine+ openssl; lệnh khởi độngnode gen-cert.js "${CERT_HOST:-localhost}" && node broker.js. Lần boot đầu sinhcerts/server.key+certs/server.certself-signed, RSA 2048, hạn 10 năm (3650 ngày), CN =CERT_HOST. Cert có sẵn thì bỏ qua. Volume./certsphải ghi được. - Đổi CN:
docker compose down, xoácerts/,docker compose up -d. REDIS_PREFIX(mặc địnhlaravel-database-) phải trùngredis.options.prefixcủ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ừ commitcb272d5). - Chạy
node broker.jstrực tiếp thì.envkhô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_defaultnetwork to reachfxconsole-redisandfxconsole-web. Always bring the fxconsole stack up first. - Dockerfile
node:20-alpine+ openssl; start commandnode gen-cert.js "${CERT_HOST:-localhost}" && node broker.js. First boot writes a self-signedcerts/server.key+certs/server.cert, RSA 2048, valid 10 years (3650 days), CN =CERT_HOST. Existing certs are kept. The./certsvolume must be writable. - Changing the CN:
docker compose down, deletecerts/,docker compose up -d. REDIS_PREFIX(defaultlaravel-database-) must match Laravel'sredis.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 commitcb272d5). - Running
node broker.jsdirectly 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.
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:
| Frame | Xử 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. |
NOOP | Keep-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/totaltuỳ 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;seq0 đế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.timelàdatetimeMQL5 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):fullthay toàn bộ snapshot;delta(mỗi ~30s, chỉ vài giờ quanh hiện tại) được merge vào snapshot theoid(hoặctime|cur|namekhi không có id). Frame không cómodeluôn là full. Delta chỉ log khiBROKER_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/totalare 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 newseq0 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.timeis an MQL5datetimeon 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):fullreplaces the snapshot;delta(every ~30s, only a few hours around now) is merged into the snapshot byid(ortime|cur|namewithout an id). Frames withoutmodeare always full. Deltas are logged only withBROKER_DEBUG=1.
WebSocket (9601, nginx /ws)WebSocket (9601, nginx /ws)
| HướngDirection | Frame |
|---|---|
| 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
| Key | Nộ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). |
brokers | Set tên brokerSet of broker names |
broker:<broker>:symbols | Set 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ếnVariable | Mặc địnhDefault | Ý nghĩaMeaning |
|---|---|---|
TICK_BROKER_SECRET | bắt buộcrequired | Trùng InpSharedSecret của TickStreamerPub. Trống → FATAL, thoát.Equals InpSharedSecret on TickStreamerPub. Empty → FATAL, exit. |
REDIS_URL | redis://127.0.0.1:6379 (compose: redis://fxconsole-redis:6379)(compose: redis://fxconsole-redis:6379) | Redis |
BROKER_HOST / BROKER_PORT | 0.0.0.0 / 9600 | Listener TLS-TCP cho EATLS-TCP listener for EAs |
WS_HOST / WS_PORT | 0.0.0.0 / 9601 | Listener WebSocket cho trình duyệtBrowser WebSocket listener |
BROKER_DEBUG | 0 | 1 = log mọi subscribe, dòng lạ, delta lịch1 = log every subscribe, unknown lines, calendar deltas |
CERT_HOST | fxconsole.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 + revsequenceDiagram
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 + revKiể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ứngSymptom | Nguyên nhân / cách xử lýCause / fix |
|---|---|
FATAL: TICK_BROKER_SECRET is empty | Chư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|auth | InpSharedSecret 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 — disconnecting | Mộ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 MT5 | CERT_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-redis | Chư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.secretphải bằngBROKER_SECRET; tên broker ≤ 64 ký tự.- Sai bất kỳ điều gì → broker ghi dòng thô
ERR|auth_or_unsupported_hello_versionrồi đóng. Không có framehello_ok. - HELLO hợp lệ: nếu có socket cũ cùng
composite_idthì đóng socket cũ; broker gửi ngaystream_on(nếu đang có người xem) hoặcstream_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.secretmust equalBROKER_SECRET; broker name ≤ 64 chars.- Anything wrong → the broker writes the raw line
ERR|auth_or_unsupported_hello_versionand closes. There is nohello_okframe. - Valid HELLO: any older socket with the same
composite_idis closed; the broker immediately sendsstream_on(if someone is watching) orstream_off.
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ướngDir | Frame | Khi nào / xử lýWhen / handling |
|---|---|---|
| EA→S | JSON 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→S | NOOP | Bỏ 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 sends | Kế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_keys | Set 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_ms | Mirror 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ếnVariable | Mặc địnhDefault | Ý nghĩaMeaning |
|---|---|---|
BROKER_SECRET | bắt buộcrequired | Trù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_SECRET | trốngempty | Tầ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_PASSWORD | trống, port 3306empty, port 3306 | Tuỳ 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_PREFIX | redis://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_PORT | 0.0.0.0/9602, 0.0.0.0/9603 | Tránh trùng TickBroker 9600/9601Keep clear of TickBroker 9600/9601 |
HB_INTERVAL_SECS | 15 | Mặ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_MS | 900 | Mặ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_DEBUG | 0 | 1 = log mọi frame, subscribe, broadcast stream_on/off1 = log every frame, subscribe, stream_on/off broadcast |
CERT_HOST | fxconsole.gamelau.uk (compose)(compose) | Phải trùng InpBrokerHost của EA (SNI).Must match the EA's InpBrokerHost (SNI). |
TZ | Asia/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ứngSymptom | Nguyê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 reason | InpBrokerSecret ≠ 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 mismatch | Payload 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 — disconnecting | Hơ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_unauthorized | broker_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 watches | hb_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) | <500ms | nhanh 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ướngDir | Frame | Ghi 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 → brokerUPDATE vps_commandstrự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ẹtpending. Luôn broadcastvps_cmdcho admin. - Gate stream: tổng subscriber WS (admin + per-key) 0 → N gửi
stream_oncho mọi agent (payload đầy đủ WMI +logins), N → 0 gửistream_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 runsUPDATE vps_commandsitself (only when not yet terminal) and warns "mt5_accounts NOT cascaded -- check broker_internal_secret and fxconsole reachability": the account stays stuck atpending.vps_cmdis always broadcast to admins. - Stream gate: total WS subscribers (admin + per-key) 0 → N sends
stream_onto every agent (full WMI payload +logins), N → 0 sendsstream_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.
| Frame | Ai 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 model | Hook | Channel | Payload |
|---|---|---|---|
Vps | updated (đổi status)updated (status changed) | vps:state | {kind:"vps", id, status} |
Mt5Account | created + updated (đổi status)created + updated (status changed) | vps:state | {kind:"account", id, vps_id, login, status, event, error} |
VpsCommand | created | vps:cmd | {vps_id, cmd_id, kind} |
VpsCommand | updated (đổ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ếnVariable | Mặc địnhDefault | Ý nghĩaMeaning |
|---|---|---|
BROKER_HOST / BROKER_PORT | 0.0.0.0 / 9605 | Listener TLS cho agent (khác 9602)TLS listener for agents (distinct from 9602) |
WS_HOST / WS_PORT | 0.0.0.0 / 9606 | WebSocket trình duyệt (nội bộ)Browser WebSocket (internal) |
BROKER_ADMIN_SECRET | trốngempty | Tầ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_URL | http://fxconsole-web | nginx 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_SECRET | trốngempty | Tầ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_MS | 5000 | Timeout HTTP callbackCallback HTTP timeout |
REDIS_URL, REDIS_PREFIX | redis://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_PASSWORD | code: 127.0.0.1, 3306, fxconsole, root, trốngcode: 127.0.0.1, 3306, fxconsole, root, empty | Bắ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_HOST | fxconsole.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_DEBUG | 0 | 1 = log HELLO, heartbeat, cmd push, đóng socket1 = log HELLO, heartbeats, cmd pushes, disconnects |
PING_INTERVAL / PING_TIMEOUT | 30 / 10 (s) | Giữ NAT, phát hiện socket nửa mởKeeps NAT warm, detects half-open sockets |
HEARTBEAT_TTL | 15 (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. |
TZ | Asia/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_cmdsequenceDiagram
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_cmdCổ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
- Bật fxconsole (
cd monitor/fxconsole && docker compose up -d) để có networkfxconsole_default.Bring fxconsole up (cd monitor/fxconsole && docker compose up -d) sofxconsole_defaultexists. cp .env.example .env, điềnCERT_HOST,DB_*(copy từ fxconsole),REDIS_URL.cp .env.example .env, fillCERT_HOST,DB_*(copied from fxconsole),REDIS_URL.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://).- 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. - 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. - Cert CA cho agent: compose của fxconsole mount
../VpsStreamBroker/certsread-only vàofxconsole-apptại/broker-certs, nênGET /api/agent/broker-configtự phục vụ cert. Cách thủ công cũ (vẫn chạy được):Agent CA cert: fxconsole's compose mounts../VpsStreamBroker/certsread-only intofxconsole-appat/broker-certs, soGET /api/agent/broker-configserves 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ứngSymptom | Nguyên nhân / cách xử lýCause / fix |
|---|---|
FATAL: certs/server.{key,cert} missing | Sinh 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 fails | CERT_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-redis | Chưa join fxconsole_default.Not on fxconsole_default. |
resolveVpsBySecret: ER_ACCESS_DENIED_ERROR | DB_USER/DB_PASSWORD khác fxconsole.DB_USER/DB_PASSWORD differ from fxconsole. |
hello_err reason=bad_secret | secret_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 cascaded | Callback /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 runs | Key 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. |
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.
EaLinkBroker (9607)(9607)
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.
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.
FrameFrames
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ướngDir | Frame | Khi 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 close | Xá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 close | Server chủ động đóngServer-initiated close |
| Field HELLOHELLO field | Quy tắcRule | Ví 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 160 | vantage::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 |
nonce | Phải bằng nonce của challengeMust equal the challenge nonce | |
sig | HMAC hex (xem dưới)Hex HMAC (below) | |
ea, ea_ver | Tự do, chỉ để logFree text, logged only | GoldenRobber, 1.42-b1 |
Xác thựcAuthentication
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_format | Field sai quy tắc hoặc nonce không khớpA field failed validation or the nonce echo doesn't match |
unknown_ea | Laravel trả 404 cho slug (cache âm 10s)Laravel returned 404 for the slug (negative cache 10s) |
auth_unavailable | Khô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_sig | HMAC sai (sai token hoặc sai bố cục message)HMAC mismatch (wrong token or message layout) |
timeout | Không có HELLO trong HELLO_GRACE_MSNo HELLO within HELLO_GRACE_MS |
bye
superseded | Cùng topic + cùng node_id#inst kết nối lạiSame topic + same node_id#inst reconnected |
timeout | Im 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_overflow | Vượt 8 KiB / 64 KiBOver 8 KiB / 64 KiB |
shutdown | Broker 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.
Redis
| LoạiKind | Key / channelKey / channel | Ghi chúNotes |
|---|---|---|
| Presence | HSET ${REDIS_PREFIX}ea_link:presence:{slug}:{composite_id}:{magic} fieldfield {node_id}#{inst} = unix giâysec, EXPIRE 90 | Ghi 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 signal | PSUBSCRIBE *ea:link:cfg | Khớ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ến môi trườngEnvironment
| BiếnVariable | Mặc địnhDefault | Ý nghĩaMeaning |
|---|---|---|
BROKER_HOST / BROKER_PORT | 0.0.0.0 / 9607 | Khác 9602 và 9605/9606Distinct from 9602 and 9605/9606 |
REDIS_URL | redis://127.0.0.1:6379 (compose: redis://fxconsole-redis:6379)(compose: redis://fxconsole-redis:6379) | Redis |
REDIS_PREFIX | laravel-database- | Trùng redis.options.prefix của LaravelMust match Laravel redis.options.prefix |
BROKER_INTERNAL_URL | http://fxconsole-web | nginx 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_SECRET | trốngempty | Tầng cuối (sau DB settings và Redis mirror)Last resort (after DB settings and the Redis mirror) |
BROKER_INTERNAL_TIMEOUT_MS | 3000 | Timeout lookup keyKey-lookup timeout |
DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASSWORD | trống, 3306empty, 3306 | MySQL 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_HOST | localhost (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_DEBUG | 0 | 1 = log challenge / hb / cfg_ack / đóng trước auth1 = log challenge / hb / cfg_ack / pre-auth closes |
HB_TIMEOUT_SEC | 60 | Đó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_MS | 5000 | Thời gian từ TLS connect tới HELLO hợp lệTime from TLS connect to a valid HELLO |
KEY_CACHE_SEC | 60 | Đổ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 |
TZ | Asia/Ho_Chi_Minh | Giờ logLog timezone |
LuồngFlow
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 oksequenceDiagram
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 okDeploy và kiểm traDeploy and verify
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 và xử lý sự cốLogs and troubleshooting
| 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=M | Fan-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 EA | EA đó 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 secret | BROKER_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_ea | Slug 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} missing | Volume 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-redis | Khô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).