Luckfox Pico Mを外出先から使う。3,750円・64MBのLinuxボードにWireCanalで公開URLをつけるまで

秋葉原で買った3,750円のLinuxボード Luckfox Pico M(RV1103・64MB・USB-Cのみ)を、Web公開用の受信ポートを追加せずにインターネット側から使えるようにするまで。SDカード書き込み、RNDIS経由のネットワーク、WireCanal無料プランでの公開、busybox initでの常駐とブラウザからのLED操作、再起動復帰(33秒)まで実測手順で解説します。

Luckfox Pico Mを外出先から使う。3,750円・64MBのLinuxボードにWireCanalで公開URLをつけるまで

こんにちは!
Qualitegプロダクト開発部です!

本日はLuckfox Pico Mという4000円弱の小さなLinuxボードをインターネット越しに操作できるようにしてみました。NAT越えの仕組みは無料プランのWireCanalを使いました。

先日、秋葉原の千石電商さんでLuckfox Pico M を税込3,750円で入手しました。

Raspberry Pi Picoとほぼ同じ大きさで、ピンヘッダ実装済み、CPUはRockchip RV1103(ARM Cortex-A7 1.2GHz)、メモリは64MB、起動はmicroSDカードからです。

Linuxが動くのに、この値段とこの大きさです。

ブレッドボードに挿してUSB-Cで給電したLuckfox Pico M。microSDカードを装着済み
今回の主役。ブレッドボードに挿してUSB-Cを繋いだLuckfox Pico M。挿さっているのが今回書き込んだmicroSD


これを外出先(インターネット越しに)から使えるようにしていきます。

ボードにはRJ45のコネクタ(いわゆるLAN端子)もWi-Fiもなく、あるのはUSB-Cのポートだけ。

それでもインターネット側から届く形にして、スマホから叩いてみます。
さて、できるのでしょうか?

先に結果を言うと、できました。

ボード側にWeb公開用の受信ポートを追加せず、WireCanalの無料プラン(0円)だけで、公開URLからLuckfox上のWebサーバーがそのまま見えるところまで行けました。

さらに今回は、ボードに挿したLED 1本を、公開ページのボタンでインターネット越しに点けたり消したりできるようにしました。

再起動しても、実測33秒で公開URLが自動で復活します。

ただし、Pico M自身にはWi-FiもRJ45もないため、今回はUSB接続したWindows PCをインターネットへのゲートウェイとして使います。
(今後はイーサつきLuckFoxでの実験記事も投稿予定です!)

なので、今回は、外出先からアクセスするときも、このPCは起動している必要があります。

この記事では、SDカードへのイメージ書き込みから、起動、ネットワーク、LEDの配線、WireCanalでの公開、再起動しても復活する常駐設定まで、実際にやった手順と実測値を最初から最後まで書きます。

対象はLuckfox Pico M(無印Luckfox Picoのピンヘッダ実装版)と公式のBuildrootイメージ、作業PCはWindows 11です。WireCanalは当社が開発・運営している自社サービスで、記事中の数値は2026年8月19日時点の実測値です。

Luckfox Pico Mとはどんなボードか

LuckfoxはRockchipのビジョン向けSoC(RV1103/RV1106)を載せた小型Linuxボードのシリーズで、Pico Mはその入門機です。今回の実機で確認した中身は次のとおりでした。

項目実機で確認した値
SoCRockchip RV1103(ARM Cortex-A7 1.2GHz・32bit・NEON/VFPv4)+RISC-V MCU+NPU 0.5TOPS
メモリ64MB DDR2。Linuxから見えるのは33MB(残りはNPU/ISPなどの予約)
OSBuildroot(公式イメージ 250607版・Linux 5.10.160・busybox init・python3 3.11同梱)
ストレージmicroSD(TF)カードから起動。ルートは6GBのext4
接続USB-C(給電兼用・RNDIS仮想NIC+ADB)。Ethernetはパッドのみ(RJ45なし)
価格3,750円(税込・千石電商・2026年8月)

Raspberry Piと違って、有線LANもWi-Fiも「そのままでは」使えません。

そこで今回は、PCとUSB-Cで繋いで仮想的なネットワークアダプタ(RNDIS)を生やして、PCとボードの間だけの小さなネットワークができるというしかけで使います。

MagJackとよばれるトランス入りのRJ45ジャック(いわゆるLAN端子)を自分で結線すればLANケーブルをつなぐことができますが少々手間がかかります。

また、そもそもRJ45ジャックつきのLuckFox製品もありますので直接LANケーブルでつなぎたい場合はそちらを使うこともできます(別記事として投稿予定です)

さて、今回はPCのインターネットをもらいにいく方法で実験しますので、外に出るにはPC側でひと工夫が要ります(手順3)。

もうひとつの特徴は、メモリの小ささです。

Linuxから見えるのは33MB。

ここにWireCanalの常駐エージェントを載せて実用になるのか、というのが今回の見どころのひとつです。

材料

