2026/06/27(土)nginxでGeoIP2とUserAgentを利用したアクセス制御を実装してみた

投稿日:

過去に何度かBOTなどを避けるためのアクセス制御を導入していたが、結果どれも微妙だった。しかしどうにかしたい、そうだ、nginxは高機能なのでできるんじゃないか?と思って実践してみたログ。

User-AgentはGEOIP2と何も関係ないが、ついでにやったので書いている。

確認環境

Env Ver(apt上) 備考
nginx 1.24.0-2ubuntu7.13 nginx -vではnginx/1.24.0 (Ubuntu)
nginx-common 1.24.0-2ubuntu7.13
libnginx-mod-stream 1.24.0-2ubuntu7.13
libnginx-mod-http-geoip2 1:3.4-5build2

やったこと

互換性確保のためnginxのダウングレード

インストールされていたnginxが1.26.1-2~jammyで、libnginx-mod-http-geoip2との互換性がなかったためダウングレードすることにした。

まず次のコマンドで整合性を確認した。コマンド自体はGPT-5.5が作ってくれたものであるが、こうすることでダウングレードを許可した上での互換性チェックができるようだ。

コマンドの実行結果としては既に追加されているコンポーネント、ダウングレードされるコンポーネント、新たに追加されるコンポーネントの一覧が出てくる。

# ngx_http_geoip2_module.soが存在しないことを確認。既にある場合は本項の工程は飛ばせると思う
ls -la /usr/lib/nginx/modules

sudo apt install -s --allow-downgrades \
  nginx=1.24.0-2ubuntu7.13 \
  nginx-common=1.24.0-2ubuntu7.13 \
  libnginx-mod-stream=1.24.0-2ubuntu7.13 \
  libnginx-mod-http-geoip2

上記の構成でインストールできることが分かったので-sを外してnginx本体をダウングレードし、他をインストールする。

sudo apt install --allow-downgrades \
  nginx=1.24.0-2ubuntu7.13 \
  nginx-common=1.24.0-2ubuntu7.13 \
  libnginx-mod-stream=1.24.0-2ubuntu7.13 \
  libnginx-mod-http-geoip2

# ngx_http_geoip2_module.soが増えていることを確認
ls -la /usr/lib/nginx/modules
ngx_http_geoip2_module.so

GEOIP2データベースの入手と配置

MMDBなら何でも行けると思うので、Matomoで使ってる無料の奴を使う。理想的にはCRONで定期取得するのがいいと思うが、面倒なので今回はやってない。

sudo mkdir -p /usr/share/GeoIP
sudo wget https://download.db-ip.com/free/dbip-country-lite-2026-06.mmdb.gz -O /usr/share/GeoIP/dbip-country-lite.gz
gunzip /usr/share/GeoIP/dbip-country-lite.gz

nginxの設定でGEOIPの有効化を行う

/etc/nginx/nginx.confに以下の設定を追記すると、$geoip2_で始まる変数にMMDBの中身が入るようになる。

load_module modules/ngx_http_geoip2_module.so;

