最近,知名的開源專案 Alist 發生了一系列風波,引發了社區對供應鏈安全的廣泛擔憂。這件事對我個人而言,不僅是一個安全警示,更是一個契機,讓我重新審視了長期以來依賴自建服務的策略。
我使用 Alist,主要看重的是其強大的 WebDAV 服務和多用戶權限功能,而不是他能掛一堆網盤上去,它讓我在自己的伺服器上輕鬆地搭建起了個人數據同步中心。但這次事件讓我意識到,** 自建服務除了要面對潛在的供應鏈風險(專案本身、作者提供的回調 API 等),** 其穩定性也完全受限於我們自己的伺服器和運維能力,強如 DMIT,上個月也因為大樓的疑似火警,宕機了幾個小時,我們購買的伺服器,其 SLA 更是無法與專業的服務商相提並論。因此,我決定尋找一個由專業團隊維護、提供原生 WebDAV 支持的雲存儲服務作為替代。
尋找替代方案:從自建到專業服務#
我的選型標準很明確:可靠的專業服務、良好的隱私策略、以及原生的 WebDAV 支持。
- 堅果雲 是一個優秀的選擇,可惜但在免費套餐下,每月 1GB 的上傳和 3GB 的下載流量,對於需要頻繁同步的 WebDAV 場景來說,限制較大。
- 隨後,我注意到了 Koofr。這是一家歐洲的服務商,以注重用戶隱私而聞名。其免費套餐相當有誠意:
- 10 GB 存儲空間
- 不限制單文件大小
- 支持 WebDAV
- 每日 50 GB 的公開分享帶寬
- 可以掛載 OneDrive 和 Google Drive
這些特性幾乎完美滿足了我所有的需求,尤其是原生 WebDAV 支持,免去了自行部署和維護的麻煩。
新的問題:網絡延遲與訪問速度#
然而,在將我的 WebDAV 客戶端指向 Koofr 後,一個新的問題出現了。由於其伺服器位於歐洲,使用本地網絡直接訪問的連接質量非常不穩定,WebDAV 的傳輸速度長期維持在幾十 KB/s,這讓文件同步幾乎無法正常進行。
放棄如此契合的服務實在可惜,我需要一個更輕量、高效的解決方案。
解決方案:利用 Deno Playground 搭建輕量級中繼#
此時,我想起了之前編寫的一個部署在 Deno 上的反向代理腳本,這個腳本原先是為了代理那些連通性比較差的 AI 的 API 用的,能代理這些 AI,也就能代理 Koofr!
Deno 的 Playground 運行在谷歌雲上,雖然是谷歌雲但是使用本地網絡訪問起來速度很不錯,它的免費額度(每月 100 萬次請求,100GB 出站流量)也非常充裕。
我的想法是,將這個腳本稍作修改,讓他支持 Koofr 的 WebDAV 代理。所有本地到 Koofr 的請求,都先經過 Deno Deploy 的節點進行轉發,從而繞開不佳的國際鏈路,實現加速效果。
核心代碼如下#
改造的核心是增加對 Koofr 域名的代理,並特殊處理 WebDAV 的 PROPFIND
方法。因為 WebDAV 客戶端在列出目錄時,響應的 XML 文件中包含了文件的 URL 路徑。代理必須將這些路徑從目標伺服器的路徑(如 /dav/Koofr/file.txt
)重寫為代理伺服器的路徑(如 /koofr/dav/Koofr/file.txt
),客戶端才能正確識別。
import { serve, type ServeHandlerInfo } from "https://deno.land/std@0.224.0/http/server.ts";
// 定義路徑到目標URL的映射
// 鍵是代理伺服器接收到的路徑前綴,值是對應的目標伺服器基礎URL
const pathMappings: Record<string, string> = {
'/anthropic': 'https://api.anthropic.com',
'/gemini': 'https://generativelanguage.googleapis.com',
'/openai': 'https://api.openai.com', // 目標是根路徑
'/openrouter': 'https://openrouter.ai/api', // 目標本身有路徑 /api
'/xai': 'https://api.x.ai',
'/telegram': 'https://api.telegram.org',
'/discord': 'https://discord.com/api', // 目標本身有路徑 /api
'/groq': 'https://api.groq.com/openai', // 目標本身有路徑 /openai
'/cohere': 'https://api.cohere.ai',
'/huggingface': 'https://api-inference.huggingface.co',
'/together': 'https://api.together.xyz',
'/novita': 'https://api.novita.ai',
'/portkey': 'https://api.portkey.ai',
'/fireworks': 'https://api.fireworks.ai/inference', // 目標本身有路徑 /inference
'/koofr': 'https://app.koofr.net',
};
// 從環境變量 "PORT" 獲取端口號,如果未設置則默認為 8000
const port = parseInt(Deno.env.get("PORT") || "8000");
console.log(`代理伺服器正在啟動,監聽端口: http://localhost:${port}`);
// 啟動HTTP伺服器,為每個請求調用回調函數
serve(async (req: Request, _connInfo: ServeHandlerInfo) => {
const incomingUrl = new URL(req.url); // 解析入站請求的URL
const incomingPathname = incomingUrl.pathname; // 獲取入站請求的路徑部分
// 創建包含安全相關頭部的 Headers 對象
const createSecureHeaders = (contentType?: string): Headers => {
const headers = new Headers();
if (contentType) {
headers.set('Content-Type', contentType);
}
headers.set('X-Content-Type-Options', 'nosniff');
headers.set('X-Frame-Options', 'DENY');
headers.set('Referrer-Policy', 'no-referrer');
return headers;
};
if (incomingPathname === '/' || incomingPathname === '/index.html') {
return new Response(null, {
status: 404,
headers: createSecureHeaders()
});
}
if (incomingPathname === '/robots.txt') {
return new Response('User-agent: *\nDisallow: /', {
status: 200,
headers: createSecureHeaders('text/plain')
});
}
let targetBaseUrlString: string | undefined;
let matchedPrefix: string | undefined;
for (const prefix in pathMappings) {
if (incomingPathname.startsWith(prefix)) {
targetBaseUrlString = pathMappings[prefix];
matchedPrefix = prefix;
break;
}
}
if (!targetBaseUrlString || !matchedPrefix) {
console.warn(`[${new Date().toISOString()}] 未找到路徑映射: ${incomingPathname}`);
return new Response("未找到: 此路徑沒有代理映射。", {
status: 404,
headers: createSecureHeaders('text/plain')
});
}
const parsedTargetBaseUrl = new URL(targetBaseUrlString);
// suffixPath 是入站請求路徑中,匹配掉代理前綴後的剩餘部分。
// 例如:incomingPathname = "/openrouter/v1/chat", matchedPrefix = "/openrouter" -> suffixPath = "/v1/chat"
// 例如:incomingPathname = "/openai/foo", matchedPrefix = "/openai" -> suffixPath = "/foo"
// 例如:incomingPathname = "/openrouter", matchedPrefix = "/openrouter" -> suffixPath = ""
const suffixPath = incomingPathname.substring(matchedPrefix.length);
// --- 路徑拼接邏輯 ---
// 1. 規範化基礎URL (`baseForNewUrl`)
// 目標是確保如果 parsedTargetBaseUrl 本身有路徑 (其 pathname 不是根路徑"/"),
// 那麼 baseForNewUrl 的字符串表示應該以 "/" 結尾,以便 new URL() 正確地追加相對路徑。
let baseForNewUrl = parsedTargetBaseUrl.href; // 初始為映射中定義的URL的href字符串
// e.g., "https://api.openai.com/" or "https://openrouter.ai/api"
// 檢查 parsedTargetBaseUrl.pathname:
// - "https://api.openai.com" -> pathname is "/"
// - "https://openrouter.ai/api" -> pathname is "/api"
// - "https://openrouter.ai/api/" -> pathname is "/api/"
if (parsedTargetBaseUrl.pathname !== '/' && !baseForNewUrl.endsWith('/')) {
// 如果目標URL的路徑不是根目錄,並且其href不以'/'結尾 (例如 "https://host.com/path")
// 則在其末尾添加'/',使其變為 "https://host.com/path/"
baseForNewUrl += '/';
}
// 現在 baseForNewUrl 對於有路徑的基礎URL,其路徑部分會以'/'結尾 (如 "https://host.com/path/")
// 對於根路徑的基礎URL,它可能是 "https://host.com/" 或 "https://host.com" (URL構造函數都能正確處理)
// 2. 規範化要追加的路徑 (`pathForNewUrl`)
// 目標是確保從 suffixPath 得到的路徑是一個相對路徑(不以 "/" 開頭)。
let pathForNewUrl = suffixPath;
// - suffixPath = "/v1/chat" -> pathForNewUrl = "v1/chat"
// - suffixPath = "v1/chat" -> pathForNewUrl = "v1/chat" (不變)
// - suffixPath = "" -> pathForNewUrl = "" (不變)
if (pathForNewUrl.startsWith('/')) {
pathForNewUrl = pathForNewUrl.substring(1);
}
// 3. 構建最終的目標URL
// 使用規範化的 baseForNewUrl 和 pathForNewUrl。
// 例如:
// - req: /openai/v1/chat -> target: https://api.openai.com, suffix: /v1/chat
// base: "https://api.openai.com/" (或 "https://api.openai.com"), path: "v1/chat"
// -> new URL("v1/chat", "https://api.openai.com/") -> https://api.openai.com/v1/chat
// - req: /openrouter/v1/chat -> target: https://openrouter.ai/api, suffix: /v1/chat
// base: "https://openrouter.ai/api/", path: "v1/chat"
// -> new URL("v1/chat", "https://openrouter.ai/api/") -> https://openrouter.ai/api/v1/chat (正確!)
// - req: /openrouter -> target: https://openrouter.ai/api, suffix: ""
// base: "https://openrouter.ai/api/", path: ""
// -> new URL("", "https://openrouter.ai/api/") -> https://openrouter.ai/api/
const finalTargetUrl = new URL(pathForNewUrl + incomingUrl.search, baseForNewUrl);
// --- 路徑拼接邏輯結束 ---
const headersToProxy = new Headers(req.headers);
headersToProxy.set("Host", finalTargetUrl.host); // 使用最終目標URL的主機名
// 刪除不應被代理的 "hop-by-hop" 頭部
headersToProxy.delete("X-Forwarded-For");
headersToProxy.delete("X-Real-IP");
headersToProxy.delete("Forwarded");
headersToProxy.delete("Via");
const proxyReq = new Request(finalTargetUrl.toString(), {
method: req.method,
headers: headersToProxy,
body: req.body,
redirect: "manual", // 代理伺服器不應自動處理重定向
});
try {
const proxyRes = await fetch(proxyReq);
// --- WebDAV 路徑重寫邏輯 ---
// 檢查是否為需要重寫響應體的特定請求。
// 針對 Koofr WebDAV 的場景:當請求方法為 PROPFIND (WebDAV用於列出目錄內容的方法)
// 且請求路徑前綴為 /koofr 時,我們需要修改響應內容。
if (matchedPrefix === '/koofr' && req.method === 'PROPFIND') {
const contentType = proxyRes.headers.get('Content-Type') || '';
// 確保我們只修改XML響應,這是WebDAV列表的格式。
if (proxyRes.ok && (contentType.includes('application/xml') || contentType.includes('text/xml'))) {
const originalBodyText = await proxyRes.text();
// `suffixPath` 是目標伺服器上的路徑, 例如 "/dav/Koofr/webdav"
// `incomingPathname` 是客戶端請求代理伺服器的完整路徑, 例如 "/koofr/dav/Koofr/webdav"
// 目標伺服器返回的XML內容中包含了大量的 `suffixPath`。
// 我們需要將它們全部替換為 `incomingPathname`,以便客戶端能構建正確的URL。
// 例如,將 XML 中的 href="/dav/Koofr/webdav/file.zip" 替換為 href="/koofr/dav/Koofr/webdav/file.zip"。
// 使用 String.prototype.replaceAll() 進行全局替換。
// 增加一個安全檢查,避免 `suffixPath` 為 "/" 時替換掉所有根路徑。
if (suffixPath && suffixPath.length > 1) {
const rewrittenBody = originalBodyText.replaceAll(suffixPath, incomingPathname);
const responseHeaders = new Headers(proxyRes.headers);
// 因為我們修改了響應體,原有的 'Content-Length' 頭不再準確,
// 需要刪除它,以便Deno/HTTP伺服器自動重新計算。
responseHeaders.delete('Content-Length');
// 為代理響應添加/確保我們自己的安全頭部
responseHeaders.set('X-Content-Type-Options', 'nosniff');
responseHeaders.set('X-Frame-Options', 'DENY');
responseHeaders.set('Referrer-Policy', 'no-referrer');
// 返回修改後的響應
return new Response(rewrittenBody, {
status: proxyRes.status,
statusText: proxyRes.statusText,
headers: responseHeaders,
});
}
}
}
// --- WebDAV 路徑重寫邏輯結束 ---
// 對於所有其他請求,或者不滿足上述重寫條件的請求,執行原始的透傳邏輯。
const responseHeaders = new Headers(proxyRes.headers);
// 從目標伺服器的響應中刪除 hop-by-hop 頭部
responseHeaders.delete("Transfer-Encoding");
responseHeaders.delete("Connection");
responseHeaders.delete("Keep-Alive");
responseHeaders.delete("Proxy-Authenticate");
responseHeaders.delete("Proxy-Authorization");
responseHeaders.delete("TE");
responseHeaders.delete("Trailers");
responseHeaders.delete("Upgrade");
// 為代理響應添加/確保我們自己的安全頭部
responseHeaders.set('X-Content-Type-Options', 'nosniff');
responseHeaders.set('X-Frame-Options', 'DENY');
responseHeaders.set('Referrer-Policy', 'no-referrer');
return new Response(proxyRes.body, {
status: proxyRes.status,
statusText: proxyRes.statusText,
headers: responseHeaders,
});
} catch (error) {
console.error(`[${new Date().toISOString()}] 請求目標URL時出錯 ${finalTargetUrl.toString()}:`, error);
return new Response("網關錯誤: 連接上游伺服器時出錯。", {
status: 502,
headers: createSecureHeaders('text/plain')
});
}
}, { port });
部署步驟#
部署過程非常簡單,且完全免費:
- 訪問
https://dash.deno.com
並使用 GitHub 賬號登錄。 - 點擊 New Playground,將完整的代理代碼粘貼進去。
- 點擊 Save & Deploy。部署完成後,Deno 會提供一個格式為
https://[project-name].deno.dev
的公開域名。
如何使用#
假設你獲得的域名是 https://my-proxy.deno.dev
。
現在,在你使用的任何 WebDAV 客戶端中,將伺服器地址從:
https://app.koofr.net
替換為 https://my-proxy.deno.dev/koofr
例如,完整的 WebDAV 訪問 URL 將從 https://app.koofr.net/dav/Koofr
變為 https://my-proxy.deno.dev/koofr/dav/Koofr
。
配置完成後,Koofr 的 WebDAV 訪問速度得到了質的提升,文件同步恢復了應有的流暢。
總結#
最終,這個 “Koofr + Deno” 的組合方案,讓我以零成本的方式,獲得了一個兼具高可靠性、隱私保護和高速訪問的個人 WebDAV 服務。
Deno 肯定會積極維護自己的 Playground,谷歌雲的 SLA 也很不錯,即使這兩個掛了,我們還是可以使用 Koofr 自己本身的鏈接進行不那麼高速的同步,只有 Koofr 自己掛了才可能無法同步,健壯性應該是比自建 Alist 作為 webdav 方案要強很多
P.S.#
這個代碼其實本身是用來代理各種 AI 的 API 用的,也就說,你可以通過這個中繼,來更絲滑的使用 AI 的 API,而不用擔心網絡 / 地區問題。
代理地址中的 [project-name]
是一個佔位符,您需要將其替換為您的實際 Deno Deploy 項目名稱。
代理地址 | 等同於 |
---|---|
https://[project-name].deno.dev/anthropic | https://api.anthropic.com |
https://[project-name].deno.dev/gemini | https://generativelanguage.googleapis.com |
https://[project-name].deno.dev/openai | https://api.openai.com |
https://[project-name].deno.dev/openrouter | https://openrouter.ai/api |
https://[project-name].deno.dev/xai | https://api.x.ai |
https://[project-name].deno.dev/telegram | https://api.telegram.org |
https://[project-name].deno.dev/discord | https://discord.com/api |
https://[project-name].deno.dev/groq | https://api.groq.com/openai |
https://[project-name].deno.dev/cohere | https://api.cohere.ai |
https://[project-name].deno.dev/huggingface | https://api-inference.huggingface.co |
https://[project-name].deno.dev/together | https://api.together.xyz |
https://[project-name].deno.dev/novita | https://api.novita.ai |
https://[project-name].deno.dev/portkey | https://api.portkey.ai |
https://[project-name].deno.dev/fireworks | https://api.fireworks.ai/inference |
https://[project-name].deno.dev/koofr | https://app.koofr.net |
你問為什麼選擇 Deno Playground 而不是 Cloudflare Worker?
因為 Worker 反代會傳透請求的請求頭,如果你沒有主動過濾,那你的真實 IP、瀏覽器的語言、瀏覽器的 UA、瀏覽器的版本和型號都會被傳給被反代的網站。
即使你使用自構造的或者主動過濾了相關的請求頭,儘管你的真實 IP 不會洩漏,但是你的 Workers 域名和真實 IP 所屬國家仍然會被傳透過去。
不論你怎樣修改代碼,你會發現 cf-ipcountry 和 cf-worker 是無論如何都去不掉的,這就是反代 github 等網站被 Netcraft 輕易發現並被舉報的根本原因。
但是,Deno 的 Acceptable use policy 中明確表示了,不應該使用他作為正向代理使用,本方法的核心就是那一段 Deno 代碼,去薅個 supabse /fastly 然後用它的 serverless function ,似乎是個阉割版本 Deno,也是一種選擇
放在自己的伺服器上也可以,雖然需要自己維護伺服器,但這段代碼至少不會涉及到商業利益了(笑)