自分用に書いた道具は、たいてい 127.0.0.1 から出ないまま一生を終えます。
自宅の iMac で FastAPI のサーバを動かしていて、ブラウザで http://127.0.0.1:8766/ を開くと画面が出る。日本語で「サンタの格好をしている猫」と書いて送ると、しばらくして画像が返ってくる。生成そのものはクラウドの GPU がやるので、iMac がやっているのは日本語を受け取り、生成の指示を組み立ててクラウドへ送るところまでです。
これを、ソファに座ったまま iPhone から使いたくなりました。同じ家の Wi-Fi にいるのだから、設定の server.host を 0.0.0.0 にすれば届くはずです。
実際、届きます。設定を1行変えるだけです。
その1行が何をしているのか
先に分けておきたいものが2つあります。iMac が持っているIPアドレスと、server.host に書く値は別物です。同じ「アドレス」という言葉で呼ぶと混ざります。
① iMac が持っているIPアドレス(機械の持ち物)
| アドレス | 誰から届くか |
|---|---|
127.0.0.1 |
その機械の中からだけ。外からは届かない |
192.168.11.6 |
同じ Wi-Fi にいる他の機械から届く — iPhone が打ち込むのはこちら |
② server.host に書く値(サーバがどれで待つかの指定)
| 書く値 | サーバが待つアドレス |
|---|---|
127.0.0.1 |
127.0.0.1 だけで待つ |
0.0.0.0 |
①の2つとも含めて、持っているアドレス全部で待つ |
見てのとおり、0.0.0.0 は①の表に出てきません。iMac はそんなアドレスを持っていないからです。あれは②の欄にだけ書く値で、意味は「どれか1つを選ばない」——アドレスの欄に書く「全部」という記号だと思ってください。
server.host が 127.0.0.1 の間は、iMac が 192.168.11.6 も持っているのに、そちらでは待っていません。0.0.0.0 にすると両方で待つようになり、iPhone から http://192.168.11.6:8766/ が開くようになります。
もとの状態(server.host は 127.0.0.1)。
server.host を 0.0.0.0 にしたあと。
iPhone に打ち込むのは①のアドレスです。②に書いた 0.0.0.0 ではありません。①のアドレスはこれで分かります。
# Wi-Fi の口の名前を先に調べる(機種によって en0 だったり en1 だったりする)
networksetup -listallhardwareports | awk '/Hardware Port: Wi-Fi/{getline; print $2}'
# → en1
ipconfig getifaddr en1 # → 192.168.11.6
私の iMac は有線の口が en0 を取っていて、Wi-Fi は en1 でした。ipconfig getifaddr en0 は何も返しません。
そして「全部」には、家の Wi-Fi のほかにケーブルで繋いだ側や仮想マシン用のアドレスも入ります。どのアドレスで待つかを選んでいるのではなく、持っているアドレス全部で待っている——0.0.0.0 にした瞬間、同じ Wi-Fi にいる機械はどれもこの画面を開けるようになる、ということです。
(macOS では初回の起動時に「ネットワーク受信接続を許可しますか」というダイアログが出ます。許可しないと外からは届きません。)
ただ、server.host を書き換えるだけでは終わりませんでした。前後に2つ作業が挟まっています。この記事はその3つを、やった順に書きます。
- 手順1:パスワードを用意してから
server.hostを0.0.0.0にする - 手順2:ボタンが隠れてしまうのを直す
- 手順3:iPhone の実機で通す
server.host を書き換えるだけでは済まなかった理由
server.host を 0.0.0.0 にすれば iPhone から届く——そこは本当です。それでも設定を書き換える前に手を止めたのは、意図しない機器からのアクセスを許したくなかったからです。
0.0.0.0 で待ち受けるということは、同じ Wi-Fi にいる全部の機械にこの画面を配るということです。家族のスマホも、テレビも、掃除機も同じネットワークにいます。掃除機がボタンを押してくることはないでしょうが、「押せる状態で置いてある」のと「押せない」のは違います。
なので順番はこうなりました。パスワードを先に作る → server.host を 0.0.0.0 にする。逆にはしない。
手順1:パスワードを用意してから server.host を 0.0.0.0 にする
手順1の中身は4つに分かれます。
| やること | この記事のどこ | |
|---|---|---|
| 1 | 待ち受け先を、ソースの直書きから設定ファイルへ移す(既定は 127.0.0.1 のまま) |
1-1 |
| 2 | パスワードの関門を、どのルートでも必ず通る1か所に置く | 1-2〜1-4 |
| 3 | パスワードが無いのに 0.0.0.0 になっていたら、起動を止める |
1-5 |
| 4 | ここまで済んでから、settings.json の server.host を 0.0.0.0 に書き換える |
1-7 |
4 を最後に置いているのが、さきほどの「パスワードを先に作る」の実体です。1〜3 の間、サーバは 127.0.0.1 のままなので、iPhone からはまだ何も見えません。
⚠ 表に 1-6 がありません。そこは予定に無かった作業で、1 に手を付けた瞬間、過去に自分が書いたテストに止められました。どう書き直すか決まったのが 3 の後だったので、その位置に残してあります。
1-1. 待ち受け先を設定ファイルへ出す。読めないときは黙って決めない
もともとはソースにこう書いてありました。
HOST = "127.0.0.1"
PORT = 8766
これを設定ファイル(config/settings.json)から読むように変えます。既定は 127.0.0.1 のままにして、iPhone から使う日だけ設定を書き換えます。
DEFAULT_HOST = "127.0.0.1"
def _bind_host() -> str:
"""待ち受け先を config/settings.json の server.host から読む。"""
try:
return load_config().server_host
except Sd2Error as exc:
# 設定が読めないときは 127.0.0.1 で待ち受け、理由はターミナルに出す
print("⚠ settings.json を読めないので {} で待ち受けます: {}".format(
DEFAULT_HOST, exc.message))
return DEFAULT_HOST
読めなかったときに待ち受け先を 127.0.0.1 にするのは意図的です。設定ファイルが壊れているときに 0.0.0.0 になってしまうと、事故が起きたときだけ誰からでも届く状態になります。
ここで一度悩んだのが「読めないときに起動ごと止めるか」でした。止めませんでした。止めるとブラウザには「接続できません」としか出ず、設定ファイルのどこが悪いのか永久に分からないからです。127.0.0.1 で起動して、理由をターミナルに出す——後述する「パスワードが無いのに 0.0.0.0 にしようとした」ときだけは、逆に止めます。
1-2. パスワードの関門は、ルートではなくミドルウェアに置く
FastAPI で認証を足すとき、最初に思いつくのは各ルートに依存を足す形です。
@app.post("/api/send")
async def send(req: SendRequest, _=Depends(require_password)): # 足し忘れる
...
これはやめました。足し忘れるからです。
この画面は今後も入口が増えます。増やした日に Depends を書き忘れると、その1本だけパスワードを訊かなくなる。しかも書き忘れは動作としては正常なので、テストを書いていなければ誰も気づきません。
そこで、どのルートでも必ず通る1か所に置きました。
@app.middleware("http")
async def screen_password_gate(request, call_next):
"""127.0.0.1 以外から来た接続にパスワードを訊く。"""
if _is_local(request):
return await call_next(request) # この Mac 自身は素通り
password = load_screen_password()
if not password:
# パスワードが用意されていないなら、外からの接続はルートを問わず断る
return JSONResponse(status_code=403, content={"detail": {
"message": "この Mac の外からは使えません",
"hint": "config/secrets.json に screen_password を書いて起動し直してください",
}})
if request.url.path == LOGIN_PATH and request.method == "POST":
return await _handle_login(request, password)
if _cookie_ok(request, password):
return await call_next(request)
return _ask_password(request)
(_handle_login() はパスワードを照合して Cookie を渡す係、_ask_password() はログイン画面を返す係です。中身は本題から外れるので割愛します。)
ルートが何本増えても、新しく書いた人が何を忘れても、ここは通ります。
⚠ 途中の 403(「この Mac の外からは使えません」)は、このあと 1-5 で書く起動時のチェックと重なって見えます。起動を止めているのだから、ここには来ないはずです。それでも書いてあるのは、起動したあとに config/secrets.json を消したり壊したりできるからで、起動時の判定は起動時の状態しか知りません。
@app.middleware("http") と call_next は初めて使ったので、動きを書いておきます。
call_next は「この先へ進める」という関数です。
-
return await call_next(request)… 本来のルート関数を呼び、その返事をそのまま返す -
return JSONResponse(...)…call_nextを呼ばない=ルート関数は動かない。ここで打ち切って自分の返事を返す
Depends が「ルート関数の前に走るもの」なのに対して、ミドルウェアは「ルート関数まで行かせるかどうかを決めるもの」です。通さない相手は中に入ってきません。
(複数付けたときの順番は書いた位置ではなく登録順で決まり、後に登録したものが外側になります。1本しか無いうちは気にしなくて済みます。)
1-3. 判定するのは接続元。ヘッダは見ない
_is_local() の中身はこれだけです。
LOCAL_CLIENTS = frozenset(["127.0.0.1", "::1", "::ffff:127.0.0.1"])
def _client_host(request) -> str:
"""接続元のIPアドレス。取れないときは空文字=外扱い(パスワードを訊く側にする)。"""
client = getattr(request, "client", None)
return (client.host or "") if client else ""
def _is_local(request) -> bool:
return _client_host(request) in LOCAL_CLIENTS
X-Forwarded-For を見ていません。これは意図的です。あのヘッダは送り手が好きな値を書けるので、見た瞬間に「127.0.0.1 だと名乗るだけで通る入口」になります。リバースプロキシ(サーバの手前に立てて、外から来たリクエストを中継するサーバ)の後ろに置くなら別の話ですが、ここは iPhone が直接つないでくる構成なので、request.client.host だけを信じます。
_client_host() が取れなかったときに空文字を返して外扱いにしているのも同じ考えです。判定できないときに通す側を選ぶと、判定できない状況が抜け道になります。
1-4. Cookie にパスワードそのものを入れない
一度合ったら覚えてほしいので Cookie を使います。中身はハッシュにしました。
def _cookie_value(password: str) -> str:
return hashlib.sha256(("sd2-screen:" + password).encode("utf-8")).hexdigest()
def _cookie_ok(request, password: str) -> bool:
got = request.cookies.get(COOKIE_NAME, "")
return bool(got) and hmac.compare_digest(got, _cookie_value(password))
比較に hmac.compare_digest() を使っているのは、== だと合っている文字数で処理時間が変わるためです。家庭内 LAN でそこまで要るかというと要りませんが、1行の差なので付けています。
Cookie を渡すところで1つ引っかかりました。
res.set_cookie(COOKIE_NAME, _cookie_value(password), max_age=COOKIE_MAX_AGE,
httponly=True, samesite="lax", path="/")
secure=True を付けていません。付けたくなりますが、家の中は http:// のままなので、付けると Cookie が一切保存されず毎回パスワードを訊かれます。httponly は画面の JavaScript から読ませないため、samesite="lax" は他所のページからリクエストを出させないためで、こちらは付けています。
パスワードが違ったときの返事も、素っ気なくしておきます。
if not (given and hmac.compare_digest(given, password)):
return HTMLResponse(_login_page("パスワードが違います"), status_code=401)
「3文字目まで合っています」と親切に教えないこと。あと WWW-Authenticate ヘッダも付けていません。付けるとブラウザ内蔵のダイアログが出てきて、自作のログイン画面と二重になります。
1-5. パスワードが無いのに 0.0.0.0 にしようとしたら、起動を止める
ここが一番効きました。
def _refuse_open_without_password(host: str) -> bool:
if host in LOCAL_CLIENTS:
return False
if load_screen_password():
return False
print("⛔ パスワードが無いので起動しません(settings.json の server.host = {})。".format(host))
print(" この設定は 8766番の画面を家の Wi-Fi の全端末へ開けます。画面には GPU を")
print(" 起動する承諾ボタンが載っていて、押すと費用がかかります。")
print(" 開けるなら: config/secrets.json に \"screen_password\": \"<パスワード>\" を書く")
print(" 開けないなら: config/settings.json の server.host を \"127.0.0.1\" に戻す")
return True
戻り値が True なら sys.exit(1) で終わります。
1行目の LOCAL_CLIENTS は 1-3 の集合の流用ですが、ここで見ているのは接続元ではなく server.host の設定値です。この Mac の中だけで待つ設定なら、パスワードが無くても止めません。
黙って 127.0.0.1 に戻さないのが肝です。親切心で戻すと、0.0.0.0 にしたつもりの人は iPhone から繋がらない理由が分からず、ルータや Wi-Fi を疑って半日溶かします。かといって戻さずそのまま動かせば、パスワードのない画面が家中の機械に配られる。止めて、何が起きているかと、どちらへ進めばいいかを両方書く。
書いてみると、ターミナルに出る文章のほうがコードより長くなりました。それでいいと思っています。
1-6. 古いテストは消さず、守りたかったものへ言い直す
⚠ ここだけ時間が戻ります。止められたのは 1-1 で HOST を触った瞬間で、ここに置いているのはどう書き直すか決まったのが 1-5 の後だったからです。
既存のテストはこうでした。
def test_server_binds_localhost_only():
assert server.HOST == "127.0.0.1"
過去の自分が「外に口を作らない」という決めごとを、テストに焼き込んでいたのです。実装を変えれば当然落ちます。
ここで手が止まりました。テストを消せば通りますが、消したら次に同じ事故を止めるものが無くなります。
考え直して気づいたのは、捕まえたかったのは「0.0.0.0 にしていること」ではなく「パスワードを用意せずに 0.0.0.0 にしていること」だったという点です。当時はパスワードという選択肢を持っていなかったので、0.0.0.0 そのものを禁じる形で書くしかなかった。
なので、歯止めの形を書き換えました。
def test_open_host_requires_password():
host = load_config().server_host
if host == "127.0.0.1":
return # この Mac のブラウザだけが相手=パスワードは要らない
assert load_screen_password(), (
"settings.json の server.host が {!r} なのにパスワードがありません。"
"config/secrets.json に screen_password を書くか、"
'host を "127.0.0.1" に戻してください'.format(host))
古いテストを消すのではなく、守りたかったものを言い直す。このテストは今も落ちうるし、落ちてほしい場面が残っています。
余談ですが、失敗メッセージに直し方を2つとも書いておくと、半年後の自分が助かります。テストの失敗メッセージは、いちばん困っている瞬間に読まれる文章です。
1-7. ここでようやく設定を書き換える
1〜3 が揃ったので、最後に settings.json を書き換えます。手順1で唯一、外から見える状態が変わる操作です。
{
"server": { "host": "0.0.0.0", "port": 8766 }
}
パスワードを config/secrets.json に入れ忘れていれば、サーバはここで起動せず、sys.exit(1) で止まって理由を出します。
手順2:ボタンが隠れてしまうのを直す
⚠ ここは計画に無かった順番です。予定では「設定を変える → 実機で通す → 画面を直す」でした。設定を変えたのだから、まず繋がることを見るのが自然に思えたのです。
ところが server.host を書き換えて iPhone の Safari で開いたら、画面は出るのに指示は送れませんでした。送信ボタンにも確認ボタンにも指が届きません。
そのうえ、送ったあとの様子が「固まった」ように見えました。内訳は2つ重なっていました。
- 指示を送ると裏でジョブが走り、画面は結果を数秒おきに問い合わせます。GPU が止まっていると、その返事は「止まっています」のまま何回でも同じ値を返します。エラーではないので、画面は淡々と待ち続けます。
- その状態を抜ける唯一の道である「起動しますか?」の確認が、幅 0px の会話の中にありました。
どちらか片方なら気づけたと思います。押すべきボタンが見えない状態と、押されるまで永遠に待つ設計が重なると、見た目は「固まった」になります。ログには 200 が並んでいるので、ログを見ても異常が見つかりません。
画面が使えないままでは「日本語で指示を出したら画像が返るか」を確かめようがない。確かめる手段のほうが先に要ると分かったので、順番を入れ替えて、先に画面を直しました。
原因は CSS でした。
#main { display: flex; }
#left { flex: 1 1 auto; } /* 会話 */
#side { flex: 0 0 760px; } /* 操作パネル */
flex は3つの値をまとめて書く書き方で、順に grow(余ったら伸びる比率)/shrink(足りないとき縮む比率)/basis(元の大きさ) です。並べるとこうなります。
| grow | shrink | basis | |
|---|---|---|---|
#left(会話) |
1 | 1 | auto |
#side(操作パネル) |
0 | 0 | 760px |
操作パネルは shrink が 0、つまり足りなくても縮まないという指定です。会話側は shrink が 1 なので縮みます。幅が足りないとき縮むのは縮む側だけなので、iPhone の横幅 390px では 760px を先に取られ、会話側に残る幅は 0px になります。会話が 0px でも画面は真っ白にはならず、それらしく表示されるのが厄介なところでした。「GPU を起動しますか?」の確認は会話の中に出るので、押したいボタンが幅 0px の中に入っているわけです。
足したのは1ブロック。効いたのは3行
メディアクエリの外には、もともとこう書いてあります(後の話に出てくるので先に)。
html, body { height: 100%; }
足したのはこの下のブロックだけです。
@media (max-width: 1000px) {
/* iOS Safari は下のアドレス欄が画面に重なる。100dvh なら重なった分を除いた高さ */
html, body { height: 100dvh; }
/* 縦に積む:上に会話、下に操作パネル */
#main { flex-direction: column; }
/* min-height:0 が要る。無いと会話が縮まず、中でスクロールしない */
#left { flex: 1 1 auto; min-height: 0; }
#side {
flex: 0 0 auto; width: 100%;
/* 天井を付ける。無いと操作パネルが伸びて上の会話が潰れる */
max-height: 45vh; max-height: 45dvh;
border-left: 0; border-top: 1px solid var(--line);
}
/* このほかに、操作パネルの中の細かい調整が数件(省略) */
}
3つだけ補足します。
100dvh。 iOS Safari は下のアドレス欄が画面に重なります。100vh だと送信ボタンがその下に隠れて押せません。dvh を知らないブラウザはこの行だけ捨てて上の height: 100% のまま動くので、書き足しても壊れません。
min-height: 0。 flex の子は既定で中身より縮まないので、これが無いと会話が伸びきってスクロールしなくなります。横に並べていたときは幅の話だったものが、縦に積んだ瞬間に高さの話に変わる——ここは毎回忘れます。
max-height を2回書いているのは打ち間違いではありません。 dvh を解釈しないブラウザは後ろの指定を捨て、vh のほうを使います。同じプロパティを2回並べるのは、この「読めないほうを捨ててもらう」ための書き方です。
境目を 1000px にしたのは、iPhone は縦 390px・横向きでも最大 956px で、どちらも 1000 未満だからです。iPad の縦(768px)もこちら側に入ります。
壊していないことをどう確かめるか
CSS の直しで怖いのは、直した先ではなく直していない先が壊れることです。iMac の画面は今までどおりであってほしい。
ヘッドレス Chrome で描いて、画像のハッシュを比べました。
# 変更前と変更後を並べる
git show HEAD:./web/index.html > /tmp/before.html
cp web/index.html /tmp/after.html
for W in 2048 1400 1001 390; do
for F in before after; do
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
--headless --disable-gpu --window-size=${W},1000 \
--screenshot=/tmp/${F}-${W}.png "file:///tmp/${F}.html"
done
done
md5 /tmp/before-*.png /tmp/after-*.png
結果は、2048px・1400px・1001px の3つがハッシュまで一致。390px だけが変わりました。境目のすぐ外側(1001px)を入れておくと、max-width を1px 間違えたときに気づけます。
肝心の 390px 側は数字で測りました。
| 測ったもの | 変更前 | 変更後 |
|---|---|---|
| 会話の幅 | 0px | 390px |
| 横スクロールの量 | 370px | 0 |
| 「起動する(費用がかかります)」ボタン | 画面の外 | 画面の中 |
「無いこと」ではなく「あること」をテストに書く
このメディアクエリは、次に誰かがレイアウトを触った日に消えます。消えたら iPhone がまた使えなくなるので、テストにしました。
NARROW_AT = "@media (max-width: 1000px)"
def test_狭い画面では縦に積む(narrow):
"""横に並べたまま縮めない——760px は 390px の画面に入らない。"""
assert re.search(r"#main\s*\{[^}]*flex-direction:\s*column", narrow)
assert re.search(r"#side\s*\{[^}]*width:\s*100%", narrow)
def test_会話は縦に縮められる(narrow):
"""min-height:0 が無いと会話が縮まず、中でスクロールしない。"""
assert re.search(r"#left\s*\{[^}]*min-height:\s*0", narrow)
narrow は index.html から @media (max-width: 1000px) の行を探し、そこから対応する閉じ括弧までを切り出すフィクスチャです。サーバも立てず、ブラウザも使わず、ファイルにそう書いてあるかだけを見ます。
書き方で1つ気をつけたのは、assert "760px" not in narrow のような「無いこと」の書き方をしないことです。最初はそう書きかけたのですが、ブロックの中のコメントに 760px の4文字が入っていて落ちました。セレクタと指定の組で探すほうが、コメントを書き換えたくらいでは落ちません。
ここははっきりさせておくべきで、このテストが通っても「iPhone で使える」とは言えません。言えるのは「CSS にそう書いてある」までです。だからテストファイルの先頭にそう書きました。指が届くかどうかは実機でしか分かりません。
手順3:実機で通す
最後が実機です。iPhone の Safari で http://192.168.11.6:8766/ を開き、パスワードを入れ、日本語で指示を出して、画像が出るまで。
なぜこれを最後に回したのか。GPU を借りると分単位でお金がかかるからです(Pod = クラウドで借りている GPU 付きの機械のことです)。手順1と2は Pod を1秒も立てずに終わります。課金の要る確認は最後にまとめて1回。
通ったときの記録はこうでした。
| 確かめたこと | 結果 |
|---|---|
| GPU の起動 | 借りてから生成できる状態まで 79秒 |
| iPhone から指示 |
POST /api/send 200 → ジョブの問い合わせ10回 → 完了 |
| 出来た画像 | 932KB・768×1024 |
| iPhone への配信 |
GET /images/…png 200(宛先 192.168.11.11 = iPhone 自身のアドレス) |
GET /images/… の 200 が iPhone の IP 宛に出たところで終わりです。ソファに座ったまま画像が出ました。
この順番でよかったのか
やった順は「server.host を変える → 画面を直す → 実機で通す」で、手順2の頭に書いたとおり、計画を1回入れ替えた結果です。
⭐ 結果として、手順1と2を終えてから、1回だけ GPU を借りる形になりました。元の順のまま押し通していたら、料金を払いながら CSS を直していたはずです。入れ替えの判断そのものは節約のためではなく、「確かめる手段が無い」という理由でした。安く済んだのは後から付いてきたものです。
まとめ
server.host を 0.0.0.0 にする1行そのものは、やはり1行でした。時間を取ったのは、その前後で決めた二択のほうです。並べるとこうなります。
| 迷った場面 | どちらの動きにしたか |
|---|---|
| 設定ファイルが読めない |
127.0.0.1 のまま動かす + 理由をターミナルに出す
|
| 接続元のIPアドレスが取れない | 外から来たものとして扱う(パスワードを訊く) |
X-Forwarded-For に 127.0.0.1 と書いてある |
見ない。信じるのは request.client.host だけ |
パスワードが無いのに server.host が 0.0.0.0
|
🔴 どちらも選ばない。起動を止める |
| 認証をどこに置くか | ルートごとの Depends ではなく、どのルートでも通るミドルウェアへ |
| 古いテストが実装を止めた | 消さずに、守りたかったものへ言い直す |
| 実機の確認をいつやるか | 最後に1回(お金がかかるので) |
⭐ 表の中で1行だけ「どちらも選ばない」があります。黙って安全なほうを選ぶと、間違いが起きたことごと隠れるからです。0.0.0.0 にしたつもりの人を黙って 127.0.0.1 で動かせば、その人は iPhone から繋がらない理由を探してルータや Wi-Fi を半日疑うことになります。安全なほうを選んでよいのは、選んだことを本人に伝えられるときだけでした。
もう1つ持ち帰ったのは、古いテストを消さずに書き直したことです。「127.0.0.1 であること」を「127.0.0.1 でないならパスワードがあること」に変えた瞬間、テストが実装の写しから方針の記録に変わりました。落ちたテストを見たとき、消すか実装をやめるかの二択に見えますが、そのテストが本当は何を守りたかったのかを聞き直すという三つ目があります。
外出先からも使いたい気持ちはありますが、それは家の Wi-Fi の中とはまったく別の話になるので、今回はここまでにしました。