もの備考
Luckfox Pico M千石電商で購入。ブレッドボードに挿して使いました
microSDカード数GBあれば十分。今回は手元の64GBを使用
USB-Cケーブルデータ通信対応のもの(充電専用ケーブルだとRNDISが出ません)
LEDと抵抗(330Ω前後)ブラウザから点灯・消灯するデモ用。ブレッドボード上で「物理ピン4 → 抵抗 → LED → 物理ピン3(GND)」とつなぎます(配線の節で説明)
Windows PCWindows 11 Pro(Hyper-Vの仮想化基盤が有効な状態)。SD書き込みとインターネット共有に使います。ボードとのファイル転送にPuTTY付属のpscp.exeも使います
WireCanalのアカウント無料プラン(0円・カード登録不要)で今回の手順はすべて試せます

WireCanalの無料プランは、canal(公開URLと転送先の組)が1本、公開アドレスはおまかせのランダムなサブドメインです。

作ったcanalはAgentを使っている限り消えず、Agentの最終接続から72時間で失効します。今回のように常駐で動かし続けるならURLは維持されます。

手順1: microSDカードにBuildrootイメージを書き込む

まず、必要なファイルとツールを集めます。入手先はすべて公式で、Luckfox Wikiの書き込み手順ページ(Flash Image)に、ツールとイメージへのリンクがまとまっています。ダウンロードするのは次の3つです。

ダウンロードするもの入手のしかた
OSイメージ手順ページの「Official Buildroot」リンクを開くとGoogle Driveが開きます。その中の「MicroSD」フォルダに機種別のzipが5つ並んでいるので、Luckfox_Pico_MicroSD_250607.zip(無印Luckfox Pico/Pico M用・約104MB)を選びます。Plus・Mini・ProMax・WebBee用と間違えないように
SocToolKit同じ手順ページのSocToolKitの項にある「Click here to download」から(今回はv1.98)。Windows用のSDカード書き込みツールです
SD Card FormatterSDアソシエーションの公式サイトから。SDカードの事前フォーマットに使います

OSイメージのzipを展開すると、次のファイルが出てきます。

env.img  idblock.img  uboot.img  boot.img  oem.img  userdata.img  rootfs.img
update.img  download.bin  sd_update.txt  tftp_update.txt

最初にSD Card Formatterでカードをフォーマットします。インストールして起動すると、カードのドライブが「カードの選択」に出るので、それが本当にSDカードか(容量・ドライブレター)を確認して、クイックフォーマットのまま「フォーマット」を押します。別のUSBドライブを挿している人は、ここの選び間違いにだけ注意してください。

SD Card FormatterでSDカード(D:)を選択した画面
カードのドライブと容量(今回は59.48GBのSDXC)を確認して、クイックフォーマットを実行
SD Card Formatterのフォーマット完了ダイアログ
数秒で完了。exFATでフォーマットされた。このあとSocToolKitでイメージを書き込む

次にSocToolKitです。zipを展開し、SocToolKit.exeを右クリックから「管理者として実行」で起動します(管理者で起動しないとSDカードを認識しないことがある、と公式手順にも書かれています)。最初にChip Selectionダイアログが出るので、RV1103を選んでOKを押します。

公式手順ではRV1106を選択するよう案内されていますが、今回は実機のSoCに合わせてRV1103を選択しました。この選択のまま、7つのイメージの書き込み完了(Create sdcard OK)からPico Mの実機起動まで確認しています。

SocToolKitのChip SelectionダイアログでRV1103を選択
起動直後のChip SelectionでRV1103を選ぶ。選ぶとウィンドウタイトルがv1.98(RV1103)になる

「SDTool」タブに切り替えると、Usb Disk欄に挿したカード(今回は59.5G)が出ています。モードは「SD Boot」を選びます。

SocToolKitのSDToolタブでSD Bootを選択した画面
SDToolタブでSD Bootを選ぶ。Usb Disk欄にカードが見えていることも確認

「Boot Files...」を押すとファイル選択ダイアログが開くので、さっき展開したフォルダから7つの.img(env.imgidblock.imguboot.imgboot.imgoem.imguserdata.imgrootfs.img)をまとめて選びます。update.imgは含めません。読み込まれると一覧に7ファイルが並びます。

SocToolKitに7つのイメージファイルを読み込んだ状態
bootファイル群7つが一覧に入った状態。update.imgは含めない

「Create SD」を押すと、書き込み先デバイスの最終確認ダイアログが出ます。デバイス名と容量をもう一度確認してYesを押すと書き込みが始まり、右側のログに各イメージの「Write disk from file (100%)...OK」が流れていきます。今回は1〜2分で「Create sdcard OK」まで到達しました。これで書き込みは完了です。

SocToolKitの書き込み完了ログ。Create sdcard OKと表示
右のログで7ファイルすべてOKになり、最後に「Create sdcard OK」。これでブート可能なSDカードができた

ここで面白いのは、同梱の sd_update.txt にSDカード上の配置がセクタ単位で書いてあることです。抜粋するとこうなっています。

