おもむろに PPP と遊びました。

PPP とは

  • シリアル通信 (RS-232) のような 2 点間通信用のプロトコル
  • OSI 参照モデルでいうと第 2 層 (データリンク層)
  • RFC でいうと RFC 1661, RFC 1662 あたり
  • 接続が確立されれば、上位レイヤのプロトコル (主に IP) のパケットを peer の端末へ届けることができる
    • アプリケーションはシリアル通信の詳細を知らなくても peer への通信が行える

Linux でお手軽に PPP を体験する

ここでは 1 組の仮想端末をシリアルケーブルに見立てて pppd による通信を行ってみます。

Linux で PPP といえば pppd というソフトウェアを利用するのが一般的なようです。pppd は ppp_generic のようなカーネルドライバと協働して PPP 通信を行います。

接続の両端で pppd を動かして通信を行う様子は シリアルラインとpppでpoint-to-point接続 で見ることができます。ただ純粋に PPP 通信を行う (たとえば PPPoE ではない) 場合シリアルケーブルのような peer-to-peer で通信を行うための装置が必要になります。

シリアルケーブルなんか手元に無いという場合、仮想端末を利用して 1 組の疑似シリアルポートを作成するという手があります (ref. socatで仮想シリアルポートを作る)。このために参考先では socat というツールを使用しています。

ということで socat で作成した仮想端末の両端で pppd を走らせれば PPP 接続を体験することができるだろうということで Ubuntu 18.04 を走らせた VM 上でやってみました。

VM 作成に使用した Vagrantfile です。

# -*- mode: ruby -*-
# vi: set ft=ruby :

Vagrant.configure("2") do |config|
  config.vm.box = "bento/ubuntu-18.04"

  config.vm.provision "shell", inline: <<-SHELL
      apt-get update
      apt-get upgrade
      apt-get install -y ppp socat
  SHELL
end

VM 起動後、socat で仮想端末のペアを用意します。

# Create VM
$ vagrant up

# Get into VM
$ vagrant ssh

# Create a pair of pseudo terminal
$ socat -x -d -d pty,raw,echo=0 pty,raw,echo=0
2019/10/02 05:40:07 socat[1704] N PTY is /dev/pts/1
2019/10/02 05:40:07 socat[1704] N PTY is /dev/pts/2
2019/10/02 05:40:07 socat[1704] N starting data transfer loop with FDs [5,5] and [7,7]

別のログインセッションから pppd を実行します。各オプションは man ページに詳細が書かれています。データ圧縮や認証を省略してできるだけシンプルな挙動になるように実行しています。

$ sudo pppd -detach debug noauth local nobsdcomp nodeflate noaccomp nopcomp novj \
    default-asyncmap /dev/pts/1 192.168.15.3: 115200

反対側の端末でも pppd を実行します。

$ sudo pppd -detach debug noauth local defaultroute noipdefault \
    nobsdcomp nodeflate noaccomp nopcomp novj default-asyncmap /dev/pts/2 192.168.11.4: 115200

pppd の debug オプションを指定すると以下のように挙動がわかりやすくなります。

PPP ではまず LCP (Link Control Protocol) によりデータリンク接続設定の negotiation を行います。デバッグログにおいては LCP ConfReq, ConfAck と表されています。その後 NCP (Network Control Protocol) によりその上で動かすネットワーク層のプロトコル毎の設定について negotiation を行います。多くの場合ここでは IP が使われることが多くその場合 IPCP と呼ばれます。

