CUBE SUGAR CONTAINER

技術系のこと書きます。

Network Namespace と Libreswan で IPsec VPN を試す (Route-based / VTI デバイス)

今回は Linux の Network Namespace で作ったネットワーク上で Libreswan を動かして IPsec VPN を試してみる。 なお、Libreswan には、いくつかの動作モードがある。 今回は、その中でも Route-based VPN using VTI と呼ばれる動作モードを利用する。 これは VTI (Virtual Tunnel Interface) というインターフェイスを作成して、そこに明示的な経路を指定することで一致するパケットを暗号化するというもの。

使った環境は次のとおり。

$ cat /etc/lsb-release 
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=22.04
DISTRIB_CODENAME=jammy
DISTRIB_DESCRIPTION="Ubuntu 22.04.3 LTS"
$ uname -srm
Linux 5.15.0-87-generic x86_64
$ ipsec --version
Linux Libreswan 3.32 (netkey) on 5.15.0-87-generic

もくじ

下準備

まずは必要なパッケージをインストールする。

$ sudo apt-get install libreswan iproute2 tcpdump

デフォルトで有効になる systemd のサービスは利用しないため停止しておく。

$ sudo systemctl stop ipsec
$ sudo systemctl disable ipsec

ネットワークを作成する

次に Network Namespace を使ってネットワークを作成する。 作成するネットワークの論理構成を以下に示す。

ネットワーク構成 (論理)

上記で router1203.0.113.1router2203.0.113.2 の間に IPsec VPN のトンネルを作る。 router1router2dummy0 インターフェイスは検証を簡単にするためにホストの代わりとして作っている。 なお、後述する VTI デバイスのインターフェイスに直接同じ IP アドレスを振っても構わない。 今回は、一応インターフェイスくらいは分けておくかという気持ちで dummy インターフェイスを作成している。

まずは必要な Network Namespace を作成する。

$ sudo ip netns add router1
$ sudo ip netns add router2

Network Namespace 同士をつなぐ Virtual Ethernet デバイスのインターフェイスを作成する。

$ sudo ip link add rt1-veth0 type veth peer name rt2-veth0

作成したインターフェイスを Network Namespace に所属させる。

$ sudo ip link set rt1-veth0 netns router1
$ sudo ip link set rt2-veth0 netns router2

インターフェイスの状態を UP に設定する。

$ sudo ip netns exec router1 ip link set rt1-veth0 up
$ sudo ip netns exec router2 ip link set rt2-veth0 up

それぞれのインターフェイスに IP アドレスを付与する。

$ sudo ip netns exec router1 ip address add 203.0.113.1/24 dev rt1-veth0
$ sudo ip netns exec router2 ip address add 203.0.113.2/24 dev rt2-veth0

それぞれの Network Namespace に IPsec トンネルを作成する上で必要なカーネルパラメータを設定する。

$ sudo ip netns exec router1 sysctl net.ipv4.ip_forward=1
$ sudo ip netns exec router1 sysctl net.ipv4.conf.default.send_redirects=0
$ sudo ip netns exec router1 sysctl net.ipv4.conf.default.accept_redirects=0
$ sudo ip netns exec router1 sysctl net.ipv4.conf.default.rp_filter=0
$ sudo ip netns exec router2 sysctl net.ipv4.ip_forward=1
$ sudo ip netns exec router2 sysctl net.ipv4.conf.default.send_redirects=0
$ sudo ip netns exec router2 sysctl net.ipv4.conf.default.accept_redirects=0
$ sudo ip netns exec router2 sysctl net.ipv4.conf.default.rp_filter=0

それぞれの Network Namespace に dummy インターフェイスを追加して IP アドレスを付与する。

$ sudo ip netns exec router1 ip link add dummy0 type dummy
$ sudo ip netns exec router1 ip link set dummy0 up
$ sudo ip netns exec router1 ip address add 192.0.2.1/24 dev dummy0
$ sudo ip netns exec router2 ip link add dummy0 type dummy
$ sudo ip netns exec router2 ip link set dummy0 up
$ sudo ip netns exec router2 ip address add 198.51.100.1/24 dev dummy0

以上で、先ほど示したネットワークの論理構成が完成した。

ひとまず router1203.0.113.1router22023.0.113.2 の間で疎通があることを確認する。

$ sudo ip netns exec router1 ping -c 3 203.0.113.2 -I 203.0.113.1
PING 203.0.113.2 (203.0.113.2) from 203.0.113.1 : 56(84) bytes of data.
64 bytes from 203.0.113.2: icmp_seq=1 ttl=64 time=0.078 ms
64 bytes from 203.0.113.2: icmp_seq=2 ttl=64 time=0.071 ms
64 bytes from 203.0.113.2: icmp_seq=3 ttl=64 time=0.075 ms

--- 203.0.113.2 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2116ms
rtt min/avg/max/mdev = 0.071/0.074/0.078/0.003 ms

また、現状では 192.0.2.1198.51.100.1 の間に疎通がないことも確認する。

$ sudo ip netns exec router1 ping -c 3 198.51.100.1 -I 192.0.2.1
PING 198.51.100.1 (198.51.100.1) from 192.0.2.1 : 56(84) bytes of data.

--- 198.51.100.1 ping statistics ---
3 packets transmitted, 0 received, 100% packet loss, time 2096ms

これは、経路の設定がされていないため。 もちろん経路さえ追加すれば疎通は取れるが、経路は VPN で暗号化されないため平文でやり取りされる。

$ sudo ip netns exec router1 ip route show
192.0.2.0/24 dev dummy0 proto kernel scope link src 192.0.2.1 
203.0.113.0/24 dev rt1-veth0 proto kernel scope link src 203.0.113.1 
$ sudo ip netns exec router2 ip route show
198.51.100.0/24 dev dummy0 proto kernel scope link src 198.51.100.1 
203.0.113.0/24 dev rt2-veth0 proto kernel scope link src 203.0.113.2 

IPsec VPN を構成する

ここからは Libreswan を使って IPsec VPN を構成していく。

はじめに NSS database を初期化する。 初期化しておかないと後述する Pluto デーモンを起動できない。

$ sudo ipsec initnss
Initializing NSS database

次に、それぞれの Network Namespace 上で動作する Libreswan の設定ファイルなどを置くためのディレクトリを用意する。

$ mkdir -p /var/tmp/router1
$ mkdir -p /var/tmp/router2

そして、それぞれのディレクトリに IPsec の事前共有鍵 (Pre-Shared Key; PSK) を用意する。

$ cat << 'EOF' > /var/tmp/router1/ipsec.secrets
203.0.113.1 203.0.113.2 : PSK "DeadBeef"
EOF
$ cat << 'EOF' > /var/tmp/router2/ipsec.secrets 
203.0.113.2 203.0.113.1 : PSK "DeadBeef"
EOF

同様に Libreswan の設定ファイルも用意する。 ikev2=yes を設定することで明示的に IKEv2 を有効にしている。

$ cat << 'EOF' > /var/tmp/router1/ipsec.conf
conn %default
    authby=secret
    auto=add
    ikev2=yes
conn myvpn
    left=203.0.113.1
    leftsubnet=192.0.2.0/24
    right=203.0.113.2
    rightsubnet=198.51.100.0/24
    mark=42/0xffffffff
    vti-interface=ipsec0
    vti-routing=yes
    vti-shared=no
