カテゴリー: ガジェット Page 1 of 106

BBC Newsの見出しを表示するシェルスクリプトの改修(メニューつき)

BBC NewsのRSSフィードを取得するスクリプトを改修しました。

改修した点

  • -m フラグの処理:
    • 引数に -m が指定された場合、インタラクティブモード(対話式)の関数を呼び出します。
  • セクションの番号選択:
    • 統合された全セクションを1から順にナンバリングして表示し、ユーザーが数字で選べるようにしました。
  • 件数の入力:
    • デフォルト値を提示しつつ、エンターを押すだけでデフォルト値(3件)が適用される親切設計にしています。

改修版スクリプト

#!/bin/bash

# デフォルト値の設定
default_section="world"
default_count=3

# メインセクションのリスト
main_sections=("world" "uk" "business" "politics" "health" "education" "science_and_environment" "technology" "entertainment_and_arts")

# グローバルセクションのリスト
global_sections=("africa" "asia" "europe" "latin_america" "middle_east" "us_and_canada")

# 全セクションのリストを統合
all_sections=("${main_sections[@]}" "${global_sections[@]}")

# --- [新規] 対話式メニュー関数 ---
interactive_menu() {
    echo "=== BBC News RSS セクション選択 ==="
    # セクション一覧を番号付きで表示
    for i in "${!all_sections[@]}"; do
        printf "%2d) %s\n" "$((i+1))" "${all_sections[$i]}"
    done

    # セクションの入力受付
    while true; do
        read -p "セクションの番号を選択してください (1-${#all_sections[@]}): " sec_num
        if [[ "$sec_num" =~ ^[0-9]+$ ]] && [ "$sec_num" -ge 1 ] && [ "$sec_num" -le "${#all_sections[@]}" ]; then
            section="${all_sections[$((sec_num-1))]}"
            break
        else
            echo "無効な入力です。正しい番号を入力してください。"
        fi
    done

    # 件数の入力受付
    read -p "表示する件数を入力してください (デフォルト: ${default_count}): " input_count
    if [[ "$input_count" =~ ^[0-9]+$ ]] && [ "$input_count" -gt 0 ]; then
        count=$input_count
    else
        count=$default_count
    fi
    echo "--------------------------------------------------"
}

# --- 引数の処理 ---
if [[ "$1" == "-m" ]]; then
    # 対話モードの起動
    interactive_menu
elif [[ "$1" =~ ^[0-9]+$ ]]; then
    section=$default_section
    count=$1
else
    section=${1:-$default_section} # 引数1が指定されていない場合はデフォルト値を使用
    count=${2:-$default_count}     # 引数2が指定されていない場合はデフォルト値を使用
fi

# 引数の短縮形を対応する正式名に変換 (通常モード用)
case "$section" in
    "usa" | "n-usa") section="us_and_canada" ;;
    "me") section="middle_east" ;;
    "latam" | "la") section="latin_america" ;;
    "eu") section="europe" ;;
    "science") section="science_and_environment" ;;
    "entertainment") section="entertainment_and_arts" ;;
    *) section=$section ;; # その他はそのまま
esac

# セクションの検証
if [[ ! " ${all_sections[@]} " =~ " ${section} " ]]; then
    echo "Error: Invalid section '${section}'. Valid sections are: ${all_sections[*]}"
    exit 1
fi

# URLの構築
if [[ " ${main_sections[@]} " =~ " ${section} " ]]; then
    url="https://feeds.bbci.co.uk/news/${section}/rss.xml"
else
    url="https://feeds.bbci.co.uk/news/world/${section}/rss.xml"
fi

# 最初に一度だけRSSフィードをダウンロードし、変数に格納する
xml_content=$(curl -s "$url")

# コンテンツが取得できなかった場合はエラー終了
if [ -z "$xml_content" ]; then
    echo "Error: No headlines found for section '${section}'. Please check the section name or try again later."
    exit 1
fi

# フィードの最終更新日時を取得し、フォーマットする
feed_date_raw=$(echo "$xml_content" | xmllint --xpath "string(//channel/lastBuildDate)" - 2>/dev/null)
if [ -n "$feed_date_raw" ]; then
    # JSTに変換して表示フォーマットを整える
    feed_date_formatted=$(date -d "$feed_date_raw" '+%Y/%m/%d %H:%M:%S %Z')
fi

# 見出しを取得
headlines=$(echo "$xml_content" | xmllint --xpath "//item/title/text()" - 2>/dev/null | sed -e 's/<!\[CDATA\[//g' -e 's/\]\]>//g' | head -n "$count")

# 見出しの表示
echo "BBC News - ${section} section (${count} headlines)"
# 取得した日付を表示
if [ -n "$feed_date_formatted" ]; then
    echo "As of: ${feed_date_formatted}"
fi
echo "--------------------------------------------------" #区切り線
echo "$headlines"

使い方

  1. 通常モード(従来通り)

引数をそのまま渡せば、セクションごとに件数を表示してくれます。

./bbc_headline.sh business 5
  1. 対話式メニューモード(新機能)

-m を付けて実行します。

./bbc_headline.sh -m

実行イメージ:

=== BBC News RSS セクション選択 ==
 1) world
 2) uk
...
15) us_and_canada
セクションの番号を選択してください (1-15): 3
表示する件数を入力してください (デフォルト: 3): 5
--------------------------------------------------
BBC News - business section (5 headlines)
...

graph TD A("スクリプト開始") --> B{"引数の判定"} %% 引数の分岐 B -->|"-m"| C["対話式メニュー関数 interactive_menu"] B -->|"数字"| D["セクション: デフォルト<br>件数: 引数1の数字"] B -->|"その他"| E["セクション: 引数1<br>件数: 引数2"] %% 対話モードの詳細 subgraph "対話モードの内部処理" C --> C1["セクション一覧を番号付きで表示"] C1 --> C2["ユーザーが番号を入力"] C2 --> C3{"有効な番号か?"} C3 -->|"No"| C2 C3 -->|"Yes"| C4["セクション名を確定"] C4 --> C5["ユーザーが表示件数を入力"] C5 --> C6{"数字が入力されたか?"} C6 -->|"Yes"| C7["入力された件数を採用"] C6 -->|"No"| C8["デフォルトの3件を採用"] end %% 合流後の共通処理 C7 --> F["短縮名の変換ケース文"] C8 --> F D --> F E --> F F --> G{"有効なセクション名か?"} G -->|"No"| H("エラーを表示して終了") G -->|"Yes"| I{"メインかグローバルか?"} %% URL構築 I -->|"メイン"| J["URL: news/セクション/rss.xml"] I -->|"グローバル"| K["URL: news/world/セクション/rss.xml"] %% 取得と出力 J --> L["curl で RSS XML をダウンロード"] K --> L L --> M{"データが空でないか?"} M -->|"No"| N("エラーを表示して終了") M -->|"Yes"| O["xmllint で最終更新日時を取得・JST変換"] O --> P["xmllint と head で見出しを抽出"] P --> Q["結果を画面に出力"] Q --> R("スクリプト終了") %% スタイルの調整 style C fill:#f9f,stroke:#333,stroke-width:2px style C1 fill:#fff2cc,stroke:#d6b656 style C2 fill:#fff2cc,stroke:#d6b656 style C5 fill:#fff2cc,stroke:#d6b656

このスクリプトは役に立つのか?

私が便利だと思ったから乗せています。実際、/etc/update-motdに仕込むことで、「今はこういう出来事が起こっている」をリアルタイムで知ることができるからです。

改良版・SSLの有効期限をチェックするRubyスクリプト。

サイトの命綱であるSSL証明書を簡単にチェックするRubyスクリプトを更に改良しました。

スクリプト内容

このRubyスクリプトの動きを解説してください

#!/usr/bin/env ruby

require 'openssl'
require 'socket'
require 'date'
require 'uri'
require 'timeout'
require 'net/http'

# ANSIカラーコード
COLORS = {
  red:    "\e[31m",
  yellow: "\e[33m",
  green:  "\e[32m",
  reset:  "\e[0m"
}.freeze

