2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

木更津高専Advent Calendar 2023

Day 10

RustでDNAT設定いじるサイトを作った

Last updated at Posted at 2023-12-09

この記事は、木更津高専 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が必要なので、それもここに配置することにします。
それに伴い、ユニットファイルも変更します。

/etc/systemd/system/auto-iptables.service
[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番ポートを開けます。環境変数などの詳細は前回の記事を参照してください。

/var/net/iptables.sh
# 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.htmlfailure.htmlstaticディレクトリ内に作成します。
ファイル内の$&は、プログラムがページを送信する際に、$をエラーコードやDBのデータを使って作成した<tr>要素に置換するための符号です。

/var/net/static/index.html
<!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>
/var/net/static/success.html
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>設定完了</title>
  </head>
  <body>
    <p>設定の$が完了しました。</p>
    <a href="/">前のページに戻る</a>
  </body>
</html>
/var/net/static/failure.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を指定しなければならないことに注意してください。

main.rs
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のデータの構造体を作成します。

services.rs
struct Setting {
    port: u16,
    destination: String,
}

#[derive(Deserialize)]
struct FormData {
    port: u16,
    ip1: u8,
    ip2: u8,
    ip3: u8,
    ip4: u8,
}

各種関数

$をある文字列に置換するための関数を作成します。

services.rs
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を書き換えるための関数を作成します。

services.rs
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 /

サイトにアクセスしたときにページを表示する関数を作ります。

services.rs
#[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敗)ので、ここで弾いておきます。

services.rs
#[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

最後に、データの変更なしにルーティングテーブルの再読み込みを行う関数を作ります。

services.rs
#[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の全体を掲載しておきます。

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を作ります。

/etc/systemd/system/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にアクセスすると以下のようなページが表示されます。

Screenshot 2023-12-09 at 20.56.37.png

さいごに

今回のプログラムは、フォームのデータをそのまま使っていて危なかったり、デザインは何も変更していなかったり、結構ダメダメな点があります。
Rust初心者なので、もっとこうした方がいい、これ使ったほうがいいといった意見がありましたら、是非コメントしてください。
最後まで閲覧していただきありがとうございました。

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?