#env.img 0x0:0x0 0x40:0x8000 0x40:0x8000
#idblock.img 0x40:0x8000 0x400:0x80000 0x170:0x2E000
#uboot.img 0x440:0x88000 0x400:0x80000 0x200:0x40000
#boot.img 0x840:0x108000 0x10000:0x2000000 0x1899:0x313200
#oem.img 0x10840:0x2108000 0x100000:0x20000000 0x17C78:0x2F8F000
#userdata.img 0x110840:0x22108000 0x80000:0x10000000 0x4C4A:0x989400
#rootfs.img 0x190840:0x32108000 0xC00000:0x180000000 0x4CD80:0x99B0000

各行は「名前 開始セクタ:開始バイト 領域サイズ 実体サイズ」です。つまりSocToolKitがやっているのは、Rockchip/Luckfox側で定められたこのオフセットへ各イメージをそのまま置くことです。

MBR/GPTのパーティションテーブルを自分で作る必要はありません。RockchipのブートROMはSDカードのセクタ64(0x8000バイト目)にある idblock.img を読みに来て起動が始まります(ブート時のパーティション構成はU-Boot環境側にも保持されています)。

Linux機で書きたい人は、この表のとおりに各イメージを所定オフセットへ直接書けば同じものが作れます。

ですので、実際にわたしもPythonつかって自分で各イメージを書き込んでみたところ、うまく起動しました。

ただ、その場合、書き込み先のディスクを間違えると別のドライブを消すので、そこだけは慎重にしましょう。

手順2: 起動して、SSHで入る

書き込んだmicroSDをPico Mに挿し、USB-CでPCに繋ぎます。これが給電になり、そのまま起動します。10秒ほどで、Windows側にネットワークアダプタ「Remote NDIS based Internet Sharing Device」が現れました。追加ドライバは不要でした(Windows 11標準のRNDISドライバで認識)。

ボード側のIPアドレスは 172.32.0.93 に固定されています。ちなみにLuckfox既定のこの 172.32.0.0/16 は、RFC 1918のプライベートアドレス範囲(172.16.0.0〜172.31.255.255)の外なので、同じ範囲への経路がある環境では競合に注意してください。

PC側のRNDISアダプタに、同じネットワークのIPアドレスを手で付けます。管理者権限のPowerShellで、アダプタのifIndexを確認してから設定します。

# RNDIS アダプタの ifIndex を確認(InterfaceDescription が Remote NDIS ... の行)
Get-NetAdapter | Where-Object InterfaceDescription -match 'NDIS'

# その ifIndex に固定 IP を付ける(例: ifIndex 41)。自動で付いた 169.254.x.x は先に外す
Remove-NetIPAddress -InterfaceIndex 41 -AddressFamily IPv4 -Confirm:$false -ErrorAction SilentlyContinue
New-NetIPAddress -InterfaceIndex 41 -IPAddress 172.32.0.100 -PrefixLength 16

これで ping 172.32.0.93 が返り、SSHで入れます。ユーザーは root、初期パスワードは luckfox です。

$ ssh root@172.32.0.93
# uname -a
Linux luckfox 5.10.160 #22 Wed Jun 11 18:43:57 CST 2025 armv7l GNU/Linux
# free -m
              total        used        free      shared  buff/cache   available
Mem:             33          10           4           0          19          20
# df -h | head -2
Filesystem                Size      Used Available Use% Mounted on
/dev/root                 5.8G     79.1M      5.6G   1% /

メモリの合計が33MB。ここに公開の仕組みを載せていきます。

手順3: ボードをインターネットに出す(PC経由)

ここが、Raspberry Piにはなかった一手間です。(しかもあまり本質的ではない・・・)

Pico MはPCとしか繋がっていないので、PCをルーター役にしてボードのパケットを外へ出します。Windowsには WinNAT という仕組みがあり、管理者権限のPowerShellで作れます。まず既存のNATがないか確認してください。

# 既存の NAT を確認(何か表示されたら注意。下の注記を参照)
Get-NetNat

WinNATの利用は、Microsoftの公式手順ではHyper-Vが有効になっていることが前提です(本記事のPCはWindows 11 Proで、WSL 2でも使われるHyper-Vの仮想化基盤が有効な状態で実測しています)。

また、WinNATは1台のPCにつき内部NATのサブネットプレフィックスを1つしかサポートしません。DockerやHyper-Vの仮想ネットワークなどが既にNATを作っているPCでは、次のコマンドが通らないことがあります。その場合、既存のNATは他の仕組みが使っているので削除せず、構成への影響を確認してから進めてください。

確認できたら、NATを作ってフォワーディングを有効にします。

New-NetNat -Name LuckfoxNAT -InternalIPInterfaceAddressPrefix 172.32.0.0/16
Set-NetIPInterface -InterfaceIndex 41 -Forwarding Enabled

ボード側には、デフォルトゲートウェイ(PCのアドレス)とDNSを教えます。

ip route add default via 172.32.0.100 dev usb0
printf 'nameserver 1.1.1.1\nnameserver 8.8.8.8\n' > /etc/resolv.conf

これで ping 1.1.1.1 が通り、名前も引けます。ただ、HTTPSはまだ通りません。ここで踏んだのが次の2つでした。

