> ## Documentation Index
> Fetch the complete documentation index at: https://felimet-hub.jmcores.com/llms.txt
> Use this file to discover all available pages before exploring further.

# 自託管 RustDesk 遠端桌面伺服器

> 以社群版 lejianwen/rustdesk-server-s6 映像檔在單一 Docker 容器內同時執行 hbbs 訊號伺服器、hbbr 中繼伺服器與 Web API 管理後台，透過 Cloudflare Tunnel 對外曝露 Web Console，無裝置數量限制，可完整自託管遠端桌面服務。

export const OptionPicker = ({options = [], lang = "zh", label}) => {
  const t = lang === "en" ? {
    pick: label || "Pick an option to compare",
    rec: "Recommended"
  } : {
    pick: label || "點選項目比較",
    rec: "推薦"
  };
  const opts = Array.isArray(options) ? options : [];
  const [sel, setSel] = useState(0);
  if (opts.length === 0) return null;
  const idx = Math.min(sel, opts.length - 1);
  const cur = opts[idx];
  const css = `
  .op-root{--op-bg:#FAF8F3;--op-surface:rgba(0,0,0,0.025);--op-border:rgba(0,0,0,0.09);--op-text:#2b2722;--op-dim:#6f6a62;--op-faint:#8a8378;--op-accent:#bf7551;border:1px solid var(--op-border);border-radius:14px;background:var(--op-bg);color:var(--op-text);overflow:hidden;}
  .dark .op-root{--op-bg:#1b1a18;--op-surface:rgba(255,255,255,0.03);--op-border:rgba(255,255,255,0.08);--op-text:#e7e3da;--op-dim:#a8a299;--op-faint:#8a8378;--op-accent:#cf8a68;}
  .op-head{padding:12px 16px 11px;border-bottom:1px solid var(--op-border);font-size:12.5px;color:var(--op-dim);display:flex;align-items:center;gap:8px;}
  .op-head-ic{color:var(--op-accent);flex-shrink:0;}
  .op-tabs{display:flex;gap:8px;padding:14px 16px 4px;flex-wrap:wrap;}
  .op-tab{position:relative;flex:1 1 130px;min-width:120px;text-align:left;background:transparent;border:1px solid var(--op-border);border-radius:11px;padding:11px 13px;cursor:pointer;color:inherit;font:inherit;transition:border-color .15s,background .15s,box-shadow .15s;}
  .op-tab:hover{background:var(--op-surface);}
  .op-tab-on{border-color:rgba(191,117,81,.5);background:rgba(191,117,81,.07);box-shadow:0 0 0 3px rgba(191,117,81,.1);}
  .op-tab-name{font-size:14.5px;font-weight:600;line-height:1.3;}
  .op-tab-on .op-tab-name{color:var(--op-accent);}
  .op-badge{display:inline-block;margin-top:6px;font-size:10.5px;font-weight:700;letter-spacing:.4px;text-transform:uppercase;padding:2px 7px;border-radius:20px;background:rgba(191,117,81,.14);color:var(--op-accent);}
  .op-rec{position:absolute;top:10px;right:11px;font-size:10px;font-weight:700;letter-spacing:.3px;text-transform:uppercase;color:var(--op-accent);}
  .op-body{padding:6px 16px 16px;}
  .op-specs{border:1px solid var(--op-border);border-radius:10px;overflow:hidden;margin-bottom:11px;}
  .op-row{display:flex;border-top:1px solid var(--op-border);font-size:13px;}
  .op-row:first-child{border-top:none;}
  .op-k{width:42%;flex-shrink:0;padding:8px 12px;color:var(--op-dim);background:var(--op-surface);}
  .op-v{flex:1 1 0;min-width:0;padding:8px 12px;font-weight:500;}
  .op-note{font-size:13px;line-height:1.6;color:var(--op-dim);padding:0 2px;}
  `;
  return <div className="op-root">
      <style>{css}</style>
      <div className="op-head"><svg className="op-head-ic" xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z" /><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65" /><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65" /></svg>{t.pick}</div>
      <div className="op-tabs">
        {opts.map((o, i) => <button key={i} type="button" className={"op-tab" + (i === idx ? " op-tab-on" : "")} onClick={() => setSel(i)}>
            {o.recommend && <span className="op-rec">★ {t.rec}</span>}
            <div className="op-tab-name">{o.name}</div>
            {o.badge && <span className="op-badge">{o.badge}</span>}
          </button>)}
      </div>
      <div className="op-body">
        <div className="op-specs">
          {(cur.specs || []).map((row, i) => <div className="op-row" key={i}>
              <div className="op-k">{row[0]}</div>
              <div className="op-v">{row[1]}</div>
            </div>)}
        </div>
        {cur.note && <div className="op-note">{cur.note}</div>}
      </div>
    </div>;
};