# 1. 引数のURL/ドメインを適切にパースするメソッド
def parse_to_uri(input)
  clean_input = input.strip
  url = clean_input.match?(%r{\Ahttps?://}) ? clean_input : "https://#{clean_input}"
  URI.parse(url)
rescue URI::InvalidURIError
  nil
end

# 2. リダイレクトを追跡して最終的なURIを返すメソッド
def fetch_effective_uri(uri, limit = 5)
  return nil if limit == 0

  response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
    http.open_timeout = 5
    http.read_timeout = 5
    http.head(uri.path.empty? ? '/' : uri.path)
  end

  case response
  when Net::HTTPRedirection
    redirect_uri = URI.join(uri, response['location'])
    fetch_effective_uri(redirect_uri, limit - 1)
  else
    uri
  end
rescue => e
  nil
end

# 3. 証明書の有効期限を取得するメソッド(エラー箇所を修正)
def fetch_certificate_expiry(uri)
  Timeout.timeout(5) do
    # ポートが明示されていない場合は、スキーマがhttpsなら443、それ以外なら80にする
    port = uri.port || (uri.scheme == 'https' ? 443 : 80)

    # TCPSocket.open はブロックを渡すと自動クローズしてくれます
    TCPSocket.open(uri.host, port) do |tcp_socket|
      ctx = OpenSSL::SSL::SSLContext.new
      
      # .open ではなく .new を使用し、ブロックの代わりに ensure でクローズする形に修正
      ssl_socket = OpenSSL::SSL::SSLSocket.new(tcp_socket, ctx)
      ssl_socket.hostname = uri.host
      
      begin
        ssl_socket.connect
        cert = ssl_socket.peer_cert
        
        expiration_date = cert.not_after.to_date
        days_remaining = (expiration_date - Date.today).to_i

        [expiration_date, days_remaining]
      ensure
        ssl_socket.close rescue nil
      end
    end
  end
rescue Timeout::Error
  [nil, "サーバーへの接続がタイムアウトしました。"]
rescue => e
  [nil, e.message]
end

# 4. 結果を表示するメソッド
def print_result(uri, expiration_date, days_remaining)
  if expiration_date
    formatted_date = expiration_date.strftime("%Y/%m/%d")
    
    color = case days_remaining
            when ...14 then COLORS[:red]
            when ...30 then COLORS[:yellow]
            else            COLORS[:green]
            end

    puts "サイト #{uri} の有効期限は #{formatted_date} です。#{color}残り #{days_remaining} 日です。#{COLORS[:reset]}"
  else
    puts "#{COLORS[:red]}サイト #{uri} の証明書取得に失敗しました: #{days_remaining}#{COLORS[:reset]}"
  end
end

# メイン処理
def main
  inputs = ARGV.empty? ? [print("チェックしたいサイト(ドメイン/URL)を入力してください: "), gets].last.to_s : ARGV

  inputs.each do |input|
    next if input.strip.empty?

    uri = parse_to_uri(input)
    if uri.nil?
      puts "#{COLORS[:red]}無効な入力です: #{input}#{COLORS[:reset]}"
      next
    end

    puts "Checking: #{uri} ..."
    
    final_uri = fetch_effective_uri(uri)

    if final_uri.nil?
      puts "#{COLORS[:red]}サイト #{uri} にアクセスできませんでした。#{COLORS[:reset]}"
      next
    end

    unless final_uri.scheme == 'https'
      puts "#{COLORS[:yellow]}最終遷移先がHTTPSではないため、スキップします: #{final_uri}#{COLORS[:reset]}"
      next
    end

    expiration_date, days_remaining = fetch_certificate_expiry(final_uri)
    print_result(final_uri, expiration_date, days_remaining)
  end
end

main if __FILE__ == $PROGRAM_NAME

スクリプトの使い方

このスクリプトを実行するには、Rubyがインストールされた環境(MacのターミナルやLinux、Windowsのコマンドプロンプトなど)が必要です。

ステップ1: ファイルに保存する

上記のコードをコピーし、任意の場所にファイルとして保存します。

  • ファイル名(例): check_cert.rb

ステップ2: スクリプトを実行する

使い方は「引数でまとめて指定する」か「実行後に入力する」の2パターンあります。

  • パターンA:引数でドメインを指定して実行(おすすめ)

調べたいサイトをスペース区切りで並べて一気に実行できます。URLでもドメインだけでもOKです。

ruby check_cert.rb google.com github.com https://rubygems.org
  • パターンB:対話形式で実行

引数を何も渡さずに実行すると、画面上で入力を求められます。

ruby check_cert.rb

実行すると チェックしたいサイト(ドメイン/URL)を入力してください: と表示されるので、そこに yahoo.co.jp などを入力してEnterを押します。

実際の動き(出力のイメージ)

スクリプトを実行すると、内部で通信が行われ、結果がターミナルに以下のように表示されます。残り日数に応じて自動で文字に色がつくため、一目で状況がわかります。

Checking: https://google.com ...
サイト https://google.com の有効期限は 2026/07/15 です。残り 50 日です。(←緑色で表示)

Checking: https://example.com ...
サイト https://example.com の有効期限は 2026/06/05 です。残り 10 日です。(←赤色で表示)

Checking: http://http-only-site.com ...
最終遷移先がHTTPSではないため、スキップします: http://http-only-site.com (←黄色で表示)

Checking: https://invalid-domain-xyz.com ...
サイト https://invalid-domain-xyz.com の証明書取得に失敗しました: Failed to open TCP connection to invalid-domain-xyz.com:443(←赤色で表示)

エラーハンドリングの動き

  • リダイレクトがある場合:
    • http://github.com と入力しても、自動的に https://github.com へ転送(リダイレクト)されたことを検知し、最終的なHTTPSのページに対して証明書をチェックします。
  • 接続できない場合:
    • ドメインが間違っていたり、サーバーがダウンしている場合は、プログラムがクラッシュせずに「Failed to open TCP connection(接続に失敗しました)」とエラー理由を教えてくれます。

まとめというか本題

筆者はLinuxターミナルの/etc/update-motd.dに仕込むことで自サイトの証明書更新のタイミングを計っています。何せ、Let's Encryptはわずか90日。更に短くなるというのもほぼ確定しましていますから。

そして、筆者が、ここまで執拗にこれをチェックするのは「私怨」が本質。

自分をかなり追い詰めていき、体と心を大きく崩してしまった前の職場。その元職場が

  • 2020年代であるにもかかわらず常時SSL化していない

という、IT系会社にあるまじき状態を鼻で笑うためというのもこのスクリプト作成のきっかけ。

  • しかも、最近、屋台骨であるWebサービスの証明書が失効してタイムアウトになっている

という更にお察し状態なのはまた別のお話。

ONE OUTS システム番外:AI時代の下品なスクレイパー「Paprika」から、3ステップでWebサイトを護るApache防衛のケーススタディ。

概要:新型AI自動化プラットフォーム「Paprika」とは?

現Xで見かけてしまったツール、Paprika

Paprikaは、分散ワーカー上のChrome(実ブラウザ)をPlaywright経由で操作し、LLM/Vision(AI)を使ってページ内の画像・動画・構造化データを根こそぎ剥ぎ取る、極めて執拗な自動化プラットフォームです。

なぜ「趣味が悪い」のか:

  • 境界線の蹂躙:
    • ログイン必須サイトや年齢確認、JavaScript描画など、管理者が明示的に引いた「機械的な巡回を拒む壁」を、クッキー偽装やセッション維持で強引に突破することを目指している点。
  • 品性のない執着:
    • CSSの変更によるクローラー避けが効かない。AIが「人間の目で画面を見て」ボタンを探し、クリックしてくるため、これまでの構造的な防護策を無効化しようとする点。
  • 他者リソースへの強欲な寄生:
    • scroll=True で遅延ロードを発火させ、画像や動画ストリームを「丸ごと一括ダウンロード」するため、サーバーの帯域やCPU(コスト)に莫大な負荷をかける点。
  • リーガルリスク: 「正当な目的のための自動化」を気取っている連中であるため、下手に通信を拒否(403等)したり速度制限をかけたりすると、「正当なアクセスを妨害された」などと言いがかり(難癖)をつけてくる厚顔無恥なリスク.

悪用できる建前

仕様書には「利用規約の遵守」や「正当な目的での利用」と美しく免責事項が書かれていますが、提供されている機能はあきらかに「大量かつ高速なコンテンツのブッコ抜き」を目的としています。この「建前と本音の圧倒的なギャップ」が「趣味が悪い」と断じた理由です。

一般的なクローラー(Googlebotなど)は、robots.txt のルールを守り、正体を名乗って巡回します。
しかしPaprikaは、JavaScriptの完全実行、遅延ロード(スクロール動作)への追従、Cookieの永続化による会員限定ページの突破を平然と行います。

サイト側が「毎日ボタンの配置(CSS)を変える」といったボット対策をしても、AIエージェントがそれを学習して乗り越えてきます。さらに、分散IPで「一見、たくさんの一般ユーザーが同時にアクセスしてきた」ように見せかけるため、従来のWAFやIP制限が非常に効きにくいのが最大の問題です。

AIエージェントが画面スクロールや walk(サイト内巡回)を繰り返すことで、一般ユーザーの快適な閲覧環境を圧迫する、一種のDoS状態のツールです。

3. ここからサイトを護るための「Apacheの防衛」

以下、筆者環境です。

  • Apache
  • mod_rewrite
  • Ubuntu 24.04

の2つがあれば基本的には対処可能です。ApacheのMod_rewriteは、アーミーナイフのような問題です。

sudo a2enmod rewrite
sudo systemctl reload apache2.service

ステップ1:User-Agentによる水際対策(デフォルトを即座に切って捨てる)

彼らがもしデフォルトの名称(PaprikaやPlaywright)をUser-Agentに残して突っ込んできた場合、もっとも軽量な処理で済みます。

apacheの.confファイルに以下を突っ込んでおきます。

# ─── 層1: 既知のAI自動化ツール・ライブラリのUAを拒否 ───
<IfModule mod_rewrite.c>
    RewriteEngine On
    RewriteCond %{HTTP_USER_AGENT} (paprika|Playwright|Stagehand|Browser-Use|Browserable) [NC]
    RewriteRule ^ - [F,L]
</IfModule>

ステップ2:利用規約(ポリシー)の明文化(法的な盾)

「言いがかり」を完全に無力化するため、サイトのフッター等に「AIエージェントや実ブラウザ偽装による一括ダウンロードをDoS行為とみなし、検知した場合は即座にアクセス制限を適用する」旨を記載しておきましょう。

そもそも、上記のDoSツールを使ってくるものが「正常な閲覧者」である理由はありません。

ステップ3:robots.txtを逆手に取った「404ハニートラップ」

  • robots.txt に罠を仕掛ける:

まともなクローラーは rogotx.txt に書かれたDisallow を守ります。しかし、こんな輩がこれを律儀に守るということはまずありません。

そこで、それを逆手に取り、品性のないAIエージェントだけを炙り出すための罠のパスを設定します。

robots.txtに以下のような罠を設けておきます。

User-agent: *
Disallow: /assets-archive/

悪意あるクローラーは「robots.txtにわざわざ書くということは、ここに大事な情報があるに違いない」と判断します。

  • Apache(.htaccess または .conf)に罠の行き先を刻む:

踏んだクローラーに対し、403(拒否)ではなく「404(存在しない)」を返すことで、「そんなものはありません」と先んじておきます。

# robots.txtを無視して歩き回る(walk)AIエージェントへの罠 
<IfModule mod_rewrite.c>
    RewriteEngine On
    # robots.txtで禁止した領域に足を踏み入れた者は、一律で404(Not Found)
    RewriteRule ^assets-archive/?$ - [R=404,L]
</IfModule>

4. まとめ

「正当な目的」を謳えば他人のリソースを奪えばいいというツール。

崇高な目的とやらにDoSツールをばらまくというその厚顔無恥ぶりは『メリー・ポピンズ』の

Not at all attractive to my way of thinking

この言葉を借りるまでもなく悪趣味の一言。

Web管理者側もただ怯えるのではなく、相手の仕様の「執着」を逆手に取り、エレガントに虚無(404)へ引きずり込む防衛術がお役に立てれば幸いです。

IPSetを効率運用するためのスクリプト

こちらのipsetによる防御。これを運用して分かったのが

  • /16や/8で切る狂気
  • 自分自身がロックアウトされるという恐怖

の2点。そこで、これを回避するためのスクリプトを改良しました。

#!/bin/bash

# セット名の定義
SET_NAME="ufw-blocklist"
SAVE_FILE="/etc/ufw/ipsets.save"

# 1. root権限チェック
if [[ $EUID -ne 0 ]]; then
   echo "エラー: このスクリプトは sudo または root 権限で実行してください。"
   exit 1
fi

# 保存用関数
save_ipset() {
    read -p "設定をファイルに保存しますか? (y/n): " confirm
    if [[ "$confirm" =~ ^[Yy]$ ]]; then
        ipset save "$SET_NAME" -f "$SAVE_FILE"
        echo "保存完了: $SAVE_FILE"
    else
        echo "保存をスキップしました(メモリ上の設定のみ更新)。"
    fi
}

# ipsetが存在しない場合に作成
if ! ipset list "$SET_NAME" > /dev/null 2>&1; then
    echo "情報: $SET_NAME が見つからないため新規作成します。"
    if ipset create "$SET_NAME" hash:net; then
        echo "作成成功"
    else
        echo "エラー: ipsetの作成に失敗しました。"
        exit 1
    fi
fi

# フェイルセーフ関数:接続中の自分のIP(SSHポータ22)を遮断しようとしていないかチェック
is_ssh_connected_ip() {
    local target_ip=$1
    
    # 現在22番ポートでサスペンド/エスタブリッシュしている接続元のIPをすべて取得
    # (awkで不要なヘッダーやコロン以降のポート番号を削る)
    local my_ssh_ips=$(ss -nt state established sport = :22 | awk 'NR>1 {print $5}' | sed 's/:[^:]*$//' | sort -u)
    
    # テスト対象のターゲットからサブネット部分(/24など)を除外してIPのみにする
    local clean_target=$(echo "$target_ip" | cut -d'/' -f1)

    for ssh_ip in $my_ssh_ips; do
        # 1. 完全一致チェック
        if [[ "$ssh_ip" == "$clean_target" ]]; then
            return 0 # ロックアウトの危険あり
        fi

        # 2. サブネットマスクごとの巻き込みチェック
        if [[ "$target_ip" == */24 ]]; then
            local target_net=$(echo "$clean_target" | cut -d'.' -f1-3)
            local ssh_net=$(echo "$ssh_ip" | cut -d'.' -f1-3)
            if [[ "$target_net" == "$ssh_net" ]]; then
                return 0
            fi
        elif [[ "$target_ip" == */16 ]]; then
            local target_net=$(echo "$clean_target" | cut -d'.' -f1-2)
            local ssh_net=$(echo "$ssh_ip" | cut -d'.' -f1-2)
            if [[ "$target_net" == "$ssh_net" ]]; then
                return 0
            fi
        fi
    done

    return 1 # 安全
}

# IP/NWの形式を整形する関数
format_target() {
    local ip=$1
    echo "=== サブネット選択 ==="
    echo "1) そのまま登録 (${ip})"
    echo "2) /24 で登録 (${ip%.*}.0/24)"
    echo "3) /16 で登録 (${ip%.*.*}.0.0/16)"
    read -p "選択肢を選んでください [1-3]: " subnet_choice
    
    case $subnet_choice in
        2) echo "${ip%.*}.0/24" ;;
        3) echo "${ip%.*.*}.0.0/16" ;;
        *) echo "$ip" ;;
    esac
}

