Raspberry Piを WiFiアクセスポイントとして設定する (ufw版)

# はじめに

Raspberry Pi を WiFi アクセス ポイントとして設定するのはよくあるのですがufwを使いたかったので、ufwを使う場合の設定のメモです.
手順は、AdafruitのとRaspberry Piのドキュメントを参考にしています。

2023/6/17 更新
dnsmasqのリースのログをjournalから確認する方法を追加しました。
2023/6/22 更新
bottleからdnsmasqのリースのログが参照できるようにしました。
2023/6/26 更新
WiFiアクセスポイントに接続されている端末の一覧を確認する方法を追加しました。
2024/5/4 更新
Network Manager版のWiFiアクセスポイント設定とufw設定を追加しました。
2024/5/20 更新
Network Manager版のband aの時にはchannelも指定する必要があることを追加しました。

::: note warn

raspberr pi の OS で NetworkManager がデフォルトで有効になりました。そのため WiFi アクセス ポイントの手順がここに書いてある手順と異なっています。WiFi アクセス ポイント化については以下のサイトを参照してください。

https://www.raspberrypi.com/documentation/computers/configuration.html#host-a-wireless-network-on-your-raspberry-pi
:::

# Network Manager版のWiFiアクセスポイント設定とufw設定(2024/5/4追記)

簡易ですが、私が設定した内容を乗せておきます。
nmcliでは、以下のような感じで設定することになります。

```
sudo nmcli device wifi hotspot ssid <example-network-name> password <example-password>
```

私は、wlan1に設定したかったので、このようにしました。

```
sudo nmcli device wifi hotspot ifname wlan1 ssid <example-network-name> password <example-password>
```

band a (5Ghz) を使うときは、channel <channel> も指定する必要がありました。指定しないと band bg で channel 1 になっていました。

NetworkManager は wifi デバイスに IP アドレス 10.42.0.1 を使用し、 10.42.0.0/24 サブネットからの IP アドレスをクライアントに割り当てられるようです。また、connectionの名前は、Hotspot になっていました。下記コマンドで、設定を確認することができます。

```
sudo nmcli device show wlan1
```

NetworkManagerでは、IPv4ルーティングを有効に自動で設定してくれました。

自動接続の設定をしないと、Raspberry piを再起動すると消えてしまいます。自動接続のために connection.autoconnect を yes に設定します。

```
sudo nmcli connect modify Hotspot connection.autoconnect yes
```

nmcliでも、ufw を使っている場合は、ufwの設定に追加ルールが必要でした。
ufwのsshを有効にしてからWiFiアクセスポイント用のルールを設定しています。
```
sudo ufw allow SSH
sudo ufw enable
```
```
sudo ufw allow in on wlan1 to 0.0.0.0/0 port 67:68 proto udp
sudo ufw allow in on wlan1 to 10.42.0.1  port 53 from 10.42.0.0/24
sudo ufw route allow in on wlan1 out on eth0 from 10.42.0.0/24
```

dhcpのIP払い出しされたIPアドレスは以下のファイルで確認できます。ファイルのパスが NetworkManager の下になっています。

```
sudo cat /var/lib/NetworkManager/dnsmasq-wlan1.leases
```

journalctl や iw は、以前と同じように使えました。

```
journalctl -t dnsmasq-dhcp -S yesterday
iw dev wlan1 station dump
```

# 以下は、Network Managerが利用されていない場合の手順

## AdafruitのとRaspberry Piの手順もご確認ください

https://learn.adafruit.com/setting-up-a-raspberry-pi-as-a-wifi-access-point/install-software

https://www.raspberrypi.com/documentation/computers/configuration.html#setting-up-a-routed-wireless-access-point

## 作りたい環境

~~~plantuml
@startuml
<style>
nwdiagDiagram {
  server {
    BackGroundColor white;
    LineColor black;
  }
  network {
    BackGroundColor white;
    LineColor black;
  }
  arrow {
    LineColor black;
  }
}
</style>
nwdiag {
  Internet [shape = cloud];
  Internet -- Router;

  network LAN {
      description = "LAN";
      Router [description = "    Router\nDHCP server\n192.168.1.1"];
      PC1 [description = "192.168.1.3\n     PC#1"];
      PC2 [description = "192.168.1.4\n     PC#2"];
      RPi [description = "192.168.1.2\n       RPi\n  WLAN AP\n192.168.4.1"];

  }
  network WLAN {
    description = "WLAN";
    RPi;
    Laptop [description = "192.168.4.2\n   Laptop\nWLAN Client"];
  }
  group {
    description = "作りたい環境";
    color = "pink";
    RPi;
    Laptop;
  }
}
@enduml
~~~

