HTTPSアプリでHTTPラジオストリームを安全に再生する:Groovyによるストリーミングリレーの実装

WebアプリケーションをHTTPS化して運用している際、外部のサードパーティやレガシーな配信サーバーがHTTPでしかコンテンツを提供していない場合、ブラウザの「Mixed Content」セキュリティポリシーによって通信がブロックされます。特にインターネットラジオなどのライブストリーミング(MP3 / AACなど)では、暗号化されていない平文のHTTPストリームを直に<audio>タグへ指定すると再生が拒否されます。
本記事では、このMixed Content問題を回避するため、MintJams CMSのラジオアプリで実装したバックエンド(Groovy)によるストリーミングリレーの仕組みと、サーバー負荷を防ぐ排他制御の手法について解説します。
この記事でわかること
- WebブラウザにおけるMixed Contentエラーが発生する条件とその回避策
- Groovy(Java Servlet環境)でライブストリーミングをChunked / Pass-through転送する方法
- 同時接続制限(Semaphore)をリクエスト間で安全に保持・管理する実装テクニック
HTTPS環境からHTTPストリームへアクセスした際の障害
ブラウザは安全な通信(HTTPS)でロードされたページから、安全でない通信(HTTP)のサードパーティリソースを読み込むことを制限します。特に動画や音声のメディアストリーム(Active Content)は厳格にブロックされます。
この問題を解決するには、クライアントと配信サーバーの間にHTTPS対応のプロキシ(リレーサーバー)を挟み、ブラウザからは「同一オリジンのHTTPSエンドポイント」として見せる必要があります。
バックエンドによるストリーミングリレーの実装パターン
ラジオアプリでは、CMS上で動作するGroovyスクリプト(stream.groovy)を作成して、ストリームデータをクライアントへ転送するリレーを構築しました。
リレー処理の流れとHTTP/1.0での接続
接続先ラジオ局に対しては HTTP/1.0 かつ Connection: close でリクエストを送信します。これにより、相手サーバーからのレスポンスデータが圧縮(Gzipなど)やChunked符号化されず、生のオーディオバイト列としてストレートに返送されるため、プロキシ側の処理を最小限に抑えられます。
// 接続処理とヘッダー送信の例 (stream.groovy より抜粋)
Socket socket = new Socket();
socket.connect(new InetSocketAddress(host, port), CONNECT_TIMEOUT_MS);
socket.setSoTimeout(HEADER_TIMEOUT_MS);
OutputStream output = socket.getOutputStream();
String req = "GET " + path + " HTTP/1.0\r\n" +
"Host: " + hostHeader + "\r\n" +
"User-Agent: cms0-radio/1.0\r\n" +
"Accept: */*\r\n" +
"Connection: close\r\n\r\n";
output.write(req.getBytes(StandardCharsets.ISO_8859_1));
output.flush();
サーブレットスレッドの即時解放(AsyncContextの活用)
長時間接続が維持されるラジオ再生において、リクエストごとにサーブレットの実行スレッドを占有し続けると、すぐにスレッドプールが枯渇します。これを防ぐために request.startAsync() を使用し、転送処理をバックグラウンドのデーモンスレッドへ委譲します。
// レスポンスを非同期モードに切り替え、スレッドを解放する
AsyncContext async = request.startAsync();
async.setTimeout(0); // タイムアウトを無効化(クライアントが切断するまで継続)
Thread relay = new Thread({ ->
byte[] buf = new byte[8192];
try {
int n;
while ((n = input.read(buf)) != -1) {
output.write(buf, 0, n);
output.flush(); // 各バッファごとにフラッシュ(flush)して低遅延を維持
}
} catch (Throwable ex) {
// クライアント停止時や接続遮断時の正常終了処理
} finally {
onEnd();
async.complete();
}
}, "radio-stream-relay");
relay.setDaemon(true);
relay.start();
リレー時のセキュリティとリソース保護
単にプロキシするだけでは、自サーバーが外部へのSSRF(Server-Side Request Forgery)攻撃の中継点として悪用されたり、大量のストリーム接続によって帯域を圧迫されるリスクがあります。
プライベートIPアドレスへのリクエスト拒否
内部ネットワーク(127.0.0.1, 10.0.0.0/8, 192.168.0.0/16 など)やリンクローカルアドレスへのリクエストは事前に弾くロジックを組み込みます。
def isPrivateHost = { String host ->
if ("localhost".equalsIgnoreCase(host)) return true;
try {
for (InetAddress a : InetAddress.getAllByName(host)) {
if (a.isLoopbackAddress() || a.isSiteLocalAddress() || a.isLinkLocalAddress()) {
return true;
}
}
} catch (Throwable ignore) {
return false;
}
return false;
};
ServletContextを利用した同時接続制限(Semaphore)
Groovyスクリプトが再コンパイルされても接続制限数を安全に管理できるよう、静的変数(static)ではなく ServletContext(application オブジェクト)に Semaphore を保持させます。
| 設定・オプション | 設定値の例 | 説明 |
|---|---|---|
MAX_STREAMS |
8 |
サーバー全体での同時ストリーミング上限数 |
CONNECT_TIMEOUT_MS |
4000 (4秒) |
ラジオ局ソケット接続のタイムアウト時間 |
STALL_TIMEOUT_MS |
20000 (20秒) |
局からのデータ供給が停止(無音化)した際のタイムアウト |
X-Accel-Buffering |
no |
Nginxなどのリバースプロキシでバッファリングさせず即時転送させるヘッダー |
この状況で困ったら:HLS(.m3u8)がリレーできない
stream.groovy は単一のバイナリストリーム(MP3 / AAC)の転送を想定しています。HLS(HTTP Live Streaming)形式の場合、プレイリストファイル(.m3u8)の中にさらに別ドメインのTSセグメントURLが多数記述されているため、この単純なバイナリプロキシでは動作しません。
// player.ts における判定例
if (window.location.protocol === 'https:' && /^http:/i.test(url)) {
if (isHlsStation(station)) {
// HLSかつHTTPの場合はリレー不可のため、即座にエラーを返して呼び出し側で処理する
this.#fail('mixedContent');
return;
}
url = `stream.groovy?url=${encodeURIComponent(url)}`;
}
HLSのMixed Content回避が必要な場合は、プレイリスト内の内部URLを書き換えるプロキシを構築するか、HLS配信元自体がHTTPSに対応している局のみを利用するようバリデーションを入れてください。
チェックリスト
- [ ]
AsyncContextを使用してサーブレットスレッドを即時解放できているか - [ ] SSRF対策として内部ネットワークへのIP解決を遮断しているか
- [ ] リバースプロキシ(Nginx等)のバッファリングを
X-Accel-Buffering: noで無効化しているか - [ ] 同時接続数を排他制御し、上限を超えた場合にステータス
503を返しているか
まとめ
ブラウザのMixed Content制限は、HTTPS環境で外部の多様なソースを扱う際に必ず直面する壁です。
バックエンドで軽量なプロキシ層を挟み、適切にスレッド管理とアクセス制限(Semaphore / IP制限)を施すことで、セキュリティを担保しつつHTTPストリームを同一オリジンとして安全に再生できるようになります。
この記事で、HTTPS環境における外部ストリーミング転送の理解が深まりましたでしょうか?
ご覧いただきありがとうございました。