using channel 47
Using interface ppp0
Connect: ppp0 <--> /dev/pts/2
sent [LCP ConfReq id=0x1 <magic 0x552f41bf>]
sent [LCP ConfReq id=0x1 <magic 0x552f41bf>]
rcvd [LCP ConfReq id=0x1 <magic 0x82dcfce2>]
sent [LCP ConfAck id=0x1 <magic 0x82dcfce2>]
sent [LCP ConfReq id=0x1 <magic 0x552f41bf>]
rcvd [LCP ConfAck id=0x1 <magic 0x552f41bf>]
sent [LCP EchoReq id=0x0 magic=0x552f41bf]
sent [IPCP ConfReq id=0x1 <addr 192.168.11.4>]
rcvd [LCP EchoReq id=0x0 magic=0x82dcfce2]
sent [LCP EchoRep id=0x0 magic=0x552f41bf]
rcvd [IPCP ConfReq id=0x1 <addr 192.168.15.3>]
sent [IPCP ConfAck id=0x1 <addr 192.168.15.3>]
rcvd [LCP EchoRep id=0x0 magic=0x82dcfce2]
rcvd [IPCP ConfAck id=0x1 <addr 192.168.11.4>]
not replacing default route to eth0 [10.0.2.2]
local  IP address 192.168.11.4
remote IP address 192.168.15.3
Script /etc/ppp/ip-up started (pid 16153)
Script /etc/ppp/ip-up finished (pid 16153), status = 0x0

# client terminated by Ctrl-C
Terminating on signal 2
Connect time 0.2 minutes.
Sent 0 bytes, received 0 bytes.
Script /etc/ppp/ip-down started (pid 16161)
sent [LCP TermReq id=0x2 "User request"]
rcvd [LCP TermAck id=0x2]
Connection terminated.
Script /etc/ppp/ip-down finished (pid 16161), status = 0x0

操作後、PPP インタフェースが新たに作成され、IP アドレスが割り当てられていることがわかります。 これにより peer へ通信したいホスト上の他アプリケーションは IP さえわかればいいということになります。

$ ip address
...
40: ppp0: <POINTOPOINT,MULTICAST,NOARP,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UNKNOWN group default qlen 3
    link/ppp 
    inet 192.168.15.3 peer 192.168.11.4/32 scope global ppp0
       valid_lft forever preferred_lft forever
41: ppp1: <POINTOPOINT,MULTICAST,NOARP,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UNKNOWN group default qlen 3
    link/ppp 
    inet 192.168.11.4 peer 192.168.15.3/32 scope global ppp1
       valid_lft forever preferred_lft forever

$ ping -c 3 192.168.15.3
PING 192.168.15.3 (192.168.15.3) 56(84) bytes of data.
64 bytes from 192.168.15.3: icmp_seq=1 ttl=64 time=0.035 ms
64 bytes from 192.168.15.3: icmp_seq=2 ttl=64 time=0.087 ms
64 bytes from 192.168.15.3: icmp_seq=3 ttl=64 time=0.105 ms

$ ping -c 3 192.168.11.4
PING 192.168.11.4 (192.168.11.4) 56(84) bytes of data.
64 bytes from 192.168.11.4: icmp_seq=1 ttl=64 time=0.036 ms
64 bytes from 192.168.11.4: icmp_seq=2 ttl=64 time=0.162 ms
64 bytes from 192.168.11.4: icmp_seq=3 ttl=64 time=0.047 ms

シリアルケーブル上で PPP 接続

上では仮想シリアルケーブルと呼べるようなものを作成して PPP を体験したわけですが、物理的にも体験したいということで USBシリアル変換ケーブル を購入しました。これを使用して手元の Linux PC と Raspberry Pi 3 Model B との間でシリアル通信を行ってみます。

Raspberry Pi (正確には Raspbian OS) を使用する場合、事前に raspi-config からシリアル通信を有効化する必要があります。 この際シリアルポートを介したログインも有効化できますが、いまは必要ないので無効のままにしておき、あとで有効化しようと思います。シリアルポート経由のログインは LAN のトラブル等で SSH でログインできない、という場合の最終手段になり得るので便利そうです。

また Raspberry Pi 3 Model B の場合、以下のような設定を /boot/config.txt に追加する必要がありそうです。これは Setting up a PPP server and client over GPIO/UART? を参考にした変更であり、自分の環境ではこれなしでは PPP 接続がうまく行えませんでした (Linux PC -> Raspberry Pi への通信がどうもうまく行かなかった)。どうやら Raspberry Pi 3 Model B や Zero に特有のハードウェア的な問題によるものであり、調べてみると他にもいくつか似たようなブログ記事が見つかりました。

$ cat /boot/config.txt
...
# for serial port
dtoverlay=pi3-disable-bt
init_uart_clock=64000000