EOF
$ cat << 'EOF' > /var/tmp/router2/ipsec.conf
conn %default
    authby=secret
    auto=add
    ikev2=yes
conn myvpn
    left=203.0.113.2
    leftsubnet=198.51.100.0/24
    right=203.0.113.1
    rightsubnet=192.0.2.0/24
    mark=42/0xffffffff
    vti-interface=ipsec0
    vti-routing=yes
    vti-shared=no
EOF

設定ファイルにフォーマット的な問題がないかは次のようにして確認できる。 何も出力がなければフォーマット的な問題がないと分かる。 ただし、設定内容の妥当性までは検証してくれないので過度な期待はしない方が良い。

$ /usr/libexec/ipsec/addconn --config /var/tmp/router1/ipsec.conf --checkconfig
$ /usr/libexec/ipsec/addconn --config /var/tmp/router2/ipsec.conf --checkconfig

次に、それぞれの Network Namespace 上で ipsec verify コマンドを実行して、すべて [OK] になっていることを確認する。 [OK] 以外の表示があると、何か動作に不都合のある設定がされていることが分かる。

$ sudo ip netns exec router1 ipsec verify
Verifying installed system and configuration files

Version check and ipsec on-path                     [OK]
Libreswan 3.32 (netkey) on 5.15.0-87-generic
Checking for IPsec support in kernel                [OK]
 NETKEY: Testing XFRM related proc values
         ICMP default/send_redirects                [OK]
         ICMP default/accept_redirects              [OK]
         XFRM larval drop                           [OK]
Pluto ipsec.conf syntax                             [OK]
Checking rp_filter                                  [OK]
Checking that pluto is running                      [OK]
 Pluto listening for IKE on udp 500                  [OK]
 Pluto listening for IKE/NAT-T on udp 4500         [OK]
 Pluto ipsec.secret syntax                          [OK]
Checking 'ip' command                                [OK]
Checking 'iptables' command                          [OK]
Checking 'prelink' command does not interfere with FIPS  [OK]
Checking for obsolete ipsec.conf options              [OK]
$ sudo ip netns exec router2 ipsec verify
Verifying installed system and configuration files

Version check and ipsec on-path                     [OK]
Libreswan 3.32 (netkey) on 5.15.0-87-generic
Checking for IPsec support in kernel                [OK]
 NETKEY: Testing XFRM related proc values
         ICMP default/send_redirects                [OK]
         ICMP default/accept_redirects              [OK]
         XFRM larval drop                           [OK]
Pluto ipsec.conf syntax                             [OK]
Checking rp_filter                                  [OK]
Checking that pluto is running                      [OK]
 Pluto listening for IKE on udp 500                  [OK]
 Pluto listening for IKE/NAT-T on udp 4500         [OK]
 Pluto ipsec.secret syntax                          [OK]
Checking 'ip' command                                [OK]
Checking 'iptables' command                          [OK]
Checking 'prelink' command does not interfere with FIPS  [OK]
Checking for obsolete ipsec.conf options              [OK]

以上で準備ができたので Libreswan の Pluto デーモンを起動する。 オプションで動作ディレクトリや設定ファイルに先ほど作成したものを指定する。 また、デバッグメッセージをターミナルに出したいので --nofork--stderrlog をつけて実行する。 こうすればデーモンとして動作せず、ログを標準エラー出力に出せる。 まずは router1 の方から。

$ sudo ip netns exec router1 ipsec pluto \
  --nofork \
  --stderrlog \
  --rundir /var/tmp/router1 \
  --config /var/tmp/router1/ipsec.conf \
  --secretsfile /var/tmp/router1/ipsec.secrets

ログからエラーなどが生じていないことを確認する。

次に、新しくターミナルを別に開いて router2 の Pluto デーモンを起動する。

$ sudo ip netns exec router2 ipsec pluto \
  --nofork \
  --stderrlog \
  --rundir /var/tmp/router2 \
  --config /var/tmp/router2/ipsec.conf \
  --secretsfile /var/tmp/router2/ipsec.secrets

デーモンを起動できたら ipsec auto コマンドを使って IPsec VPN のコネクションを開始する。

$ sudo ip netns exec router1 ipsec auto \
  --config /var/tmp/router1/ipsec.conf \
  --ctlsocket /var/tmp/router1/pluto.ctl \
  --start myvpn

コマンドを実行すると IPsec VPN のセッションが確立される。 確立されたセッションは ipsec show コマンドで確認できる。

$ sudo ip netns exec router1 ipsec show
192.0.2.0/24 <=> 198.51.100.0/24 using reqid 16393
$ sudo ip netns exec router2 ipsec show
198.51.100.0/24 <=> 192.0.2.0/24 using reqid 16389

セッションが確立されると自動的に router1router2ipsec0 という名前で VTI が作成される。

$ sudo ip netns exec router1 ip address show ipsec0
5: ipsec0@NONE: <POINTOPOINT,NOARP,UP,LOWER_UP> mtu 1480 qdisc noqueue state UNKNOWN group default qlen 1000
    link/ipip 203.0.113.1 peer 203.0.113.2
    inet6 fe80::200:5efe:cb00:7101/64 scope link 
       valid_lft forever preferred_lft forever
$ sudo ip netns exec router2 ip address show ipsec0
5: ipsec0@NONE: <POINTOPOINT,NOARP,UP,LOWER_UP> mtu 1480 qdisc noqueue state UNKNOWN group default qlen 1000
    link/ipip 203.0.113.2 peer 203.0.113.1
    inet6 fe80::200:5efe:cb00:7102/64 scope link 
       valid_lft forever preferred_lft forever

そして、宛先が VTI の経路が対向のサブネット (rightsubet) から自動的に設定される。 ようするに経路にマッチして VTI にフォワードされたパケットが自動的に暗号化されるということ。

$ sudo ip netns exec router1 ip route show
192.0.2.0/24 dev dummy0 proto kernel scope link src 192.0.2.1 
198.51.100.0/24 dev ipsec0 scope link 
203.0.113.0/24 dev rt1-veth0 proto kernel scope link src 203.0.113.1
$ sudo ip netns exec router2 ip route show
192.0.2.0/24 dev ipsec0 scope link 
198.51.100.0/24 dev dummy0 proto kernel scope link src 198.51.100.1 
203.0.113.0/24 dev rt2-veth0 proto kernel scope link src 203.0.113.2

ちなみに ipsec.conf で VTI 関連の設定をしない場合には作成や経路の設定は自動的にはされない。 その場合は、次のように手動で設定することもできる。

$ sudo ip netns exec router1 ip tunnel add ipsec0 mode vti local 203.0.113.1 remote 203.0.113.2 key 42
$ sudo ip netns exec router1 ip link set ipsec0 up
$ sudo ip netns exec router1 ip route add 198.51.100.0/24 dev ipsec0
$ sudo ip netns exec router2 ip tunnel add ipsec0 mode vti local 203.0.113.2 remote 203.0.113.1 key 42
$ sudo ip netns exec router2 ip link set ipsec0 up
$ sudo ip netns exec router2 ip route add 192.0.2.0/24 dev ipsec0

動作を確認する

さて、ここまでで正常に IPsec VPN が確立されたようなので動作を確認しよう。

