自宅LAN内のラズパイで動かしているサービスをIPv6で外部に公開する

# やりたいこと
- `PPPoE`接続ではなく`IPoE`接続している自宅LAN上のラズパイをインターネット経由でアクセスできるようにしたい。
  - プロバイダから割り当てられるWAN側のIPv4のアドレスは他のユーザと共用しており、v4のアドレスを使ってサービスを公開するのは難しいです。
  - IPv6であれば自宅LAN内に接続したマシンにグローバルユニークアドレス(GUA)が割り当てられるので、外部からLAN内のラズパイにアクセスするが可能になります。
- ラズパイ上で動かすサービスはDockerのコンテナで動かしており、このサービスからインターネット経由で別のサービスのAPIを呼び出したい。

「外部にラズパイを公開する」なんてセキュリティ的に聞いただけでも恐ろしい感じがするかもしれませんが、適切にセキュリティ対策を行えば特に問題ないと考えます。

# インターネット接続に関する前提
- ルータ + ONU(終端装置)やHGW(ホームゲートウェイ)などを使って自宅LANからインターネットへ接続しているものとします。
- 従来の`PPPoE`接続ではなく`IPoE`接続でIPv6を利用するものとします。(IPoEに関する説明はここでは行いません)

# ご注意
- ラズパイを外部へ公開する場合は、必ずルータ、ラズパイのOS/ネットワーク、コンテナ周りのセキュリティに関する設定を適切に行ってください。
- IPv6でラズパイを外部へ公開するので、外部からはIPv6でしかラズパイ上のサービスを呼び出すことができません。
  サービスを呼び出すクライアントもIPv6を使うことになります。
- サービスを動かすDockerコンテナもIPv6を使うので、コンテナ側からインターネット経由で呼び出す外部サービスもIPv6で呼び出せる必要があります。
  私は自分のサービスでWikipediaのAPIを呼び出しているのですが、WikipediaはIPv6でAPIを呼び出すことができました。（世の中の有名なサービスはIPv6でも接続できるものが多いかと思います）

# 環境例
今回は以下のような環境で試しました。

- ラズパイ: Model 4B(メモリ:8GB)
- OS: Raspbian OS(Buster)
- Docker: バージョンは`20.10.2`とします。
  - Experimental機能の`ip6tables`を使います。
    なお、最新バージョン`20.10.8`ではExperimentalではなくなっているかもしれませんが
    私の環境では`20.10.2`を使っているのでこのバージョンを例に挙げておきます。

# 手順の概要

- 1.ルータのパケットフィルタリング設定
  - WAN側、LAN側のパケットフィルタリング設定を行います。
- 2.ラズパイのネットワーク設定
  - IPv6のアドレス割り当て、DNSサーバ、フォワーディングの設定を行います。
- 3.ラズパイのファイアウォール設定
  - ufwを使った設定を行います。
- 4.ラズパイのDocker設定
- 5.docker-composenのサンプル
  - サンプルでDjangoのコンテナを動かしてみます。
- 6.dockerのネットワーク確認
  - サンプルで作成したdockerネットワークの確認を行います。
- 7.動作確認
  - 外部からコンテナへのアクセス
  - コンテナから外部へのアクセス
- 8.ダイナミックDNSへの登録

## 1.ルータ等のパケットフィルタリング設定
- ご自身のルータやHGW等にパケットフィルタリングの機能があると思いますので、ルータ等の管理画面でパケットフィルタリングの設定を確認してください。ここでは例としてデフォルトゲートウェイのIPv6が以下のものであるとします。
```s デフォルトゲートウェイのv6のIPアドレス
2xxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx
```
このアドレスはグローバルユニークアドレス(GUA)であるため、セキュリティを考慮してマスクしてあります。
ルータの管理画面等でルータのLAN側のIPv6アドレスを確認した上で、ご自身の環境にあったものを設定してください。

- 私は市販のルータとONUを使っているのですが、プロバイダからレンタルするHGWを利用されている場合、デフォルトでIPv6のパケットフィルタリングが適切に設定されていないものもあるとのことです。必ずHGW側でケットフィルタリングの設定を行ってください。

- 今回はサンプルとしてdocker-composeを使ってDjangoのコンテナを8080番ポートで起動させるので、もしサンプルを実行される場合は8080番のポートを開いてください。