export const ArchFlow = ({nodes = [], lang = "zh"}) => {
  const t = lang === "en" ? {
    flow: "Data flow",
    hint: "Click any stage to see what it does",
    stage: "Stage"
  } : {
    flow: "資料流",
    hint: "點任一階段看它做什麼",
    stage: "階段"
  };
  const safe = Array.isArray(nodes) ? nodes : [];
  const [active, setActive] = useState(0);
  if (safe.length === 0) return null;
  const idx = Math.min(active, safe.length - 1);
  const current = safe[idx];
  const css = `
  .af-root{--af-bg:#FAF8F3;--af-surface:rgba(191,117,81,0.06);--af-surface2:rgba(0,0,0,0.025);--af-border:rgba(0,0,0,0.09);--af-text:#2b2722;--af-dim:#6f6a62;--af-faint:#8a8378;--af-rail:rgba(0,0,0,0.12);--af-accent:#bf7551;border:1px solid var(--af-border);border-radius:14px;background:var(--af-bg);color:var(--af-text);overflow:hidden;}
  .dark .af-root{--af-bg:#1b1a18;--af-surface:rgba(207,138,104,0.09);--af-surface2:rgba(255,255,255,0.03);--af-border:rgba(255,255,255,0.08);--af-text:#e7e3da;--af-dim:#a8a299;--af-faint:#8a8378;--af-rail:rgba(255,255,255,0.13);--af-accent:#cf8a68;}
  .af-head{padding:13px 18px 11px;display:flex;align-items:center;gap:9px;flex-wrap:wrap;border-bottom:1px solid var(--af-border);}
  .af-head-ic{color:var(--af-accent);flex-shrink:0;}
  .af-head-t{font-size:12px;font-weight:700;letter-spacing:.6px;color:var(--af-dim);text-transform:uppercase;}
  .af-head-h{font-size:12px;color:var(--af-faint);}
  .af-grid{display:flex;gap:0;align-items:stretch;}
  .af-list{flex:1 1 0;min-width:0;padding:12px 12px 16px;}
  .af-node{display:flex;align-items:stretch;gap:10px;width:100%;text-align:left;background:transparent;border:none;cursor:pointer;padding:0;color:inherit;font:inherit;-webkit-tap-highlight-color:transparent;}
  .af-railcol{width:22px;flex-shrink:0;display:flex;flex-direction:column;align-items:center;}
  .af-dot{width:9px;height:9px;border-radius:50%;margin-top:14px;background:var(--af-faint);opacity:.5;flex-shrink:0;transition:background .15s,box-shadow .15s,opacity .15s;}
  .af-line{width:2px;flex:1 1 0;background:var(--af-rail);min-height:10px;}
  .af-body{flex:1 1 0;min-width:0;padding:9px 12px;margin:2px 0;border-radius:9px;border:1px solid transparent;transition:background .15s,border-color .15s;}
  .af-node:hover .af-body{background:var(--af-surface2);}
  .af-node-on .af-body{background:var(--af-surface);border-color:rgba(191,117,81,.30);}
  .af-node-on .af-dot{background:var(--af-accent);opacity:1;box-shadow:0 0 0 4px rgba(191,117,81,.15);}
  .af-label{font-size:14.5px;font-weight:550;line-height:1.45;}
  .af-node-on .af-label{color:var(--af-accent);}
  .af-detail{width:300px;flex-shrink:0;border-left:1px solid var(--af-border);padding:16px 18px;background:var(--af-surface2);}
  .af-detail-k{font-size:11px;font-weight:700;letter-spacing:.5px;text-transform:uppercase;color:var(--af-accent);margin-bottom:7px;}
  .af-detail-l{font-size:16px;font-weight:600;margin-bottom:9px;line-height:1.35;}
  .af-detail-d{font-size:13.5px;line-height:1.65;color:var(--af-dim);}
  @media (max-width:640px){.af-grid{flex-direction:column;}.af-detail{width:auto;border-left:none;border-top:1px solid var(--af-border);}}
  `;
  return <div className="af-root">
      <style>{css}</style>
      <div className="af-head">
        <svg className="af-head-ic" xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect width="8" height="8" x="3" y="3" rx="2" /><path d="M7 11v4a2 2 0 0 0 2 2h4" /><rect width="8" height="8" x="13" y="13" rx="2" /></svg>
        <span className="af-head-t">{t.flow}</span>
        <span className="af-head-h">{t.hint}</span>
      </div>
      <div className="af-grid">
        <div className="af-list">
          {safe.map((n, i) => <button key={i} type="button" className={"af-node" + (i === idx ? " af-node-on" : "")} onClick={() => setActive(i)} onMouseEnter={() => setActive(i)}>
              <div className="af-railcol">
                <div className="af-dot" />
                {i < safe.length - 1 && <div className="af-line" />}
              </div>
              <div className="af-body">
                <div className="af-label">{n.label}</div>
              </div>
            </button>)}
        </div>
        <div className="af-detail">
          <div className="af-detail-k">{t.stage} {idx + 1} / {safe.length}</div>
          <div className="af-detail-l">{current.label}</div>
          <div className="af-detail-d">{current.detail}</div>
        </div>
      </div>
    </div>;
};

