管理者からのアクセスを nftables や Apache(Require ip)で許可する際、固定 IP アドレスを前提に設定する場合は簡単ですが、固定 IP を持っていないユーザーに対してポート制限やアクセス制限を動的に変更できないかという相談を受けました。
要件
- Web サイト担当者の自宅から FTP(990、60000~60100)ポートと、WordPress の管理画面(/wp-admin)にアクセスできるようにさせたい。
- 担当者の自宅はフレッツ光の常時接続なので滅多にグローバル IP が変わらないが、年に数回変わることがある。現在契約しているプロバイダーの固定 IP アドレスオプションは高額(OCN:10,780円/月)なので使いたくない。
- 固定 IP アドレスの安いプロバイダー(GMOとくとくBB:1,210円/月)もあるが、プロバイダーの乗り換えは不可。
- VPS の管理画面へのアクセスは不可。
- 会社への VPN 接続は不可。
年に数回、ウェブマスターのグローバル IP が変わってしまったときに対応できれば良いようなので、ブラウザからファイアウォールの設定と、Apache のアクセス制限を変更できる仕組みを作ることにします。
準備
ディレクトリの作成
今回は /usr/share/ip_refresh に一連のファイルを作成していく事にします。
データを格納するサブディレクトリ(data)を作成し、パーミッションを変更します。
mkdir -p /usr/share/ip_refresh/data/
chmod 707 /usr/share/ip_refresh/data/設定ファイルの作成
ユーザー情報の設定ファイル(config.ini)を作成します。
設定ファイルのフォーマット
- [セクション]: 拠点名
- description: 拠点の説明
- ipv4: グローバル IPv4 アドレス
- host: リモートホスト(後方一致、前半の変動する部分は記述しない)
セクションを分けることで、複数の拠点も登録できます。
[webmaster]
description = ウェブマスター
ipv4 = xxx.xxx.xxx.xxx
host = .ipoe.ocn.ne.jp
[office]
description = オフィス
ipv4 = yyy.yyy.yyy.yyy
host = .v4.enabler.ne.jpデータは PHP から書き換えるので、所有者を Apache に変更しておきます。
chown -R apache. /usr/share/ip_refresh/data/WebUI の作成
フォームの作成
まずはメインとなるフォームを作成します。
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ウェブマスター用 IP アドレス変更</title>
<!-- jQuery 3.7.1 -->
<script src="https://code.jquery.com/jquery-3.7.1.min.js" integrity="sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo=" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<!-- Bootstrap 5.3 -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js" integrity="sha384-geWF76RCwLtnZ8qwWowPQNguL3RmwHVBC9FhGdlKrxdiJJigb/j/68SIy3Te4Bkz" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-9ndCyUaIbzAi2FUVXJi0CjmCapSmO7SnpJef0486qhLnuZ2cdeRhO02iuK6FUUVM" crossorigin="anonymous" referrerpolicy="no-referrer">
<!-- JSON -->
<script src="json.js"></script>
</head>
<body>
<div class="container-fluid py-4">
<div class="row justify-content-center align-items-center vh-100">
<div class="col-lg-4 col-md-6 col-sm-8 col-xs-12">
<!-- フォーム -->
<div class="card">
<div class="card-header">
ウェブマスター用 IP アドレス変更
</div>
<div class="card-body">
<?php if($ini = parse_ini_file('data/config.ini', true)): ?>
<form method="post">
<fieldset>
<div class="mt-4 mb-4 row">
<label for="location" class="col-sm-4 col-form-label">Location</label>
<div class="col-sm-8">
<select id="location" name="location" class="form-select" aria-describedby="locationHelp" autofocus required>
<option value="" selected disabled>変更する場所</option>
<?php foreach ($ini as $namespace => $location): ?>
<option value="<?=$namespace?>"><?=$location['description']?></option>
<?php endforeach; ?>
</select>
<div id="locationHelp" class="form-text">変更する場所を選択してください</div>
</div>
</div>
<div class="mb-5 row">
<label for="ipv4" class="col-sm-4 col-form-label">IPv4</label>
<div class="col-sm-8">
<input type="text" id="ipv4" name="ipv4" class="form-control ipv4" value="<?=$_SERVER['HTTP_X_FORWARDED_FOR']?>" placeholder="変更後のIPアドレス" pattern="^((\d{1,2}|1\d\d|2[0-4]\d|25[0-5])\.){3}(\d{1,2}|1\d\d|2[0-4]\d|25[0-5])$" required>
</div>
</div>
<div class="mb-4 row">
<label for="ipv4" class="col-sm-4 col-form-label"> </label>
<div class="col-sm-8">
<button type="button" id="confirm" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#myModal">送信</button>
<button type="reset" class="btn btn-secondary">クリア</button>
</div>
</div>
</fieldset>
</form>
<?php else: ?>
<div class="alert alert-danger" role="alert"><strong>ERROR</strong>: 設定ファイルの読み込みに失敗しました!</div>
<?php endif; ?>
</div>
</div>
<!-- モーダル -->
<div id="myModal" class="modal fade" tabindex="-1" aria-labelledby="modal-title" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h2 id="modal-title" class="modal-title">確認</h2>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<h3 id="confirm_result"></h3>
<div id="confirm_body"></div>
</div>
<div class="modal-footer">
<button type="button" id="close" class="btn btn-default" data-bs-dismiss="modal">キャンセル</button>
<button type="button" id="execute" class="btn btn-primary">実行</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</body>
</html>フォームと非同期通信するための JavaScript を作成します。
function errorHandler(jqXHR, textStatus, errorThrown) {
var error;
if (jqXHR || errorThrown) {
try {
error = JSON.parse(jqXHR.responseText).error.toString();
} catch (e) {
error = 'parsererror(' + errorThrown + '): ' + jqXHR.responseText;
}
} else {
error = textStatus + '(HTTP request failed)';
}
return error;
}
$(function () {
// 確認画面
$('#confirm').click(function () {
$.ajax({
url: 'refresh.php',
type: 'post',
dataType: 'json',
data: {
location: $('#location').val(),
ipv4: $('#ipv4').val()
}
})
.done(function (response) {
$('#confirm_result').text('準備完了');
$('#confirm_body').html(response.data);
$('#mode').val('execute');
$('#close').text('キャンセル');
$("#execute").show();
$("#execute").prop("disabled", false);
})
.fail(function (jqXHR, textStatus, errorThrown) {
$('#confirm_result').text('エラー');
$('#confirm_body').html(errorHandler(jqXHR, textStatus, errorThrown));
$('#close').text('キャンセル');
$("#execute").show();
$("#execute").prop("disabled", true);
});
});
// 実行画面
$('#execute').click(function () {
$.ajax({
url: 'refresh.php',
type: 'post',
dataType: 'json',
data: {
location: $('#location').val(),
ipv4: $('#ipv4').val(),
mode: 'execute'
}
})
.done(function (response) {
$('#modal-title').text('正常終了');
$('#confirm_result').text('完了');
$('#confirm_body').html(response.data);
$('#close').text('閉じる');
$("#execute").hide();
})
.fail(function (jqXHR, textStatus, errorThrown) {
$('#confirm_result').text('エラー');
$('#confirm_body').html(errorHandler(jqXHR, textStatus, errorThrown));
$('#close').text('閉じる');
$("#execute").hide();
});
});
});フォームのリクエストを実際に処理する PHP を作成します。
<?php
//////////////////////////////////////////////////
// 設定
$config = 'data/config.ini';
$flag = 'data/flag';
$apache = 'data/apache';
$nftables = 'data/nftables';
//////////////////////////////////////////////////
// 事前処理
// 設定ファイル読み込み
$ini = parse_ini_file($config, true);
if($ini === false) { error('設定ファイルの読み込みに失敗しました!'); }
// 入力値の確認
if(!isset($_POST['location']) || $_POST['location'] === '') { error('変更する場所が選択されていません!'); }
if(!isset($_POST['ipv4']) || $_POST['ipv4'] === '') { error('IP アドレスが入力されていません!'); }
// リモートホスト確認
$remotehost = gethostbyaddr($_POST['ipv4']);
if(!$remotehost) { error('リモートホストが確認できません!'); }
if(strpos($remotehost, $ini[$_POST['location']]['host']) === false) { error('プロバイダーが一致しません!'); }
// IPv4アドレスの確認
if($_POST['ipv4'] == $ini[$_POST['location']]['ipv4']) { error('IP アドレスに変更がありません!'); }
//////////////////////////////////////////////////
// 実行処理
if($_POST['mode'] == 'execute') {
// 設定ファイル書き換え
$ini[$_POST['location']]['ipv4'] = $_POST['ipv4'];
$fp = fopen($config, 'w');
foreach($ini as $namespace => $properties) {
fputs($fp, '[' . $namespace . "]\n");
foreach($properties as $prop => $val) {
fputs($fp, "$prop = $val\n");
}
}
fclose($fp);
// Apache 設定書き換え
if(!file_exists($apache)) { touch($apache); }
$fp = fopen($apache, 'w');
foreach($ini as $namespace => $properties) {
fputs($fp, "Require ip " . $ini[$namespace]['ipv4'] . "\n");
}
fclose($fp);
// ファイアウォール設定書き換え
if(!file_exists($nftables)) { touch($nftables); }
$fp = fopen($nftables, 'w');
fputs($fp, "define webmaster = {\n");
foreach($ini as $namespace => $properties) {
fputs($fp, " " . $ini[$namespace]['ipv4'] . ",\n");
}
fputs($fp, "}\n");
fclose($fp);
// フラグ用のファイルを作成
if(!file_exists($flag)) { touch($flag); }
// 設定反映
$data = "<p>設定を変更しました!</p>\n";
$data .= "<p>反映されるまでに最大 5 分程度かかる場合があります。</p>\n";
}
else {
$data = '<p>「<strong>' . $ini[$_POST['location']]['description'] . '</strong>」の IP アドレスを「' . $ini[$_POST['location']]['ipv4'] . '」から「<strong>' . $_POST['ipv4'] . "</strong>」に変更します。</p>\n";
}
//////////////////////////////////////////////////
// 成功メッセージ出力
header('Content-Type: application/json');
echo json_encode(compact('data'));
exit();
//////////////////////////////////////////////////
// エラー処理
function error($error) {
header('Content-Type: application/json');
http_response_code(400);
echo json_encode(compact('error'));
exit();
}
?>ウェブマスターの IP アドレスに変更があった場合は、nftables と Apache に読み込める形でホワイトリストを作成します。
設定を反映させるのに cron を使います(後述)ので、変更を検出しやすいようにフラグファイルも作成しています。
Basic 認証
WebUI に Basic 認証をかけて、不正なアクセスを防ぎます。
htpasswd -c /usr/share/ip_refresh/.htpasswd (ユーザー名)
(out) New password: (パスワード)
(out) Re-type new password: (パスワード)
(out) Adding password for user (ユーザー名)Apache の公開設定
Apache の設定ファイルを作成して、WebUI を公開します。
Alias にあえて日本語を使用することで、少しでも推測されにくい URL にしている(つもり)です。
Alias /ウェブマスター専用 /usr/share/ip_refresh
<Directory /usr/share/ip_refresh/>
AddDefaultCharset UTF-8
AuthType Basic
AuthName "auth"
AuthUserFile /usr/share/ip_refresh/.htpasswd
Require valid-user
</Directory>サービスに適用する
ホワイトリストを nftables と Apache のディレクトリに移動し、サービスに反映するシェルスクリプトを作成します。
#!/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin
FLAG=/usr/share/ip_refresh/data/flag
if [ -e $FLAG ]; then
rm $FLAG
echo "---ウェブマスター用 IP アドレス変更開始---"
echo ""
# Apache
echo "Reload Apache..."
mv -f /usr/share/ip_refresh/data/apache /etc/httpd/conf/webmaster
chown root. /etc/httpd/conf/webmaster
systemctl reload httpd.service
echo "完了"
# Firewall
echo "Reload Firewall..."
mv -f /usr/share/ip_refresh/data/nftables /etc/nftables/list/webmaster
chown root. /etc/nftables/list/webmaster
systemctl reload nftables.service
systemctl restart fail2ban.service
echo "完了"
echo ""
echo "---ウェブマスター用 IP アドレス変更終了---"
fi実行権限を付けておきます。
chmod +x /usr/share/ip_refresh/cron.sh今回の依頼者はタイムラグを許容できるとの事だったので、Cron で 5 分おきにチェックして、フラグファイルが存在したら(IP アドレスが変更されていたら)処理を実行するようにしました。
# ウェブマスター用 IP アドレス変更
*/5 * * * * root /usr/share/ip_refresh/cron.shnftables の設定変更
今回はウェブマスター用のホワイトリスト(/etc/nftables/list/webmaster)に対して、FTP 用のポートを通すように nftables の設定を変更していきます。
ベースとなるファイアウォールの設定は、過去の記事を参照してください。

