> ## 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.

# Synology NAS 萬用字元憑證自動更新

> 在 Synology NAS 上以 acme.sh 搭配 Cloudflare DNS-01 驗證申請與自動更新 Let's Encrypt 萬用字元 TLS 憑證，透過 synology_dsm deploy hook 自動部署至 DSM 管理介面，無需開放 80 與 443 連接埠。

export const CertDecision = ({lang = "zh"}) => {
  const t = lang === "en" ? {
    lead: "How does the traffic reach the NAS? Pick a path to see the recommended certificate.",
    rec: "Recommended certificate",
    why: "Why",
    sep: ": "
  } : {
    lead: "流量怎麼到 NAS？選一條路徑看建議憑證。",
    rec: "建議憑證",
    why: "原因",
    sep: "："
  };
  const PATHS = lang === "en" ? [{
    key: "proxy",
    label: "Via Cloudflare proxy",
    sub: "orange cloud",
    cert: "Cloudflare Origin Certificate",
    why: "Full (Strict) mode; the 15-year cert needs no renewal upkeep.",
    note: "Issued by Cloudflare and trusted only on the Cloudflare-to-NAS hop, so no public CA is needed."
  }, {
    key: "direct",
    label: "Direct connection",
    sub: "VPN / Mail / WebDAV, grey cloud",
    cert: "Let's Encrypt Wildcard",
    why: "A public CA that browsers and clients already trust.",
    note: "acme.sh renews it automatically over DNS-01 (only within 60 days of expiry)."
  }] : [{
    key: "proxy",
    label: "走 Cloudflare proxy",
    sub: "橘雲",
    cert: "Cloudflare Origin Certificate",
    why: "Full (Strict) 模式，15 年效期免維護。",
    note: "由 Cloudflare 簽發，僅用於 Cloudflare 到 NAS 這一段，不需公開 CA。"
  }, {
    key: "direct",
    label: "直連",
    sub: "VPN / Mail / WebDAV，灰雲",
    cert: "Let's Encrypt Wildcard",
    why: "公開 CA，瀏覽器與用戶端皆信任。",
    note: "acme.sh 以 DNS-01 自動續期（到期前 60 天內才實際續期）。"
  }];
  const ICONS = {
    proxy: <path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z" />,
    direct: <><circle cx="6" cy="19" r="3" /><path d="M9 19h8.5a3.5 3.5 0 0 0 0-7h-11a3.5 3.5 0 0 1 0-7H15" /><circle cx="18" cy="5" r="3" /></>
  };
  const [sel, setSel] = useState(0);
  const cur = PATHS[sel];
  const css = `
  .cd-root{--cd-bg:#FAF8F3;--cd-surface:rgba(0,0,0,0.025);--cd-border:rgba(0,0,0,0.09);--cd-text:#2b2722;--cd-dim:#6f6a62;--cd-faint:#8a8378;--cd-accent:#bf7551;border:1px solid var(--cd-border);border-radius:14px;background:var(--cd-bg);color:var(--cd-text);overflow:hidden;}
  .dark .cd-root{--cd-bg:#1b1a18;--cd-surface:rgba(255,255,255,0.03);--cd-border:rgba(255,255,255,0.08);--cd-text:#e7e3da;--cd-dim:#a8a299;--cd-faint:#8a8378;--cd-accent:#cf8a68;}
  .cd-head{padding:13px 18px 11px;border-bottom:1px solid var(--cd-border);font-size:12.5px;color:var(--cd-dim);display:flex;align-items:center;gap:8px;}
  .cd-head-ic{color:var(--cd-accent);flex-shrink:0;}
  .cd-body{padding:14px 16px 16px;}
  .cd-paths{display:flex;gap:9px;flex-wrap:wrap;}
  .cd-path{flex:1 1 160px;min-width:150px;display:flex;align-items:center;gap:10px;text-align:left;background:transparent;border:1px solid var(--cd-border);border-radius:11px;padding:11px 13px;cursor:pointer;color:inherit;font:inherit;transition:border-color .15s,background .15s,box-shadow .15s;}
  .cd-path:hover{background:var(--cd-surface);}
  .cd-path-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);}
  .cd-path-ic{color:var(--cd-faint);flex-shrink:0;}
  .cd-path-on .cd-path-ic{color:var(--cd-accent);}
  .cd-path-l{font-size:14px;font-weight:600;line-height:1.25;}
  .cd-path-on .cd-path-l{color:var(--cd-accent);}
  .cd-path-s{font-size:11.5px;color:var(--cd-faint);margin-top:1px;}
  .cd-rec{margin-top:13px;border:1px solid rgba(191,117,81,.3);border-radius:10px;background:rgba(191,117,81,.06);padding:13px 15px;}
  .cd-rec-k{font-size:11px;font-weight:700;letter-spacing:.5px;text-transform:uppercase;color:var(--cd-accent);margin-bottom:5px;}
  .cd-rec-cert{font-size:16px;font-weight:700;color:var(--cd-text);margin-bottom:9px;}
  .cd-rec-why{font-size:13px;line-height:1.6;color:var(--cd-dim);}
  .cd-rec-why b{color:var(--cd-text);font-weight:600;}
  .cd-rec-note{font-size:12.5px;line-height:1.6;color:var(--cd-faint);margin-top:8px;padding-top:8px;border-top:1px solid var(--cd-border);}
  @media (max-width:520px){.cd-path{flex:1 1 100%;}}
  `;
  return <div className="cd-root">
      <style>{css}</style>
      <div className="cd-head">
        <svg className="cd-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="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" /><path d="m9 12 2 2 4-4" /></svg>
        {t.lead}
      </div>
      <div className="cd-body">
        <div className="cd-paths">
          {PATHS.map((p, i) => <button key={p.key} type="button" className={"cd-path" + (i === sel ? " cd-path-on" : "")} onClick={() => setSel(i)}>
              <svg className="cd-path-ic" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">{ICONS[p.key]}</svg>
              <span>
                <span className="cd-path-l">{p.label}</span>
                <span className="cd-path-s">{p.sub}</span>
              </span>
            </button>)}
        </div>
        <div className="cd-rec">
          <div className="cd-rec-k">{t.rec}</div>
          <div className="cd-rec-cert">{cur.cert}</div>
          <div className="cd-rec-why"><b>{t.why}{t.sep}</b>{cur.why}</div>
          <div className="cd-rec-note">{cur.note}</div>
        </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>;
};