`RustDesk` · `Docker` · `Cloudflare Tunnel` · `Self-Hosted` · `Remote Desktop`

## 專案概述

本專案以社群版映像檔 `lejianwen/rustdesk-server-s6` 建構自託管遠端桌面基礎設施。該映像以 s6-overlay 把 hbbs（信號伺服器）、hbbr（中繼伺服器）與 Go 語言實作的 Web API 管理後台打包成單一容器，配合一個 cloudflared 容器共享網路，Web Console 透過 Cloudflare Tunnel（Zero Trust）對外曝露，RustDesk 用戶端則直連主機 21115-21117 埠。無裝置數量限制，MIT 授權免費使用。

## 核心功能

* 單一容器整合 hbbs + hbbr + Web API（不需分別管理三個 container）
* Web Console 透過 Cloudflare Tunnel 曝露，主機不對 Web Console 開放入站埠
* RustDesk 用戶端直連走 21115-21117，不繞 Tunnel（延遲最低）
* 使用者管理、裝置管理、地址簿/群組、登入日誌、連線日誌、OIDC/LDAP 登入、Web Client、強制登入（`MUST_LOGIN=Y`）
* 無裝置數量限制，MIT 授權

## 配置比較

<OptionPicker
  options={[
{
name: "官方開源版",
specs: [["映像檔", "rustdesk/rustdesk-server"], ["Web 管理後台", "無"], ["裝置數量", "無限制"], ["強制登入", "無"], ["OIDC / LDAP", "無"], ["授權", "AGPL"], ["支援", "社群"]],
note: "純訊號 + 中繼，無 Web 後台。適合只要打洞、自行管理的最小部署。"
},
{
name: "社群版", badge: "本 repo", recommend: true,
specs: [["映像檔", "lejianwen/rustdesk-server-s6"], ["Web 管理後台", "有"], ["裝置數量", "無限制"], ["強制登入", "有（MUST_LOGIN=Y）"], ["OIDC / LDAP", "有"], ["授權", "MIT（完全免費）"], ["支援", "社群"]],
note: "單容器整合 hbbs + hbbr + Web API，無裝置數限制、MIT 完全免費。本專案採用。"
},
{
name: "官方 Pro 版",
specs: [["映像檔", "rustdesk/rustdesk-server-pro"], ["Web 管理後台", "有"], ["裝置數量", "Free Tier 3 台"], ["強制登入", "有"], ["OIDC / LDAP", "有"], ["授權", "商業授權"], ["支援", "官方"]],
note: "官方維護、功能完整，但免費額度限 3 台裝置，超量需商業授權。"
}
]}
/>

## 專案架構