ひとつめは、証明書のルート集(CAバンドル)がイメージに入っていないこと。TLSの相手を検証できないので、Pythonでもエージェントでも certificate verify failed になります。Git for Windowsに同梱されているMozillaのバンドルをそのまま置きました。

# 実機側: 置き場所を作る
mkdir -p /etc/ssl/certs

# Windows 側(PowerShell): PC から実機へコピー(PuTTY 付属の pscp)
pscp -pw luckfox "C:\Program Files\Git\mingw64\etc\ssl\certs\ca-bundle.crt" root@172.32.0.93:/etc/ssl/certs/ca-certificates.crt

# 実機側: 権限と、python が既定で見に行くパスへのリンク
chmod 644 /etc/ssl/certs/ca-certificates.crt
ln -sf /etc/ssl/certs/ca-certificates.crt /etc/ssl/cert.pem

ふたつめは、時計です。起動直後の実機は1970年1月1日から始まっていて、この状態では証明書の有効期間の外なので、これもTLSが失敗します。ntpdは入っているのですが、経路ができる前に起動しているので、いったん手で合わせました。

# 現在の日時(UTC)に置き換えて実行してください
date -s '2026-08-18 08:45:51'

この2つを入れたところで、実機からHTTPSが通るようになりました。

# python3 -c 'import urllib.request;print(urllib.request.urlopen("https://download.wirecanal.com/latest/VERSION").read())'
b'0.18.3\n'

経路・DNS・時刻は再起動で消えるので、手順6の起動スクリプトにまとめて入れ直します。

配線: LEDを1本つなぐ

公開の前に、今回のデモの主役であるLEDをつないでおきます。使うのは物理ピン4(GPIO1_C7)と、その隣の物理ピン3(GND)です。

LuckFox Pico M のピン配置(公式ドキュメントより引用)

ピン配置は公式のGPIOドキュメントに図があります。

USB-Cコネクタを上にしてボードを見て、左の列の上から3番目がGND、4番目がGPIO1_C7です。

今回のデモでつかったパーツ配置

ブレッドボード上で

ピン4 → 抵抗(1KΩ)→ LEDアノード(長い足)>LEDカソード(短い足)→ ピン3(GND)

とつないであります。

出力とGNDが隣同士のピンなので配線は最短で済みます。

なお、物理ピン1・2はデバッグ用のシリアルコンソール(UART2)に割り当てられているので、LED用途には使わないでおきます。

つないだら、SSHから点くかどうかを先に確かめておきます。LinuxのGPIOはsysfsからそのまま操作できます。

echo 55 > /sys/class/gpio/export
echo out > /sys/class/gpio/gpio55/direction
echo 1 > /sys/class/gpio/gpio55/value   # 点灯
echo 0 > /sys/class/gpio/gpio55/value   # 消灯

55という番号は「GPIO番号 = bank×32 +(groupをA=0〜D=3として)group×8 + 番号」の式から来ています。

GPIO1_C7なら 1×32+2×8+7 = 55 です。echo 1 でブレッドボードのLEDが点けば、配線は合っています。

手順4: WireCanalでcanalを作る

ここからはRaspberry Pi 5の記事と同じ流れです。ダッシュボードの「新しい canal を作成」から、4ステップのウィザードで作ります。今回はアカウントを無料プランのままにして、そのままの条件で進めます。

canal作成ウィザード step1 種類選択(無料プラン)
種類はHTTP。無料プランなのでTCPは選べないが、今回はWeb画面なのでHTTPで足りる
canal作成ウィザード step2 公開アドレスの決め方
公開アドレスは「おまかせサブドメイン」。ランダムな名前で今すぐ発行され、全プランで使える

転送先には localhost:18080 を入れます。Luckfoxの中でこれから立てるWebサーバーのポートです。

canal作成ウィザード step3 転送先の設定
転送先に localhost:18080 を指定。エッジサーバーは既定のままでよい
canal作成ウィザード step4 確認画面
この内容で作成。今回発行された公開アドレスは https://fxsu8b0c.ja000.wirecanal.com

作成すると、canal詳細画面に接続ファイル(wirecanal.json)とセットアップ手順が出ます。Linuxタブの手順は「ワンライナーでAgentを入れる → wirecanal.jsonを置く → 起動する」の3つです。

canal詳細のセットアップ画面(Linuxタブ)
canal詳細のセットアップ画面。wirecanal.jsonをダウンロードして、Agentの隣に置く

手順5: AgentをLuckfoxに入れて、公開する

WireCanalのAgentは、CPUの種類ごとに1個の静的バイナリです。Luckfox Pico Mは32bit ARM(uname -marmv7l)なので、wirecanal-linux-arm を使います。ワンライナーの install.shuname -m で自動判定して同じファイルを取りに行くのですが、今回のBuildrootイメージには curl が入っていません。なのでPC側でダウンロードして、実機へコピーしました。

# Windows 側(PowerShell)
curl.exe -fsSL https://download.wirecanal.com/latest/wirecanal-linux-arm -o wirecanal
pscp -pw luckfox wirecanal root@172.32.0.93:/root/wirecanal_demo/wirecanal
pscp -pw luckfox wirecanal.json root@172.32.0.93:/root/wirecanal_demo/wirecanal.json   # canal 詳細からダウンロードした接続ファイル