# メインループ
while true; do
    echo "------------------------------------------"
    echo " ipset 管理メニュー ($SET_NAME)"
    echo "------------------------------------------"
    echo "1) IP/ネットワークを追加"
    echo "2) IP/ネットワークを削除"
    echo "3) ステータス表示 (list)"
    echo "q) 終了 (Exit)"
    echo "------------------------------------------"
    read -p "番号を選択してください: " choice

    case $choice in
        1)
            echo "--- 追加モード ---"
            local added_any=false
            while true; do
                read -p "追加する IPアドレス: " raw_ip
                if [ -n "$raw_ip" ]; then
                    # サブネットの選択
                    target=$(format_target "$raw_ip")
                    
                    # フェイルセーフ発動チェック
                    if is_ssh_connected_ip "$target"; then
                        echo "【警告】現在あなたが接続しているSSH元IPが含まれているため、登録をブロックしました! ($target)"
                    else
                        if ipset add "$SET_NAME" "$target" 2>/dev/null; then
                            echo "追加成功: $target"
                            added_any=true
                        else
                            echo "エラー: 追加に失敗したか、既に登録されています。 ($target)"
                        fi
                    fi
                fi
                
                # 続けて入力するか確認
                read -p "続けて他のIPを入力しますか? (y/n): " continue_add
                if [[ ! "$continue_add" =~ ^[Yy]$ ]]; then
                    break
                fi
                echo "------------------------------------------"
            done
            
            # 追加が1件でもあれば一括で保存確認
            if [ "$added_any" = true ]; then
                save_ipset
            fi
            ;;
            
        2)
            echo "--- 削除(解除)モード ---"
            read -p "削除する IPアドレス: " raw_ip
            if [ -n "$raw_ip" ]; then
                target=$(format_target "$raw_ip")
                
                # 削除時も念のためセーフティ(既存の接続を誤って切らないようにするため)
                if is_ssh_connected_ip "$target"; then
                    echo "【警告】現在接続中のSSHセッションが含まれています。削除を中止します。 ($target)"
                else
                    if ipset del "$SET_NAME" "$target" 2>/dev/null; then
                        echo "削除成功: $target"
                        save_ipset
                    else
                        echo "エラー: 削除に失敗したか、リストに存在しません。 ($target)"
                    fi
                fi
            fi
            ;;
            
        3)
            echo "--- 現在のリスト ---"
            ipset list "$SET_NAME"
            ;;
            
        q)
            echo "終了します。"
            exit 0
            ;;
            
        *)
            echo "無効な選択です。"
            ;;
    esac
    echo ""
