-- Views
September 16, 26
スライド概要
iOSDC2026で発表した標準SDKだけで作る デスクトップアプリ内HTTPサーバーについて
iOS app developer
標準SDKだけで作る デスクトップアプリ内HTTPサーバー NWListenerとPOSIX socketの落とし穴 Norihiko Oba / iOSDC Japan 2026 / Track D
大庭 規彦 @higan96 • iOSエンジニア • 株式会社ユビレジ • 個人開発 • Kujaku
Kujaku 生成画像を閲覧・分類するMacアプリ HTTPサーバーとして動作し LAN内で画像配信できる機能を開発 このときの開発でハマった「落とし穴」についてのお話になります
本日話すこと • サーバー実装の前準備 • 実装の開始と落とし穴 • 問題を切り分けて実験で絞り込む • App Sandboxのログと設定から接続できる条件を探る
サーバー実装の前準備
HTTPサーバー開発の3つの選択肢 1. Network.framework — NWListener(2018年〜) • 2025年にはSwift Concurrency対応のNetworkListenerが追加 • Network framework is by far the best choice.(TN3151) 2. Darwin — POSIX socket • import Darwinで呼べるBSD由来の標準API 3. それらを使うライブラリ(SwiftNIOなど、今回は扱わない)
HTTPサーバー開発の3つの選択肢 1. Network.framework — NWListener(2018年〜) • 2025年にはSwift Concurrency対応のNetworkListenerが追加 • Network framework is by far the best choice.(TN3151) 2. Darwin — POSIX socket • import Darwinで呼べるBSD由来の標準API 3. それらを使うライブラリ(SwiftNIOなど、今回は扱わない)
import Foundation;
import Network
let parameters: NWParametersBuilder<TCP> = .parameters { TCP() }.localPort(8080)
let listener = try NetworkListener(using: parameters)
try await listener.run { connection in
①portの確保 + ②接続の到着を監視
let request = try await connection.receive(atLeast: 1, atMost: 8192)
③dataの受信
let response = Data(
"HTTP/1.1 200 OK\r\nContent-Length: 12\r\n\r\nHello World!".utf8
)
try await connection.send(response, endOfStream: true)
}
④応答して送信を終了
App Sandboxとは • macOSがカーネルで強制するアクセス制御の仕組みで、アプリが乗っ取られ るような場合の被害を制限する • ネットワーク、ファイル、カメラなど、必要なアクセスをentitlementで宣言 する • こちらを有効化するのがMac App Store配布には必須
App Sandboxの設定を行う
実装の開始と落とし穴
let queue = DispatchQueue(label: "HTTPServer")
let listener = try NWListener(using: .tcp, on: 8080)
①portの準備
listener.stateUpdateHandler = { print("listener:", $0) }
②接続到着を監視する準備
listener.newConnectionHandler = { connection in
connection.stateUpdateHandler = { print("connection:", $0)}
connection.start(queue: queue)
③dataの受信
connection.receive(
minimumIncompleteLength: 1,
maximumLength: 8_192
) { _, _, _, _ in
let response = Data("HTTP/1.1 200 OK\r\nContent-Length: 12\r\n\r\nHello World!".utf8)
connection.send(content: response, completion: .contentProcessed { _ in connection.cancel() } )
}
}
④応答して
listener.start(queue: queue)
④接続を閉じる
①portの確保 + ②接続到着の監視を開始
Mac Safari -> 200 OK
iPhone Safari -> 接続不可
状況の整理 • Macのlocalhostでは ✅ 200 OK • 同じWi-Fi(LAN)につながるiPhoneのブラウザからは接続不可 • エラーもなし • NWListenerの各APIは「エラーを返していない」 • 待ち受け状態(NWListener.State)も.failed(error)にならない • .ready(外部からの接続を待機中)のまま • newConnectionHandlerが呼ばれない
疑ったこと 端末間通信がルーターの 設定で禁止されている? macOS irewallが 影響している? Local Networkの権限が 許可されてない? App Sandboxの受信権限を f 設定し忘れ? 確認したこと 接続結果 未設定 不達のまま ON -> OFF 不達のまま TCP受信は対象外 不達のまま 有効 不達のまま
問題を切り分け、実験で絞り込む
アプリ内HTTPサーバーの通信の流れ 5つの層に切り分け、それぞれの層で検証する 1 2 3 4 5
実験1: localhostでの接続 ■ 実験内容 Macのブラウザからlocalhost(127.0.0.1)へ接続 ■ 検証内容 ブラウザ上に「Hello World!」と表示されHTTPが適切に処理され応答が 返ること
localhost 200 OK
アプリ内HTTPサーバーの通信の流れ 少なくともHTTPは適切に処理されレスポンスが返っている 1 2 3 4 5
実験2: Python HTTPサーバーへのLAN内スマホからの接続 ■ 実験内容 Mac上のPython HTTPサーバーにLAN内のスマホからアクセス $ python3 -m http.server 8000 --bind 0.0.0.0 ■ 検証内容 実行したディレクトリのファイルを見ることができた場合、LANを通りスマホ からMacに到達したことが確認できる
Python HTTPサーバーで繋がった
アプリ内HTTPサーバーの通信の流れ Pythonプロセスに到達したのでLAN 経路とiPhone側は問題ない 1 2 3 4 5
実験3: Darwin/POSIX socketでの接続 ■ 実験内容 3つの選択肢として挙げたなかの2つ目 NWListenerより低レベルなBSD socket APIで同じHTTPサーバーを実装し、接 続がどこまで到達するかを観測する ■ 検証内容 socket、bind、listen、acceptのどこで止まるのかを確認する
let fd = Darwin.socket(AF_INET, SOCK_STREAM, IPPROTO_TCP)
guard fd >= 0 else { throw .posix("socket", errno) }
var addr = sockaddr_in()
addr.sin_len = UInt8(MemoryLayout<sockaddr_in>.size)
addr.sin_family = sa_family_t(AF_INET)
addr.sin_port = UInt16(8080).bigEndian
addr.sin_addr = in_addr(s_addr: INADDR_ANY) // 0.0.0.0
guard Darwin.bind(fd, ...) == 0 else { throw .posix("bind", errno) }
guard Darwin.listen(fd, SOMAXCONN) == 0 else { throw .posix("listen", errno) }
let source = DispatchSource.makeReadSource( ileDescriptor: fd, queue: queue)
source.setEventHandler {
let client = Darwin.accept(fd, nil, nil)
guard client >= 0 else {
print("accept:", String(cString: strerror(errno)))
return
}
// レスポンス送信部分は省略
f
}
self.source = source
source.resume()
Macアプリ + Darwin/POSIX socketで繋がった
アプリ内HTTPサーバーの通信の流れ Darwin/POSIX socketで実装したアプリが「完全に」疎通した 1 2 3 4 5
NWListenerが原因なのか? Darwin/POSIX socketで実装したアプリが疎通したことでAPIの違いに原因の可 能性があるのではないか? ■ 可能性 1. NWListenerに原因があり、APIの利用を諦めるべきなのか? 2. NWListenerとApp Sandboxなど「実行環境の組み合わせ」が原因なのか? 次の実験ではNWListenerをCLIで動かし、NWListenerが原因の線を消していく
実験4: NWListenerをCLIで実行 ■ 実験内容 App SandboxなしのCLIで、同じNWListenerを動かす ■ 検証内容 アプリの外でiPhoneへのHTTP応答が届くか。 失敗した場合、断定はできないがAPIが関与している可能性が高まる。
let queue = DispatchQueue(label: "HTTPServer") let listener = try NWListener(using: .tcp, on: 8080) listener.stateUpdateHandler = { print("listener:", $0) } listener.newConnectionHandler = { connection in connection.stateUpdateHandler = { print("connection:", $0) } connection.start(queue: queue) connection.receive(minimumIncompleteLength: 1, maximumLength: 8_192) { _, _, _, _ in let response = Data("HTTP/1.1 200 OK(省略)".utf8) connection.send(content: response, completion: .contentProcessed { _ in connection.cancel() }) } } listener.start(queue: queue) dispatchMain()
NWListener + CLIで繋がった
アプリ内HTTPサーバーの通信の流れ NWListener CLIがすべての経路を通過した 1 2 3 4 5
アプリとCLIで何が違う? アプリ CLI 有効 なし コード署名 Apple Development ad-hoc (開発者証明書なし) アプリの識別情報 Bundle IDあり なし App Sandbox App Sandboxを無効にした構成で、実験5を行う
実験5: App Sandboxの無効化 ■ 実験内容 NWListenerのコード・通信設定は変えずApp Sandboxを無効にして再ビルド し、同じWi-Fiのスマホからブラウザでアクセスする ■ 検証内容 ・App Sandboxの有効・無効で、接続の成否が変わるか
NWListener + アプリ + App Sandbox無効化で繋がった
実験5: App Sandboxの無効化 ■ 結果 ✅ App Sandboxを無効にするとHTTPレスポンスが届いた 再び有効にすると、接続できなくなった ■ わかったこと App Sandboxの有無で接続の成否が分かれた App Sandboxの関与は確認できたが、接続を拒否する内部の仕組みまでは特定 できていない
実験で確認したこと ② LAN ③ LAN→Mac 1. localhost 2. Pythonサーバー 3. Darwin POSIX socket 未確認 LAN経由で到達 LAN経由で到達 未確認 Pythonプロセスに 到達 アプリに到達 ④ LAN接続の受理 未確認 未確認 accept成功 ⑤ HTTP処理 200 OK 未確認 200 OK 4. NWListener + CLI 5. App Sandbox の無効化 LAN経由で到達 LAN経由で到達 CLIプロセスに 到達 アプリに到達 接続callbackが 接続callbackが 呼ばれる 呼ばれる 200 OK 200 OK
App Sandboxのログと設定から 接続できる条件を探る
接続生成のログにEPERMの手がかり iPhoneの接続時に記録されたログ(抜粋・改行) nw_path_evaluator_create_ low_inner NECP_CLIENT_ACTION_ADD_FLOW … [1: Operation not permitted] nw_connection_create_from_protocol_on_nw_queue [C14] Failed to create connection from listener errno 1 = EPERM: 接続用 lowの追加が許可されていない f f このログだけでは拒否の主体は分からないので、kernelログで確かめる
Sandboxのログにnetwork-outboundの拒否 Console.app で kernel のログを見る 14:37:09.727 (Sandbox) probe-server-only(26871) deny(1) network-outbound remote:*:51671 14:37:09.727 SK[0]: low_req_prepare 14:37:09.727 SK[0]: fsw_ low_add low req failed MAC check failed to add low_uuid … (err 1) 14:37:09.727 necp_client_add_ low: Add low error (1) network-outboundの拒否とADD_FLOWのエラー1が同時刻・同一threadに記録 f f f f f f iPhoneからの受信接続の処理で、outboundが拒否されMAC(Mandatory Access Control)checkが失敗していることが観測された
Outgoing Connections (Client) を足す
Outgoing Connections (Client)の有効化で繋がった
繋がった ■ 検証結果(macOS 26.5.2) ✅ Incoming Connections + Outgoing ConnectionsでiPhoneへHTTP応答が届いた ✅ App Sandboxは有効のまま ■ 疑問 受信側の通信のはずなのになぜか送信側の通信として扱われApp Sandboxに拒否され ている。 なんでこんな挙動なのか?
公開XNUから分かること GitHubで公開されているXNU(kernel)の関連処理を要約すると、「相手のアドレスの有無」で connect側/listen側のMAC hookを選んでいる(Sandbox に許可を問い合わせる処理) // xnu: low_manager.c low_req_check_mac_allowed()の要約 if (!has_daddr && !has_dport) // remote address も port もない mac_skywalk_ low_check_listen(…) // listen側として check else mac_skywalk_ low_check_connect(…) // connect側として check 受信側として処理されるべき接続が送信側の処理に振られることでApp Sandboxの「Outgoing f f f f Connections (Client)」の権限不足で拒否されたと考えるとログとも整合するので可能性が高い
まとめ • 原因としてはkernelがアドレスを持つ受信側の通信を送信側(connect側)の MAC hook(許可設定の確認)にまわしているのが原因の可能性が高い • App Sandboxのpolicyが非公開なので断定はできない • ここが落とし穴の正体と言えそう • 通信を伴うMacアプリの場合、受信だけでも送信の許可をApp Sandboxで有効に すると意図通り動くケースがある • 環境に依存した振る舞いの可能性もあるので状況をみて設定する • 問題を切り分けて実験を繰り返せばkernelの謎挙動までたどり着く
実際に作ったもの 1. LAN閲覧機能を開始 2. QRコードの発行 3. QRコードを読み取りURLを取得したら ブラウザでアクセス 4. 画像一覧が表示 5. 画像選択で詳細を表示
ご清聴ありがとう ございました