実機側で実行権限を付けて、バージョンを確認します。

# cd /root/wirecanal_demo && chmod +x wirecanal && chmod 600 wirecanal.json
# ./wirecanal --version
wirecanal v.0.18.3 powered by Qualiteg Inc. https://qualiteg.com

公開する中身は、ブラウザからLEDを点け消しできる小さなWebサーバーにしました。ONボタンを押すと、さっき配線したLEDが実際に光り、OFFで消えます。押されている側のボタンに色が付くので、いまどちらの状態かも画面で分かります。イメージにpython3が入っているので、標準ライブラリだけで書いています。127.0.0.1の18080番で待ち、GET /led で現在の状態を返し、POST /led/on/led/off で切り替えます。LEDの操作は、配線の節で試したのと同じ /sys/class/gpio への書き込みです。

demo_server.py(実機に置いたもの・全文)

# LED control page for the Luckfox Pico M
# Served on 127.0.0.1:18080 and published to the internet through a WireCanal tunnel.
# The ON/OFF buttons drive GPIO1_C7 (sysfs number 55, physical pin 4) where an LED is wired.
import http.server, subprocess, socket, html, os

GPIO = "55"
GPIO_DIR = "/sys/class/gpio/gpio" + GPIO

def gpio_init():
    if not os.path.isdir(GPIO_DIR):
        with open("/sys/class/gpio/export", "w") as f:
            f.write(GPIO)
    with open(GPIO_DIR + "/direction", "w") as f:
        f.write("out")

def gpio_set(v):
    with open(GPIO_DIR + "/value", "w") as f:
        f.write("1" if v else "0")

def gpio_get():
    with open(GPIO_DIR + "/value") as f:
        return f.read().strip() == "1"

def sh(c):
    try: return subprocess.run(c, shell=True, capture_output=True, text=True, timeout=5).stdout.strip()
    except Exception as e: return str(e)

PAGE = """<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Luckfox Pico M LED control</title>
<style>body{font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;margin:0;background:#f7fafd;color:#15222b}
.wrap{max-width:720px;margin:40px auto;padding:0 20px}.card{background:#fff;border:1px solid #dde5ee;border-radius:16px;padding:28px 32px;box-shadow:0 2px 12px rgba(0,0,0,.05)}
h1{font-size:1.5rem;margin:0 0 6px}.sub{color:#5f6b78;margin:0 0 20px}table{border-collapse:collapse;width:100%%}td{padding:8px 6px;border-bottom:1px solid #eef2f6;vertical-align:top}
td:first-child{color:#5f6b78;width:150px}code{background:#f1f5f9;padding:2px 6px;border-radius:6px}
.led{display:flex;align-items:center;gap:16px;margin:18px 0 22px}
.dot{width:26px;height:26px;border-radius:50%%;background:#cfd8e3;transition:all .15s}
.dot.on{background:#e5484d;box-shadow:0 0 14px 3px rgba(229,72,77,.55)}
button{font-size:1rem;font-weight:600;border-radius:10px;border:1px solid #cdd7e1;background:#fff;color:#15222b;padding:10px 26px;cursor:pointer;transition:all .15s}
button.active{background:#1f7ae0;border-color:#1f7ae0;color:#fff}
#btnOn.active{background:#e5484d;border-color:#e5484d}
.state{font-weight:700;min-width:44px}
.foot{color:#8b96a4;font-size:.85rem;margin-top:16px}</style></head><body><div class="wrap"><div class="card">
<h1>Luckfox Pico M &mdash; LED control</h1>
<p class="sub">The buttons below drive a real LED wired to GPIO1_C7 (physical pin 4) on the board, through a WireCanal tunnel. No inbound port is exposed directly to the internet.</p>
<div class="led"><div id="dot" class="dot"></div><span id="state" class="state">...</span>
<button id="btnOn" onclick="setLed(1)">ON</button><button id="btnOff" onclick="setLed(0)">OFF</button></div>
<table>
<tr><td>Board</td><td>Luckfox Pico M (Rockchip RV1103, ARM Cortex-A7, 64 MB DDR2)</td></tr>
<tr><td>Hostname</td><td><code>%(host)s</code></td></tr>
<tr><td>Kernel</td><td><code>%(uname)s</code></td></tr>
<tr><td>Uptime</td><td>%(uptime)s</td></tr>
<tr><td>LED pin</td><td><code>GPIO1_C7</code> = sysfs gpio%(gpio)s, physical pin 4 (GND on pin 3)</td></tr>
</table>
<p class="foot">Agent: WireCanal (linux/arm) &middot; Powered by Qualiteg</p>
</div></div>
<script>
async function refresh(){const r=await fetch('/led');const j=await r.json();
document.getElementById('dot').className='dot'+(j.on?' on':'');
document.getElementById('state').textContent=j.on?'ON':'OFF';
document.getElementById('btnOn').className=j.on?'active':'';
document.getElementById('btnOff').className=j.on?'':'active';}
async function setLed(v){await fetch('/led/'+(v?'on':'off'),{method:'POST'});refresh();}
refresh();setInterval(refresh,3000);
</script></body></html>"""