Linux PC, Raspberry Pi それぞれで journalctl -k のログから対応するデバイスを特定します。今回は /dev/ttyUSB0/dev/ttyAMA0 でした。

pppd に渡すオプションはほぼ仮想端末を利用したときと同様です。変更したのは nocrtscts を追加したこと、speed を 9600 にしたことぐらいです。speed を変更したのは stty で端末の設定を見て調整した結果ですが特に必要ないかもしれません。

# from Linux
$ sudo pppd -detach debug noauth local defaultroute noipdefault nobsdcomp nodeflate \
    noaccomp nopcomp novj default-asyncmap nocrtscts /dev/ttyUSB0 192.168.11.4: 9600

# from Raspberry Pi
$ sudo pppd -detach debug noauth local nobsdcomp nodeflate noaccomp nopcomp novj \
    default-asyncmap nocrtscts /dev/ttyAMA0 192.168.15.3: 9600

シリアルケーブルを利用した通信については他に RaspberryPiとシリアル接続LinuxでUSBシリアルケーブルを使う を参考にしました。

pppd で ISP からグローバル IP をもらってくる

身近で PPP や PPPoE といった単語を聞く一例は自宅にインターネット環境を整えるときだと思います。このとき ISP から PPP でグローバル IP をもらって来るといった知識はあっても、実際に行う操作はルータに ISP から受け取ったログインユーザ、パスワードを入力するだけであり全く PPP を意識することはありません。

ということでここでは Raspberry Pi 上で動かした pppd を利用して ISP からグローバル IP をもらってくる様子を確認してみます。

まずは自宅ネットワーク構成を以下のように変更しました。普段モデムから直接接続されるのはルータなのですが、それを Raspberry Pi に置き換え、そこからシリアルケーブルを介して Linux PC を繋いでいます。上述の通り raspi-config でシリアルポート経由のログインを有効にしておけば、Linux PC からログインして Raspberry Pi 上で操作を行えます。

| Linux PC | -- serial cable -- | Raspberry Pi | -- lan cable -- | modem | -- (WAN)

Raspberry Pi 上で動かす pppd 設定は pppd - Arch Wiki を参考にしました。設定ファイルの内容はほぼ Arch Wiki のそれと同じなのですが、PPP 接続確立後インターネットへの通信がうまくいかなかったので replacedefaultroute オプションを後から足しています。

# /etc/ppp/peers/sample-connection

plugin rp-pppoe.so

# network interface
eth0
# login name
name <login_name>
usepeerdns
persist
defaultroute
replacedefaultroute
hide-password
noauth

ISP から受け取ったログイン情報を pap-secrets, chap-secrets に記載します。本来どちらかだけで良さそうな気がしますが、事前に認証方法がわからなければ両方に記載しても支障はありません。

# /etc/ppp/pap-secrets

...
# OUTBOUND connections

# Here you should add your userid password to connect to your providers via
# PAP. The * means that the password is to be used for ANY host you connect
# to. Thus you do not have to worry about the foreign machine name. Just
# replace password with your password.
# If you have different providers with different passwords then you better
# remove the following line.

#       *       password

<login_name> * <login_password>
# /etc/ppp/chap-secrets

# Secrets for authentication using CHAP
# client        server  secret                  IP addresses
<login_name> * <login_password> *

pppd により接続が行えることを確認します。

$ sudo pppd call sample-connection

$ sudo journalctl
...
Oct 02 11:48:41 raspberrypi pppd[2490]: Plugin rp-pppoe.so loaded.
Oct 02 11:48:41 raspberrypi pppd[2491]: pppd 2.4.7 started by root, uid 0
Oct 02 11:48:41 raspberrypi sudo[2485]: pam_unix(sudo:session): session closed for user root
Oct 02 11:48:42 raspberrypi pppd[2491]: PPP session is 9185
Oct 02 11:48:42 raspberrypi pppd[2491]: Connected to <mac> via interface eth0
Oct 02 11:48:42 raspberrypi pppd[2491]: Using interface ppp0
Oct 02 11:48:42 raspberrypi pppd[2491]: Connect: ppp0 <--> eth0
Oct 02 11:48:42 raspberrypi pppd[2491]: CHAP authentication succeeded
Oct 02 11:48:42 raspberrypi pppd[2491]: CHAP authentication succeeded
Oct 02 11:48:42 raspberrypi pppd[2491]: peer from calling number <mac> authorized
Oct 02 11:48:42 raspberrypi pppd[2491]: replacing old default route to eth0 [0.0.0.0]
Oct 02 11:48:42 raspberrypi pppd[2491]: local  IP address <ip>
Oct 02 11:48:42 raspberrypi pppd[2491]: remote IP address <ip>
Oct 02 11:48:42 raspberrypi pppd[2491]: primary   DNS address <ip>
Oct 02 11:48:42 raspberrypi pppd[2491]: secondary DNS address <ip>
Oct 02 11:48:42 raspberrypi dhcpcd[468]: eth0: deleted default route