done

ステップ 1:スクリプトの配置と実行権限の付与

まずはスクリプトをサーバーに保存し、実行できるように権限を付与します。

  • スクリプトファイルの作成: 任意のディレクトリ
  • 任意の場所にファイルを作成します(例: blocklist-mgr.sh)。
  • 実行権限の付与: chmod コマンド
  • sudo chmod +x blocklist-mgr.sh を実行して、スクリプトに実行権限を与えます。
  • root権限で起動: sudo 必須
  • sudo ./blocklist-mgr.sh で起動します。

ステップ 2:メニュー画面の操作

起動すると、以下のような対話型メニューが表示されます。

```text

ipset 管理メニュー (ufw-blocklist)

1) IP/ネットワークを追加
2) IP/ネットワークを削除
3) ステータス表示 (list)

q) 終了 (Exit)

番号を選択してください:

1 IP/ネットワークを追加する場合(メニューで「1」を選択)

悪意のあるアクセス元IPを見つけたら、「1」を入力して追加モードに入ります。

  1. IPアドレスの入力: ブロックしたいIP(例: 192.168.1.50)を入力します。
  2. サブネット(範囲)の選択:以下のように範囲を聞かれるので、攻撃の規模に合わせて選択します。
    • 1(単一IPのみ)
    • 2(/24:周辺のIP 256台をまとめてブロック)
    • 3(/16:大規模なネットワークごとブロック)
  3. 連続入力: 「続けて他のIPを入力しますか? (y/n)」と聞かれるので、複数ある場合は y で続けて入力できます。
  4. ファイルへの永続化:

追加が終わると「設定をファイルに保存しますか? (y/n)」と聞かれます。ここで y を選ぶと、OS再起動後もブロックが有効になります。

もし間違えて「自分が今SSH接続しているIP(またはその所属サブネット)」を入力してしまった場合、スクリプトが自動でそれを検知し、

【警告】登録をブロックしました! と表示して処理を中断します。これにより、リモートワーク中にサーバーから締め出される悲劇を防ぎます。

2 ブロックを解除(削除)する場合(メニューで「2」を選択)

誤ってブロックしてしまったIPや、制限を解除したいIPがある場合は「2」を選択します。
追加時と同様にIPを入力し、サブネット範囲を選択すれば、リストから安全に削除されます。ここでも自分が接続中のIPを誤って削除(変更)しないようセーフティが働きます。

3 現在のブロックリストを確認する場合(メニューで「3」を選択)

「3」を選択すると、現在 ipset に登録されてリアルタイムでブロックされているIPの一覧がズラリと表示されます。正しく反映されているか確認したいときに便利です。

4 終了する場合(メニューで「q」を選択)

「q」を入力すると、安全にメニューを閉じます。

攻撃ログの傾向とスペルミス。

自分のサーバへのアクセスログ。いつもの挨拶の他、「これはさすがに」と思うものがあったのでご紹介です。

ログ抜粋

例によってIPアドレスやホスト名を無害化(ダミーデータへの置き換え)を行っています。テロリストに名前を与える気はないからです。

[Fri May 22 00:01:58.357561 2026] [security2:error] [pid 563988:tid 133631884367552] [client 192.0.2.10:13346] [client 192.0.2.10] ModSecurity: Multipart parsing error: Multipart: No boundaries found in payload. [hostname "example.com"] [uri "/"] [unique_id "ag8eZi5y5sdCQ_cRAbbcQAAAAFc"]
[Fri May 22 00:01:58.357663 2026] [security2:error] [pid 563989:tid 133632756782784] [client 192.0.2.10:13348] [client 192.0.2.10] ModSecurity: Multipart parsing error: Multipart: No boundaries found in payload. [hostname "example.com"] [uri "/projects/"] [unique_id "ag8eZuLCu5fVcQiqqP37FAAAAIo"]
[Fri May 22 00:02:00.980490 2026] [security2:error] [pid 563989:tid 133632656135872] [client 192.0.2.10:53854] [client 192.0.2.10] ModSecurity: Multipart parsing error: Multipart: No boundaries found in payload. [hostname "example.com"] [uri "/"] [unique_id "ag8eaOLCu5fVcQiqqP37HQAAAIw"]
[Fri May 22 00:22:32.285606 2026] [security2:error] [pid 563989:tid 133632790353600] [client 192.0.2.20:48723] [client 192.0.2.20] ModSecurity: Access denied with code 404 (phase 1). Pattern match "^[\\\\d.]+(:\\\\d+)?$" at REQUEST_HEADERS:Host. [file "/usr/share/modsecurity-crs/coreruleset/rules/REQUEST-900-EXCLUSION-RULES-BEFORE-CRS.conf"] [line "53"] [id "10004"] [msg "[CUSTOM RULE] Host header is a numeric IP address (incl port). Blocked immediately."] [tag "application-attack"] [tag "PROTOCOL_VIOLATION/INVALID_HREQ"] [hostname "203.0.113.50"] [uri "/"] [unique_id "ag8jOOLCu5fVcQiqqP3_ewAAAIY"]
[Fri May 22 00:22:38.213267 2026] [security2:error] [pid 563989:tid 133632647743168] [client 192.0.2.20:56013] [client 192.0.2.20] ModSecurity: Access denied with code 404 (phase 1). Operator EQ matched 0 at REQUEST_HEADERS. [file "/usr/share/modsecurity-crs/coreruleset/rules/REQUEST-900-EXCLUSION-RULES-BEFORE-CRS.conf"] [line "62"] [id "10005"] [msg "[CUSTOM RULE] Missing Host Header. Blocked immediately."] [hostname "example.com"] [uri "/"] [unique_id "ag8jPuLCu5fVcQiqqP3_gwAAAI0"]
[Fri May 22 00:36:55.821987 2026] [security2:error] [pid 563989:tid 133632370915008] [client 192.0.2.30:38808] [client 192.0.2.30] ModSecurity: Access denied with code 404 (phase 1). Pattern match "^[\\\\d.]+(:\\\\d+)?$" at REQUEST_HEADERS:Host. [file "/usr/share/modsecurity-crs/coreruleset/rules/REQUEST-900-EXCLUSION-RULES-BEFORE-CRS.conf"] [line "53"] [id "10004"] [msg "[CUSTOM RULE] Host header is a numeric IP address (incl port). Blocked immediately."] [tag "application-attack"] [tag "PROTOCOL_VIOLATION/INVALID_HREQ"] [hostname "203.0.113.50"] [uri "/.env"] [unique_id "ag8ml-LCu5fVcQiqqP0CpgAAAJU"]
[Fri May 22 02:56:01.000854 2026] [security2:error] [pid 563988:tid 133632387700416] [client 192.0.2.40:46164] [client 192.0.2.40] ModSecurity: Access denied with code 404 (phase 1). Operator EQ matched 0 at REQUEST_HEADERS. [file "/usr/share/modsecurity-crs/coreruleset/rules/REQUEST-900-EXCLUSION-RULES-BEFORE-CRS.conf"] [line "62"] [id "10005"] [msg "[CUSTOM RULE] Missing Host Header. Blocked immediately."] [hostname "example.com"] [uri "/"] [unique_id "ag9HMS5y5sdCQ_cRAbbtWQAAAEw"]
[Fri May 22 03:18:21.209460 2026] [security2:error] [pid 563989:tid 133632396093120] [client 192.0.2.50:53678] [client 192.0.2.50] ModSecurity: Warning. Matched phrase "Mozlila" at REQUEST_HEADERS:User-Agent. [file "/usr/share/modsecurity-crs/coreruleset/rules/REQUEST-913-SCANNER-DETECTION.conf"] [line "56"] [id "913100"] [msg "Found User-Agent associated with security scanner"] [data "Matched Data: Mozlila found within REQUEST_HEADERS:User-Agent: Mozlila/5.0 (Linux; Android 7.0; SM-G892A Bulid/NRD90M; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/60.0.3112.107 Moblie Safari/537.36"] [severity "CRITICAL"] [hostname "example.com"] [uri "/"] [unique_id "ag9MbeLCu5fVcQiqqP0kggAAAJI"]
[Fri May 22 03:18:51.242531 2026] [security2:error] [pid 563989:tid 133632396093120] [client 192.0.2.50:58448] [client 192.0.2.50] ModSecurity: Warning. Matched phrase ".env" at REQUEST_FILENAME. [file "/usr/share/modsecurity-crs/coreruleset/rules/REQUEST-930-APPLICATION-ATTACK-LFI.conf"] [line "145"] [id "930130"] [msg "Restricted File Access Attempt"] [data "Matched Data: .env found within REQUEST_FILENAME: /.env.live"] [severity "CRITICAL"] [hostname "example.com"] [uri "/.env.live"] [unique_id "ag9Mi-LCu5fVcQiqqP0kmwAAAJI"]
[Fri May 22 03:18:51.349415 2026] [security2:error] [pid 563989:tid 133632379307712] [client 192.0.2.50:58432] [client 192.0.2.50] ModSecurity: Warning. String match within "..." at TX:extension. [file "/usr/share/modsecurity-crs/coreruleset/rules/REQUEST-920-PROTOCOL-ENFORCEMENT.conf"] [line "1105"] [id "920440"] [msg "URL file extension is restricted by policy"] [data ".old"] [severity "CRITICAL"] [hostname "example.com"] [uri "/.env.old"] [unique_id "ag9Mi-LCu5fVcQiqqP0knQAAAJQ"]
[Fri May 22 03:19:17.202887 2026] [security2:error] [pid 563988:tid 133632807139008] [client 192.0.2.50:48772] [client 192.0.2.50] ModSecurity: Warning. Matched phrase ".gitlab-ci.yml" at REQUEST_FILENAME. [file "/usr/share/modsecurity-crs/coreruleset/rules/REQUEST-930-APPLICATION-ATTACK-LFI.conf"] [line "145"] [id "930130"] [msg "Restricted File Access Attempt"] [data "Matched Data: .gitlab-ci.yml found within REQUEST_FILENAME: /.gitlab-ci.yml"] [severity "CRITICAL"] [hostname "example.com"] [uri "/.gitlab-ci.yml"] [unique_id "ag9MpS5y5sdCQ_cRAbbvZQAAAEM"]

ここから気になった攻撃ログを見てみましょう。

謎のパースエラー:WAFや脆弱性スキャナの「ご挨拶」

  • 該当ログの傾向:
    • Multipart parsing error: No boundaries found in payload.
  • 攻撃者の意図:

これは攻撃者がファイルアップロード(Multipart形式)を模したリクエストを送っているものの、データ構造が壊れている(Boundaryがない)ためにWAF(ModSecurity)が激怒している状態です。
手動の攻撃ではなく、雑に作られた自動スキャンツール(ボット)が、手当たり次第に脆弱性を探す「偵察フェーズ」としてパケットを送りつけてきたと考えられます。

IPアドレス直打ちでのアクセス:無差別スキャンの証拠

  • 該当ログの傾向:
    • Host header is a numeric IP address / Missing Host Header.
  • 攻撃者の意図:
    通常、ユーザーは「example.com」というドメイン名でアクセスしますが、このボットはドメイン名ではなくサーバーの「生のIPアドレス」を直接指定、あるいはHostヘッダーを空にしてアクセスしています。
    これは「特定のサイトを狙った攻撃」ではなく、「インターネット全体に存在するIPアドレスへ、適当に片っ端から攻撃を打つ」タイプの無差別スキャン(IPスキャン)を行っている明確な証拠です。

「.env」の執拗な探索:お宝(環境変数)の窃盗狙い

  • 該当ログの傾向:
    • /.env, /.env.live, /.env.local, /.env.old, /.env.test
  • 攻撃者の意図:

Webアプリケーションの設定ファイルである .env(環境変数ファイル)を狙っています。
ここには「データベースのパスワード」「AWSやstripeなどのAPI秘密鍵」といった、一発でシステムが崩壊するレベルの情報が書かれています。
ご丁寧に .env だけでなく .env.old や .env.test などの「バックアップっぽいファイル名」まで推測して探している点です。

開発者がうっかり残した過去の遺物を探す、攻撃者の「ずる賢さ」がよく出ています。

「.gitlab-ci.yml」の探索:開発環境のソースコード狙い

  • 該当ログの傾向:
    • /.gitlab-ci.yml
  • 攻撃者の意図:

CI/CD(自動ビルド・デプロイ)の設定ファイルです。ここを覗かれると、システムの内部構造や、ソースコードが保管されているリポジトリの場所、デプロイ用の認証情報などが漏洩します。開発環境のセキュリティ設定が甘いサイトを見つけ出そうとしています。

「Mozlila」という偽装ミス

  • 該当ログの傾向:
    • Matched phrase "Mozlila" at REQUEST_HEADERS:User-Agent.
  • 攻撃者の意図とオチ:

攻撃者は、普通のブラウザ(Mozilla)からのアクセスに見せかけようとUser-Agentを偽装しています。しかし、よく見ると Mozlila(ロとリが逆:モズリラ) になっています。
これはチープなハッキングツールの作成者がスペルミスをしたまま配布しているケースで、ModSecurityのシグネチャ(検出ルール)に「チープなスキャナ特有の文字列」として一発で見破られてブロックされています。

というよりも、この「モズリラ」を紹介したくてこの記事を書いたまであります。

まとめ

もちろん、これらのログは抜粋であり、その数十倍。サイトによっては数百・数千倍のログがあることは日常茶飯事。それでも

  • 相手がどのような攻撃を試みようとしているのか?
  • そのために何を防ぐか?

と、「ログに残る攻撃があるうちは」対策するのは嗜みだと思いました。真に巧妙な攻撃者はログをいかに残さないかに全力を挙げるわけですし。

NextcloudのタスクとiOSのリマインダを認識させる

Nextdloudを更に統合プラットフォームとして使うため、以下の手順が必要でした。

環境

Nextcloud側

  • Ver 33
  • Nextdloud Task (標準アプリ)
  • Ubuntu 24.04
  • PHP-FPM 8.3
  • Apache 2.4
  • MySQL 8
  • ※二要素認証あり
  • ※外部からアクセスできる環境にあること

iOS側

  • iOS 26.42
  • iPhone Air

さっくりとした手順

  1. Nextdloud側でアプリパスワードを作ります。
  2. iOS側でアカウントを競ってします。

Nextcloud側でのアプリパスワードの設定

  1. 個人設定 > セキュリティに遷移します。
  2. デバイスとセッションの一番下、アプリ名というところに適当な名前を付けます。iOSリマインダー
  3. 新しいアプリパスワード作成をクリックします。このパスワードは一度しか表示されません。控えておきます。(一番手っ取り早いのはそのパスワードをコピーして、Nextdloud Talk等で貼り付けること。ただし、Nextdloud全てにアクセスできるパスワードです。設定後、速やかにTalkから削除しましょう。

iOS側での連携

  1. iPhoneの「設定」>「リマインダー(またはアカウント)」>「CalDAVアカウントを追加」の画面を開きます。
  2. 以下のように設定します。
    1. サーバ: 自分のNextdloudのドメイン
    2. アカウント:自分のNextdloudのアカウント
    3. パスワード:先ほど生成したアプリパスワード
    4. 設定:自分が覚えやすいもの
  3. 設定後「次へ」をタップして、エラーがないことを確認します。

連携の確認

Nextdloud側で適当なタスクを作成して、iOS側で表示されることを確認します。

iOSの「リマインダー」に、Nextdloudで設定したタスクが表示されることを確認します。

iOS側で適当なタスクを作成して、Nextdloud側で表示されることを確認します。

firewalldのゾーンと設定方法のケーススタディ

RHEL系Linuxに備わっているfirewalld。非常に柔軟で(比較的)直感的に使える仕組みだったのでメモを残します。

特に驚きだったのがzoneの概念。

ufwが基本的に「システム全体に対してポートを開けるか閉じるか」をシンプルに管理するのに対し、firewalldは「接続するネットワークの信頼度に応じて、ファイアウォールのルールを瞬時に切り替える」という柔軟性がありました。

そもそもfirewalldにおけるゾーンとは?

一言で言うと、ゾーンとは「接続元のネットワークやインターフェース(LANカードなど)の『信頼度』に応じたグループ分け」のことです。

従来の iptables などでは、「このIPアドレスからのこのポートへの通信を許可する」といった細かいルールを1つずつ書く必要があります。

一方、firewalld では以下のようなステップで考えます。

  1. あらかじめ「拒否」「自宅用」「パブリック(公共)」といった、ルールの異なる箱(ゾーン)を用意しておく。
  2. ネットワークインターフェース(例: eth0)や、特定のIPアドレスをその箱に割り当てます。

これにより、「カフェのWi-Fiに繋いだ時は『パブリック』ゾーンに切り替える」「会社のLANに繋いだ時は『社内』ゾーンに切り替える」といった管理が、一瞬でできるようになります。

代表的なプリセットゾーン

firewalld には、最初からいくつかのゾーンが用意されています。

ゾーン名信頼度主な用途・特徴
drop最低すべての受信パッケージを破棄します(応答すら返さない)。こちらからの送信は可能です。
blockすべての受信を拒否します。drop と違い、相手に「拒否しました」という通知(ICMP)を返します。
public低〜中デフォルトのゾーン。 不特定多数がいる公共のネットワーク用。自分が許可した通信(sshなど)だけを通します。
externalルーターとして使う場合の「外側(インターネット側)」用。マスカレード(NAT)が有効になります。
**home / internal**自宅や社内LANなど、周囲のコンピューターを信頼できる場合用。お互いの通信が少し緩く許可されています。
trusted最高すべてを許可します。完全に安全だと分かっているネットワーク専用です。

ゾーンの3つの重要なルール

インターフェースは必ずどこかのゾーンに属する

ネットワークカード(eth0wlan0 など)は、必ずいずれか1つのゾーンに紐付けられます。何も設定していない場合、自動的に public(デフォルトゾーン)に属します。

IPアドレス単位での割り当ても可能

「インターフェース全体は public だけど、上司のPCのIPアドレス(192.168.1.50)だけは trusted ゾーンとして扱う」といった柔軟な設定が可能です。

ルールはゾーンごとに設定する

Webサーバー(80番ポート)を開放したい」となったら、「public ゾーンに対して80番ポートを許可する」というように、ゾーンに対して設定を紐付けます。

もっと有り体に言うと

『ジョジョの奇妙な冒険』第5部における『マン・イン・ザ・ミラー』です。

ここには『スタンド力』は おれの許可なくしては入る事はできない
『おまえ本体』だけ入る事を許可した
ここにある物は全て命のない『物質』だけだ―――おまえとオレだけ!他に『生きてる物』はいない………

という「許可」と「許可しない」を、鏡の世界ではなく「ゾーン」ごとに決められる能力、と言っていいでしょう。

よく使う基本コマンド

ゾーンの状態を確認・操作するための、代表的な firewall-cmd コマンドです。

現在のデフォルトゾーンを確認する

firewall-cmd --get-default-zone

すべてのゾーンの設定を確認する

firewall-cmd --list-all-zones

特定のインターフェース(例: eth0)のゾーンを変更する

firewall-cmd --zone=home --change-interface=eth0

特定のゾーン(例: public)にサービス(例: http)を許可する

 firewall-cmd --zone=public --add-service=http --permanent

`

  • --permanent をつけた後は、設定を反映させるために以下が必要です