class H(http.server.BaseHTTPRequestHandler):
    def _send(self, code, body, ctype="text/html; charset=utf-8"):
        data = body.encode("utf-8")
        self.send_response(code)
        self.send_header("Content-Type", ctype)
        self.send_header("Content-Length", str(len(data)))
        self.end_headers()
        self.wfile.write(data)
    def do_GET(self):
        if self.path == "/led":
            return self._send(200, '{"on": %s}' % ("true" if gpio_get() else "false"), "application/json")
        body = PAGE % {
            "host": html.escape(socket.gethostname()),
            "uname": html.escape(sh("uname -srm")),
            "uptime": html.escape(sh("uptime")),
            "gpio": GPIO,
        }
        self._send(200, body)
    def do_POST(self):
        if self.path == "/led/on":
            gpio_set(True)
        elif self.path == "/led/off":
            gpio_set(False)
        else:
            return self._send(404, '{"error":"not found"}', "application/json")
        self._send(200, '{"on": %s}' % ("true" if gpio_get() else "false"), "application/json")
    def log_message(self, *a): pass

gpio_init()
http.server.HTTPServer(("127.0.0.1", 18080), H).serve_forever()

ひとつだけ注意点があります。今回のBuildroot環境では python3 -m http.server をそのまま起動するとIPv6側のソケットを開こうとして Address family not supported で落ちました(このイメージのカーネルにはIPv6がありません)。上のコードのように 127.0.0.1 を明示して待ち受ければ問題ありません。

Webサーバーを裏で起動し、続けてAgentを起動します。

cd /root/wirecanal_demo
(setsid nohup python3 demo_server.py >/dev/null 2>&1 &)
./wirecanal -config wirecanal.json
wirecanal v.0.18.3 powered by Qualiteg Inc. https://qualiteg.com
wirecanal: warning: mode=http かつ http ルール未設定のため、全パスを転送先へ通します。特定のパス・メソッドに絞る場合は http.default: deny + http.rules を設定してください
wirecanal: 自動更新が有効です(無効化は wirecanal.json に "auto_update": false)
wirecanal: canal urym: https://fxsu8b0c.ja000.wirecanal.com へのアクセスをローカルの転送先 localhost:18080 に届けます
wirecanal: 設定は https://app.wirecanal.com/canals/urym をご確認ください
wirecanal 0.18.3: connecting tenant=urym mode=http forward_target=localhost:18080

この時点で、ダッシュボードの接続状態が「● 接続中」に変わります。

canal詳細で接続中になった状態
canal詳細が「● 接続中」に。Luckfox上のAgentから、外向きの1本でトンネルが張られた
ダッシュボード一覧で接続中とAgentのバージョンが表示された状態
一覧にも「● 接続中 v0.18.3」。無料プランなのでcanalは1/1本

インターネット側から見てみる

公開URLをブラウザで開きます。

公開URLを開いてONを押したところ。この瞬間、手元のブレッドボードでLEDが実際に光っている

LED制御ページが表示され、ONが押されて赤く光っている画面

「Linux 5.10.160 armv7l」というカーネルも稼働時間も、さっきSSHで見た実機の生きた値がそのままインターネット越しに出ています。そしてONボタンを押すと、手元のブレッドボードの上でLEDが実際に光ります。ブラウザは公開URLを叩いているだけで、その先で実物が動く。スマホでも同じURLをそのまま開けます。

スマホ幅でLED制御ページを表示した画面
スマホ幅で開いたところ。外出先から、家のブレッドボードのLEDを点け消しできる

curlでの実測はこうでした。ページを経由せず、APIを直接叩いてLEDを操作することもできます。

$ curl -s -o /dev/null -w "HTTP %{http_code} / %{time_total}s" https://fxsu8b0c.ja000.wirecanal.com/
HTTP 200 / 0.232s
$ curl -s -X POST https://fxsu8b0c.ja000.wirecanal.com/led/on
{"on": true}
$ curl -s -X POST https://fxsu8b0c.ja000.wirecanal.com/led/off
{"on": false}

このときインターネット側からPico Mへ直接到達できる受信ポートは、ひとつもありません。18080番は 127.0.0.1 だけで待っていて、PCのNATにも受信(ポートフォワード)の設定は何も入れていません。公開は、Agentの外向き接続だけで成立しています。SSH(22番)はRNDIS側で待受していますが、これはPCとボードの間だけの閉じたネットワークで、インターネット側からは届きません。

なお、今回は認証を付けていないため、URLを知っている人はページの閲覧もLEDのON/OFF操作もできます。今回は抵抗を介したLED 1本だけのデモなのでそのまま公開していますが、リレー・モーター・鍵・電源制御など実物に影響するGPIOを公開する場合は、canalのアクセス保護(IP制限・認証)やHTTPルールで、操作できる相手・パス・メソッドを必ず絞ってください。

手順6: 常駐させて、電源を抜いても戻ってくるようにする