# 現在のルールセットを消去
flush ruleset
# 管理者用IPアドレスリストを読み込む
include "/etc/nftables/list/admin"
# ウェブマスター用IPアドレスリストを読み込む
include "/etc/nftables/list/webmaster"
# 日本国内IPアドレスリストを読み込む
include "/etc/nftables/list/domestic"
table inet filter {
set admin {
type ipv4_addr
elements = $admin
}
set webmaster {
type ipv4_addr
elements = $webmaster
}
set domestic {
type ipv4_addr
flags interval
elements = $domestic
}
chain input {
…省略…
}
chain TCP {
…省略…
# ウェブマスターにのみ解放
# FTPS, FTPパッシブポート
tcp dport { 990, 60000-60100 } ip saddr @webmaster accept
…省略…
}
…省略…
}Apache の設定変更
次に Apache の WordPress 用の設定を変更して、管理画面(/wp-admin)にアクセス制限を追加します。IncludeOptional で取り込むと、ファイルが無かった場合でもエラーにならないので便利です。
<Location /wp-admin>
Require local
IncludeOptional /etc/httpd/conf/webmaster
</Location>※同様に設定すれば、phpMyAdmin などにも流用できて便利です。
fail2ban の設定変更
ついでに fail2ban の除外設定にも追加しておきます。
[DEFAULT]
…省略…
ignorecommand = grep -sq "<ip>" /usr/share/ip_refresh/data/config.ini
…省略…アクセス元の IP アドレスが config.ini に記述してあれば、監視対象から除外します。

まとめ
以上で、ブラウザからファイアウォールの設定を動的に変更する事ができるようになりました。
今回は依頼者の希望でこのように設定しましたが、Basic 認証がかかっているとはいえ公開サイト上に設置するのはリスクがあります。
この記事のやり方を推奨するものではなく、こんな方法もあるよという感じで参考程度にしてください。
本来はプロバイダーの固定 IP アドレスサービスを利用するのが無難だと思います。固定 IP アドレスを追加するなら GMOとくとくBB が、月額 1,210円で最も安くておすすめです。


コメント