接続完了後のネットワークインタフェース、ルーティングテーブルは以下のようになりました。 <ppp_ip> の部分が ISP からもらったグローバル IP です。

$ ip address
...
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP group default qlen 1000
    link/ether <mac> brd ff:ff:ff:ff:ff:ff
    inet <eth0_ip>/16 brd xxx.xxx.255.255 scope global noprefixroute eth0
       valid_lft forever preferred_lft forever
72: ppp0: <POINTOPOINT,MULTICAST,NOARP,UP,LOWER_UP> mtu 1454 qdisc pfifo_fast state UNKNOWN group default qlen 3
    link/ppp 
    inet <ppp_ip> peer <peer_ip>/32 scope global ppp0
       valid_lft forever preferred_lft forever

$ ip route
default dev ppp0 scope link 
<peer_ip> dev ppp0 proto kernel scope link src <ppp_ip>
xxx.xxx.0.0/16 dev eth0 scope link src <eth0_ip> metric 202 

PPPoE で接続確立する場合、はじめに相手の MAC アドレス知る必要があるのでは、と思っていたのですがどうやら最初の negotiation でお互いの MAC アドレスの交換を行うようです (ref. PPPoE - Wikipedia)。確かに使用するネットワークインタフェースの情報は pppd のオプションで渡しているので、そこへブロードキャストでパケットを送信すれば negotiation の開始はできそうです。

簡単な PPP プログラムの作成

ここまでの内容で PPP のことを多少知ることができたのでそれを生かして PPP を喋れる簡単なプログラム を作成しました。

できることはかなり限定的で「あるオプションを指定した pppd と会話して ppp ネットワークインタフェースの作成、IP のアサインができる」というだけです。細かいオプションの対応だったりエラーハンドリングだったりには対応していません。

# 仮想端末を利用した実行確認の様子

$ socat -x -d -d pty,raw,echo=0 pty,raw,echo=0

$ sudo pppd -detach debug noauth local nobsdcomp nodeflate noaccomp nopcomp novj \
    default-asyncmap /dev/pts/1 192.168.15.3: 115200

$ sudo ./ppp /dev/pts/2

PPP には RFC 1661 (The Point-toPoint Protocol)、RFC 1662 (PPP in HDLC-like Framing) という仕様が存在するので、これを見ながら実装を進めていけばいいです。

多分一番時間をかけたのは Linux カーネルに ppp 用のネットワークインタフェースを作成してもらうお願いをするところで、 ppp ドライバとのやり取りの方法がわからず苦労しました。一応資料としてはかなり内容の古そうな PPP Generic Driver and Channel Interface は見つけたのですが頼りなかったので pppd を strace で観察して関係ありそうなシステムコールを引っ張ってくるという形で無理矢理実装しました。そのときに お試しで作ったコード を参考までにリンクしておきます。基本的には /dev/ppp を開いて ioctl で操作するという感じなのですが、channel だったり unit だったりの概念がいまいちピンと来なかったり 2 つのファイルディスクリプタの使い分けがよくわからなかったりしてもやもやします。

もう一点 Linux カーネルとやり取りする部分として作成した ppp ネットワークインタフェースに IP アドレスをアサインするというものがあります。やはり strace で操作を確認して実装したのですがこちらは比較的わかりやすい手続きになっていました。お試しで作ったコード を見ていただくとそれがわかると思います。