プロキシサーバをHonoで作ろう
前回、最近 Hono で外部連携用の AWS Lambda 書いていますという記事を書きましたが、記事の中でユースケースとしてあげていた「他のサービスへのデータ連携のためにAWS Lambda + Honoでプロキシサーバを書く」という応用例は、そこそこ需要があると個人的に思っているので、今回改めて記事にしました。
本記事で書くプロキシサーバは squid などの本格的なものではなく、HTTPリクエストを外部サービスに簡単に中継する程度のものです
本記事のソースコードは https://gitlab.com/ksaitou/2024-03-28_awslambdahonoproxy から入手可能です
なぜ外部サービスのREST APIサーバへのプロキシサーバが必要なのか?
様々なケースで外部サービスREST APIへの連携用中継(プロキシ)を作成したいケースがあると考えられます。
- 外部連携時のリクエスト(データ)を加工したい
- 例: Teams のチャットに外部サービスからの通知を届けたいが、Teams側が受け取れるJSONと外部サービスのJSONの形式が合わないので一度変換を噛ませたい
- 外部連携時のログを取得したい
- 例: 連携先サービスの流量やエラーを把握したいので、CloudWatch Logsなどにラムダからリクエストの内容やレスポンスの内容を記録しておいて、後から外部連携の頻度などを分析できるようにしたい
- リクエストにリトライ耐性を付与したい
- 例: プロキシした際に相手先のシステムがダウンしていたなどの理由でエラーとなったリクエスト内容をキューに保存しておき、後で再度リクエストできるように備えたい
-
連携先サービスのREST APIの認証・認可に不満がある
- サービス内で複数の目的別認証トークンを保持できない
- テスト環境・QA環境・本番環境など複数環境で認証トークンを使い分けできない
- 認証トークンへの認可が強力すぎるので制限をかけたい
- 認証トークン一つあれば特定APIの呼び出しから全データの取得〜削除のAPI呼び出しが出来てしまう
- 例および実装例: 本記事で紹介します
- サービス内で複数の目的別認証トークンを保持できない
本記事では最後の例にフォーカスして実装例を紹介します。
実際にHonoでプロキシサーバを書いてみる
簡単な設計
実際にHonoで以下のように動作するプロキシサーバを書いてみます。
動作としては、きちんと認可されているAPIにプロキシ用トークンを提示した場合は連携先サーバにリクエストを仲介し、認可外のAPIへアクセスした場合やプロキシ用のトークンを提示できない場合は 403 Forbidden
を返却するようになっています。
仮に認証トークンが一種類しかなく、どんなAPI呼び出しでも認可してしまうような困ったREST APIがあったとして、そのセキュリティリスクをプロキシを挟むことで緩和するものとなっていることが分かると思います。
またAWS Lambdaを用いることで、サーバを所有せずに必要に応じて起動し最小限のコストで運用できるようになっています。実装はHonoを用いることで、AWS Lambdaにベンダーロックインされなくなっています。
実装
ではソースコードを見てみましょう。
(https://gitlab.com/ksaitou/2024-03-28_awslambdahonoproxy でも公開しています)
import { Hono, Context } from "hono";
import { env } from "hono/adapter";
// 連携先REST APIへプロキシする関数
const proxy = async (c: Context) => {
const {
DISTRIBUTED_AUTH_TOKEN,
TARGET_HOST,
TARGET_HOST_VERY_SECRET_AUTH_TOKEN,
} = env<{
/** 自前の認証トークン (`Bearer XXXXXX`) */
DISTRIBUTED_AUTH_TOKEN: string;
/** もともとの宛先(プロキシ先) */
TARGET_HOST: string;
/** もともとの宛先の認証トークン (`Bearer YYYYYY`) */
TARGET_HOST_VERY_SECRET_AUTH_TOKEN: string;
}>(c);
// 自前の認証トークンを提示しているかチェック
console.log("req auth", c.req.header("Authorization"));
if (DISTRIBUTED_AUTH_TOKEN !== c.req.header("Authorization")) {
c.status(403);
return c.json({ message: "Authorization token mismatched" });
}
// プロキシ用のリクエストを作成
const targetHost = new URL(TARGET_HOST);
const newUrl = Object.assign(new URL(c.req.url), {
protocol: targetHost.protocol,
hostname: targetHost.hostname,
port: targetHost.port,
});
const newHeaders = new Headers(c.req.raw.headers);
newHeaders.set("Authorization", TARGET_HOST_VERY_SECRET_AUTH_TOKEN);
const req = new Request(newUrl.toString(), {
method: c.req.method,
headers: newHeaders,
body: ["GET", "HEAD"].includes(c.req.method)
? undefined
: await c.req.blob(),
});
// 連携先のAPIサーバにリクエスト発行 / 連携元にそのままレスポンス
console.log(`Proxying to ${c.req.method} ${newUrl}...`);
const res = await fetch(req);
console.log(` => ${res.status} ${res.statusText}`);
return res;
};
// プロキシが必要なAPIエンドポイントのみプロキシを適用
export default new Hono()
.post("/api/v1/proxied_api", proxy)
.all("/api/v1/another_proxied_api", proxy);
実装のポイント
プロキシの実装についていくつかポイントを書いておきました。
- 基本的には定義されたAPIルートに対して、プロキシ内の認証が通ったらプロキシする素直な実装です
- Honoは独自のリクエスト・レスポンスAPIを持たず Fetch API に登場するWeb API標準の
Request
・Response
・Headers
でリクエストやレスポンスを表現しているので、その流儀に従っています1- 連携元サービスから飛んできた
Request
オブジェクトを少し修正したものをFetchAPI(連携先サービス)に送る2 - FetchAPI(連携先サービス)から返された
Response
はそのまま連携元サービスに返す- FetchAPIのリクエスト・レスポンスと、サーバ(Hono)のリクエスト・レスポンスで扱う値が同じ型なので、今回のようなプロキシ用途ではオブジェクトをそのまま渡したりして、ちょっと楽できることが分かります
- 連携元サービスから飛んできた
- 同様にURLのホスト部の書き換えも自作処理で頑張るのではなく標準の
URL
クラスを使ってホスト部のみを書き換えています - プロキシリクエスト内容の
body: await c.req.blob()
の部分は Node.js ではbody: c.req.raw.body, duplex: "half"
と指定することも可能です- Bun 1.0.35 ではこれは動作しませんでした。なので今回の例では元のリクエストのBlobを一旦受信してからそれをそのままプロキシするリクエストに詰め込んでいます
Honoとはそもそも何なのか
そもそもHonoで実装するHTTPサーバ const app = new Hono()
は、雑にいってしまえばインターネットに繋がらないけれども、自作したAPIルートにはつながる、Fetch APIのモック実装みたいなものです。3
import { describe, expect, test } from "vitest";
import { Hono } from "hono";
describe("hono fetch", async () => {
// Honoのサーバを作成&fetch(本体)を取り出す
const app = new Hono().get("/hello", async (c) => new Response("World"));
const { fetch } = app;
// fetch を用いてFetch APIと同じ感覚でリクエストを投げてみる
test("/hello", async () => {
const res = await fetch(new Request("http://example.com/hello"));
expect(await res.text()).toBe("World");
});
test("/should_not_be_found", async () => {
const res = await fetch(
new Request("http://example.com/should_not_be_found")
);
expect(res.status).toBe(404);
});
});
ちなみにこうやってHonoで書いたサーバを export default
したソースをBunで実行するとWebサーバが起動するわけですが、
export default (new Hono().get("/hello", async (c) => new Response("World")))
$ bun --hot src/index.ts
Started server http://localhost:3000
これはHonoが特別何かをしているわけではなく、Bun側で fetch
をエクスポートしているソースを実行する場合、自動的に fetch
をWebサーバとして起動してくれる機能があるお蔭です。ちなみにCloudflare Workersも同様の仕様です。
ではAWS Lambdaはというと、そのような便利仕様ではないため、hono/aws-lambda
でラップしてあげる必要があったのでした。
import { handle } from 'hono/aws-lambda'
import app from "./index"
// Honoをラップし、 handler としてエクスポート
export const handler = handle(app)
結局このAWS Lambdaのアダプタがやっていることは、Honoの fetch
に食べさせる Request
オブジェクトを ラムダのイベント引数から作成、fetch
から返された Response
オブジェクトをラムダの結果オブジェクトに変換しているわけです。 fetch
用のアダプタですね。
別のサーバ上にHonoを載せる場合も同様にRequest・Responseオブジェクトをサーバネイティブなオブジェクトとの間で変換してあげれば実装できることがわかります。
まとめ
Honoでの簡単なプロキシサーバの書き方について触れました。それによりAWS Lambdaと組み合わせて外部APIセキュリティの脆弱さを補強する実装例を示しました。
また、Hono自体がWeb標準なFetchAPIをベースにしていることもあり、開発が楽になることも分かりました。
みなさんもどんどん小さなサーバを書いてみましょう。
-
別に
fetch
に渡すリクエスト情報については明示的にnew Request(...)
せずともコンストラクタに渡す引数をfetch
にそのまま渡しても構いませんが、本記事では説明のためにわざわざnew Request(...)
しています ↩ -
ソースコード中では
c.req
(Request
のラッパオブジェクト) を使っていますが、そのままのRequest
オブジェクトはc.req.raw
でアクセスできます ↩ -
Hono fetchの引数は本来のFetch APIと違いますが、第一引数にRequestを取り、返り値としてResponseを返し、fetchという名前なので、同一視できるような存在ということでいいと考えます ↩