まずは、最初の方で確認した dummy0 インターフェイス同士の IP アドレスで疎通を確認しておく。

$ sudo ip netns exec router1 ping 198.51.100.1 -I 192.0.2.1
PING 198.51.100.1 (198.51.100.1) from 192.0.2.1 : 56(84) bytes of data.
64 bytes from 198.51.100.1: icmp_seq=1 ttl=64 time=0.188 ms
64 bytes from 198.51.100.1: icmp_seq=2 ttl=64 time=0.266 ms
64 bytes from 198.51.100.1: icmp_seq=3 ttl=64 time=0.316 ms
...

今度は、ちゃんと ping に疎通があることが確認できる。

では、次にパケットをキャプチャしてみる。 まずは router1ipsec0 インターフェイスから。

$ sudo ip netns exec router1 tcpdump -tnl -i ipsec0
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on ipsec0, link-type RAW (Raw IP), snapshot length 262144 bytes
IP 192.0.2.1 > 198.51.100.1: ICMP echo request, id 10575, seq 29, length 64
IP 198.51.100.1 > 192.0.2.1: ICMP echo reply, id 10575, seq 29, length 64
IP 192.0.2.1 > 198.51.100.1: ICMP echo request, id 10575, seq 30, length 64
IP 198.51.100.1 > 192.0.2.1: ICMP echo reply, id 10575, seq 30, length 64
IP 192.0.2.1 > 198.51.100.1: ICMP echo request, id 10575, seq 31, length 64
IP 198.51.100.1 > 192.0.2.1: ICMP echo reply, id 10575, seq 31, length 64
...

上記から ipsec0 インターフェイスの時点では平文で内容が見えることが分かる。 これは IPsec VPN のトンネルの出入り口を見ているため。

では、続いて router1rt1-veth0 インターフェイスをキャプチャしよう。

$ sudo ip netns exec router1 tcpdump -tnl -i rt1-veth0
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on rt1-veth0, link-type EN10MB (Ethernet), snapshot length 262144 bytes
IP 203.0.113.1 > 203.0.113.2: ESP(spi=0x2ef3568a,seq=0x30), length 120
IP 203.0.113.2 > 203.0.113.1: ESP(spi=0xe12a2591,seq=0x30), length 120
IP 203.0.113.1 > 203.0.113.2: ESP(spi=0x2ef3568a,seq=0x31), length 120
IP 203.0.113.2 > 203.0.113.1: ESP(spi=0xe12a2591,seq=0x31), length 120
IP 203.0.113.1 > 203.0.113.2: ESP(spi=0x2ef3568a,seq=0x32), length 120
IP 203.0.113.2 > 203.0.113.1: ESP(spi=0xe12a2591,seq=0x32), length 120
...

すると、今度はパケットが ESP (Encapsulating Security Payload) になっており、中身が見えない。 これは IPsec VPN のトンネルで暗号化された部分を見ているため。

続いて IKE SA と Child SA を確立している部分の通信も確認してみよう。 一旦 ping は止めておく。 そして、ipsec auto コマンドを使って VPN のコネクションを一旦削除する。

$ sudo ip netns exec router1 ipsec auto \
  --config /var/tmp/router1/ipsec.conf \
  --ctlsocket /var/tmp/router1/pluto.ctl \
  --delete myvpn

続いて、次のようにして IPsec と IKE の通信が確認できるように tcpdump を実行する。

$ sudo ip netns exec router1 tcpdump -tnl -i rt1-veth0 esp or udp port 500 or udp port 4500 or tcp port 4500
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on rt1-veth0, link-type EN10MB (Ethernet), snapshot length 262144 bytes

準備ができたら IPsec VPN のコネクションを次のようにして張りなおす。

$ sudo ip netns exec router1 ipsec auto \
  --config /var/tmp/router1/ipsec.conf \
  --ctlsocket /var/tmp/router1/pluto.ctl \
  --add myvpn
$ sudo ip netns exec router1 ipsec auto \
  --config /var/tmp/router1/ipsec.conf \
  --ctlsocket /var/tmp/router1/pluto.ctl \
  --start myvpn

すると tcpdump のターミナルに次のような出力が得られる。

$ sudo ip netns exec router1 tcpdump -tnl -i rt1-veth0 esp or udp port 500 or udp port 4500 or tcp port 4500
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on rt1-veth0, link-type EN10MB (Ethernet), snapshot length 262144 bytes
IP 203.0.113.1.500 > 203.0.113.2.500: isakmp: parent_sa ikev2_init[I]
IP 203.0.113.2.500 > 203.0.113.1.500: isakmp: parent_sa ikev2_init[R]
IP 203.0.113.1.500 > 203.0.113.2.500: isakmp: child_sa  ikev2_auth[I]
IP 203.0.113.2.500 > 203.0.113.1.500: isakmp: child_sa  ikev2_auth[R]

isakmp: parent_sa となっているのが IKE SA を確立している部分だろう。 ikev2_init[I]ikev2_init[R][I][R] はイニシエータとレスポンダを表している。 そして isakmp: child_sa が Child SA を確立している部分のはず。 Child SA が確立されると IPsec VPN が確立されたことになる。

まとめ

今回は次の内容を実施した。

  • Network Namespace を使ってネットワークを作成する
  • ネットワークに Libreswan を使って IPsec VPN を確立する
  • IPsec に関する通信をパケットキャプチャする

参考

libreswan.org

Network Namespace と nftables で Destination NAT を試す

今回は Network Namespace で作ったネットワーク上で nftables 1 を使った Destination NAT を試してみる。 このエントリは、以下のエントリの続きとなっている。

blog.amedama.jp

上記は Source NAT だったのが、今回は Destination NAT になっている。 使っているネットワーク構成は変わらない。

Destination NAT は、よく「ポートを開ける」とか「ポートを開放する」といった表現をされるもの。 ようするに LAN 側のノードに対して、インターネットを起点とした通信を可能にする。

使った環境は次のとおり。

$ cat /etc/lsb-release 
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=22.04
DISTRIB_CODENAME=jammy
DISTRIB_DESCRIPTION="Ubuntu 22.04.3 LTS"
$ uname -srm
Linux 5.15.0-87-generic x86_64
$ nft --version
nftables v1.0.2 (Lester Gooch)

もくじ

下準備

あらかじめ必要なパッケージをインストールしておく。

$ sudo apt-get -y install nftables iproute2 tcpdump

nftables のサービスを稼働させる。

$ sudo systemctl start nftables
$ sudo systemctl enable nftables

nftables のカーネルモジュールがロードされていることを確認する。

$ lsmod | grep ^nf_tables
nf_tables             258048  0

Network Namespace でネットワークを作成する

作成するネットワークの論理構成を以下に示す。 構成は Source NAT で使ったものと変わらない。

ネットワーク構成 (論理)

203.0.113.0/24 をグローバル、192.0.2.0/24 をプライベートなセグメントに見立てている。 Destination NAT の例では、宛先 IP アドレスが 203.0.113.254 のパケットを lan の持っている 192.0.2.1 に書き換える。 ただし、書き換えるのはトランスポート層のプロトコルが TCP でポート番号が 54321 の場合だけに限る。

ここからは Network Namespace を使ってネットワークを作成していく。 といっても Source NAT の時とやることは変わらない。 まずは Network Namespace を作成する。

