Windows版USBIPのGUIラッパーをつくってみた

# 忙しい人用
使い方
1. USBIPサーバーを立てます。
1. 以下のコードをusbip.exeと同一ディレクトリに作成した、適当なPythonファイルにコピペします。
1. 43行目付近にサーバー側のアカウントを記載します。
1. 管理者権限でプログラムを起動します。
1. サーバーアドレスの欄にUSBIPサーバーのアドレスを入れて「一覧を取得」します。

<details><summary><b>USBIPのGUIラッパー(Pythonプログラム)</b></summary>

```python
##########
#Copyright (c) 2023 Osane(kasumigaura)
#Released under the MIT license
#https://opensource.org/licenses/mit-license.php
##########
import tkinter as tk
import tkinter.ttk as ttk
import tkinter.scrolledtext
from tkinter import messagebox
import paramiko, time, subprocess, os, sys

host="localhost"
timeoutable_exec_return=""
default_usbip=""

#ログ用の現在時刻表示関数
def logtime():
	return "("+time.strftime('%Y/%m/%d %H:%M:%S', time.localtime())+")"

#ローカルでコマンドを実行する関数。std_textをFalseにするとreturncodeだけ返却する
def local_cmd(command, std_text=True):
	return_list=[]
	return_obj=""
	text_println("LOCAL "+logtime()+"  $  "+command)
	if std_text:
		return_obj=subprocess.run(command, shell=True, capture_output=True, text=True)
		return_list.append(return_obj.stdout.splitlines())
		return_list.append(return_obj.stderr.splitlines())
		for o in return_list[0]:
			text_println("    "+o.rstrip())
		for e in return_list[1]:
			text_println("    "+e.rstrip())
	else:
		return_list=subprocess.run(command, shell=True).returncode
	text_println("\n")
	return return_list

#サーバーにSSHで接続してコマンドを実行する
def server_ssh(command):
	global host
	return_list=[]
	with paramiko.SSHClient() as ssh:
		ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
		host=input_host.get()
		try:
			ssh.connect(host, username='your_username', password="your_password", timeout=5)
			text_println("SSH "+logtime()+"  $  "+command)
			stdin, stdout, stderr=ssh.exec_command(command, timeout=5)
			for o in stdout:
				return_list.append(["std",o.rstrip()])
				text_println("    "+o.rstrip())
			for e in stderr:
				return_list.append(["err",e.rstrip()])
				text_println("    "+e.rstrip())
		except (paramiko.ssh_exception.NoValidConnectionsError, ConnectionResetError, TimeoutError) as e:
			text_println("ERR "+logtime()+"  "+host+"に接続できませんでした。しばらくしてから、もう一度お試しください。")
			tk.messagebox.showerror("接続失敗", host+"に接続できませんでした。しばらくしてから、もう一度お試しください。")
		text_println("\n")
	return return_list

#切断処理を行ってから終了する関数
def all_exit():
	if tk.messagebox.askquestion("終了確認","このソフトウェアが起動している間のみ、ネットワーク上のUSB機器に接続できます。\n本当に終了しますか？", icon='warning')=="yes":
		change_cursor("watch")
		for item in get_local_list():
			disconnect(item, True)
		local_cmd('bcdedit.exe /set TESTSIGNING OFF')
		change_cursor("")
		root.destroy()

#サーバー側で認識しているUSB機器を取得する
def get_list():
	change_cursor("watch")
	usb_list_result=server_ssh("usbip list -l")
	usb_list_v=[]
	for i in range(0, len(usb_list_result), 3):
		usb_list_v.append(usb_list_result[i][1])
	input_usblist.config(values=usb_list_v)
	input_usblist.set("")
	change_cursor("")

#クライアント側に接続している機器の一覧を表示する
def get_local_list():
	local_usb_list=local_cmd(usbip_exe()+" port")
	input_local_usblist=[]
	for index, i in enumerate(local_usb_list[0]):
		if i.startswith("Port"):
			i_temp=i.split(":")
			input_local_usblist.append(i_temp[0]+": "+local_usb_list[0][index+1].strip())
	input_locallist.config(values=input_local_usblist)
	input_locallist.set("")
	return input_local_usblist

#サーバー側でbindし、クライアント側でattachする
def connect_usb():
	if tk.messagebox.askquestion("接続前のチェック","他の人が利用中のUSB機器でないことを確認しましたか？")=="yes":
		change_cursor("watch")
		select_list=input_usblist.get().split()
		bind_result=server_ssh("sudo usbip bind -b "+select_list[2])
		attach_ok=local_cmd(usbip_exe()+" attach -r "+host+" -b "+select_list[2], False)
		time.sleep(3)
		get_local_list()
		change_cursor("")

#選択した機器を切断するラッパー(GUIからの呼び出し用)
def disconnect_usb():
	change_cursor("watch")
	disconnect(input_locallist.get())
	change_cursor("")

#選択した機器を切断する
def disconnect(port_str, quick=False):
	disconn_usb=port_str.split(":")
	detach_ok=local_cmd(usbip_exe()+" detach -p "+disconn_usb[0].replace("Port ",""))
	if not quick:
		time.sleep(3)
		get_local_list()

#usbip_exeの場所を返却する。特殊な場所に置きたい場合は、ダブルクオーテーションで囲んでexe_listに追加する
def usbip_exe():
	global default_usbip
	if default_usbip=="":
		exe_list=['"'+os.path.dirname(os.path.abspath(__file__))+r'\usbip-win\usbip.exe"','"'+os.path.dirname(os.path.abspath(__file__))+r'\usbip.exe"']
		if hasattr(sys, '_MEIPASS'):
			exe_list.insert(2,'"'+os.path.join(sys._MEIPASS,"usbip.exe")+'"')

		default_usbip="usbip"

		for x in exe_list:
			if os.path.isfile(x[1:-1]):
				default_usbip=x
				break;
	return default_usbip

#コマンド実行結果表示用関数
def text_println(msg):#最後に改行を自動付与するエイリアス
	text_print(msg+"\n")
def text_cls():
	send_status.configure(state='normal')
	send_status.delete("1.0","end")
	send_status.configure(state='disabled')
	send_status.see('end')
	send_status.update()
def text_print(msg):
	send_status.configure(state='normal')
	send_status.insert(tk.END,msg)
	send_status.configure(state='disabled')
	send_status.see('end')
	send_status.update()

#カーソルアイコン変更用
def change_cursor(c):
	root.configure(cursor=c)
	send_status.configure(cursor=c)
	input_host.configure(cursor=c)
	input_usblist.configure(cursor=c)
	input_locallist.configure(cursor=c)


####ここから画面表示の設定#####
root=tk.Tk()
root.title('クライアント')
root.geometry('960x360')
root.protocol("WM_DELETE_WINDOW", all_exit)
frame=tk.Frame(root, padx=5, pady=5)
frame.grid(column=0, row=0)
frame.rowconfigure(1, weight=1)
frame.rowconfigure(2, weight=1)
frame.rowconfigure(3, weight=1)
frame.columnconfigure(0, weight=1)
frame.columnconfigure(1, weight=1)

###左側1###
input_frame=tk.Frame(frame, padx=5, pady=5)
input_frame.grid(column=0, row=0)

label_1=tk.Label(input_frame, font=('', 13), text='サーバーアドレス')
label_1.grid(column=0, row=0)
input_host=tk.Entry(input_frame, font=('', 15), width=30)
input_host.insert(0, 'localhost')
input_host.grid(column=0, row=1, pady=2)

input_button=tk.Frame(input_frame, padx=5, pady=5)
input_button.grid(column=0, row=2, sticky="EWNS")
input_button.columnconfigure(0, weight=1)
input_button.columnconfigure(1, weight=1)
input_button1=tk.Button(input_button, font=('', 13), text='一覧を取得', command=get_list)
input_button1.grid(column=0, row=0)
input_button3=tk.Button(input_button, font=('', 13), text='終了', command=all_exit)
input_button3.grid(column=1, row=0)

space_label=tk.Label(input_frame, font=('', 13), text='')
space_label.grid(column=0, row=3)


###左側2###
server_frame=tk.Frame(frame, padx=5, pady=5)
server_frame.grid(column=0, row=1)

label_2=tk.Label(server_frame, font=('', 13), text='認識中機器')
label_2.grid(column=0, row=0)
input_usblist=ttk.Combobox(server_frame, font=('', 15), width=30)
input_usblist.grid(column=0, row=1, pady=2)

usblist_button=tk.Frame(server_frame, padx=5, pady=5)
usblist_button.grid(column=0, row=2, sticky="EWNS")
usblist_button.columnconfigure(0, weight=1)
usblist_button.columnconfigure(1, weight=1)
usblist_button1=tk.Button(usblist_button, font=('', 13), text='接続', command=connect_usb)
usblist_button1.grid(column=0, row=0)
usblist_button1=tk.Button(usblist_button, font=('', 13), text='更新', command=get_list)
usblist_button1.grid(column=1, row=0)

space_label2=tk.Label(server_frame, font=('', 13), text='')
space_label2.grid(column=0, row=3)


###左側3###
local_frame=tk.Frame(frame, padx=5, pady=5)
local_frame.grid(column=0, row=2)

label_3=tk.Label(local_frame, font=('', 13), text='接続済機器')
label_3.grid(column=0, row=0)
input_locallist=ttk.Combobox(local_frame, font=('', 15), width=30)
input_locallist.grid(column=0, row=1, pady=2)

locallist_button=tk.Frame(local_frame, padx=5, pady=5)
locallist_button.grid(column=0, row=2, sticky="EWNS")
locallist_button.columnconfigure(0, weight=1)
locallist_button.columnconfigure(1, weight=1)
locallist_button1=tk.Button(locallist_button, font=('', 13), text='切断', command=disconnect_usb)
locallist_button1.grid(column=0, row=0)
locallist_button2=tk.Button(locallist_button, font=('', 13), text='更新', command=get_local_list)
locallist_button2.grid(column=1, row=0)

###右側###
hist_frame=tk.Frame(frame, padx=20, pady=5)
hist_frame.grid(column=1, row=0, rowspan=3, sticky="EWNS")
hist_frame.columnconfigure(0, weight=1)
hist_frame.columnconfigure(1, weight=1)

#ここからhist_frame内部
label_2=tk.Label(hist_frame, font=('', 12), text='コマンド実行結果')
label_2.grid(column=0, row=0)
send_status=tk.scrolledtext.ScrolledText(hist_frame, state='disabled', font=('', 12), width=70, height=18, bg="#ddd")
send_status.grid(column=0, row=1)


####管理者権限での起動チェック####
start_runas=local_cmd(usbip_exe()+' help')
if len(start_runas[0])==0:
	tk.messagebox.showerror("起動失敗", "管理者権限で実行してください")
	sys.exit()

####初期設定の確認####
parent_fol=os.path.dirname(usbip_exe())

local_cmd('certutil -p usbip -f -importPFX '+os.path.join(parent_fol, "usbip_test.pfx")+'"')

test_sig=local_cmd('bcdedit /enum | find "testsigning"')
if len(test_sig[0])==0 or (test_sig[0][0].split())[1]!="Yes":
	boot_test=local_cmd('bcdedit.exe /set TESTSIGNING ON')
	if len(boot_test[0])==0 or boot_test[0][0]!="この操作を正しく終了しました。":
		tk.messagebox.showerror("起動失敗", "test signingを有効にできませんでした。\nセキュアブートの設定を確認してください。")
		sys.exit()

#(usbip_exe()+" install)では正しくUDEかWDMか判定してくれないので、両方実行する
local_cmd(usbip_exe()+" install -u")
local_cmd(usbip_exe()+" install -w")

root.mainloop()
```
</details>

