Frontdoor · Design 2026-05-11

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 đó:

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

Máy local của bạn IDE: Cursor / VS Code Remote-SSHJetBrains Gateway / Terminal cloudflared client(ProxyCommand) ~/.ssh/id_ed25519 Cloudflare edge Cloudflare Access SSH(short-lived cert) CF Tunnel(outbound only) Worker host frontdoor-agent Workspace container sshd :22 ~/.ssh/authorized_keys Control plane (Cloudflare Pages) UI · SSH Keys tab API /api/keys/*/api/workspaces/:name/sync-keys D1 · ssh_keys

ssh tunnel TCP :22 pub key upload job: syncKeys

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

User UI API D1 Agent Workspace

— Setup keys — mở "SSH Keys" tab POST /api/keys INSERT ssh_keys

— Create workspace — + New workspace POST /api/workspaces SELECT keys WHERE owner=? enqueue createWorkspace +keys docker run sshd image docker exec authorized_keys

— Connect — ssh dev.6iang.com (via cloudflared access) shell ready

— Add new key + sync — Add laptop key POST /api/keys bấm "Sync" POST /api/keys/sync-all broadcast syncAuthorizedKeys docker exec — update keys


4. Lifecycle SSH key

pending_sync active revoked

POST /api/keys synced ≥1 ws

add/remove key

user delete cron cleanup

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:

Control plane Agent Docker cloudflared D1

SELECT keys WHERE owner_email=? enqueue createWorkspace {name, image, keys} docker network/volume create docker run frontdoor/workspace:latest container_id, container_ip docker exec: write authorized_keys append ingress: .6iang.com → ssh://ip:22 reloaded job done UPDATE workspaces SET state='running'

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 cloudflared nế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

Migration timeline (10 ngày) Image Build workspace:next Test on local container Backend D1 migration 0005 /api/keys endpoints syncAuthorizedKeys job Tunnel ingress mgmt UI SSH Keys page Workspace card changes Client setup guide Migrate Migrate workspace tesst Migrate all workspaces Remove code-server image d0 d2 d4 d6 d8 d10 d12 d14

Bước migrate từng workspace

  1. Stop workspace cũ (docker stop fd-<name>)
  2. Rsync /root volume sang volume mới (giữ lại config, dotfiles)
  3. Start container mới với image workspace:latest
  4. Inject authorized_keys
  5. Cập nhật ingress rule trong cloudflared config
  6. Verify: ssh <name>.6iang.com từ máy local
  7. 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-server giữ 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 LocalForward work 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ới ssh ... -t 'tmux new -As main' qua ssh_config.
  • Volume reuse khi rebuild: image upgrade → user data /root giữ 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