Frontdoor — SSH-only Workspaces + Key Management
Status: Proposal — chưa implement Author: Giang • Date: 2026-05-11 Replaces: code-server inside workspace containers Companion doc: Batch Job Queue
1. Vì sao thay đổi
Hiện tại mỗi workspace = container chạy code-server --auth none, expose qua *.6iang.com HTTP, gate bằng CF Access. Workflow đó:
- Phải dùng VS Code trong browser — chậm hơn native, extension hạn chế.
- Image nặng ~600 MB chỉ vì code-server runtime + extensions.
- Không phù hợp với dev local hàng ngày của Giang (Cursor + IDE native).
- Browser code chỉ thật sự hữu ích khi onboard người khác hoặc dùng tablet — không phải case chính.
Mục tiêu: workspace nhẹ, SSH-first, đăng nhập bằng SSH key đã upload và sync vào tất cả workspace.
2. Kiến trúc mới
So với hiện tại: bỏ code-server, bỏ HTTP route per workspace, thay bằng SSH qua CF Access SSH.
3. Workflow user
4. Lifecycle SSH key
Khi key bị revoke → sync ngay (loại key khỏi authorized_keys của tất cả workspace). User đăng nhập tiếp bằng key khác (nếu có), hoặc bị khoá ra ngoài (nếu key cuối cùng).
Guard: UI cảnh báo "đây là key cuối cùng — bạn sẽ bị khoá ra ngoài workspace, tiếp tục?" trước khi cho xoá.
5. Database schema
Migration 0005_ssh_keys.sql
CREATE TABLE ssh_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner_email TEXT NOT NULL,
name TEXT NOT NULL, -- "laptop", "mac mini", "phone"
public_key TEXT NOT NULL, -- toàn bộ "ssh-ed25519 AAAA... comment"
fingerprint TEXT NOT NULL UNIQUE, -- sha256 fingerprint, unique cluster
key_type TEXT NOT NULL, -- ed25519 | rsa | ecdsa
comment TEXT,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
last_used_at INTEGER -- updated by agent on successful ssh
);
CREATE INDEX idx_ssh_keys_owner ON ssh_keys(owner_email);
-- Per-workspace sync state — track xem 1 workspace đã sync chưa.
-- Khi workspace.state='running' và last_synced_at < max(ssh_keys.created_at) → cần re-sync.
CREATE TABLE workspace_key_sync (
workspace_name TEXT PRIMARY KEY REFERENCES workspaces(name) ON DELETE CASCADE,
last_synced_at INTEGER NOT NULL,
key_count INTEGER NOT NULL DEFAULT 0,
last_error TEXT
);
-- Audit log (extends audit_log existing)
-- Không cần bảng mới — dùng existing audit_log với action='ssh-key-add', 'ssh-key-revoke', 'ssh-key-sync'
Sample row
id | 1
owner_email | giangm9@gmail.com
name | laptop-m4
public_key | ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIK... giang@macbook
fingerprint | SHA256:abc123def456...
key_type | ed25519
comment | giang@macbook
6. API endpoints
Admin / UI (CF Access JWT — giangm9@gmail.com)
| Method | Path | Mô tả |
|---|---|---|
GET |
/api/keys |
List keys của user |
POST |
/api/keys |
Add key. Body {name, public_key}. Server parse fingerprint, type |
DELETE |
/api/keys/:id |
Revoke key |
POST |
/api/keys/sync-all |
Broadcast sync tới mọi workspace của user |
POST |
/api/workspaces/:name/sync-keys |
Sync 1 workspace cụ thể |
GET |
/api/workspaces/:name/sync-status |
Trả về {last_synced_at, key_count, last_error} |
Agent jobs mới
| Job type | Payload | Mô tả |
|---|---|---|
syncAuthorizedKeys |
{workspace_name, keys: ["ssh-ed25519 …", …]} |
Ghi đè authorized_keys của 1 container |
Lưu ý: dùng cơ chế jobs table sẵn có (workspace path) — không liên quan batch queue.
7. Image mới (custom workspace image)
image/Dockerfile
FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
openssh-server sudo curl wget git ca-certificates gnupg lsb-release \
build-essential python3 python3-pip python3-venv \
nodejs npm \
tmux htop vim nano less jq ripgrep fd-find \
zsh \
&& rm -rf /var/lib/apt/lists/*
# Node 22 + pnpm (replaces apt nodejs which is old)
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y nodejs \
&& npm install -g pnpm@9
# AWS CLI
RUN curl -fsSL "https://awscli.amazonaws.com/awscli-exe-linux-$(uname -m).zip" -o /tmp/aws.zip \
&& unzip /tmp/aws.zip -d /tmp && /tmp/aws/install && rm -rf /tmp/aws*
# GitHub CLI
RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
| dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
> /etc/apt/sources.list.d/github-cli.list \
&& apt-get update && apt-get install -y gh
# Claude Code CLI
RUN npm install -g @anthropic-ai/claude-code
# sshd config
RUN mkdir /var/run/sshd /root/.ssh \
&& chmod 700 /root/.ssh \
&& touch /root/.ssh/authorized_keys \
&& chmod 600 /root/.ssh/authorized_keys
# Disable password auth — key only
RUN sed -i 's/^#*PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config \
&& sed -i 's/^#*PermitRootLogin.*/PermitRootLogin prohibit-password/' /etc/ssh/sshd_config \
&& sed -i 's/^#*ChallengeResponseAuthentication.*/ChallengeResponseAuthentication no/' /etc/ssh/sshd_config
# Default shell zsh, no oh-my-zsh (clean)
RUN chsh -s /bin/zsh root
# Generate host keys at first run via entrypoint (so each container has unique keys)
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
EXPOSE 22
ENTRYPOINT ["/entrypoint.sh"]
CMD ["/usr/sbin/sshd", "-D", "-e"]
image/entrypoint.sh
#!/usr/bin/env bash
set -e
# Generate host keys on first start (persisted via volume on /etc/ssh)
if [ ! -f /etc/ssh/ssh_host_ed25519_key ]; then
ssh-keygen -A
fi
exec "$@"
Sự khác biệt so với image cũ
Image cũ (coder) |
Image mới (ssh) |
|
|---|---|---|
| Base | ubuntu 22.04 + code-server install script | ubuntu 24.04 |
| Size | ~1.2 GB | ~700 MB (nhẹ 40%) |
| Entry | code-server --auth none |
sshd -D -e |
| Ports | 8080 HTTP | 22 SSH |
| Default shell | bash | zsh |
| First start | code-server settings, extensions | ssh-keygen -A (host keys) |
8. Routing thay đổi
Hiện tại (HTTP only)
*.6iang.com → CF Tunnel → Traefik :80 → container:8080 (code-server)
Mới (SSH qua CF Access)
*.6iang.com → CF Tunnel → workspace:22 (sshd)
Client: cloudflared access ssh --hostname X.6iang.com
↓ WSS to CF edge
↓ CF verifies Access JWT (email OTP)
↓ short-lived SSH cert exchange
↓ tunneled TCP → tunnel exit → host → sshd
Hai cách expose port 22 từ container đến tunnel
| Cách | Mô tả | Trade-off |
|---|---|---|
| A. Traefik TCP entrypoint :22 + SNI | Traefik nghe :22, route theo SNI | SSH không có SNI native — KHÔNG khả thi cho SSH thuần |
| B. cloudflared route per workspace | Mỗi workspace 1 ingress rule trong tunnel config: <name>.6iang.com → tcp://container_ip:22 |
Khả thi. Tunnel config update mỗi lần tạo workspace |
| C. cloudflared sidecar per workspace | Mỗi container có cloudflared riêng đăng ký 1 hostname | Nặng (1 tunnel client/ws), không scale |
Đề xuất B: một tunnel, dynamic ingress. Agent cập nhật config.yml của cloudflared khi workspace lifecycle thay đổi:
# /etc/cloudflared/config.yml (managed by agent)
tunnel: 8fca8440-...
credentials-file: /etc/cloudflared/cert.json
ingress:
- hostname: dev.6iang.com
service: ssh://172.20.0.5:22
- hostname: work.6iang.com
service: ssh://172.20.0.6:22
- hostname: frontdoor.6iang.com
service: https://frontdoor-preview.pages.dev
- service: http_status:404
Sau mỗi update: cloudflared tunnel ingress validate && supervisorctl reload cloudflared (hoặc docker exec cloudflared kill -HUP 1).
Alternative đơn giản hơn: Cloudflared hỗ trợ chế độ "single-service per hostname" — set ingress wildcard:
- hostname: "*.6iang.com"
service: ssh://traefik:22
nhưng Traefik không route SSH theo hostname được. Vậy phải duy trì ingress list dynamic (cách B). Việc này bản thân là 1 thay đổi không nhỏ → cần đưa vào agent job: updateTunnelIngress.
9. Workspace lifecycle thay đổi
Khi tạo workspace mới:
Khi xoá workspace: thêm bước xoá ingress rule + xoá volume vol-<name> + fd-sshhost-<name>.
10. UI
Sidebar nav
Workspaces
Monitor
Volumes
SSH Keys ← mới
Settings
Trang SSH Keys
┌───────────────────────────────────────────────────────────────────┐
│ SSH Keys [+ Add key] [Sync all] │
├───────────────────────────────────────────────────────────────────┤
│ laptop-m4 │
│ SHA256:abc123def456… ed25519 added 3d ago last used 2h ago │
│ "giang@macbook" [Revoke] │
├───────────────────────────────────────────────────────────────────┤
│ mac-mini-server │
│ SHA256:xyz789… ed25519 added 1w ago never used │
│ [Revoke] │
├───────────────────────────────────────────────────────────────────┤
│ phone-termius │
│ SHA256:def456… rsa-3072 added 2w ago last used 5d ago │
│ [Revoke] │
└───────────────────────────────────────────────────────────────────┘
Sync status:
✓ dev synced 2m ago (3 keys)
✓ work synced 2m ago (3 keys)
⚠ archive synced 1d ago (2 keys) — needs sync
[Sync archive]
Modal "+ Add key"
Name [____________________] e.g. "laptop"
Public key [____________________]
[____________________]
[____________________]
paste your ~/.ssh/id_ed25519.pub here
Detected: ed25519 · SHA256:abc...
(sẽ tự động sync vào all running workspaces)
[Cancel] [Add & Sync]
Workspace card mới
Bỏ "Open in browser" → thay bằng "SSH command" copy button:
┌──────────────────────────────┐
│ ⚙ dev │
│ CPU 0.3% Mem 412 MB │
│ │
│ ssh dev.6iang.com │
│ [📋 Copy] [⚙ Settings] │
└──────────────────────────────┘
Drawer phải có section "Connect" với:
ssh dev.6iang.com- ssh config block để paste vào
~/.ssh/config - Hướng dẫn cài
cloudflarednếu chưa có
11. Client setup (one-time)
Trang Settings thêm tab "Client setup" hiển thị instructions:
# 1. Install cloudflared
brew install cloudflared # macOS
# hoặc: https://github.com/cloudflare/cloudflared/releases
# 2. Configure SSH (paste vào ~/.ssh/config)
Host *.6iang.com
User root
ProxyCommand /usr/local/bin/cloudflared access ssh --hostname %h
IdentityFile ~/.ssh/id_ed25519
# 3. Authenticate (one-time, opens browser)
cloudflared access login https://6iang.com
# 4. Done — test:
ssh dev.6iang.com
12. Migration plan từ code-server hiện tại
Bước migrate từng workspace
- Stop workspace cũ (
docker stop fd-<name>) - Rsync
/rootvolume sang volume mới (giữ lại config, dotfiles) - Start container mới với image
workspace:latest - Inject
authorized_keys - Cập nhật ingress rule trong cloudflared config
- Verify:
ssh <name>.6iang.comtừ máy local - Xoá container + volume cũ
UI có nút "Migrate to SSH" mỗi workspace card cũ — tự động hoá các bước trên.
13. Backwards compatibility
Trong 1-2 tuần đầu, giữ song song:
- Workspace mới mặc định image SSH
- Workspace cũ vẫn chạy code-server, có nút "Migrate"
- HTTP route
*.6iang.com → code-servergiữ cho workspace cũ; SSH route song song
Sau khi mọi workspace migrated:
- Xoá HTTP routing logic trong Traefik
- Update docs
14. Failure modes
| Tình huống | Handle |
|---|---|
| User upload key sai format | API parse fail → 400 "invalid key, expected ssh-ed25519 … or ssh-rsa …" |
| User xoá hết keys | UI cảnh báo "bạn sẽ mất truy cập" + require gõ "REVOKE" để xác nhận |
| Workspace down khi sync | Job syncAuthorizedKeys retry sau khi container restart (sync state lưu trong DB; cron check pending sync mỗi 5 phút) |
| cloudflared reload fail | Validate ingress trước reload; nếu fail giữ config cũ + alert UI |
| Container không có ip ổn định (Docker restart) | Sync container_ip vào DB khi container start; agent cập nhật ingress nếu ip đổi |
| Host key thay đổi (image rebuild) | Mount volume fd-sshhost-<name>:/etc/ssh — persist host keys across rebuild |
| Lose access — không SSH được | Fallback: dùng docker exec qua Frontdoor terminal (vẫn còn) để recovery authorized_keys |
15. Bảo mật
| Threat | Mitigation |
|---|---|
| Public key bị leak | Public key không phải secret. Khoá private giữ máy user. |
| Tunnel bypass | CF Access SSH require cert exchange — chỉ user đã authenticate qua email OTP mới xin được cert. Cert sống ngắn (~1h). |
| Lateral movement giữa workspaces | Containers cùng network nhưng SSH không expose ra Traefik HTTP. Muốn cứng hơn: per-workspace network namespace |
| Root SSH | PermitRootLogin prohibit-password + PasswordAuthentication no → chỉ key-based root. Lý do dùng root: workspace của owner, không multi-tenant trong cùng container. |
| Stolen private key | User revoke key qua UI → sync ngay → mất quyền. Audit log ghi mọi lần sync. |
| Host key bị fake | ~/.ssh/known_hosts (client side) — first connection cảnh báo TOFU. CF Access SSH bypass này bằng cert chính chủ. |
16. Effort estimate
| Phần | LOC | Effort |
|---|---|---|
| Migration 0005 + DB methods | ~120 TS | 0.25 ngày |
API endpoints /api/keys/* + sync |
~280 TS | 0.75 ngày |
Agent job syncAuthorizedKeys + tunnel ingress mgmt |
~350 Go | 1 ngày |
Image Dockerfile + entrypoint + multi-arch build |
~80 lines | 0.5 ngày |
| Tunnel ingress dynamic manager (Go) | ~200 Go | 0.5 ngày |
| UI SSH Keys page + workspace card update + client setup tab | ~700 TSX | 1.5 ngày |
| Migration UI + per-workspace migrate flow | ~250 TSX + 100 Go | 0.75 ngày |
| E2E test (real SSH from Mac → workspace) | — | 0.5 ngày |
| Tổng | ~2080 | ~5.75 ngày |
17. Open questions
- Multi-user SSH: khi Phase 2 multi-user activate, mỗi user upload key riêng, mỗi workspace inject keys của owner. Cần
workspace.owner_emailđã có sẵn, chỉ thêm filter. - Team workspace sharing: workspace có owner + shared_with[]. Inject keys của tất cả users được share. Phase 3.
- OpenSSH version: Ubuntu 24.04 base có OpenSSH 9.6 — đủ tốt. Không cần custom build.
- Container IP stability: Docker bridge IP có thể đổi khi container restart. Mitigation: dùng static IP trong network config, hoặc DNS-based ingress (cloudflared resolve service hostname mỗi connection).
- Per-workspace TCP forwarding: VS Code Remote-SSH cần forward ports cho preview server. SSH
LocalForwardwork native, không cần config thêm. - Tmux session persistence: encourage user dùng
tmuxđể session keep khi SSH disconnect. Có thể wrap SSH command vớissh ... -t 'tmux new -As main'qua ssh_config. - Volume reuse khi rebuild: image upgrade → user data
/rootgiữ qua volume.~/.zshrc, history, projects survive image rebuild. - Mobile/tablet truy cập: SSH client mobile (Termius, Blink Shell) hỗ trợ cloudflared? Termius có proxy support, Blink có
mosh + ssh proxy. Cần test thực tế.
18. Tóm tắt thay đổi vs hệ thống hiện tại
| Lĩnh vực | Trước (code-server) | Sau (SSH) |
|---|---|---|
| Image size | ~1.2 GB | ~700 MB |
| Entry process | code-server --auth none |
sshd -D -e |
| Exposed port | 8080 (HTTP) | 22 (SSH) |
| Auth method | CF Access (HTTP cookie) | CF Access SSH cert + SSH key |
| Edge routing | Traefik HTTP per workspace | cloudflared ingress per workspace |
| Client | Browser tab | Cursor / VS Code Remote-SSH / terminal |
| Session persistence | Browser tab open | tmux / screen |
| Tools | code-server extensions | native IDE extensions (qua Remote-SSH) |
| Onboarding new user | Mở URL | Cài cloudflared + setup ssh config |
| UI ở Frontdoor | Workspaces, Monitor, Volumes | + SSH Keys tab |