☎️

systemdに全部賭けろ(第三部)

Linuxでルータを作る(ホームゲートウェイ設置編)


記事の構成について

最終回・第三部では、ルータの下流にHGWを設置し、ひかり電話を利用するための設定を行う。


手順3:HGWの設置

HGW側インターフェースの設定

HGW側インターフェースhgw)の設定はやや複雑だ。

ネットワーク構成みた通り、HGWに対してはDHCPv6-PDでプレフィックスを配布する。このとき、DHCPv6サーバ自体はKeaに任せるとしても、RAによるデフォルトゲートウェイの通知1別途必要になる。この部分については、systemd-networkdで実現する。

送信するRAのパラメータは、(HGWが本来受け取る)フレッツ網のものを参考にして決めるとよい。フレッツ網のRAは、以下のようにすると受信できる。

Terminal window
> rdisc6 -1 wan0
Soliciting ff02::2 (ff02::2) on wan0...
Hop limit : 64 ( 0x40)
Stateful address conf. : Yes
Stateful other conf. : Yes
Mobile home agent : No
Router preference : medium
Neighbor discovery proxy : No
Router lifetime : 1800 (0x00000708) seconds
Reachable time : 300000 (0x000493e0) milliseconds
Retransmit time : 10000 (0x00002710) milliseconds
Source link-layer address: XX:XX:XX:XX:XX:XX
MTU : 1500 bytes (valid)
from fe80::xxxx:xxxx:xxxx:xxxx

これをもとに、以下のように設定する。

/etc/systemd/network/15-hgw.network
[Match]
Name=hgw
[Network]
LinkLocalAddressing=ipv6
IPv6Forwarding=yes
IPv6SendRA=yes
# フレッツ網に似せたRAを射出する
[IPv6SendRA]
# M-flag、O-flagを有効にしておく
Managed=yes
OtherInformation=yes
HopLimit=64
RouterLifetimeSec=1800
ReachableTimeSec=300
RetransmitSec=10

IPv4の転送ルール設定

HGW向けに、IPv4をルーティングせず転送するルールも設定する。

/etc/nftables.d/lazy/hgw.nft
#!/usr/bin/nft -f
destroy table netdev bridge-hgw-v4
table netdev bridge-hgw-v4 {
chain inbound {
type filter hook ingress device "wan0" priority -500; policy accept;
ether type { ip, arp } counter fwd to "hgw"
}
chain outbound {
type filter hook ingress device "hgw" priority -500; policy accept;
ether type { ip, arp } counter fwd to "wan0"
}
}

これだけで、wan0入ってきたIPv4パケットは、(L3に上がることなく)即座にhgwから出ていくようになる。逆も然り。

ただし、nftablesのingressフックは対象となるデバイスが存在しない場合エラーになる。そのため下のようにsystemdサービスを利用し、デバイスの存在を保証してから適用するのが安全だ。

/etc/systemd/system/nftables-hgw.service
[Unit]
Description=Apply nftables filter rules for hgw
BindsTo=sys-subsystem-net-devices-hgw.device
After=sys-subsystem-net-devices-hgw.device nftables.service
Requires=nftables.service
[Service]
Type=oneshot
ExecStart=/usr/bin/nft -f /etc/nftables.d/lazy/hgw.nft
RemainAfterExit=true
[Install]
WantedBy=multi-user.target
Terminal window
systemctl enable nftables-hgw

ファイアウォールの設定更新

HGWからのIPv6パケットを受け付けるよう、メインのファイアウォールにも設定を加えておく。