firewall-cmd --reload

Linuxサーバーを構築する際は、まず「このサーバーはどこに置かれていて、どのゾーンを適用すべきか」を考えていきましょう。

そのケーススタディを行っていきます。

ケーススタディ

各部署が持っているサーバを管理するためシステム部が一括でzabbixのエージェントを入れたいという状況。

  • システム部だけにzabbixサーバへのssh接続(ターミナル操作)と管理画面(Web管理画面)の閲覧と操作を許可
  • それ以外の部署が所属するNWには上記2つを拒否。
  • zabbixサーバそのものを司る大本のzabbixサーバはエージェントの通信を許可。
  • ただし、zabbixのエージェントは通るようにします。

Step 1: システム部専用ゾーンの作成と「IP」の紐付け

まずは 192.168.1.0/24 だけが所属する専用のゾーン illuso を作ります。

  • ゾーンの新規作成
sudo firewall-cmd --permanent --new-zone=illuso
  • 一度リロードして、OSに新しいゾーンを認識させる(重要)
sudo firewall-cmd --reload

作成したゾーンに「部内NWのIPセグメント」を紐付ける

sudo firewall-cmd --permanent --zone=illuso --add-source=192.168.1.0/24

Step 2: システム部専用ゾーンに「許可サービス」を追加

