この記事は、木更津高専 Advent Calendar 2023の10日目の記事です。
前→Raspberry Pi as a Router by @toma09to
次→クリスマスがやってこないカワイソウな人たちに爆撃するdiscordBotつくった話 by @yukipeti
動機
前回、iptablesのルールを設定しましたが、そのうち、DNATの設定は新しい機器の導入、サービスの開始・終了などで頻繁に書き換える可能性があります。
そこで、Rustを使ってWeb上からDNATの設定を書き換えられるようにします。
開発環境
- Raspberry Pi 4 Model B 4GB
- Ubuntu Server 22.04.3 LTS
- Rust 1.72.1
- Actix Web 4.4.0
- Rusqlite 0.29.0
- Serde 1.0.188
事前準備
プログラムの配置場所は/var/net
内に配置することとし、後述しますが、動作のために前回作成したiptables.sh
が必要なので、それもここに配置することにします。
それに伴い、ユニットファイルも変更します。
[Unit]
Description=Automatic iptables setup service
After=network.target
[Service]
User=root
- ExecStart=/etc/iptables.sh
+ ExecStart=/var/net/iptables.sh
Type=oneshot
[Install]
WantedBy=multi-user.target
プログラムの作成
DNATの自動管理を実現するために、以下の動作をするプログラムを作成します。
- DNATの設定をDBで管理する
- サイト上でDNATの設定を変更できる
- 設定に変更があったら、iptablesの設定を再反映する
シェルスクリプトの修正
DNAT部分を自動化するにあたり、ファイルを分割し、/var/net/dnat.sh
にDNAT設定を記録することにします。
また、サイトを表示するために内部ネットワークに対して8080
番ポートを開けます。環境変数などの詳細は前回の記事を参照してください。
# DNAT
- iptables -t nat -A PREROUTING -p tcp -d ${PRIVATE_IP} --dport 80 -j DNAT --to-destination 192.168.1.10
- iptables -t nat -A PREROUTING -p tcp -d ${PRIVATE_IP} --dport 443 -j DNAT --to-destination 192.168.1.10
- iptables -t nat -A PREROUTING -i ${INT_INT} -p tcp -d ${GLOBAL_IP} --dport 80 -j DNAT --to-destination 192.168.1.10
- iptables -t nat -A PREROUTING -i ${INT_INT} -p tcp -d ${GLOBAL_IP} --dport 443 -j DNAT --to-destination 192.168.1.10
+ iptables -A INPUT -s ${INT_NET} -i ${INT_INT} -p tcp --dport 8080 -j ACCEPT
+ source /var/net/dnat.sh
ページの雛形の作成
表示するページは設定の確認・追加・削除ができるindex.html
、設定の変更が成功したか失敗したかを表示するsuccess.html
、failure.html
をstatic
ディレクトリ内に作成します。
ファイル内の$
や&
は、プログラムがページを送信する際に、$
をエラーコードやDBのデータを使って作成した<tr>
要素に置換するための符号です。
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>ポートフォワーディング設定</title>
<style>
.forms {
display: block;
}
.port {
width: 100px;
margin-right: 10px;
}
.ip {
width: 40px;
}
</style>
</head>
<body>
<p>
<h3>設定の追加</h3>
<form method="POST" action="/add" class="forms">
<div class="forms">
<input type="number" class="port" name="port" size="10" min="1" max="49151" placeholder="ポート番号" required>
<input type="number" class="ip" name="ip1" size="4" value="192" readonly>.
<input type="number" class="ip" name="ip2" size="4" value="168" readonly>.
<input type="number" class="ip" name="ip3" size="4" value="1" readonly>.
<input type="number" class="ip" name="ip4" size="4" min="2" max="254" required>
<input type="submit" value="追加">
</div>
</form>
</p>
<p>
<h3>設定の削除</h3>
<form method="POST" action="/delete" class="forms">
<div class="forms">
<input type="number" class="port" name="port" size="10" min="1" max="49151" placeholder="ポート番号" required>
<input type="number" class="ip" name="ip1" size="4" value="192" readonly>.
<input type="number" class="ip" name="ip2" size="4" value="168" readonly>.
<input type="number" class="ip" name="ip3" size="4" value="1" readonly>.
<input type="number" class="ip" name="ip4" size="4" min="2" max="254" required>
<input type="submit" value="削除">
</div>
</form>
</p>
<p>
<h3>ポートフォワーディング設定一覧</h3>
<table border="1">
<tr>
<th>ポート番号</th>
<th>転送先アドレス</th>
</tr>
$
</table>
</p>
<p>
<h3>ルーティングテーブルの再読み込み</h3>
<form method="POST" action="/reload" class="forms">
<div class="forms">
<input type="submit" id="reload" name="reload" value="再読み込み">
</div>
</form>
</p>
</body>
</html>
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>設定完了</title>
</head>
<body>
<p>設定の$が完了しました。</p>
<a href="/">前のページに戻る</a>
</body>
</html>
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>設定失敗</title>
</head>
<body>
<p>設定の$に失敗しました。</p>
<p>
エラーコード:<br>
&
</p>
<a href="/">前のページに戻る</a>
</body>
</html>
サーバープログラムを作成
main.rs
にサーバーの起動に関する記述をします。
自分以外のマシンに公開するために、bind()
の引数に127.0.0.1
ではなく0.0.0.0
を指定しなければならないことに注意してください。
use actix_web::{App, HttpServer};
mod services;
#[actix_web::main]
async fn main() -> std::io::Result<()> {
HttpServer::new(|| {
App::new().configure(services::config)
})
.bind(("0.0.0.0", 8080))?
.run()
.await
}
services.rs
を作成し、サービスの内容を記述していきます。
必要なuse
等は、最後に全ファイルを掲載しているので、そちらをご参照ください。
構造体
フォームのデータやDBのデータの構造体を作成します。
struct Setting {
port: u16,
destination: String,
}
#[derive(Deserialize)]
struct FormData {
port: u16,
ip1: u8,
ip2: u8,
ip3: u8,
ip4: u8,
}
各種関数
$
をある文字列に置換するための関数を作成します。
fn read_and_replace(filename: &str, repl: &str) -> String {
let raw = fs::read_to_string(filename).unwrap();
let content = raw.replace("$", repl);
content
}
設定に変更があったとき、/var/net/iptables.sh
を書き換えるための関数を作成します。
fn update_script(conn: &Connection) {
let mut stmt = conn.prepare("SELECT * FROM settings").unwrap();
let settings_iter = stmt.query_map([], |row| {
Ok(Setting {
port: row.get(0).unwrap(),
destination: row.get(1).unwrap(),
})
}).unwrap();
let mut file = File::create("/var/net/dnat.sh").unwrap();
// 一旦内容を全て削除
file.flush().unwrap();
// 内容を全て書き込む
let mut text = String::from("#!/bin/sh\n\n");
for item in settings_iter {
if let Ok(item) = item {
text += &format!("iptables -t nat -A PREROUTING -p tcp -d $PRIVATE_IP --dport {} -j DNAT --to-destination {}\n", item.port, item.destination);
text += &format!("iptables -t nat -A PREROUTING -i $INT_INT -p tcp -d $GLOBAL_IP --dport {} -j DNAT --to-destination {}\n", item.port, item.destination);
}
}
file.write_all(text.as_bytes()).unwrap();
Command::new("/var/net/iptables.sh").output().unwrap();
}
GET /
サイトにアクセスしたときにページを表示する関数を作ります。
#[get("/")]
async fn get() -> HttpResponse {
let conn = Connection::open("/var/net/settings.db").unwrap();
conn.execute(
"CREATE TABLE IF NOT EXISTS settings (
port INTEGER PRIMARY KEY,
destination STRING NOT NULL
)",
(),
).unwrap();
let mut stmt = conn.prepare("SELECT * FROM settings").unwrap();
let settings_iter = stmt.query_map([], |row| {
Ok(Setting {
port: row.get(0).unwrap(),
destination: row.get(1).unwrap(),
})
}).unwrap();
let mut settings = Vec::new();
for item in settings_iter {
if let Ok(item) = item {
settings.push(item);
}
}
let mut table = String::new();
table.push_str("<tr><th>ポート番号</th><th>転送先アドレス</th></tr>");
for item in settings {
table.push_str(&format!("<tr><td>{}</td><td>{}</td></tr>", item.port, item.destination));
}
let content = read_and_replace("/var/net/static/index.html", &table);
HttpResponse::Ok().body(content)
}
POST /(add|delete)
フォームから追加・削除するときに動作する関数を作ります。
なお、22
番ポートの設定をしてしまうとルーターへのSSHが出来なくなってしまう(1敗)ので、ここで弾いておきます。
#[post("/add")]
async fn add(data: web::Form<FormData>) -> HttpResponse {
let conn = Connection::open("/var/net/settings.db").unwrap();
let destination = format!("{}.{}.{}.{}", data.ip1, data.ip2, data.ip3, data.ip4);
let success = read_and_replace("/var/net/static/success.html", "追加");
let failure = read_and_replace("/var/net/static/failure.html", "追加");
if data.port == 22 {
let failure = failure.replace("&", "22番ポートは禁止されています。");
return HttpResponse::Forbidden().body(failure);
}
let response = match conn.execute(
"INSERT INTO settings (port, destination) VALUES (?1, ?2)",
(&data.port, &destination),
) {
Ok(_) => {
update_script(&conn);
HttpResponse::Ok().body(success)
},
Err(err) => {
let failure = failure.replace("&", &format!("{}", err));
HttpResponse::Conflict().body(failure)
}
};
response
}
#[post("/delete")]
async fn delete(data: web::Form<FormData>) -> HttpResponse {
let conn = Connection::open("/var/net/settings.db").unwrap();
let destination = format!("{}.{}.{}.{}", data.ip1, data.ip2, data.ip3, data.ip4);
let success = read_and_replace("/var/net/static/success.html", "削除");
let failure = read_and_replace("/var/net/static/failure.html", "削除");
let response = match conn.execute(
"DELETE FROM settings WHERE port = ?1 AND destination = ?2",
(&data.port, &destination),
) {
Ok(1) => {
update_script(&conn);
HttpResponse::Ok().body(success)
},
Ok(_) => {
let failure = failure.replace("&", "指定したレコードが存在しません");
HttpResponse::NotFound().body(failure)
},
Err(err) => {
let failure = failure.replace("&", &format!("{}", err));
HttpResponse::Conflict().body(failure)
}
};
response
}
POST /reload
最後に、データの変更なしにルーティングテーブルの再読み込みを行う関数を作ります。
#[post("/reload")]
async fn reload() -> HttpResponse {
let conn = Connection::open("/var/net/settings.db").unwrap();
update_script(&conn);
let content = read_and_replace("/var/net/static/reload.html", "");
HttpResponse::Ok().body(content)
}
全ファイル
services.rs
の全体を掲載しておきます。
use std::fs;
use std::fs::File;
use std::io::Write;
use std::process::Command;
use actix_web::{web, get, post, HttpResponse};
use rusqlite::{Connection};
use serde::Deserialize;
struct Setting {
port: u16,
destination: String,
}
#[derive(Deserialize)]
struct FormData {
port: u16,
ip1: u8,
ip2: u8,
ip3: u8,
ip4: u8,
}
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("")
.service(get)
.service(add)
.service(delete)
.service(reload),
);
}
#[get("/")]
async fn get() -> HttpResponse {
let conn = Connection::open("/var/net/settings.db").unwrap();
conn.execute(
"CREATE TABLE IF NOT EXISTS settings (
port INTEGER PRIMARY KEY,
destination STRING NOT NULL
)",
(),
).unwrap();
let mut stmt = conn.prepare("SELECT * FROM settings").unwrap();
let settings_iter = stmt.query_map([], |row| {
Ok(Setting {
port: row.get(0).unwrap(),
destination: row.get(1).unwrap(),
})
}).unwrap();
let mut settings = Vec::new();
for item in settings_iter {
if let Ok(item) = item {
settings.push(item);
}
}
let mut table = String::new();
table.push_str("<tr><th>ポート番号</th><th>転送先アドレス</th></tr>");
for item in settings {
table.push_str(&format!("<tr><td>{}</td><td>{}</td></tr>", item.port, item.destination));
}
let content = read_and_replace("/var/net/static/index.html", &table);
HttpResponse::Ok().body(content)
}
#[post("/add")]
async fn add(data: web::Form<FormData>) -> HttpResponse {
let conn = Connection::open("/var/net/settings.db").unwrap();
let destination = format!("{}.{}.{}.{}", data.ip1, data.ip2, data.ip3, data.ip4);
let success = read_and_replace("/var/net/static/success.html", "追加");
let failure = read_and_replace("/var/net/static/failure.html", "追加");
if data.port == 22 {
let failure = failure.replace("&", "22番ポートは禁止されています。");
return HttpResponse::Forbidden().body(failure);
}
let response = match conn.execute(
"INSERT INTO settings (port, destination) VALUES (?1, ?2)",
(&data.port, &destination),
) {
Ok(_) => {
update_script(&conn);
HttpResponse::Ok().body(success)
},
Err(err) => {
let failure = failure.replace("&", &format!("{}", err));
HttpResponse::Conflict().body(failure)
}
};
response
}
#[post("/delete")]
async fn delete(data: web::Form<FormData>) -> HttpResponse {
let conn = Connection::open("/var/net/settings.db").unwrap();
let destination = format!("{}.{}.{}.{}", data.ip1, data.ip2, data.ip3, data.ip4);
let success = read_and_replace("/var/net/static/success.html", "削除");
let failure = read_and_replace("/var/net/static/failure.html", "削除");
let response = match conn.execute(
"DELETE FROM settings WHERE port = ?1 AND destination = ?2",
(&data.port, &destination),
) {
Ok(1) => {
update_script(&conn);
HttpResponse::Ok().body(success)
},
Ok(_) => {
let failure = failure.replace("&", "指定したレコードが存在しません");
HttpResponse::NotFound().body(failure)
},
Err(err) => {
let failure = failure.replace("&", &format!("{}", err));
HttpResponse::Conflict().body(failure)
}
};
response
}
#[post("/reload")]
async fn reload() -> HttpResponse {
let conn = Connection::open("/var/net/settings.db").unwrap();
update_script(&conn);
let content = read_and_replace("/var/net/static/reload.html", "");
HttpResponse::Ok().body(content)
}
fn read_and_replace(filename: &str, repl: &str) -> String {
let raw = fs::read_to_string(filename).unwrap();
let content = raw.replace("$", repl);
content
}
fn update_script(conn: &Connection) {
let mut stmt = conn.prepare("SELECT * FROM settings").unwrap();
let settings_iter = stmt.query_map([], |row| {
Ok(Setting {
port: row.get(0).unwrap(),
destination: row.get(1).unwrap(),
})
}).unwrap();
let mut file = File::create("/var/net/dnat.sh").unwrap();
// 一旦内容を全て削除
file.flush().unwrap();
// 内容を全て書き込む
let mut text = String::from("#!/bin/sh\n\n");
for item in settings_iter {
if let Ok(item) = item {
text += &format!("iptables -t nat -A PREROUTING -p tcp -d $PRIVATE_IP --dport {} -j DNAT --to-destination {}\n", item.port, item.destination);
text += &format!("iptables -t nat -A PREROUTING -i $INT_INT -p tcp -d $GLOBAL_IP --dport {} -j DNAT --to-destination {}\n", item.port, item.destination);
}
}
file.write_all(text.as_bytes()).unwrap();
Command::new("/var/net/iptables.sh").output().unwrap();
}
ファイルの設置
上記のRustプログラムをコンパイルし、バイナリファイルをweb_dnat
という名前にします。
その後、/var/net
に以下のようにファイルを設置します。
/var/net
├─ dnat.sh
├─ iptables.sh
├─ settings.db
├─ web_dnat
└─ static
├─ failure.html
├─ index.html
├─ reload.html
└─ success.html
ユニット作成
最後に、Webサーバーを動作させるためのユニット構成ファイルweb-dnat.service
を作ります。
[Unit]
Description=DNAT Web Service
After=network.target
[Service]
User=root
WorkingDirectory=/var/net
ExecStart=/var/net/web_dnat
Type=simple
Restart=always
[Install]
WantedBy=multi-user.target
プロセスを開始します。
$ sudo systemd enable web-dnat.service
$ sudo systemd start web-dnat.service
完成
{ルーターのIP}:8080
にアクセスすると以下のようなページが表示されます。
さいごに
今回のプログラムは、フォームのデータをそのまま使っていて危なかったり、デザインは何も変更していなかったり、結構ダメダメな点があります。
Rust初心者なので、もっとこうした方がいい、これ使ったほうがいいといった意見がありましたら、是非コメントしてください。
最後まで閲覧していただきありがとうございました。