AgenFact. Agent 的產出,值得被看見

AgenFact. Agent 的產出,值得被看見

Agent 產出了一份檔案,要分享卻還得先找到它放在哪台機器、哪個目錄,登入機器把檔案複製出來,再貼進聊天室。光是把結果交給別人,就得多做這一串事情。

AgenFact — 讓作品被看見
AgenFact — 上傳、預覽,分享 AI Agent 產生的 artifact。

所以我用 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 的帳號。

AgenFact 請求從 Cloudflare 進 Knative,再到 CouchDB

上傳成功會拿到一個短 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

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 找過期文件。
1.1. Technical Overview — Apache CouchDB® 3.5 Documentation

家裡現在只用單機。以後真的要複製,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())
}

couchdb/client.go:metadata → revision → attachment,附件失敗時嘗試刪除

兩個附件錯誤分支都會嘗試 DELETE,但程式用 _, _ 忽略刪除結果。因此,上傳失敗時,這份 metadata 不一定已經清乾淨。

下載時先取得 document,再用 Resty 的 SetDoNotParseResponse 串流附件,Range 和 If-Range 也轉送給 CouchDB,不必先把整份檔案收進 Go 行程的記憶體。

1.4.2. /{db}/{docid}/{attname} — Apache CouchDB® 3.5 Documentation

過期後怎麼清理

sweeper 預設每 60 秒執行一輪,用 _find 查詢 expires_at 大於 0、且小於或等於現在時間的文件;每批最多 500 筆,每輪 timeout 30 秒。它負責回收過期文件,不負責決定連結何時失效。

CouchDB 採 append-only 寫入,刪除會留下 tombstone,也就是刪除標記;不再使用的文件與附件資料,之後由 compaction 回收空間,期間 database 仍可讀寫。AgenFact 不呼叫 purge。所以這裡的過期不代表到點就完成磁碟上的實體抹除。

私有檔怎麼驗證

Cloudflare Access 負責確認使用者已登入,AgenFact 再檢查登入的 email 是否在檔案授權名單內。通過 Access,不代表家裡所有私有檔都能下載。

Cloudflare Access 只保護 private-files,AgenFact 再驗證 email

Access 設成 self-hosted application,只保護 agenfact.weii.cloud/private-files 和它下面的路徑。policy 是 any_authenticated_user,讓使用者先通過身分提供者;session 為 168 小時,cookie 設 HttpOnly 和 SameSite=Lax。

實際請求依照這個順序走:

  1. 瀏覽器請求公開的 /files/:path,後端看到私有檔,回 HTTP 302,導向 /private-files/:path。
  2. Cloudflare Access 檢查登入狀態;沒有工作階段就導向身分提供者。登入後,Access 把 application token 放進 Cf-Access-Jwt-Assertion request header。
  3. AgenFact 先讀 Cf-Access-Jwt-Assertion;沒有這個 header 時,才接受 Authorization: Bearer。後端不從 CF_Authorization cookie 取 token。
  4. 後端驗證 token 簽章,並檢查 issuer 與 audience 是否符合這個 Access application 的設定。
  5. 驗證通過後,再將 email 與 authorized_emails 做不分大小寫的比對。不在名單內回 HTTP 403,在名單內才讀取 CouchDB attachment。
瀏覽器、AgenFact、Cloudflare 和身分提供者之間的驗證順序

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

Application token
Access application token 的 JWT 結構與身分欄位。

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

Validate JWTs
Origin 驗證 Access JWT 的方式與注意事項。

如果沒有設定 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
	}
}

server/handler/files.go:Authenticate → metadata → email allowlist

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,不會阻止瀏覽器在符合條件的請求中附帶它。

Set-Cookie header - HTTP | MDN
Cookie 的 Domain、HttpOnly 與 SameSite 適用範圍。

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 則相同。

Learn more about the Public Suffix List

預覽頁的 CSP 設為 sandbox allow-scripts,不開 allow-same-origin,讓腳本在 opaque origin 裡執行,也就是不沿用預覽站原本的 origin。frame-ancestors 只允許主站嵌入,收到訊息時也要確認來自主站。

主站對帶 Origin 的寫入請求,另外檢查 scheme 與 host,拒絕跨來源寫入。不同網域、sandbox、訊息驗證和後端檢查各自處理不同風險,不能只做其中一項就算完成。

程式怎麼拆

前面看的是一份檔案的流程,接著看這些工作在程式裡怎麼分。AgenFact 主要分成啟動組裝、HTTP 請求處理、檔案儲存和瀏覽器介面;前端與 Go 後端放在同一個映像裡,互動式 HTML 則在另一個 origin 的 iframe 裡執行。不同 host 能使用哪些 API,也由後端另外限制。

AgenFact 建置、Go 模組與瀏覽器預覽邊界:main 組裝 Gin、handler、auth、CouchDB client 和 sweeper,Svelte 以 postMessage 傳入隔離 iframe
虛線代表建置/組裝,實線代表呼叫或資料傳遞;iframe 裡才是不受信任 HTML 的執行環境。

入口與背景工作

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 請求與回應。

AgenFact 上傳呼叫序列:UploadsHandler 檢查輸入並建立 FileDocument,SaveFile 依序 PUT metadata 和 content attachment,附件失敗時嘗試 DELETE 並可能留下 metadata
圖中聚焦新檔上傳及附件寫入失敗;衝突、metadata 寫入失敗等提前返回省略。rollback 是嘗試,不是交易保證。

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 的依賴關係則整理如下:

AgenFact 程式分成 main、server、handler、auth 和 couchdb

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 各自管理。

Golang 大補帖:我那個「偶爾」會拿出來用的私藏工具與套件清單!(Part 4)
Fx 的 provide、invoke、lifecycle,以及測試和 mock。

心得

沿用現有 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
}

server/handler/uploads.go:建立 metadata,呼叫 CouchDB client

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
}

server/auth/access.go:只從 request headers 取 token

正式環境走 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
  }]
}

節錄 terraform-cloudflare/zero_trust.tf 與 agenfact.tf;不是獨立的完整部署檔

公開入口的 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 }
  }
]

既有 cloudflare_ruleset.agenfact_waf_block_files_mutations 的 rules;expression 僅換行以利閱讀

第二條 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
    }
  }]
}

terraform-cloudflare/agenfact.tf:搭配上方的 host/method 排除規則

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,機敏設定也由環境變數提供。

Golang 大補帖:我那個「偶爾」會拿出來用的私藏工具與套件清單!(Part 1)
Cobra、Viper、Zerolog 這些日常會拿出來用的套件。

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

Golang 大補帖:我那個「偶爾」會拿出來用的私藏工具與套件清單!(Part 2)
Gin 路由與 middleware、Resty HTTP client 的使用範例。