/etc/nftables.conf
#!/usr/bin/nft -f
destroy table inet filter
table inet filter {
set LANv4 {
type ipv4_addr; flags interval;
elements = { 10.0.0.0/24 }
}
chain input {
type filter hook input priority filter; policy drop;
ct state invalid drop; ct state { established, related } accept;
iif lo accept
icmpv6 type {
destination-unreachable,
packet-too-big,
time-exceeded,
parameter-problem,
nd-neighbor-solicit,
nd-neighbor-advert
} limit rate 5/second burst 10 packets accept
icmpv6 type { nd-router-advert } iif "wan0" counter accept
icmpv6 type { echo-request, nd-router-solicit } iifname "home" counter accept
icmpv6 type { echo-request, nd-router-solicit } iifname { "home", "hgw" } counter accept
icmp type { destination-unreachable, time-exceeded, parameter-problem } limit rate 5/second burst 10 packets accept
icmp type { echo-request } iifname { "home" } accept
udp dport dhcpv6-client iif "wan0" ip6 saddr fe80::/64 counter accept
udp dport dhcpv6-server iifname "hgw" ip6 saddr fe80::/64 accept
udp dport bootps iifname "home" accept
ip saddr @LANv4 iifname { "home" } jump lan-services
ip6 saddr fe80::/64 iifname { "home" } jump lan-services
counter
}
chain lan-services {
meta l4proto { tcp, udp } th dport { ssh, domain } accept
}
chain forward {
type filter hook forward priority filter; policy drop;
ct state invalid drop; ct state { established, related } accept;
icmpv6 type { destination-unreachable, packet-too-big, time-exceeded, parameter-problem } counter accept
icmp type { destination-unreachable, time-exceeded, parameter-problem } accept
meta l4proto { tcp, udp } th dport { 135, 137-139, 445 } oifname { "wan0", "ip6tnl0" } counter drop comment "NetBIOS"
iifname "home" oif "wan0" accept comment "v6"
iifname { "home", "hgw" } oif "wan0" accept comment "v6"
iifname "home" oifname "ip6tnl0" accept comment "v4"
counter
}
}
include "/etc/nftables.d/*.nft"

DHCPv6サーバの設定

DHCPv6サーバ(Kea)には、下のような最低限の設定を投入する。

払い出すIPv6プレフィックスや、DHCPv6オプションの内容を書く必要はない。あとで書くプログラム動的に追加するからだ。

/etc/kea/kea-dhcp6.conf
{
"Dhcp6": {
"interfaces-config": {
"interfaces": [
"hgw"
]
},
"control-socket": {
"socket-type": "unix",
"socket-name": "kea6-ctrl-socket"
},
"lease-database": {
"type": "memfile",
"lfc-interval": 3600
},
"expired-leases-processing": {
"reclaim-timer-wait-time": 10,
"flush-reclaimed-timer-wait-time": 25,
"hold-reclaimed-time": 3600,
"max-reclaim-leases": 100,
"max-reclaim-time": 250,
"unwarned-reclaim-cycles": 5
},
// フレッツ網を模倣
"renew-timer": 7200,
"rebind-timer": 10800,
"preferred-lifetime": 12600,
"valid-lifetime": 14400,
"hooks-libraries": [
// DHCPリースの変動時にスクリプトを実行するフック
{
"library": "/usr/lib/kea/hooks/libdhcp_run_script.so",
"parameters": {
"name": "/usr/share/kea/scripts/pd-route.sh",
"sync": false
}
}
],
"subnet6": [],
"loggers": [
{
"name": "kea-dhcp6",
"output-options": [
{
"output": "kea-dhcp6.log"
}
],
"severity": "INFO",
"debuglevel": 0
}
]
}
}

ただし、HGWにプレフィックスを払い出す際には、払い出したプレフィックスに向けた経路も設定する必要がある(逆に、失効時は経路の削除が必要)。このためフック使い、払い出し・失効などのタイミングで以下のスクリプトを実行している。

