LoginSignup
7
6

IIJmioでジェイムズくん流ケチケチ運用

Last updated at Posted at 2018-12-28

はじめに

私はiOS系デバイスでIIJmioを3回線、合計一ヶ月12GBの枠で使っております…が、時々、月末にクーポン不足(いわゆる「ギガ不足」)になります。
そこで、QoSを維持しつつ、ジェイムズくんもビックリのケチケチ運用をすることにしました。

  • トラヒックが100kbpsを上回ったら、クーポンをオンにする
  • トラヒックが200kbpsを下回ったら、クーポンをオフにする
  • 「クーポン非使用時は3日で366MB以上通信をすると規制」にひっかからないようにする

アプリのバックグラウンド通信などはクーポンを使わさせず、人間が操作しているときだけクーポンを使おう、という戦略です。

このためには、以下の問題を解決する必要があります。

  • IIJmio APIの使用量はメガバイト単位で帰ってくるので、byte per secの粒度で速度を求めることはできない
  • iOSのデバイスの通信量をiOSのアプリから調べるのは難しい

今回は通信量を調べていますが、スマホの画面が点灯しているのか消灯しているのかを調べても良いかもしれませんね、方法はちょっと思いつきませんが。

iOSの通信量を観測

オンデマンドVPNを常に張るよううにして、VPNのもう片方の端点で計測します。
IIJmio(というかMNO?)が計測しているトラヒックとは多少違うかもしれませんが、多少の再送は誤差でしょうからヨシとします。

OpenVPNをいれる (サーバサイド)

IPを常時話せるマシンにOpenVPNをいれて待機させます。
今回はiptablesを使う関係で、linuxでいきます。

udp port 1234に待機させるとすると、こんなかんじ。

dev tun0
lport 1234
ping 60
ping-restart 120
float
comp-lzo adaptive
push "comp-lzo adaptiveclient-connect "/usr/local/bin/connected-iphone.pl"
script-security 2

push "redirect-gateway def1"
push "dhcp-option DNS 192.168.100.1"

#以下お好みで…

iOSのOpenVPNクライアントは使える暗号アルゴリズムに制約があるので、注意しましょう。

client-connectスクリプトを書く

接続してきたクラアイントのIPアドレスから、それがWiFiなのかIIJmioの回線なのか判別してmongodbに記録するようにします。

環境変数でいろいろ貰えます。

iptablesで通信量を測定する

openvpnのstateファイルで見てもいいのですが、iptablesで調べれば十分な粒度がでます。

ルール作成

iptablesに以下のような計測のためだけのルールをいれます。

iptables -A INPUT -p udp -m udp -s IIJMIONET --dport 1234 -j count-ovpn-iphone-cell-dev
iptables -A OUTPUT -p udp -m udp -d IIJMIONET --sport 1234 -j count-ovpn-iphone-cell-dev
iptables -A count-ovpn-iphone-cell-dev -j RETURN

IIJMIONETの部分には、IIJmioで使うIPv4アドレスブロックを列挙します。
これは公表されていませんが、上記のclinet-connectで集めたりdigったりした限り、2019年の時点では以下で大丈夫っポイです。

49.239.64.0/18
163.49.128.0/17
202.214.0.0/16
202.221.0.0/16
202.232.0.0/16
202.32.0.0/16
203.180.0.0/16
210.128.0.0/16
210.130.0.0/16
210.138.0.0/16
210.148.0.0/16
210.149.0.0/16

集計

/sbin/iptables -nxvLで上記のルールを通過したパケットの量が出ます。
トンネルデバイスのtunも一緒に集計すると、OpenVPNの圧縮でどのくらいトクをしたか、あるいはヘッダの追加分でどのくらい損をしたかが分かるようになります。
httpsや既に圧縮されたデータ転送が多いご時世、そんなに得はしませんね。

集計はmunin pluginの形にしておき、muninでグラフを書くとともに後述の制御プログラムからも参照します。

OpenVPNをいれる (iOSクライアントサイド)

OpenVPN Client for iOSをいれる

‎「OpenVPN Connect」をApp Storeで

オンデマンドVPNを有効にする

mobileconfigのxmlをつくって、iOSに食べさせます。

iOS VPN on-demand profile with OpenVPN - The CodingMerc LLC - The CodingMerc LLC