<ArchFlow
  nodes={[
{ label: "用戶端 / 管理者瀏覽器", detail: "RustDesk 用戶端透過 TCP/UDP 21115-21117 直連主機，繞過 Tunnel 以保持最低延遲。管理者瀏覽器則走 Cloudflare Edge 進入 Web Console。" },
{ label: "Cloudflare Edge", detail: "管理流量的入口，提供 TLS 終止與 WAF 防護，再轉發至 Cloudflare Tunnel。" },
{ label: "Cloudflare Tunnel", detail: "outbound-only 反向隧道，cloudflared 容器主動連出，主機不需開放 Web Console 的入站埠。" },
{ label: "cloudflared 容器", detail: "與 rustdesk-s6 共享網路命名空間（network_mode: service:rustdesk），直接存取 localhost:21114，不需額外 expose 埠號。" },
{ label: "rustdesk-s6 容器", detail: "s6-overlay 在單一容器內同時執行 hbbs（信號伺服器）、hbbr（中繼伺服器）與 Go Web API 管理後台，無裝置數量限制。" }
]}
/>

兩個容器（rustdesk-s6、cloudflared）共享同一網路命名空間（`network_mode: service:rustdesk`），cloudflared 直接存取 `localhost:21114`，不需額外 expose 埠號給外部。

## 快速開始

<Steps>
  <Step title="複製 repo 並設定環境變數">
    ```bash theme={null}
    git clone https://github.com/felimet/self-host-rustdesk-server
    cd self-host-rustdesk-server
    cp .env.example .env
    ```

    `.env` 中填入兩個必要值：`CLOUDFLARE_TUNNEL_TOKEN`（從 Cloudflare Zero Trust Dashboard 建立 Tunnel 取得）與 `RUSTDESK_API_JWT_KEY`（自訂隨機字串）。
  </Step>

  <Step title="啟動服務">
    ```bash theme={null}
    docker compose up -d
    ```
  </Step>

  <Step title="取得伺服器公鑰">
    ```bash theme={null}
    cat ./data/server/id_ed25519.pub
    ```

    這串公鑰要填進 RustDesk 用戶端的「Key」欄位。也可在 Container Manager 的容器日誌頁看到。
  </Step>

  <Step title="首次登入 Web Console">
    瀏覽器開 `https://rustdesk-console.example.com`（你在 Cloudflare Tunnel 設的 hostname）。預設帳號 `admin`，初始密碼從容器日誌取得：

    ```bash theme={null}
    docker logs rustdesk
    ```

    登入後立刻修改密碼。
  </Step>

  <Step title="設定 RustDesk 用戶端">
    | 欄位      | 值                                                    |
    | ------- | ---------------------------------------------------- |
    | ID 伺服器  | `rustdesk-server.example.com`（直連域名）                  |
    | 中繼伺服器   | 同上或留空                                                |
    | API 伺服器 | `https://rustdesk-console.example.com`（完整 HTTPS URL） |
    | Key     | 步驟三取得的公鑰                                             |

    ID/中繼伺服器只填域名，不加 `http://` 或 port。
  </Step>
</Steps>

## 注意事項

<Warning>
  `docker-compose.yml` 與 `.env` 內的 `PROXY_ENABLE`、`TRUST_PROXY` 等網路參數直接影響防爆破封鎖和真實攻擊者 IP 辨識，在不完全了解底層機制的情況下請保持預設設定。`.env` 含 Tunnel Token 與 JWT Key，不要提交版本控制。`data/server/id_ed25519` 是私鑰，絕不外洩；公鑰（`id_ed25519.pub`）才是配發給用戶端的部分。
</Warning>

## 實際應用面

實驗室成員遠端存取實驗室工作站，不依賴商業遠端桌面服務，連線記錄留在自管伺服器。管理員透過 Web Console 管理使用者帳號與裝置，強制登入模式（`MUST_LOGIN=Y`）確保未登入帳號無法建立連線。

## 相關連結

* GitHub：[felimet/self-host-rustdesk-server](https://github.com/felimet/self-host-rustdesk-server)
* lejianwen/rustdesk-server（社群 server fork）：[github.com/lejianwen/rustdesk-server](https://github.com/lejianwen/rustdesk-server)
* lejianwen/rustdesk-api（Web API 後端）：[github.com/lejianwen/rustdesk-api](https://github.com/lejianwen/rustdesk-api)
* RustDesk 官方開源 server：[github.com/rustdesk/rustdesk-server](https://github.com/rustdesk/rustdesk-server)
