Skip to content

Latest commit

 

History

History
851 lines (670 loc) · 35.7 KB

File metadata and controls

851 lines (670 loc) · 35.7 KB
Pingclair

基於 Pingora 打造的現代高效能 Web 伺服器與反向代理
結合 Cloudflare Pingora 的極致效能與 Caddy 的極簡開發體驗

License Rust Status PRs Welcome

English · 中文 · Français


📖 專案簡介

Pingclair 是新一代的 Web 伺服器與反向代理工具。它的核心理念,是把 Cloudflare Pingora(處理過兆級請求的 Rust 代理框架)的強大能力,包裝在一層近似 Caddy 的易用外殼之下。

傳統 Nginx 的設定往往晦澀難懂,而 Caddy 雖然好用,卻建立在 Go 之上。Pingclair 想要填補這個空缺:提供一套 100% 以 Rust 撰寫記憶體安全高效能設定直覺的方案。

無論你需要的是單純的靜態檔案伺服器,還是支援複雜負載平衡、自動 HTTPS 與 HTTP/3 的企業級閘道,Pingclair 都能勝任。

✨ 核心特性

  • 🚀 以 Pingora 為核心 — 站在巨人的肩膀上,倚靠 Cloudflare 歷經實戰驗證的基礎設施,提供企業級的穩定度與吞吐量。明文監聽器支援 HTTP/1.1 與 prior-knowledge h2c;TLS 監聽器則透過 ALPN 協商 HTTP/2。
  • 🔒 記憶體安全 — 受惠於 Rust 的語言特性,徹底杜絕緩衝區溢位這類常見的記憶體安全漏洞。
  • 📝 相容 Caddyfile 的設定 — 極簡的設定 DSL,支援自動 HTTPS多重監聽器具名匹配器,相容主流 Caddyfile 語法。
  • 原生支援 HTTP/3 (QUIC) — 基於 Cloudflare 的 quiche(支撐 Cloudflare 邊緣網路的生產級 QUIC 協定棧)打造,在不穩定的網路環境下提供更低的延遲與更好的連線遷移能力。明確設定 tls 後,任何監聽埠都可提供 HTTPS 與 H3;443 與 8443 仍保留自動辨識。目前所有下游協議都不轉送已宣告的 request trailers:回應尚未送出時會明確回傳 501,H3 已送出時則重設 stream。上游回應若宣告 trailers,會在完整的端到端轉送完成前明確回傳 502。H3 CONNECT 與 extended CONNECT 在 tunnel 支援完成前會回傳 501
  • 🔄 智慧負載平衡 — 內建多種演算法(輪詢、最少連線等),支援健康檢查與故障自動轉移。
  • 🔐 自動與私有 HTTPS — 整合 ACME(Let's Encrypt)申請公開憑證;tls internal 則提供持久化本機 CA,供私有源站與隧道使用。
  • 📁 高效能靜態檔案服務 — 支援 Gzip/Brotli 壓縮、Range 請求與高效率的檔案傳輸。
  • 📊 可觀測性 — 開箱即用的 Prometheus 指標匯出。

⚡ 效能基準測試

最新對比:Pingclair HEAD 43ec589 對 nginx 1.31.3,於 AWS us-west-2a 三台 c7i-flex.large(各 2 vCPU、非 burst)測量,反向代理的後端使用 獨立主機。負載為 1 KiB 檔案;H1 用 wrk -t2 -c100,H2/H1S 用 h2load -t2 -c50;所有記錄輪次皆為零失敗。

場景 Pingclair nginx 1.31.3
H1 靜態 84,208 105,588
H2 靜態(50×10) 74,587 94,712
H1S 靜態 70,004 55,304
H1 反向代理 38,938 85,744
H2 反向代理(50×10) 33,516 45,872
H1S 反向代理 34,418 55,894

H1S 靜態是 Pingclair 的領先項目(+27%)。靜態 H1/H2 約落後 20%; 反向代理的 H1/H1S 仍是最大差距,H2 反向代理約落後 27%。逐次執行的 原始證據保留在本機 benchmarks/results/20260803_c7iflex_nocase/,不在 倉庫內。

📦 安裝指南

前置需求

  • Rust 工具鏈 — 需要 Rust 1.97 或更新的版本。

從原始碼編譯安裝

建議從原始碼編譯,以取得針對你本機 CPU 最佳化的執行檔:

# 1. 複製儲存庫
git clone https://github.com/dorianverlaine/pingclair.git
cd pingclair

# 2. 編譯並安裝(release 模式)
cargo install --path ./pingclair

安裝完成後,pingclair 指令便會加入你的系統 PATH

Linux 一鍵安裝

📦 預設安裝的是 v0.2.0-rc.1,一個發行候選版。 這是刻意讓 releases/latest 指過去的:v0.1.7 早於 HTTP/3 對等、雙向 TLS、管理 API, 以及 CHANGELOG.md 裡大部分的安全修正,不適合當起點。這個 rc 已在三種傳輸協定上連續兩天未重啟——量測數據,以及唯一一個已知缺陷(高負載 下 WebSocket 升級會失敗),都寫在 發行說明 裡。腳本會印出它正在安裝的版本標籤,並在解壓縮前驗證官方發佈的 SHA-256 校驗和。

任何 Linux 發行版都適用同一支安裝腳本:它會自動下載(或編譯)執行檔、設定 systemd 服務,並建立低權限的 pingclair 使用者(透過 setcap 綁定低號連接埠)。安裝完成後,可以使用 pc(pingclair 的縮寫)指令來管理服務。

# 執行安裝腳本(需要 sudo 權限)
curl -fsSL https://raw.githubusercontent.com/dorianverlaine/pingclair/main/scripts/install.sh | sudo bash

腳本提供兩個旗標,可以追蹤 main 而非穩定版:

安裝最新的 main 開發版建置(預先編譯好的 binary):

curl -fsSL https://raw.githubusercontent.com/dorianverlaine/pingclair/main/scripts/install.sh | sudo bash -s -- --dev

Clone main 並在本機編譯(需要 Rust 1.97+):

curl -fsSL https://raw.githubusercontent.com/dorianverlaine/pingclair/main/scripts/install.sh | sudo bash -s -- --main

開發版建置(不穩定)

專案仍在快速迭代,每次 push 到 main 都會產出供部署測試用的快照—— 不是穩定版

  • 容器映像(GHCR):dev tag 跟隨最新 push,每個 build 另有完整的 commit SHA tag,可以釘住特定快照。

    docker pull ghcr.io/dorianverlaine/pingclair:dev
    docker run --rm -p 8080:80 \
      -v "$PWD/Pingclairfile:/etc/pingclair/Pingclairfile:ro" \
      ghcr.io/dorianverlaine/pingclair:dev
  • Linux 二進位檔(x86_64 與 aarch64):附在對應的 GitHub Actions run, 保留 14 天,從該次 run 的 artifact 清單下載。

每個開發版都是移動中的樹的快照,部署到重要環境前請自行驗證。

以 Docker Compose 做正式部署

正式部署建議跑 config-file 模式,並把 TLS store 放在持久 volume(裡面有 憑證、ACME 帳戶金鑰與 internal CA——刪掉等於全部重新簽發):

services:
  pingclair:
    image: ghcr.io/dorianverlaine/pingclair:dev
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
      - "443:443/udp"   # HTTP/3
    volumes:
      - ./conf:/etc/pingclair:ro
      - ./site:/srv
      - pingclair_tls:/var/lib/pingclair/certs
    command: ["pingclair", "run", "/etc/pingclair/Pingclairfile"]

volumes:
  pingclair_tls:

Pingclairfile./conf/、靜態檔放 ./site/(設定裡用 root /srv 指到它)。容器以 config 檔啟動,HTTPS、自動 80 轉跳與 HTTP/3 的行為與主機部署完全一致。

信任 tls internal 的根憑證

tls internal 用持久本機 CA 簽發 leaf。要驗證憑證的用戶端必須信任其根, 位置在 $PINGCLAIR_TLS_STORE/internal/root.crt(容器內: docker compose cp pingclair:/var/lib/pingclair/certs/internal/root.crt ./root.crt)。安裝到系統信任庫:

  • Linux:複製到 /usr/local/share/ca-certificates/root.crt 後執行 sudo update-ca-certificates
  • macOS:sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain root.crt
  • 自帶信任庫的瀏覽器(Firefox、部分平台的 Chrome)需在憑證管理員手動匯入 根憑證。

只對你控制的來源做這件事;internal CA 不是公開憑證機構。

🏃 快速上手

Pingclair 提供兩種執行模式:CLI 命令列模式(適合快速測試)與設定檔模式(適合正式環境)。

1. 命令列模式(CLI)

啟動靜態檔案伺服器
將目前目錄下的檔案透過 HTTP 8080 連接埠對外提供服務:

pingclair file-server --listen :8080 --root .

啟動反向代理
將本機 8080 連接埠的流量轉發到後端的 3000 連接埠:

pingclair reverse-proxy --from :8080 --to localhost:3000

管理系統服務(Linux)
安裝後可使用內建指令管理 systemd 服務:

pc service start    # 啟動
pc service stop     # 停止
pc service status   # 查詢狀態
pc service reload   # 平滑重載設定(SIGHUP)
pc service restart  # 重新啟動

2. 設定檔模式(推薦)

在專案根目錄下建立一個名為 Pingclairfile 的檔案,接著執行:

pingclair run Pingclairfile

🛠️ 設定詳解(Pingclairfile)

Pingclair DSL 是專門用於描述伺服器行為的結構化設定語言;如同 Caddy 的 Caddyfile,其慣用檔名為 Pingclairfile

基礎結構

最簡單的設定包含一個或多個站台區塊:

# 定義一個監聽 localhost 的伺服器
localhost:8080 {
    # 靜態檔案服務
    file_server ./public
}

公開網域的自動 HTTPS

tls auto 透過 ACME(Let's Encrypt)申請並自動續簽公開憑證,不需要寫 listen

{
    email admin@example.com
}

example.com {
    tls auto
    reverse_proxy app:8080
}

這就是完整的設定。有 TLS 而沒寫 listen 的 site 會在 443 提供 HTTPS, Pingclair 另外自動開一個 port 80 的明文 listener,做兩件事:回應 ACME 的 HTTP-01 挑戰——CA 是以明文 HTTP 打在這個 port(RFC 8555 §8.3)——以及把 其餘請求以 308 導向 HTTPS。所以即使 block 裡設定了 TLS,port 80 仍維持明文: 那裡放 TLS listener 會拒絕 CA 的明文探測,憑證永遠簽不下來。

行為由全域區塊控制:

auto_https 效果
on(預設) 自動開 port 80、回應 ACME 挑戰、重導到 HTTPS。
disable_redirects 自動開 port 80 並回應 ACME 挑戰,但不重導。
off 什麼都不開,憑證管理也一併關閉。

在 block 裡自己寫 listen :80 就等於放棄自動 listener,Pingclair 會完全照你 的設定服務那個 port。若 port 80 無法綁定(已被占用,或權限不足),自動 listener 會被跳過並留下警告,HTTPS 照常服務,但 ACME HTTP-01 驗證不會運作。

同一個 block 可以混用明確 scheme,例如 http://example.com, https://example.com { … }。Pingclair 會共用 handler, 但維持各 listener 的獨立策略:HTTP 保持明文並直接服務設定的 route,HTTPS 仍會取得自動憑證。HTTP 與 HTTPS 使用不同 hostname 時,也會各自保留正確的 host 範圍;tls off 即使寫在 port 443 上也仍具優先權。

Pingclair 安裝憑證時會一併送出 CA 簽發的中繼憑證。只送 leaf 的伺服器在瀏覽器 裡看起來是正常的——瀏覽器會快取中繼憑證,也會用 AIA 自行補抓——但 curl、 Go 與 Java 會直接拒絕連線。

要自己寫重導,redir 支援 {host}{uri}。目標要加引號,否則 { 會被 當成 block 的開頭:

http://example.com {
    redir "https://{host}{uri}" 308
}

私有源站的 internal TLS

當 TLS client 是可信隧道、負載平衡器或私有服務,而且無法完成公開 ACME 驗證時, 可使用 tls internal

https://origin.example.test:6688 {
    tls internal
    reverse_proxy app:8080
}

Pingclair 會在 PINGCLAIR_TLS_STORE 下持久化一個有效十年的本機 CA,以及 可續期的 90 天 leaf 憑證——裸二進位預設 $XDG_DATA_HOME/pingclair (即 ~/.local/share/pingclair),容器映像則為 /var/lib/pingclair/certs。 需要驗證源站的 client 應信任 $PINGCLAIR_TLS_STORE/internal/root.crt;CA 私鑰則保存在僅 owner 可讀的 authority.json。H1/H2 與 H3 共用同一份持久化 leaf。tls internal 必須搭配明確站台名稱,且不可和 tls auto、ACME email 或手動憑證路徑混用。

全域的 local_certs 選項對所有沒有自己憑證管理的站台套用同一選擇:所有 預設自動化改用持久化本機 CA,而不是公開 ACME。

若 Pingclair 位於你所管理的負載平衡器或 CDN 後方,只能在全域區塊列出可信 代理網段。未受信任的上一跳不能透過 X-Forwarded-ForX-Real-IPX-Forwarded-Proto 偽造 client identity:

{
    trusted_proxies 10.0.0.0/8 2001:db8::/32
}

example.com {
    listen :8443 proxy_protocol
    reverse_proxy app:8080
}

存取控制、rate limit、IP-hash 負載平衡、上游轉送、placeholder 與 access log 會共用同一個已驗證 client IP。目前變更 trusted_proxies 後需要重新啟動。 listen … proxy_protocol 會要求每條 TCP 連線帶有 PROXY v1 或 v2,並在 TLS/HTTP 解析前拒絕不在 trusted_proxies 的 transport peer。XFF 與 RFC 7239 Forwarded chain 都有上限;畸形或彼此衝突的身分會 fail closed。PROXY protocol 不適用於 UDP HTTP/3 listener。

資源上限與 timeout

下游上限設定在站台層級,上游各階段 timeout 則設定於 reverse_proxy。 時間長度必須附帶單位。WebSocket upgrade、flush_interval -1text/event-stream 會套用長連線覆寫;off 代表明確移除該長連線期限。

example.com {
    limits {
        header_timeout 5s
        body_timeout 30s
        idle_timeout 30s
        request_timeout 2m
        max_headers 100
        max_header_bytes 65536
        max_connections 10000
        upload_bytes_per_sec 10485760
        download_bytes_per_sec 52428800
        long_connections {
            idle_timeout 5m
            request_timeout off
        }
    }

    reverse_proxy app:8080 {
        retry {
            max_attempts 4
            total_timeout 2s
            backoff 50ms
            status_codes 429 502 503 504
            methods GET HEAD
        }
        overload {
            max_in_flight 256
            max_pending 64
            pending_timeout 250ms
            upstream_max_connections 64
        }
        circuit_breaker {
            consecutive_failures 5
            error_rate_percent 50
            minimum_requests 20
            window_requests 100
            open_for 30s
            half_open_requests 1
            failure_statuses 429 502 503 504
        }
        transport http {
            connect_timeout 3s
            first_byte_timeout 30s
            between_reads_timeout 15s
        }
    }
}

max_attempts 包含第一次嘗試。連線建立失敗時,因尚未有 request bytes 送到該後端,可安全改送其他後端;狀態碼重試則只接受設定允許的冪等方法, 而且 request 必須實際沒有 body。Pingclair 不會為此策略緩衝或重送 request body。省略 retry 時會保留舊有的連線失敗切換上限,也不會因 response status 進行重試。

max_in_flight 限制 route 內正在執行的工作,max_pending 則提供有界等待佇列; 佇列已滿會快速回 429,等待逾時回 503。upstream_max_connections 是保守的 單一 backend request 占用上限;H2 多工也受同一上限約束,而不是猜測實體 socket 數量。Circuit breaker 依具體 backend 分開計算,任一設定門檻成立就 open 並快速 回 503;open_for 到期後只允許設定數量的 half-open probe。未列 failure_statuses 時,所有 5xx 都算失敗。相容的 Admin/SIGHUP reload 會保留 既有 circuit 狀態;變更保護政策或 upstream 集合則建立全新狀態。

header、body 與整體 request 超限時,只要協議仍能送出回應,就會回傳明確的 HTTP 錯誤;idle transport 與超出上限的 HTTP/2、HTTP/3 連線則會關閉。 Pingora 0.8 對 H1/H2 僅提供一個上游 read timer,因此兩個階段會採用 first_byte_timeoutbetween_reads_timeout 中較嚴格者;H3 bridge 則會在收到 response header 後切換 timer。目前修改 H1/H2 pre-routing header_timeout、H2 field-section cap 或 H1/H2 connection limit 後, 需要重新啟動 listener。

Admin /loadpingclair reload、SIGUSR1 與 run --watch 會先完整準備 相容變更,再以單一 transaction 發佈。API key、origin、Admin 關閉、 既有 listener 的 route、手動憑證內容與既有 mTLS trust pool,都會在 回報成功前生效;由舊 mTLS generation 放行的連線必須重新握手。需要 重建 socket 或 TLS context 的變更——包含新增/刪除 listener、新增 TLS hostname、變更 transport 已擷取的政策,或在原本允許 session resumption 的 listener 上啟用 mTLS——會保留 last-known-good 設定並拒絕。Admin API 回傳 409"restart_required": true,且不會 autosave 被拒絕的文件。

路由與匹配

Pingclair 提供強大的路由匹配能力,你可以依照路徑、網域、標頭等條件分流請求。

example.com {
    # 1. 使用具名匹配器匹配 API 路徑
    @api {
        path /api/v1/*
    }

    # 針對 API 請求的邏輯
    handle @api {
        header {
            set Content-Type "application/json"
        }
        reverse_proxy localhost:3000
    }

    # 2. 匹配靜態資源
    handle /assets/* {
        header {
            set Cache-Control "public, max-age=86400"
        }
        file_server ./assets
    }

    # 3. 預設回退(Fallback)
    handle {
        respond "Page Not Found" 404
    }
}

進階特性:巨集(Macros)

這是 Pingclair 最強大的特性之一。你可以定義「巨集」來封裝重複的設定片段,並在多個伺服器或路由中重複使用,讓設定檔保持整潔(DRY 原則)。

// 定義一個名為 security_headers 的巨集,用於加入安全標頭
macro security_headers!() {
    headers {
        remove: ["Server", "X-Powered-By"];
        set: {
            "X-Frame-Options": "DENY",
            "X-XSS-Protection": "1; mode=block",
            "Strict-Transport-Security": "max-age=31536000",
        };
    }
}

// 定義通用的日誌設定巨集
macro standard_log!(path) {
    log {
        output: File(path);
        format: Json;
        level: Info;
    }
}

server "blog.example.com" {
    listen: "0.0.0.0:443";

    // 使用巨集
    use security_headers!();
    use standard_log!("/var/log/pingclair/blog.log");

    route {
        _ => { file_server "./blog"; }
    }
}

server "shop.example.com" {
    listen: "0.0.0.0:443";

    // 重複使用相同的安全設定
    use security_headers!();
    use standard_log!("/var/log/pingclair/shop.log");

    route {
        _ => { proxy "http://shop-backend:8000"; }
    }
}

反向代理與負載平衡

:80 :8080 {
    reverse_proxy {
        lb_policy least_conn
        to 10.0.0.1:8080 {
            weight 3
        }
        to 10.0.0.2:8080
        # 🛟 僅在所有主要後端皆不可用時使用。
        to 10.0.0.3:8080 {
            backup
        }
        health_check {
            path /health
            interval 5s
            timeout 2s
            status 200 204
            consecutive_failure 3
            consecutive_success 2
            max_response_body_bytes 65536
            slow_start 30s
        }
    }
}

主動健康檢查在請求之外執行,因此閒置的故障後端會在使用者請求碰到它之前退出 輪詢,並在連續探測成功後重新加入。探測可設定 method、Host、header、狀態碼集合、 有 byte 上限的 body 比對、獨立連接埠、連線重用、門檻與 slow-start。HTTPS 探測 會沿用該 route 的 pinned CA、client certificate、SNI 與協議政策。

精確的本機 rate limit

api.example.com {
    @api path /api/*
    route @api {
        rate_limit 100 60s {
            burst 20
            key tenant X-Tenant-ID
        }
        reverse_proxy app:8080
    }
}

Token bucket 會輸出精確的 RateLimit-LimitRateLimit-RemainingRateLimit-Reset response header,拒絕時另有 Retry-After。在 block 加上 dry_run 可只計數與回報、不回 429。key 可選 ipglobalrouteapi_keyheader <name>tenant [name]。這是 process-local limiter; Redis distributed limit 不在 v0.2 範圍內。

上游 scheme 會決定連線協議:裸位址或 http:// 使用 HTTP/1.1;https:// 透過 ALPN 協商 HTTP/2,並可回退至 HTTP/1.1;h2c:// 強制使用明文 prior-knowledge HTTP/2;h2:// 則強制使用 TLS HTTP/2。原生 gRPC 應使用 h2c://h2://,確保 response trailers 以端到端 metadata 傳遞。

Unix socket upstream 寫成 unix//path/to.sock,直接撥接該 socket; unix+h2c//path/to.sock 則在其上使用 prior-knowledge HTTP/2。 Unix upstream 不會進入 DNS refresher。

上游也可以由 DNS 在執行期間動態發現:dynamic a name port 解析 name 的 所有位址記錄,dynamic srv _svc._tcp.example.com 解析 SRV 記錄並使用 記錄自帶的 port。每個 source 的 refresh 間隔各自生效;未設定時才跟隨全域 dns_refresh。省略 resolvers 會使用主機的系統 DNS 設定。SRV source 的 grace_period 只會從第一次刷新失敗起,在有限期間內保留上一次成功的 peer set; 未設定時,發現失敗會撤下 dynamic peers。Hickory 沒有精確的 RFC 6555 DNS server 撥號 hook,因此 dial_fallback_delay 會被明確拒絕,絕不接受為 no-op。查詢一律由 背景 scheduler 執行,絕不在請求路徑上。 dial 字串也可以含請求 placeholder(例如 reverse_proxy {re.dial.1}), 每個請求展開一次,並以 host+port 快取。

retry 政策接受 Caddy 的 lb_retry_match 拼法:methodpathheader 與 CEL expression。method、path 與 status-code expression 會在執行期求值; 執行期無法求值的 expression 會保留在編譯後的設定中,並在啟動時記錄。 lb_policy weighted_round_robin 支援每個 upstream 一個權重;reverse_proxy 區塊裡的 methodrewrite 會在請求送往上游前改寫請求。

request_buffers <size>response_buffers <size> 會先把該方向的 body 讀進 記憶體再往下送,慢速的一端因此佔住這台 proxy,而不是佔住後端的 worker。 大小遵循 SI/IEC 之分——1MB 是一百萬 bytes,1MiB 是 1,048,576——也接受 unlimited這裡的 unlimited 不等於無上限的記憶體:緩衝到固定的 8 MiB 上限就停,其餘照舊串流;上限一事在啟動時會說,body 真的超過時再說一次 (只說一次)。兩種情況下 body 都會完整送達,差別只在什麼時候開始送。 fastcgi transport 不受緩衝影響,這件事啟動時同樣會說。

reverse_proxy 也接受 handle_response 區塊,搭配 response matcher (@name status …@name header …)、replace_statuscopy_responsecopy_response_headers。決策只讀回應標頭;替換回應只發一次靜態 body, 其餘上游 body 逐塊丟棄,所以攔截絕不會整份緩衝。intercept { … } 會對 proxied response 註冊同一組 handler。

forward_auth <gateway> { uri …; copy_headers … } 在請求繼續送往後端前先做 一次 auth round trip:2xx 會把列出的回應 header 複製到設定的請求目標上 (包含重新命名的目標,一律先刪掉客戶端自帶的值),其餘狀態直接回給客戶端。 含 _ 的傳入 header 名稱會被丟棄,與 Caddy 預設一致。這個語法糖會編譯成 不帶 body 的 GET proxy 子請求,並轉送原始 method 與 URI;H1、H2、H3 共用 同一套串流 exchange。

以主機名寫的 upstream 會在執行期間定期重解析,容器換 IP 重啟後不需 reload 即可跟上。 解析失敗時會保留上一個位址繼續服務 —— resolver 故障不該讓站台跟著掛掉;啟動當下 還解析不到的名稱,也會在解析成功後自動加入 pool,因此代理可以先於 app 啟動。 IP 字面位址完全不會經過 resolver。

{
    # ⏱️ 預設 30s。`dns_refresh off` 會釘住一般 hostname 與未自訂 `refresh`
    # ⏱️ 的 dynamic source;source 自訂的間隔仍會生效。單位是必填的:
    # ⏱️ `30` 不等於 `30s`。
    dns_refresh 15s
}

單頁應用:try_files

try_files 會把請求改寫成第一個「在站台 root 底下確實存在」的候選路徑, 它本身不回應任何東西——真正送出檔案的是排在它後面的 file_server。 官方的單頁應用寫法可以原樣貼上:

example.com {
    root * /srv
    encode gzip
    try_files {path} /index.html
    file_server
}

請求打到真實檔案就送那個檔案,其餘一律改寫成 /index.html,交給前端自己路由。 改寫會保留 query string。

候選路徑結尾有 / 的只匹配目錄,沒有 / 的只匹配一般檔案——決定的是設定檔裡 寫的那個斜線,不是請求帶進來的那個

try_files {path} {path}/ /index.html 也可以:第二個候選匹配目錄,所以請求 /docs 會找到 /docs/,剩下的交給 file server。

這個指令不是獨立的 handler,而是一段簡寫:它展開成一個 file matcher, 加上一個「改寫成 matcher 挑中的那個候選」的 rewrite。它其餘的行為都是從這裡來的:

example.com {
    root * /srv

    # 🔍 用 glob 指名磁碟上那個帶雜湊的 bundle,設定檔不必知道雜湊值。
    try_files /build/app.*.js

    # 🎲 當「取第一個存在的」不是你要的規則時,指定挑選策略。
    try_files {path} {path}.html {
        policy most_recently_modified
    }

    # 🚨 候選寫成狀態碼就是「拋出這個狀態」,而不是去匹配一個檔案。
    try_files {path} =404

    file_server
}

五種策略是 first_exist(預設)、first_exist_fallbacksmallest_sizelargest_sizemost_recently_modified。候選可以使用任何「請求本身答得出來」 的 placeholder——{path}{uri}{query}{host}{method}{http.request.header.*}{http.vars.*}{re.*} 等等;而帶 query string 的候選(/index.php?{query})在它是被挑中的那一個時,會取代請求原本的 query。

還有三件事維持 fail closed,錯誤訊息會講出理由, 而不是編譯成一個語意悄悄不同的東西:

拒絕 理由
候選裡的 .. 片段 限制在 document root 內是詞法層做的,所以有可能跳出去的候選直接拒絕,而不是每個請求再檢查一次。
matcher 解不出來的 placeholder({env.HOME}{scheme} 它會被當成一個「檔名裡有大括號」的檔案去找——這種錯誤設定的表現和「檔案不存在」完全一樣。
認不得的 policy,或任何其他子指令 未知策略什麼都匹配不到,在線上看起來就是「這些檔案都不存在」。

🛡️ 從 placeholder 值裡帶進來的 glob 特殊字元會被跳脫,所以請求 /* 沒辦法把 try_files /files/{path} 變成一次目錄列表。只有設定檔裡寫的文字能決定 一個候選要不要當成 glob 展開。

路徑手術:uri

example.com {
    uri strip_prefix /api
    uri strip_suffix .php
    uri path_regexp /{2,} /
    reverse_proxy 127.0.0.1:3000
}

uri replaceuri query指名拒絕。在 Caddy 裡 replace 是取代路徑中的 一段子字串,而 Pingclair 的 rewrite 是整條路徑換掉;收下它會編得過、然後送出一個 跟你寫的不一樣的 URL,所以改成報錯。改寫 query string 這件事目前還沒有。

Caddy parity 控制項

example.com {
    error_page 404 /srv/errors/404.html

    @legacy path /legacy/*
    redir @legacy https://example.com/new permanent

    handle /api/* {
        cors https://app.example.com {
            methods GET POST
            allow_credentials
        }
        access_control {
            allow_ip 10.0.0.0/8
            deny_user_agent "(?i)bot"
        }
        # 正則 capture 使用 $1、$2……,並會保留 query string。
        rewrite "^/api/(.*)$" "/v1/$1"
        reverse_proxy 127.0.0.1:3000
    }
}

片段與 import

片段(snippet)是以 (name) { … } 定義、用 import name 引用的可重用片段。 import 可以把一個區塊交給片段,片段裡寫 {block} 的地方會被該區塊取代; 具名子區塊用 {blocks.<key>} 定址:

(site) {
    https://{args[0]} {
        {block}
    }
}

import site test.domain {
    reverse_proxy 127.0.0.1:3000 {
        header_up Host {host}
    }
}

沒有餵內容的佔位符就取代為空,所以寫了 {block} 的片段在呼叫端沒給區塊時 仍然可以編譯。參數列內的佔位符會被拒絕:Caddy 的 token 層可以在取代後重新 解析那一行,directive 樹做不到,所以 Pingclair 會明說而不是猜測。從檔案 import 進來的片段定義,對之後的 import 都看得到。

日誌文法

log <name> { … } 跟 Caddy 一樣:區塊設定一個具名的站台 logger,名字就是 它的 handle。沒有區塊的 log <name> 仍指向全域選項宣告的 channel;單獨一個 log 則開啟站台預設的 access log。log 區塊接受 hostnamesincludeexclude(全域)、sampling,以及檔案輪替選項(modedir_moderoll_*);log_skip 會把符合的請求排除在 access log 之外。

尚未支援

Pingclair 對外宣稱相容 Caddyfile,那麼這個宣稱誠實的另一半,就是講清楚它到哪裡 為止。以下每一個名字都是認得的:寫了會得到「這個功能還沒做」的錯誤, 不會被當成拼錯,也不會被安靜忽略。用到它們的設定啟動不了

Directive:

copy_response copy_response_headers fs invoke log_append log_name map push skip_log tracing

全域選項:

acme_ca acme_ca_root acme_eab cert_issuer cert_lifetime ech events fallback_sni filesystem frankenphp key_type ocsp_interval ocsp_stapling on_demand_tls preferred_chains renew_interval shutdown_delay storage storage_clean_interval

有兩個名字落在「上面兩張清單」與「完整支援」之間,所以寫在這裡而不是塞進任何一張。 pkiacme_server 能解析、能驗證、能序列化——帶著它們的設定載入得了也跑得起來 ——但 Pingclair 不會作為憑證機構對其他客戶端簽發憑證,而且它會明說,不會安靜地 什麼都不簽。dnsacme_dns 只實作了 Cloudflare;其餘 provider 名稱在啟動時 指名拒絕,不會被收下然後忽略。

其中三件值得直接講明白,因為它們決定的是「Pingclair 適不適合你」, 而不是之後才會踩到的細節:

  • DNS-01 只出貨一個 provider:Cloudflare。 tls { dns cloudflare <token> } 與全域 acme_dns 可以簽發萬用字元憑證,80 埠不通的機器也簽得到。 其他 provider 名字在啟動時指名拒絕——不會默默退回 HTTP-01,因為 HTTP-01 證明不了萬用字元的控制權,而那個失敗會在續簽時才出現, 訊息裡完全不會提到你設的那個選項。
  • PHP 透過 php_fastcgi 以 FastCGI 提供(HTTP/1.1 與 HTTP/2); HTTP/3 在 H3 planner 自備 FastCGI client 之前,會對 FastCGI route 回 501。
  • 憑證與狀態只存在本機磁碟storage),多個實例無法共用同一份憑證儲存。

handle_errors 值得單獨一行:這個型別在程式碼裡存在但什麼都不做, 所以它是被拒絕而不是被接受。自訂錯誤頁請用 error_page—— 那是 Pingclair 自己的 directive,不是 Caddy 的。

🔁 只要 parser 拒絕的名字沒出現在這份文件裡,測試就會紅,所以這份清單不會 悄悄落後 parser 查的那張表。README 宣稱一個 binary 沒有的能力,比宣稱得少還糟。

已知缺陷:WebSocket 升級在負載下會失敗

Pingclair 代理 WebSocket,而機器忙碌時大約 10–15% 的升級會失敗。這一條寫在 這裡而不是上面那份清單裡,因為這個功能並不是缺席——它會動,然後間歇性地不動。

問題出在 pingora-proxy 0.8.1,不是這個專案自己對升級的處理:trace 確認送往上游的 請求確實帶著 Connection: UpgradeUpgrade: websocket。上游 issue: cloudflare/pingora#946, 截至 2026-08-18 仍開啟。

一句話說明壞在哪:升級請求是一個沒有 body 的 GET,而那個空 body 的結束被誤當成 隧道的結束——但只在上游的 101 先被讀到時才會,而機器越不閒置,代理輸掉這個競態的 次數就越多。

正是這個排序讓它藏得住。在閒置的十核機器上,升級測試四十次全過;在兩核容器裡四十次 失敗六次;而只要在上游送出 101 之前插入任何延遲——哪怕只是一次純粹的 yield——失敗 就完全消失。所以開發機會告訴你這個缺陷不存在。

沒有任何設定可以避開它。從外面看到的失敗樣子是:101 之後連線立刻被拆掉,兩端同時 看到 EOF,沒有錯誤訊息。

🏗️ 架構概觀

Pingclair 採用模組化的 Cargo Workspace 結構管理程式碼:

Crate(模組) 說明
pingclair CLI 進入點。負責解析命令列參數、初始化日誌,並引導系統啟動。
pingclair-core 核心執行期。定義核心資料結構、Trait 與伺服器生命週期管理。
pingclair-config 設定編譯器。負責解析 Pingclairfile,進行詞法分析、語法分析與語意檢查,產生執行期設定物件。
pingclair-proxy 代理實作。基於 Pingora Proxy Trait 實作的 HTTP/TCP 代理邏輯,包含負載平衡器,以及基於 Cloudflare quiche 打造的 HTTP/3(QUIC)監聽器。
pingclair-static 靜態檔案服務。實作高效率的檔案讀取、MIME 類型推斷與串流傳輸。
pingclair-tls TLS 管理。處理手動憑證、持久化 internal CA 與 ACME 自動申請(Let's Encrypt)。
pingclair-api Admin API。提供 RESTful 介面,可在執行期動態檢視狀態或熱更新設定。
pingclair-plugin 🚧 骨架,尚不可用。未來外掛介面的雛形,整個 workspace 沒有任何呼叫者。設定裡寫 plugin handler 會被拒絕,而不是接受後靜默忽略。規劃於 v0.3。

🤝 參與貢獻

我們非常歡迎社群的貢獻!無論你想修正 Bug、新增特性,或僅僅是改善文件。

請先閱讀 CONTRIBUTING.md。裡面說明了每個 commit 都必須通過的四道 gate、對一個 Web 伺服器而言什麼才算測試充分,以及從程式碼本身看不出來的架構限制(BoringSSL 鏈結、HTTP/3 路徑、bounded memory)。

各版本之間改了什麼——以及哪些已經在 main 上但尚未發布——記在 CHANGELOG.md

首次貢獻者需簽署一次性的 CLA你的著作權仍屬於你自己。

📄 授權條款

本專案採用 Apache 2.0 授權條款 開源。完整條款見 LICENSE, 散布時的姓名標示義務與第三方元件見 NOTICE


以 ❤️ 與 Rust 打造