# はじめに
USBIPについて、[Arch Linux JP Wiki](https://wiki.archlinux.jp/index.php/USB/IP)には次のように書かれている。
> USB/IP プロジェクトは IP ネットワーク上で汎用の USB デバイス共有システムを開発することを目的としています。コンピュータ間で USB デバイスを共有するために、USB/IP は "USB I/O メッセージ" を TCP/IP ペイロードにカプセル化してコンピュータ間で通信します。

つまり、USB接続しかできない機器類（例えば、安いスキャナーなど）をネットワーク上のどこからでも使えるように出来る。

サーバー側の対応OSはLinuxのみだが、クライアントソフトウェアはWindowsにも対応している。ただし、クライアントソフトウェアは、コマンドラインツールなので、（非エンジニアは）拒否反応を起こす可能性がある。そこで、簡単なGUIラッパーを作成した。

# このプログラムの概要

https://qiita.com/nakamurahiro/items/e187ed5c28a506bf1a65

このページを大いに参考にした。簡単に言ってしまえば、ボタンに合わせてコマンドを実行しているだけである（local_cmd関数）。ただし、サーバー側で認識されているデバイスは、bindしてやらないと、クライアント側から見えない。サーバー側に接続されているUSB機器が固定の場合は必要ないが、今回の場合はどんな機器が接続されるか分からない環境であったため、毎回、SSHで一覧を取りに行っている。

SSH接続する方法等については

https://rurukblog.com/post/python-paramiko-use/

を参考にした。ただ、`usbip bind`コマンドがsudoの必要なコマンドであるため、rbashとかvisudoとかを用いて、サーバー側でうまいこと設定してあげる必要がある。

# つまずいた点
## 1. subprocess.runが終わらない
`usbip attach -r host_addr -b ID`をコマンドラインから実行すると結果がすぐ表示されるのに、Pythonから`subprocess.run("usbip attach -r host_addr -b ID", shell=True, capture_output=True, text=True)`を実行すると終わらない & Pythonが落ちてしまう、という現象があった。
これは、`capture_output=True`を指定しているとプログラム終了まで待ってしまう仕様に対し、usbip.exeから呼び出されるattacher.exeが、動き続けるためである。本当は標準出力を取得したかったが、引数で`capture_output=False`となるよう分岐させることとした。

## 2. 「install -u」なのか「install -w」なのか
usbip.exeのインストールは`usbip install`で自動的にUDEなのかWDMなのか判定していい感じにやってくれる、といった旨を言っている人をネット上で見かけたが、少なくとも私の環境では上手く行かなかった。自前で実装しようとしたが、どうやら難しそうだったので、両方実行するという、力技で解決している。 ~~だめだったら、エラー吐くだけだし。~~
~~証明書のインポートも同じような力技で解決している。~~

# おわりに
よきUSBIPライフを～～

## おまけ
PyInstallerを使うと、usbip.exeやattacher.exe等のusbip-winごと1つのexeファイルにまとめることが出来る。つまり、ダブルクリックするだけで、本ソフトを起動させることが出来るようになる。PyInstallerにリソースファイルを埋め込む方法は、以下の記事を参照。
なお、生成されたexeファイルを常に管理者権限で立ち上げるよう要求するには、hoge.specの`exe = EXE(...)`の中に`uac_admin=True`を追加すると良い。

https://qiita.com/firedfly/items/f6de5cfb446da4b53eeb