/usr/share/kea/scripts/pd-route.sh
#!/bin/sh
PROTO=static
remove_ipv6_routes() {
if [ "$LEASE6_TYPE" = "IA_PD" ]; then
ip -6 route delete "${LEASE6_ADDRESS}/${LEASE6_PREFIX_LEN}" proto "${PROTO}"
fi
}
on_commit() {
if [ "$LEASES6_SIZE" -gt 0 ]; then
if [ "$LEASES6_AT0_TYPE" = "IA_PD" ]; then
ip -6 route replace "${LEASES6_AT0_ADDRESS}/${LEASES6_AT0_PREFIX_LEN}" via "${QUERY6_REMOTE_ADDR}" dev "${QUERY6_IFACE_NAME}" proto "${PROTO}"
fi
fi
}
case "$1" in
"lease6_release" | "lease6_expire")
remove_ipv6_routes
;;
"leases6_committed")
on_commit
;;
esac

また、KeaのHTTP APIも設定しておく。

/etc/kea/kea-ctrl-agent.conf
{
"Control-agent": {
"http-host": "127.0.0.1",
"http-port": 8000,
"control-sockets": {
"dhcp4": {
"socket-type": "unix",
"socket-name": "kea4-ctrl-socket"
},
"dhcp6": {
"socket-type": "unix",
"socket-name": "kea6-ctrl-socket"
},
"d2": {
"socket-type": "unix",
"socket-name": "kea-ddns-ctrl-socket"
}
},
"hooks-libraries": [],
"loggers": [
{
"name": "kea-ctrl-agent",
"output-options": [
{
"output": "kea-ctrl-agent.log"
}
],
"severity": "INFO",
"debuglevel": 0
}
]
}
}
Terminal window
systemctl enable --now kea-dhcp6
systemctl enable --now kea-ctrl-agent

ひかり電話関連情報の受信

さて、第二部のように、ただDHCPv6クライアントを動かしただけでは、ひかり電話の関連情報は降ってこない。種明かしをしてしまうと、DHCPv6リクエストで

ことで、これらの情報を受信できる。実はこのことは、NTTの技術参考資料2ほのめかされている。

リンク先資料の「フレッツ 光ネクスト編」を見てみると、2.4.2.1.5「DHCPv6オプション」には以下のように書かれている。

Option Request に指定可能なオプション番号は、”17”,”22”,”23”,”24”,”31”です

そして2.4.2.1.6「端末設定自動化のためのネットワーク関連情報通知機能」にはこうある。

端末への設定自動化のためのネットワーク関連情報通知機能を取得する場合は、端末からの要求メッセージにて、Option Requestオプション(6)にオプション17を含め、Vendor Classオプション(16)に必要な情報(端末情報)を設定し、網に送信します

何やらわかりにくい書き方だが、これはおそらくひかり電話関連情報の取得を指している。そしてVendor Classオプションの値については、

ネットワーク関連情報を設定する端末のハードウェアアドレス(MACアドレス)を設定

あり、HGWのMACアドレスを含めればよいことがわかる3

これを踏まえ、DHCPv6クライアントの設定を追記する。

/etc/systemd/network/05-wan0.network
# ...
[DHCPv6]
DUIDType=link-layer
IAID=0
SendHostname=no
UseCaptivePortal=no
UseDNR=no
# これがなければsystemd-networkdはOption 24を無視するため注意
UseDomains=yes
RequestOptions=17 22 23 24 31
# 一見文字列しかダメそうだが、実はhexも送れる
VendorClass=\xde\xad\xbe\xef\x00\x05
# ...

必要なHGWのMACアドレス(ここではde:ad:be:ef:00:05仮定)は、ルータで

Terminal window
> ip -6 neigh show dev hgw | awk '{print $3}'
(中略)
de:ad:be:ef:00:05

などとするか、HGWのWeb設定画面4から確認する。

設定を再読み込みし、D-Busを叩くと、

Terminal window
networkctl reload
busctl -j call org.freedesktop.network1 /org/freedesktop/network1 org.freedesktop.network1.Manager Describe | jq '.data[0] | fromjson | .Interfaces[] | select(.DHCPv6Client) | .DHCPv6Client.VendorSpecificOptions'

ひかり電話関連情報が得られるはずだ。

