diff --git a/README.md b/README.md index 9addad8..33eaea5 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,10 @@ Inferno guide. For the AVF backend, the owner's `ios-bundle` workflow builds an app and iOS 16.5 (see [docs/ETHICS.md](docs/ETHICS.md), point 6). This repo then gets the result running on the phone. +The app doesn't use the network unless you turn **Network** on for an install (off by default; see +[docs/PHONE-QUICKSTART.md](docs/PHONE-QUICKSTART.md#network-qemu)). It declares Android's INTERNET +permission for that setting, and Android grants it at install. + ## What runs where ``` diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c17661e..ba47519 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -10,9 +10,14 @@ (adb shell pm grant ); never at runtime. --> - + + + diff --git a/app/src/main/java/io/github/dylanneve1/iosonpixel/Installs.kt b/app/src/main/java/io/github/dylanneve1/iosonpixel/Installs.kt index 0f72b98..38a60a4 100644 --- a/app/src/main/java/io/github/dylanneve1/iosonpixel/Installs.kt +++ b/app/src/main/java/io/github/dylanneve1/iosonpixel/Installs.kt @@ -225,12 +225,13 @@ object InstallMigration { /** A fingerprint of the old preferences: settings and location. */ fun seen(settings: VmSettings, vmInternal: Boolean): String = - encode(settings).apply { remove(KEY_AVF_DISPLAY); remove(KEY_AVF_NETWORK); remove(KEY_AVF_CAMERA) }.put("vm_internal", vmInternal).toString() + encodeLegacy(settings).put("vm_internal", vmInternal).toString() - /** Per-install only (not in the old preferences), so it's left out of [seen] and kept by [resync]. */ + /** Per-install only (not in the old preferences), so they're left out of [seen] and kept by [resync]. */ private const val KEY_AVF_DISPLAY = "avf_display" private const val KEY_AVF_NETWORK = "avf_network" private const val KEY_AVF_CAMERA = "avf_camera" + private const val KEY_QEMU_NETWORK = "qemu_network" /** * After a downgrade and re-upgrade: if the old preferences no longer match [seen] (what this @@ -245,6 +246,7 @@ object InstallMigration { if (it.legacy) it.copy( settings = legacy.settings.copy( avfDisplay = it.settings.avfDisplay, avfNetwork = it.settings.avfNetwork, avfCamera = it.settings.avfCamera, + qemuNetwork = it.settings.qemuNetwork, ), internal = legacy.vmInternal, ) @@ -285,7 +287,12 @@ object InstallMigration { }.takeIf { list -> list.map { it.id }.toSet().size == list.size } }.getOrNull() - private fun encode(s: VmSettings) = JSONObject().apply { + /** + * The settings the single-VM app's preferences also have, in the order [encode] writes them. + * A fresh object rather than [encode] minus the per-install keys: org.json's (JVM) map would + * order the keys differently once it has grown, and the fingerprint must not change. + */ + private fun encodeLegacy(s: VmSettings) = JSONObject().apply { put("ram_gb", s.ramGb) put("smp", s.smp) put("tb_size_mb", s.tbSizeMb) @@ -295,9 +302,13 @@ object InstallMigration { put("in_process", s.inProcess) put("security", s.security.id) put("extra_args", s.extraArgs) + } + + private fun encode(s: VmSettings) = encodeLegacy(s).apply { put(KEY_AVF_DISPLAY, s.avfDisplay) put(KEY_AVF_NETWORK, s.avfNetwork) put(KEY_AVF_CAMERA, s.avfCamera) + put(KEY_QEMU_NETWORK, s.qemuNetwork) } private fun decodeSettings(o: JSONObject): VmSettings { @@ -315,6 +326,7 @@ object InstallMigration { avfDisplay = o.optBoolean(KEY_AVF_DISPLAY, d.avfDisplay), avfNetwork = o.optBoolean(KEY_AVF_NETWORK, d.avfNetwork), avfCamera = o.optBoolean(KEY_AVF_CAMERA, d.avfCamera), + qemuNetwork = o.optBoolean(KEY_QEMU_NETWORK, d.qemuNetwork), ) } } diff --git a/app/src/main/java/io/github/dylanneve1/iosonpixel/MainActivity.kt b/app/src/main/java/io/github/dylanneve1/iosonpixel/MainActivity.kt index a00e0c4..9672d69 100644 --- a/app/src/main/java/io/github/dylanneve1/iosonpixel/MainActivity.kt +++ b/app/src/main/java/io/github/dylanneve1/iosonpixel/MainActivity.kt @@ -68,6 +68,7 @@ class MainActivity : Activity() { private lateinit var pinning: TextView private lateinit var splitWx: Switch private lateinit var inProcess: Switch + private lateinit var qemuNetwork: Switch private lateinit var avfDisplay: Switch private lateinit var avfNetwork: Switch private lateinit var avfCamera: Switch @@ -514,6 +515,13 @@ class MainActivity : Activity() { ) // The checks depend on it, so apply it right away. inProcess.setOnCheckedChangeListener { _, _ -> if (!filling) { saveSettings(); render() } } + qemuNetwork = switch( + advanced, "Network (QEMU)", + "QEMU only: the guest reaches the internet through the phone's connection (Wi-Fi or mobile data), over " + + "its USB link: iOS shows it as a wired network, not Wi-Fi. Off: QEMU opens no network socket. " + + "Restore boots never use it. Applies from the next start.", s.qemuNetwork, + ) + qemuNetwork.setOnCheckedChangeListener { _, _ -> if (!filling) { saveSettings(); render() } } extraArgs = ui.field( advanced, "Extra arguments", s.extraArgs, help = "Appended to the QEMU command line, or to the AVF guest's kernel command line. " + @@ -620,6 +628,7 @@ class MainActivity : Activity() { affinity.setText(s.vcpuAffinity) splitWx.isChecked = s.splitWx inProcess.isChecked = s.inProcess + qemuNetwork.isChecked = s.qemuNetwork avfDisplay.isChecked = s.avfDisplay avfNetwork.isChecked = s.avfNetwork avfCamera.isChecked = s.avfCamera @@ -633,7 +642,8 @@ class MainActivity : Activity() { setAdvancedShown( s.vcpuAffinity != d.vcpuAffinity || s.splitWx != d.splitWx || s.inProcess != d.inProcess || s.backend != d.backend || s.security != d.security || s.extraArgs != d.extraArgs || - s.avfDisplay != d.avfDisplay || s.avfNetwork != d.avfNetwork || s.avfCamera != d.avfCamera || devSession, + s.avfDisplay != d.avfDisplay || s.avfNetwork != d.avfNetwork || s.avfCamera != d.avfCamera || + s.qemuNetwork != d.qemuNetwork || devSession, ) } @@ -805,13 +815,18 @@ class MainActivity : Activity() { statusDot.background = ui.pill(dotColor) val notice = VmState.notice?.takeIf { ranHere } statusDetail.setTextColor(if (notice != null) Ui.BAD else Ui.MUTED) - statusDetail.text = when { - notice != null -> notice + (lastExit?.let { "\nLast exit: $it" } ?: "") - lastExit != null -> "Last exit: $lastExit" - st == VmState.Status.RUNNING && backend.hasDisplay -> "Open the display to use the iPhone." - st == VmState.Status.RUNNING -> "No display yet: the guest console is in Diagnostics, below." - else -> "" - } + // The QEMU Network setting's state for the running VM ("Network: waiting for guest", ...). + val network = VmState.network?.takeIf { ranHere && st != VmState.Status.STOPPED } + statusDetail.text = listOfNotNull( + when { + notice != null -> notice + (lastExit?.let { "\nLast exit: $it" } ?: "") + lastExit != null -> "Last exit: $lastExit" + st == VmState.Status.RUNNING && backend.hasDisplay -> "Open the display to use the iPhone." + st == VmState.Status.RUNNING -> "No display yet: the guest console is in Diagnostics, below." + else -> null + }, + network, + ).joinToString("\n") statusDetail.visibility = if (statusDetail.text.isEmpty()) View.GONE else View.VISIBLE val list = backend.checks() @@ -966,6 +981,7 @@ class MainActivity : Activity() { avfDisplay = avfDisplay.isChecked, avfNetwork = avfNetwork.isChecked, avfCamera = avfCamera.isChecked, + qemuNetwork = qemuNetwork.isChecked, ) } diff --git a/app/src/main/java/io/github/dylanneve1/iosonpixel/Monitor.kt b/app/src/main/java/io/github/dylanneve1/iosonpixel/Monitor.kt index 033e9f7..332acce 100644 --- a/app/src/main/java/io/github/dylanneve1/iosonpixel/Monitor.kt +++ b/app/src/main/java/io/github/dylanneve1/iosonpixel/Monitor.kt @@ -11,7 +11,10 @@ import java.util.concurrent.Executors class Monitor(private val paths: AppPaths) { /** Sends one command synchronously; returns false if QEMU isn't listening. */ - fun send(command: String): Boolean = try { + fun send(command: String): Boolean = query(command) != null + + /** Sends one command synchronously and returns what QEMU printed (up to the last 4 KiB), or null if it isn't listening. */ + fun query(command: String): String? = try { LocalSocket().use { s -> s.connect(LocalSocketAddress(paths.monitorSocket.absolutePath, LocalSocketAddress.Namespace.FILESYSTEM)) s.soTimeout = 2000 @@ -19,25 +22,25 @@ class Monitor(private val paths: AppPaths) { readUntilPrompt(input) s.outputStream.write("$command\n".toByteArray()) s.outputStream.flush() - readUntilPrompt(input) + readUntilPrompt(input).removeSuffix(PROMPT) } - true } catch (e: Exception) { - false + null } - private fun readUntilPrompt(input: java.io.InputStream) { + private fun readUntilPrompt(input: java.io.InputStream): String { val tail = StringBuilder() while (true) { val b = input.read() - if (b < 0) return + if (b < 0) return tail.toString() tail.append(b.toChar()) - if (tail.endsWith("(qemu) ")) return + if (tail.endsWith(PROMPT)) return tail.toString() if (tail.length > 4096) tail.delete(0, 2048) } } companion object { + private const val PROMPT = "(qemu) " private val io = Executors.newSingleThreadExecutor() /** Presses QEMU key [key] for [holdMs] without blocking the caller. */ diff --git a/app/src/main/java/io/github/dylanneve1/iosonpixel/QemuBackend.kt b/app/src/main/java/io/github/dylanneve1/iosonpixel/QemuBackend.kt index 7cf26fb..23543b1 100644 --- a/app/src/main/java/io/github/dylanneve1/iosonpixel/QemuBackend.kt +++ b/app/src/main/java/io/github/dylanneve1/iosonpixel/QemuBackend.kt @@ -60,24 +60,40 @@ class QemuBackend(ctx: Context, private val install: Install) : VmBackend { val extra = QemuCommand.extraArgs(app) if (extra.isNotEmpty()) log("debug: ${extra.size} extra QEMU args appended from files/qemu-extra-args.txt") val cmd = QemuCommand.build(paths, vm, settings) + settings.extraArgList + extra + val network = QemuCommand.network(vm, settings) + when { + network -> log("network: on (slirp behind the guest's USB link)") + settings.qemuNetwork -> log("network: restore boot, so the Network setting is not used") + } log("$ " + cmd.joinToString(" ")) + VmState.network(QemuNet.statusText(settings.qemuNetwork, QemuCommand.restoreBoot(vm), connected = false, address = null)) + + // With the Network setting on, slirp reads the phone's DNS servers from this file (#82). + val resolver = if (network) QemuNet.Resolver(app, paths, log).also { it.start() } else null + val env = QemuCommand.env(paths.tmpDir, settings) + + (if (resolver != null) mapOf(QemuNet.ENV_RESOLV_CONF to paths.resolvConf.absolutePath) else emptyMap()) + fun watched(i: VmBackend.Instance): VmBackend.Instance = + if (resolver != null) QemuNet.Watch(i, paths, resolver) else i + fun failed(message: String): VmBackend.Instance? { + log(message) + resolver?.stop() + VmState.network(null) + return null + } - val env = QemuCommand.env(paths.tmpDir, settings) if (settings.inProcess) { val vmProcess = QemuInProcess(app, paths, cmd, vm.dir.absolutePath, env) - if (vmProcess.bind()) return vmProcess - log("error: failed to start QEMU: cannot bind the :vm service") - return null + if (vmProcess.bind()) return watched(vmProcess) + return failed("error: failed to start QEMU: cannot bind the :vm service") } val pb = ProcessBuilder(cmd).directory(vm.dir).redirectErrorStream(true) pb.environment().putAll(env) return try { - Instance(pb.start()) + watched(Instance(pb.start())) } catch (e: Exception) { - log("error: failed to start QEMU: $e") - null + failed("error: failed to start QEMU: $e") } } @@ -165,6 +181,18 @@ object QemuCommand { return runCatching { f.readText().split(Regex("\\s+")).filter { it.isNotEmpty() } }.getOrDefault(emptyList()) } + /** The slirp netdev's id; the machine's net uplink names it. */ + const val NETDEV_ID = "n0" + + /** A restore boot: the VM folder names a restore ramdisk (passed as -initrd). */ + fun restoreBoot(vm: VmLayout): Boolean = vm.ramdisk?.isFile == true + + /** + * Whether this boot gets the guest network: the Network setting is on and it is a normal boot. + * Restore boots always keep the inferno uplink, which restore tools talk to. + */ + fun network(vm: VmLayout, s: VmSettings): Boolean = s.qemuNetwork && !restoreBoot(vm) + /** * Environment for QEMU, the same for the child process and the in-process :vm path. * TMPDIR is where -snapshot puts its overlays (vl.XXXXXX): the app's cache dir, which @@ -179,17 +207,27 @@ object QemuCommand { /** Mirrors the sample command from the Inferno guide, minus host-only display flags. */ fun build(paths: AppPaths, vm: VmLayout, s: VmSettings): List { fun path(key: String) = vm.file(key)!!.absolutePath - val machine = listOf( - "t8030", - "trustcache=${path("trustcache")}", - "sep-fw=${path("sep_fw")}", - "sep-rom=${path("sep_rom")}", - "kaslr-off=true", + val network = network(vm, s) + val uplink = if (network) listOf( + // The guest's USB NCM link, bridged to the slirp netdev below (Inferno's + // usb-uplink-type=net: QEMU is the USB host and puts the iPhone in mode 3). + "usb-uplink-type=net", + "usb-uplink-addr=$NETDEV_ID", + ) else listOf( // Connect out over a unix socket (retried quietly while nothing // listens) instead of the default TCP server, which needs INTERNET. "usb-uplink-type=inferno", "usb-uplink-addr=unix:${paths.usbUplinkSocket.absolutePath}", - ).joinToString(",") + ) + val machine = ( + listOf( + "t8030", + "trustcache=${path("trustcache")}", + "sep-fw=${path("sep_fw")}", + "sep-rom=${path("sep_rom")}", + "kaslr-off=true", + ) + uplink + ).joinToString(",") val accel = buildString { append("tcg,thread=multi,tb-size=").append(s.tbSizeMb) @@ -223,6 +261,9 @@ object QemuCommand { "-drive", "file=sep_nvram,if=pflash,format=raw,file.locking=off", "-drive", "file=sep_ssc,if=pflash,format=raw,file.locking=off", ) + // User-mode networking: DHCP, DNS and NAT in QEMU, through ordinary sockets in the app's + // UID. Only with the Network setting on; off, there is no netdev at all. + if (network) args += listOf("-netdev", "user,id=$NETDEV_ID") fun nvme(id: String, file: String, format: String, device: String, nsid: Int, nstype: Int, extra: String = "") { args += listOf( diff --git a/app/src/main/java/io/github/dylanneve1/iosonpixel/QemuNet.kt b/app/src/main/java/io/github/dylanneve1/iosonpixel/QemuNet.kt new file mode 100644 index 0000000..431dad2 --- /dev/null +++ b/app/src/main/java/io/github/dylanneve1/iosonpixel/QemuNet.kt @@ -0,0 +1,171 @@ +package io.github.dylanneve1.iosonpixel + +import android.content.Context +import android.net.ConnectivityManager +import android.net.LinkProperties +import android.net.Network +import java.io.File +import java.net.Inet6Address +import java.net.InetAddress + +/** + * The QEMU backend's Network setting at run time ([VmSettings.qemuNetwork], docs/NETWORKING.md): + * the phone's DNS servers for slirp, and the status line. + * + * libslirp normally reads /etc/resolv.conf, which Android doesn't have. Our build reads the file + * named by [ENV_RESOLV_CONF] instead (#82), so the app writes the default network's DNS servers + * (from [LinkProperties]) to [AppPaths.resolvConf] and rewrites it when they change. slirp notices + * a new file by itself; QEMU needs no restart. + */ +object QemuNet { + /** QEMU's environment: the resolv.conf-format file libslirp reads (set before QEMU starts, never changed). */ + const val ENV_RESOLV_CONF = "SLIRP_RESOLV_CONF" + + /** Inferno's usb-uplink-type=net reports the guest's NCM link state with these lines. */ + private const val CONNECTED = "usb-net-uplink: guest reports network connected" + private const val DISCONNECTED = "usb-net-uplink: guest reports network disconnected" + + /** slirp's default network: 10.0.2.2 is the gateway, 10.0.2.3 the DNS forwarder, guests start at .15. */ + private val GUEST_ADDR = Regex("""\b10\.0\.2\.(\d{1,3})\b""") + + /** + * resolv.conf text for [servers], or null when there is none: an empty file would make slirp + * fall back to 127.0.0.1, which has no resolver on Android, so the caller deletes it instead. + * A link-local IPv6 server gets the interface ([ifname]) as its scope. + */ + fun resolvConf(servers: List, ifname: String?): String? { + if (servers.isEmpty()) return null + return servers.joinToString("") { a -> + val host = a.hostAddress!!.substringBefore('%') + val scope = if (a is Inet6Address && a.isLinkLocalAddress && !ifname.isNullOrEmpty()) "%$ifname" else "" + "nameserver $host$scope\n" + } + } + + /** The guest's address in `info usernet` output: the first 10.0.2.x that isn't slirp's own. */ + fun guestAddress(usernet: String): String? = + GUEST_ADDR.findAll(usernet).map { it.value to it.groupValues[1].toInt() } + .firstOrNull { (_, host) -> host !in setOf(0, 2, 3, 255) && host < 256 }?.first + + /** The status line's text for a QEMU run; see [VmState.network]. */ + fun statusText(on: Boolean, restore: Boolean, connected: Boolean, address: String?): String = when { + !on -> "Network: off" + restore -> "Network: off for restore boots" + address != null -> "Network: guest has $address" + connected -> "Network: guest connected, waiting for its address" + else -> "Network: waiting for guest" + } + + /** Writes [text] to [file] atomically (a temp file renamed over it), or deletes [file] when [text] is null. */ + fun writeAtomically(file: File, text: String?) { + if (text == null) { + file.delete() + return + } + file.parentFile?.mkdirs() + val tmp = File(file.parentFile, file.name + ".tmp") + tmp.writeText(text) + if (!tmp.renameTo(file)) { + tmp.delete() + error("cannot replace ${file.path}") + } + } + + /** + * Follows the phone's default network while a VM with the Network setting runs, keeping + * [AppPaths.resolvConf] in step with its DNS servers. [stop] when the VM is gone. + */ + class Resolver(ctx: Context, private val paths: AppPaths, private val log: (String) -> Unit) { + private val cm = ctx.getSystemService(ConnectivityManager::class.java) + private var last: String? = null + + private val callback = object : ConnectivityManager.NetworkCallback() { + override fun onLinkPropertiesChanged(network: Network, lp: LinkProperties) = update(lp) + override fun onLost(network: Network) = update(null) + } + + /** Writes the current servers now, then follows changes. */ + fun start() { + update(cm.activeNetwork?.let { cm.getLinkProperties(it) }) + runCatching { cm.registerDefaultNetworkCallback(callback) } + .onFailure { log("network: cannot follow the phone's network: $it") } + } + + fun stop() { + runCatching { cm.unregisterNetworkCallback(callback) } + paths.resolvConf.delete() + } + + @Synchronized private fun update(lp: LinkProperties?) { + val text = lp?.let { resolvConf(it.dnsServers, it.interfaceName) } + if (text == last && (text == null || paths.resolvConf.isFile)) return + last = text + runCatching { writeAtomically(paths.resolvConf, text) } + .onSuccess { + log( + if (text == null) "network: the phone has no DNS server right now; guest DNS fails until it has one" + else "network: DNS through " + text.lines().filter { it.isNotBlank() }.joinToString(", ") { it.removePrefix("nameserver ") }, + ) + } + .onFailure { log("network: cannot write ${paths.resolvConf.path}: $it") } + } + } + + /** + * A QEMU run with the guest network: watches the output for the guest's link, asks slirp + * (`info usernet` on the monitor) for the guest's address, and keeps the status line. + */ + class Watch( + private val inner: VmBackend.Instance, + private val paths: AppPaths, + private val resolver: Resolver, + ) : VmBackend.Instance by inner { + @Volatile private var connected = false + @Volatile private var address: String? = null + @Volatile private var done = false + + init { + publish() + } + + override fun pump(onLine: (String) -> Unit): Int { + val poller = Thread({ poll() }, "vm-net-status").apply { isDaemon = true; start() } + try { + return inner.pump { line -> + when { + CONNECTED in line -> { connected = true; publish() } + DISCONNECTED in line -> { connected = false; address = null; publish() } + } + onLine(line) + } + } finally { + done = true + poller.interrupt() + resolver.stop() + VmState.network(null) + } + } + + /** While the link is up and the address unknown, ask slirp every few seconds. */ + private fun poll() { + try { + while (!done) { + Thread.sleep(POLL_MS) + if (!connected || address != null) continue + val found = Monitor(paths).query("info usernet")?.let { guestAddress(it) } ?: continue + address = found + publish() + } + } catch (_: InterruptedException) { + } + } + + private fun publish() { + if (!done) VmState.network(statusText(on = true, restore = false, connected = connected, address = address)) + } + + private companion object { + const val POLL_MS = 3000L + } + } +} diff --git a/app/src/main/java/io/github/dylanneve1/iosonpixel/VmConfig.kt b/app/src/main/java/io/github/dylanneve1/iosonpixel/VmConfig.kt index 82324f4..4727197 100644 --- a/app/src/main/java/io/github/dylanneve1/iosonpixel/VmConfig.kt +++ b/app/src/main/java/io/github/dylanneve1/iosonpixel/VmConfig.kt @@ -116,6 +116,14 @@ data class VmSettings( * refused, the setting goes back off. Per install only, as [avfDisplay]. */ val avfCamera: Boolean = true, + /** + * QEMU only: the guest's network (docs/NETWORKING.md, #83). Off by default, and off means the + * command line is exactly what it was before this setting: the inferno uplink on a unix + * socket, no -netdev, so nothing opens a network socket. On: slirp (-netdev user) behind the + * guest's USB NCM link ([QemuCommand.network]); restore boots never use it. Per install only, + * like [avfDisplay]. + */ + val qemuNetwork: Boolean = false, ) { /** [extraArgs] as tokens. */ val extraArgList: List get() = extraArgs.split(Regex("\\s+")).filter { it.isNotEmpty() } @@ -213,19 +221,30 @@ object TensorG5 { * Paths the app uses at runtime for install [installId]: its sockets, logs and run records, so * two installs never clobber each other. The install's VM folder itself is [Install.dir]. */ -class AppPaths(ctx: Context, val installId: String) { - private val external: File = ctx.getExternalFilesDir(null) ?: File(ctx.filesDir, "shared") +class AppPaths( + filesDir: File, + val tmpDir: File, + private val external: File, + nativeLibraryDir: String, + val installId: String, +) { + constructor(ctx: Context, installId: String) : this( + filesDir = ctx.filesDir, + tmpDir = ctx.cacheDir, + external = ctx.getExternalFilesDir(null) ?: File(ctx.filesDir, "shared"), + nativeLibraryDir = ctx.applicationInfo.nativeLibraryDir, + installId = installId, + ) - val qemu = File(ctx.applicationInfo.nativeLibraryDir, "libqemu-system-aarch64.so") + val qemu = File(nativeLibraryDir, "libqemu-system-aarch64.so") /** The in-process variant: QEMU as a library, and the JNI shim that calls it. */ - val qemuLib = File(ctx.applicationInfo.nativeLibraryDir, "libqemu-system-aarch64-shared.so") - val qemuJni = File(ctx.applicationInfo.nativeLibraryDir, "libqemu_jni.so") + val qemuLib = File(nativeLibraryDir, "libqemu-system-aarch64-shared.so") + val qemuJni = File(nativeLibraryDir, "libqemu_jni.so") /** Keymaps etc.: read-only, shared by all installs. */ - val dataDir = File(ctx.filesDir, "qemu-data") - val tmpDir: File = ctx.cacheDir + val dataDir = File(filesDir, "qemu-data") /** files/run/: this install's sockets and run records (short, for the 108-byte socket path limit). */ - val runDir = File(File(ctx.filesDir, "run"), installId) + val runDir = File(File(filesDir, "run"), installId) val vncSocket = File(runDir, "vnc.sock") val monitorSocket = File(runDir, "monitor.sock") /** QMP, for multi-touch input ([QmpTouch]). */ @@ -235,6 +254,11 @@ class AppPaths(ctx: Context, val installId: String) { * defaults to a TCP listener, which needs the INTERNET permission (#67). */ val usbUplinkSocket = File(runDir, "usb-uplink.sock") + /** + * The phone's DNS servers for slirp (the QEMU Network setting): resolv.conf format, named by + * SLIRP_RESOLV_CONF in QEMU's environment, rewritten when the phone's network changes ([QemuNet]). + */ + val resolvConf = File(runDir, "resolv.conf") /** The last run's status and output view, for the debug bundle. */ val lastRun = File(runDir, "last_run.txt") val lastRunOutput = File(runDir, "last_run_output.txt") diff --git a/app/src/main/java/io/github/dylanneve1/iosonpixel/VmService.kt b/app/src/main/java/io/github/dylanneve1/iosonpixel/VmService.kt index 81b0dcf..51c6446 100644 --- a/app/src/main/java/io/github/dylanneve1/iosonpixel/VmService.kt +++ b/app/src/main/java/io/github/dylanneve1/iosonpixel/VmService.kt @@ -50,6 +50,18 @@ object VmState { notifyListeners() } + /** + * The QEMU Network setting's status line for the running VM ("Network: off", "Network: waiting + * for guest", "Network: guest has 10.0.2.15"; see [QemuNet.statusText]), or null when no QEMU VM runs. + */ + @Volatile var network: String? = null + private set + + fun network(text: String?) { + network = text + notifyListeners() + } + /** VMs started in this process (counted at STARTING), so a screen can tell when "its" run is over. */ @Volatile var runs = 0 private set @@ -119,6 +131,7 @@ class VmService : Service() { } VmState.notice(null) + VmState.network(null) VmState.install = install VmState.set(VmState.Status.STARTING, exit = null) val vm = backend.start(settings, VmState::addLine) ?: run { diff --git a/app/src/test/java/io/github/dylanneve1/iosonpixel/QemuCommandTest.kt b/app/src/test/java/io/github/dylanneve1/iosonpixel/QemuCommandTest.kt new file mode 100644 index 0000000..818e245 --- /dev/null +++ b/app/src/test/java/io/github/dylanneve1/iosonpixel/QemuCommandTest.kt @@ -0,0 +1,153 @@ +package io.github.dylanneve1.iosonpixel + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import java.net.InetAddress + +/** + * The QEMU command line. With the Network setting off it must stay exactly what it was before + * the setting existed: the inferno uplink on the unix socket and no -netdev (#83). + */ +class QemuCommandTest { + @get:Rule val tmp = TemporaryFolder() + + private fun paths(): AppPaths { + val files = tmp.newFolder("files") + return AppPaths(files, tmp.newFolder("cache"), tmp.newFolder("external"), "/lib/arm64", "1") + } + + /** A VM folder with every file [VmLayout] wants, optionally with a restore ramdisk. */ + private fun vm(ramdisk: Boolean = false): VmLayout { + val dir = tmp.newFolder("vm") + val names = mapOf( + "kernel" to "kernelcache", "dtb" to "devicetree", "trustcache" to "trustcache", + "sep_fw" to "sep_fw", "sep_rom" to "sep_rom", "root" to "root.qcow2", + ) + val props = names.entries.joinToString("\n") { "${it.key}=${it.value}" } + + "\nroot_format=qcow2\n" + (if (ramdisk) "ramdisk=ramdisk.img\n" else "") + File(dir, VmLayout.MANIFEST).writeText(props) + (names.values + listOfNotNull("ramdisk.img".takeIf { ramdisk })).forEach { File(dir, it).writeText("x") } + return VmLayout(dir) + } + + /** What QemuCommand.build produced before the Network setting, for [s] (child process, no ramdisk). */ + private fun before(p: AppPaths, vm: VmLayout, s: VmSettings): List { + fun f(name: String) = File(vm.dir, name).absolutePath + val args = mutableListOf( + p.qemu.absolutePath, + "-L", p.dataDir.absolutePath, + "-M", "t8030,trustcache=${f("trustcache")},sep-fw=${f("sep_fw")},sep-rom=${f("sep_rom")},kaslr-off=true," + + "usb-uplink-type=inferno,usb-uplink-addr=unix:${p.usbUplinkSocket.absolutePath}", + "-accel", "tcg,thread=multi,tb-size=${s.tbSizeMb}", + "-kernel", f("kernelcache"), + "-dtb", f("devicetree"), + "-append", "tlto_us=-1 mtxspin=-1 agm-genuine=1 agm-authentic=1 agm-trusted=1 serial=3 wdt=-1 -vm_compressor_wk_sw", + "-smp", s.smp.toString(), + "-m", "${s.ramGb}G", + "-display", "none", "-vnc", "unix:${p.vncSocket.absolutePath}", + "-monitor", "unix:${p.monitorSocket.absolutePath},server=on,wait=off", + "-qmp", "unix:${p.qmpSocket.absolutePath},server=on,wait=off", + "-serial", "stdio", + "-drive", "file=sep_nvram,if=pflash,format=raw,file.locking=off", + "-drive", "file=sep_ssc,if=pflash,format=raw,file.locking=off", + "-drive", "file=root.qcow2,format=qcow2,if=none,id=root,file.locking=off,discard=unmap,detect-zeroes=unmap", + "-device", "nvme-ns,drive=root,bus=nvme-bus.0,nsid=1,nstype=1,id=root,logical_block_size=4096,physical_block_size=4096", + ) + for (ns in VmLayout.SMALL_DISKS) args += listOf( + "-drive", "file=${ns.name},format=raw,if=none,id=${ns.name},file.locking=off", + "-device", "${ns.device},drive=${ns.name},bus=nvme-bus.0,nsid=${ns.nsid},nstype=${ns.nstype},id=${ns.name}," + + "logical_block_size=4096,physical_block_size=4096", + ) + return args + } + + @Test fun networkOffIsTheCommandLineFromBefore() { + val p = paths() + val vm = vm() + val s = VmSettings() + val cmd = QemuCommand.build(p, vm, s) + assertEquals(before(p, vm, s), cmd) + assertFalse(cmd.any { it.startsWith("-netdev") || it.startsWith("-nic") || it.startsWith("-net") }) + assertTrue(cmd.any { "usb-uplink-type=inferno" in it }) + } + + @Test fun networkOffWithEverythingElseOnHasNoNetdev() { + val s = VmSettings(inProcess = true, splitWx = true, extraArgs = "-d guest_errors") + val cmd = QemuCommand.build(paths(), vm(ramdisk = true), s) + assertFalse(cmd.any { it.startsWith("-netdev") }) + assertFalse(cmd.any { "usb-uplink-type=net," in it }) + } + + @Test fun networkOnAddsSlirpAndTheNetUplink() { + val p = paths() + val vm = vm() + val off = QemuCommand.build(p, vm, VmSettings()) + val on = QemuCommand.build(p, vm, VmSettings(qemuNetwork = true)) + val machine = on[on.indexOf("-M") + 1] + assertTrue(machine.endsWith(",kaslr-off=true,usb-uplink-type=net,usb-uplink-addr=n0")) + assertFalse("usb-uplink-type=inferno" in machine) + val i = on.indexOf("-netdev") + assertEquals("user,id=n0", on[i + 1]) + // Nothing else changes. + assertEquals(off.filter { !it.startsWith("t8030,") }, on.filterIndexed { n, it -> !it.startsWith("t8030,") && n != i && n != i + 1 }) + } + + @Test fun restoreBootsKeepTheInfernoUplink() { + val p = paths() + val vm = vm(ramdisk = true) + val s = VmSettings(qemuNetwork = true) + assertTrue(QemuCommand.restoreBoot(vm)) + assertFalse(QemuCommand.network(vm, s)) + val cmd = QemuCommand.build(p, vm, s) + assertEquals(cmd, QemuCommand.build(p, vm, VmSettings())) + assertTrue(cmd[cmd.indexOf("-M") + 1].endsWith("usb-uplink-type=inferno,usb-uplink-addr=unix:${p.usbUplinkSocket.absolutePath}")) + assertFalse(cmd.any { it.startsWith("-netdev") }) + } + + @Test fun resolvConfListsTheServers() { + val v4 = InetAddress.getByName("192.168.1.1") + val v6 = InetAddress.getByName("2001:4860:4860::8888") + val ll = InetAddress.getByName("fe80::1") + assertEquals( + "nameserver 192.168.1.1\nnameserver 2001:4860:4860:0:0:0:0:8888\nnameserver fe80:0:0:0:0:0:0:1%wlan0\n", + QemuNet.resolvConf(listOf(v4, v6, ll), "wlan0"), + ) + // No servers: no file, never an empty one (slirp would fall back to 127.0.0.1). + assertEquals(null, QemuNet.resolvConf(emptyList(), "wlan0")) + } + + @Test fun guestAddressSkipsSlirpsOwn() { + val usernet = """ + Hub -1 (n0): + Protocol[State] FD Source Address Port Dest. Address Port RecvQ SendQ + UDP[236 sec] 42 10.0.2.3 53 10.0.2.15 62000 0 0 + TCP[ESTABLISHED] 45 192.168.1.20 40000 10.0.2.15 49152 0 0 + """.trimIndent() + assertEquals("10.0.2.15", QemuNet.guestAddress(usernet)) + assertEquals(null, QemuNet.guestAddress("Hub -1 (n0):\n Protocol[State] FD Source Address\n")) + } + + @Test fun statusLine() { + assertEquals("Network: off", QemuNet.statusText(on = false, restore = false, connected = false, address = null)) + assertEquals("Network: waiting for guest", QemuNet.statusText(on = true, restore = false, connected = false, address = null)) + assertEquals("Network: guest has 10.0.2.15", QemuNet.statusText(on = true, restore = false, connected = true, address = "10.0.2.15")) + assertEquals("Network: off for restore boots", QemuNet.statusText(on = true, restore = true, connected = false, address = null)) + } + + @Test fun settingIsOffByDefaultAndPersists() { + assertFalse(VmSettings().qemuNetwork) + val i = Install(id = "2", name = "x", internalDir = "/a", sharedDir = "/b", internal = true, settings = VmSettings(qemuNetwork = true)) + assertEquals(true, InstallMigration.decode(InstallMigration.encode(listOf(i)))!!.single().settings.qemuNetwork) + // Lists written before the setting existed read as off. + val old = InstallMigration.encode(listOf(i.copy(settings = VmSettings()))).replace(",\"qemu_network\":false", "") + assertFalse("qemu_network" in old) + assertFalse(InstallMigration.decode(old)!!.single().settings.qemuNetwork) + // Per install: not in the single-VM app's preferences, so it never triggers a resync. + assertEquals(InstallMigration.seen(VmSettings(), true), InstallMigration.seen(VmSettings(qemuNetwork = true), true)) + } +} diff --git a/docs/ETHICS.md b/docs/ETHICS.md index be0c3ae..8f776e2 100644 --- a/docs/ETHICS.md +++ b/docs/ETHICS.md @@ -53,6 +53,13 @@ Points 2 and 3 concern the QEMU (Inferno) backend. Nothing of Inferno's firmware is touched, so upstream Inferno's request (point 3) still holds. +7. **It doesn't use the network unless you turn it on.** The app declares Android's INTERNET + permission, because the guest's network (the per-install **Network** settings, QEMU and AVF) runs + in the app's own process. Android grants that permission at install and offers no way to revoke it, + so this is a promise about behaviour: with Network off, which is the default, the app and QEMU open + no network socket, and QEMU's command line has no `-netdev` (a unit test checks it). See + [NETWORKING.md](NETWORKING.md). + ## Why this is a reasonable thing to build * **Emulation itself is lawful and well established.** Building software that diff --git a/docs/NETWORKING.md b/docs/NETWORKING.md index ad67cc6..f13b468 100644 --- a/docs/NETWORKING.md +++ b/docs/NETWORKING.md @@ -270,6 +270,18 @@ byte-for-byte as it is today while it's off: `inferno` uplink, no `-netdev`. So the guarantee changes from "can't reach the network" to "doesn't, unless you turn Network on". README and ETHICS wording should say so. +**Done in the app:** Settings › Advanced › **Network (QEMU)**, per install (`qemu_network` +in the install list). `QemuCommand.build` adds `-netdev user,id=n0` and switches the +uplink to `usb-uplink-type=net,usb-uplink-addr=n0` only when the setting is on and the +boot isn't a restore boot (no `ramdisk=` in vm.properties); `QemuCommandTest` checks +that the command line with the setting off is the one from before. With the setting on, +`QemuNet` writes the default network's DNS servers (`LinkProperties.getDnsServers()`) to +`files/run//resolv.conf`, replaces it atomically when they change, and passes its +path in `SLIRP_RESOLV_CONF`, which our libslirp reads instead of `/etc/resolv.conf` (#82). +The status card shows "Network: off / waiting for guest / guest has 10.0.2.15", from +Inferno's `usb-net-uplink: guest reports network connected` line and slirp's +`info usernet`. + If a build that *can't* reach the network is ever wanted: a product flavor without the permission (about 1 day), or a helper APK that holds INTERNET and gets frames through a socketpair fd passed over Binder (about 1 week). diff --git a/docs/PHONE-QUICKSTART.md b/docs/PHONE-QUICKSTART.md index ca38a44..1e907a8 100644 --- a/docs/PHONE-QUICKSTART.md +++ b/docs/PHONE-QUICKSTART.md @@ -154,6 +154,7 @@ The settings on the main screen are saved when you tap **Start**: | JIT translation cache (MiB) | 1024 | 64–4096 | | vCPU → Tensor G5 core pinning | `auto` | `auto` derives it from the vCPU count (application vCPUs → `2-7`, SEP vCPU → `0-1`; the effective string is shown under the field). Or one cpu-set per vCPU, `;`-separated, the last one repeats. Empty turns pinning off | | Split W^X JIT mapping | off | try it only if QEMU fails to allocate its code buffer | +| Network (QEMU) | **off** | Advanced. The guest's internet through the phone's connection; see [Network](#network-qemu) | Tap **Start**. The status goes *starting → running*, the notification appears, and the display opens. QEMU's output scrolls under **QEMU output** on @@ -218,7 +219,8 @@ With adb you can also read the log directly: * **Touch.** Each finger is passed through as its own contact (over QMP) when the bundled Inferno has multitouch support in its touch controller (Scion-Project/pyre#12); otherwise only one finger works. There's no - hardware keyboard input beyond the buttons above. The app doesn't configure audio or networking. + hardware keyboard input beyond the buttons above. The app doesn't configure audio. Networking is + off unless you turn it on (see [Network](#network-qemu)). * **Long runs are untested.** We don't yet know how Android's background limits (the phantom-process killer) treat a long boot with the screen off. Keep the app or the display in front during the first runs. @@ -226,6 +228,36 @@ With adb you can also read the log directly: no display. See [AVF backend](#avf-backend-experimental) below and [scion-avf](https://github.com/Scion-Project/scion-avf). +## Network (QEMU) + +**Settings › Advanced › Network (QEMU)**, per install, **off by default**. +The design is in [NETWORKING.md](NETWORKING.md). + +* **Off:** QEMU's command line is the same as in builds that had no setting: + the guest's USB port connects out over a unix socket (`usb-uplink-type=inferno`) + and there is no `-netdev`. Nothing opens a network socket. +* **On:** normal boots get QEMU's user-mode network (slirp: DHCP, DNS and NAT) + behind the iPhone's own USB reverse tether (`-netdev user,id=n0` and + `usb-uplink-type=net,usb-uplink-addr=n0`). iOS gets 10.0.2.15 and uses the + phone's Wi-Fi or mobile data. It shows as a wired link: Settings › Wi-Fi stays + empty and there's no Wi-Fi icon. DNS goes to the phone's current DNS servers, + which the app writes to `files/run//resolv.conf` and updates when the + phone changes networks. +* **Restore boots** (a VM folder with a `ramdisk=` entry) always keep the + `inferno` uplink, so the setting has no effect on them. +* The status card shows **Network: off**, **Network: waiting for guest**, or + **Network: guest has 10.0.2.15** while a QEMU VM runs. The output starts with + `network: on (slirp behind the guest's USB link)` and the DNS servers used. +* Needs a QEMU build with libslirp ([#82](https://github.com/Scion-Project/scion/issues/82)). + Without it QEMU exits at once with an error about `-netdev user`. + +**Why the app declares INTERNET.** slirp's sockets belong to the app, so the +app has to declare `android.permission.INTERNET` (and `ACCESS_NETWORK_STATE`, +for the DNS servers). Android grants INTERNET at install and has no switch to +take it away, so the promise is that the app **doesn't** use the network unless +you turn Network on, not that it can't. To check: with the setting off, nothing +shows in `adb shell ss -tuanp` (or `lsof -i`) for the app's processes during a boot. + ## Several installs The **Installs** card at the top of the main screen lists the VMs the app