`Synology` · `acme.sh` · `Let's Encrypt` · `Cloudflare` · `TLS` · `Shell`

## 專案概述

DSM 7 內建的 Let's Encrypt 只支援 HTTP-01 驗證，必須對外開 port 80，且拿不到萬用字元憑證。本專案以 acme.sh 搭配 Cloudflare DNS-01 驗證，在不開放任何入站埠的條件下申請 `*.example.com` 萬用字元憑證，並透過 `synology_dsm` deploy hook 自動部署至 DSM。同時說明雙憑證策略：Cloudflare 橘雲（proxy）流量用 Origin Certificate，VPN/Mail 等直連流量用 Let's Encrypt 萬用字元憑證。

## 核心功能

* DNS-01 驗證，透過 Cloudflare API 寫入 TXT 記錄完成 ACME 挑戰，無需開放任何埠號
* 萬用字元憑證（`example.com` + `*.example.com`）一次涵蓋所有子網域
* `synology_dsm` deploy hook 在申請/續期後自動透過 DSM Web API 部署並重載憑證
* DSM 工作排程自動觸發 acme.sh renew，acme.sh 內部在到期前 60 天內才實際續期，每天跑安全
* 雙憑證共存：DSM 控制台可針對不同服務分別指派 Origin Certificate 或 Let's Encrypt 憑證

## 部署架構

<ArchFlow
  nodes={[
{ label: "Cloudflare DNS API", detail: "acme.sh 呼叫 Cloudflare API 寫入 TXT 記錄，完成 DNS-01 ACME 挑戰，無需開放任何入站埠。" },
{ label: "Let's Encrypt CA", detail: "驗證 TXT 記錄後簽發 example.com 與 *.example.com 萬用字元憑證。" },
{ label: "acme.sh", detail: "管理憑證申請與續期，在到期前 60 天內才實際執行，每天由 DSM 工作排程觸發可安全跑。" },
{ label: "synology_dsm deploy hook", detail: "透過 DSM Web API 將新憑證推送至 DSM 並重載，不需手動操作。" },
{ label: "DSM 憑證管理", detail: "在控制台中分別為橘雲流量指派 Cloudflare Origin Certificate，為 VPN / Mail / WebDAV 等直連服務指派 Let's Encrypt 萬用字元憑證。" }
]}
/>

## 憑證策略

```
瀏覽器 / 用戶端
  ├─ HTTPS → Cloudflare Edge（Universal SSL，自動）
  │            └─ 橘雲 proxy → NAS：Cloudflare Origin Certificate（15 年效期）
  └─ 直連（灰雲 / VPN / Mail / WebDAV）
               └─ NAS：Let's Encrypt Wildcard（acme.sh 自動續期）
```

<CertDecision />

## 快速開始

