AgenFact. Agent 的產出,值得被看見
Agent 產出了一份檔案,要分享卻還得先找到它放在哪台機器、哪個目錄,登入機器把檔案複製出來,再貼進聊天室。光是把結果交給別人,就得多做這一串事情。
所以我用 Go 寫了 AgenFact,讓 Agent 可以上傳檔案,拿到一個能直接分享的短網址。臨時分享可以設到期時間;只想給特定的人看,就指定授權 email。服務跑在 home lab 的 Kubernetes 上,檔案和中繼資料都放進 CouchDB。
想讓 Agent 使用的話,把下面這段 prompt 貼給它就可以了:
Read https://agenfact.weii.cloud/llm.txt, install the AgenFact skill into your configured skills directory, and verify the installation. Do not upload any files during installation.
下面會用到 HTTP、Go 和 Knative。如果還不熟,建議先按上一頁逃生,這篇沒有附贈新手復活點。CouchDB 則會補上這次用到的基礎。好了,下面開幹。
一個檔案怎麼走
瀏覽器或 CLI 把檔案送到 POST /uploads,經過 Cloudflare,再進 Kubernetes 上的 Knative Service。Knative 管理服務的 revision,流量走最新一份 ready revision;檔案讀寫則交給另外部署的 CouchDB,AgenFact 只使用自己 database 的帳號。

上傳成功會拿到一個短 ID 和分享網址。沒有指定授權 email,就是知道網址就能讀的公開檔;指定 email 後,只有登入且在名單內的人才能讀。透過公開上傳入口建立的檔案預設 7 天後過期,指定路徑寫入則預設不過期。
下載時,GET /files/:path 先讀 document。公開檔接著串流名為 content 的 attachment;私有檔則回 HTTP 302,讓瀏覽器改走 /private-files/:path 的登入流程,公開路徑本身不驗身分。帶 ?meta=1 時改回 HTTP 401,JSON 裡提供 auth_url,方便呼叫端處理登入。
檔案到期後,使用者就不能再讀,不需要等背景清理。expires_at 記錄 Unix 秒,0 代表不過期;讀取時發現到期,服務立即回 HTTP 404,並觸發背景刪除。另外還有 sweeper 定期找出過期文件,處理那些到期後沒再被讀取的檔案。
也就是說,連結失效不必等文件刪除,刪除文件也不代表磁碟空間立刻釋出。前者由讀取時的到期檢查決定,後面的清理方式再往下說。
為什麼選 CouchDB

Apache CouchDB 是以 document 為單位的資料庫。每份 document 有自己的 ID,欄位直接是 JSON,不需要先拆成很多 table。它提供 HTTP API,Go 服務用一般的 HTTP client 就能讀寫。
這次主要用到三件事:
- document 可以帶 binary attachment。檔名、到期時間、授權名單和檔案內容留在同一個 database,不用另外準備檔案儲存服務。
- 單次 document 新增、修改或刪除具有原子性,會全部成功或全部失敗。更新採 MVCC,必須帶目前的 revision;revision 不符就回 HTTP 409,不會默默覆蓋別人的更新。
- 可以用 Mango query 查詢 JSON 欄位。AgenFact 透過
expires_at的 index 找過期文件。