$ sudo ip netns add lan
$ sudo ip netns add router
$ sudo ip netns add wan

次に Network Namespace 同士をつなぐ veth インターフェイスを追加する。

$ sudo ip link add lan-veth0 type veth peer name gw-veth0
$ sudo ip link add wan-veth0 type veth peer name gw-veth1

作成したインターフェイスを Network Namespace に所属させる。

$ sudo ip link set lan-veth0 netns lan
$ sudo ip link set gw-veth0 netns router
$ sudo ip link set gw-veth1 netns router
$ sudo ip link set wan-veth0 netns wan

インターフェイスの状態を UP に設定する。

$ sudo ip netns exec lan ip link set lan-veth0 up
$ sudo ip netns exec router ip link set gw-veth0 up
$ sudo ip netns exec router ip link set gw-veth1 up
$ sudo ip netns exec wan ip link set wan-veth0 up

lan について、インターフェイスに IP アドレスを付与する。 また、デフォルトルートを設定する。

$ sudo ip netns exec lan ip address add 192.0.2.1/24 dev lan-veth0
$ sudo ip netns exec lan ip route add default via 192.0.2.254

router について、インターフェイスに IP アドレスを付与する。 また、ルータとして動作するようにカーネルパラメータの net.ipv4.ip_forward1 を設定する。

$ sudo ip netns exec router ip address add 192.0.2.254/24 dev gw-veth0
$ sudo ip netns exec router ip address add 203.0.113.254/24 dev gw-veth1
$ sudo ip netns exec router sysctl net.ipv4.ip_forward=1

最後に wan について、インターフェイスに IP アドレスを付与する。 また、デフォルトルートを設定する。

$ sudo ip netns exec wan ip address add 203.0.113.1/24 dev wan-veth0
$ sudo ip netns exec wan ip route add default via 203.0.113.254

nftables を設定する

ここからは nftables を使って Destination NAT を設定していく。

nftables の設定は nft list ruleset コマンドで確認できる。 初期状態では何も設定されていないため、結果は何も表示されない。

$ sudo ip netns exec router nft list ruleset

テーブルを追加する

まずは nft create table コマンドでテーブルを追加する。 テーブルはアドレスファミリとチェーンタイプを指定して追加する。

$ sudo ip netns exec router nft create table ip nat

上記ではアドレスファミリが ip でチェーンタイプが nat のテーブルを作っている。

テーブルを追加すると、次のように nft list ruleset の結果にテーブルが表示される。

$ sudo ip netns exec router nft list ruleset
table ip nat {
}

チェーンを追加する

続いて、処理のタイミングを表すチェーンをテーブルに追加する。 以下では先ほど作った ip nat のテーブルに PREROUTING という名前でチェーンを追加している。 カッコ内は追加するチェーンの種類と、処理されるタイミングを示している。

$ sudo ip netns exec router nft add chain ip nat PREROUTING { type nat hook prerouting priority dstnat\; }

次のように nft list ruleset の結果にチェーンが表示される。

$ sudo ip netns exec router nft list ruleset
table ip nat {
    chain PREROUTING {
        type nat hook prerouting priority dstnat; policy accept;
    }
}

ルールを追加する

最後に、具体的な処理を表すルールをチェーンに追加する。 以下では、先ほど作った ip nat テーブルの PREROUTING チェーンにルールを追加している。

$ sudo ip netns exec router nft add rule ip nat PREROUTING ip daddr 203.0.113.254 tcp dport 54321 dnat to 192.0.2.1

上記では、宛先 IP アドレスが 203.0.113.254 で TCP の宛先ポートが 54321 ポートを 192.0.2.1 に Destination NAT で転送するように指定している。

次のように nft list ruleset の結果にルールが表示される。

$ sudo ip netns exec router nft list ruleset
table ip nat {
    chain PREROUTING {
        type nat hook prerouting priority dstnat; policy accept;
        ip daddr 203.0.113.254 tcp dport 54321 dnat to 192.0.2.1
    }
}

動作を確認する

すべての設定が完了したので、ここからは動作を確認していこう。

まずは lan で nc(1) を使って TCP の 54321 ポートで通信を待ち受けておく。

$ sudo ip netns exec lan nc -lnv 54321

次に、別のターミナルを開いて、同様に wan 側でも TCP の 54321 ポートに関する通信をキャプチャできるようにしておく。

$ sudo ip netns exec wan tcpdump -tnl -i wan-veth0 "tcp and port 54321"

さらに別のターミナルを開いて lan 側でも TCP の 54321 ポートに関する通信をキャプチャできるようにする。

$ sudo ip netns exec lan tcpdump -tnl -i lan-veth0 "tcp and port 54321"

準備ができたら wan で nc(1) を使って router203.0.113.254 の 54321 ポートに接続する。

$ sudo ip netns exec wan nc 203.0.113.254 54321

その上で lan 側のキャプチャを確認してみよう。 すると 203.0.113.1 が送信元で 192.0.2.1 を宛先にしたパケットを起点に通信が発生していることが確認できる。

$ sudo ip netns exec lan tcpdump -tnl -i lan-veth0 "tcp and port 54321"
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on lan-veth0, link-type EN10MB (Ethernet), snapshot length 262144 bytes
IP 203.0.113.1.40154 > 192.0.2.1.54321: Flags [S], seq 2852554981, win 64240, options [mss 1460,sackOK,TS val 2906027089 ecr 0,nop,wscale 7], length 0
IP 192.0.2.1.54321 > 203.0.113.1.40154: Flags [S.], seq 3924803357, ack 2852554982, win 65160, options [mss 1460,sackOK,TS val 451299770 ecr 2906027089,nop,wscale 7], length 0
IP 203.0.113.1.40154 > 192.0.2.1.54321: Flags [.], ack 1, win 502, options [nop,nop,TS val 2906027089 ecr 451299770], length 0

上記は Destination NAT によって、宛先 IP アドレスが書き換えられたことによって生じている。

同様に wan 側のキャプチャも確認しよう。 すると、こちらでは 203.0.113.1 が送信元で 203.0.113.254 が宛先のパケットが起点になっていることが確認できる。

$ sudo ip netns exec wan tcpdump -tnl -i wan-veth0 "tcp and port 54321"
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on wan-veth0, link-type EN10MB (Ethernet), snapshot length 262144 bytes
IP 203.0.113.1.49200 > 203.0.113.254.54321: Flags [S], seq 3470368251, win 64240, options [mss 1460,sackOK,TS val 2906122441 ecr 0,nop,wscale 7], length 0
IP 203.0.113.254.54321 > 203.0.113.1.49200: Flags [S.], seq 2999511574, ack 3470368252, win 65160, options [mss 1460,sackOK,TS val 451395122 ecr 2906122441,nop,wscale 7], length 0
IP 203.0.113.1.49200 > 203.0.113.254.54321: Flags [.], ack 1, win 502, options [nop,nop,TS val 2906122441 ecr 451395122], length 0

つまり、宛先 IP アドレスが 203.0.113.254 で TCP の宛先ポート番号も 54321 番だったことから、先ほど設定した nftables のルールに合致した。 その結果として Destinatio NAT の処理が実行されて宛先 IP アドレスが 203.0.113.254 から 192.0.2.1 に書き換えられたというわけ。

まとめ

今回は Network Namespace を使って作成したネットワーク上で、nftables を使って Destination NAT を試してみた。