http {
    ...

    geoip2 /usr/share/GeoIP/dbip-country-lite {
      auto_reload 24h;
      $geoip2_metadata_country_build metadata build_epoch;
      $geoip2_country_code country iso_code;
      $geoip2_country_name country names en;
    }

    ...

nginxのJSONログに国コードを含める

JSONログにキーを追加してやればよい。

http {
  ...

  log_format main_json escape=json
  '{'
      ...
      '"country_code":"$geoip2_country_code"'
  '}';

国コードとUserAgentの判定フィルタの元ネタを作る

nginxのmap構文を使って判定用の元ネタを作る。

左側は~で始めると正規表現扱いになり、~*とすると大文字小文字の違いを無視してくれるようになるらしい。

http {
  # headlesschromeを2にしてるのは、これはちょっと毛色が違うと思ったため、便宜上分類分けしている。
  # 但し現時点でこの値を特別扱いしておらず、1と等価なので無駄ではある。
  map $http_user_agent $deny_ua {
    default                 0;
    "~*meta-externalagent"  1;
    "~*baidu"               1;
    "~*headlesschrome"      2;
    ""                      1;
  }

  map $geoip2_country_code $deny_ca {
    default 0;
    "CN"    1;
    "BR"    1;
    "IN"    1;
  }

  # nginxのif文にはandやorに相当する演算子がないのでmapで合成しておく
  map "$deny_ua:$deny_ca" $deny_client {
    default 1;
    "0:0"   0;
  }
  ....
}

雰囲気で読んでいるが構文の意味合いはこうだと思う。KeyValueで並べていくと入力値($input_variable)のKey(input_value)に対するValue(output_value)が出力値($output_variable)に入るのだと思う。

map $input_variable $output_variable {
    default <default_value>; # デフォルト値
    input_value output_value;
    ...
}

フィルタ用のsnippetsを作る

vhostに撒くように/etc/nginx/snippets/deny_client.confのようなsnippetsを作る。

前述のmapで判定をまとめているのでif文一つで制御できるようになっている。

if ($deny_client = 1) {
  return 404;
}

snippetsをvhostに撒く

反映したいvhostの設定にsnippetsを撒いていく。

server {
  ...

  include snippets/deny_client.conf;

  ...

動作検証

こんな感じで検証して弾かれてれることを確認した。国コードはどうにもならないが原理上問題ないはずなので大丈夫だろう。

curl -H "User-Agent:meta-externalagent/1.1" https://lycolia.info

あとは念のために国コードが出ているかどうかをnginxのログを見て出ていたらOK。

関連記事

あとがき

今回のアクセス制御の導入にあたり、運用面で課題のあるAnubisを回避しつつ、大量に存在するCGIにラッパーCGIを嚙ますなどの対応を回避できたのはよかった。

nginxは何とも高性能だ。そして自宅サーバーに全部乗せしたおかげで、レンタルサーバーでは決して手が届かないことができるのが良いと思った。

しかしググってもGEOIP無印の情報ばかりで、GEOIP2の情報が全然なかったので地味にハマってしまった。こういう時LLMに情報を漁ってもらうと取っ掛かりが得られて突破口を見つけられたりするので便利だと感じる。

パッケージを見ていて気になったこと

パッケージを見ていて気になったことは元のコードは保守されてないにもかかわらず、ディストリごとにコードが保守されているように見えたことだ。

今回導入したのはlibnginx-mod-http-geoip2だが、これは元を辿ればDebianのlibnginx-mod-http-geoip2のようで、更に元を辿るとleev/ngx_http_geoip2_module: Nginx GeoIP2 moduleに行きつく。

何故ならDebianのリポジトリがGitHubより新しく、READMEの中身が同じだからだ。設定方法もまるっきり同じに見える。

GitHubのリポジトリは2年以上放置されており「Support nginx 1.23.0」で時が止まっているが、Ubuntuでは「Rebuild against new nginx 1.30.1.」とあるため、だいぶ新しいところまでサポートが進んでいる。

よく考えるとパッケージのバージョンに-ubuntuみたいなのがついているのも良くあることなので、もしかしたらディストリごとにこういったアプリケーションを保守していたりするのだろうか?

そういえばPHPなんかも本家がサポート切れててもUbuntuではサポート内とか聞いたことがある。動作サポートなのかセキュリティサポートなのか、何のサポート課なのかまでは知らないが、星の数ほどあるパッケージをディストリごとに保守しているとしたら、これは凄いことだなと思ったし、他人が書いた得体のしれないパッケージをどう保守しているのかも気になった。

Linuxの世界は興味深い…。

nginxのダウングレードからのバージョンの復旧について

ダウングレードしても基本的に影響はないと思うが、元のバージョンに上げようとするとUbuntuそのもののバージョンアップが必要なことを知った。

しかし24.04.04 LTSから26.04への安全なアップグレードはまだ提供されていないようなのでいったん断念した。26.04.01が出ると上げられるようになるらしく、現状はお試し版みたいな感じらしい。

よくあるx.00は安定板だけど不具合がある、x.01で真の安定板になるみたいな話はUbuntuにもあるんだなと思った。

2026/06/26(金)nginxに未定義のホスト名で要求が来たときにエラーを返せるようにした

更新日:
投稿日:

nginxのバーチャルホストを増やしたり減らしたりしていて未定義のドメインにアクセスしたとき、最も若い設定ファイルに飛ばされることに気づいた。

これだと未定義のバーチャルホストを叩いたときに変なところに飛んでいくので、エラーにするようにした。

起きていた現象

git.lycolia.infoの設定をnginxから削除し、DNSにレコードが残っている状態でhttps://git.lycolia.infoを叩くと他の特定のドメインでホストしているサイトに飛ばされる状態になっていた。

例えば/etc/nginx/conf.d/access-analizer.confがあるとした場合、未定義の任意のドメイン名を叩くと、この設定ファイルで定義した場所が表示される状態だった。リダイレクトなどはなく、URLそのままで表示される感じ。

確認環境

  • nginx/1.26.1

対処方法

/etc/nginx/conf.d/ddefault.confなど、適当に設定ファイルを作って次の設定を記述すると対処できる。

server {
  listen 80 default_server;
  listen 443 ssl default_server;
  listen [::]:80 default_server;
  listen [::]:443 ssl default_server;

  ssl_reject_handshake on;

  return 444;
}

default_serverと書くと、未定義のホストに対するアクセスがすべてここに吸収される。

server_name ""にしておくとIP直打ちのアクセスもトラップできるらしいが、手元で確認した感じは、あってもなくても機能するように見えたので付けていない。

ssl_reject_handshake onにしておくことで、未定義のホストに対してHTTPSでアクセスしてきたときに、ハンドシェイクをせず突き返す事ができるようだ。

以下のようにlisten443sslをつけなくても同じように動いたが、違いはよく分かっていない。

server {
  listen 80 default_server;
  listen 443 default_server;
  listen [::]:80 default_server;
  listen [::]:443 default_server;

  ssl_reject_handshake on;

  return 444;
}

余談がうちのサーバーは外向きに80番ポートを開けていないので、80に対して制御を入れているのはもっぱらローカル用だ。開発環境を増減させてる時に変なところに繋がると面倒なので入れている。

検証結果

この設定をしたことで次のようなHTTP要求をすべてエラーで返せるようになった。

curl -H "Host:hoge.lycolia.info" "https://[2400:4153:8f01:c800:c14b:3f7a:2b54:353a]"
curl: (35) error:14077458:SSL routines:SSL23_GET_SERVER_HELLO:tlsv1 unrecognized name

curl "https://[2400:4153:8f01:c800:c14b:3f7a:2b54:353a]"
curl: (35) error:14077458:SSL routines:SSL23_GET_SERVER_HELLO:tlsv1 unrecognized name

curl -H "Host:hoge.lycolia.info" "https://180.33.219.150"
curl: (35) error:14077458:SSL routines:SSL23_GET_SERVER_HELLO:tlsv1 unrecognized name

curl "https://180.33.219.150"
curl: (35) error:14077458:SSL routines:SSL23_GET_SERVER_HELLO:tlsv1 unrecognized name

但しhttpスキーマにして443ポートに投げるとHTMLが返ってくるようだった。まぁ別にいいかと思ったが、サーバーのバージョンが出てると微妙な気がしたのでserver_tokens off;/etc/nginx/nginx.confに足しておいた。記事で明かしているとはいえ、悪意のあるBOTが機械的に叩いてきたときに見られると余りよくない。

curl -H "Host:hoge.lycolia.info" "http://[2400:4153:8f01:c800:c14b:3f7a:2b54:353a]:443"
<html>
<head><title>400 The plain HTTP request was sent to HTTPS port</title></head>
<body>
<center><h1>400 Bad Request</h1></center>
<center>The plain HTTP request was sent to HTTPS port</center>
<hr><center>nginx/1.24.0 (Ubuntu)</center>
</body>
</html>

curl "http://[2400:4153:8f01:c800:c14b:3f7a:2b54:353a]:443"
<html>
<head><title>400 The plain HTTP request was sent to HTTPS port</title></head>
<body>
<center><h1>400 Bad Request</h1></center>
<center>The plain HTTP request was sent to HTTPS port</center>
<hr><center>nginx/1.24.0 (Ubuntu)</center>
</body>
</html>

curl -H "Host:hoge.lycolia.info" "http://180.33.219.150:443"
<html>
<head><title>400 The plain HTTP request was sent to HTTPS port</title></head>
<body>
<center><h1>400 Bad Request</h1></center>
<center>The plain HTTP request was sent to HTTPS port</center>
<hr><center>nginx</center>
</body>
</html>

curl "http://180.33.219.150:443"
<html>
<head><title>400 The plain HTTP request was sent to HTTPS port</title></head>
<body>
<center><h1>400 Bad Request</h1></center>
<center>The plain HTTP request was sent to HTTPS port</center>
<hr><center>nginx</center>
</body>
</html>

おまけで80番を叩いて見たらそもそも繋がらなかった。ルーターで塞いでるのでnginxに届いていない。

curl -H "Host:hoge.lycolia.info" "http://[2400:4153:8f01:c800:c14b:3f7a:2b54:353a]"
curl: (7) Failed to connect to 2400:4153:8f01:c800:c14b:3f7a:2b54:353a port 80 after 4 ms: 接続が拒絶されました

curl "http://[2400:4153:8f01:c800:c14b:3f7a:2b54:353a]"
curl: (7) Failed to connect to 2400:4153:8f01:c800:c14b:3f7a:2b54:353a port 80 after 8 ms: 接続が拒絶されました

curl -H "Host:hoge.lycolia.info" "http://180.33.219.150"
curl: (7) Failed to connect to 180.33.219.150 port 80 after 7 ms: 接続が拒絶されました

curl "http://180.33.219.150"
curl: (7) Failed to connect to 180.33.219.150 port 80 after 6 ms: 接続が拒絶されました

2026/06/25(木)自宅サーバーの一部がダウンしたが監視を入れていてよかった話

更新日:
投稿日:

仕事を終えて自分のサイトを見ると一部がダウンしておりGrafanaを見てみたらディスク使用率が100%になっていた。Mastodonがダウン、Grafanaも不安定な状態だった。

Prometheusのsyslog集めが強すぎてストレージパンクしたかな?と思ってPrometheusを止めてみたが効果はなかった。

ディスク占有しているやつを探す

ひとまずディスクを占有している主を突き止めるため、ディレクトリ単位の容量を出して突き止めることにした。犯人はForgejoだった。

犯人探しに使った軌跡

sudo du -xh --max-depth=1 /
sudo du -xh --max-depth=1 /var
sudo du -xh --max-depth=1 /var/lib
sudo du -xh --max-depth=1 /var/lib/forgejo
sudo du -xh --max-depth=1 /var/lib/forgejo/data
sudo du -xh --max-depth=1 /var/lib/forgejo/data/repo-archive

一次対応としてForgejoへの外部アクセスを遮断

取り敢えずForgejoが攻撃されてると面倒だなと思い、nginxの設定を書き換えてForgejoへの外部接続を拒否した。

次にrepo-archiveはキャッシュらしいのでForgejoの管理画面から消すことにした。すると即座にディスクは空き、全てが元に戻った。

犯人捜し

一旦サーバーは安定状態になったので犯人を捜すことにした。

まずはForgejoのログを見たのだが役に立たなかったので、次に私はGrafanaのLokiからnginxのログを引くことにした。

そしてLokiからnginxのログを見たらすぐに分かった。meta-externalagent、つまり悪名高いFacebookのクローラーだった。過去にこのブログにやってくる海外IPのBOTの挙動を軽く調べたでも出したが、こいつはやたらアクセスしてくる。

しかし何故Facebookのクローラーがなぜ…?と思ったがURLを見てすぐわかった。アーカイブにアクセスしているのだ。

もしかしたらForgejoはアーカイブURLにアクセスされるとその都度.zipや.tar.gzを生成しているのかもしれないと思って調べたらその通りだった。そんなクソみたいな仕様ある…?と思ったが、アーカイブを作るなら他に方法はない。

ForgejoはGitHubと違ってパス単位に作れるため、パスが多いリポジトリほど大量に作れるし、過去のバージョンを参照されたら事実上ほとんど無限に生成できる。そりゃパンクする。

Forgejoはどう思っているのか?

こんなのほとんど脆弱性である。しかもディスククォーターを設定したところでどうにもならない。ダウンロードできなくなって詰むだけだ。定期的にクリアしてもいいがSSDの寿命が縮んでしまう。

そこでForgejoの人たちはどう思っているのか調べてみたら案の定課題が起票されていた。最後の奴に至ってはメインコントリビューターの一人と思われるGusted氏の起票である。

結局どうすることにしたか

リポジトリの中身をzipで落としたい需要というのはどうしても出てくるし、それは仕方がない。しかし世代ごとパスごとに作ってしまうとなると、その数は無数になりすぎてしまう。

そしてダウンロードさせる以上どこかにデータを置く必要はある。仮に定期的に消すとしてもストレージの消耗は避けられない。

結論としてForgejoは撤去し、成果物は配布ページを作ってそこでzip配信するのが無難だなと思った。

簡単なコードについてはゴミ箱みたいな場所を作ってそこに転がしておくのもいいだろう。

明らかに利用者がいるValueDomainの奴とかもあるので、ひとまず土曜日にページを作って配信しようと思った。

meta-externalagentのアクセス推移

全部で何アクセス化までは見れていないが万単位でアクセスされてそうなので無茶苦茶だなと思った。

metaは最早攻撃者にも近いと思う。

経緯とか

18時頃にメトリクスが切れているのは多分書き込めなくて一時的に死んだからと思われる。再開できた理由は不明。この時点でネットワークトラフィックも死んでおりまともにアクセスできない状態だったと思われる。

19時半頃に手当を開始したが、FacebookのクローラーはForgejoを遮断するまでダウンロード不能(statできずにエラーを吐いていた)になったURLにひたすらアクセスしていた。

あとがき

今回の障害はいい勉強になった。

ディスク容量の急増という障害があることを知れたこと、単にログとメトリクスを集めているだけでも解ることがあることが知れた。

勿論、閾値を設定した上での発報や、毎回クエリを叩かなくていいダッシュボードがあればよりよいと思うが、単に監視装置を置いているだけでもこうやって知ることができるのだなというので、いい経験になった。

2026/06/25(木)フライパンが壊れたので買いなおしたら快適になった

投稿日:

シンクの縁にフライパンを載せてたら取っ手を下にして落下し、取っ手が壊れてしまったので買い替えたら快適になった話。

買い替えたやつ

ニトリのIH・ガス火 超軽量フライパン 20cm(KY067)というやつを買った。たぶん前買ったやつの後継商品。

まず新品なのでピカピカで、二枚目の写真はサバを二回焼いて洗った後だが、全く黒くなっていない。汚れもすぐ落ち非常に快適だ。

前のフライパンは汚れが落ちづらくなっており、表面も黒ずんでいたので、寿命を知らせてくれるために壊れてくれたのかもしれない。

しかしフライパンを買い替えるたびにIHはいらないなと思うのだが、IH用とガス火用を分けて生産するとコストがかかるので仕方がないのだろう、と思ったらガス火 超軽量フライパン 20cm(KY066)という、表面仕様やサイズ感がほとんど同じものがあるようなので、こっちに買い替えようかなとか思った。

こちらの方が底面の表面積が広く、火の通りが良さそうだ。

ニトリのこのフライパンのいいところは20cmを名乗っているのにコーナンで売っている20cmのフライパンや、ニトリで売ってる他の20cmのフライパンより大きく見えることだ。実際22cm用のフライパンの二がハマる謎のサイズだし、ニトリで売ってる他のフライパンに間口を重ねてもデカい。

22cmの浅型フライパンなんて探してもそうそう存在しないので、中々都合のいいサイズである。

同日夜追記

ガス火 超軽量フライパン 20cm(KY066)を見てきたが、一回り小さかったので、これを買うのはやめることにした。

あとがき

このフライパンは確か8年ほど前に買い替えたものだと思うので、十分働いてくれた。ニトリで買って持ち帰ってる途中にアスファルトに落として傷を入れてしまい、すぐ買いなおしたのを今でも覚えている。

前述でガス火用のフライパンもあるとの情報を得たが、前回行ったとき見つけられなかったし、どこにあるのだろうとフロアマップを見たら、どうやらIHと全然違うところにあるようで気づけなかったようだ。残業した後に閉店前30分前に滑り込んだので、あまり探す余裕がなかったかもしれない。

しかし今年はモニタが壊れたり、イヤーパッドが壊れたり、スマホを買い替えたりで、買い替えが多い年だ。

モニタの買い替えについて過去記事を探したら書いてなさそうだったので、これについてもまた書きたいと思う。年初にモニタが壊れたのは中々盛大な出来事だった…。

2026/06/24(水)ForgejoでWebUIからのコミットに署名させる

投稿日:

GitHubではWebUIからPRのマージやファイル編集をしたときにコミットに署名がつくが、これをForgejoでもやる方法。

確認環境

  • Forgejo 15.0.3

やり方

基本は公式にあるInstance Commit Signingの通り。

  1. 署名用のSSH鍵を作る
    ssh-keygen -t ED25519 -f forgejo
    ssh-keygen -y -f forgejo > forgejo.pub
    sudo mv forgejo* /etc/forgejo
    sudo chown git:git /etc/forgejo/forgejo
    sudo chown git:git /etc/forgejo/forgejo.pub
    
  2. /etc/forgejo/app.iniに設定を足す
    [repository.signing]
    FORMAT = ssh
    SIGNING_KEY = /etc/forgejo/forgejo.pub
    SIGNING_NAME = "Example Git Instance"
    SIGNING_EMAIL = "noreply@git.example.com"
    INITIAL_COMMIT = pubkey
    WIKI = pubkey
    CRUD_ACTIONS = pubkey
    MERGES = pubkey
    
  3. Forgejoを再起動する
    sudo systemctl restart forgejo.service
    

結果

対応前 対応後

コミットに署名がつき、自分のアイコンが出るようになった。

但しGitHubとは違い、Forgejoが署名したことが分かる見た目になっている。

実はGitHubでもWebUIからの修正にはGitHubの鍵で署名が行われているのだが、GitHubではUI上その区別がない。Forgejo はその区別があるため、より明確な表示になったといえる。

あとがき

コミット履歴のアイコンにForgejoがいるのが気に食わなかったので、自分のアイコンで上書きできてよかった。これで履歴の気味悪さを忘れてWebUIから気軽に治せる。

署名なしコミットは文字やアイコンが大きく、行も広くなるため、表示がしっくりこなかったので丁度よくなった。