From 857c5ddca6f5ec1280d64bd56a68a7913ec8c25a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 08:16:39 +0000 Subject: [PATCH 1/8] feat(doctor): detect Docker and container runtimes `noir doctor` gains a `docker` row that answers two questions: is Docker installed, and is its daemon running. A stopped daemon and a refused socket each end with the fix for the current platform; a daemon endpoint that does not answer within 10 s is reported as unknown instead of hanging the check. Docker is optional for NOIR, so the row never changes the exit code, and the probe only runs the local `docker --version` and `docker version`. NOIR also recognises when it runs inside Docker, Podman, Kubernetes or LXC. `doctor` then shows a `container` row, and the "no device" guidance explains USB passthrough, since the cable is rarely the problem there. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01LSsPaaLQJLx6wnsTuHo5hD --- CHANGELOG.md | 11 + README.md | 20 ++ cli/src/main/kotlin/app/noir/cli/Guidance.kt | 113 +++++++ cli/src/main/kotlin/app/noir/cli/Main.kt | 25 +- .../kotlin/app/noir/cli/DockerGuidanceTest.kt | 127 ++++++++ .../kotlin/app/noir/core/platform/Docker.kt | 267 +++++++++++++++++ .../kotlin/app/noir/core/platform/Platform.kt | 2 +- .../app/noir/core/platform/DockerTest.kt | 276 ++++++++++++++++++ 8 files changed, 835 insertions(+), 6 deletions(-) create mode 100644 cli/src/test/kotlin/app/noir/cli/DockerGuidanceTest.kt create mode 100644 core/src/jvmMain/kotlin/app/noir/core/platform/Docker.kt create mode 100644 core/src/jvmTest/kotlin/app/noir/core/platform/DockerTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 172955b..1ff85f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added + +- **Docker detection** — `noir doctor` now reports whether Docker is installed + and whether its daemon is running, and prints the fix when the daemon is + stopped or refuses access. Docker stays optional: the row never changes the + exit code, and the check only runs the local `docker --version` and + `docker version`. +- **container awareness** — NOIR recognises when it runs inside Docker, Podman, + Kubernetes or LXC. `doctor` shows a `container` row, and the "no device" + guidance explains USB passthrough instead of blaming the cable. + ## [0.4.2] — 2026-11-07 **Security and safety release. All users of 0.4.1 or earlier should update.** diff --git a/README.md b/README.md index abc37b2..cb3a70c 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,8 @@ noir doctor NOIR doctor platform Mac OS X aarch64 java 17.0.19 + docker /usr/local/bin/docker + 客户端 29.9.2 · 守护进程 29.9.2 运行中 adb /opt/homebrew/.../platform-tools/adb Version 37.0.1-15733141 device 178b0d2f device 2509FPN0BC @@ -89,6 +91,24 @@ noir setup # 缺什么下什么 noir setup --yes # 不询问,适合脚本 ``` +### Docker 安装 / 运行检测 + +`docker` 一行回答两个问题:Docker 装了没有,守护进程在不在跑。Docker 是可选项 +(只有想在容器里运行 NOIR 才需要),所以这一行只报告,不影响 `doctor` 的退出码: + +| 情况 | `doctor` 显示 | +|---|---| +| 没装 | `未安装(可选:…)` | +| 装了,守护进程在跑 | `客户端 29.9.2 · 守护进程 29.9.2 运行中` | +| 装了,没启动 | `守护进程未运行`,下一行是启动方法 | +| 装了,当前用户没权限 | `守护进程拒绝访问`,下一行是解决方法 | +| 认不出的错误,或 10 秒内没有应答 | `守护进程状态未知`,下一行是原因 | + +检测只调用本机的 `docker --version` 和 `docker version`,不拉镜像,也不启动容器。 + +NOIR 自己跑在容器里时(Docker / Podman / Kubernetes / LXC),`doctor` 会多一行 +`container docker`。这时找不到设备,提示会换成 USB 透传的做法,而不是让你换数据线。 + ## 出现问题时 所有错误都会带「怎么办」,而不是抛异常栈: diff --git a/cli/src/main/kotlin/app/noir/cli/Guidance.kt b/cli/src/main/kotlin/app/noir/cli/Guidance.kt index e673c93..6690aa1 100644 --- a/cli/src/main/kotlin/app/noir/cli/Guidance.kt +++ b/cli/src/main/kotlin/app/noir/cli/Guidance.kt @@ -7,6 +7,10 @@ package app.noir.cli import app.noir.core.Lang import app.noir.core.Tui +import app.noir.core.platform.ContainerRuntime +import app.noir.core.platform.DockerDaemon +import app.noir.core.platform.DockerStatus +import app.noir.core.platform.Platform /** Human-friendly text blocks: every failure should end with an action. */ object Guidance { @@ -171,6 +175,115 @@ object Guidance { """.trimMargin() }) + /** + * Doctor rows for Docker. Docker is optional, so "not installed" is a plain + * fact; a daemon that is installed but unusable still ends with an action. + */ + fun docker( + status: DockerStatus, + lang: Lang = LANG, + windows: Boolean = Platform.isWindows, + mac: Boolean = Platform.isMac + ): String { + fun l(zh: String, en: String) = if (lang == Lang.EN) en else zh + + if (!status.installed) { + return l( + " docker 未安装(可选:只有想在容器里运行 NOIR 才需要)", + " docker not installed (optional: only needed to run NOIR in a container)" + ) + } + + val client = status.clientVersion?.let { l("客户端 $it", "client $it") } + val daemon = when (status.daemon) { + DockerDaemon.RUNNING -> + status.serverVersion?.let { l("守护进程 $it 运行中", "daemon $it running") } + ?: l("守护进程运行中", "daemon running") + DockerDaemon.STOPPED -> l("守护进程未运行", "daemon not running") + DockerDaemon.DENIED -> l("守护进程拒绝访问", "daemon refused access") + DockerDaemon.UNKNOWN -> l("守护进程状态未知", "daemon state unknown") + } + val next = when (status.daemon) { + DockerDaemon.RUNNING -> null + DockerDaemon.STOPPED -> + if (windows || mac) { + l("启动方法:打开 Docker Desktop", "start it: open Docker Desktop") + } else { + l("启动方法:sudo systemctl start docker", "start it: sudo systemctl start docker") + } + DockerDaemon.DENIED -> when { + windows -> l( + "解决方法:把当前账号加入 docker-users 用户组,然后重新登录", + "fix: add your account to the docker-users group, then sign in again" + ) + mac -> l("解决方法:重启 Docker Desktop", "fix: restart Docker Desktop") + else -> l( + "解决方法:sudo usermod -aG docker \$USER,然后重新登录", + "fix: sudo usermod -aG docker \$USER, then log in again" + ) + } + DockerDaemon.UNKNOWN -> status.detail + } + + val indent = " ".repeat(12) + val sb = StringBuilder(" docker ").append(status.cli) + sb.append('\n').append(indent).append(asciiText(listOfNotNull(client, daemon).joinToString(" · "))) + if (next != null) sb.append('\n').append(indent).append(asciiText(next)) + return sb.toString() + } + + /** Doctor row shown only when NOIR itself runs inside a container. */ + fun container(runtime: ContainerRuntime, lang: Lang = LANG): String { + val name = if (runtime == ContainerRuntime.OTHER) { + if (lang == Lang.EN) "yes" else "是" + } else { + runtime.label + } + return " container $name" + } + + /** + * "No device" inside a container: the cable is rarely the problem there, + * the missing USB passthrough is. + */ + fun containerDeviceGuide(runtime: ContainerRuntime, lang: Lang = LANG): String { + val known = runtime != ContainerRuntime.OTHER + val zh = listOf( + "", + "NOIR 正在容器里运行" + (if (known) "(${runtime.label})" else "") + + ",容器默认看不到宿主机的 USB 设备。", + "", + "1. Linux 宿主机:docker run 时加上这两个参数,把 USB 透传进容器", + " --device-cgroup-rule='c 189:* rmw' -v /dev/bus/usb:/dev/bus/usb", + " 宿主机上的 adb 会占住手机,先执行 adb kill-server", + "", + "2. macOS / Windows:Docker Desktop 不能透传 USB", + " 改用便携包,或让容器复用宿主机的 adb server:", + " -e ADB_SERVER_SOCKET=tcp:host.docker.internal:5037", + "", + "3. 手机端仍需开启 USB 调试,并在弹窗里点「允许」" + ) + val en = listOf( + "", + "NOIR is running inside a container" + (if (known) " (${runtime.label})" else "") + + ". Containers cannot see the host's USB devices by default.", + "", + "1. Linux host: add these two flags to docker run to pass USB through", + " --device-cgroup-rule='c 189:* rmw' -v /dev/bus/usb:/dev/bus/usb", + " A host adb server holds the phone; stop it first: adb kill-server", + "", + "2. macOS / Windows: Docker Desktop cannot pass USB through", + " Use the portable build, or reuse the host's adb server:", + " -e ADB_SERVER_SOCKET=tcp:host.docker.internal:5037", + "", + "3. The phone still needs USB debugging on, and its prompt allowed." + ) + return guideBox( + if (lang == Lang.EN) "no device detected in the container" else "容器里没有检测到设备", + if (lang == Lang.EN) en else zh + ) + } + fun error(title: String, hint: String? = null): String { val total = (Tui.terminalColumns() - 2).coerceIn(40, 100) val inner = total - 4 diff --git a/cli/src/main/kotlin/app/noir/cli/Main.kt b/cli/src/main/kotlin/app/noir/cli/Main.kt index c96d70c..0559536 100644 --- a/cli/src/main/kotlin/app/noir/cli/Main.kt +++ b/cli/src/main/kotlin/app/noir/cli/Main.kt @@ -29,6 +29,8 @@ import app.noir.core.apk.ManifestAnalyzer import app.noir.core.apk.ManifestFacts import app.noir.core.apk.Trackers import app.noir.core.jadx.Jadx +import app.noir.core.platform.ContainerRuntime +import app.noir.core.platform.Docker import app.noir.core.platform.Platform import app.noir.core.platform.Setup import java.io.File @@ -209,7 +211,7 @@ private fun usage() { noir guided setup + scan (interactive) noir --about what this is, what it writes, license noir --version print the version - noir doctor check adb / jadx / device + noir doctor check adb / jadx / docker / device noir setup download missing tools into ~/.noir/tools noir devices list connected devices noir scan [options] inventory apps, permissions and endpoints @@ -366,7 +368,7 @@ private fun cmdDevices(raw: List) { val filter = args.value("serial") val devices = adb.devices().let { all -> if (filter == null) all else all.filter { it.serial == filter } } if (devices.isEmpty()) { - println(Guidance.deviceGuide()) + println(noDeviceGuide()) return } devices.firstOrNull { it.state == "device" }?.let { applyDeviceLanguage(adb, it) } @@ -726,6 +728,12 @@ private fun cmdDoctor(raw: List) { println(" platform ${Platform.describe()}") println(" java ${System.getProperty("java.version")}") + // Docker is optional: report it before anything that can end the check, never fail on it. + CONTAINER?.let { println(Guidance.container(it)) } + val docker = Docker.probe() + // Inside a container the Docker CLI is normally absent; "not installed" would only confuse. + if (docker.installed || CONTAINER == null) println(Guidance.docker(docker)) + val adbPath = Platform.findAdb(args.value("adb")) if (adbPath == null) { println(t(" adb 未找到", " adb not found")) @@ -748,7 +756,7 @@ private fun cmdDoctor(raw: List) { if (devices.isEmpty()) { println(t(" device 未连接", " device not connected")) println() - println(Guidance.deviceGuide()) + println(noDeviceGuide()) exitProcess(1) } else { devices.forEach { println(" device ${it.serial} ${it.state} ${it.model}") } @@ -1617,6 +1625,13 @@ private fun offerCleanup(args: Args, adb: Adb, scan: ScanResult, findings: List< private fun isInteractive(): Boolean = System.console() != null || System.getenv("NOIR_INTERACTIVE") == "1" +/** The container runtime NOIR itself runs in; null on an ordinary host. */ +private val CONTAINER: ContainerRuntime? by lazy { Docker.container() } + +/** Inside a container a missing device is usually missing USB passthrough, not a bad cable. */ +private fun noDeviceGuide(): String = + CONTAINER?.let { Guidance.containerDeviceGuide(it) } ?: Guidance.deviceGuide() + private fun connectAdb(args: Args): Adb { val explicit = args.value("adb") if (explicit != null && !File(explicit).exists()) { @@ -1683,7 +1698,7 @@ private fun awaitDevice(adb: Adb, requested: String?, interactive: Boolean): Dev val unauthorized = devices.any { it.state == "unauthorized" } if (!printed) { - println(if (unauthorized) Guidance.unauthorizedGuide() else Guidance.deviceGuide()) + println(if (unauthorized) Guidance.unauthorizedGuide() else noDeviceGuide()) printed = true } @@ -1703,7 +1718,7 @@ private fun awaitDevice(adb: Adb, requested: String?, interactive: Boolean): Dev if (waited >= 180) { throw NoirException( t("等了 180 秒仍然没有设备。", "no device appeared after 180 seconds."), - Guidance.deviceGuide() + noDeviceGuide() ) } diff --git a/cli/src/test/kotlin/app/noir/cli/DockerGuidanceTest.kt b/cli/src/test/kotlin/app/noir/cli/DockerGuidanceTest.kt new file mode 100644 index 0000000..289be68 --- /dev/null +++ b/cli/src/test/kotlin/app/noir/cli/DockerGuidanceTest.kt @@ -0,0 +1,127 @@ +/* + * NOIR — Android leak audit + * Copyright (c) 2026 Verlintas + * SPDX-License-Identifier: MIT + */ +package app.noir.cli + +import app.noir.core.Lang +import app.noir.core.Tui +import app.noir.core.platform.ContainerRuntime +import app.noir.core.platform.DockerDaemon +import app.noir.core.platform.DockerStatus +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DockerGuidanceTest { + + private val han = Regex("[\\u4e00-\\u9fff]") + + private fun status(daemon: DockerDaemon, server: String? = null, detail: String? = null) = + DockerStatus("/usr/bin/docker", "29.9.2", daemon, server, detail) + + private val everyState = listOf( + DockerStatus(cli = null), + status(DockerDaemon.RUNNING, server = "29.9.2"), + status(DockerDaemon.STOPPED), + status(DockerDaemon.DENIED), + status(DockerDaemon.UNKNOWN, detail = "no answer within 10s") + ) + + @Test + fun `missing docker is reported as optional, not as a failure`() { + val row = Guidance.docker(DockerStatus(cli = null), Lang.EN) + assertEquals(1, row.lines().size) + assertTrue(row.contains("not installed")) + assertTrue(row.contains("optional")) + } + + @Test + fun `a running daemon shows both versions and no advice`() { + val rows = Guidance.docker(status(DockerDaemon.RUNNING, server = "29.9.1"), Lang.EN).lines() + assertEquals(listOf(" docker /usr/bin/docker", " client 29.9.2 · daemon 29.9.1 running"), rows) + } + + @Test + fun `a stopped daemon ends with how to start it on this platform`() { + fun advice(windows: Boolean, mac: Boolean) = + Guidance.docker(status(DockerDaemon.STOPPED), Lang.EN, windows = windows, mac = mac).lines().last() + + assertTrue(advice(windows = false, mac = false).contains("systemctl start docker")) + assertTrue(advice(windows = true, mac = false).contains("Docker Desktop")) + assertTrue(advice(windows = false, mac = true).contains("Docker Desktop")) + } + + @Test + fun `a refused daemon ends with how to get access on this platform`() { + fun advice(windows: Boolean, mac: Boolean) = + Guidance.docker(status(DockerDaemon.DENIED), Lang.EN, windows = windows, mac = mac).lines().last() + + assertTrue(advice(windows = false, mac = false).contains("usermod -aG docker \$USER")) + assertTrue(advice(windows = true, mac = false).contains("docker-users")) + assertTrue(advice(windows = false, mac = true).contains("Docker Desktop")) + } + + @Test + fun `an unknown state passes the cli's own reason through`() { + val rows = Guidance.docker(status(DockerDaemon.UNKNOWN, detail = "no answer within 10s"), Lang.EN).lines() + assertTrue(rows[1].contains("daemon state unknown")) + assertEquals(" no answer within 10s", rows.last()) + } + + @Test + fun `english docker text has no chinese characters`() { + val blocks = everyState.flatMap { state -> + listOf( + Guidance.docker(state, Lang.EN, windows = false, mac = false), + Guidance.docker(state, Lang.EN, windows = true, mac = false), + Guidance.docker(state, Lang.EN, windows = false, mac = true) + ) + } + ContainerRuntime.entries.flatMap { runtime -> + listOf(Guidance.container(runtime, Lang.EN), Guidance.containerDeviceGuide(runtime, Lang.EN)) + } + blocks.forEach { block -> + assertFalse(han.containsMatchIn(block), "untranslated guidance:\n$block") + } + } + + @Test + fun `ascii mode keeps english docker text 7-bit`() { + ASCII = true + try { + val blocks = everyState.map { Guidance.docker(it, Lang.EN) } + + ContainerRuntime.entries.map { Guidance.containerDeviceGuide(it, Lang.EN) } + blocks.forEach { block -> + assertTrue(block.all { it.code < 128 }, "non-ASCII output in --ascii mode:\n$block") + } + } finally { + ASCII = false + } + } + + @Test + fun `container row names the runtime when it is known`() { + assertEquals(" container docker", Guidance.container(ContainerRuntime.DOCKER, Lang.EN)) + assertEquals(" container podman", Guidance.container(ContainerRuntime.PODMAN, Lang.ZH)) + assertEquals(" container yes", Guidance.container(ContainerRuntime.OTHER, Lang.EN)) + } + + @Test + fun `container guide explains usb passthrough and fits the terminal`() { + val limit = Tui.terminalColumns() + listOf(Lang.ZH, Lang.EN).forEach { lang -> + ContainerRuntime.entries.forEach { runtime -> + val guide = Guidance.containerDeviceGuide(runtime, lang) + assertTrue(guide.contains("/dev/bus/usb"), "no passthrough hint:\n$guide") + assertTrue(guide.contains("ADB_SERVER_SOCKET"), "no adb server hint:\n$guide") + guide.lines().forEach { line -> + assertTrue(Tui.visibleWidth(line) <= limit, "line wider than $limit: [$line]") + } + } + } + assertTrue(Guidance.containerDeviceGuide(ContainerRuntime.DOCKER, Lang.EN).contains("(docker)")) + assertFalse(Guidance.containerDeviceGuide(ContainerRuntime.OTHER, Lang.EN).contains("(")) + } +} diff --git a/core/src/jvmMain/kotlin/app/noir/core/platform/Docker.kt b/core/src/jvmMain/kotlin/app/noir/core/platform/Docker.kt new file mode 100644 index 0000000..adef794 --- /dev/null +++ b/core/src/jvmMain/kotlin/app/noir/core/platform/Docker.kt @@ -0,0 +1,267 @@ +/* + * NOIR — Android leak audit + * Copyright (c) 2026 Verlintas + * SPDX-License-Identifier: MIT + */ +package app.noir.core.platform + +import java.io.File +import java.util.concurrent.TimeUnit + +/** How the Docker daemon answered when it was asked for its version. */ +enum class DockerDaemon { + /** The daemon answered. */ + RUNNING, + + /** The CLI is installed, but no daemon is listening. */ + STOPPED, + + /** A daemon is listening, but this user is not allowed to talk to it. */ + DENIED, + + /** No clear answer: a timeout, or an error NOIR does not recognise. */ + UNKNOWN +} + +/** + * Result of [Docker.probe]. `cli == null` means Docker is not installed; the + * other fields only carry meaning when it is. + */ +data class DockerStatus( + val cli: String?, + val clientVersion: String? = null, + val daemon: DockerDaemon = DockerDaemon.UNKNOWN, + val serverVersion: String? = null, + /** First useful line of the CLI's complaint, when there was one. */ + val detail: String? = null +) { + val installed: Boolean get() = cli != null + val running: Boolean get() = daemon == DockerDaemon.RUNNING +} + +/** The container runtime NOIR itself is running under. */ +enum class ContainerRuntime(val label: String) { + DOCKER("docker"), + PODMAN("podman"), + KUBERNETES("kubernetes"), + LXC("lxc"), + OTHER("container") +} + +/** What [Docker.parseDaemon] read out of `docker version`. */ +internal data class DaemonAnswer( + val state: DockerDaemon, + val version: String? = null, + val detail: String? = null +) + +/** + * Docker is optional for NOIR — it is only needed to run NOIR from a container + * image. This answers three questions for `noir doctor`: is Docker installed, + * is its daemon running, and is NOIR itself inside a container (where USB + * devices stay invisible until they are passed through). + * + * Only the local `docker` CLI is asked, against whatever endpoint the user + * already configured for it. Nothing is pulled and no container is started. + */ +object Docker { + + private val CLIENT_VERSION = Regex("\\bversion\\s+v?([0-9][^\\s,]*)", RegexOption.IGNORE_CASE) + private val VERSION_LINE = Regex("^v?[0-9]+\\.[0-9]\\S*$") + + // Checked before STOPPED: a refused socket also mentions "connect". + // Windows words its part of the message in the system language. + private val DENIED_MARKERS = listOf( + "permission denied", + "access is denied", + "拒绝访问" + ) + + private val STOPPED_MARKERS = listOf( + "cannot connect to the docker daemon", + "failed to connect to the docker api", + "is the docker daemon running", + "docker daemon is not running", + "error during connect", + "connection refused", + "no such file or directory", + "cannot find the file specified" + ) + + /** Locates the Docker CLI: `PATH` first, then the usual install locations. */ + fun find(): String? { + Platform.which("docker")?.let { return it } + + val home = System.getProperty("user.home") + val candidates = buildList { + when { + Platform.isWindows -> { + val programs = System.getenv("ProgramFiles") ?: "C:\\Program Files" + add("$programs\\Docker\\Docker\\resources\\bin\\docker.exe") + } + Platform.isMac -> { + add("/usr/local/bin/docker") + add("/opt/homebrew/bin/docker") + add("$home/.docker/bin/docker") + add("/Applications/Docker.app/Contents/Resources/bin/docker") + add("$home/.orbstack/bin/docker") + add("$home/.rd/bin/docker") + } + else -> { + add("/usr/bin/docker") + add("/usr/local/bin/docker") + add("/snap/bin/docker") + add("$home/bin/docker") + add("$home/.rd/bin/docker") + } + } + } + return candidates.firstOrNull(Platform::usable) + } + + /** + * Asks the CLI for its own version, then for the daemon's. + * + * Never throws and never waits longer than [timeoutSec] per question: a + * CLI that cannot start, or a daemon endpoint that does not answer, is + * reported as [DockerDaemon.UNKNOWN] with the reason in `detail`. + */ + fun probe(cli: String? = find(), timeoutSec: Long = 10): DockerStatus { + if (cli == null) return DockerStatus(cli = null) + val silent = "no answer within ${timeoutSec}s" + + // `--version` never touches the daemon, so it works while Docker is stopped. + val client = try { + run(cli, listOf("--version"), timeoutSec) + } catch (e: Exception) { + return DockerStatus(cli, detail = e.message ?: e::class.simpleName) + } + if (client.timedOut) return DockerStatus(cli, detail = silent) + val clientVersion = parseClientVersion(client.text) + + val server = try { + run(cli, listOf("version", "--format", "{{.Server.Version}}"), timeoutSec) + } catch (e: Exception) { + return DockerStatus(cli, clientVersion, detail = e.message ?: e::class.simpleName) + } + if (server.timedOut) return DockerStatus(cli, clientVersion, detail = silent) + + val answer = parseDaemon(server.code, server.text) + return DockerStatus(cli, clientVersion, answer.state, answer.version, answer.detail) + } + + /** `Docker version 29.9.2, build a82e528` → `29.9.2`. */ + internal fun parseClientVersion(output: String): String? = + CLIENT_VERSION.find(output)?.groupValues?.get(1) + + /** + * Reads the outcome of `docker version --format {{.Server.Version}}`, with + * stderr merged in. A running daemon prints just its version; otherwise + * the CLI explains why it could not reach one, in wording that differs by + * platform and Docker release. + */ + internal fun parseDaemon(code: Int, output: String): DaemonAnswer { + val lines = output.lineSequence().map { it.trim() }.filter { it.isNotEmpty() }.toList() + if (code == 0) { + // Warnings may precede the answer; the version is the last thing printed. + lines.lastOrNull { VERSION_LINE.matches(it) }?.let { + return DaemonAnswer(DockerDaemon.RUNNING, version = it.removePrefix("v")) + } + } + + // A warning (an unreadable config file, …) is not the reason the daemon is silent. + val complaints = lines.filterNot { it.startsWith("warning", ignoreCase = true) } + fun firstWith(markers: List): String? = + complaints.firstOrNull { line -> markers.any { it in line.lowercase() } } + + firstWith(DENIED_MARKERS)?.let { return DaemonAnswer(DockerDaemon.DENIED, detail = it.take(300)) } + firstWith(STOPPED_MARKERS)?.let { return DaemonAnswer(DockerDaemon.STOPPED, detail = it.take(300)) } + return DaemonAnswer( + DockerDaemon.UNKNOWN, + detail = (complaints.firstOrNull() ?: lines.firstOrNull())?.take(300) + ) + } + + /** The container runtime NOIR is running in, or null on an ordinary host. */ + fun container(): ContainerRuntime? { + if (!Platform.isLinux) return null + return detectContainer( + env = System.getenv(), + exists = { File(it).exists() }, + cgroup = try { + File("/proc/1/cgroup").readText() + } catch (e: Exception) { + null + } + ) + } + + /** + * Container detection from the marker files and variables the runtimes + * leave behind. The cgroup of PID 1 is the fallback: on cgroup v1 it names + * the runtime, on pure v2 it is just `0::/` and says nothing. + */ + internal fun detectContainer( + env: Map, + exists: (String) -> Boolean, + cgroup: String? + ): ContainerRuntime? { + if (!env["KUBERNETES_SERVICE_HOST"].isNullOrBlank()) return ContainerRuntime.KUBERNETES + if (exists("/.dockerenv")) return ContainerRuntime.DOCKER + if (exists("/run/.containerenv")) return ContainerRuntime.PODMAN + + // systemd's convention: container=docker|podman|lxc|oci|… + env["container"]?.trim()?.lowercase()?.takeIf { it.isNotEmpty() }?.let { value -> + return when { + "docker" in value -> ContainerRuntime.DOCKER + "podman" in value -> ContainerRuntime.PODMAN + "lxc" in value -> ContainerRuntime.LXC + else -> ContainerRuntime.OTHER + } + } + + val groups = cgroup?.lowercase() ?: return null + return when { + "kubepods" in groups -> ContainerRuntime.KUBERNETES + "docker" in groups -> ContainerRuntime.DOCKER + "libpod" in groups -> ContainerRuntime.PODMAN + "/lxc" in groups -> ContainerRuntime.LXC + else -> null + } + } + + private class Output(val code: Int, val text: String, val timedOut: Boolean) + + /** + * Runs the CLI with a real deadline. stdout is drained on a daemon thread + * so a process that hangs without closing its pipe cannot hang NOIR. + */ + private fun run(cli: String, args: List, timeoutSec: Long): Output { + val process = ProcessBuilder(Exec.argv(cli, args)) + .redirectErrorStream(true) + .start() + val text = StringBuilder() + val reader = Thread { + try { + process.inputStream.bufferedReader().use { stream -> + while (true) { + val line = stream.readLine() ?: break + synchronized(text) { text.append(line).append('\n') } + } + } + } catch (_: Exception) { + // Process killed on timeout, or the pipe was closed. + } + } + reader.isDaemon = true + reader.start() + + if (!process.waitFor(timeoutSec, TimeUnit.SECONDS)) { + process.destroyForcibly() + reader.join(2_000) + return Output(-1, synchronized(text) { text.toString() }, timedOut = true) + } + reader.join(2_000) + return Output(process.exitValue(), synchronized(text) { text.toString() }, timedOut = false) + } +} diff --git a/core/src/jvmMain/kotlin/app/noir/core/platform/Platform.kt b/core/src/jvmMain/kotlin/app/noir/core/platform/Platform.kt index 3ec4b9d..710c466 100644 --- a/core/src/jvmMain/kotlin/app/noir/core/platform/Platform.kt +++ b/core/src/jvmMain/kotlin/app/noir/core/platform/Platform.kt @@ -84,7 +84,7 @@ object Platform { return candidates.firstOrNull(::usable) } - private fun usable(path: String): Boolean = File(path).let { it.isFile && (isWindows || it.canExecute()) } + internal fun usable(path: String): Boolean = File(path).let { it.isFile && (isWindows || it.canExecute()) } /** Minimal `which`, without spawning a shell. */ fun which(command: String): String? { diff --git a/core/src/jvmTest/kotlin/app/noir/core/platform/DockerTest.kt b/core/src/jvmTest/kotlin/app/noir/core/platform/DockerTest.kt new file mode 100644 index 0000000..7418b9c --- /dev/null +++ b/core/src/jvmTest/kotlin/app/noir/core/platform/DockerTest.kt @@ -0,0 +1,276 @@ +/* + * NOIR — Android leak audit + * Copyright (c) 2026 Verlintas + * SPDX-License-Identifier: MIT + */ +package app.noir.core.platform + +import java.io.File +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class DockerTest { + + /** A stand-in `docker` that answers `--version` and `version` the way [body] says. */ + private fun fakeDocker(body: String): String { + val file = File(Files.createTempDirectory("noir-docker").toFile(), "docker") + file.writeText("#!/bin/sh\n$body\n") + file.setExecutable(true) + return file.absolutePath + } + + @Test + fun `client version is read from docker --version`() { + assertEquals("29.9.2", Docker.parseClientVersion("Docker version 29.9.2, build a82e528\n")) + assertEquals("20.10.24+dfsg1", Docker.parseClientVersion("Docker version 20.10.24+dfsg1, build 297e128")) + assertEquals("4.9.3", Docker.parseClientVersion("podman version 4.9.3")) + assertNull(Docker.parseClientVersion("docker: command not found")) + } + + @Test + fun `a daemon that answers is running`() { + val answer = Docker.parseDaemon(0, "29.9.2\n") + assertEquals(DockerDaemon.RUNNING, answer.state) + assertEquals("29.9.2", answer.version) + } + + @Test + fun `warnings before the version do not hide a running daemon`() { + val answer = Docker.parseDaemon( + 0, + "WARNING: Error loading config file: /home/u/.docker/config.json: " + + "open /home/u/.docker/config.json: permission denied\n" + + "27.3.1\n" + ) + assertEquals(DockerDaemon.RUNNING, answer.state) + assertEquals("27.3.1", answer.version) + } + + @Test + fun `a missing or dead socket means the daemon is stopped`() { + // Docker 29 on Linux, socket file absent. + val modern = Docker.parseDaemon( + 1, + "\nfailed to connect to the docker API at unix:///var/run/docker.sock; check if the path " + + "is correct and if the daemon is running: dial unix /var/run/docker.sock: connect: " + + "no such file or directory\n" + ) + assertEquals(DockerDaemon.STOPPED, modern.state) + assertNull(modern.version) + assertTrue(modern.detail!!.startsWith("failed to connect to the docker API")) + + // Socket file present, nobody listening; also what older releases always printed. + val classic = Docker.parseDaemon( + 1, + "\nCannot connect to the Docker daemon at unix:///var/run/docker.sock. " + + "Is the docker daemon running?\n" + ) + assertEquals(DockerDaemon.STOPPED, classic.state) + + // Docker Desktop on Windows, not started. + val windows = Docker.parseDaemon( + 1, + "error during connect: Get \"http://%2F%2F.%2Fpipe%2FdockerDesktopLinuxEngine/v1.47/version\": " + + "open //./pipe/dockerDesktopLinuxEngine: The system cannot find the file specified.\n" + ) + assertEquals(DockerDaemon.STOPPED, windows.state) + } + + @Test + fun `a refused socket is denied, not stopped`() { + val modern = Docker.parseDaemon( + 1, + "\npermission denied while trying to connect to the docker API at unix:///var/run/docker.sock\n" + ) + assertEquals(DockerDaemon.DENIED, modern.state) + + val classic = Docker.parseDaemon( + 1, + "permission denied while trying to connect to the Docker daemon socket at " + + "unix:///var/run/docker.sock: Get \"http://%2Fvar%2Frun%2Fdocker.sock/v1.24/version\": " + + "dial unix /var/run/docker.sock: connect: permission denied\n" + ) + assertEquals(DockerDaemon.DENIED, classic.state) + + val windows = Docker.parseDaemon( + 1, + "error during connect: Get \"http://%2F%2F.%2Fpipe%2Fdocker_engine/v1.24/version\": " + + "open //./pipe/docker_engine: Access is denied.\n" + ) + assertEquals(DockerDaemon.DENIED, windows.state) + + // Same failure on a Chinese Windows: the OS half of the message is localised. + val windowsZh = Docker.parseDaemon( + 1, + "error during connect: Get \"http://%2F%2F.%2Fpipe%2Fdocker_engine/v1.24/version\": " + + "open //./pipe/docker_engine: 拒绝访问。\n" + ) + assertEquals(DockerDaemon.DENIED, windowsZh.state) + } + + @Test + fun `an unreadable config file does not turn a stopped daemon into a denied one`() { + val answer = Docker.parseDaemon( + 1, + "WARNING: Error loading config file: /home/u/.docker/config.json: " + + "open /home/u/.docker/config.json: permission denied\n" + + "Cannot connect to the Docker daemon at unix:///var/run/docker.sock. " + + "Is the docker daemon running?\n" + ) + assertEquals(DockerDaemon.STOPPED, answer.state) + assertTrue(answer.detail!!.startsWith("Cannot connect")) + } + + @Test + fun `an unrecognised failure stays unknown and keeps its reason`() { + val answer = Docker.parseDaemon( + 125, + "Error: template: version:1:9: executing \"version\" at <.Server.Version>: " + + "nil pointer evaluating *define.Version.Version\n" + ) + assertEquals(DockerDaemon.UNKNOWN, answer.state) + assertTrue(answer.detail!!.startsWith("Error: template")) + + // Exit 0 without a version is not proof of a running daemon. + assertEquals(DockerDaemon.UNKNOWN, Docker.parseDaemon(0, "\n").state) + assertEquals(DockerDaemon.UNKNOWN, Docker.parseDaemon(0, "").state) + } + + @Test + fun `no cli means docker is not installed`() { + val status = Docker.probe(cli = null) + assertFalse(status.installed) + assertFalse(status.running) + } + + @Test + fun `probe reports a running daemon`() { + if (Platform.isWindows) return + val cli = fakeDocker( + """ + case "${'$'}1" in + --version) echo "Docker version 27.3.1, build ce12230" ;; + version) echo "27.3.1" ;; + esac + """.trimIndent() + ) + + val status = Docker.probe(cli) + assertTrue(status.installed) + assertTrue(status.running) + assertEquals(cli, status.cli) + assertEquals("27.3.1", status.clientVersion) + assertEquals("27.3.1", status.serverVersion) + } + + @Test + fun `probe reports an installed but stopped daemon`() { + if (Platform.isWindows) return + val cli = fakeDocker( + """ + case "${'$'}1" in + --version) echo "Docker version 27.3.1, build ce12230" ;; + version) + echo + echo "Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?" >&2 + exit 1 ;; + esac + """.trimIndent() + ) + + val status = Docker.probe(cli) + assertTrue(status.installed) + assertFalse(status.running) + assertEquals(DockerDaemon.STOPPED, status.daemon) + assertEquals("27.3.1", status.clientVersion) + assertNull(status.serverVersion) + } + + @Test + fun `probe gives up on a daemon that never answers`() { + if (Platform.isWindows) return + val cli = fakeDocker( + """ + case "${'$'}1" in + --version) echo "Docker version 27.3.1, build ce12230" ;; + version) sleep 8 ;; + esac + """.trimIndent() + ) + + val started = System.currentTimeMillis() + val status = Docker.probe(cli, timeoutSec = 1) + val elapsed = System.currentTimeMillis() - started + + assertEquals(DockerDaemon.UNKNOWN, status.daemon) + assertEquals("27.3.1", status.clientVersion) + assertEquals("no answer within 1s", status.detail) + // The orphaned `sleep` keeps the pipe open; probe must not wait for it. + assertTrue(elapsed < 6_000, "timeout took too long: ${elapsed}ms") + } + + @Test + fun `probe survives a cli that cannot start`() { + val missing = File(Files.createTempDirectory("noir-docker").toFile(), "docker").absolutePath + + val status = Docker.probe(missing) + assertTrue(status.installed) + assertEquals(DockerDaemon.UNKNOWN, status.daemon) + assertNotNull(status.detail) + } + + @Test + fun `marker files and variables identify the container runtime`() { + fun detect( + env: Map = emptyMap(), + files: Set = emptySet(), + cgroup: String? = null + ) = Docker.detectContainer(env, { it in files }, cgroup) + + assertEquals(ContainerRuntime.DOCKER, detect(files = setOf("/.dockerenv"))) + assertEquals(ContainerRuntime.PODMAN, detect(files = setOf("/run/.containerenv"))) + assertEquals(ContainerRuntime.PODMAN, detect(env = mapOf("container" to "podman"))) + assertEquals(ContainerRuntime.LXC, detect(env = mapOf("container" to "lxc"))) + assertEquals(ContainerRuntime.OTHER, detect(env = mapOf("container" to "oci"))) + + // A pod is still a pod when the node happens to run Docker underneath. + assertEquals( + ContainerRuntime.KUBERNETES, + detect(env = mapOf("KUBERNETES_SERVICE_HOST" to "10.96.0.1"), files = setOf("/.dockerenv")) + ) + } + + @Test + fun `cgroup v1 names the runtime when the marker files are gone`() { + val docker = """ + 9:name=systemd:/docker/5ee95099ef2841a219aeb64bc156a17cd3d894287bd14e04bb066940ee26373d + 5:devices:/docker/5ee95099ef2841a219aeb64bc156a17cd3d894287bd14e04bb066940ee26373d + 0::/docker/5ee95099ef2841a219aeb64bc156a17cd3d894287bd14e04bb066940ee26373d + """.trimIndent() + val pod = "11:memory:/kubepods/burstable/pod3c1f0d5e/0a1b2c3d4e5f\n" + val podman = "0::/machine.slice/libpod-0a1b2c3d4e5f.scope\n" + + fun detect(cgroup: String?) = Docker.detectContainer(emptyMap(), { false }, cgroup) + + assertEquals(ContainerRuntime.DOCKER, detect(docker)) + assertEquals(ContainerRuntime.KUBERNETES, detect(pod)) + assertEquals(ContainerRuntime.PODMAN, detect(podman)) + } + + @Test + fun `an ordinary host is not a container`() { + fun detect(cgroup: String?) = Docker.detectContainer(emptyMap(), { false }, cgroup) + + // systemd host on cgroup v1, on cgroup v2, and a host where /proc is unreadable. + assertNull(detect("9:name=systemd:/\n8:pids:/\n5:devices:/\n")) + assertNull(detect("0::/init.scope\n")) + assertNull(detect(null)) + // An empty `container` variable is not a claim. + assertNull(Docker.detectContainer(mapOf("container" to " "), { false }, "0::/\n")) + } +} From 09c344a9e704fa5175a46804bb2570b28b34650c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 08:16:50 +0000 Subject: [PATCH 2/8] feat(docker): add a Dockerfile for running NOIR in a container The image carries a JRE, adb and NOIR, so a Linux host needs nothing but Docker. Build and runtime stages share one Java release, because Gradle targets the JDK it runs on. adb comes from the distribution package, which also exists for arm64 (Google's platform-tools zip is x86_64 only); jadx stays out and is fetched on demand by `noir setup`, as with the portable builds. .dockerignore is an allowlist, which keeps local scan reports (noir-out/) and tool caches out of the build context. The README documents the USB passthrough flags, the volume that keeps the adb authorisation between runs, and how to reuse a host adb server where USB cannot be passed through. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01LSsPaaLQJLx6wnsTuHo5hD --- .dockerignore | 15 +++++++++++++++ CHANGELOG.md | 4 ++++ Dockerfile | 36 ++++++++++++++++++++++++++++++++++++ README.md | 38 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 93 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8b18936 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +# Allowlist: only what the Gradle build needs enters the build context, so +# local scan reports (noir-out/) and tool caches can never end up in a layer. +* +!build.gradle.kts +!settings.gradle.kts +!gradle.properties +!gradlew +!gradle/ +!core/ +!cli/ +!LICENSE + +**/build/ +**/.gradle/ +**/.kotlin/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ff85f4..2ea0c3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,10 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **container awareness** — NOIR recognises when it runs inside Docker, Podman, Kubernetes or LXC. `doctor` shows a `container` row, and the "no device" guidance explains USB passthrough instead of blaming the cable. +- **Docker image** — a `Dockerfile` builds an image with a JRE, adb and NOIR, + so a Linux host needs nothing but Docker. The README documents the USB + passthrough flags, the volume that keeps the adb authorisation, and how to + reuse a host adb server where USB cannot be passed through. ## [0.4.2] — 2026-11-07 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..78c1a49 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,36 @@ +# NOIR in a container: a JRE, adb and NOIR itself, so a Linux host needs +# nothing but Docker. +# +# docker build -t noir . +# docker run --rm -it \ +# --device-cgroup-rule='c 189:* rmw' -v /dev/bus/usb:/dev/bus/usb \ +# -v noir-home:/root -v "$PWD:/work" noir +# +# README ("方式三:Docker") explains every flag. + +# Build and runtime share one Java release: Gradle targets the JDK it runs on, +# so a newer build JDK would produce classes the runtime cannot load. +FROM eclipse-temurin:17-jdk-noble AS build +WORKDIR /src +COPY . . +RUN ./gradlew :cli:installDist --no-daemon --console=plain + +FROM eclipse-temurin:17-jre-noble +LABEL org.opencontainers.image.title="NOIR" \ + org.opencontainers.image.description="Android leak audit" \ + org.opencontainers.image.licenses="MIT" + +# adb comes from the distribution: Google's platform-tools zip is x86_64 only, +# the package also exists for arm64. jadx is not baked in; `noir setup` +# downloads it into the /root volume when deep/deepscan are wanted. +RUN apt-get update \ + && apt-get install -y --no-install-recommends adb \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=build /src/cli/build/install/noir /opt/noir +COPY LICENSE /opt/noir/LICENSE +ENV PATH="/opt/noir/bin:${PATH}" + +# Reports land in ./noir-out relative to the working directory; mount it. +WORKDIR /work +ENTRYPOINT ["noir"] diff --git a/README.md b/README.md index cb3a70c..657f07e 100644 --- a/README.md +++ b/README.md @@ -200,6 +200,44 @@ cd NOIR ./cli/build/install/noir/bin/noir --about ``` +**方式三:Docker(Linux 宿主机)** + +镜像里带 JRE、adb 和 NOIR 本身,宿主机只需要 Docker。在仓库根目录构建 +(首次会下载 Gradle 和依赖,需要几分钟): + +```bash +docker build -t noir . +``` + +```bash +docker run --rm -it \ + --device-cgroup-rule='c 189:* rmw' -v /dev/bus/usb:/dev/bus/usb \ + -v noir-home:/root -v "$PWD:/work" \ + noir +``` + +| 参数 | 作用 | +|---|---| +| `--device-cgroup-rule='c 189:* rmw'`
`-v /dev/bus/usb:/dev/bus/usb` | 把 USB 设备透传进容器,重新插拔也有效 | +| `-v noir-home:/root` | 保存 adb 授权密钥和扫描缓存;不加的话,每次运行手机都要重新点「允许」 | +| `-v "$PWD:/work"` | 报告写到当前目录的 `noir-out/` | + +- 宿主机上如果也装了 adb,先执行 `adb kill-server`,否则它会占住手机。 +- 命令末尾可以接任何子命令,例如 `… noir doctor`、`… noir ui`。 +- `deep` / `deepscan` 需要的 jadx 不在镜像里:执行一次 `… noir setup --yes`,它会下载到 `noir-home`。 +- 容器内以 root 运行,所以 `noir-out/` 里的文件属主是 root。 +- 在容器里找不到设备时,NOIR 会直接提示上面的透传参数。 + +macOS / Windows 的 Docker Desktop 不能把 USB 设备透传进容器,建议直接用方式一的便携包。 +一定要用容器的话,先在宿主机上启动 adb(`adb start-server`),再让容器复用它: + +```bash +docker run --rm -it \ + -e ADB_SERVER_SOCKET=tcp:host.docker.internal:5037 \ + -v noir-home:/root -v "$PWD:/work" \ + noir +``` + ## 构建 ```bash From c81285b055885c9a6ccebe7bbb4988cdb166ea51 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 08:26:09 +0000 Subject: [PATCH 3/8] chore(repo): renormalize gradlew.bat line endings .gitattributes marks gradlew.bat as `text eol=crlf`, so git expects the stored blob to be LF and converts to CRLF on checkout. The blob was committed with CRLF instead, which made every fresh clone report the file as modified. Only the stored blob changes. The checked-out file is byte-identical and still has CRLF, as Windows batch files need. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01LSsPaaLQJLx6wnsTuHo5hD --- gradlew.bat | 224 ++++++++++++++++++++++++++-------------------------- 1 file changed, 112 insertions(+), 112 deletions(-) diff --git a/gradlew.bat b/gradlew.bat index 03754c4..3185a43 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -1,112 +1,112 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem -@rem SPDX-License-Identifier: Apache-2.0 -@rem - -@if "%DEBUG%"=="" @echo off -@rem ########################################################################## -@rem -@rem gradlew startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables, and ensure extensions are enabled -setlocal EnableExtensions - -@rem Catch executions from older scripts and ensure they exit cleanly. -@rem This can be removed once we can be reasonably confident that few people -@rem will be migrating directly to this new wrapper. -goto afterSafetyNet -:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -goto exitWithErrorLevel -:afterSafetyNet - -set DIRNAME=%~dp0 -if "%DIRNAME%"=="" set DIRNAME=. -@rem This is normally unused -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if %ERRORLEVEL% equ 0 goto execute - -1>&2 echo. -1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -1>&2 echo. -1>&2 echo Please set the JAVA_HOME variable in your environment to match the -1>&2 echo location of your Java installation. - -"%COMSPEC%" /c exit 1 -goto exitWithErrorLevel - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -1>&2 echo. -1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -1>&2 echo. -1>&2 echo Please set the JAVA_HOME variable in your environment to match the -1>&2 echo location of your Java installation. - -"%COMSPEC%" /c exit 1 -goto exitWithErrorLevel - -:execute -@rem Setup the command line - - - -@rem Execute gradlew -@rem endlocal doesn't take effect until after the line is parsed and variables are expanded -@rem which allows us to clear the local environment before executing the java command -endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel & goto exitWithErrorLevel - -@rem This label must not be changed. We rely on old scripts being able to jump to this point. -:exitWithErrorLevel -@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts -"%COMSPEC%" /c exit %ERRORLEVEL% +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem gradlew startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +@rem Catch executions from older scripts and ensure they exit cleanly. +@rem This can be removed once we can be reasonably confident that few people +@rem will be migrating directly to this new wrapper. +goto afterSafetyNet +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +goto exitWithErrorLevel +:afterSafetyNet + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. + +"%COMSPEC%" /c exit 1 +goto exitWithErrorLevel + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. + +"%COMSPEC%" /c exit 1 +goto exitWithErrorLevel + +:execute +@rem Setup the command line + + + +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel & goto exitWithErrorLevel + +@rem This label must not be changed. We rely on old scripts being able to jump to this point. +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% From 598cc7748983a721229b1013df9adbeac9b0123b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Nov 2026 08:15:56 +0000 Subject: [PATCH 4/8] Revert "chore(repo): renormalize gradlew.bat line endings" This reverts commit c81285b055885c9a6ccebe7bbb4988cdb166ea51. Requested in review: the change is unrelated to Docker support and put 224 lines of line-ending noise into the pull request. gradlew.bat is byte-identical to main again. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01LSsPaaLQJLx6wnsTuHo5hD --- gradlew.bat | 224 ++++++++++++++++++++++++++-------------------------- 1 file changed, 112 insertions(+), 112 deletions(-) diff --git a/gradlew.bat b/gradlew.bat index 3185a43..03754c4 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -1,112 +1,112 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem -@rem SPDX-License-Identifier: Apache-2.0 -@rem - -@if "%DEBUG%"=="" @echo off -@rem ########################################################################## -@rem -@rem gradlew startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables, and ensure extensions are enabled -setlocal EnableExtensions - -@rem Catch executions from older scripts and ensure they exit cleanly. -@rem This can be removed once we can be reasonably confident that few people -@rem will be migrating directly to this new wrapper. -goto afterSafetyNet -:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -goto exitWithErrorLevel -:afterSafetyNet - -set DIRNAME=%~dp0 -if "%DIRNAME%"=="" set DIRNAME=. -@rem This is normally unused -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if %ERRORLEVEL% equ 0 goto execute - -1>&2 echo. -1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -1>&2 echo. -1>&2 echo Please set the JAVA_HOME variable in your environment to match the -1>&2 echo location of your Java installation. - -"%COMSPEC%" /c exit 1 -goto exitWithErrorLevel - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -1>&2 echo. -1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -1>&2 echo. -1>&2 echo Please set the JAVA_HOME variable in your environment to match the -1>&2 echo location of your Java installation. - -"%COMSPEC%" /c exit 1 -goto exitWithErrorLevel - -:execute -@rem Setup the command line - - - -@rem Execute gradlew -@rem endlocal doesn't take effect until after the line is parsed and variables are expanded -@rem which allows us to clear the local environment before executing the java command -endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel & goto exitWithErrorLevel - -@rem This label must not be changed. We rely on old scripts being able to jump to this point. -:exitWithErrorLevel -@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts -"%COMSPEC%" /c exit %ERRORLEVEL% +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem gradlew startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +@rem Catch executions from older scripts and ensure they exit cleanly. +@rem This can be removed once we can be reasonably confident that few people +@rem will be migrating directly to this new wrapper. +goto afterSafetyNet +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +goto exitWithErrorLevel +:afterSafetyNet + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. + +"%COMSPEC%" /c exit 1 +goto exitWithErrorLevel + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. + +"%COMSPEC%" /c exit 1 +goto exitWithErrorLevel + +:execute +@rem Setup the command line + + + +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel & goto exitWithErrorLevel + +@rem This label must not be changed. We rely on old scripts being able to jump to this point. +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% From dea6ff41a91681de30b2c052e1345dc35e5ab1ff Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Nov 2026 08:06:14 +0000 Subject: [PATCH 5/8] fix(doctor): stop reading every Docker error as a stopped daemon "error during connect" is the prefix the Docker CLI puts in front of every transport failure, so matching it reported a rejected TLS certificate or a protocol mismatch as "daemon not running" while a daemon was in fact listening. The marker is gone; such failures are now unknown and `doctor` shows Docker's own message. The operating system's wording ("no such file or directory", "cannot find the file specified", "permission denied", ...) was too broad as well: a missing ca.pem or an unreadable key matched it. It now only counts on a line that names the daemon's own socket or pipe. The new fixtures are the messages Docker 29 printed for an untrusted certificate, missing certificate files and HTTP/TLS mismatches. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01LSsPaaLQJLx6wnsTuHo5hD --- CHANGELOG.md | 7 +- README.md | 2 +- .../kotlin/app/noir/core/platform/Docker.kt | 64 +++++++++---- .../app/noir/core/platform/DockerTest.kt | 89 +++++++++++++++++++ 4 files changed, 140 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d9baf4..d5b54d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,9 +9,10 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **Docker detection** — `noir doctor` now reports whether Docker is installed and whether its daemon is running, and prints the fix when the daemon is - stopped or refuses access. Docker stays optional: the row never changes the - exit code, and the check only runs the local `docker --version` and - `docker version`. + stopped or refuses access. Any other failure, such as a rejected TLS + certificate, is shown as unknown with Docker's own message. Docker stays + optional: the row never changes the exit code, and the check only runs the + local `docker --version` and `docker version`. - **container awareness** — NOIR recognises when it runs inside Docker, Podman, Kubernetes or LXC. `doctor` shows a `container` row, and the "no device" guidance explains USB passthrough instead of blaming the cable. diff --git a/README.md b/README.md index 657f07e..ce3a053 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,7 @@ noir setup --yes # 不询问,适合脚本 | 装了,守护进程在跑 | `客户端 29.9.2 · 守护进程 29.9.2 运行中` | | 装了,没启动 | `守护进程未运行`,下一行是启动方法 | | 装了,当前用户没权限 | `守护进程拒绝访问`,下一行是解决方法 | -| 认不出的错误,或 10 秒内没有应答 | `守护进程状态未知`,下一行是原因 | +| 其他错误(例如 TLS 证书校验失败),或 10 秒内没有应答 | `守护进程状态未知`,下一行是原因:Docker 的原始报错,或超时提示 | 检测只调用本机的 `docker --version` 和 `docker version`,不拉镜像,也不启动容器。 diff --git a/core/src/jvmMain/kotlin/app/noir/core/platform/Docker.kt b/core/src/jvmMain/kotlin/app/noir/core/platform/Docker.kt index adef794..895ee1e 100644 --- a/core/src/jvmMain/kotlin/app/noir/core/platform/Docker.kt +++ b/core/src/jvmMain/kotlin/app/noir/core/platform/Docker.kt @@ -69,23 +69,36 @@ object Docker { private val CLIENT_VERSION = Regex("\\bversion\\s+v?([0-9][^\\s,]*)", RegexOption.IGNORE_CASE) private val VERSION_LINE = Regex("^v?[0-9]+\\.[0-9]\\S*$") - // Checked before STOPPED: a refused socket also mentions "connect". - // Windows words its part of the message in the system language. - private val DENIED_MARKERS = listOf( - "permission denied", - "access is denied", - "拒绝访问" - ) - - private val STOPPED_MARKERS = listOf( + // Sentences the Docker CLI writes itself, and only when it could not reach + // the daemon's socket or pipe at all. "error during connect" is not one of + // them: the CLI puts that in front of every transport failure, a rejected + // TLS certificate included, and those leave the daemon's state open. + private val STOPPED_PHRASES = listOf( "cannot connect to the docker daemon", "failed to connect to the docker api", - "is the docker daemon running", - "docker daemon is not running", - "error during connect", - "connection refused", + "docker daemon is not running" + ) + + private val DENIED_PHRASES = listOf( + "permission denied while trying to connect" + ) + + // The operating system's own wording also turns up for unrelated files (a + // missing ca.pem, an unreadable key), so it only counts on a line that + // names the daemon's endpoint. Windows writes it in the system language. + private val ENDPOINT = listOf("dial unix", "open //./pipe/") + + private val OS_MISSING = listOf( "no such file or directory", - "cannot find the file specified" + "connection refused", + "cannot find the file specified", + "系统找不到指定的文件" + ) + + private val OS_DENIED = listOf( + "permission denied", + "access is denied", + "拒绝访问" ) /** Locates the Docker CLI: `PATH` first, then the usual install locations. */ @@ -171,17 +184,32 @@ object Docker { // A warning (an unreadable config file, …) is not the reason the daemon is silent. val complaints = lines.filterNot { it.startsWith("warning", ignoreCase = true) } - fun firstWith(markers: List): String? = - complaints.firstOrNull { line -> markers.any { it in line.lowercase() } } - firstWith(DENIED_MARKERS)?.let { return DaemonAnswer(DockerDaemon.DENIED, detail = it.take(300)) } - firstWith(STOPPED_MARKERS)?.let { return DaemonAnswer(DockerDaemon.STOPPED, detail = it.take(300)) } + // Refused is checked first: that message also says the daemon could not be reached. + complaints.firstOrNull(::refused)?.let { + return DaemonAnswer(DockerDaemon.DENIED, detail = it.take(300)) + } + complaints.firstOrNull(::unreachable)?.let { + return DaemonAnswer(DockerDaemon.STOPPED, detail = it.take(300)) + } return DaemonAnswer( DockerDaemon.UNKNOWN, detail = (complaints.firstOrNull() ?: lines.firstOrNull())?.take(300) ) } + private fun refused(line: String): Boolean { + val text = line.lowercase() + return DENIED_PHRASES.any { it in text } || + (ENDPOINT.any { it in text } && OS_DENIED.any { it in text }) + } + + private fun unreachable(line: String): Boolean { + val text = line.lowercase() + return STOPPED_PHRASES.any { it in text } || + (ENDPOINT.any { it in text } && OS_MISSING.any { it in text }) + } + /** The container runtime NOIR is running in, or null on an ordinary host. */ fun container(): ContainerRuntime? { if (!Platform.isLinux) return null diff --git a/core/src/jvmTest/kotlin/app/noir/core/platform/DockerTest.kt b/core/src/jvmTest/kotlin/app/noir/core/platform/DockerTest.kt index 7418b9c..78bbe57 100644 --- a/core/src/jvmTest/kotlin/app/noir/core/platform/DockerTest.kt +++ b/core/src/jvmTest/kotlin/app/noir/core/platform/DockerTest.kt @@ -113,6 +113,95 @@ class DockerTest { assertEquals(DockerDaemon.DENIED, windowsZh.state) } + @Test + fun `windows blames privileges even when the pipe is simply missing`() { + // A non-elevated client gets this sentence whatever went wrong; only the + // operating system's own half says whether the pipe exists. + val prefix = "error during connect: in the default daemon configuration on Windows, the docker " + + "client must be run with elevated privileges to connect: " + + "Get \"http://%2F%2F.%2Fpipe%2Fdocker_engine/v1.24/version\": open //./pipe/docker_engine: " + + assertEquals( + DockerDaemon.STOPPED, + Docker.parseDaemon(1, prefix + "The system cannot find the file specified.\n").state + ) + assertEquals(DockerDaemon.DENIED, Docker.parseDaemon(1, prefix + "Access is denied.\n").state) + + // Docker Desktop's pipe on a Chinese Windows. + val desktopZh = Docker.parseDaemon( + 1, + "error during connect: Get \"http://%2F%2F.%2Fpipe%2FdockerDesktopLinuxEngine/v1.47/version\": " + + "open //./pipe/dockerDesktopLinuxEngine: 系统找不到指定的文件。\n" + ) + assertEquals(DockerDaemon.STOPPED, desktopZh.state) + } + + @Test + fun `a tls failure is not a stopped daemon`() { + // All four were printed by Docker 29 against a port that was listening. + // "error during connect" is the CLI's prefix for every transport failure, + // so it says nothing about whether the daemon is up. + val untrusted = Docker.parseDaemon( + 1, + "\nerror during connect: Get \"https://127.0.0.1:2376/v1.56/version\": tls: failed to verify " + + "certificate: x509: certificate signed by unknown authority\n" + ) + assertEquals(DockerDaemon.UNKNOWN, untrusted.state) + assertTrue(untrusted.detail!!.contains("x509: certificate signed by unknown authority")) + + val plainToTls = Docker.parseDaemon( + 1, + "\nerror during connect: Get \"http://127.0.0.1:2376/v1.56/version\": read tcp " + + "127.0.0.1:46398->127.0.0.1:2376: read: connection reset by peer\n" + ) + assertEquals(DockerDaemon.UNKNOWN, plainToTls.state) + + val tlsToPlain = Docker.parseDaemon( + 1, + "\nerror during connect: Get \"https://127.0.0.1:2375/v1.56/version\": http: server gave HTTP " + + "response to HTTPS client\n" + ) + assertEquals(DockerDaemon.UNKNOWN, tlsToPlain.state) + + // An SSH endpoint that refuses the connection is not a stopped daemon either. + val ssh = Docker.parseDaemon( + 1, + "error during connect: Get \"http://docker.example.com/v1.56/version\": command [ssh -o " + + "ConnectTimeout=30 -T -- host docker system dial-stdio] has exited with exit status 255, " + + "make sure the URL is valid, and Docker 18.09 or later is installed on the remote host: " + + "stderr=ssh: connect to host host port 22: Connection refused\n" + ) + assertEquals(DockerDaemon.UNKNOWN, ssh.state) + } + + @Test + fun `operating system errors only count for the daemon's own endpoint`() { + // Docker 29 with DOCKER_TLS_VERIFY=1 and no certificates: a file is + // missing, but it is not the socket. + val missingCa = Docker.parseDaemon( + 1, + "Failed to initialize: unable to resolve docker endpoint: open /home/u/.docker/ca.pem: " + + "no such file or directory\n" + ) + assertEquals(DockerDaemon.UNKNOWN, missingCa.state) + assertTrue(missingCa.detail!!.contains("ca.pem")) + + val missingCaWindows = Docker.parseDaemon( + 1, + "Failed to initialize: unable to resolve docker endpoint: open C:\\Users\\u\\.docker\\ca.pem: " + + "The system cannot find the file specified.\n" + ) + assertEquals(DockerDaemon.UNKNOWN, missingCaWindows.state) + + // Likewise an unreadable key is not a refused socket. + val unreadableKey = Docker.parseDaemon( + 1, + "Failed to initialize: unable to resolve docker endpoint: open /home/u/.docker/key.pem: " + + "permission denied\n" + ) + assertEquals(DockerDaemon.UNKNOWN, unreadableKey.state) + } + @Test fun `an unreadable config file does not turn a stopped daemon into a denied one`() { val answer = Docker.parseDaemon( From 05509ec61336a3374957fda1573012238bc2bde3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Nov 2026 08:07:35 +0000 Subject: [PATCH 6/8] fix(docker): stop recommending a shared host adb server The Docker Desktop recipe pointed the container at the host's adb server through ADB_SERVER_SOCKET=tcp:host.docker.internal:5037. A host adb server listens on 127.0.0.1:5037 only, so that connection usually fails, and the usual way around it, `adb -a`, exposes an unauthenticated port to the whole network. The README and the in-container "no device" guidance now send macOS and Windows users to the portable build, where USB works, and the README says why the shared-server route is not offered. A test keeps the guidance from pointing at a host adb server again. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01LSsPaaLQJLx6wnsTuHo5hD --- CHANGELOG.md | 5 +++-- README.md | 11 +++-------- cli/src/main/kotlin/app/noir/cli/Guidance.kt | 6 ++---- .../kotlin/app/noir/cli/DockerGuidanceTest.kt | 17 ++++++++++++++++- 4 files changed, 24 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d5b54d6..919cba7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,8 +18,9 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). guidance explains USB passthrough instead of blaming the cable. - **Docker image** — a `Dockerfile` builds an image with a JRE, adb and NOIR, so a Linux host needs nothing but Docker. The README documents the USB - passthrough flags, the volume that keeps the adb authorisation, and how to - reuse a host adb server where USB cannot be passed through. + passthrough flags and the volume that keeps the adb authorisation. Docker + Desktop cannot pass USB through, so macOS and Windows stay with the + portable build. ### Fixed diff --git a/README.md b/README.md index ce3a053..446277e 100644 --- a/README.md +++ b/README.md @@ -228,15 +228,10 @@ docker run --rm -it \ - 容器内以 root 运行,所以 `noir-out/` 里的文件属主是 root。 - 在容器里找不到设备时,NOIR 会直接提示上面的透传参数。 -macOS / Windows 的 Docker Desktop 不能把 USB 设备透传进容器,建议直接用方式一的便携包。 -一定要用容器的话,先在宿主机上启动 adb(`adb start-server`),再让容器复用它: +macOS / Windows 的 Docker Desktop 不能把 USB 设备透传进容器,这两个系统请用方式一的便携包。 -```bash -docker run --rm -it \ - -e ADB_SERVER_SOCKET=tcp:host.docker.internal:5037 \ - -v noir-home:/root -v "$PWD:/work" \ - noir -``` +不要改成让容器去连宿主机的 adb server:它默认只监听 `127.0.0.1:5037`,容器通常连不上; +而用 `adb -a` 让它监听所有网卡,会把一个没有认证的端口暴露给整个局域网,任何人都能借它控制手机。 ## 构建 diff --git a/cli/src/main/kotlin/app/noir/cli/Guidance.kt b/cli/src/main/kotlin/app/noir/cli/Guidance.kt index 6690aa1..d9ba24a 100644 --- a/cli/src/main/kotlin/app/noir/cli/Guidance.kt +++ b/cli/src/main/kotlin/app/noir/cli/Guidance.kt @@ -258,8 +258,7 @@ object Guidance { " 宿主机上的 adb 会占住手机,先执行 adb kill-server", "", "2. macOS / Windows:Docker Desktop 不能透传 USB", - " 改用便携包,或让容器复用宿主机的 adb server:", - " -e ADB_SERVER_SOCKET=tcp:host.docker.internal:5037", + " 请改用便携包,直接在宿主机上运行 NOIR", "", "3. 手机端仍需开启 USB 调试,并在弹窗里点「允许」" ) @@ -273,8 +272,7 @@ object Guidance { " A host adb server holds the phone; stop it first: adb kill-server", "", "2. macOS / Windows: Docker Desktop cannot pass USB through", - " Use the portable build, or reuse the host's adb server:", - " -e ADB_SERVER_SOCKET=tcp:host.docker.internal:5037", + " Use the portable build and run NOIR on the host instead", "", "3. The phone still needs USB debugging on, and its prompt allowed." ) diff --git a/cli/src/test/kotlin/app/noir/cli/DockerGuidanceTest.kt b/cli/src/test/kotlin/app/noir/cli/DockerGuidanceTest.kt index 289be68..802da8b 100644 --- a/cli/src/test/kotlin/app/noir/cli/DockerGuidanceTest.kt +++ b/cli/src/test/kotlin/app/noir/cli/DockerGuidanceTest.kt @@ -115,7 +115,6 @@ class DockerGuidanceTest { ContainerRuntime.entries.forEach { runtime -> val guide = Guidance.containerDeviceGuide(runtime, lang) assertTrue(guide.contains("/dev/bus/usb"), "no passthrough hint:\n$guide") - assertTrue(guide.contains("ADB_SERVER_SOCKET"), "no adb server hint:\n$guide") guide.lines().forEach { line -> assertTrue(Tui.visibleWidth(line) <= limit, "line wider than $limit: [$line]") } @@ -124,4 +123,20 @@ class DockerGuidanceTest { assertTrue(Guidance.containerDeviceGuide(ContainerRuntime.DOCKER, Lang.EN).contains("(docker)")) assertFalse(Guidance.containerDeviceGuide(ContainerRuntime.OTHER, Lang.EN).contains("(")) } + + @Test + fun `container guide sends docker desktop users to usb, not to a host adb server`() { + // A host adb server listens on 127.0.0.1 only, and opening it up with + // `adb -a` exposes an unauthenticated port. The guide must not suggest either. + listOf(Lang.ZH, Lang.EN).forEach { lang -> + ContainerRuntime.entries.forEach { runtime -> + val guide = Guidance.containerDeviceGuide(runtime, lang) + listOf("ADB_SERVER_SOCKET", "host.docker.internal", "5037", "adb -a").forEach { risky -> + assertFalse(guide.contains(risky), "guide points at a host adb server ($risky):\n$guide") + } + } + } + assertTrue(Guidance.containerDeviceGuide(ContainerRuntime.DOCKER, Lang.EN).contains("portable build")) + assertTrue(Guidance.containerDeviceGuide(ContainerRuntime.DOCKER, Lang.ZH).contains("便携包")) + } } From 4df3926d34c573e0b803f5ebda5dfeea127ee210 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Nov 2026 08:13:03 +0000 Subject: [PATCH 7/8] build(docker): pin the base images and document root USB access The floating eclipse-temurin tags are replaced by the exact release they resolve to today (17.0.21_9), each with its multi-arch index digest, so a rebuild cannot silently pick up a different base. Dependabot now also watches the Dockerfile, otherwise the pins would only go stale. The README and the Dockerfile state that the container runs as root so adb can open the passed-through USB nodes, and that the documented flags reach every USB device on the host. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01LSsPaaLQJLx6wnsTuHo5hD --- .github/dependabot.yml | 6 ++++++ Dockerfile | 10 ++++++++-- README.md | 5 ++++- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 46f1477..879f043 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -11,3 +11,9 @@ updates: schedule: interval: weekly open-pull-requests-limit: 5 + + - package-ecosystem: docker + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 diff --git a/Dockerfile b/Dockerfile index 78c1a49..f948d7e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,12 +10,13 @@ # Build and runtime share one Java release: Gradle targets the JDK it runs on, # so a newer build JDK would produce classes the runtime cannot load. -FROM eclipse-temurin:17-jdk-noble AS build +# Both base images are pinned by version and digest; Dependabot moves the pins. +FROM eclipse-temurin:17.0.21_9-jdk-noble@sha256:1bedb04434bd275ef072355e9caec2a22be5240fbe82ad51a59323f2bbcab4a6 AS build WORKDIR /src COPY . . RUN ./gradlew :cli:installDist --no-daemon --console=plain -FROM eclipse-temurin:17-jre-noble +FROM eclipse-temurin:17.0.21_9-jre-noble@sha256:042c05a77eb915684fc8c92cfbbef4fd6f8c24ca7d934d1a7db3543453aaa077 LABEL org.opencontainers.image.title="NOIR" \ org.opencontainers.image.description="Android leak audit" \ org.opencontainers.image.licenses="MIT" @@ -31,6 +32,11 @@ COPY --from=build /src/cli/build/install/noir /opt/noir COPY LICENSE /opt/noir/LICENSE ENV PATH="/opt/noir/bin:${PATH}" +# The container stays root on purpose: adb has to open the USB device nodes +# passed through from the host, and those belong to root. With the documented +# flags that means every USB device on the host, not only the phone, so run +# only an image you built yourself. +# # Reports land in ./noir-out relative to the working directory; mount it. WORKDIR /work ENTRYPOINT ["noir"] diff --git a/README.md b/README.md index 446277e..5fb983a 100644 --- a/README.md +++ b/README.md @@ -225,9 +225,12 @@ docker run --rm -it \ - 宿主机上如果也装了 adb,先执行 `adb kill-server`,否则它会占住手机。 - 命令末尾可以接任何子命令,例如 `… noir doctor`、`… noir ui`。 - `deep` / `deepscan` 需要的 jadx 不在镜像里:执行一次 `… noir setup --yes`,它会下载到 `noir-home`。 -- 容器内以 root 运行,所以 `noir-out/` 里的文件属主是 root。 - 在容器里找不到设备时,NOIR 会直接提示上面的透传参数。 +> **权限说明**:容器内的 NOIR 和 adb 以 root 运行,这样才能打开透传进来的 USB 设备节点。 +> 上面两个 USB 参数给出的是宿主机上**全部** USB 设备的访问权,不只是手机,所以只运行你自己构建的镜像。 +> 副作用是 `noir-out/` 里的文件属主也是 root。 + macOS / Windows 的 Docker Desktop 不能把 USB 设备透传进容器,这两个系统请用方式一的便携包。 不要改成让容器去连宿主机的 adb server:它默认只监听 `127.0.0.1:5037`,容器通常连不上; From 2edd56087bd0a8dd94ce9547632e022474ba4626 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Nov 2026 08:13:03 +0000 Subject: [PATCH 8/8] ci: build and smoke-test the Docker image The Dockerfile was not exercised anywhere. The new job builds the image on amd64 and on a native arm64 runner, then checks what the build could not show on its own: - the distribution's adb package installs and runs, - NOIR starts on the image's JRE, notices the container and explains USB passthrough when no device is attached, - the build context holds the .dockerignore allowlist and nothing else; a planted noir-out/report.json must not reach the build stage. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01LSsPaaLQJLx6wnsTuHo5hD --- .github/workflows/build.yml | 52 +++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index fc87d9c..cd01ab4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -78,3 +78,55 @@ jobs: if: always() shell: bash run: ./gradlew --stop || true + + docker: + name: docker (${{ matrix.arch }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + arch: amd64 + - os: ubuntu-24.04-arm + arch: arm64 + + steps: + - uses: actions/checkout@v7 + + # .dockerignore is an allowlist: a local scan report must not reach a layer. + - name: Plant a report the build context must ignore + shell: bash + run: | + mkdir -p noir-out + echo "private scan data" > noir-out/report.json + + - name: Build image + shell: bash + run: docker build -t noir:ci . + + - name: Smoke test + shell: bash + run: | + set -euo pipefail + docker run --rm noir:ci --version + docker run --rm --entrypoint adb noir:ci version + # No phone here, so doctor exits 1. It must still find the packaged + # adb, notice the container and explain USB passthrough. + docker run --rm -e NOIR_LANG=en noir:ci doctor | tee doctor.txt || true + grep -q "container docker" doctor.txt + grep -q "adb /usr/bin/adb" doctor.txt + grep -q "no device detected in the container" doctor.txt + + - name: Build context holds the allowlist only + shell: bash + run: | + set -euo pipefail + docker build --target build -t noir:ci-build . + allowed='build\.gradle\.kts|settings\.gradle\.kts|gradle\.properties|gradlew|gradle|core|cli|LICENSE|build|\.gradle|\.kotlin' + extra="$(docker run --rm --entrypoint ls noir:ci-build -A /src | grep -vxE "$allowed" || true)" + if [ -n "$extra" ]; then + echo "unexpected files in the build context:" + echo "$extra" + exit 1 + fi