積みラズパイを崩したい!Goで自作IP-KVMソフトを作ってみた
こんにちは。
最近仕事はPHP、趣味はGoばっかり触ってるブルートフォースDIYです。
皆さんラズパイは積んでいますか?
もちろんどこのご家庭にも積まれていると思います。
我が家にも日本橋のじゃんぱらで6,980円で購入したRaspberry Pi 4が眠っており、さてどう使おうかと悩んでいました。
そんな折、1月にNanoKVMの話を書いていてピンときたわけです。
「そうだ、自分でKVMを作ってみよう」
PiKVMやTinyPilotといった既存プロジェクトは洗練されており、わざわざ自作するものか?という気もしますが、
NanoKVMの記事で「速度最優先の典型的なIoT脆弱性」とか偉そうに書いた手前、じゃあお前が作ったらどうなるんだと言われたらぐうの音も出ません。
ならば自分で作ってみよう。
そんでわかりやすい構造かつセーフティも充実させて差別化してみよう、となったわけですね。
ということで、Goで自作IP-KVMソフト「Goshenite-KVM」を作った話を書いていきます。
よければお付き合いください。
IP-KVMって結局なに?
IP-KVMとはキーボード・ビデオ・マウス・IPの略で、遠隔からPCをハードウェアレベルで操作する仕組みです。
KVM over IPと呼ばれたりもします。
リモートデスクトップやVNCがOSの上で動くのに対して、KVMは画面とキーボードとマウスを直接接続した状態を作ります。
OSが起動してなくてもBIOSやブートメニューが触れるのはそういうことです。
必要な要素はだいたい3つ。
映像としてHDMIの画面を取り込んで遠隔に配信する部分、入力としてキーボードとマウスの操作をUSBでターゲットPCに注入する部分、そしてISOを仮想ディスクとして見せたりファイルを渡したりする便利機能。
Goshenite-KVMはこれをGo単体バイナリ+ブラウザUIで実行します。
CGOを避けて単一バイナリにこだわったのはそこそこ頑張ったと思います。
自作の動機
既存のPiKVMやTinyPilotなどは素晴らしいプロジェクトですが、特定のハードウェア構成やOSイメージに依存する部分があり、試そうと思うとそこそこカロリーが必要です。
自作であれば構成を自分の環境に合わせられるし、何より中身を全部把握できます。
今回のテーマは「わかりやすいコード」にしました。成熟度で先行プロジェクトに勝てない以上、「なぜこう動くのか」「どこが危ないのか」が追えることを強みにしたかった。
というのがGosheniteという名前の由来でもあったりします。あとGoも入ってるし。
作ったもの

ブラウザをクライアントにした構成で、主な機能はこんな感じです。
映像はMJPEGストリーム(HTTPのmultipart)で配信。
入力はWebSocketでキーとマウスのイベントを送り、Linux側でHIDに書き込み。
仮想メディアはUSB gadget(configfsのmass_storage)で、ファイル転送はアップロード・ダウンロード・削除に対応。
音声はarecordで取り込んでWebSocketで生PCM配信。
管理はブラウザUIから外部アクセスのオンオフ、パスワード設定、サービス再起動、USB VID/PID設定あたりが触れます。
「WebRTCで低遅延!」みたいなモダンなことはまだやっていません。
構造の話
internal/portにインターフェース定義、internal/domainにビジネスロジック、internal/adapter/inboundにHTTPやWebSocketで外から受ける部分、
internal/adapter/outboundにV4L2やUSB gadgetやarecordなどOS依存の実装、というヘキサゴナルっぽい構成にしています。
たとえばHIDの書き込み。
domain側のhid.Serviceはインターフェースだけ知っていて、実体がフェイクなのかUSBガジェットなのかは起動時に外から差し込みます。
hid.writerをusbgadgetにした場合は見つかるまで最大で約20秒リトライし、autoの場合は待たずにその場で確認して見つからなければHIDごと無効化してwarningsに理由を定時。
「動いてるつもりが実はfakeだった」が怖いので、HIDのfakeフォールバックが起こらないよう気をつけています。
これの何が嬉しいかというと、開発中はfake実装でUIを作れることですね。
ラズパイが手元になくても進むし、ユニットテストも書きやすい。
そしてmkfs.vfatやmountみたいな怖い処理をアダプタ側に隔離できます。
映像まわり

映像はV4L2からフレームを取り込んでJPEGにして配信します。
可能ならデバイスのMJPEGをそのまま使い、MJPEGが出ないフォーマットの場合はソフトウェアでJPEG化。
任意でV4L2のM2Mエンコーダも使えるようにしてあります。
遅延対策として、最新のJPEGを1枚だけ保持して受け手にはチャネルで「新しいの来たよ」と通知するだけにしました。
バッファはなく、遅い接続でも最新フレームだけ拾えます。
ストリームのハンドラ側では、前回送ったフレームとCRC32が一致したら送信をスキップしつつ、1秒以上経過していたら同一でも送るという判定を入れています。
要するに「全フレームを漏れなく送る」じゃなくて「常に最新を送り続ける」方針です。
手持ちのラズパイ4と貧弱なキャプチャーだとフルストリームは体感が悪く、試行錯誤でここに落ち着きました。
映像周りはもっと改良したい気持ちがある。
HIDで事故を潰す
ここは事故りがちな部分なのでマメに対策を入れました。
操作権はWebSocket1本だけ。ブラウザでタブを2個開くと、どっちが操作してるかわからなくなります。
なので後着が来たら旧接続に専用のCloseMessageを送って切断する後勝ち方式にしました。
古いタブが居座る問題はこれで消えます。
次にウォッチドッグ。
接続が一瞬切れてキーアップが届かないと「Ctrl押しっぱなし」が起きがち。
hid.Serviceにwatchdog goroutineを常駐させ、最後の操作から一定時間経過したらキーボードもマウスもゼロレポートを強制送信して全解放します。
JS側も500msごとにpingを飛ばしつつキー状態を定期的に再送してパケットロス耐性を狙っています。
マウスの累積値にもクランプをかけて、カーソルが画面の彼方へ飛んでいく事態をある程度は防ぎます。ある程度ね。
地味に入れてよかったのがHIDの自動復旧。
書き込み時にUSBが外れたりしてエラーが返ってきたらクールダウン付きで自動rebindを試みます。
さらに仮想メディアのmountやejectの直後にも、HIDの再初期化を0秒、1秒、3秒の3回走らせています。
仮想メディアをマウントするとUSBデバイス認識が変わってHIDが切れることがありますが、これで自動的に復帰して継続操作が可能です。
仮想メディア