家裡現在只用單機。以後真的要複製,API 和文件模型也不用先改。不過 CouchDB 沒有內建文件 TTL,過期判斷與清理仍然是應用程式的工作。
和其他存法比較
檔案放本機目錄,檔名、大小、到期時間另外存 JSON 或 SQLite,也能做這個服務。只是應用程式得自己處理檔案與索引的一致性:索引寫成功、檔案搬移卻失敗,就可能留下一個打不開的連結。Pod 換機器時,檔案和索引也要一起安排。
object storage 加 PostgreSQL 或其他 metadata store,適合把檔案容量、備份和資料查詢分開管理。但如果家裡每個小服務都各開一套 DB instance 或儲存服務,維護負擔會先增加。上傳時也還是得處理物件成功、metadata 失敗的情況。AgenFact 的檔案和 metadata 幾乎總是一起讀,目前沒有太大的拆分需求。
把二進位直接放進關聯式資料庫的 bytea 或 BLOB,也能集中管理;檔案資料同樣會增加儲存、備份和還原的負擔,CouchDB attachment 也不例外。我選 CouchDB,看中的是現有 instance 可以共用,AgenFact 只要使用自己的 database 和帳號,不必為這個小服務再維護另一套儲存。
把資料放在一起,仍然得處理寫入失敗。AgenFact 分兩次寫入 metadata 和 attachment,失敗時要嘗試 rollback。這裡省事的是讀寫、刪除都能用同一套 HTTP API;以家裡目前的量,我接受這個取捨。等容量或獨立備份需求大到值得拆開,再換 object storage 加 metadata store。
AgenFact 怎麼存
document ID 就是檔案路徑,固定的 attachment 名稱是 content。JSON 欄位包含 path、filename、content_type、size、created_at、expires_at、is_private 和 authorized_emails。
建立新檔案分兩次 HTTP request:先 PUT document,取得新的 revision,再帶著這個 revision PUT 名為 content 的 attachment。每次寫入各自具原子性,但中間仍可能失敗,只留下 metadata。
CouchDB 沒有跨文件 transaction;即使這裡只更新同一份 document,兩個獨立請求也不會自動合成一筆交易。
attachment 寫入失敗時,client 會帶著剛取得的 revision,嘗試刪除新建的 document。如果 rollback 也失敗,就可能留下一份沒有附件的 document;後續讀取找不到附件,也無法下載。這是失敗後的補償處理,沒有 transaction 的保證。
Go 實際怎麼呼叫
上傳 handler 先建立 FileDocument,再呼叫 SaveFile;呼叫端的完整摘錄放在附錄。正文先看 couchdb/client.go 裡真正反映儲存取捨的兩次 PUT:第一次取得 revision,第二次帶著它寫入 content 附件。
// Step 1: Write document metadata
var actionResp ActionResponse
putDocResp, err := c.resty.R().
SetContext(ctx).
SetHeader("Content-Type", "application/json").
SetBody(doc).
SetResult(&actionResp).
Put("/" + c.dbName + "/" + escapedID)
if err != nil {
return fmt.Errorf("put document metadata: %w", err)
}
if putDocResp.IsError() {
if putDocResp.StatusCode() == http.StatusConflict && !allowOverwrite {
return ErrConflict
}
return fmt.Errorf("couchdb put metadata error: %s %s", putDocResp.Status(), putDocResp.String())
}
rev := actionResp.Rev
// Step 2: Upload attachment
var attActionResp ActionResponse
putAttResp, err := c.resty.R().
SetContext(ctx).
SetQueryParam("rev", rev).
SetHeader("Content-Type", contentType).
SetBody(content).
SetResult(&attActionResp).
Put("/" + c.dbName + "/" + escapedID + "/" + AttachmentName)
if err != nil {
// Roll back doc
_, _ = c.resty.R().SetQueryParam("rev", rev).Delete("/" + c.dbName + "/" + escapedID)
return fmt.Errorf("put attachment: %w", err)
}
if putAttResp.IsError() {
// Roll back doc
_, _ = c.resty.R().SetQueryParam("rev", rev).Delete("/" + c.dbName + "/" + escapedID)
return fmt.Errorf("couchdb put attachment error: %s %s", putAttResp.Status(), putAttResp.String())
}
兩個附件錯誤分支都會嘗試 DELETE,但程式用 _, _ 忽略刪除結果。因此,上傳失敗時,這份 metadata 不一定已經清乾淨。
下載時先取得 document,再用 Resty 的 SetDoNotParseResponse 串流附件,Range 和 If-Range 也轉送給 CouchDB,不必先把整份檔案收進 Go 行程的記憶體。