## 2.ラズパイのネットワーク設定
<IPアドレスの割り当てとDNSサーバの設定>
`/etc/dhcpcd.conf`にラズパイに割り当てるIPアドレスとDNSサーバのIPアドレスを設定します。

- IPアドレスとDNSサーバの設定を行います。私は有線でラズパイとルータを接続しており、ネットワークインターフェースは`eth0`を使っています。ここでは`/etc/dhcpcd.conf`で`eth0`のv6アドレスとDNSサーバのv6アドレスを設定しますが、適宜インターフェースはご自身の環境に合わせて指定してください。
  いきなり最初からラズパイで利用できるIPv6のアドレス割り当てるのも難しいと思いますので、
私はルータから自動的に割り当てられたv6のアドレスを確認してからそのアドレスを割り当て増田。
今回は以下のアドレスを割り当てるものとします。（ここでもセキュリティを考慮してマスクしてあります）

```shell
# ラズパイに割り当てるIPv6のアドレス
2yyy:yyyy:yyyy:yyyy:yyyy:yyyy:yyyy:yyyy/64
```
- 次にDNSサーバはGoogleのIPv6で公開されているものとデフォルトゲートウェイのものを指定します。
ここではIPv6の`2001:4860:4860::8888`(GoogleのプライマリDNS)、`2001:4860:4860::8844`(GoogleのセカンダリDNS)、2xxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx(デフォルトゲートウェイ)の順番で設定しておきます。

```shell
# dhcpcd.confの設定
interface eth0
static ip6_address=2yyy:yyyy:yyyy:yyyy:yyyy:yyyy:yyyy:yyyy/64
static routers=2xxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx
static domain_name_servers=2001:4860:4860::8888 2001:4860:4860::8844 2xxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx
```

次にコンテナからラズパイ経由でインターネットに出るためのパケットフォワードの設定を行います。

- ラズパイ上でIPv6のパケットのフォワーディングの設定を行います。
今回はDockerのコンテナ上で動かしているサービス側からインターネット経由で別サービスのAPIを呼び出したいので、
`/etc/sysctl.conf`の以下2つのipv6に関するforwardingを有効にしておきます。
デフォルトだと以下のように該当する2行がコメントアウトされていると思います。

```shell
# 変更前のsysctl.conf
#net.ipv6.conf.all.forwarding=0
#net.ipv6.conf.default.forwarding=0
```
以下のようにコメントを外して0を1に変更します。

```shell
# 変更後のsysctl.conf
net.ipv6.conf.all.forwarding=1
net.ipv6.conf.default.forwarding=1
```
この設定を行うことでDockerコンテナの中からIPv6のパケットがラズパイ経由でインターネットへ出ることができるようになります。

# 3.ラズパイのファイアウォール設定
- 次にファイアウォールの設定を行います。私はufwを使っているのでここではufwを使って設定します。
（ufw以外のツールを使っている方はご自身の環境に応じた設定を行ってください）
- 今回はラズパイから出ていったパケットに関連するパケットのみがラズパイへ入って来れるようフォワードの設定を行います。

[ufwのforward設定]
私の環境でufwの設定を確認したらデフォルトでパケットのフォワードに関しては全てDROPするようになっていました。

```shell
# /etc/default/ufwの抜粋
DEFAULT_FORWARD_POLICY="DROP"
```

IPv6に関する設定を行うため`/etc/ufw/before6.rules`の末尾にあるCOMMITの上に以下を追加します。

```shell
# IPv6に関するufwの設定追加
...
-A ufw6-before-forward -i eth0 -j ACCEPT
-A ufw6-before-forward -m state --state RELATED,ESTABLISHED -j ACCEPT

# don't delete the 'COMMIT' line or these rules won't be processed
COMMIT
```

設定を変更したら以下の通りufwの設定をリロードしてください。

```shell
# ufwのリロード
sudo ufw reload
```

# 4.ラズパイのDocker設定
- 次にDocker周りの設定を行います。DockerはデフォルトだとIPv6が無効になっているため有効にする必要があります。
今回は`/etc/docker/daemon.json`を以下の通り設定します。

```json
{
  "ipv6": true,
  "fixed-cidr-v6": "2001:db8:1::/64",
  "experimental": true,
  "ip6tables": true
}
```

