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:
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 leaselockedBy+FOR UPDATE SKIP LOCKEDnê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êng — env:<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 quadocker.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.envlà thứ không được mất.Node.uuidbấ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 khiREMOTE_STRICT_NODE_AUTHchư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=provisioning — khô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:latestchỉ 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> là 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 và :uuid/install → 404 (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.ts — 13 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 đặtWINGS_TOKEN_ID/WINGS_TOKENmớ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ểmtotal_configstrong 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 = 1000Bốn chi tiết, mỗi cái sinh ra từ một sự cố:
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.traefik.docker.network— ghim mạng, không thì Traefik lấy nhầm IPvays_nw→ 502 im lặng.certresolver/middlewaresđọc từ env — biên tự dựng đặt tênle/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).- 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- Panel gửi TRỌN danh sách tên miền của project mỗi lần thêm/bớt → wings ghi lại cả tệp
(atomic: temp + rename). Không sinh router mồ côi, chạy lại vô hại. - Service trỏ tên container ĐANG CHẠY, không phải tên chuẩn lý thuyết — project deploy từ bản
cũ vẫn mang tên khác, trỏ nhầm thì Traefik trả 404 im lặng. - Tên miền riêng nằm ngoài zone của node ⇒ buộc HTTP-01, không dùng được DNS-01 wildcard.
- Danh sách hậu tố cấm (
.vayg.cloud,.b.matbao.ai,.dev.matbao.ai…) được kiểm ở cả hai
phía — Panel chặn ở form, wings là lằn phòng thủ cuối.
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:
- CẤM
HostRegexp(^.+$)hay bất kỳ router bắt-mọi-hostname. Sự cố thật: một router như vậy
trả 200 kèm cookie phiên của app khác cho mọi hostname chưa có chủ trong vùng wildcard. - Router thật: không khai
priority, hoặc khai> 10. Không khai thì priority = độ dài rule,
luôn thắng router chặn-vùng. Khai≤ 10là chui xuống dưới nó và bị nuốt. - Đừng gỡ
\.?$khỏi regex chặn vùng.x.example.com.(có dấu chấm cuối) vàx.example.com
là cùng một địa chỉ nhưng Traefik không cắt dấu chấm trước khi so.
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á |