参考

manpages.ubuntu.com

Network Namespace と nftables で Source NAT を試す

今回は Network Namespace で作ったネットワーク上で nftables 1 を使った Source NAT (Network Address Translation) を試してみる。 nftables は、Linux で長らく使われてきた iptables 2 などのプログラムを置き換えることを志向したフレームワーク。 nftables と iptables は、どちらも Linux の netfilter 3 という仕組みを使って実装されたフロントエンドに相当する。

netfilter は Linux カーネルのプロトコルスタックにおいて、処理のタイミング毎に用意されたフックポイントにコールバックを登録できる仕組みのこと。 登録されるコールバックの中でパケットやフレームを処理することで、ファイアウォールや NAT といった機能を実現できる。

Source NAT を使うと、少数のグローバルアドレスを、多数のプライベートアドレスで共有できるようになる。 つまり、限られた資源である IPv4 のグローバルアドレスを節約することができる。 なお、アドレスを節約するためには NAT の一種である NAPT (Network Address Port Translation) である必要がある。

今回使った環境は次のとおり。

$ cat /etc/lsb-release 
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=22.04
DISTRIB_CODENAME=jammy
DISTRIB_DESCRIPTION="Ubuntu 22.04.3 LTS"
$ uname -srm
Linux 5.15.0-87-generic x86_64
$ nft --version
nftables v1.0.2 (Lester Gooch)

もくじ

下準備

あらかじめ必要なパッケージをインストールしておく。

$ sudo apt-get -y install nftables iproute2 tcpdump

nftables のサービスを稼働させる。

$ sudo systemctl start nftables
$ sudo systemctl enable nftables

nftables のカーネルモジュールがロードされていることを確認する。

$ lsmod | grep ^nf_tables
nf_tables             258048  0

Network Namespace でネットワークを作成する

まずは今回作成するネットワークの論理構成を以下に示す。

ネットワーク構成 (論理)

203.0.113.0/24 をグローバル、192.0.2.0/24 をプライベートなセグメントに見立てている。 Source NAT では、送信元 IP アドレスが 192.0.2.0/24 のパケットを router の持っている 203.0.113.254 に書き換える。

ここからは Network Namespace を使ってネットワークを作成していく。 まずは Network Namespace を作成する。

$ sudo ip netns add lan
$ sudo ip netns add router
$ sudo ip netns add wan

次に Network Namespace 同士をつなぐ veth インターフェイスを追加する。

$ sudo ip link add lan-veth0 type veth peer name gw-veth0
$ sudo ip link add wan-veth0 type veth peer name gw-veth1

作成したインターフェイスを Network Namespace に所属させる。

$ sudo ip link set lan-veth0 netns lan
$ sudo ip link set gw-veth0 netns router
$ sudo ip link set gw-veth1 netns router
$ sudo ip link set wan-veth0 netns wan

インターフェイスの状態を UP に設定する。

$ sudo ip netns exec lan ip link set lan-veth0 up
$ sudo ip netns exec router ip link set gw-veth0 up
$ sudo ip netns exec router ip link set gw-veth1 up
$ sudo ip netns exec wan ip link set wan-veth0 up

lan について、インターフェイスに IP アドレスを付与する。 また、デフォルトルートを設定する。

$ sudo ip netns exec lan ip address add 192.0.2.1/24 dev lan-veth0
$ sudo ip netns exec lan ip route add default via 192.0.2.254

router について、インターフェイスに IP アドレスを付与する。 また、ルータとして動作するようにカーネルパラメータの net.ipv4.ip_forward1 を設定する。

$ sudo ip netns exec router ip address add 192.0.2.254/24 dev gw-veth0
$ sudo ip netns exec router ip address add 203.0.113.254/24 dev gw-veth1
$ sudo ip netns exec router sysctl net.ipv4.ip_forward=1

最後に wan について、インターフェイスに IP アドレスを付与する。 また、デフォルトルートを設定する。

$ sudo ip netns exec wan ip address add 203.0.113.1/24 dev wan-veth0
$ sudo ip netns exec wan ip route add default via 203.0.113.254

nftables を設定する

ここからは nftables を使って Source NAT を設定していく。

nftables は基本的に nft(8) で設定する。 設定は nft list ruleset コマンドで確認できる。 初期状態では何も設定されていないため、結果は何も表示されない。

$ sudo ip netns exec router nft list ruleset

テーブルを追加する

まずは nft create table コマンドでテーブルを追加する。 テーブルはアドレスファミリとチェーンタイプを指定して追加する。

$ sudo ip netns exec router nft create table ip nat

上記ではアドレスファミリが ip でチェーンタイプが nat のテーブルを作っている。

テーブルを追加すると、次のように nft list ruleset の結果にテーブルが表示される。

$ sudo ip netns exec router nft list ruleset
table ip nat {
}

チェーンを追加する

続いて、処理のタイミングを表すチェーンをテーブルに追加する。 以下では先ほど作った ip nat のテーブルに POSTROUTING という名前でチェーンを追加している。 カッコ内は追加するチェーンの種類と、処理されるタイミングを示している。

$ sudo ip netns exec router nft add chain ip nat POSTROUTING { type nat hook postrouting priority srcnat\; }

チェーンを追加すると nft list ruleset の結果にチェーンが表示されるようになる。

$ sudo ip netns exec router nft list ruleset
table ip nat {
    chain POSTROUTING {
        type nat hook postrouting priority srcnat; policy accept;
    }
}

ルールを追加する

最後に、具体的な処理の内容を表すルールをチェーンに追加する。 以下では先ほど作った ip nat テーブルの POSTROUTING チェーンにルールを追加している。

$ sudo ip netns exec router nft add rule ip nat POSTROUTING oifname "gw-veth1" ip saddr 192.0.2.0/24 masquerade

上記は送信元 IP アドレスが 192.0.2.0/24 で出力先のインターフェイスが gw-veth1 のときに IP マスカレードを実行するという意味になる。

ルールが追加されると nft list ruleset の実行結果は次のようになる。

$ sudo ip netns exec router nft list ruleset
table ip nat {
    chain POSTROUTING {
        type nat hook postrouting priority srcnat; policy accept;
        oifname "gw-veth1" ip saddr 192.0.2.0/24 masquerade
    }
}

動作を確認する

必要な設定が全て終わったので、ここからは動作を確認しよう。

lan から wan の IP アドレスに宛てて ping を打ってみよう。

$ sudo ip netns exec lan ping 203.0.113.1
PING 203.0.113.1 (203.0.113.1) 56(84) bytes of data.
64 bytes from 203.0.113.1: icmp_seq=1 ttl=63 time=0.164 ms
64 bytes from 203.0.113.1: icmp_seq=2 ttl=63 time=0.110 ms
64 bytes from 203.0.113.1: icmp_seq=3 ttl=63 time=0.107 ms
...

別のターミナルを開いて、まずは lan のインターフェイスでパケットをキャプチャする。