<Steps>
  <Step title="建立 Cloudflare API Token">
    Cloudflare Dashboard → My Profile → API Tokens → Create Token → Custom Token。

    權限設定：Zone → DNS → **Edit**，區域資源選帳號下的目標 Zone。記下 Token（只顯示一次）和 Zone ID（Dashboard 右側欄）。
  </Step>

  <Step title="安裝 acme.sh">
    ```bash theme={null}
    sudo -i
    rm -rf /usr/local/share/acme.sh /root/.acme.sh
    cd /tmp && wget https://github.com/acmesh-official/acme.sh/archive/master.tar.gz
    tar xvf master.tar.gz && cd acme.sh-master/
    ./acme.sh --install --nocron \
        --home /usr/local/share/acme.sh \
        --accountemail "your-email@example.com"
    chown -R your_username /usr/local/share/acme.sh/
    ```

    必須用 `--nocron`（DSM 升級會清 crontab）並指定 `--home`（不指定會裝到 `/root/.acme.sh/`，Synology 的 `/root` 結構不標準會報錯）。
  </Step>

  <Step title="向 Let's Encrypt 註冊帳號">
    ```bash theme={null}
    /usr/local/share/acme.sh/acme.sh --register-account \
        -m "your-email@example.com" \
        --home /usr/local/share/acme.sh \
        --server letsencrypt
    ```

    必須帶 `--server letsencrypt`，acme.sh 新版預設 CA 已改為 ZeroSSL。
  </Step>

  <Step title="申請萬用字元憑證（先測試再正式）">
    ```bash theme={null}
    export CF_Token="your_cloudflare_api_token"
    export CF_Zone_ID="your_zone_id"

    # 先用 staging 環境驗證設定正確
    /usr/local/share/acme.sh/acme.sh --issue --dns dns_cf \
        -d "example.com" -d "*.example.com" \
        --home /usr/local/share/acme.sh \
        --server letsencrypt --test

    # 測試通過後正式申請
    /usr/local/share/acme.sh/acme.sh --issue --dns dns_cf \
        -d "example.com" -d "*.example.com" \
        --home /usr/local/share/acme.sh \
        --server letsencrypt --force
    ```
  </Step>

  <Step title="部署憑證到 DSM">
    ```bash theme={null}
    export SYNO_Hostname="localhost"
    export SYNO_Port="5000"
    export SYNO_Username="your_username"
    export SYNO_Password='your_password'
    export SYNO_Certificate="Let's Encrypt Wildcard"
    export SYNO_Create=1

    /usr/local/share/acme.sh/acme.sh --deploy \
        -d "example.com" -d "*.example.com" \
        --deploy-hook synology_dsm \
        --home /usr/local/share/acme.sh
    ```

    到 DSM 控制台 → 安全性 → 憑證確認出現「Let's Encrypt Wildcard」。
  </Step>

  <Step title="設定 DSM 工作排程自動續期">
    控制台 → 工作排程 → 新增 → 排程的工作 → 使用者自訂的指令碼，每天凌晨 3:00 執行：

    ```bash theme={null}
    /usr/local/share/acme.sh/acme.sh --renew \
        -d "example.com" -d "*.example.com" \
        --deploy-hook synology_dsm \
        --home /usr/local/share/acme.sh
    ```

    或直接使用 repo 內的 `scripts/renew-cert.sh`。acme.sh 內部只在到期前 60 天內才實際執行，每天跑不會觸發 rate limit。
  </Step>
</Steps>

## 注意事項

<Warning>
  Let's Encrypt production 對同一網域每週限發 5 張憑證，設定有誤反覆失敗會被暫時鎖住。申請前必須先跑 `--test`（staging 環境不受限），測試通過再用 `--force` 正式申請。API Token 必須用 Custom Token（Zone DNS Edit 權限），不要用 Global API Key，後者是帳號完整權限，外洩風險大。`account.conf` 內的 `SYNO_CERTIFICATE` 若含單引號字元（如 `Let's`），需用雙引號包整個值。
</Warning>

## 實際應用面

實驗室 NAS 同時跑 VPN、Mail、WebDAV、Synology Drive 等需要瀏覽器認得的公開憑證的服務，單張萬用字元憑證統一涵蓋，不需為每個子網域個別申請。橘雲服務另掛 Origin Certificate，兩套憑證在 DSM 控制台分別指派給對應服務。

## 相關連結

* GitHub：[felimet/synology-letsencrypt-cloudflare](https://github.com/felimet/synology-letsencrypt-cloudflare)
* acme.sh 官方 repo：[acmesh-official/acme.sh](https://github.com/acmesh-official/acme.sh)
* acme.sh Cloudflare DNS API 說明：[acme.sh/wiki/dnsapi#dns\_cf](https://github.com/acmesh-official/acme.sh/wiki/dnsapi#dns_cf)
* acme.sh Synology DSM deploy hook：[acme.sh/wiki/deployhooks#synology\_dsm](https://github.com/acmesh-official/acme.sh/wiki/deployhooks#synology_dsm)