[
{
"EnterpriseId": 210,
"SubOptionCode": 202,
"SubOptionData": "XXXXXXXXXX"
},
{
"EnterpriseId": 210,
"SubOptionCode": 204,
"SubOptionData": "XXXXXXXXXX"
},
{
"EnterpriseId": 210,
"SubOptionCode": 201,
"SubOptionData": "deadbeef0005"
},
{
"EnterpriseId": 210,
"SubOptionCode": 210,
"SubOptionData": "XXXXXXXXXX"
}
]

お、それぞれの設定(201, 202, 204, 210)の意味は以下のサイトなどを見るとわかる。

rabbit51 @ https://blog.goo.ne.jp/rabbit5151 blog list
rabbit51 @ https://blog.goo.ne.jp/rabbit5151 blog list
blog.goo.ne.jp

ひかり電話関連情報の配布

最後に、DHCPv6のリースを検知して、Keaの設定を更新する処理を書く。

Keaの設定はKeaを再起動すると吹き飛んでしまうため、DHCPv6のリース時だけでなく、Kea自体の再起動の際にも更新処理が走るようにしている。

スクリプト(長いので注意)
/usr/local/bin/networkd-kea6-relay
#!/usr/bin/env python
from asyncio import Event, create_task, run
from ipaddress import IPv6Address
from json import loads
from time import sleep
from typing import TypedDict
from requests import exceptions, post
from sdbus import (
DbusInterfaceCommonAsync,
dbus_method_async,
dbus_signal_async,
sd_bus_open_system,
set_default_bus,
)
SYSTEMD_IF = "org.freedesktop.systemd1"
SYSTEMD_NODE_PATH = "/org/freedesktop/systemd1"
SYSTEMD_MGR_IF = "org.freedesktop.systemd1.Manager"
NETWORKD_IF = "org.freedesktop.network1"
NETWORKD_NODE_PATH = "/org/freedesktop/network1"
NETWORKD_MGR_IF = "org.freedesktop.network1.Manager"
NETWORKD_LINK_IF = "org.freedesktop.network1.Link"
NETWORKD_DHCP6C_IF = "org.freedesktop.network1.DHCPv6Client"
DHCP6C_ETH = "wan0"
HGW_ETH = "hgw"
KEA_API = "http://127.0.0.1:8000"
KEA_SERVICE = "kea-dhcp6.service"
KEA_MAX_RETRIES = 10
KEA_RETRY_DELAY = 5
class Networkd(DbusInterfaceCommonAsync, interface_name=NETWORKD_MGR_IF):
def __init__(self) -> None:
super().__init__()
self._proxify(NETWORKD_IF, NETWORKD_NODE_PATH)
@dbus_method_async("s", "io")
async def get_link_by_name(self, name: str) -> tuple[int, str]:
raise NotImplementedError
class NetworkdLink(DbusInterfaceCommonAsync, interface_name=NETWORKD_LINK_IF):
def __init__(self, object_path: str) -> None:
super().__init__()
self._proxify(NETWORKD_IF, object_path)
@dbus_method_async(result_signature="s")
async def describe(self) -> str:
raise NotImplementedError
class Systemd(DbusInterfaceCommonAsync, interface_name=SYSTEMD_MGR_IF):
def __init__(self, object_path: str) -> None:
super().__init__()
self._proxify(SYSTEMD_IF, SYSTEMD_NODE_PATH)
@dbus_signal_async(signal_signature="uoss")
def job_removed(self) -> tuple[int, str, str, str]:
raise NotImplementedError
class VendorSpecificOption(TypedDict):
EnterpriseId: int
SubOptionCode: int
SubOptionData: str
class Dhcp6(TypedDict):
prefix: IPv6Address
dns: str
ntp: str
sip: str
domains: str
opts: list[VendorSpecificOption]
class Dhcp6State:
def __init__(self):
self.state: Dhcp6 | None = None
def get(self) -> Dhcp6 | None:
return self.state
async def set(self, linkpath: str):
dhcp6 = loads(await NetworkdLink(linkpath).describe())
dns, ntp, sip = [
", ".join([IPv6Address(bytes(e["Address"])).compressed for e in dhcp6[k]])
for k in ["DNS", "NTP", "SIP"]
]
self.state = {
"prefix": IPv6Address(bytes(dhcp6["DHCPv6Client"]["Prefixes"][0]["Prefix"])),
"dns": dns,
"ntp": ntp,
"sip": sip,
"domains": ", ".join([f"{e['Domain']}." for e in dhcp6["SearchDomains"]]),
"opts": dhcp6["DHCPv6Client"]["VendorSpecificOptions"],
}
return self.state
def update_kea_config(dhcp6: Dhcp6):
def cmd(cmd: str, config: dict[str, str] | None):
return post(
KEA_API,
json={"command": cmd, "service": ["dhcp6"], "arguments": {"Dhcp6": config}},
timeout=3,
)
def get_config():
for i in range(KEA_MAX_RETRIES):
try:
resp = cmd("config-get", None)
json = resp.json()[0]
if resp.status_code == 200 and json["result"] == 0:
return resp.json()[0]["arguments"]["Dhcp6"]
else:
raise exceptions.ConnectionError
except exceptions.ConnectionError:
print(
f"Kea API not reachable, retrying in {KEA_RETRY_DELAY} seconds... ({i + 1}/{KEA_MAX_RETRIES})"
)
sleep(KEA_RETRY_DELAY)
print("Kea API is not reachable after maximum retries.")
return None
config = get_config()
if config is None:
return
config["subnet6"] = [
{
"id": 1,
"subnet": f"{dhcp6['prefix'] + (0xEF << 64)}/64",
"interface": HGW_ETH,
"pools": [{"pool": f"{dhcp6['prefix'] + (0xEF << 64)}/64"}],
"pd-pools": [
{
"prefix": f"{dhcp6['prefix'] + (0xF0 << 64)}",
"prefix-len": 60,
"delegated-len": 60,
}
],
}
]
config["option-def"] = [
{
"name": f"ntt-{opt['SubOptionCode']}",
"code": opt["SubOptionCode"],
"type": "binary",
"space": f"vendor-{opt['EnterpriseId']}",
}
for opt in dhcp6["opts"]
]
config["option-data"] = [
{
"code": opt["SubOptionCode"],
"always-send": True,
"space": f"vendor-{opt['EnterpriseId']}",
"csv-format": False,
"data": opt["SubOptionData"],
}
for opt in dhcp6["opts"]
] + [
{
"name": "vendor-opts",
"data": f"{dhcp6['opts'][0]['EnterpriseId']}",
},
{"name": "sip-server-addr", "data": dhcp6["sip"]},
{"name": "dns-servers", "data": dhcp6["dns"]},
{"name": "domain-search", "data": dhcp6["domains"]},
{"name": "sntp-servers", "data": dhcp6["ntp"]},
]
test_config = cmd("config-test", config).json()[0]
print(f"Kea: {test_config['text']}")
if test_config["result"] != 0:
return
set_config = cmd("config-set", config).json()[0]
if set_config["result"] != 0:
print(f"Error: {set_config['text']}")
return
print(f"Kea: {set_config['text']} Hash: {set_config['arguments']['hash']}")
async def observe_dhcp6_lease(dhcp6_state: Dhcp6State):
async for linkpath, (
iface,
props,
_,
) in DbusInterfaceCommonAsync.properties_changed.catch_anywhere(NETWORKD_IF):
if iface != NETWORKD_DHCP6C_IF or props["State"][1] != "bound":
continue
print(f"DHCPv6 lease acquired on {linkpath}, updating Kea configuration...")
dhcp6 = await dhcp6_state.set(linkpath)
update_kea_config(dhcp6)
async def observe_kea_restart(dhcp6_state: Dhcp6State):
async for _, (
_,
_,
service,
signal,
) in Systemd.job_removed.catch_anywhere(SYSTEMD_IF):
if service != KEA_SERVICE or signal != "done":
continue
print(f"{KEA_SERVICE} restarted, updating Kea configuration...")
dhcp6 = dhcp6_state.get()
if dhcp6 is None:
print("No DHCPv6 state available, fetching from Networkd...")
_, linkpath = await Networkd().get_link_by_name(DHCP6C_ETH)
print(f"Link path: {linkpath}")
dhcp6 = await dhcp6_state.set(linkpath)
update_kea_config(dhcp6)
async def main():
set_default_bus(sd_bus_open_system())
print("Start DHCPv6 proxying...")
dhcp6_state_manager = Dhcp6State()
task1 = create_task(observe_dhcp6_lease(dhcp6_state_manager))
task2 = create_task(observe_kea_restart(dhcp6_state_manager))
await task1
await task2
_ = await Event().wait()
if __name__ == "__main__":
try:
run(main())
except KeyboardInterrupt:
print("\nExiting...")
except Exception as e:
print(f"Error: {e}")