iOSのバージョンアップとOpenVPNのバージョンアップで時々変わるので、気をつけましょう。
OpenVPNのサポートをみていると「Appleがまた仕様変更した」「なんとか対応しました」とかあります(苦笑)。

オンデマンドルールでは、自宅WiFi以外のときは常に繋がるようにします。
これでその辺にある暗号化なしWiFiに繋げても安心ですし、VPN経由で自宅NASの中の映像をみたりイロイロ遊べるようになります。

ただし、公衆WiFiの自動ログイン系アプリとの相性は良くないようです。キャプティブWiFiのログインまでVPNの中でやろうとして失敗してしまいます。

設定例xml (追記: 2020年10月)

生成されるXMLはこんな感じです。

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>HasRemovalPasscode</key>
	<false/>
	<key>PayloadContent</key>
	<array>
		<dict>
			<key>IPv4</key>
			<dict>
				<key>OverridePrimary</key>
				<integer>1</integer>
			</dict>
			<key>PayloadDescription</key>
			<string>VPN 設定を構成します</string>
			<key>PayloadDisplayName</key>
			<string>VPN</string>
			<key>PayloadIdentifier</key>
			<string>com.apple.vpn.managed.${UUID自分で生成してね}</string>
			<key>PayloadType</key>
			<string>com.apple.vpn.managed</string>
			<key>PayloadUUID</key>
			<string>${上記と同じUUID}</string>
			<key>PayloadVersion</key>
			<integer>1</integer>
			<key>Proxies</key>
			<dict>
				<key>HTTPEnable</key>
				<integer>0</integer>
				<key>HTTPSEnable</key>
				<integer>0</integer>
			</dict>
			<key>UserDefinedName</key>
			<string>${プロファイルに表示される名前 自分でいれてね}</string>
			<key>VPN</key>
			<dict>
				<key>AuthName</key>
				<string>DEFAULT</string>
				<key>AuthenticationMethod</key>
				<string>Certificate</string>
				<key>DisconnectOnIdle</key>
				<integer>0</integer>
				<key>ExcludeLocalNetworks</key>
				<integer>1</integer>
				<key>IncludeAllNetworks</key>
				<integer>1</integer>
				<key>OnDemandEnabled</key>
				<integer>1</integer>
				<key>OnDemandRules</key>
				<array>
					<dict>
						<key>Action</key>
						<string>Disconnect</string>
						<key>SSIDMatch</key>
						<array>
							<string>${自宅のWiFiのSSID(VPNを使わない接続)}</string>
						</array>
					</dict>
					<dict>
						<key>Action</key>
						<string>Connect</string>
						<key>InterfaceTypeMatch</key>
						<string>Cellular</string>
					</dict>
					<dict>
						<key>Action</key>
						<string>Connect</string>
						<key>InterfaceTypeMatch</key>
						<string>WiFi</string>
					</dict>
					<dict>
						<key>Action</key>
						<array>
							<dict>
								<key>InterfaceTypeMatch</key>
								<string>WiFi</string>
							</dict>
						</array>
					</dict>
					<dict>
						<key>Action</key>
						<array>
							<dict>
								<key>InterfaceTypeMatch</key>
								<string>Cellular</string>
							</dict>
						</array>
					</dict>
				</array>
				<key>PayloadCertificateUUID</key>
				<string>${適当なUUIDいれてね}</string>
				<key>RemoteAddress</key>
				<string>DEFAULT</string>
			</dict>
			<key>VPNSubType</key>
			<string>net.openvpn.connect.app</string>
			<key>VPNType</key>
			<string>VPN</string>
			<key>VendorConfig</key>
			<dict>
				<key>auth</key>
				<string>SHA256</string>
				<key>ca</key>
				<string>${ここに証明書貼り付ける --から始まる行から全部いれる。改行は\nに置換}</string>
				<key>cipher</key>
				<string>AES-256-CBC</string>
				<key>client</key>
				<string>NOARGS</string>
				<key>dev</key>
				<string>tun</string>
				<key>float</key>
				<string>NOARGS</string>
				<key>proto</key>
				<string>udp</string>
				<key>remote</key>
				<string>${相手IPアドレス半角空白UDPポート番号}</string>
				<key>reng-sec</key>
				<string>3600</string>
				<key>resolv-retry</key>
				<string>infinite</string>
				<key>tls-client</key>
				<string>NOARGS</string>
			</dict>
		</dict>
	</array>
	<key>PayloadDescription</key>
	<string>オンデマンド接続VPN</string>
	<key>PayloadDisplayName</key>
	<string>${「OpenVPN オンデマンド自動接続」みたいな名前適当に}</string>
	<key>PayloadIdentifier</key>
	<string>${com.example.hoge.fuga 形式の識別子。たいして意味はない}</string>
	<key>PayloadOrganization</key>
	<string>${example.com 形式の組織名。自分のドメインあればそれ。たいして意味はない}</string>
	<key>PayloadRemovalDisallowed</key>
	<false/>
	<key>PayloadType</key>
	<string>Configuration</string>
	<key>PayloadUUID</key>
	<string>${UUID適当にいれてね}</string>
	<key>PayloadVersion</key>
	<integer>1</integer>