- `"fixed-cidr-v6": "2001:db8:1::/64"`の設定はDockerの公式サイトにあるサンプルと同じものです。ご自身の環境に合わせて適宜変えてしまって大丈夫です。
- `"ip6tables": true`ですが、これはIPv6に関するNATの設定を自動的に行うためのものです。
  IPv4の場合、`-p`や`--publish`などでホスト上（今回だとラズパイ上）にコンテナのポートを割り当てた際はDockerがデフォルトでNATの設定をしてくれます。しかし、IPv6の場合はデフォルトではNATの設定をしてくれません。`"ip6table": true`をセットすれば自動的にIPv6のNAT設定が行われるようになります。
  なお、このパラメータはDockerバージョン20.10.8ではexperimentalであるため、`"experimental": true`も併せて設定ししておきます。
- docker.jsonを設定したらDockerデーモンを再起動してください。

# 5.docker-composeのサンプル
次にdocker-composeを使って実際にDjangoのコンテナを動かしてIPv6まわりの確認をしてみます。
ここではサンプルとして以下のような`docker-compose.yml`を作成し、テスト用のDjangoのコンテナ(django-test)とIPv6のテスト用ネットワーク(ipv6-test)を用意します。
なお、今回はDjangoのコンテナを使っていますがRailsやSpringBootといった他のフレームワークのコンテナでも同じ話となります。

```yaml
# docker-compose.ymlのサンプル
version: "3.1"

services:
  django-test:
    build: .
    command: python3 manage.py runserver [::]:8080
    volumes:
      - .:/code
    networks:
      - ipv6-test
    ports:
      - 8080:8080

networks:
  ipv6-test:
    enable_ipv6: true
    driver: bridge
    ipam:
      driver: default
      config:
        - subnet: 2001:1000:1000::/64
          gateway: 2001:1000:1000::1
```

- 上の`networks`の部分で`enable_ipv6: true`を指定しないとコンテナにIPv6のアドレスが割り当てられません。
- `ipam`の`config`でサブネットとゲートウェイの設定を行いっています。
サブネットですが、先ほど`docker.json`の`fixed-cidr-v6`で設定した内容(`"2001:db8:1::/64"`)と同じものをセットすると、ネットワーク作成時に以下のようなエラーが発生します。

```shell
Creating network "docker-test_ipv6-test" with driver "bridge"
ERROR: Pool overlaps with other one on this address space
```

なので、ここではサンプルで`2001:1000:1000::/64`を設定しています。