$ sudo ip netns exec lan tcpdump -tnl -i lan-veth0 icmp
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on lan-veth0, link-type EN10MB (Ethernet), snapshot length 262144 bytes
IP 192.0.2.1 > 203.0.113.1: ICMP echo request, id 23567, seq 11, length 64
IP 203.0.113.1 > 192.0.2.1: ICMP echo reply, id 23567, seq 11, length 64
IP 192.0.2.1 > 203.0.113.1: ICMP echo request, id 23567, seq 12, length 64
IP 203.0.113.1 > 192.0.2.1: ICMP echo reply, id 23567, seq 12, length 64
IP 192.0.2.1 > 203.0.113.1: ICMP echo request, id 23567, seq 13, length 64
IP 203.0.113.1 > 192.0.2.1: ICMP echo reply, id 23567, seq 13, length 64

この段階では、送信元 IP アドレスは 192.0.2.1 のまま。

続いては wan のインターフェイスでパケットをキャプチャしよう。

$ sudo ip netns exec wan tcpdump -tnl -i wan-veth0 icmp
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on wan-veth0, link-type EN10MB (Ethernet), snapshot length 262144 bytes
IP 203.0.113.254 > 203.0.113.1: ICMP echo request, id 23567, seq 22, length 64
IP 203.0.113.1 > 203.0.113.254: ICMP echo reply, id 23567, seq 22, length 64
IP 203.0.113.254 > 203.0.113.1: ICMP echo request, id 23567, seq 23, length 64
IP 203.0.113.1 > 203.0.113.254: ICMP echo reply, id 23567, seq 23, length 64
IP 203.0.113.254 > 203.0.113.1: ICMP echo request, id 23567, seq 24, length 64
IP 203.0.113.1 > 203.0.113.254: ICMP echo reply, id 23567, seq 24, length 64

今度は送信元 IP アドレスが 203.0.113.254 になっている。 つまり、ちゃんと router の持っている IP アドレスに送信元が書き換えられていることが確認できる。

まとめ

今回は Network Namespace を使って作成したネットワーク上で、nftables を使って Source NAT を試してみた。

補足

iptables のルールを nftables のルールに変換する

nftables には、iptables など旧来のプログラムから移行するためのツールが用意されている。 たとえば iptables から移行する際には iptables-translate(8) が利用できる。 使用する際は iptables コマンドを iptables-translate コマンドに書き換えて実行する。 すると、nftables 版の設定に書き換えられた結果が表示される。

$ iptables-translate -t nat \
    -A POSTROUTING \
    -s 192.0.2.0/24 \
    -o gw-veth1 \
    -j MASQUERADE

ただし、利用する上で注意点がある。 iptables ではデフォルトで用意されているテーブルやチェーンがあった。 しかし nftables にはデフォルトで用意されるテーブルやチェーンが基本的にない。 そのため、必要に応じて自分で作る必要がある。

ネットワーク図の作成について

ネットワーク図は以下の nwdiag 4 の定義で作成した。

nwdiag {

  network {
    address = '192.0.2.0/24';
    lan[address = 'lan-veth0, 192.0.2.1'];
    router[address = 'gw-veth0, 192.0.2.254'];
  }

  network {
    address = '203.0.113.0/24';
    router[address = 'gw-veth1, 203.0.113.254'];
    wan[address = 'wan-veth0, 203.0.113.1'];
  }
}

参考

manpages.ubuntu.com

battery で Apple Silicon な MacBook のバッテリー充電に上限を設ける

リチウムイオン電池を使ったバッテリーは、一般的に残量が 0% や 100% の付近にあると劣化が進みやすい。 また、充電サイクルの回数が増える毎に、少しずつではあるが着実に劣化していく。 バッテリーが劣化すると、設計上の容量よりも電気を蓄える力が落ちて機器の稼働時間の短縮につながる。

最近の macOS には、バッテリーの寿命を延ばす目的で「バッテリー充電の最適化」という機能がある。 これは、機器の使われ方をデバイスが学習して、なるべく残量が 100% の状態を維持し続けないようにするもの。 この機能を有効にしていると、たまに充電が 80% 前後でしばらく止まることがある。 しかし、この機能を使ってもユーザが明示的に充電を止めることはできない。

そこで、今回は battery というアプリケーションを紹介する。 このアプリケーションを使うと Apple Silicon な MacBook で明示的にバッテリーの充電に上限を設定できる。

github.com

充電中に指定したバッテリーの残量まで到達すると、充電が自動で停止してそれ以上は増えない。 また、充電が停止している間も動作用の電源はケーブルから供給され続ける。 そのため、バッテリーの充電サイクルがむやみに増加することもない。 結果として、バッテリーの寿命を伸ばせる可能性がある。

もくじ

下準備

battery のインストール方法はいくつかある。 その中でも Homebrew からインストールするのが、おそらく一番カンタンだと思う。

$ brew install --cask battery

battery を実行する

battery は CUI と GUI の両方のアプリケーションがある。

GUI のアプリケーションは /Applications 以下にインストールされる。

$ open /Applications/battery.app

アプリケーションを起動すると、ツールバーにバッテリーのアイコンが現れる。 この状態で、自動でバッテリーの充電に 80% の上限が設定される。 設定は SMC (System Management Controller) に書き込まれるため、マシンを再起動しても設定はそのままになるようだ。

CUI に関しては battery というコマンドがインストールされる。 このコマンドで充電される上限を設定することもできる。 というより GUI は、この CUI の単なるラッパーのようだ。

たとえば battery status コマンドでバッテリーの充電状態を確認できたりもする。 以下は 80% に到達したことで充電が停止した際のログ。

$ battery status
10/15/23-17:19:32 - Battery at 80% (attached; remaining), smc charging disabled
10/15/23-17:19:32 - Your battery is currently being maintained at 80%

ちなみに上記のコマンドは、内部的に smc-command という実装を使っているようだ。 これは Mac の SMC に対して特定のキー・バリューを書き込むもの。

github.com

なお、SMC に書き込みをするとファンの回転数の制御などもできる。 そのため、上記のリポジトリには「使い方によってはマシンに不可逆なダメージを与えうるので何があっても自己責任だよ」という注意書きがある。

まとめ

今回は battery というアプリケーションを使って Apple Silicon な MacBook のバッテリー充電に上限を設定する方法を書いた。

Python: tarfile で tar ファイルを圧縮・展開する

Python の標準ライブラリには tarfile というモジュールがある。 このモジュールを使うと tar 形式で複数のファイルをまとめることができる。 また tarfile モジュールは gzip や bzip2 といった形式の圧縮・展開もサポートしている。 今回は、そんな tarfile モジュールで、利用する場面の多い tar 形式でまとめて gzip 形式で圧縮したファイル (tar.gz) を扱ってみる。

使った環境は次のとおり。

$ sw_vers
ProductName:        macOS
ProductVersion:     13.5
BuildVersion:       22G74
$ python -V
Python 3.10.12
$ tar --version
bsdtar 3.5.3 - libarchive 3.5.3 zlib/1.2.11 liblzma/5.0.5 bz2lib/1.0.8

もくじ

tar ファイルを展開する

まずは extraction.txt というテキストファイルが 1 つ入った tar.gz ファイルを用意する。 ファイルは tar(1) コマンドを使って作成して、名前は extraction.tar.gz にした。

$ echo "Hello, World" > extraction.txt
$ tar czvf extraction.tar.gz extraction.txt 
a extraction.txt
$ file extraction.tar.gz 
extraction.tar.gz: gzip compressed data, last modified: Tue Aug 20 03:03:04 2023, from Unix, original size modulo 2^32 2048