</dict>
</plist>

ダミーのVPNプロファイルをいれる

上記の常時接続VPNだけだと、VPNがうまく使えないとき(WiFiでudpのVPNが規制されているとき1や、VPN設置端点のIPアドレスからのアクセスを規制しているサイトに接続したいとき、VPNを使っていると文句をいうNetflixを見るとき)に手がでなくなることがあります。
手動でVPNプロファイルを無効化できるように、ダミーのプロファイルを一つ追加しておきます。

経路を調整する

このままでは、このVPN端点そのものに接続するOpenVPN以外のトラヒックはVPNを通らないので、このVPN端点にはプライベートIPv4アドレスを付与して、iOSに対してVPNの中からのdnsクエリーにプライベートIPv4を応答するようなdnsサーバをたて、なるべく多くのトラヒックがOpenVPNを通ってくれるようにします。
そうしないと、自動速度調節で自分のサーバとの通信がカウントされなくなってしまいます。

dnsmasqをたてて、

interface=tun0
address=/dav.example.com/192.168.100.1

といった設定でOpenVPNからの問い合わせに少し偽装した返答を返すようにし、OpenVPNでは

push "dhcp-option DNS 192.168.100.1"

とします。

圧縮プロキシをたてる

これでトラヒックはすべて自前のマシンを通ることになりますので、ziproxyなどのL4より上で動く圧縮プロキシを建てると少しは幸せになれるかもしれません。
逆に誤動作で不幸になるかもしれません。

注記 (追記: 2020年2月)

今回はソースIPの変化により、使うOpenVPNのインスタンス自体が変わるようにしています。
これはtunXのデバイスのトラヒックを測る手間のためなのですが、tunXは同じままでもconnect-scriptで「今からwifiです」「今からIIJmioです」を検出できるわけですから、そこでカウンタをリセットするといった手をつかえば、OpenVPNによってwifiとセルラーでクライアントが移動してもシームレスにtunXを維持する(IPアドレスが変わらない、tcpセッションを継続する)ということも可能です。
今回はそこまでしてTCPを維持する必要がない、wifiとセルラーでOpenVPNの設定を変更したい(セルラーのときのみDHCPで圧縮プロキシを使用したい)との判断でこのようにやっていますが、connect-scriptの中でiptablesを叩いて透過プロキシへ回したり止めたりする、といった方法もありえます。

IIJmioを制御する

IIJmio APIトークンを取得する

IIJmioのAPIコンソールにいって、デベロッパー登録をして、自分で認証をするとaccess_tokenを貰えるようにします。
access_tokenは#で繋がったURLに出るので、javascriptの支援を得てサーバ側で回収します。

一度取得したトークンは三ヶ月使えます。
三ヶ月に一回人間がログインするのもバカらしいので、seleniumで更新するようにcronを仕込みます。
この場合、seleniumのdriver.current_urlにトークンが入るのでCGIなどでの回収は不要です。

IIJmioの状態を常にとれるようにする

iijmio APIの制限を回避しながら、クーポンの有効/無効、当日を含めた過去3日間のクーポンなし通信量を集計するプログラムをかいて、IIJmioに怒られない程度の頻度(数分に一回程度)で廻します。

IIJmio制御プログラムを書く