それが入ったCDやUSBメモリとして振る舞う
仮想メディアはUSBガジェット(configfs)のmass_storageを使います。
ISOをマウントしてターゲット側から仮想CDとして見えるようにしたり、imgをマウントしてUSBメモリとして見せたりEjectもできます。
configfs周りの実装で苦労したのはメディア切り替え時のUDC rebind問題。
mass_storageのfileアトリビュートを書き換えようとするとEBUSYが返ることがあり、その場合はUDCを一回unbindしてから書き換えて再びbindする必要があります。
ただ毎回rebindするとHIDも巻き込まれて操作が途切れるので、「まずrebindなしで試す、EBUSYが返ったらrebindしてリトライ」という二段構えにしました。
rebind自体もEBUSYで失敗することがあるので、sleepを挟みつつ最大5回リトライします。
UIにはUSBイメージ作成機能も入れてあり、アップロードしたファイルをFATイメージの中に入れたusb.imgをサーバ側で生成できます。
内部的にはmkfs.vfat → loopマウント → コピー → umountの流れ。

音声はほどほど
音声はarecordでraw PCMを吸って、そのままWebSocketで垂れ流します。
ブラウザ側はWebAudioで鳴らし、フォーマット情報は最初にJSONで送って以後はバイナリという流れ。
KVMの音声はないと困るけどガチで音楽聞く用途でもないと思うのでほどほどに抑えました。
設定UIは必要だと思った
しばらく触ってないと忘れるので、ブラウザの管理モーダルから接続パスワード、外部アクセスのオンオフ、
systemdの自動起動、サービス再起動、VID/PID設定あたりを触れるようにしています。
ここの設定保存は割とこだわりました。
YAMLを雑に文字列置換するとコメントが消えたりインデントが壊れたりするので、yaml.v3のNodeレベルで解析して必要なキーだけupsertしています。
元の改行コードと末尾改行の有無も保持。保存は一時ファイルからfsync → renameの原子的更新でパーミッションも600。
「UIから設定変えたらconfig壊れて起動しなくなりました」は最悪なので気を使ったポイントです。

セキュリティの話
Goshenite-KVMはインターネットに公開するKVMを想定していません。
前提はLANかTailscaleのようなVPNです。
ただ「閉域前提だから何もしない」は危ないので、危な方なところは潰しておきました。
起動時にチェックが入り、http.listenが0.0.0.0やグローバルIPなのに認証が無ければ起動を拒否します。
判定ではlocalhostやRFC1918の私設IP、TailscaleのCGNAT、IPv6のULAはローカル扱い。
ホスト名でバインドされた場合は判定不能なので公開扱い。
迷ったら公開として扱うfail-closedの方針です。
動かし方
手元PCで雰囲気だけ試す場合はfakeモードが使えます。
ビルドしてconfig.fake.yamlを指定して起動すればhttp://127.0.0.1:18080/にアクセスできます。
映像やHIDはフェイクですが、UIの動線やAPIの雰囲気は確認できます。
fakevideo、fakehid、fakeaudioのそれぞれのアダプタが差し込まれるので、ラズパイが手元にない環境でも一通り触れるのがヘキサゴナルの恩恵ですね。
Windowsでもちょくちょく進められました。
おまけでDockerもあるけどテスト用なので特に動いたりはしません。
ラズパイに入れる場合はGoなしで入れられるインストール経路を用意しています。
初回はパスワード(トークン)を生成してセットアップURLを出し、そのURLを1回開けばブラウザ側にトークンが入る流れ。
USBガジェットまで有効化したい場合はVID/PIDが必要です。
pid.codesに申請予定ですが、通るまでにもし触ってくださる場合は各自判断でテスト用のものを使ってください。
管理モーダルにVID/PIDフォームを用意してるので、そこに入力して出てきたコマンドをターミナルに打ち込んだら反映されます。

終わりに
ここまで読んでいただきありがとうございます。
先行KVMでよくね?という感覚もありつつ、バイナリ置くだけで使えるポータビリティや認証周りはそこそこ差別化になってるかなと思ってます。
今後はもう少し低帯域・低遅延な映像や、PCのオンオフなども手をつけたい。
ATX配線ではなく外付けのSwitch botみたいなのをBluetooth経由で操作するのがイメージとしては理想かな?
あと積みラズパイといえば最近OpenClawがド流行りしてるので、自分もああいう感じの作りたいなあといろいろ試しています。
リポジトリはこちら。
