Lainbo

Lainbo's Blog

If you’re nothing without the suit, then you shouldn't have it.
github
email
follow

自作のAlistを放棄した後、私はどのようにKoofrとDenoを使って安定した高速のWebDAVを構築するか

最近、知名なオープンソースプロジェクト 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 は Google Cloud 上で動作しており、Google Cloud であってもローカルネットワークからのアクセス速度は非常に良好です。彼の無料枠(毎月 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文字列
                                               // 例:"https://api.openai.com/" または "https://openrouter.ai/api"

  // parsedTargetBaseUrl.pathname をチェック:
  // - "https://api.openai.com" -> pathname は "/"
  // - "https://openrouter.ai/api" -> pathname は "/api"
  // - "https://openrouter.ai/api/" -> pathname は "/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 });

デプロイ手順#

デプロイプロセスは非常に簡単で、完全に無料です:

  1. https://dash.deno.com にアクセスし、GitHub アカウントでログインします。
  2. New Playground をクリックし、完全なプロキシコードを貼り付けます。
  3. Save & Deploy をクリックします。デプロイが完了すると、Deno は https://[project-name].deno.dev という形式の公開ドメインを提供します。

使用方法#

あなたが得たドメインが https://my-proxy.deno.dev だと仮定します。

今、あなたが使用している任意の WebDAV クライアントで、サーバーアドレスを次のように置き換えます:
https://app.koofr.nethttps://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 を積極的に維持するでしょうし、Google Cloud の SLA も非常に良好です。たとえこの二つがダウンしても、私たちは Koofr 自身のリンクを使用してそれほど高速でない同期を行うことができ、Koofr 自身がダウンしない限り同期ができないということはありません。堅牢性は自前の Alist を WebDAV ソリューションとして使用するよりもはるかに強いはずです。

P.S.#

このコードは実際にはさまざまな AI の API をプロキシするために使用されるものであり、つまり、このリレーを通じて、ネットワークや地域の問題を心配することなく、AI の API をよりスムーズに使用することができます。

プロキシアドレスの [project-name] はプレースホルダーであり、実際の Deno Deploy プロジェクト名に置き換える必要があります。

プロキシアドレス同等の
https://[project-name].deno.dev/anthropichttps://api.anthropic.com
https://[project-name].deno.dev/geminihttps://generativelanguage.googleapis.com
https://[project-name].deno.dev/openaihttps://api.openai.com
https://[project-name].deno.dev/openrouterhttps://openrouter.ai/api
https://[project-name].deno.dev/xaihttps://api.x.ai
https://[project-name].deno.dev/telegramhttps://api.telegram.org
https://[project-name].deno.dev/discordhttps://discord.com/api
https://[project-name].deno.dev/groqhttps://api.groq.com/openai
https://[project-name].deno.dev/coherehttps://api.cohere.ai
https://[project-name].deno.dev/huggingfacehttps://api-inference.huggingface.co
https://[project-name].deno.dev/togetherhttps://api.together.xyz
https://[project-name].deno.dev/novitahttps://api.novita.ai
https://[project-name].deno.dev/portkeyhttps://api.portkey.ai
https://[project-name].deno.dev/fireworkshttps://api.fireworks.ai/inference
https://[project-name].deno.dev/koofrhttps://app.koofr.net

なぜ Deno Playground を選んだのか、Cloudflare Worker ではなく?

なぜなら、Worker のリバースプロキシはリクエストのリクエストヘッダーを透過させるため、あなたが積極的にフィルタリングしない限り、あなたの実際の IP、ブラウザの言語、ブラウザの UA、ブラウザのバージョンとモデルがプロキシされたウェブサイトに送信されるからです。
たとえあなたが自作のフィルタリングを行ったとしても、あなたの実際の IP が漏れないとしても、あなたの Workers ドメインと実際の IP 所属国は透過されてしまいます。
どんなにコードを変更しても、cf-ipcountry と cf-worker は決して消すことができないことがわかります。これが、GitHub などのウェブサイトが Netcraft に簡単に発見され、報告される根本的な理由です。

しかし、Deno の Acceptable use policy では、正向プロキシとして使用すべきではないと明示されています。この方法の核心は、その Deno コードの部分で、supabase /fastly を利用してそのサーバーレス関数を使用することは、Deno の削減版のようなものでもあり、選択肢の一つです。
image

自分のサーバーに置いても構いませんが、サーバーを維持する必要がありますが、このコードは少なくとも商業的利益には関与しないでしょう(笑)

読み込み中...
文章は、創作者によって署名され、ブロックチェーンに安全に保存されています。