ここからが本題です。
ようやくここまできて、

  • iOS端末の秒単位、バイト単位の粒度でのトラヒック情報
  • iOS端末がWiFiなのかセルラー回線なのかどうかの判定
  • IIJmioが規制しそうかどうかの基準となる直近三日通信量
  • IIJmioのクーポンの状態

がとれます。
気をつけることは

  • 複数のSIMがあるときは個別に制御できるが、一回のAPI呼び出しでまとめて設定しないとAPI利用制限の枠を無駄に消費してしまう
  • クーポンなし直近三日通信量はIIJmioの資料では「数時間毎に更新」になっているので、すこし余裕をもたせる

ですね。

擬似コード。

while(1){
    #速度確認
    my $munin_new=read_munin();
    for my $dev (qw(ipad iphone)){
        for my $net (qw(wifi cell)){
            $bps->{$dev}->{$net}=(($munin_new->{"${dev}_${net}_dev_down"} - $munin_last->{"${dev}_${net}_dev_down"} +
                                   $munin_new->{"${dev}_${net}_dev_up"}   - $munin_last->{"${dev}_${net}_dev_up"} )
                                  /
                                  ($munin_new->{time}                     - $munin_last->{time}));
        }
    }
    $munin_last=$munin_new;

    #もし規制値に近いときはクーポンを使う
    $done=0;
    foreach my $d (qw(ipad iphone)){
        if(read_data("iijmio last3days $d") > $iij_coupon_limit){
            $forc_fast{$d}=true;
            setiij($d, true);
            $done++;
        }
    }
    if($done){
        setiij();
    }

    #人間がHomeBridge経由で高速・低速をリクエストしたときは3時間はそれに従う
    ##略

    #位置情報関係
    ##在宅?
    if($lastarea eq "out" and
       $area     eq "in"){
        #お家に帰ってきた瞬間
        setiij("all", false);
        $lastarea=$area;
        goto END;
    }
    if($lastarea eq "in" and
       $area     eq "out"){
        #出て行った瞬間
        setiij("all", true);
        $lastarea=$area;
        goto END;
    }
    $lastarea=$area;

    #自宅にいるときはクーポンは使わない
    $done=0;
    for my $dev (@DEVICES){
      if(ping($dev)){
        $done+=setiij($dev, false);
      }
    }
    if($done){
        setiij();
        goto END;
    }

    ##どこかでwifiつかっているときはクーポンつかわない
    $done=0;
    for my $dev (@DEVICES){
        if(read_data("openvpn $dev") eq "wifi"){
            $done+=setiij($dev, false);
            $lastnet->{$dev}="wifi";
        }
    }
    if($done){
        setiij();
        goto END;
    }

   #wifiから出てcellularに移動した瞬間はONにする
   $done=0;
   for my $dev (@DEVICES){
       if(read_data("openvpn $dev") eq "cellular" and
          $lastnet->{$dev} eq "wifi"){
            $done+=setiij($dev, true);
            $lastnet->{$dev}="cellular";
       }
       $lastnet->{$dev}=read_data(openvpn $dev);
    }
    if($done){
        setiij();
        goto END;
    }

   #速度による制御
   $done=0;
   for my $dev (@DEVICES){
       if(not $force_fast{$dev}){
         if((! getiijcoupon($dev)) and ($bps->{$dev}->{cell} > $bps_up_limit)){
             $done+=setiij($dev, true);
         }elsif((getiijcoupon($dev)) and ($bps->{$dev}->{cell} < $bps_down_limit)){
             $done+=setiij($dev, false);
          }
      }
    }
    if($done) setiij();

    #おわり
  END:
    sleep(適当な間隔);
}

function setiij(デバイス, クーポン使用のbool)
{
  if(デバイス){ 値を控えて置いて、前回の値と異なれば1を返す }
  if(not デバイス or デバイス == "all"){ iijにリクエスト(); }

  #高頻度にやると拒否されるので、前回のリクエスト時間を記録しておいて、最低でも60秒はあける。
}

webhookで「背面トントン加速」を作る (追記: 2020年10月)

iOS14で「背面トントンwebhook」ができるようになりました。
障害者向け機能設定から「背面タップ」に「ショートカット」のアクションを設定できます。
そのショートカットをwebhookとして特定のURLをアクセスさせ、その中でクーポンを操作するのです。