手順5の起動はターミナルを閉じると止まりますし、経路・DNS・時刻は再起動で消えます。Raspberry Pi OSならsystemdの雛形を置くだけでしたが、Buildrootのinitはbusyboxの /etc/init.d/S??name 方式なので、起動スクリプトを1本書きます。

その前に、スワップを用意しておきます。Linuxから見えるメモリが33MBで、Agentは自動更新のための常駐ウォッチドッグ(wirecanal-safeguard)と組で動くので、そのぶんの余裕を持たせるためです。SDカード上に128MBのファイルを切って有効にしました。

dd if=/dev/zero of=/swapfile bs=1M count=128
chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile

起動スクリプトは次のとおりです。やっていることは、スワップを有効化し、USB側のネットワーク(usb0)にアドレスが付くのを待ち、経路とDNSを入れ、ntpを起動し直して時計が合うのを待ってから、Webサーバーとエージェントを起動する、という順番です。エージェントは終了したら5秒後に立ち上げ直すループにしています。systemdの Restart=always の代わりです。監視ループのPIDをファイルに控えて、二重起動の防止と、stop で監視ループごと全部止まるようにしてあります(監視ループを残したままエージェントだけkillすると、5秒後に勝手に復活してしまいます)。

/etc/init.d/S99wirecanal(実機に置いたもの・全文)

#!/bin/sh
# WireCanal Agent (Luckfox Pico M / Buildroot busybox init)
WD=/root/wirecanal_demo
SUPERVISOR_PID=/var/run/wirecanal-supervisor.pid
case "$1" in
  start)
    # 二重起動防止(前回の監視ループが生きていれば何もしない)
    if [ -f "$SUPERVISOR_PID" ] && kill -0 "$(cat $SUPERVISOR_PID)" 2>/dev/null; then
      echo "wirecanal supervisor already running (pid $(cat $SUPERVISOR_PID))"
      exit 0
    fi
    (
      [ -f /swapfile ] && swapon /swapfile 2>/dev/null
      i=0; while ! ip -4 addr show usb0 2>/dev/null | grep -q inet && [ $i -lt 60 ]; do sleep 2; i=$((i+1)); done
      ip route | grep -q '^default' || ip route add default via 172.32.0.100 dev usb0
      grep -q nameserver /etc/resolv.conf 2>/dev/null || printf 'nameserver 1.1.1.1\nnameserver 8.8.8.8\n' > /etc/resolv.conf
      /etc/init.d/S49ntp restart >/dev/null 2>&1
      i=0; while [ "$(date +%Y)" -lt 2025 ] && [ $i -lt 20 ]; do sleep 3; i=$((i+1)); done
      [ "$(date +%Y)" -lt 2025 ] && python3 $WD/settime.py >/dev/null 2>&1
      cd $WD && (setsid nohup python3 demo_server.py >/dev/null 2>&1 &)
      # Agent が終了したら 5 秒後に立ち上げ直す(systemd の Restart=always の代わり)
      cd $WD && while true; do ./wirecanal -config wirecanal.json >> agent.log 2>&1; sleep 5; done
    ) >/dev/null 2>&1 &
    echo $! > $SUPERVISOR_PID
    ;;
  stop)
    # 監視ループ → Agent/ウォッチドッグ → Web サーバーの順にすべて止める
    if [ -f "$SUPERVISOR_PID" ]; then
      kill "$(cat $SUPERVISOR_PID)" 2>/dev/null
      rm -f $SUPERVISOR_PID
    fi
    for p in $(ps | grep -E '[w]irecanal -config|[w]irecanal-safeguard|[d]emo_server' | awk '{print $1}'); do
      kill "$p" 2>/dev/null
    done
    ;;
  restart) "$0" stop; sleep 1; "$0" start ;;
esac
exit 0

途中で呼んでいる settime.py は、ntpが間に合わなかったときの保険で、HTTPの応答ヘッダにある日付で時計を合わせるだけの数行のスクリプトです。これはTLSを使える時刻まで戻すためのデモ用フォールバックで、通常の時刻同期はNTPに任せます。

settime.py(全文)

import urllib.request, urllib.error, email.utils, subprocess
try:
    r = urllib.request.urlopen('http://download.wirecanal.com/', timeout=10)
except urllib.error.HTTPError as e:
    r = e
t = email.utils.parsedate_to_datetime(r.headers.get('Date'))
subprocess.run(['date', '-u', '-s', t.strftime('%Y-%m-%d %H:%M:%S')])

ここで一度ハマった話を書いておきます。busyboxの ip route show default は「default」の指定を無視して全経路を返します。最初はこれで「経路がある」と誤判定して、再起動後に外へ出られませんでした。上のスクリプトのように ip route | grep '^default' で見るのが確実です。もうひとつはPIDの控え方で、サブシェルの中で $$ を書くと、BusyBoxのshでは親のPIDが記録されてしまい、二重起動ガードが効きません。上のスクリプトのように、サブシェルをバックグラウンドにした直後に親側で $! を書き出すのが確実です(これも実測で気づきました)。

実行権限を付けて sync したら、いよいよ再起動です。