Step 1 で作ったゾーンに、ssh, http, https の鍵(許可)を配置します。

  • システム部専用ゾーンに対して SSH を許可
sudo firewall-cmd --permanent --zone=illuso --add-service=ssh
  • システム部専用ゾーンに対して HTTP を許可
sudo firewall-cmd --permanent --zone=illuso --add-service=http
  • システム部専用ゾーンに対して HTTPS を許可
sudo firewall-cmd --permanent --zone=illuso --add-service=https

Step 3: 上位監視サーバー専用のゾーン作成と設定

社内のZabbixサーバー(192.168.12.6)だけが所属する man-in-the-mirror を作り、Zabbix Agent用の 10050 ポートを許可します。

  • 上位サーバー用のゾーンを新規作成
sudo firewall-cmd --permanent --new-zone=man-in-the-mirror
  • 再度リロードして、新しいゾーンを認識させる
sudo firewall-cmd --reload
  • そのゾーンに「上位ZabbixのIP」を紐付ける
sudo firewall-cmd --permanent --zone=man-in-the-mirror --add-source=192.168.12.6
  • そのゾーンに 10050 ポート(tcp)の許可を与える
sudo firewall-cmd --permanent --zone=man-in-the-mirror --add-port=10050/tcp

Step 4: 全員に開くポートの追加 と public の掃除