【iOS 14】「背面タップ」で生まれ変わるiPhone 背中をトントンするだけの便利機能 | アプリオ

  1. 自分のhttpsサーバの中にiijのクーポンオンをするcgiを作る
  2. 「ショートカット」アプリ内で新規ショートカット作成
  3. 最初のステップで「URL」に自分のWebサーバのURLを指定
  4. 次のステップで「ネットワーク」「URLの内容を取得」を設定
  5. セキュリティ対策として秘密のヘッダーを作り秘密の文字列を入れておく(cgi側でも検証する)
  6. 設定から「背面タップ」に作ったショートカットを割り当て

とすると「iPhoneの背面をトントンすると数秒後にはクーポンON」ができます。

上記のsleep()で回しているループとの競合がおきないように細工する必要はあります。
私はsleep()をmongodbのchangestreamに変更し、cgiではmongodbに書き込みをするように変更しました。

##変更通知を受け取るようにする

クーポンが変更されたら、変更した旨と理由をiOSで受け取るようにします。
pushoverが使えます。jsonをPOSTすると、iOSデバイスに通知がきます。

結果をみる

muninを入れる (サーバサイド)

httpdとあわせてdockerで立ち上げます。

munin-nodeを入れる (クライアントサイド)

munin-nodeで上記のスクリプトの出力を返すようにします。

muninで予測をさせる

munin-nodeのgraph_args_afterに、rrdtoolのPREDICTを含むRPNを入れ、一ヶ月周期で予測させます。

結果

iij.png

いい感じですね。

iijmioはSIMの枚数で契約が決まるため、「SIMの枚数は3枚、容量を12GBより減らして減額」という契約変更はできません。余剰をスマホの写真自動アップロードやNetflix, dアニメなどに廻すのがいいですね。
SIMを追加して家族のスマホを入れるという手もあります。

半年運用してみての感想

iPadを取り出してなにかし始めた瞬間、すこし遅いのが気になることはありますが、許容範囲内です。
VPNを使っているとネトフリにリージョン回避を疑われて拒否されるので、音泉、dアニメ、YouTube、観劇三昧、ottavaあたりで消費しておりますが、それでもクーポンはかなり貯まります。
セルラー通信容量が余り、さらにVPN接続で自宅NASに常時アクセスできるわけですから本体ストレージにデータを入れて持ち運ぶ必要もなくなるので、次の端末更新では端末のストレージサイズを減らすことも可能です。
64GBですら余裕!

コロナさんマジ優秀! (追記: 2020年10月)

コロナさんのおかげで在宅勤務なため、容量が余って捨てている状態です。
遠隔授業でギガ不足になって困っている学生さんに寄付したいくらい...

iij-year-202010.png

上記スクリプト、説明のためとはいえgotoなんて使っていますが「GoToキャンペーンは有害である」なんて形でgoto排斥論に再び脚光が当たるとはダイクストラもびっくり!

Future Work

  • 結構いいかげんな制御ですが、もうすこし賢いヒステリシス曲線をいれるとか、時系列解析、機械学習とかさせると、「人間が触りだすと5秒でクーポンON」とかできるかもしれません。
  • iOSがTOS, DiffServ, Traffic ClassなどのQoSのためのIPヘッダ部分を使ってくれるのかどうかは知りませんが、「優先してくれと主張するパケットが多いときはクーポンON」という手もあるかもしれません。
  • QoSをみなくても、httpsの接続先を調べて「twitterなら低速でいいよ」等とする手もあります。自分のトラヒックを自分で監視するだけですから、通信の秘密上は何ら問題はありません。
  • 脱獄すればいくらでも手はあるのでしょうが、それは流石にねぇ...

Future Work (追記: 2020年2月)

OpenVPNからWireGuardへ

OpenVPNの時代はそろそろ終わりかもしれません。
PPPを幾重にもラップしたり、TCP over TCPする連中よりはマシとはいえ、「倒す側がやがて倒される側になる」という無常な循環に則り、WireGuardにより倒されるでしょうね...

WireGuardにはendpoint移動時のhookはないようですが、

# wg show wg0 transfer
rn5LA6xiWin8fx9NwQbvzqxqere2x0HN1pOhrL6ek14=    4588504      58274052
# wg show wg0 endpoints
rn5LA6xiWin8fx9NwQbvzqxqere2x0HN1pOhrL6ek14=    10.1.2.3:1234

