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

この記事は、[木更津高専 Advent Calendar 2023](https://qiita.com/advent-calendar/2023/nit_kisarazu)の10日目の記事です。

前→[Raspberry Pi as a Router](https://qiita.com/toma09to/items/56f42ab35792d8ec6bdc) by [@toma09to](https://qiita.com/toma09to)
次→[クリスマスがやってこないｶﾜｲｿｳな人たちに爆撃するdiscordBotつくった話](https://qiita.com/yukipeti/items/2080e6cf40631fb15089) by [@yukipeti](https://qiita.com/yukipeti)

# 動機
[前回](https://qiita.com/toma09to/items/56f42ab35792d8ec6bdc)、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`が必要なので、それもここに配置することにします。
それに伴い、ユニットファイルも変更します。
```diff_system_d:/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`番ポートを開けます。環境変数などの詳細は[前回の記事](https://qiita.com/toma09to/items/56f42ab35792d8ec6bdc)を参照してください。
```diff_shell:/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.html`、`failure.html`を`static`ディレクトリ内に作成します。
ファイル内の`$`や`&`は、プログラムがページを送信する際に、`$`をエラーコードやDBのデータを使って作成した`<tr>`要素に置換するための符号です。
```html:/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>
```

```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>
```

```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`を指定しなければならないことに注意してください。
```rust: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`を作成し、サービスの内容を記述していきます。

:::note info
必要な`use`等は、最後に全ファイルを掲載しているので、[そちら](#全ファイル)をご参照ください。
:::

### 構造体
フォームのデータやDBのデータの構造体を作成します。
```rust:services.rs
struct Setting {
    port: u16,
    destination: String,
}

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

### 各種関数
`$`をある文字列に置換するための関数を作成します。
```rust: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`を書き換えるための関数を作成します。
```rust: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 /
サイトにアクセスしたときにページを表示する関数を作ります。
```rust: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敗）ので、ここで弾いておきます。
```rust: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
最後に、データの変更なしにルーティングテーブルの再読み込みを行う関数を作ります。
```rust: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`の全体を掲載しておきます。
```rust: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`を作ります。
```systemd:/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
```

プロセスを開始します。
```shell-session
$ sudo systemd enable web-dnat.service
$ sudo systemd start web-dnat.service
```

# 完成
`{ルーターのIP}:8080`にアクセスすると以下のようなページが表示されます。

![Screenshot 2023-12-09 at 20.56.37.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1647235/b70a1c42-9249-560f-7d29-96e4013af103.png)

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