VIBEHOST V2 — Kiến trúc hệ thống

Tài liệu này trả lời sáu câu: kiến trúc hai nửa · Panel giữ gì · Wings làm gì và ghi ở đâu ·
node dựng thế nào và liên lạc qua đâu · bảo mật và xác thực · tên miền được gán ra sao.

Nguồn sự thật là code (vays-panel/, wings/) và hệ đang chạy — không phải bản thiết kế.


1. Kiến trúc — hai nửa, một ranh giới

VIBEHOST V2 (tên trong repo: VAYS) tách làm hai, ranh giới là interface Executor:

Rendering Mermaid…
graph TB U["Khách<br/>trình duyệt"] -->|HTTPS| P subgraph PANEL["PANEL — vays-panel/ (Next.js 16)"] P["Route handlers<br/>117 endpoint"] --> DB[("Postgres 16<br/>nguồn sự thật")] P --> R[("Redis<br/>hàng đợi")] P --> M[("MinIO<br/>tệp/ảnh")] P --> EX{"Executor"} EX -->|"WINGS_ENABLED=false"| SIM["SimulatedExecutor<br/>(mô phỏng)"] EX -->|"WINGS_ENABLED=true"| WE["WingsExecutor"] end WE -->|"HTTPS + Bearer"| W subgraph NODE["NODE — wings/ (Go, fork Pterodactyl)"] W["Wings daemon :8080"] -->|docker.sock| D["Docker Engine"] D --> C1["vays-app-&lt;uuid8&gt;<br/>website khách"] D --> C2["container CSDL"] end W -.->|"Bearer &lt;tokenId&gt;.&lt;token&gt;<br/>/api/remote/*"| P C1 -.->|nhãn Traefik| T["Traefik của node"] U -->|HTTPS| T

Nguyên tắc chia việc:

Panel Wings
Giữ Nghiệp vụ + toàn bộ dữ liệu Không giữ dữ liệu nghiệp vụ nào
Biết Khách là ai, gói gì, hạn mức, lịch sử Chỉ biết "dựng container này từ ảnh kia"
Mất thì Mất tất cả Mất chỗ chạy, không mất dữ liệu tài khoản
Nhân bản Một bản duy nhất Nhiều node

Ranh giới Executor (vays-panel/src/server/executor/) cho phép đổi thật ↔ mô phỏng bằng một
biến môi trường
mà không đụng UI/nghiệp vụ. WINGS_ENABLED=false là mặc định an toàn.


2. Panel — có gì, lưu gì

2.1 Thành phần

Thành phần Ảnh Vai trò
vibehost-panel build từ vays-panel/ (Next.js 16.2, Node 22) Giao diện + 117 route handler + poller inline
vibehost-worker cùng ảnh panel Tiến trình runner thứ 2, chạy scripts/poller.ts
vibehost-pg postgres:16 Nguồn sự thật
vibehost-redis redis:7-alpine Hàng đợi/phiên
vibehost-minio minio/minio Ảnh đại diện, ZIP nguồn deploy

Vì sao có worker riêng: poller inline của panel claim 1 job/nhịp, mà một lượt AI-fix có thể
chạy tới 16 phút → một deploy chặn toàn bộ hàng đợi. Worker là runner thứ hai; hai bên claim job
bằng lease lockedBy + FOR UPDATE SKIP LOCKED nên không giành cùng một job.

2.2 Nhóm API (117 route handler)