と簡単に転送オクテット数やエンドポイントのアドレスがとれますので、ここをうまくとればわざわざiptablesでゴチャゴチャしたりカウントする必要もなくなるでしょう。

とはいえOpenVPNでも特に困っていないのでわざわざ引っ越す理由も無いと言えば無いんですよね。
OpenVPNとwgではiOSでのCPU負荷(つまり電力消費)はそんなに違いがないそうですし...

IPsec IKEv2 (追記: 2021年4月)

最近のiOSはIKEv2が使えるそうです。
それならOpenVPNやWireGuardを使わなくても、サーバ側にstrongswanをいれてIKEv2でやってみるというのも手かもしれません。

strongSwanにiPhoneからオレオレ証明書認証でIPsec IKEv2を繋げる - Qiita
IKEv2 VPNサーバーにiPhone (iOS)でオンデマンド接続させる方法 - Qiita

ただ、今回のようにiPhoneとiPadという複数の機材をつかう場合はちょっと問題点があります。

  • おばかなNATに弱い

iOSのIKEv2 NAT-T状態でのUDPカプセリングは常に送信元も宛先も4500となります。
同じNATの背後に複数の端末があったとき、NAT環境によってはsrc IP/Port, dst IP/Portの4つ組が同じで混乱することがありえます。
つまり巷の喫茶店などの無線LAN NATがポート番号のランダマイズをしっかりやってくれるか(iptablesでいうところの -j MASQUERADE --random)という問題ですね。
iOSの現在の実装ではNAT-T時のポート番号に4500以外を使うように指定することはできませんし、現に巷にはおばかなNATはたくさんあります。

  • 通信量のカウントが面倒

上記の問題が解決できたとしても、複数のクライアントがある状態でサーバ側にて、外側(カプセリングされた後)のトラヒックを計測するには細工が必要です。
updownスクリプトの中で相手のアドレスとポート番号がとれますので、それを動的にiptables -Iで計測ルールに挿入してあげなければなりません。
あるいは、radiusをいれてaccountingするか、ソースに手をいれるといった方法もあるでしょう。

VPS側に複数のIPアドレスがあれば上記の問題は比較的簡単に解決できます。
IPv6ならいくらでも用意できるのですが、「巷のおばかなNAT箱」はIPv6を通してくれないので困ったものです(そうしたら、そもそもNATなんてもののお世話になることも減るのですが)。

### 2022年追記

「おばかなNAT」は諦めてIPsecにしました。
OpenVPNと比較して、OSのもつプロファイルなので安定して常時接続してくれる気がします。
バッテリーへの影響については特に変わらないようです。

strongSwanにiPhoneからオレオレ証明書認証でIPsec IKEv2を繋げる - Qiita

Elastic Stackで可視化

ログ的なものをmongodbに書いていますが、elasticsearchにも書き込めば、Kibanaの視覚化が面白いかもしれません。
「面白い」というだけで格段に何かがよくなるわけでもないですが、「視覚化」は正義です。

ギガプランについて (追記2021年8月)

IIJmioギガプランですが、ギガプラン専用APIは2021年夏時点では公開されていません。
しかし、実際APIを叩いてみればわかりますがギガプラン回線では従来 hdohdu となった部分が hdx になってクーポンOn/Offの制御できます。

ただし、ギガプランで複数回線の容量シェアを組んでいるとクーポンの残高はAPIからは確認できません。APIからは確認できなくてもwebからは確認できますので、あとは...

免責と注意

  • IIJmio API利用規約違反ではないとは思いますが、何か言われても責任はとれません。
  • あまり激しく制御すると、IIJmio約款・規約18条(利用停止)「当社が不適切と判断する態様においてIIJmioサービスを利用したとき」等の条項を適用されてしまう可能性はあります。限界を狙って激しく制御するのはやめましょう。
  • 常時VPNはそれなりに電池を喰います。スマホのバッテリー寿命は多少短くなります。
  1. OpenVPNを使う場合はsslhを使って443/tcpを酷使するという手があります。iodineみたいなVPN over DNSを53/udpに建てるといった手もありますけど...実用性は❓ですね。

7
6
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
6