上記のファイルを Python の tarfile モジュールで展開してみる。 以下のサンプルコードでは、アーカイブに含まれるファイルのバイト列を読み取ってファイル名と共に出力している。 tarfile.open() 関数は、モードに "r" を指定すると自動的に圧縮アルゴリズムを読み取って展開の処理をしてくれる。

"""任意の圧縮方式を使った tar ファイルを展開するサンプルコード"""

import tarfile


def main():
    filepath = "extraction.tar.gz"
    with tarfile.open(filepath, mode="r") as tar:
        # アーカイブに含まれるファイルの情報を一覧で取得する
        members: list[tarfile.TarInfo] = tar.getmembers()
        for tar_info in members:
            # ファイル名または TarInfo を指定してファイルオブジェクトにアクセスできる
            with tar.extractfile(tar_info) as file_fp:
                # ファイルの内容を出力する
                print(f"{tar_info.name}: {file_fp.read()}")


if __name__ == "__main__":
    main()

上記を実行してみよう。

$ python extraction.py
extraction.txt: b'Hello, World\n'

ちゃんと含まれている extraction.txt に "Hello, World" という文字列に解釈できるバイト列が含まれることが確認できた。

tar ファイルを作成する

続いては tar ファイルを作成するサンプルコードを示す。 tarfile モジュールの API は、すでにファイルシステムに存在するファイルを扱うものが多い。 しかし、オンメモリのデータをアーカイブにすることも、もちろんできる。 以下では io.BytesIO を使ってファイルライクオブジェクトを作成し、それをアーカイブに含めている。 アーカイブは compression.tar.gz という名前で、アーカイブに含まれるファイル名は compression.txt にした。 なお、gzip 形式で圧縮する場合には tarfile.open() 関数の mode 引数に "w:gz" を指定する。

"""gzip 形式で圧縮した tar ファイルを作成するサンプルコード"""

import tarfile
import io


def main():
    # アーカイブに含めるファイルの内容を用意する
    file_buffer = io.BytesIO()
    file_buffer.write("Hello, World\n".encode("ascii"))
    file_buffer.seek(0)

    # "w:gz" モードでファイルを開くことで gzip で圧縮した tar ファイルになる
    filepath = "compression.tar.gz"
    with tarfile.open(filepath, mode="w:gz") as tar:
        # アーカイブに含めるファイルの名前を指定する
        tar_info = tarfile.TarInfo(name="compression.txt")
        # アーカイブに含めるファイルのサイズを指定する
        tar_info.size = len(file_buffer.getvalue())
        # アーカイブにファイルを追加する
        tar.addfile(tar_info, fileobj=file_buffer)


if __name__ == "__main__":
    main()

上記を実行してみよう。

$ python compression.py

すると compression.tar.gz という名前でアーカイブのファイルができる。

$ file compression.tar.gz 
compression.tar.gz: gzip compressed data, was "compression.tar", last modified: Tue Aug 20 10:50:49 2023, max compression, original size modulo 2^32 10240

tar(1) コマンドを使って展開してみよう。

$ tar zxvf compression.tar.gz              
x compression.txt

すると compression.txt というファイルができる。 内容を確認してみよう。

$ cat compression.txt        
Hello, World

ちゃんとサンプルコードで使った文字列が書き込まれている。

ちなみに、今回はアーカイブの直下にファイルを配置した。 もし、展開したときにファイルをディレクトリに入れたいときは TarInfo の引数 name にスラッシュを含めよう。

まとめ

今回は Python の tarfile モジュールを使って tar ファイルを圧縮・展開してみた。

参考

docs.python.org

GCP: Cloud Functions で Cloud Storage にオブジェクトを保存する

今回は Google Cloud の Cloud Functions で実行した処理の中で Cloud Storage にオブジェクトを保存する方法について。 Cloud Functions で実行した何らかの処理の成果物を保存する先として Cloud Storage を使うイメージになる。

操作は、基本的に Google Cloud SDK の gcloud コマンドから実施する。 なお、操作の対象となる Google Cloud API が有効化されていない場合には、別途有効化するかを確認する表示が出ることもある。

使った環境は次のとおり。

$ sw_vers              
ProductName:        macOS
ProductVersion:     13.5
BuildVersion:       22G74
$ gcloud version                            
Google Cloud SDK 442.0.0
bq 2.0.96
core 2023.08.04
gcloud-crc32c 1.0.0
gsutil 5.25

下準備

まずは Google Cloud SDK をインストールしておく。

$ brew install google-cloud-sdk

そして、利用したいアカウントにログインする。

$ gcloud auth login

必要に応じて、デフォルトで操作する対象のプロジェクトを指定する。

$ gcloud config set project <project-id>

Cloud Pub/Sub のトピックを作成する

Cloud Functions のトリガーとして Cloud Pub/Sub を使いたい。 そこで、まずはトピックを作成しておく。 ここでは example-pubsub-topic という名前にした。

$ gcloud pubsub topics create example-pubsub-topic

Cloud Storage のバケットを作成する

続いて Cloud Storage にオブジェクトを保存する先となるバケットを作成する。 バケットに付ける名前は、Cloud Storage のシステム全体でユニークな必要がある。

今回はユーザ名や日付などから、ユニークになりやすい組み合わせでサンプルとなるバケットの名前を作った。 別にこれでなくとも誰かが使っているバケットの名前と衝突しなければ何でも構わない。

$ UNIQUE_BUCKET_NAME=$(echo $(whoami)-example-bucket-$(date +%Y%m%d)) 
$ echo $UNIQUE_BUCKET_NAME  
amedama-example-bucket-20230806

バケットは gcloud storage buckets create コマンドで作成する。 基本的に Cloud Storage の操作をするときは、対象を URI で指定する。 その際、スキーマとして先頭に gs:// をつける必要がある。 また、--location オプションを指定しない場合はマルチリージョンで us にバケットが作成される。

$ gcloud storage buckets create \
    gs://${UNIQUE_BUCKET_NAME} \
    --location=asia

Cloud Functions を作成する

続いては Cloud Storage のバケットにオブジェクトを作成する Cloud Functions を作成する。

まずは必要なファイルを入れるディレクトリを用意する。 ここでは helloworld という名前で作成した。

$ mkdir helloworld

続いて Cloud Functions の本体となる処理を Python のモジュールとして作成する。 ポイントは google.cloud.storage パッケージを使って Cloud Storage にオブジェクトを作成しているところ。 バケットの名前は BUCKET_NAME という環境変数から取得するように作ってある。 なお、環境変数が指定されていない場合のエラーハンドリングは省略してある。

$ cat << 'EOF' > helloworld/main.py
import logging
import os
from datetime import datetime

from google.cloud import logging as gcloud_logging
from google.cloud import storage as gcloud_storage


gcloud_logging.Client().setup_logging()
LOG = logging.getLogger(__name__)


def main(event, context):
    gcs_client = gcloud_storage.Client()
    bucket_name = os.environ.get("BUCKET_NAME")
    bucket = gcs_client.bucket(bucket_name)

    now = datetime.now()
    file_name = now.strftime("%Y-%m-%d/%H:%M:%S.log")
    blob = bucket.blob(file_name)

    timestamp = now.strftime("%Y%m%d%H%M%S")
    file_data = f"executed: {timestamp}\n"
    blob.upload_from_string(file_data)

    LOG.info(
        "successfully saved to %s in %s",
        file_name,
        bucket_name
    )