- 次にDjangoのプロジェクトを生成するため以下を実行します。
  (https://docs.docker.jp/compose/django.html を参考にしました)

```shell
# Djangoのプロジェクト作成
docker-compose run web django-admin.py startproject composeexample .
```

プロジェクトが作成されたら、`composeexample/setting.py`を開いて`ALLOWED_HOSTS`に
`/etc/dhcpcd.d`で設定したラズパイのIPv6のアドレスをセットします。（ここではセキュリティ上マスクして記載します）

```shell
# ALLOWED_HOSTSの設定
ALLOWED_HOSTS = ['[2yyy:yyyy:yyyy:yyyy:yyyy:yyyy:yyyy:yyyy]']
```

次に`django-compose`でコンテナを起動します。

```shell
# コンテナの起動
docker-compose up -d
```
サービスを起動させたら`docker-compose ps`でコンテナの一覧を見てみます。

```shell

# docker-compose ps
          Name                         Command               State                    Ports
-------------------------------------------------------------------------------------------------------------
docker-test_django-test_1   python3 manage.py runserve ...   Up      0.0.0.0:8080->8080/tcp,:::8080->8080/tcp
```

django-testのコンテナは`python3 manage.py runserver [::]:8080`というように、IPアドレスにv6の`[::]`を指定して起動しています。この場合、IPv6とIPv4の両方でdjangoのサーバはLISTENします。(IPv6の`[::]`はIPv4でいうところの"0.0.0.0"に相当します)

# 6.Dockerのネットワーク確認
- 次に上で作成されたネットワーク(ipv6-test)を`docker network inspect`で確認してみます。
- 
```shell
# 作成されたネットワークの確認
docker network inspect docker-test_ipv6-test
```
結果は以下のようになりました。

```json
[
    {
        "Name": "docker-test_ipv6-test",
        "Id": "aa9292514cc7130a1a70b7e07409f08c62273fa96d62d574b7b1320cec177f4b",
        "Created": "2021-10-01T05:55:42.889737347+09:00",
        "Scope": "local",
        "Driver": "bridge",
        "EnableIPv6": true,
        "IPAM": {
            "Driver": "default",
            "Options": null,
            "Config": [
                {
                    "Subnet": "172.20.0.0/16",
                    "Gateway": "172.20.0.1"
                },
                {
                    "Subnet": "2001:1000:1000::/64",
                    "Gateway": "2001:1000:1000::1"
                }
            ]
        },
        "Internal": false,
        "Attachable": true,
        "Ingress": false,
        "ConfigFrom": {
            "Network": ""
        },
        "ConfigOnly": false,
        "Containers": {
            "b603d5fbc5fd70b0a71e88ec420a113da2f5292dd7aebc25223d5c4d6dc6522a": {
                "Name": "docker-test_django-test_1",
                "EndpointID": "94f080c14ef708fc91ec6d509aadbaa7dd0226ad32f6a40cc16717ccb1361ab5",
                "MacAddress": "02:42:ac:14:00:02",
                "IPv4Address": "172.20.0.2/16",
                "IPv6Address": "2001:1000:1000::2/64"
            }
        },
        "Options": {
            "com.docker.network.enable_ipv6": "true"
        },
        "Labels": {
            "com.docker.compose.network": "ipv6-test",
            "com.docker.compose.project": "docker-test",
            "com.docker.compose.version": "1.29.2"
        }
    }
]
```

`Containers`のところでDjangoのコンテナ(`Name`が`docker-test_django-test_1`となっている部分)に対してIPv6のアドレス(`IPV6Address`が`2001:1000:1000::2/64`となっている部分)が割り当てられているのが確認できます。また、IPv4のアドレスは自動的に`172.20.0.2/16`が割り当てられています。
次に`docker exec`でDjangoのコンテナに入り`ip a`でIPアドレスを確認してみます。

```shell
# IPアドレスの確認
docker exec -it docker-test_django-test_1 /bin/bash

# ip aでIPアドレスを表示します
root@b603d5fbc5fd:/code# ip a
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
    inet6 ::1/128 scope host
       valid_lft forever preferred_lft forever
36: eth0@if37: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default
    link/ether 02:42:ac:14:00:02 brd ff:ff:ff:ff:ff:ff link-netnsid 0
    inet 172.20.0.2/16 brd 172.20.255.255 scope global eth0
       valid_lft forever preferred_lft forever
    inet6 2001:1000:1000::2/64 scope global nodad
       valid_lft forever preferred_lft forever
    inet6 fe80::42:acff:fe14:2/64 scope link
       valid_lft forever preferred_lft forever
```

上で`inet6 2001:1000:1000::2/64 scope global nodad`と表示されました。
`docker network inspect`で確認したものと同じアドレスになっていることがわかります。


[ip6tablesの確認]
次に4で設定した`"ip6tables": true`が正しく機能しているか確認してみます。
以下の通り`ip6tables`コマンドをラズパイ上で実行します。

```shell
sudo ip6tables -t nat -L -n
```

以下のような結果が表示されました。

```shell
Chain PREROUTING (policy ACCEPT)
target     prot opt source               destination
DOCKER     all      ::/0                 ::/0                 ADDRTYPE match dst-type LOCAL

Chain INPUT (policy ACCEPT)
target     prot opt source               destination

Chain POSTROUTING (policy ACCEPT)
target     prot opt source               destination
MASQUERADE  all      2001:1000:1000::/64  ::/0
MASQUERADE  all      2001:db8:1::/64      ::/0
MASQUERADE  tcp      2001:1000:1000::2    2001:1000:1000::2    tcp dpt:8080

Chain OUTPUT (policy ACCEPT)
target     prot opt source               destination
DOCKER     all      ::/0                !::1                  ADDRTYPE match dst-type LOCAL

Chain DOCKER (2 references)
target     prot opt source               destination
RETURN     all      ::/0                 ::/0
RETURN     all      ::/0                 ::/0
RETURN     all      ::/0                 ::/0
DNAT       tcp      ::/0                 ::/0                 tcp dpt:8080 to:[2001:1000:1000::2]:8080
```
以下2行の設定からIPv6に関するNATの設定が正しく行われていることがわかります。

```shell
MASQUERADE  tcp      2001:1000:1000::2    2001:1000:1000::2    tcp dpt:8080
DNAT       tcp      ::/0                 ::/0                 tcp dpt:8080 to:[2001:1000:1000::2]:8080
```

# 7.動作確認
[外部からラズパイ上のコンテナへのアクセス]
- 試しにブラウザを開いて`http://[2yyy:yyyy:yyyy:yyyy:yyyy:yyyy:yyyy:yyyy]:8080`にアクセスしてみてください。正しくアクセスできると以下のようなHTMLがブラウザに表示されると思います。

```shell
It worked!
Congratulations on your first Django-powered page.
Next, start your first app by running python manage.py startapp [app_label].

You're seeing this message because you have DEBUG = True in your Django settings file and you haven't configured any URLs. Get to work!
```

なお、ラズパイと同じLAN内にあるマシンから確認した場合、インターネット経由のアクセスではなくなるので、可能であればインターネット経由で確認した方が「外部からラズパイ上のサービスへアクセスできるようにする」という今回の趣旨の確認になると思います。
もし上のDjangoコンテナからHTMLが返ってこない場合、ルータのパケットフィルタリングやラズパイのネットワーク設定等を見直してください。

[コンテナ内部から外部へのアクセス]
- 次にDjangoのコンテナ内部からラズパイ、ルータを経由して自宅LANの外部へアクセスできるか確認してみます。
  試しに以下の通りDjangoのコンテナに入って`ping6`でGoogleのDNSサーバへアクセスしてみます。
  問題なく接続できれば以下のような結果が返ってきます。

```shell
docker exec -it docker-test_django-test_1 /bin/bash

root@809786c94601:/code# ping6 -c 3  dns.google
PING dns.google(dns.google (2001:4860:4860::8844)) 56 data bytes
64 bytes from dns.google (2001:4860:4860::8844): icmp_seq=1 ttl=114 time=8.01 ms
64 bytes from dns.google (2001:4860:4860::8844): icmp_seq=2 ttl=114 time=5.53 ms
64 bytes from dns.google (2001:4860:4860::8844): icmp_seq=3 ttl=114 time=4.94 ms
```

次に以下の通りコンテナ内部からcurlで外部のWebサイトへアクセスしてみます。
試しにここでは日本語版WikipediaのメインページへIPv6でアクセスしみましょう。

```s
# IPv6で接続するので`-6`スイッチをつけます
root@809786c94601:/code# curl -6 https://ja.wikipedia.org/wiki/%E3%83%A1%E3%82%A4%E3%83%B3%E3%83%9A%E3%83%BC%E3%82%B8
```

HTMLのレスポンスが返ってきたら、問題なくIPv6でWikipediaへアクセスできています。

# 8.DynamicDNSへの独自ドメインとIPv6の登録
Dockerコンテナで動かしているサービスを外部に公開する場合、通常はhttpsで公開すると思います。なので、Nginxなどでリバースプロキシを立てて証明書をインストールして運用することになると思います。
以下のような手順で独自ドメインと証明書を取得し、独自ドメインとIPv6アドレスをDDNSに登録すれば良いです。

1. No-IPのようなサービスでフリーの独自ドメインを取得する。
2. Let's EncryptやZeroSSLといった無料の証明書サービスにて独自ドメインの証明書を発行する。
3. ラズパイに割り当てたIPv6のアドレスをNo-IPのようなDynamicDNSへ登録する。

No-IPはドメイン更新(手動)が必要ですが、無料で使えるので私は普段よく利用しています。


# まとめ
今回はラズパイを使って以下のことを確認しました。

- IPoE接続しているLAN内のラズパイに対して外部からIPv6でアクセスする。
- Dockerコンテナからインターネット上のサービスに対してIPv6でアクセスする。

なお、以下の点にご注意下さい。

- ラズパイを外部へ公開する場合、セキュリティ対策をきちんと行ってください。
- ラズパイ上のサービスをIPv6で公開する場合、サービスを呼び出すクライアントもIPv6に対応している必要があります。
- 世の中で公開されているサービスによってはIPv6に対応していないものもあります。

# 参考資料

[Docker]

- https://docs.docker.com/config/daemon/ipv6/
- https://blog.iphoting.com/blog/2021/02/10/ipv6-docker-docker-compose-and-shorewall6-ip6tables/

[IPv6]

- https://ja.wikipedia.org/wiki/IPv6

[DDNS]

- https://www.noip.com/