過期後怎麼清理
sweeper 預設每 60 秒執行一輪,用 _find 查詢 expires_at 大於 0、且小於或等於現在時間的文件;每批最多 500 筆,每輪 timeout 30 秒。它負責回收過期文件,不負責決定連結何時失效。
CouchDB 採 append-only 寫入,刪除會留下 tombstone,也就是刪除標記;不再使用的文件與附件資料,之後由 compaction 回收空間,期間 database 仍可讀寫。AgenFact 不呼叫 purge。所以這裡的過期不代表到點就完成磁碟上的實體抹除。
私有檔怎麼驗證
Cloudflare Access 負責確認使用者已登入,AgenFact 再檢查登入的 email 是否在檔案授權名單內。通過 Access,不代表家裡所有私有檔都能下載。

Access 設成 self-hosted application,只保護 agenfact.weii.cloud/private-files 和它下面的路徑。policy 是 any_authenticated_user,讓使用者先通過身分提供者;session 為 168 小時,cookie 設 HttpOnly 和 SameSite=Lax。
實際請求依照這個順序走:
- 瀏覽器請求公開的
/files/:path,後端看到私有檔,回 HTTP 302,導向/private-files/:path。 - Cloudflare Access 檢查登入狀態;沒有工作階段就導向身分提供者。登入後,Access 把 application token 放進
Cf-Access-Jwt-Assertionrequest header。 - AgenFact 先讀
Cf-Access-Jwt-Assertion;沒有這個 header 時,才接受Authorization: Bearer。後端不從CF_Authorizationcookie 取 token。 - 後端驗證 token 簽章,並檢查 issuer 與 audience 是否符合這個 Access application 的設定。
- 驗證通過後,再將 email 與
authorized_emails做不分大小寫的比對。不在名單內回 HTTP 403,在名單內才讀取 CouchDB attachment。

application token 是 Access 發給已登入使用者的 JWT,裡面包含身分與驗證資訊。這裡用 email 授權,不使用 groups;issuer 必須符合設定值,audience 必須包含這個 application 的 AUD tag。

Cloudflare 官方建議 origin 驗證 Cf-Access-Jwt-Assertion header,不能假設每次請求都會把 CF_Authorization cookie 送到 origin。