chmod +x /etc/init.d/S99wirecanal && sync && reboot

再起動しても、公開は自動で復活するか

再起動をかけた瞬間から時間を測り、公開URLが200を返すまでを実測しました。

検収項目実測結果
公開URL再起動から33秒でHTTP 200に復活
スワップ/swapfile 128MB が自動で有効化(復帰直後の使用は9MB)
Webサーバー(demo_server.py)自動起動。GPIO(gpio55)のエクスポートも自動でやり直し
Agent(wirecanal)+ウォッチドッグ自動起動・自動接続
LED操作復帰後に公開URLへ POST /led/on → 点灯を実測
メモリ(復帰直後)used 17MB。Agent本体のRSSは4.4MB、ウォッチドッグは1.1MB
# uptime
 10:31:08 up 0 min,  load average: 5.00, 1.25, 0.41
# free -m
              total        used        free      shared  buff/cache   available
Mem:             33          17           1           0          14          13
Swap:           128           9         119
# ps | grep -E 'wirecanal|demo_server'
  793 root     python3 demo_server.py
  794 root     ./wirecanal -config wirecanal.json
  808 root     /root/wirecanal_demo/wirecanal-safeguard -config wirecanal.json -exec /root/wirecanal_demo/wirecanal -state-dir .wirecanal-state

復帰直後に公開URLを開くと、ページの稼働時間が「up 0 min」でした。再起動したばかりの実物が、そのままインターネット側から見えている証拠です。USB-Cを物理的に抜き差ししたときも同じで、挿し直すだけで何もせずに公開URLが復活しました。

3,750円のボードでも、外から届くサーバーになった

Luckfox Pico Mは、Linuxから見えるメモリが33MB、有線もWi-Fiもない、USB-Cが1本だけのボードです。それでも、SDカードへの書き込みからインターネット側で公開URLが返るまで、必要だったのは、この記事に書いた手順だけでした。Web公開のための受信ポートは最後まで追加していませんし、WireCanalは無料プランのままです。

Raspberry Pi 5のときと違ったのは、ネットワークをPC経由にする一手間と、Buildrootならではの下ごしらえ(CAバンドル・時刻・busybox init)です。逆に言うと、そこさえ越えれば、Agentのやること自体は同じでした。wirecanal-linux-arm のバイナリを置いて、wirecanal.jsonを隣に置いて、起動する。それだけです。

今回はLED 1本でしたが、この小さなボードには、これからセンサーやカメラを載せて、外出先から見られるものを増やしていきたいとおもいます!

それでは、また次回お会いしましょう!

出典・参考

関連記事

Read more

Raspberry Pi 5のWebサーバーをインターネットに公開する。ポートは開けない、WireCanalのトンネルで

Raspberry Pi 5のWebサーバーをインターネットに公開する。ポートは開けない、WireCanalのトンネルで

受信ポートを閉じたまま、Raspberry Pi 5のWebサーバーをインターネットに公開します。WireCanalのAgentをワンライナーで入れ、canalを作り、systemdで常駐化。ポート開放もルーター設定も不要で、操作はおよそ5分。再起動後の自動復活まで実測で検収しました。

By Qualiteg プロダクト開発部
Raspberry Pi 5を堅牢化する。SSH鍵認証・UFW・fail2banと、再起動でIPv6が復活する罠

Raspberry Pi 5を堅牢化する。SSH鍵認証・UFW・fail2banと、再起動でIPv6が復活する罠

セットアップ直後のRaspberry Pi 5は、パスワード認証が有効なまま、ファイアウォールなし、更新可能なパッケージ172件と無防備です。SSHの鍵認証化・UFW・fail2ban・自動更新・IPv6無効化を実コマンドと実測検収で解説します。sysctlのIPv6無効化が再起動で復活する現象と恒久対策も実機で確認しました。

By Qualiteg プロダクト開発部
LINE Botをローカルで開発する。WebhookのURLが毎回変わる問題を固定URLで解決する

LINE Botをローカルで開発する。WebhookのURLが毎回変わる問題を固定URLで解決する

こんにちは! LINE Botを作ろうとして最初にぶつかる壁は、コードではなくWebhookです。 LINEのMessaging APIは、ユーザーがBotに送ったメッセージを、LINEのサーバーからこちらのサーバーへHTTPSのPOSTで通知してきます。 この受け口(Webhook URL)にはインターネットから届くHTTPSのURLしか指定できず、開発中の http://localhost:5000 をそのまま書くことはできません。 トンネルツールで一時URLを発行して回避する方法が定番ですが、今度は別の問題が待っています。トンネルの方式によっては、起動のたびにランダムなURLが発行されます。その場合、開発を再開するたびにLINE Developersコンソールを開いてWebhook URLを書き換えることになります。コードを直すたび、翌日再開するたび、まずURLの貼り直しから。これが地味に堪えます。 結論から言うと、 ローカルPCの開発サーバーに「変わらない公開URL」を1本付けてしまえば、Webhook URLの設定は最初の1回だけで終わります。 この記事では、L

By Qualiteg プロダクト開発部