## ソフトウェアをインストールする

Pi のオペレーティング システムとユーティリティが新しくて最新であることを確認します。

~~~
sudo apt update
sudo apt -y upgrade
~~~

hostapd と dnsmasq、ufw をインストールしAdafruitの手順にしたがって再起動します。
ufwを利用するため、手順にある`netfilter-persistent`と`iptables-persistent`は不要です。

~~~
sudo apt install -y hostapd dnsmasq ufw
sudo systemctl unmask hostapd
sudo systemctl enable hostapd
sudo reboot
~~~

## ネットワークルーターをセットアップする

### ワイヤレスインターフェイスのIP構成を定義する

IP アドレスを構成するには、次のように構成ファイルを編集します。ここではnanoエディターを使用していますが、選択した別のエディターに置き換えることもできます。

~~~
sudo nano /etc/dhcpcd.conf
~~~

dhcpcd.confには数十行の内容があります。ファイルの末尾に移動し、最後に次の行を追加します。

~~~:/etc/dhcpcd.conf
interface wlan0
    static ip_address=192.168.4.1/24
    nohook wpa_supplicant
~~~

## ワイヤレス ネットワークのDHCPサービスとDNSサービスを構成する

~~~
sudo nano /etc/dnsmasq.conf
~~~

~~~
interface=wlan0 # Listening interface
domain-needed
bogus-priv

dhcp-range=192.168.4.2,192.168.4.20,255.255.255.0,24h
                # Pool of IP addresses served via DHCP
dhcp-option=option:router,192.168.4.1
~~~

`domain-needed`は、ドメインの無いホスト名のみ問い合わせの場合、上位DNSサーバに転送しないようにするための設定です。
`bogus-priv`は、プライベートIPアドレスの逆引きを上位DNSサーバに転送しないための設定です。
`dhcp-option=option:router,192.168.4.1`は、DHCPクライアントに通知するルータのIPアドレスの設定です。


### IPv4ルーティングを有効にする

`/etc/ufw/sysctl.conf`を編集してIPv4ルーティングを有効にします。

~~~
sudo nano /etc/ufw/sysctl.conf
~~~

下記の `#net/ipv4/ip_forward=1`のコメントを外します。

~~~:/etc/ufw/sysctl.conf 編集前
# Uncomment this to allow this host to route packets between interfaces
#net/ipv4/ip_forward=1
#net/ipv6/conf/default/forwarding=1
#net/ipv6/conf/all/forwarding=1
~~~

~~~:/etc/ufw/sysctl.conf 編集後
# Uncomment this to allow this host to route packets between interfaces
net/ipv4/ip_forward=1
#net/ipv6/conf/default/forwarding=1
#net/ipv6/conf/all/forwarding=1
~~~

### IPマスカレードを有効にする

`/etc/ufw/before.rules`を編集してIP マスカレードを有効にします。

~~~:/etc/ufw/before.rules 編集前
#
# rules.before
#
# Rules that should be run before the ufw command line added rules. Custom
# rules should be added to one of these chains:
#   ufw-before-input
#   ufw-before-output
#   ufw-before-forward
#

# Don't delete these required lines, otherwise there will be errors
*filter
:ufw-before-input - [0:0]
:ufw-before-output - [0:0]
:ufw-before-forward - [0:0]
:ufw-not-local - [0:0]
# End required lines
（以下省略）
~~~

以下の数行を`*filter`の前に挿入します。

~~~:/etc/ufw/before.rulesに挿入する記述
# NAT
*nat
-F
-A POSTROUTING -s 192.168.4.0/24 -o eth0 -j MASQUERADE
COMMIT
~~~

編集後は以下のようになります。

~~~:/etc/ufw/before.rules 編集後
#
# rules.before
#
# Rules that should be run before the ufw command line added rules. Custom
# rules should be added to one of these chains:
#   ufw-before-input
#   ufw-before-output
#   ufw-before-forward
#

# NAT
*nat
-F
-A POSTROUTING -s 192.168.4.0/24 -o eth0 -j MASQUERADE
COMMIT

# Don't delete these required lines, otherwise there will be errors
*filter
:ufw-before-input - [0:0]
:ufw-before-output - [0:0]
:ufw-before-forward - [0:0]
:ufw-not-local - [0:0]
# End required lines
（以下省略）
~~~

#### ufw を有効にする

ufw を有効にしますが、sshでアクセスしている場合は、SSHをルールに追加しから有効にしてください。