如果沒有設定 audience,VerifyToken 會回傳 ErrAudienceNotConfigured,API 回應 HTTP 500 並拒絕存取,不會因為缺設定就略過檢查。
Go 的驗證與授權呼叫
對應前面的流程,GetPrivateFile 先呼叫 Authenticate,成功後才讀 metadata、比對 email。下面摘自 server/handler/files.go,保留驗證失敗、metadata 讀取失敗及授權拒絕的處理;通過後的 metadata 回應與 streamFile 呼叫省略:
identity, statusCode, err := h.auth.Authenticate(c)
if err != nil {
server.RespondError(c, statusCode, err.Error())
return
}
metadata, err := h.client.GetFileMetadata(c.Request.Context(), safePath)
if err != nil {
if errors.Is(err, couchdb.ErrNotFound) {
server.RespondError(c, http.StatusNotFound, fmt.Sprintf("file not found: %s", safePath))
return
}
h.logger.Error().Err(err).Str("path", safePath).Msg("failed getting private file metadata")
server.RespondError(c, http.StatusInternalServerError, "failed getting private file metadata")
return
}
if metadata.IsPrivate {
userEmail := identity.Email
authorized := false
for _, em := range metadata.AuthorizedEmails {
if strings.EqualFold(em, userEmail) {
authorized = true
break
}
}
if !authorized {
h.logger.Warn().
Str("user_email", userEmail).
Str("path", safePath).
Msg("private file access denied: email not authorized")
server.RespondError(c, http.StatusForbidden, "email not authorized for this file")
return
}
}
Authenticate 取 token 與驗證簽章的細節放在文末附錄;這段要看的是,取得身分後仍然要比對每份檔案的 email 名單。
/private-files 另外加上 Cross-Origin-Resource-Policy: same-origin,限制其他網站以一般跨來源嵌入方式載入私有檔;公開檔沒有這道限制。
公開入口的操作限制
公開的 POST /uploads 不要求登入;訪客可以建立檔案,也能下載公開檔,私有檔仍走前面的驗證與授權。這個入口靠檔案大小上限、TTL 和 Cloudflare rate limit 限制濫用;指定路徑的 /files/ 寫入則由 WAF 擋住。
網際網路上的訪客不能對主站 /files/ 做 POST、PUT、DELETE;需要管理檔案時走可信任網路入口。這道 WAF 只限制寫入操作,不替代私有檔的 JWT 驗證與 email 授權。
公開上傳的限流只針對 POST https://agenfact.weii.cloud/uploads:同一個來源 IP 在同一個 Cloudflare 資料中心,10 秒內最多 5 次,超過後擋 10 秒,回 HTTP 429 和 Retry-After: 10。這不是跨所有資料中心共用的全域精準配額,下載也不在這條規則的範圍內。
家裡使用免費方案的 path-only 限流寫法,因此要先在 custom rule 排除其他 host 與 HTTP 方法:路徑是 /uploads、但不是送到主站的 POST 請求,才用 skip 跳過 http_ratelimit,其餘請求再交給限流規則。這條 skip 不略過 Access 或其他安全階段;完整 WAF 與 rate limit 設定集中在附錄。
不受信任的預覽內容
上傳的 HTML 可能帶 JavaScript,不能直接在主站執行。AgenFact 讓 iframe 載入另一個網站的 /agenfact-preview.html,主站再透過 postMessage 傳入 HTML,由預覽頁用 document.write 寫入。這樣檔案裡的程式仍能執行,但執行環境跟主站分開。
只換 subdomain 並不等於隔開。cookie 的 Domain 如果設成 example.com,適用範圍就包含 a.example.com 和 b.example.com。把不受信任的內容搬到兄弟子網域,仍可能落在同一個 cookie 與 same-site 範圍內。HttpOnly 只禁止腳本直接讀取 cookie,不會阻止瀏覽器在符合條件的請求中附帶它。
AgenFact 要求預覽站與主站使用不同的可註冊網域,避免共用父網域 cookie;預覽內容能做哪些事,仍要靠 sandbox 與後端請求檢查限制。
golang.org/x/net/publicsuffix 負責在啟動時檢查這件事:兩個 origin 都必須是 HTTPS,不能帶帳號、path、query 或 fragment。程式用 EffectiveTLDPlusOne 計算可註冊網域,兩個結果一樣就拒絕啟動。
判斷可註冊網域,不能一律取網域最後兩段。像 co.uk 本身就是公共後綴,要依 Public Suffix List 判斷;example.co.uk 和 another.co.uk 是兩個不同的可註冊網域,example.co.uk 和 www.example.co.uk 則相同。
預覽頁的 CSP 設為 sandbox allow-scripts,不開 allow-same-origin,讓腳本在 opaque origin 裡執行,也就是不沿用預覽站原本的 origin。frame-ancestors 只允許主站嵌入,收到訊息時也要確認來自主站。
主站對帶 Origin 的寫入請求,另外檢查 scheme 與 host,拒絕跨來源寫入。不同網域、sandbox、訊息驗證和後端檢查各自處理不同風險,不能只做其中一項就算完成。
程式怎麼拆
前面看的是一份檔案的流程,接著看這些工作在程式裡怎麼分。AgenFact 主要分成啟動組裝、HTTP 請求處理、檔案儲存和瀏覽器介面;前端與 Go 後端放在同一個映像裡,互動式 HTML 則在另一個 origin 的 iframe 裡執行。不同 host 能使用哪些 API,也由後端另外限制。