Nhóm Ai gọi Xác thực
/api/auth/* Trình duyệt Auth.js v5, Credentials + JWT
/api/v1/* Giao diện khách + API ngoài Phiên đăng nhập hoặc PAT
/api/admin/* Quản trị viên Phiên + requireRole(["SUPERADMIN"])
/api/remote/* Wings gọi vào Bearer <tokenId>.<token> — xem §5
/api/mcp Trợ lý AI/agent PAT
/api/internal/* Chính panel (loopback) INTERNAL_API_TOKEN

2.3 Panel lưu cái gì, ở đâu

Dữ liệu Nơi lưu Ghi chú
Tài khoản, gói, subscription Postgres · User, Plan, UserSubscription Mật khẩu argon2id; hash PBKDF2 nhập từ V1 được nhận diện tự động
Dự án, deployment, job, node Postgres · Project, Deployment, Job, Node Job là hàng đợi thật (không phải Redis)
Biến môi trường của khách Postgres · EnvVar.valueRef Niêm AES-256-GCM, không bao giờ ở dạng thô
Token của node Postgres · Node.tokenSealed Niêm cùng cơ chế, AAD = node:<uuid>
Credential CSDL khách DatabaseInstance.credentialRef Niêm; hiện đúng một lần lúc tạo/xoay
Ảnh đại diện, ZIP nguồn MinIO bucket vays-sources
Số đo node Postgres · NodeMetric Nhịp lấy mẫu cấu hình theo node (metricIntervalSec)

Cơ chế niêm (server/wings/envelope.ts): AES-256-GCM, khoá WINGS_ENV_KEY (32 byte hex), mỗi
loại bí mật có AAD riêngenv:<projectId>, envvar:<projectId>:<key>, db:<databaseId>,
node:<uuid>, integ:<userId>:<provider>. AAD khác nhau nghĩa là bản niêm của node không dùng
lại được cho project
, kể cả khi lộ. Bản niêm mang kid; xoay khoá mà không xoay bản niêm thì mở
ra lỗi rõ ràng (kid=… khác khoá hiện tại), không hỏng lặng.


3. Wings — làm gì, ghi ở đâu, path nào

3.1 Làm gì

Wings là tay chân, không có nghiệp vụ. Nó nhận lệnh từ Panel và thao tác Docker Engine qua
docker.sock. Nhóm endpoint chính (wings/router/router.go):

Nhóm Endpoint Việc
Hệ thống /api/system, /api/system/metrics, /api/system/restart Thông tin node, số đo CPU/RAM/đĩa/mạng
Server /api/servers, /api/servers/:uuid/* Vòng đời server-shell
VAYS /api/servers/:uuid/vays/* env-inject · deploy-run · rollback · releases · snapshot · snapshot-restore · snapshots · scan-repo · analyze · env-suggest · agent-run · app-logs · domain-set
Dọn dẹp /api/vays/reconcile Liệt kê + xoá tài nguyên mồ côi — chỉ thứ mang nhãn com.vays.owner=vays-wings
Tương tác /api/servers/:uuid/vays/pty, /ws Terminal + log realtime

3.2 Ghi ở đâu — đường dẫn thật trên node

Đường dẫn Nội dung Ai tạo
/var/lib/vays-wings/ Thư mục gốc dữ liệu wings WINGS_ROOT_DIR
/var/lib/vays-wings/volumes/<uuid>/ Thư mục làm việc của từng website khách wings
/var/lib/vays-wings/archives/, /backups/ Kho lưu / snapshot wings
/var/lib/vays-wings/states.json Trạng thái server ghi nhớ qua lần khởi động lại wings
/var/log/vays-wings/ Nhật ký wings
/tmp/vays-wings/ Tạm wings
/etc/vays-node/node.env UUID BỀN của node install-vays-node.sh
/srv/vays-traefik/dynamic/ File route tên miền riêng (custom-<uuid8>.yml) wings ghi, Traefik đọc

⚠️ /etc/vays-node/node.env là thứ không được mất. Node.uuid bất biến và
BuildSlot.nodeKey / Deployment.nodeUuid / DatabaseInstance.nodeUuid đều trỏ theo nó. Xoá
tệp rồi cài lại = đẻ ra một node mà Panel không biết là node nào, và lịch sử deploy trỏ vào một
định danh không còn tồn tại.

3.3 Mạng trên node

Mạng Dùng cho
vays_nw (mặc định 10.78.0.0/16) Mạng quản container do wings tự tạo lúc khởi động
Mạng biên (VIBE_PREVIEW_NETWORK) Container ứng dụng join vào để Traefik thấy

4. Node — dựng thế nào, liên lạc qua đâu

4.1 Hai kiểu node

Node tự dựng (máy trắng) Node cắm vào biên có sẵn
Script deploy/install-vays-node.sh docker run tối thiểu
Traefik Script dựng riêng, chiếm 80/443 Dùng Traefik đang có
Ví dụ đang chạy cmc-1.vibenode.matbao.ai demohoadon.vibenode.matbao.ai (trên máy V1)
Khi nào Máy chưa có gì Máy đã phục vụ khách, không được restart docker

4.2 Dựng node — bốn bước

# 1. Chở ảnh sang (node KHÔNG kéo từ registry — xem §4.4)
bash deploy/ship-images-to-node.sh root@<ip-node>

# 2. Dựng node lần đầu (token tạm — bước 5 sẽ cấp token riêng)
PANEL_REMOTE_URL=https://vibehost.matbao.ai \
WINGS_TOKEN=<secret tạm> \
WINGS_NODE_TOKEN_ID=<id tạm> \
NODE_DOMAIN=n2.vibenode.matbao.ai \
bash install-vays-node.sh          # in ra UUID node

# 3. DNS: *.<NODE_DOMAIN> và wings.<NODE_DOMAIN> → IP node

# 4. Đăng ký vào Panel: /admin/nodes → "+ Thêm node", hoặc
#    npx tsx prisma/seed-node.ts  (đọc WINGS_* từ env của panel)

# 5. CẤP TOKEN RIÊNG cho node (bắt buộc — xem §5.4). Chạy TRONG container panel:
#    npx tsx scripts/rotate-node-token.ts --uuid=<uuid node>
#    → in ra WINGS_TOKEN_ID + WINGS_TOKEN mới; đặt cho wings của node rồi dựng lại nó.
#    THỨ TỰ: DB trước, node sau. Giữa hai bước node sẽ 401 cho tới khi được dựng lại.

Từ 03/08 mỗi node có credential riêng; không còn dùng chung token toàn cục của panel. Node
chưa cấp token riêng chỉ đi được khi REMOTE_STRICT_NODE_AUTH chưa bật — trên prod cờ này đã
bật, nên bước 5 là bắt buộc, không phải tuỳ chọn.

Node mới thêm vào hệ đã có node khác sinh ra ở trạng thái schedulable=false,
status=provisioningkhông nhận việc. Mở nhận việc là một thao tác tường minh.

4.3 Liên lạc — hai chiều, hai đường khác nhau

Panel ──HTTPS──▶ Traefik của node :443 ──▶ Wings :8080     (điều khiển)
      Authorization: Bearer <WINGS_TOKEN>

Wings ──HTTPS──▶ Panel /api/remote/*                        (nạp cấu hình, báo trạng thái)
      Authorization: Bearer <tokenId>.<token>

Panel dựng URL từ ba trường của hàng Node: scheme://fqdn:port — production luôn là
https://…:443 vì đường này đi qua Internet.

⚠️ Cổng 8080 của wings KHÔNG mở ra ngoài. Nó chỉ nghe loopback (127.0.0.1:8080) để chẩn
lỗi; đường vào duy nhất từ Internet là qua Traefik có TLS.

4.4 Phân phối ảnh

Node không kéo ảnh từ registry lúc cài: ảnh ghcr.io/matbao/vays-wings:* chưa từng được push
lên ghcr, và ảnh nền server-shell mang tên registry.vayg.cloud/vibe-base:latest là registry của
hệ cũ. Ảnh được chở thẳng bằng docker save | ssh | docker load
(deploy/ship-images-to-node.sh), đối chiếu bằng image ID, không bằng tên tag — tag :latest
là tag di động, so theo tên sẽ báo "đã có" trong khi node vẫn chạy ảnh cũ.

Registry nội bộ registry.matbao.ai (container vibe-registry trên panel host, htpasswd,
data /srv/vibe-registry/data) dùng cho phân phối ảnh và cho đợt di trú V1. Tên cũ
registry.vayg.cloud vẫn được router nhận trong lúc chuyển.

Tiền tố ~ trước tên ảnh = ảnh LOCAL, đừng pull (quy ước của wings). Vì vậy
~registry.vayg.cloud/vibe-base:latest chỉ là cái nhãn, không phải địa chỉ để tải.


5. Bảo mật và xác thực

5.1 Bốn đường xác thực, không đường nào dùng chung khoá

Đường Ai → ai Cơ chế Nguồn
Người dùng Trình duyệt → Panel Auth.js v5, Credentials + JWT, argon2id server/auth/
Panel → Wings Panel → node Bearer <WINGS_TOKEN> server/wings/client.ts
Wings → Panel node → /api/remote/* Bearer <tokenId>.<token>, so bằng timingSafeEqual server/wings/remoteAuth.ts
Terminal (PTY) Trình duyệt → Wings Token ngắn hạn <serverUUID>.<expiry>.<HMAC-SHA256> router_server_vibe.go

Token PTY đáng chú ý: trình duyệt không bao giờ cầm khoá node. Panel ký một token gắn với
đúng một server + một hạn dùng; token rò rỉ chỉ mở được terminal của đúng container đó trong vài
phút, không hành động thay node được.

5.2 Phân quyền

requireUser() xác thực lại với DB mỗi lượt thay vì tin JWT: token có thể được cấp trước khi
tài khoản bị đình chỉ / đổi gói / đổi vai. Vai: SUPERADMIN · ADMIN · CUSTOMER.

5.3 Cô lập trên node

Lớp Cách
Nhãn sở hữu Chỉ container/volume/ảnh mang com.vays.owner=vays-wings mới bị reconcile đụng tới. Tên là không đủ — đã kiểm bằng test
Cô lập tenant Luật iptables vays-isolate chặn container↔container giữa các tenant trên mạng biên, chừa Traefik
Chặn metadata vays-egress chặn 169.254.169.254 + link-local + loopback — container khách đọc được metadata đám mây là lấy được credential của MÁY CHỦ
gVisor (tuỳ chọn) VAYS_RUNTIME=runsc chạy container khách trong sandbox
Tiến trình canh vays-node-guard.timer mỗi 60s dựng lại luật iptables — luật không tự bền qua reboot, và IP Traefik đổi sau mỗi lần dựng lại

Sự cố thật đứng sau lớp canh: bản đầu ghim CỨNG IP Traefik lúc cài. Node khởi động lại → IP đổi →
luật DROP bắt-tất nuốt luôn Traefik → mất toàn bộ định tuyến của node.

5.4 Cô lập giữa các node — đã vá 03/08

Trước bản vá có hai lỗ nối nhau, và cái sau là hệ quả của cái trước:

# Lỗ Hệ quả
1 assertNodeAuth() so token với env TOÀN CỤC Mọi node dùng chung một credential ⇒ thu hồi một node là thu hồi tất cả. Và hàm không nói được request đến từ node NÀO
2 /api/remote/servers gọi listServerShells() không điều kiện Trả server của mọi node

Đây không phải suy đoán. Đo ngày 03/08 khi cắm node thứ hai: nó lập tức kéo danh sách của node
thứ nhất và đã thử dựng lại workload của node kia trên chính nó — chỉ trượt vì máy đó thiếu ảnh
nền. Rào khi ấy là một tai nạn may mắn, không phải cơ chế.

Nay đã vá:

Lớp Cách
Định danh node tokenId trong Bearer <tokenId>.<token>khoá tra hàng Node (cột Node.tokenId, @unique)
Xác minh Mở Node.tokenSealed (AAD node:<uuid>) rồi so hằng-thời-gian. Bản niêm của node này không dùng lại được cho node khác
Lọc danh sách listServerShells(node.uuid) — Project qua Deployment.nodeUuid, DatabaseInstance qua nodeUuid, Stack qua User.preferredNodeId
Chặn truy cập chéo assertServerOnNode() trên /servers/:uuid:uuid/install404 (không phải 403: node không có quyền biết server đó CÓ TỒN TẠI hay không)
Đóng đường cũ REMOTE_STRICT_NODE_AUTH=true — token toàn cục trả 401

Nghiệm trên hệ thật sau khi vá: wings.cmc-1 nạp total_configs=1 (đúng 1 website của nó),
v1-beta-hoadon nạp total_configs=10 (đúng 10 website của nó) — không node nào thấy 11.
Token toàn cục cũ trả 401. Bộ test scripts/remote-node-scope-smoke.ts13 khẳng định,
trọng tâm là ca âm (tokenId của A + secret của B bị từ chối; node A không thấy và không đọc được
server của node khác) và ca ngược (node sở hữu vẫn đọc được — chặn quá tay cũng là hỏng).

Đổi token của một node: scripts/rotate-node-token.ts --uuid=<node> sinh secret mới và niêm
vào DB, rồi đặt WINGS_TOKEN_ID/WINGS_TOKEN mới cho wings của node đó. Thứ tự bắt buộc là
DB trước, node sau — trong khoảng giữa, chiều Panel→Wings dùng token mới mà node còn token cũ
nên sẽ 401 cho tới khi node được dựng lại. Đổi xong kiểm total_configs trong log wings.

5.5 Còn lại một chỗ có chủ ý

Wings giữ docker.sock — toàn quyền trên Docker Engine của node. Đây là bản chất của nó (là
tay chân thực thi), bù bằng nhãn sở hữu com.vays.owner + luật iptables + gVisor tuỳ chọn.

6. Proxy và tên miền

6.1 Ba loại địa chỉ

Loại Dạng Cấp bởi
Panel vibehost.matbao.ai Traefik của panel host
Website khách <slug>.<zone-của-node> Nhãn Docker do wings gắn
Tên miền riêng của khách tên miền bất kỳ File dynamic wings ghi ra

6.2 Website khách — đi bằng NHÃN DOCKER

Wings gắn nhãn lúc tạo container (router_server_vibe_docker.go):

traefik.enable                                    = true
traefik.docker.network                            = <VIBE_PREVIEW_NETWORK>
traefik.http.routers.<app>.rule                   = Host(`<slug>.<domain>`)
traefik.http.routers.<app>.entrypoints            = websecure
traefik.http.routers.<app>.tls.certresolver       = <VIBE_CERTRESOLVER>       (mặc định le)
traefik.http.routers.<app>.middlewares            = <VIBE_ROUTER_MIDDLEWARES> (mặc định mb-local@file)
traefik.http.services.<app>.loadbalancer.server.port = <cổng app>
traefik.http.routers.<app>.priority               = 1000

Bốn chi tiết, mỗi cái sinh ra từ một sự cố:

  1. priority=1000 — để bản deploy luôn thắng router cũ cùng Host. Hai router cùng Host mà cùng
    priority thì Traefik round-robin → 502 chập chờn. Đây cũng là thứ làm cắt chuyển V1→V2 tự
    sạch
    : router cũ không khai priority (= độ dài rule, nhỏ hơn nhiều) nên thua ngay khi bản mới có mặt.
  2. traefik.docker.network — ghim mạng, không thì Traefik lấy nhầm IP vays_nw → 502 im lặng.
  3. certresolver / middlewares đọc từ env — biên tự dựng đặt tên le/mb-local@file, biên
    có sẵn đặt tên khác. Router trỏ vào resolver/middleware không tồn tại thì Traefik bỏ luôn
    router
    : xanh trong Panel, 404 ngoài Internet, không một dòng lỗi phía ta.
    VIBE_ROUTER_MIDDLEWARES=- nghĩa là "biên này không có middleware nào" ⇒ bỏ hẳn nhãn; gắn
    nhãn rỗng không tương đương (Traefik hiểu là middleware tên "" rồi vẫn loại router).
  4. Chỉ bản prod mang nhãn — bản candidate (blue-green) KHÔNG có nhãn Traefik nên không nhận
    traffic; Panel dò nó bằng exec-in-container.

6.3 Tên miền riêng — đi bằng FILE

Không gắn thêm nhãn vì đổi nhãn là phải dựng lại container → mất secret trong tmpfs + gián
đoạn. Thay vào đó wings ghi custom-<uuid8>.yml vào VIBE_TRAEFIK_DYNAMIC_DIR
(/srv/vays-traefik/dynamic), Traefik watch nạp ngay:

http:
  routers:
    cust-vidu-com:
      rule: "Host(`vidu.com`)"
      entryPoints: [websecure]
      tls: { certResolver: le-http }      # HTTP-01 per-host
      service: vays-app-<uuid8>@docker

6.4 Luật của biên dùng chung

Khi node nằm trên biên nhiều team dùng chung:


7. Hệ đang chạy (03/08/2026)

Máy Vai trò
Panel 103.15.50.89 vibehost.matbao.ai · Postgres/Redis/MinIO · registry registry.matbao.ai · GitLab Runner
Node cmc-1 203.171.31.200 Traefik riêng · zone *.cmc-1.vibenode.matbao.ai
Node hoadon 103.15.50.109:22026 Cắm vào Traefik có sẵn của V1 · zone *.hoadon.online · 10 website đã di trú

CI/CD: merge main → GitLab Runner (tag vibehost-prod, chạy ngay trên máy panel) →
deploy/deploy-vibehost.sh.


8. Tài liệu liên quan

Tệp Nội dung
docs/ARCH.md · docs/PRD-SRS.md Kiến trúc gốc + đặc tả FR/NFR
docs/CICD-VIBEHOST.md Vận hành panel, bí mật, rollback
docs/BAO-CAO-HA-TANG-VIBEHOST-V2.md Kiểm kê hạ tầng, dung lượng, rác
docs/plan/dong-bo-v1-v2/12-NODE-TREN-MAY-V1.md Dựng node trên máy đang phục vụ khách
docs/plan/dong-bo-v1-v2/13-NHAT-KY-MIGRATE-HOADON.md Nhật ký di trú V1 → V2
deploy/RUNBOOK.md Vận hành node, bẫy đã trả giá