~~~
sudo ufw allow SSH
sudo ufw enable
~~~

#### ufw の転送を有効にする

いろいろ試した結果下記の記述が最低限必要そうです。

~~~
sudo ufw allow in on wlan0 to 0.0.0.0/0 port 67:68 proto udp
sudo ufw allow in on wlan0 to 192.168.4.1 port 53 from 192.168.4.0/24
sudo ufw route allow in on wlan0 out on eth0 from 192.168.4.0/24
~~~

## APソフトウェアの設定

~~~
sudo nano /etc/hostapd/hostapd.conf
~~~

以下の情報を`/etc/hostapd/hostapd.conf`に設定します。
この設定は、ネットワーク名`NameOfNetwork`、パスワード`AardvarkBadgerHedgehog`、チャネル 7 を使用していることを前提としています。名前とパスワードを引用符で囲んではいけないことに注意してください。パスフレーズの長さは 8 ～ 64 文字にする必要があります。
ネットワーク名とパスワードは、変更してください。
私は日本在住なので`country_code`は、JPを設定しています。

~~~:/etc/hostapd/hostapd.conf
country_code=JP
interface=wlan0
ssid=NameOfNetwork
hw_mode=g
channel=7
macaddr_acl=0
auth_algs=1
ignore_broadcast_ssid=0
wpa=2
wpa_passphrase=AardvarkBadgerHedgehog
wpa_key_mgmt=WPA-PSK
wpa_pairwise=TKIP
rsn_pairwise=CCMP
~~~

Adafruitの手順には、`raspi-config nonint do_wifi_country`の設定があったので念のため設定しました。
その後再起動します。

~~~
sudo raspi-config nonint do_wifi_country JP
sudo systemctl reboot
~~~

##  WiFiアクセスポイントに接続された端末の確認

### dnsmasqからリースされた端末の確認

dhcpのIP払い出しを確認するとWiFiアクセスポイントに接続された端末の確認することができます。

~~~
cat /var/lib/misc/dnsmasq.leases
~~~

dnsmasqのログがjournalにあります。次のコマンドで昨日からのリースのログが確認できます。

~~~
journalctl -t dnsmasq-dhcp -S yesterday
~~~

### WiFiアクセスポイントに接続してる端末の確認

WiFiアクセスポイントに接続されている端末の一覧は次のように取得することができます。

~~~
iw dev wlan0 station dump
~~~


### web経由で確認

IP払い出しをWeb経由で確認するため、python3とbottleをつかってWebサーバを立ち上げます。
bottleをインストールします。

~~~
apt install python3-bottle
~~~

bottleを使ったサーバ

~~~python:rpi_service.py
#!/usr/bin/python3

import grp
import pwd
import os
from bottle import template, route, run
from datetime import datetime
import json
import socket
import subprocess
from wsgiref.simple_server import WSGIServer


# Bottleサービスをカスタマイズするため WSGIServer を利用する
class wsgi_server(WSGIServer):

    # サーバを終了した直後にもう一度サーバを起動しようとすると、
    # bindがエラーで終了することがある。
    # その問題を回避するためにSO_REUSEADDRを有効にする。
    # allow_reuse_address = True にすると SO_REUSEADDR が有効になる。
    allow_reuse_address = True

    def server_bind(self):
        WSGIServer.server_bind(self)
        self._drop_privileges()

    def _drop_privileges(self):
        # 1024ポート以下のポート(特権ポート)を利用するために、
        # root ユーザで起動される場合がある。
        # root ユーザで起動された時は、ユーザを変更して権限をドロップする。
        name = 'www-data'
        group = 'www-data'
        groups = ['systemd-journal']
        if os.getuid() == 0:
            uid = pwd.getpwnam(name)[2]
            gid = grp.getgrnam(group)[2]
            gids = [grp.getgrnam(g)[2] for g in groups]
            os.setgid(gid)
            os.setgroups(gids)
            os.setuid(uid)


def _hostname():
    return socket.gethostname().capitalize()


def _rpi_model():
    with open('/proc/device-tree/model') as f:
        model = f.read().rstrip('\x00')
    return model