EOF

オブジェクトには Cloud Functions が実行された時刻をタイムスタンプとして書き込んでいる。

動作に必要なパッケージは requirements.txt に記述する。

$ cat << 'EOF' > helloworld/requirements.txt
google-cloud-logging
google-cloud-storage
EOF

上記で次のようなファイル構成が作られる。

$ tree helloworld 
helloworld
├── main.py
└── requirements.txt

1 directory, 2 files

Cloud Functions をデプロイする

先ほどのディレクトリを元に Cloud Functions をデプロイするには gcloud functions deploy コマンドを使う。 このとき --set-env-vars オプションを使って環境変数を設定できる。 バケットの名前として BUCKET_NAME を忘れずに指定しよう。 また --trigger-topic を指定することで、指定した Cloud Pub/Sub のトピックにメッセージが到着した際に処理がトリガーされる。

$ gcloud functions deploy helloworld \
  --gen2 \
  --no-allow-unauthenticated \
  --runtime python310 \
  --memory 128Mi \
  --region asia-northeast1 \
  --trigger-topic example-pubsub-topic \
  --source helloworld \
  --entry-point main \
  --set-env-vars BUCKET_NAME=${UNIQUE_BUCKET_NAME}

うまくいけば gcloud functions list にデプロイした Cloud Functions が表示される。

$ gcloud functions list
NAME        STATE   TRIGGER                      REGION      ENVIRONMENT
helloworld  ACTIVE  topic: example-pubsub-topic  asia-northeast1  2nd gen

また gcloud functions logs read コマンドでエラーなどが出ていないかも確認しておこう。

$ gcloud functions logs read helloworld --region asia-northeast1
LEVEL  NAME        TIME_UTC                 LOG
I      helloworld  2023-08-06 14:56:35.116  Default STARTUP TCP probe succeeded after 1 attempt for container "helloworld-1" on port 8080.

正常にデプロイできたら Cloud Pub/Sub にメッセージを送信して Cloud Functions を起動しよう。

$ gcloud pubsub topics publish projects/$(gcloud config get-value project)/topics/example-pubsub-topic --message "-"

少ししたら、また gcloud functions logs read コマンドを実行してログを確認しよう。 うまくいけば次のようなログが出力される。

$ gcloud functions logs read helloworld --region asia-northeast1                                                         
LEVEL  NAME        TIME_UTC                 LOG
I      helloworld  2023-08-06 14:59:00.192  successfully saved to 2023-08-06/14:58:57.log in amedama-example-bucket-20230806
I      helloworld  2023-08-06 14:59:00.191
I      helloworld  2023-08-06 14:58:57.341
I      helloworld  2023-08-06 14:56:35.116  Default STARTUP TCP probe succeeded after 1 attempt for container "helloworld-1" on port 8080.

Cloud Storage の内容を確認する

Cloud Functions から作成された Cloud Storage のオブジェクトを確認しよう。

gcloud storage ls -r コマンドを使うことでバケットの中身を再帰的に表示できる。

$ gcloud storage ls -r gs://${UNIQUE_BUCKET_NAME}
gs://amedama-example-bucket-20230806/:

gs://amedama-example-bucket-20230806/2023-08-06/:
gs://amedama-example-bucket-20230806/2023-08-06/14:58:57.log

オブジェクトの内容は gcloud storage cat コマンドで確認できる。

$ gcloud storage cat gs://${UNIQUE_BUCKET_NAME}/2023-08-06/14:58:57.log 
executed: 20230806145857

どうやら意図した通りに作成されているようだ。

後片付けする

動作の確認が終わったら後片付けをしよう。

まずは Cloud Storage のオブジェクトとバケットを削除する。 gcloud storage rm -r を使うとバケットの中身とバケット自体が再帰的に削除できる。

$ gcloud storage rm -r gs://${UNIQUE_BUCKET_NAME}

続いて gcloud functions delete コマンドを使って Cloud Functions の関数を削除する。

$ gcloud functions delete helloworld --region asia-northeast1

そして gcloud pubsub topics delete コマンドを使って Cloud Pub/Sub のトピックを削除する。

$ gcloud pubsub topics delete example-pubsub-topic

また、Cloud Functions ではデプロイしたファイル群などを以下のような名前で Cloud Storage に保存している。

gcf-v2-sources-<project-id>-<region>
gcf-v2-uploads-<project-id>-<region>

これらも気になる場合には先ほどと同じ要領で削除しておこう。

$ gcloud storage buckets list | grep name
$ gcloud storage rm -r gs://<bucket-name> 

まとめ

今回は Cloud Functions を使って Cloud Storage にオブジェクトを保存する方法を確認した。

Python: lhafile で LZH フォーマットの圧縮ファイルを展開する

一昔前の日本では、ファイルの圧縮に LZH フォーマットがよく使われていた。 今ではほとんど使われることが無くなったとはいえ、しぶとく生き残っているシステムもある。 今回は、そうしたシステムからダウンロードしたファイルを Python の lhafile で展開する方法について書く。

使った環境は次のとおり。

$ sw_vers
ProductName:        macOS
ProductVersion:     13.5
BuildVersion:       22G74
$ uname -srm
Darwin 22.6.0 arm64
$ lha --version
LHa for UNIX version 1.14i-ac20050924p1 (arm-apple-darwin22.1.0)
$ python -V
Python 3.10.12
$ pip list | grep lhafile
lhafile         0.3.0

もくじ

下準備

LZH フォーマットの圧縮ファイルを用意するために lha をインストールしておく。

$ brew install lha

そして、本題となる lhafile もインストールしておく。

$ pip install lhafile

圧縮ファイルを用意する

まずは展開のサンプルに使う圧縮ファイルを用意したい。

まずは greet.txt という名前でテキストファイルを作成する。

$ echo "Hello, World" > greet.txt

上記のファイルを lha(1) で圧縮する。

$ lha a greet.lzh greet.txt 
greet.txt   - Frozen(100%)o

これで greet.lzh という名前で圧縮ファイルができた。

$ file greet.lzh                       
greet.lzh:   LHarc 1.x/ARX archive data  [lh0], 'U' OS

lhafile で展開する

ここからは Python の REPL を使って動作を見ていく。

$ python

まずは lhafile モジュールから LhaFile クラスをインポートする。

>>> from lhafile import LhaFile

先ほどのファイル名を指定してクラスをインスタンス化しよう。

>>> lha_file = LhaFile("greet.lzh")

圧縮ファイルに含まれるファイル名のリストは LhaFile#namelist() メソッドで得られる。

>>> lha_file.namelist()
['greet.txt']

圧縮ファイルに含まれるファイルの内容を展開したいときは LhaFile#read() メソッドを使う。 先ほど得られたファイル名を指定することで、展開したファイルのバイト列が得られる。

>>> lha_file.read("greet.txt")
b'Hello, World\n'

バイト列さえ得られれば、あとは好きに処理すれば良い。 たとえば、またファイルとして書き出してみよう。

>>> with open("/tmp/greet.txt", mode="wb") as fp:
...     fp.write(lha_file.read("greet.txt"))
... 
13

書き出したファイルの中身を確認すると、ちゃんと圧縮前の内容が得られる。

$ cat /tmp/greet.txt      
Hello, World

いじょう。

参考

github.com