スクリプト内にも書いたが、執筆時点でstable版のsystemd(v257)では、IPv6のSIPサーバアドレスを取得できない。そのためv258のリリースを待つか、手動でキャプチャした内容をハードコードするほかない。

systemd v258のリリースにより、私のパッチmainlineに入り、無事必要なすべての情報をD-Busから取得できるようになった。めでしめでたし 🎉

あとはこのスクリプトをsystemdサービスで実行するよう設定すれば完成だ。

/etc/systemd/system/networkd-kea6-relay.service
[Unit]
Description=Networkd DHCPv6 lease info relay for Kea
After=network.target sys-subsystem-net-devices-wan0.device
BindsTo=sys-subsystem-net-devices-wan0.device
[Service]
ExecStart=/usr/local/bin/networkd-kea6-relay
Restart=always
RestartSec=5
Environment=PYTHONUNBUFFERED=1
[Install]
WantedBy=multi-user.target
Terminal window
systemctl enable --now networkd-kea6-relay

動作検証

IPv6 IPoE

VDSL回線(< 100Mbps)であるため、速度には期待していないものの、常時90Mbps程度は出ている。HGWを上流に置いていた際には時折パケットが詰まるような感じがあったが、それがなくなった。

主要なCDNへのpingは数ms程度で通っており、遅延も問題ないと思われる。HGWを上流に置く場合より、心なしか(1-2ms程度)速くなっているかもしれない。