誰からでも受け付ける Zabbix Server(10051)や SMTP(25)を public に設定し、同時に、先ほど部内限定へお引越しさせた不要なサービス(ssh, http, https)を public から削除します。

  • 自身の Zabbix Server ポート(10051)を全員に開放
sudo firewall-cmd --permanent --zone=public --add-port=10051/tcp
  • SMTP(25)を全員に開放
sudo firewall-cmd --permanent --zone=public --add-service=smtp
  • 【とても重要】どこからでも SSH できる状態を public から削除
sudo firewall-cmd --permanent --zone=public --remove-service=ssh
  • どこからでも HTTP できる状態を public から削除
sudo firewall-cmd --permanent --zone=public --remove-service=http
  • HTTPS も public から削除
sudo firewall-cmd --permanent --zone=public --remove-service=https

Step 5: 設定の最終反映と確認

ここまでの --permanent(次回起動用の設定)を、一気に本番環境へ反映(リロード)させます。

  • すべての設定を反映させます。
sudo firewall-cmd --reload

反映が完了したら、正しく設定できているか各ゾーンを覗いてみましょう。

  • 部内限定ゾーンの確認(sourcesにIP、servicesにssh http httpsがあること)
sudo firewall-cmd --zone=illuso --list-all
  • 上位Zabbix用ゾーンの確認(sourceにIP、portsに10050/tcpがあること)
sudo firewall-cmd --zone=man-in-the-mirror --list-all
  • 共通ゾーンの確認(servicesからssh, http, httpsが消え、zabbix-serverやsmtp、portsに10051/tcpがあること)
sudo firewall-cmd --zone=public --list-all

まとめ

以上、手順は多いものの

  • こいつは許可
  • こいつは拒否

を極めて柔軟に行えるのはRHEL系Linuxの持つ特権だと思いました。とはいえ、

この条件は「これから入ろうとする邪魔者」を拒否するには有効ですが、入ってしまったものを取り除くのは極めて厄介です。とくにウィルス(マルウェア)の除去は

『マン・イン・ザ・ミラー』オレだけが外に出る事を許可しろォォォォーーーーッ
うおおおががががが だが! ウイルスは許可しないィィィィィーーーッ
感染した部分は出る事は 許可しないィィィィィィィーーーッ!!

とはならないので注意が必要です。

(最後のこれが言いたいだけのエントリーを書き終えました)

Ubuntu24.04にfail2banの条件緩和。(ヘビーユースのWebサーバの問題点)

昨日設定したfailbanとの連携。ufw.aggressive。

結論から言うと「あまりにも閾値が低すぎて自分自身がロックアウトを喰らう」結果になりました。

何が起きたのか?

自分のIPからのアクセスが全てのサービスにつながらなくなった。

これはSSH接続はのみならずWeb閲覧でも弾かれるを意味します。

取り急ぎ、「確実にignoreipされている」場所からアクセスし、

sudo fail2ban-client status ufw

をしたところ、ものの見事にアクセスしていたIPアドレスが含まれています。

そのため、

sudo fail2ban-client set ufw unbaip IPアドレス

として条件を解除。

そんな中で見つけた挙動。Redminetneは比較的単純な通信が発生するため、

[ufw]
enabled = true
filter = ufw-aggressive
action = iptables-allports
logpath = /var/log/ufw.log
maxretry = 1
bantime = -1
# ignoreipには、自分自身のローカルホストと、巻き添えを防ぎたい大手検索エンジン(Googlebotなど)のIP帯を指定します

では、大量のretryが発生し、fail2banはご丁寧に「こいつは大量アクセスを繰り返している」としてban。更に、当然ながら、iptables-allportsが含まれているため、自分自身が締め出しを食らったという次第。

対処

「閾値の緩和」に尽きます。「人間の通常の作業は問題ないがbotが引っかかる」を目安に修正しました。この時の注意点は 対象サイトに過度にアクセスしないことに尽きます。iptablesと手を組んでいる以上、無効の怒りを買わないよう、接続はSSHのみにとどめます。

修正ファイル /etc/fail2ban/jail.local

[ufw]
enabled = true
filter = ufw-aggressive
action = iptables-allports
logpath = /var/log/ufw.log
# ↓ 1 から 30 〜 50 程度に大幅緩和(ボットは防げて人間は誤検知しない絶妙なライン)
maxretry = 50
# ↓ 10分〜15分(600〜900秒)の間に規定回数叩いたらアウトにする
findtime = 900
bantime = -1
# ignoreipは、自分が今アクセスしているIPを真っ先に入れます
ignoreip = 127.0.0.0/8 ::1 

設定後、

sudo fail2ban-client reload

を実行して設定完了。

設定終わって

  • Growi
  • Nextcloud

を操作して通常通りの作業が行えることを確認。

まとめ

今回、慌てずに作業できていたのが「どこからアクセスすれば安全か」を知っていたこと。

「頑固で融通が利かない門番」

を意図通りに動かすためには、門番が動く条件をきちっと動かす必要があるというお話でした。

Ubuntu24.04にufwとfail2banを設定(2026年の手順)

インターネット上にWebサーバーを公開すると、ものの数分で世界中から自動スキャンやブルートフォースアタック(総当たり攻撃)の嵐に見舞われます。
アクセス元のIPアドレスが固定されていれば接続元を絞れますが、「自宅や出先からリモートアクセスして作業する」場合、ファイアウォールの門を広く開けざるを得ません。

この記事では、そんな環境でもサーバーを鉄壁に守るため、UFW(ファイアウォール)とFail2ban(ログ監視型自動遮断ツール)を組み合わせ、不審者を検知した瞬間に「すべてのポートから永久追放(永久BAN)」する強力なネットワーク保護の設定手順を解説します。

動作環境

  • OS: Ubuntu 24.04 LTS

さっくりとした手順

  1. UFWの設定: 必要最低限のポート(SSH/Web)のみを許可
  2. Fail2banのインストール
  3. Fail2banの設定: UFWと連携し、不審なスキャンを一撃で永久BANする設定を追加

UFWの設定(SSHとWeb通信のみを有効化)

まずは不要なポートをすべて閉じ、必要な通信だけを通す基本的な防壁を作ります。

SSH接続の許可(過度な接続を制限)

単なる許可(allow)ではなく、短時間の連続アクセスを制限する limit を使うことで、ブルートフォースアタックの速度を鈍らせます。

sudo ufw limit proto tcp from any to any port 22

Web通信(HTTP / HTTPS)の許可

sudo ufw allow 80/tcp
sudo ufw allow 443/tcp

UFWのログレベルを「中」に引き上げる

Ubuntuのデフォルト(low)では、UFWが自動で弾いた不審なアクセスのログが残りません。これではFail2banが検知できないため、ログレベルを medium に引き上げます。

sudo ufw logging medium

UFWを有効化して反映

※ この作業は何度やっても心臓に悪い作業です。

  • 別のターミナルを開けてロックアウトされないようにする
  • リモートコンソールできる環境にいる

を確実に確認してから作業を行います。

sudo ufw enable

注意: Command may disrupt existing ssh connections. Proceed with operation (y|n)? と聞かれたら、落ち着いて y を入力して続けます。

設定の確認

sudo ufw status verbose

以下のように、状態が「アクティブ(ロギング: on (medium))」になり、ルールが適用されていることを確認します。