入口與背景工作
main.go 讀入設定、組裝依賴,再註冊前端、健康檢查、上傳和檔案路由。server/gin.go 管理 HTTP server、request logger 與 recovery;couchdb/sweeper.go 執行過期文件清理,這兩項工作都跟著服務啟停。
handler 決定 HTTP 行為,storage 處理 CouchDB 細節
server/handler/uploads.go 處理 request body 上限、multipart 欄位、TTL、email 格式和隨機檔名,再呼叫 SaveFile。上傳內容會在大小限制內先讀進記憶體,和前面提到的串流下載不同。
server/handler/files.go 則處理路徑、公開/私有讀取、metadata 回應,以及指定路徑寫入與刪除。
server/auth 回傳驗證後的身分,files handler 決定能不能讀,再交給 couchdb/client.go 取得附件。CouchDB 的 REST URL、revision 和串流處理集中在 client,handler 只需處理 HTTP 請求與回應。

Svelte 與 Go 一起交付,預覽仍然分開執行
web/src/App.svelte 先讀 /api/config,取得檔案大小、TTL、收件人上限與預覽 origin,再送出上傳請求。分享頁則先取 metadata,需要顯示內容時才抓檔案。
Dockerfile 先執行 Svelte check/build,將產生的 webdist 複製進最終映像,由 Go 提供頁面與 assets,不需要另外跑一個常駐的 Node server。
server/frontend.go 的 host gate,也就是依請求 host 限制入口的檢查,會在路由之前執行。主站能使用分享頁和 API;隔離 host 只提供 preview runner,以及公開短路徑的 GET、HEAD、OPTIONS,不直接提供私有檔與主站 API。兩個 host 可以由同一個 Go process 回應,瀏覽器裡仍然是分開的安全邊界。
使用者明確啟動互動式 HTML 後,主站才把已讀到的內容傳入 preview runner。主頁先確認 ready 訊息來自自己的 iframe,而且 origin 是 opaque origin 對應的字串 null;runner 則檢查訊息來自 parent 與設定的主站 origin。
主站傳給 iframe 的是 HTML,不會把 Access JWT 或 cookie 一起交過去。即使前後端包在同一個映像裡,前一節的隔離網域與 sandbox 限制也不能省。
Agent 安裝資源和檔案 API 分開
skills/content.go 用 go:embed 將安裝說明、SKILL、Python helper 與相關文字資源編入 binary,再由 server/skills.go 透過 /llm.txt 和 /skills/agenfact/* 以純文字提供。這些端點只提供安裝資源,不會替 Agent 執行安裝或上傳;要分享檔案時,仍由 Agent 呼叫 /uploads。
後端 package 的依賴關係則整理如下:

Go 套件的接線重點
完整套件範例我以前寫在 Golang 大補帖,這裡只留認證與啟停兩個接線重點。一般設定與日誌說明放在附錄。
CouchDB 和 Access 各用一個 Resty client,避免向 Access 取得 JWKS 時,意外帶上資料庫 Basic Auth。main.go 的 provider 就把兩個 client 分開建立:
var backendClientProviders = fx.Provide(
func() (*couchdb.Client, error) {
return couchdb.NewClient(
viper.GetString(config.KeyCouchDBURL),
viper.GetString(config.KeyCouchDBUsername),
viper.GetString(config.KeyCouchDBPassword),
viper.GetString(config.KeyCouchDBDB),
resty.New(),
)
},
func() *auth.AccessAuth {
return auth.NewAccessAuth(resty.New().SetTimeout(60 * time.Second))
},
)
couchdb.NewClient 只設定傳入的 client,Access 則使用另一個沒有資料庫帳密的 client。多建一個物件,就能把這兩套認證設定分開。
Fx 用 Provide 宣告建立依賴的函式,用 Invoke 呼叫註冊函式。下面是 main.go 的最小組裝摘錄,需要哪些依賴由函式參數表達:
app := fx.New(
backendClientProviders,
fx.Provide(server.NewGinEngine, handler.NewPublicStaticHandler),
fx.Invoke(
server.RegisterFrontend,
couchdb.RegisterSweeper,
handler.RegisterHealthHandler,
handler.RegisterUploadsHandler,
handler.RegisterFilesHandler,
),
fx.WithLogger(fxlogger.WithZerolog(log.Logger)),
)
app.Run()
Invoke 呼叫註冊函式;HTTP server 與 sweeper 的啟停則由 fx.Lifecycle hooks 管理。HTTP server 在 OnStart 啟動監聽,OnStop 呼叫 Shutdown。
sweeper 也在啟動時開始工作,先掃一次,再依 interval 清理,停止時關閉 stop channel。這些啟停工作集中在 lifecycle,不由 handler 各自管理。

心得
沿用現有 CouchDB,把分享檔和 metadata 放在一起,對我來說最省事。現在要分享 Agent 的產出,不用再登入機器找檔、複製,然後貼進聊天室。
代價也很明確:分步寫入失敗後要補償,過期檔要清理,CouchDB attachment 並沒有把這些工作一起省掉。
以家裡目前的量,單機夠用。之後真的需要存大量檔案或獨立備份,再考慮 object storage 加 metadata store;現在拆開,只是多維護一個系統。
附錄:程式與設定摘錄
只想看設計與取捨,讀到上面的心得就可以了。下面保留完整的呼叫與設定摘錄,方便需要實作時對照;Terraform 沿用 Cloudflare provider 5.16.0,這些片段不是一份獨立的部署檔。
上傳 handler 呼叫
上傳 handler 先把路徑、到期時間和授權名單放進 FileDocument,再呼叫 SaveFile 儲存檔案。下面摘自 server/handler/uploads.go,省略前面的 multipart 解析與大小檢查;最後一個參數 false 表示不覆寫既有檔案:
doc := &couchdb.FileDocument{
Path: generatedName,
Filename: originalFilename,
ContentType: fileContentType,
Size: int64(len(fileData)),
CreatedAt: time.Now().Unix(),
ExpiresAt: expiresAt,
IsPrivate: isPrivate,
AuthorizedEmails: authorizedEmails,
}
if err := h.client.SaveFile(c.Request.Context(), doc, bytes.NewReader(fileData), int64(len(fileData)), fileContentType, false); err != nil {
h.logger.Error().Err(err).Str("path", generatedName).Msg("failed to save uploaded file to CouchDB")
server.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("failed to save file: %v", err))
return
}
Token 擷取與簽章驗證
Authenticate 會先呼叫 ExtractTokenFromRequest 取出 token,再交給 VerifyToken 驗證。前面提到的 header 優先順序,就寫在這個函式裡:
func ExtractTokenFromRequest(c *gin.Context) (string, error) {
if token := c.GetHeader("Cf-Access-Jwt-Assertion"); token != "" {
return token, nil
}
authHeader := c.GetHeader("Authorization")
if authHeader != "" {
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) == 2 && strings.EqualFold(parts[0], "bearer") && parts[1] != "" {
return parts[1], nil
}
}
return "", ErrMissingToken
}
正式環境走 RS256。ParseWithClaims 的 key function 拒絕非 RSA 的 signing method,再依 JWT header 的 kid,從 JWKS 公鑰清單挑出金鑰來驗證簽章。
JWKS 沒另外指定 URL 時,就從 issuer 的 /cdn-cgi/access/certs 取得,結果快取一小時。HS256 只在測試 secret 有設定時啟用,這條路徑只接受 HMAC signing method。
Access application 與 policy
下面沿用家裡 Cloudflare provider 5.16.0 的語法,保留私有路徑使用的 policy 與 application,省略 provider、ACCOUNT_ID 變數宣告及圖示。
everyone 讓通過 Access 登入的人進到後端,每份檔案的收件者名單仍由 AgenFact 檢查:
resource "cloudflare_zero_trust_access_policy" "any_authenticated_user" {
account_id = var.ACCOUNT_ID
name = "Any Authenticated User"
decision = "allow"
session_duration = "168h"
include = [{
everyone = {}
}]
}
resource "cloudflare_zero_trust_access_application" "agenfact_private_files" {
account_id = var.ACCOUNT_ID
name = "AgenFact Private Files"
type = "self_hosted"
session_duration = "168h"
same_site_cookie_attribute = "lax"
http_only_cookie_attribute = true
app_launcher_visible = false
destinations = [
{ type = "public", uri = "agenfact.weii.cloud/private-files" },
{ type = "public", uri = "agenfact.weii.cloud/private-files/*" }
]
policies = [{
id = cloudflare_zero_trust_access_policy.any_authenticated_user.id
}]
}
公開入口的 WAF 與 rate limit
下面兩段設定要一起看。第一段是既有 http_request_firewall_custom ruleset 的 rules:先擋主站 /files/ 的寫入,再排除不屬於主站 POST /uploads 的限流請求。
# Excerpt: rules in the existing http_request_firewall_custom ruleset.
rules = [
{
enabled = true
action = "block"
expression = join(" and ", [
"(http.host eq \"agenfact.weii.cloud\")",
"(starts_with(http.request.uri.path, \"/files/\"))",
"(http.request.method in {\"POST\" \"PUT\" \"DELETE\"})"
])
},
{
ref = "agenfact_upload_rate_limit_scope"
enabled = true
action = "skip"
expression = join(" and ", [
"(http.request.uri.path eq \"/uploads\")",
"not ((http.host eq \"agenfact.weii.cloud\") and (http.request.method eq \"POST\"))"
])
action_parameters = {
phases = ["http_ratelimit"]
}
logging = { enabled = true }
}
]
第二條 skip 只跳過 http_ratelimit,不略過 Access 或其他安全階段。這些 rules 要合併進既有 ruleset,不能另外建立一套同 phase 的 zone ruleset。接著才是相依的限流 ruleset:
resource "cloudflare_ruleset" "agenfact_upload_rate_limit" {
zone_id = local.zone_id["weii.cloud"]
name = "AgenFact upload rate limit"
description = "Limit public AgenFact uploads to 5 requests per IP per 10 seconds"
kind = "zone"
phase = "http_ratelimit"
depends_on = [cloudflare_ruleset.agenfact_waf_block_files_mutations]
rules = [{
ref = "agenfact_uploads_per_ip"
enabled = true
action = "block"
expression = "http.request.uri.path eq \"/uploads\""
description = "Block excessive public uploads for 10 seconds"
ratelimit = {
characteristics = ["cf.colo.id", "ip.src"]
period = 10
requests_per_period = 5
mitigation_timeout = 10
}
}]
}
characteristics 的 cf.colo.id 與 ip.src 對應前面提到的資料中心與來源 IP 計數。period 和 mitigation_timeout 的單位都是秒;這裡沿用 Cloudflare 預設封鎖回應,沒有自訂 response。depends_on 確保先建立配套的排除規則,再建立限流規則。
設定與日誌
Cobra 定義 command 和 flags,Viper 整合設定檔與環境變數。設定檔依序從目前目錄、./config 和 /etc/agenfact 搜尋;找不到檔案仍可啟動,但有檔案卻解析失敗就會回報錯誤。
環境變數支援 AGENFACT_ 前綴與無前綴版本。家裡使用前綴版,例如 AGENFACT_GIN_MODE=release、AGENFACT_LOG_FORMAT=json,機敏設定也由環境變數提供。

Gin 沒用 gin.Default(),改用 gin.New(),自行掛上 Zerolog middleware 和 gin.Recovery()。這樣 HTTP 請求、應用程式與 Fx 的 log 可以使用同一套格式;家裡設成 JSON,啟動錯誤和 request log 就能一起查。