IPv4 over IPv6

速度・遅延についてはIPv6 IPoEと同様で、オーバーヘッドはない。tc使ったポート振り分けも問題なく動作しており、(いわゆる)ニチバンベンチも快適だ。

HGW・ひかり電話

HGWのWeb設定画面から、DHCPの取得や、ファームウェアの更新チェックが行えることを確認した。また、下流のアナログ電話機からの発着信(ナンバーディスプレイあり)も問題なく行えた。

ハードウェアの状態

ルータのCPU使用率はめったに10%を越えない。電力設定を控えめにしていることもあり、温度もそこまで高くはない(耳を近づけると、ファンが回るかすかな音は聞こえる)。

今後の課題

まとめ

みなさんも夏休みの自由研究として、ルータを自作しよう 🤩


  1. DHCPv6ではデフォルトゲートウェイを通知できないため

  2. 「IP通信網サービスのインタフェース」第三分冊(第45版)、2025年8月1日最終閲覧

  3. 実際、HGWが送っているDHCPv6リクエストをキャプチャしてみると、この通りになっている。いうより本来は順序が逆で、先にキャプチャをしたことで技術資料の意味が明らかになったのだが、その点は置いておく

  4. 手元のHGW(RX-600KI)の場合、「情報 > 現在の状態」から確認できた