状態: アクティブ
ロギング: on (medium)
Default: deny (incoming), allow (outgoing), deny (routed)
To                         Action      From
--                         ------      ----
22/tcp                     LIMIT       Anywhere                  
80/tcp                     ALLOW       Anywhere                  
443/tcp                    ALLOW       Anywhere                  
22/tcp (v6)                LIMIT       Anywhere (v6)             
80/tcp (v6)                ALLOW       Anywhere (v6)             
443/tcp (v6)               ALLOW       Anywhere (v6)             
  • 必須チェック:
    • ここで一度別ウィンドウを開き、サーバーへ新しいSSH接続ができるか必ずテストしてください。問題なければ sudo reboot で再起動し、再起動後も接続できることを確認します。

Fail2banのインストール

ログを監視して自動でブロックする必須ツールです。

sudo aptitude update && sudo aptitude install fail2ban

筆者の好みでaptitudeを用いています。

起動確認

systemctl status fail2ban.service

active (running) と表示されていればOKです。

3. Fail2banの設定(一撃永久BANの罠を張る)

① ufw-aggressive フィルターの作成

UFWの拒否ログ([UFW BLOCK])をFail2banに認識させるための判定ルールを作成します。
※Ubuntu 24.04環境の仕様に合わせ、フィルターファイル名は ufw-aggressive.confとします。

sudo tee /etc/fail2ban/filter.d/ufw-aggressive.conf > /dev/null << 'EOF'
[Definition]
failregex = \[UFW BLOCK\].+SRC=<HOST> DST
ignoreregex =
EOF

jail.local の作成・編集

Fail2banの挙動を定義するローカル設定ファイルを作成します。

以下のファイルを教義・信仰に沿ったエディタで編集します。

/etc/fail2ban/jail.local
[ufw]
enabled = true
filter = ufw-aggressive
action = iptables-allports
logpath = /var/log/ufw.log
maxretry = 1
bantime = -1
# ignoreipには、自分自身のローカルホストと、巻き添えを防ぎたい大手検索エンジン(Googlebotなど)のIP帯を指定します
ignoreip = 127.0.0.0/8 ::1 66.249.64.0/19

[sshd]
enabled = true
filter = sshd
mode = normal
port = 22
protocol = tcp
logpath = /var/log/auth.log
maxretry = 3
bantime = -1
ignoreip = 127.0.0.0/8 ::1
# ignoreipには、自分自身のローカルホストと、ある程度回線が固定されているIP帯を指定します。

この設定の「強さ」のポイント

  • bantime = -1:
    • 一度捕まった攻撃者は永久にBAN(解除なし)。
  • maxretry = 1(ufwセクション):
    • 許可していないポートへ1回でもスキャンを仕掛けてきたら即アウト。
  • action = iptables-allports:
    • 特定のポートだけでなく、すべてのポートからの通信を完全遮断します。
  • ignoreip = ... 66.249.64.0/19:
    • maxretry = 1 は強力すぎるため、Webサイトを巡回するGoogleのクローラー(Googlebot)を誤って永久追放しないよう、あらかじめホワイトリストで保護しています。

設定の反映と効果確認

設定が完了したら、Fail2banに読み込ませます。

sudo fail2ban-client reload

防御効果の確認

設定してわずか数分〜数十分。ログファイル(/var/log/fail2ban.log)を覗いてみると、その効果は一目瞭然です。

2026-05-17 07:14:26,476 fail2ban.filter         [1720]: INFO    [ufw] Found xxx.xxx.xxx.xxx
2026-05-17 07:14:26,623 fail2ban.actions        [1720]: NOTICE  [ufw] Ban xxx.xxx.xxx.xxx
2026-05-17 07:14:44,198 fail2ban.filter         [1720]: INFO    [ufw] Found yyy.yyy.yyy.yyy
2026-05-17 07:14:44,647 fail2ban.actions        [1720]: NOTICE  [ufw] Ban yyy.yyy.yyy.yyy

世界中から飛んでくる不審なアタックやスキャンを、Fail2banが次々と検知し、その場で息の根を止めて(Ban)くれているのが分かります。

現在の捕獲状況は、以下のコマンドでリアルタイムに確認できます。

sudo fail2ban-client status ufw

インターネットの荒波に晒されているサーバーであれば、10分も経たないうちに数十〜100以上の悪質なIPアドレスが Banned IP list: に積み上がっていきます。

もしうっかり味方や自分をBANしてしまったら?

万が一、設定ミスなどで必要なIPをBANしてしまった場合は、以下のコマンドで個別に救出(BAN解除)が可能です。

sudo fail2ban-client set ufw unbanip <解除したいIPアドレス>

まとめ

固定IPを持たない環境であっても、「UFWで怪しい動きを検知し、Fail2banですべてのポートを即座に塞ぐ」という二段構えを構築することで、驚くほど強固なサーバーへと進化させることができます。

筆者はかれこれ4年ほどvps運用を続けている中、重篤な攻撃に晒されていないのは基本であるこのufwとfail2banのおかげ。

休息も慈悲も与えぬ。何があってもだ。
No rest, no mercy. No matter what.

のレベルで攻撃者にいかなる躊躇も容赦もしないのがVPSサーバの基本です。

失敗記録:LVM物理ボリューム縮小とディスク切り詰めの挑戦。

注意事項

  • これは失敗した手順です。
  • なのでやってはいけないやつです。
  • あくまでも私の失敗したときの記録として残します。

何をやりたかったのか?

「KVMで作成したディスク(LVM)を500→200程度に切り詰めようとしたところ失敗した」。

環境

  • ホスト
    • Rocky Linux 8.6
    • KVM
  • ゲスト
    • Rocy Linux 9.7
    • シックボリュームで構築

1. ゲストOS内でのデータ整理

まず、ディスク容量を空けるために /home を削除・再作成し、使用量を削減しました。

  • 状態: 物理ボリューム(PV)500GB に対し、中身の合計(Root+Home+Swap)を 170GB 程度まで圧縮。

2. データの「前詰め」作業(pvmove)

LVMの「末尾」にあるデータを物理的にディスクの「先頭」へ移動させました。

  • コマンド: sudo pvmove --alloc anywhere /dev/vda2
  • 結果: pvdisplay -m にて、使用中セグメントが 0 ~ 73153 PE(約180GB圏内)に固まり、それ以降が FREE になったことを確認。

3. PVリサイズの試行(pvresize)

管理情報を 180GB に書き換えようと試みました。

  • コマンド:
sudo pvresize --setphysicalvolumesize 180G /dev/vda2
  • 結果: cannot resize to 46079 extents as later ones are allocated により失敗。
  • 考察: LVMの内部メタデータや、目に見えない微細なフラグが末尾に残っていた可能性。

4. ホスト側での物理コピー(dd による強行突破)

「データは前に寄せた」という事実に基づき、ホスト側から物理的に180GB 分だけを切り出す作戦を敢行。

  • 手順:
  1. ホスト側で新LV(180GB)を作成。
  2. dd コマンドで旧LVから 180GB 分を抽出コピー。
  3. lvrename を使い、VMが参照するターゲットを 180GB の新ディスクにすり替え。

5. 最終結果

  • 起動: 成功。
  • ログイン: 失敗。
  • 状況: virsh console 等で応答なし。
  • 結論: LVMおよびファイルシステムの整合性において、180GB という境界線で「管理情報の断裂」が発生。

教訓

  • LVMの末尾は聖域: pvmove でデータを寄せても、LVM自身の管理領域(Metadata Area)の整合性を保ったまま物理サイズを削るのは、OS稼働中や単純な dd では極めて困難である。
  • 切り詰めるなら「外から」より「中から」: 今回のように外部から dd で削る手法は、パーティションテーブルとLVMヘッダの整合性が 1 バイトでも狂うとシステム停止に直結する。

やはり、この手のリサイズは「新たにサーバを作成し(リサイズした上で)データを流し込む」という地道な主だが一番です。

Page 1 of 106

Powered by WordPress & Theme by Anders Norén