INDEX_HTML = """<!DOCTYPE html>
<html lang="ja">

<head>
  <title>{{hostname}}</title>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
</head>

<body>
  <main>
    <h1>{{hostname}}</h1>
    <p>モデル: {{rpi_model}}</p>

    <article>
      <header>
        <h2>Dnsmasq リース情報</h2>
        <p>
          DnsmasqでリースされたIPアドレス一覧
          <button onclick="update_dhcp_leases()">更新</button>
        </p>
      </header>
      <table>
        <thead>
          <tr>
            <th>リース期間</th>
            <th>MAC アドレス</th>
            <th>IP アドレス</th>
            <th>名前</th>
            <th>クライアントID</th>
          </tr>
        </thead>
        <tbody id="dhcp_leases"></tbody>
      </table>
    </article>

    <article>
      <header>
        <h2>Dnsmasq ログ</h2>
        <p>
          DnsmasqのDHCPのログ
          <button onclick="update_dhcp_logs()">更新</button>
        </p>
      </header>
      <table>
        <thead>
          <tr>
            <th>日時</th>
            <th>メッセージ</th>
          </tr>
        </thead>
        <tbody id="dhcp_logs"></tbody>
      </table>
    </article>
  </main>
  <script type="text/javascript">
    function update_dhcp_leases() {
      fetch("/dnsmasq/dhcp/leases")
        .then((res) => res.json()).then((json) => {
          let tbody = '';
          json.leases.forEach((element) => {
            tbody += `<tr>
              <td>${element.lease_time}</td>
              <td>${element.mac_address}</td>
              <td>${element.ip_address}</td>
              <td>${element.name}</td>
              <td>${element.client_id}</td>
            </tr>`;
          });
          document.getElementById("dhcp_leases").innerHTML = tbody;
        });
    }
    function update_dhcp_logs() {
      fetch("/dnsmasq/dhcp/logs")
        .then((res) => res.json()).then((json) => {
          let tbody = '';
          json.logs.forEach((log) => {
            tbody += `<tr>
              <td>${log.time}</td>
              <td>${log.message}</td>
            </tr>`;
          });
          document.getElementById("dhcp_logs").innerHTML = tbody;
        });
    }
    window.onload = function() {
      update_dhcp_leases();
      update_dhcp_logs();
    }
  </script>
</body>

</html>
"""


@route('/')
def index():
    return template(
        INDEX_HTML,
        hostname=_hostname(),
        rpi_model=_rpi_model(),
    )


def _dnsmasq_dhcp_leases():
    leases = []
    with open('/var/lib/misc/dnsmasq.leases') as f:
        for line in f:
            element = line.split()
            if len(element) != 5:
                continue
            lease_time = int(element[0])
            if lease_time == 0:
                lease_time = 'Never'
            else:
                lease_time = datetime.fromtimestamp(
                    lease_time).strftime('%Y-%m-%d %H:%M:%S')
            leases.append({
                'lease_time': lease_time,
                'mac_address': element[1],
                'ip_address': element[2],
                'name': element[3],
                'client_id': element[4],
            })
    return {'leases': leases}


@route('/dnsmasq/dhcp/leases')
def dnsmasq_dhcp_leases():
    # dict だと jsonに変換される。list は json にはならない
    return _dnsmasq_dhcp_leases()


def _dnsmasq_dhcp_logs():
    completed = subprocess.run(
        ['journalctl', '-t', 'dnsmasq-dhcp', '-S', 'yesterday', '-r',
            '-o', 'json',
            '--output-fields=_SOURCE_REALTIME_TIMESTAMP,MESSAGE'],
        stdin=subprocess.DEVNULL,
        capture_output=True,
        text=True,
    )
    logs = []
    if completed.returncode == 0:
        for s in completed.stdout.splitlines():
            j = json.loads(s)
            tm = datetime.fromtimestamp(
                int(j['_SOURCE_REALTIME_TIMESTAMP'])/1000000)
            logs.append({
                'time': tm.strftime('%Y-%m-%d %H:%M:%S'),
                'message': j['MESSAGE'],
            })
    return {'logs': logs}


@route('/dnsmasq/dhcp/logs')
def dnsmasq_dhcp_logs():
    # dict だと jsonに変換される。list は json にはならない
    return _dnsmasq_dhcp_logs()


run(host='::', port=80, server_class=wsgi_server)
~~~

80番ポートを使っているのでsudo python3から起動します。
~~~
$ sudo python3 rpi_service.py
Bottle v0.13-dev server starting up (using WSGIRefServer(server_class=<class '__main__.wsgi_server'>))...
Listening on http://:::80/
Hit Ctrl-C to quit.

~~~

ブラウザでアクセスすると下記のようになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2527700/15698928-9362-094d-6a75-2fe238b11c27.png)



## 参考

Dnsmasqの設定は以下のページを参考にさせていただきました。

https://gazee.net/develop/raspberry-pi-dnsmasq-dns-dhcp-server/