From f5db6a78a1fbc907c56bb58d74cdb20b6bb1bff3 Mon Sep 17 00:00:00 2001 From: taka2233 Date: Fri, 24 Apr 2026 18:26:22 +0900 Subject: [PATCH 01/22] chore: add protobuf-java-util dependency to build.gradle --- build.gradle | 1 + 1 file changed, 1 insertion(+) diff --git a/build.gradle b/build.gradle index 74d54c6f..87f7b2cc 100644 --- a/build.gradle +++ b/build.gradle @@ -51,6 +51,7 @@ dependencies { implementation 'com.j256.ormlite:ormlite-core:4.48' implementation 'com.j256.ormlite:ormlite-jdbc:4.48' implementation 'com.google.protobuf:protobuf-java:4.31.1' + implementation 'com.google.protobuf:protobuf-java-util:4.31.1' implementation 'org.slf4j:slf4j-api:2.0.9' implementation 'org.slf4j:slf4j-log4j12:1.7.25' implementation 'org.jline:jline:3.25.1' From eba2fe68ae649aa4db1ff8ba8ca300ed2fe40423 Mon Sep 17 00:00:00 2001 From: taka2233 Date: Fri, 24 Apr 2026 18:26:49 +0900 Subject: [PATCH 02/22] feat: implement gRPC support with DescriptorSetLoader, GrpcServiceRegistry, and ProtocRunner classes --- .../packetproxy/grpc/DescriptorSetLoader.kt | 59 +++++ .../packetproxy/grpc/GrpcProtoWireFormat.kt | 240 ++++++++++++++++++ .../packetproxy/grpc/GrpcServiceRegistry.kt | 70 +++++ .../grpc/GrpcServiceRegistryStore.kt | 65 +++++ .../core/packetproxy/grpc/ProtoFileSet.kt | 68 +++++ .../core/packetproxy/grpc/ProtocRunner.kt | 132 ++++++++++ 6 files changed, 634 insertions(+) create mode 100644 src/main/kotlin/core/packetproxy/grpc/DescriptorSetLoader.kt create mode 100644 src/main/kotlin/core/packetproxy/grpc/GrpcProtoWireFormat.kt create mode 100644 src/main/kotlin/core/packetproxy/grpc/GrpcServiceRegistry.kt create mode 100644 src/main/kotlin/core/packetproxy/grpc/GrpcServiceRegistryStore.kt create mode 100644 src/main/kotlin/core/packetproxy/grpc/ProtoFileSet.kt create mode 100644 src/main/kotlin/core/packetproxy/grpc/ProtocRunner.kt diff --git a/src/main/kotlin/core/packetproxy/grpc/DescriptorSetLoader.kt b/src/main/kotlin/core/packetproxy/grpc/DescriptorSetLoader.kt new file mode 100644 index 00000000..ca3c67ab --- /dev/null +++ b/src/main/kotlin/core/packetproxy/grpc/DescriptorSetLoader.kt @@ -0,0 +1,59 @@ +/* + * Copyright 2026 DeNA Co., Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package packetproxy.grpc + +import com.google.protobuf.DescriptorProtos.FileDescriptorSet +import com.google.protobuf.Descriptors.DescriptorValidationException +import com.google.protobuf.Descriptors.FileDescriptor +import java.io.File +import java.io.IOException +import java.nio.file.Files + +/** + * Loads a `.desc` file produced by `protoc --include_imports` and builds a dependency-ordered list + * of [FileDescriptor] instances. + */ +class DescriptorSetLoader private constructor() { + companion object { + /** + * Reads [descFile], parses the [FileDescriptorSet], and resolves each file's dependencies in + * order. Requires `protoc --include_imports` so that all transitive deps are present. + */ + @JvmStatic + @Throws(IOException::class, DescriptorValidationException::class, IllegalStateException::class) + fun loadAndBuild(descFile: File): List { + val bytes = Files.readAllBytes(descFile.toPath()) + val fds = FileDescriptorSet.parseFrom(bytes) + val known = HashMap() + val ordered = ArrayList() + for (fdp in fds.fileList) { + val deps = + Array(fdp.dependencyCount) { i -> + val depName = fdp.getDependency(i) + known[depName] + ?: throw IllegalStateException( + "Missing dependency '$depName' while building '${fdp.name}'. " + + "Re-generate with: protoc --include_imports --descriptor_set_out=out.desc -I... your.proto" + ) + } + val built = FileDescriptor.buildFrom(fdp, deps) + ordered.add(built) + known[built.name] = built + } + return ordered + } + } +} diff --git a/src/main/kotlin/core/packetproxy/grpc/GrpcProtoWireFormat.kt b/src/main/kotlin/core/packetproxy/grpc/GrpcProtoWireFormat.kt new file mode 100644 index 00000000..b4eaa539 --- /dev/null +++ b/src/main/kotlin/core/packetproxy/grpc/GrpcProtoWireFormat.kt @@ -0,0 +1,240 @@ +/* + * Copyright 2026 DeNA Co., Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package packetproxy.grpc + +import packetproxy.common.Protobuf3 +import packetproxy.common.Utils + +import com.fasterxml.jackson.core.JsonFactory +import com.fasterxml.jackson.core.JsonToken +import com.google.protobuf.Descriptors.Descriptor +import com.google.protobuf.DynamicMessage +import com.google.protobuf.util.JsonFormat +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer +import java.nio.charset.StandardCharsets +import java.util.ArrayList +import java.util.Arrays +import java.util.Collections +import org.apache.commons.lang3.ArrayUtils + +/** + * gRPC length-prefixed bodies to/from UTF-8 JSON using a [GrpcServiceRegistry], with schema-less + * fallback. + */ +class GrpcProtoWireFormat private constructor() { + companion object { + private val JSON_PRINTER = + JsonFormat.printer().preservingProtoFieldNames().alwaysPrintFieldsWithNoPresence() + + private val JSON_PARSER = JsonFormat.parser().ignoringUnknownFields() + + @JvmStatic + @Throws(Exception::class) + fun decodeGrpcHttpBodyToUtf8( + raw: ByteArray, + registry: GrpcServiceRegistry?, + isRequest: Boolean, + grpcPath: String?, + lastRequestGrpcPath: String?, + ): ByteArray { + if (registry == null) { + return decodeSchemalessGrpcBody(raw) + } + val type = + if (isRequest) registry.getInputType(grpcPath) + else registry.getOutputType(lastRequestGrpcPath) + if (type == null) { + return decodeSchemalessGrpcBody(raw) + } + val body = ByteArrayOutputStream() + var pos = 0 + while (pos < raw.size) { + val compressedFlag = raw[pos] + if (compressedFlag.toInt() != 0) { + throw Exception("gRPC: compressed flag in gRPC message is not supported yet") + } + pos += 1 + val messageLength = ByteBuffer.wrap(Arrays.copyOfRange(raw, pos, pos + 4)).int + pos += 4 + val grpcMsg = Arrays.copyOfRange(raw, pos, pos + messageLength) + pos += messageLength + if (body.size() > 0) { + body.write('\n'.code) + } + body.write(decodeOnePayload(grpcMsg, type).toByteArray(StandardCharsets.UTF_8)) + } + return body.toByteArray() + } + + @Throws(Exception::class) + private fun decodeSchemalessGrpcBody(raw: ByteArray): ByteArray { + val body = ByteArrayOutputStream() + var pos = 0 + while (pos < raw.size) { + val compressedFlag = raw[pos] + if (compressedFlag.toInt() != 0) { + throw Exception("gRPC: compressed flag in gRPC message is not supported yet") + } + pos += 1 + val messageLength = ByteBuffer.wrap(Arrays.copyOfRange(raw, pos, pos + 4)).int + pos += 4 + val grpcMsg = Arrays.copyOfRange(raw, pos, pos + messageLength) + pos += messageLength + if (body.size() > 0) { + body.write('\n'.code) + } + body.write(Protobuf3.decode(grpcMsg).toByteArray(StandardCharsets.UTF_8)) + } + return body.toByteArray() + } + + @Throws(Exception::class) + private fun decodeOnePayload(payload: ByteArray, type: Descriptor): String { + return try { + val msg = DynamicMessage.parseFrom(type, payload) + JSON_PRINTER.print(msg) + } catch (_: Exception) { + Protobuf3.decode(payload) + } + } + + @JvmStatic + @Throws(Exception::class) + fun encodeClientRequestHttpBody( + body: ByteArray, + registry: GrpcServiceRegistry?, + grpcPath: String?, + ): ByteArray { + if (registry == null) { + return encodeSchemalessGrpcBody(body) + } + val type = registry.getInputType(grpcPath) ?: return encodeSchemalessGrpcBody(body) + return encodeGrpcBodyFromJsonChunks(body, type) + } + + @JvmStatic + @Throws(Exception::class) + fun encodeServerResponseHttpBody( + body: ByteArray, + registry: GrpcServiceRegistry?, + lastRequestGrpcPath: String?, + ): ByteArray { + if (body.isEmpty()) { + return body + } + if (registry == null) { + return encodeSchemalessGrpcBody(body) + } + val type = + registry.getOutputType(lastRequestGrpcPath) ?: return encodeSchemalessGrpcBody(body) + return encodeGrpcBodyFromJsonChunks(body, type) + } + + @Throws(Exception::class) + private fun encodeSchemalessGrpcBody(body: ByteArray): ByteArray { + if (body.isEmpty()) { + return body + } + val rawStream = ByteArrayOutputStream() + var pos = 0 + while (pos < body.size) { + val subBody: ByteArray + val idx = Utils.indexOf(body, pos, body.size, "\n}".toByteArray(StandardCharsets.UTF_8)) + if (idx > 0) { + subBody = ArrayUtils.subarray(body, pos, idx + 2) + pos = idx + 2 + } else { + subBody = ArrayUtils.subarray(body, pos, body.size) + pos = body.size + } + val msg = String(subBody, StandardCharsets.UTF_8) + val data = Protobuf3.encode(msg) + writeGrpcFrame(rawStream, data) + } + return rawStream.toByteArray() + } + + /** + * Splits a UTF-8 body into top-level JSON objects. Uses Jackson's tokenizer so `\n` inside + * string values does not spuriously split (unlike the `"\n}"` heuristic in + * [encodeSchemalessGrpcBody]). + */ + private fun splitTopLevelJsonObjects(text: String?): List { + if (text.isNullOrEmpty()) { + return Collections.emptyList() + } + val out = ArrayList() + val factory = JsonFactory() + try { + factory.createParser(text).use { p -> + var depth = 0 + var start = -1 + while (p.nextToken() != null) { + if (p.currentToken() == JsonToken.START_OBJECT) { + if (depth == 0) { + start = p.currentLocation.charOffset.toInt() + } + depth++ + } else if (p.currentToken() == JsonToken.END_OBJECT) { + depth-- + if (depth == 0 && start >= 0) { + val end = p.currentLocation.charOffset.toInt() + 1 + out.add(text.substring(start, end)) + } + } + } + } + } catch (_: Exception) {} + return out + } + + @Throws(Exception::class) + private fun encodeGrpcBodyFromJsonChunks(body: ByteArray, type: Descriptor): ByteArray { + val s = String(body, StandardCharsets.UTF_8) + var objects = splitTopLevelJsonObjects(s) + if (objects.isEmpty() && s.trim().isNotEmpty()) { + objects = Collections.singletonList(s) + } + val rawStream = ByteArrayOutputStream() + for (json in objects) { + val trimmed = json.trim() + if (trimmed.isEmpty()) continue + val data = encodeOneJsonToBinary(trimmed, type) + writeGrpcFrame(rawStream, data) + } + return rawStream.toByteArray() + } + + @Throws(Exception::class) + private fun encodeOneJsonToBinary(json: String, type: Descriptor): ByteArray { + return try { + val builder = DynamicMessage.newBuilder(type) + JSON_PARSER.merge(json, builder) + builder.build().toByteArray() + } catch (_: Exception) { + Protobuf3.encode(json) + } + } + + @Throws(Exception::class) + private fun writeGrpcFrame(rawStream: ByteArrayOutputStream, payload: ByteArray) { + rawStream.write(0) + rawStream.write(ByteBuffer.allocate(4).putInt(payload.size).array()) + rawStream.write(payload) + } + } +} diff --git a/src/main/kotlin/core/packetproxy/grpc/GrpcServiceRegistry.kt b/src/main/kotlin/core/packetproxy/grpc/GrpcServiceRegistry.kt new file mode 100644 index 00000000..ad8ff52d --- /dev/null +++ b/src/main/kotlin/core/packetproxy/grpc/GrpcServiceRegistry.kt @@ -0,0 +1,70 @@ +/* + * Copyright 2026 DeNA Co., Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package packetproxy.grpc + +import com.google.protobuf.Descriptors.Descriptor +import com.google.protobuf.Descriptors.FileDescriptor +import java.util.Collections + +/** Resolves gRPC `:path` values (e.g. `/pkg.Service/Method`) to protobuf message descriptors. */ +class GrpcServiceRegistry(fileDescriptors: List) { + private val inputByPath: Map + private val outputByPath: Map + private val messageByFullName: Map + + init { + val inputs = HashMap() + val outputs = HashMap() + val messages = HashMap() + for (fd in fileDescriptors) { + indexMessages(fd.messageTypes, messages) + for (service in fd.services) { + for (method in service.methods) { + val grpcPath = "/${service.fullName}/${method.name}" + inputs[grpcPath] = method.inputType + outputs[grpcPath] = method.outputType + } + } + } + inputByPath = Collections.unmodifiableMap(inputs) + outputByPath = Collections.unmodifiableMap(outputs) + messageByFullName = Collections.unmodifiableMap(messages) + } + + fun getInputType(grpcPath: String?): Descriptor? { + if (grpcPath == null) return null + return inputByPath[grpcPath] + } + + fun getOutputType(grpcPath: String?): Descriptor? { + if (grpcPath == null) return null + return outputByPath[grpcPath] + } + + fun findMessageByName(fullName: String?): Descriptor? { + if (fullName == null) return null + return messageByFullName[fullName] + } + + companion object { + private fun indexMessages(types: Iterable, out: MutableMap) { + for (d in types) { + out[d.fullName] = d + indexMessages(d.nestedTypes, out) + } + } + } +} diff --git a/src/main/kotlin/core/packetproxy/grpc/GrpcServiceRegistryStore.kt b/src/main/kotlin/core/packetproxy/grpc/GrpcServiceRegistryStore.kt new file mode 100644 index 00000000..de1b17f8 --- /dev/null +++ b/src/main/kotlin/core/packetproxy/grpc/GrpcServiceRegistryStore.kt @@ -0,0 +1,65 @@ +/* + * Copyright 2026 DeNA Co., Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package packetproxy.grpc + +import java.io.File +import java.util.concurrent.ConcurrentHashMap + +/** + * Caches [GrpcServiceRegistry] per descriptor file path so short-lived encoders do not re-parse + * `.desc`. + */ +class GrpcServiceRegistryStore private constructor() { + private val cache = ConcurrentHashMap() + + @Throws(Exception::class) + fun get(descFile: File?): GrpcServiceRegistry { + if (descFile == null) { + throw IllegalArgumentException("descFile is null") + } + val key = descFile.canonicalPath + cache[key]?.let { + return it + } + synchronized(this) { + cache[key]?.let { + return it + } + val hit = GrpcServiceRegistry(DescriptorSetLoader.loadAndBuild(descFile)) + cache[key] = hit + return hit + } + } + + fun invalidate(descFile: File?) { + if (descFile == null) return + try { + cache.remove(descFile.canonicalPath) + } catch (_: Exception) { + cache.remove(descFile.absolutePath) + } + } + + fun invalidateAll() { + cache.clear() + } + + companion object { + private val INSTANCE = GrpcServiceRegistryStore() + + @JvmStatic fun getInstance(): GrpcServiceRegistryStore = INSTANCE + } +} diff --git a/src/main/kotlin/core/packetproxy/grpc/ProtoFileSet.kt b/src/main/kotlin/core/packetproxy/grpc/ProtoFileSet.kt new file mode 100644 index 00000000..9be5a03b --- /dev/null +++ b/src/main/kotlin/core/packetproxy/grpc/ProtoFileSet.kt @@ -0,0 +1,68 @@ +/* + * Copyright 2026 DeNA Co., Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package packetproxy.grpc + +import java.io.File +import java.util.ArrayList +import java.util.LinkedHashMap +import org.apache.commons.io.FilenameUtils + +/** Ordered, de-duplicated set of `.proto` paths for `protoc` invocation. */ +class ProtoFileSet { + private val canonicalToFile = LinkedHashMap() + + @Throws(Exception::class) + fun addFile(file: File?): Boolean { + if (file == null || !file.isFile) return false + if (!"proto".equals(FilenameUtils.getExtension(file.name), ignoreCase = true)) { + return false + } + val key = file.canonicalPath + if (canonicalToFile.containsKey(key)) return false + canonicalToFile[key] = file + return true + } + + @Throws(Exception::class) + fun addDirectoryShallow(dir: File?): Int { + if (dir == null || !dir.isDirectory) return 0 + val children = dir.listFiles() ?: return 0 + var added = 0 + for (child in children) { + if (addFile(child)) added++ + } + return added + } + + @Throws(Exception::class) + fun remove(file: File?): Boolean { + if (file == null) return false + return canonicalToFile.remove(file.canonicalPath) != null + } + + fun list(): List = ArrayList(canonicalToFile.values) + + /** Unique parent directories of added protos, in insertion order (for `protoc -I`). */ + @Throws(Exception::class) + fun includePaths(): List { + val dirs = LinkedHashMap() + for (f in canonicalToFile.values) { + val parent = f.parentFile ?: continue + dirs[parent.canonicalPath] = parent + } + return ArrayList(dirs.values) + } +} diff --git a/src/main/kotlin/core/packetproxy/grpc/ProtocRunner.kt b/src/main/kotlin/core/packetproxy/grpc/ProtocRunner.kt new file mode 100644 index 00000000..de282472 --- /dev/null +++ b/src/main/kotlin/core/packetproxy/grpc/ProtocRunner.kt @@ -0,0 +1,132 @@ +/* + * Copyright 2026 DeNA Co., Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package packetproxy.grpc + +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.InputStream +import java.nio.charset.StandardCharsets +import java.util.ArrayList +import java.util.concurrent.TimeUnit +import org.apache.commons.io.IOUtils + +/** + * Runs the `protoc` binary from `PATH` to emit a + * [com.google.protobuf.DescriptorProtos.FileDescriptorSet]. + */ +class ProtocRunner private constructor() { + class Result( + @JvmField val ok: Boolean, + @JvmField val exitCode: Int, + @JvmField val stdout: String, + @JvmField val stderr: String, + @JvmField val descFile: File, + ) + + companion object { + private val DEFAULT_DESC_DIR = File(System.getProperty("user.home"), ".packetproxy/grpc_desc") + + @JvmStatic + @Throws(Exception::class) + fun checkProtocOnPath() { + val pb = ProcessBuilder("protoc", "--version") + pb.redirectErrorStream(true) + val p = pb.start() + val out = drainToString(p.inputStream) + val finished = p.waitFor(30, TimeUnit.SECONDS) + if (!finished) { + p.destroyForcibly() + throw Exception("protoc が応答しません(タイムアウト)。PATH に protoc が含まれているか確認してください。") + } + if (p.exitValue() != 0) { + throw Exception("protoc の実行に失敗しました: $out") + } + } + + /** + * Allocates an output path under `~/.packetproxy/grpc_desc/` and runs `protoc`. The file name + * encodes the optional [serverId] so callers can correlate the output with a server entry. + */ + @JvmStatic + @Throws(Exception::class) + fun run(protos: List, includes: List, serverId: Int?): Result { + if (!DEFAULT_DESC_DIR.exists() && !DEFAULT_DESC_DIR.mkdirs()) { + throw IllegalStateException("Cannot create directory: ${DEFAULT_DESC_DIR.absolutePath}") + } + val ts = System.currentTimeMillis() + val name = if (serverId != null) "server_${serverId}_$ts.desc" else "new_$ts.desc" + return run(protos, includes, File(DEFAULT_DESC_DIR, name)) + } + + @JvmStatic + @Throws(Exception::class) + fun run(protos: List, includes: List, outDesc: File): Result { + val cmd = ArrayList() + cmd.add("protoc") + cmd.add("--include_imports") + cmd.add("--descriptor_set_out=${outDesc.absolutePath}") + for (inc in includes) { + cmd.add("-I${inc.absolutePath}") + } + for (proto in protos) { + cmd.add(proto.absolutePath) + } + val pb = ProcessBuilder(cmd) + pb.redirectErrorStream(false) + val p = pb.start() + val outBuf = ByteArrayOutputStream() + val errBuf = ByteArrayOutputStream() + val tout = Thread { copyQuietly(p.inputStream, outBuf) } + val terr = Thread { copyQuietly(p.errorStream, errBuf) } + tout.start() + terr.start() + val finished = p.waitFor(5, TimeUnit.MINUTES) + if (!finished) { + p.destroyForcibly() + tout.join(2000) + terr.join(2000) + return Result( + false, + -1, + outBuf.toString(StandardCharsets.UTF_8), + errBuf.toString(StandardCharsets.UTF_8) + "\n[timeout]", + outDesc, + ) + } + tout.join() + terr.join() + val code = p.exitValue() + return Result( + code == 0, + code, + outBuf.toString(StandardCharsets.UTF_8), + errBuf.toString(StandardCharsets.UTF_8), + outDesc, + ) + } + + private fun copyQuietly(input: InputStream?, dest: ByteArrayOutputStream) { + try { + if (input != null) IOUtils.copy(input, dest) + } catch (_: Exception) {} + } + + @Throws(Exception::class) + private fun drainToString(input: InputStream): String { + return String(IOUtils.toByteArray(input), StandardCharsets.UTF_8) + } + } +} From 30e4992a1e3409f084db504fe9c76db02a6a7dfb Mon Sep 17 00:00:00 2001 From: taka2233 Date: Fri, 24 Apr 2026 18:27:06 +0900 Subject: [PATCH 03/22] feat: add native and Swing directory chooser implementations to NativeFileChooser --- .../packetproxy/gui/NativeFileChooser.java | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/src/main/java/core/packetproxy/gui/NativeFileChooser.java b/src/main/java/core/packetproxy/gui/NativeFileChooser.java index 06d7436f..7d68484e 100644 --- a/src/main/java/core/packetproxy/gui/NativeFileChooser.java +++ b/src/main/java/core/packetproxy/gui/NativeFileChooser.java @@ -136,6 +136,21 @@ public int showSaveDialog(Component parent) { } } + /** + * Show a directory chooser (native on macOS). + * + * @param parent + * parent component + * @return APPROVE_OPTION if a directory was selected + */ + public int showDirectoryDialog(Component parent) { + if (PacketProxyUtility.getInstance().isMac()) { + return showNativeDirectoryDialog(parent); + } else { + return showSwingDirectoryDialog(parent); + } + } + /** * Get the Frame ancestor of the given component. * @@ -334,4 +349,61 @@ private int showSwingSaveDialog(Component parent) { return ERROR_OPTION; } } + + private int showNativeDirectoryDialog(Component parent) { + String previous = System.getProperty("apple.awt.fileDialogForDirectories"); + try { + System.setProperty("apple.awt.fileDialogForDirectories", "true"); + Frame frame = getFrame(parent); + FileDialog dialog = new FileDialog(frame, dialogTitle != null ? dialogTitle : "Select folder", + FileDialog.LOAD); + if (currentDirectory != null) { + dialog.setDirectory(currentDirectory.getAbsolutePath()); + } + dialog.setVisible(true); + String file = dialog.getFile(); + String directory = dialog.getDirectory(); + if (directory != null) { + if (file != null) { + selectedFile = new File(directory, file); + } else { + selectedFile = new File(directory); + } + return APPROVE_OPTION; + } + return CANCEL_OPTION; + } catch (Exception e) { + return ERROR_OPTION; + } finally { + if (previous != null) { + System.setProperty("apple.awt.fileDialogForDirectories", previous); + } else { + System.clearProperty("apple.awt.fileDialogForDirectories"); + } + } + } + + private int showSwingDirectoryDialog(Component parent) { + try { + JFileChooser chooser = new JFileChooser(); + if (currentDirectory != null) { + chooser.setCurrentDirectory(currentDirectory); + } + if (dialogTitle != null) { + chooser.setDialogTitle(dialogTitle); + } + chooser.setFileSelectionMode(JFileChooser.DIRECTORIES_ONLY); + chooser.setAcceptAllFileFilterUsed(true); + int result = chooser.showOpenDialog(parent); + if (result == JFileChooser.APPROVE_OPTION) { + selectedFile = chooser.getSelectedFile(); + return APPROVE_OPTION; + } else if (result == JFileChooser.ERROR_OPTION) { + return ERROR_OPTION; + } + return CANCEL_OPTION; + } catch (Exception e) { + return ERROR_OPTION; + } + } } From 496a4824e556ae913690fa094da2a24ce8dda2c4 Mon Sep 17 00:00:00 2001 From: taka2233 Date: Fri, 24 Apr 2026 18:27:34 +0900 Subject: [PATCH 04/22] feat: enhance packet encoding by adding server_ip and server_port to encoder instance creation --- .../java/core/packetproxy/model/OneShotPacket.java | 12 ++++++++---- src/main/java/core/packetproxy/model/Packet.java | 12 ++++++++---- src/main/java/core/packetproxy/model/Server.java | 12 ++++++++++++ src/main/java/core/packetproxy/model/Servers.java | 11 +++++++++++ 4 files changed, 39 insertions(+), 8 deletions(-) diff --git a/src/main/java/core/packetproxy/model/OneShotPacket.java b/src/main/java/core/packetproxy/model/OneShotPacket.java index 8c199a7d..51ab6704 100644 --- a/src/main/java/core/packetproxy/model/OneShotPacket.java +++ b/src/main/java/core/packetproxy/model/OneShotPacket.java @@ -186,21 +186,25 @@ public Packet toPacket() throws Exception { } public String getSummarizedRequest() throws Exception { - Encoder encoder = EncoderManager.getInstance().createInstance(encoder_name, alpn); + Encoder encoder = EncoderManager.getInstance().createInstance(encoder_name, alpn, + new InetSocketAddress(server_ip, server_port)); if (encoder == null) { err("エンコードモジュール: %s が見当たらないので、Sample とみなしました", encoder_name); - encoder = EncoderManager.getInstance().createInstance("Sample", alpn); + encoder = EncoderManager.getInstance().createInstance("Sample", alpn, + new InetSocketAddress(server_ip, server_port)); } return (getDirection() == Direction.CLIENT) ? encoder.getSummarizedRequest(toPacket()) : ""; } public String getSummarizedResponse() throws Exception { - Encoder encoder = EncoderManager.getInstance().createInstance(encoder_name, alpn); + Encoder encoder = EncoderManager.getInstance().createInstance(encoder_name, alpn, + new InetSocketAddress(server_ip, server_port)); if (encoder == null) { err("エンコードモジュール: %s が見当たらないので、Sample とみなしました", encoder_name); - encoder = EncoderManager.getInstance().createInstance("Sample", alpn); + encoder = EncoderManager.getInstance().createInstance("Sample", alpn, + new InetSocketAddress(server_ip, server_port)); } return (getDirection() == Direction.SERVER) ? encoder.getSummarizedResponse(toPacket()) : ""; } diff --git a/src/main/java/core/packetproxy/model/Packet.java b/src/main/java/core/packetproxy/model/Packet.java index f3a15a8b..e6ea4b42 100644 --- a/src/main/java/core/packetproxy/model/Packet.java +++ b/src/main/java/core/packetproxy/model/Packet.java @@ -279,21 +279,25 @@ public void setColor(String color) { } public String getSummarizedRequest() throws Exception { - Encoder encoder = EncoderManager.getInstance().createInstance(encoder_name, null); + Encoder encoder = EncoderManager.getInstance().createInstance(encoder_name, null, + new InetSocketAddress(server_ip, server_port)); if (encoder == null) { err("エンコードモジュール: %s が見当たらないので、Sample とみなしました", encoder_name); - encoder = EncoderManager.getInstance().createInstance("Sample", null); + encoder = EncoderManager.getInstance().createInstance("Sample", null, + new InetSocketAddress(server_ip, server_port)); } return (getDirection() == Direction.CLIENT) ? encoder.getSummarizedRequest(this) : ""; } public String getSummarizedResponse() throws Exception { - Encoder encoder = EncoderManager.getInstance().createInstance(encoder_name, null); + Encoder encoder = EncoderManager.getInstance().createInstance(encoder_name, null, + new InetSocketAddress(server_ip, server_port)); if (encoder == null) { err("エンコードモジュール: %s が見当たらないので、Sample とみなしました", encoder_name); - encoder = EncoderManager.getInstance().createInstance("Sample", null); + encoder = EncoderManager.getInstance().createInstance("Sample", null, + new InetSocketAddress(server_ip, server_port)); } return (getDirection() == Direction.SERVER) ? encoder.getSummarizedResponse(this) : ""; } diff --git a/src/main/java/core/packetproxy/model/Server.java b/src/main/java/core/packetproxy/model/Server.java index 32a005d6..802468f9 100644 --- a/src/main/java/core/packetproxy/model/Server.java +++ b/src/main/java/core/packetproxy/model/Server.java @@ -54,6 +54,9 @@ public class Server { @DatabaseField private String comment; + @DatabaseField(columnName = "descriptor_path") + private String descriptorPath; + private boolean specifiedByHostName; public Server() { @@ -79,6 +82,7 @@ private void initialize(String ip, int port, boolean use_ssl, String encoder, bo this.resolved_by_dns6 = resolved_by_dns6; this.http_proxy = http_proxy; this.comment = comment; + this.descriptorPath = null; this.specifiedByHostName = isHostName(ip); } @@ -190,6 +194,14 @@ public void setComment(String comment) { this.comment = comment; } + public String getDescriptorPath() { + return descriptorPath; + } + + public void setDescriptorPath(String descriptorPath) { + this.descriptorPath = descriptorPath; + } + public List getIps() { try { diff --git a/src/main/java/core/packetproxy/model/Servers.java b/src/main/java/core/packetproxy/model/Servers.java index 8210ed52..091d4c68 100644 --- a/src/main/java/core/packetproxy/model/Servers.java +++ b/src/main/java/core/packetproxy/model/Servers.java @@ -49,6 +49,15 @@ private Servers() throws Exception { database = Database.getInstance(); dao = database.createTable(Server.class, this); cache = new DaoQueryCache(); + ensureDescriptorPathColumn(); + } + + private void ensureDescriptorPathColumn() { + try { + dao.executeRawNoArgs("ALTER TABLE servers ADD COLUMN descriptor_path VARCHAR"); + } catch (Exception ignored) { + // column already exists + } } public void create(Server server) throws Exception { @@ -258,12 +267,14 @@ public void propertyChange(PropertyChangeEvent evt) { database = Database.getInstance(); dao = database.createTable(Server.class, this); cache.clear(); + ensureDescriptorPathColumn(); firePropertyChange(message); break; case RECREATE : database = Database.getInstance(); dao = database.createTable(Server.class, this); cache.clear(); + ensureDescriptorPathColumn(); break; default : break; From 4f41bd1a75e7ce4eec4abca5570a917e796266c5 Mon Sep 17 00:00:00 2001 From: taka2233 Date: Fri, 24 Apr 2026 18:28:08 +0900 Subject: [PATCH 05/22] feat: update EncoderManager and related classes to support server address in encoder instance creation and enhance gRPC handling with descriptor file management --- .../java/core/packetproxy/DuplexFactory.java | 13 +- .../java/core/packetproxy/EncoderManager.java | 34 +++- .../core/packetproxy/ProxySSLForward.java | 2 +- .../core/packetproxy/ProxySSLTransparent.java | 2 +- .../core/packetproxy/encode/EncodeGRPC.java | 129 +++---------- .../encode/EncodeGRPCStreaming.java | 174 ++++++++---------- 6 files changed, 142 insertions(+), 212 deletions(-) diff --git a/src/main/java/core/packetproxy/DuplexFactory.java b/src/main/java/core/packetproxy/DuplexFactory.java index 566e3e18..a387e3ab 100644 --- a/src/main/java/core/packetproxy/DuplexFactory.java +++ b/src/main/java/core/packetproxy/DuplexFactory.java @@ -69,7 +69,9 @@ private static void prepareDuplex(final Duplex duplex, Endpoint client_endpoint, duplex.addDuplexEventListener(new Duplex.DuplexEventListener() { private Packets packets = Packets.getInstance(); - private Encoder encoder = EncoderManager.getInstance().createInstance(encoder_name, ALPN); + private Encoder encoder = EncoderManager.getInstance().createInstance(encoder_name, ALPN, + server_addr); + private Modifications mods = Modifications.getInstance(); private Packet client_packet; private Packet server_packet; @@ -346,7 +348,8 @@ public static DuplexSync createDuplexSyncFromOneShotPacket(final OneShotPacket o private Packets packets = Packets.getInstance(); private Encoder encoder = EncoderManager.getInstance().createInstance(oneshot.getEncoder(), - oneshot.getAlpn()); + oneshot.getAlpn(), oneshot.getServer()); + private Packet client_packet; private Packet server_packet; @@ -535,7 +538,8 @@ public static DuplexSync createDuplexSyncForSinglePacketAttack(final OneShotPack private Packets packets = Packets.getInstance(); private Encoder encoder = EncoderManager.getInstance().createInstance(oneshot.getEncoder(), - oneshot.getAlpn()); + oneshot.getAlpn(), oneshot.getServer()); + private Packet client_packet; private Packet server_packet; @@ -694,7 +698,8 @@ public static Duplex createDuplexFromOriginalDuplex(Duplex original_duplex, OneS private Packets packets = Packets.getInstance(); private Encoder encoder = EncoderManager.getInstance().createInstance(oneshot.getEncoder(), - oneshot.getAlpn()); + oneshot.getAlpn(), oneshot.getServer()); + private Packet client_packet; private Packet server_packet; diff --git a/src/main/java/core/packetproxy/EncoderManager.java b/src/main/java/core/packetproxy/EncoderManager.java index 61699668..c1bd03c5 100644 --- a/src/main/java/core/packetproxy/EncoderManager.java +++ b/src/main/java/core/packetproxy/EncoderManager.java @@ -20,6 +20,7 @@ import com.google.common.collect.Sets; import java.io.File; import java.lang.reflect.Modifier; +import java.net.InetSocketAddress; import java.net.URL; import java.net.URLClassLoader; import java.nio.file.Path; @@ -37,7 +38,11 @@ import javax.tools.StandardLocation; import javax.tools.ToolProvider; import org.apache.commons.io.FilenameUtils; +import packetproxy.encode.EncodeGRPC; +import packetproxy.encode.EncodeGRPCStreaming; import packetproxy.encode.Encoder; +import packetproxy.model.Server; +import packetproxy.model.Servers; public class EncoderManager { @@ -188,12 +193,37 @@ private Encoder createInstance(Class klass, String ALPN) throws Excepti return klass.getConstructor(String.class).newInstance(ALPN); } - public Encoder createInstance(String encoderName, String ALPN) throws Exception { + public Encoder createInstance(String encoderName, String ALPN, InetSocketAddress serverAddr) throws Exception { Class klass = module_list.get(encoderName); if (klass == null) { return null; } - return createInstance(klass, ALPN); + Encoder encoder = createInstance(klass, ALPN); + applyGrpcDescriptor(encoder, serverAddr); + return encoder; + } + + private void applyGrpcDescriptor(Encoder encoder, InetSocketAddress serverAddr) { + if (serverAddr == null) + return; + try { + Server server = Servers.getInstance().queryByAddress(serverAddr); + if (server == null) + return; + String path = server.getDescriptorPath(); + if (path == null || path.trim().isEmpty()) + return; + File f = new File(path.trim()); + if (!f.isFile()) + return; + if (encoder instanceof EncodeGRPC) { + ((EncodeGRPC) encoder).setDescriptorFile(f); + } else if (encoder instanceof EncodeGRPCStreaming) { + ((EncodeGRPCStreaming) encoder).setDescriptorFile(f); + } + } catch (Exception e) { + errWithStackTrace(e); + } } } diff --git a/src/main/java/core/packetproxy/ProxySSLForward.java b/src/main/java/core/packetproxy/ProxySSLForward.java index b4c6f329..39894404 100644 --- a/src/main/java/core/packetproxy/ProxySSLForward.java +++ b/src/main/java/core/packetproxy/ProxySSLForward.java @@ -104,7 +104,7 @@ public void createConnection(SSLSocketEndpoint client_e, SSLSocketEndpoint serve if (alpn == null || alpn.isEmpty()) { - Encoder encoder = EncoderManager.getInstance().createInstance(server.getEncoder(), ""); + Encoder encoder = EncoderManager.getInstance().createInstance(server.getEncoder(), "", null); if (encoder instanceof EncodeHTTPBase) { /* The client does not support ALPN. It seems to be an old HTTP client */ diff --git a/src/main/java/core/packetproxy/ProxySSLTransparent.java b/src/main/java/core/packetproxy/ProxySSLTransparent.java index 8ad59482..d6e2a41a 100644 --- a/src/main/java/core/packetproxy/ProxySSLTransparent.java +++ b/src/main/java/core/packetproxy/ProxySSLTransparent.java @@ -219,7 +219,7 @@ public void createConnection(SSLSocketEndpoint client_e, SSLSocketEndpoint serve if (alpn == null || alpn.isEmpty()) { - Encoder encoder = EncoderManager.getInstance().createInstance(server.getEncoder(), ""); + Encoder encoder = EncoderManager.getInstance().createInstance(server.getEncoder(), "", null); if (encoder instanceof EncodeHTTPBase) { /* The client does not support ALPN. It seems to be an old HTTP client */ diff --git a/src/main/java/core/packetproxy/encode/EncodeGRPC.java b/src/main/java/core/packetproxy/encode/EncodeGRPC.java index 068490d9..025a09a1 100644 --- a/src/main/java/core/packetproxy/encode/EncodeGRPC.java +++ b/src/main/java/core/packetproxy/encode/EncodeGRPC.java @@ -15,19 +15,32 @@ */ package packetproxy.encode; -import java.io.ByteArrayOutputStream; -import java.nio.ByteBuffer; -import java.nio.charset.StandardCharsets; -import java.util.Arrays; -import org.apache.commons.lang3.ArrayUtils; -import packetproxy.common.Protobuf3; -import packetproxy.common.Utils; +import static packetproxy.util.Logging.errWithStackTrace; + +import java.io.File; +import packetproxy.grpc.GrpcProtoWireFormat; +import packetproxy.grpc.GrpcServiceRegistry; +import packetproxy.grpc.GrpcServiceRegistryStore; import packetproxy.http.Http; import packetproxy.http2.Grpc; public class EncodeGRPC extends EncodeHTTPBase { - private byte compressedFlag; + private volatile GrpcServiceRegistry registry; + private volatile String lastGrpcPath; + + public synchronized void setDescriptorFile(File descFile) { + if (descFile == null || !descFile.isFile()) { + this.registry = null; + return; + } + try { + this.registry = GrpcServiceRegistryStore.getInstance().get(descFile); + } catch (Exception e) { + this.registry = null; + errWithStackTrace(e); + } + } public EncodeGRPC() throws Exception { super(); @@ -44,59 +57,17 @@ public String getName() { @Override protected Http decodeClientRequestHttp(Http inputHttp) throws Exception { + lastGrpcPath = inputHttp.getPath(); byte[] raw = inputHttp.getBody(); - ByteArrayOutputStream body = new ByteArrayOutputStream(); - int pos = 0; - while (pos < raw.length) { - - compressedFlag = raw[pos]; - if (compressedFlag != 0) { - - throw new Exception("gRPC: compressed flag in gRPC message is not supported yet"); - } - pos += 1; - int messageLength = ByteBuffer.wrap(Arrays.copyOfRange(raw, pos, pos + 4)).getInt(); - pos += 4; - byte[] grpcMsg = Arrays.copyOfRange(raw, pos, pos + messageLength); - byte[] decodedMsg = decodeGrpcClientPayload(grpcMsg); - if (body.size() > 0) { - - body.write("\n".getBytes()); - } - body.write(Protobuf3.decode(decodedMsg).getBytes(StandardCharsets.UTF_8)); - pos += messageLength; - } - inputHttp.setBody(body.toByteArray()); + inputHttp.setBody(GrpcProtoWireFormat.decodeGrpcHttpBodyToUtf8(raw, registry, true, lastGrpcPath, null)); return inputHttp; } @Override protected Http encodeClientRequestHttp(Http inputHttp) throws Exception { + lastGrpcPath = inputHttp.getPath(); byte[] body = inputHttp.getBody(); - ByteArrayOutputStream rawStream = new ByteArrayOutputStream(); - int pos = 0; - while (pos < body.length) { - - byte[] subBody; - int idx; - if ((idx = Utils.indexOf(body, pos, body.length, "\n}".getBytes())) > 0) { // split into gRPC messages - - subBody = ArrayUtils.subarray(body, pos, idx + 2); - pos = idx + 2; - } else { - - subBody = ArrayUtils.subarray(body, pos, body.length); - pos = body.length; - } - String msg = new String(subBody, StandardCharsets.UTF_8); - byte[] data = Protobuf3.encode(msg); - byte[] encodedData = encodeGrpcClientPayload(data); - int encodedDataLen = encodedData.length; - rawStream.write((byte) 0); // always compressed flag is zero - rawStream.write(ByteBuffer.allocate(4).putInt(encodedDataLen).array()); - rawStream.write(encodedData); - } - inputHttp.setBody(rawStream.toByteArray()); + inputHttp.setBody(GrpcProtoWireFormat.encodeClientRequestHttpBody(body, registry, lastGrpcPath)); return inputHttp; } @@ -104,31 +75,9 @@ protected Http encodeClientRequestHttp(Http inputHttp) throws Exception { protected Http decodeServerResponseHttp(Http inputHttp) throws Exception { byte[] raw = inputHttp.getBody(); if (raw.length == 0) { - return inputHttp; } - ByteArrayOutputStream body = new ByteArrayOutputStream(); - int pos = 0; - while (pos < raw.length) { - - compressedFlag = raw[pos]; - if (compressedFlag != 0) { - - throw new Exception("gRPC: compressed flag in gRPC message is not supported yet"); - } - pos += 1; - int messageLength = ByteBuffer.wrap(Arrays.copyOfRange(raw, pos, pos + 4)).getInt(); - pos += 4; - byte[] grpcMsg = Arrays.copyOfRange(raw, pos, pos + messageLength); - byte[] decodedMsg = decodeGrpcServerPayload(grpcMsg); - if (body.size() > 0) { - - body.write("\n".getBytes()); - } - body.write(Protobuf3.decode(decodedMsg).getBytes(StandardCharsets.UTF_8)); - pos += messageLength; - } - inputHttp.setBody(body.toByteArray()); + inputHttp.setBody(GrpcProtoWireFormat.decodeGrpcHttpBodyToUtf8(raw, registry, false, null, lastGrpcPath)); return inputHttp; } @@ -136,33 +85,9 @@ protected Http decodeServerResponseHttp(Http inputHttp) throws Exception { protected Http encodeServerResponseHttp(Http inputHttp) throws Exception { byte[] body = inputHttp.getBody(); if (body.length == 0) { - return inputHttp; } - ByteArrayOutputStream rawStream = new ByteArrayOutputStream(); - int pos = 0; - while (pos < body.length) { - - byte[] subBody; - int idx; - if ((idx = Utils.indexOf(body, pos, body.length, "\n}".getBytes())) > 0) { // split into gRPC messages - - subBody = ArrayUtils.subarray(body, pos, idx + 2); - pos = idx + 2; - } else { - - subBody = ArrayUtils.subarray(body, pos, body.length); - pos = body.length; - } - String msg = new String(subBody, StandardCharsets.UTF_8); - byte[] data = Protobuf3.encode(msg); - byte[] encodedData = encodeGrpcServerPayload(data); - int encodedDataLen = encodedData.length; - rawStream.write((byte) 0); // always compressed flag is zero - rawStream.write(ByteBuffer.allocate(4).putInt(encodedDataLen).array()); - rawStream.write(encodedData); - } - inputHttp.setBody(rawStream.toByteArray()); + inputHttp.setBody(GrpcProtoWireFormat.encodeServerResponseHttpBody(body, registry, lastGrpcPath)); return inputHttp; } diff --git a/src/main/java/core/packetproxy/encode/EncodeGRPCStreaming.java b/src/main/java/core/packetproxy/encode/EncodeGRPCStreaming.java index fc80ad55..bfb1f9ae 100644 --- a/src/main/java/core/packetproxy/encode/EncodeGRPCStreaming.java +++ b/src/main/java/core/packetproxy/encode/EncodeGRPCStreaming.java @@ -15,20 +15,76 @@ */ package packetproxy.encode; -import java.io.ByteArrayOutputStream; -import java.nio.ByteBuffer; -import java.nio.charset.StandardCharsets; -import java.util.Arrays; -import org.apache.commons.lang3.ArrayUtils; -import packetproxy.common.Protobuf3; -import packetproxy.common.Utils; +import static packetproxy.util.Logging.errWithStackTrace; + +import java.io.File; +import java.util.concurrent.ConcurrentHashMap; +import packetproxy.grpc.GrpcProtoWireFormat; +import packetproxy.grpc.GrpcServiceRegistry; +import packetproxy.grpc.GrpcServiceRegistryStore; import packetproxy.http.Http; import packetproxy.http2.GrpcStreaming; // gRPCでデータフレーム1つずつをメッセージと解釈して送受信するエンコーダ public class EncodeGRPCStreaming extends EncodeHTTPBase { - private byte compressedFlag; + private volatile GrpcServiceRegistry registry; + private volatile String lastGrpcPath; + private final ConcurrentHashMap grpcPathByStreamId = new ConcurrentHashMap<>(); + + public synchronized void setDescriptorFile(File descFile) { + grpcPathByStreamId.clear(); + if (descFile == null || !descFile.isFile()) { + this.registry = null; + return; + } + try { + this.registry = GrpcServiceRegistryStore.getInstance().get(descFile); + } catch (Exception e) { + this.registry = null; + errWithStackTrace(e); + } + } + + private String resolveGrpcPathClient(Http http) { + String path = http.getPath(); + String streamIdStr = http.getFirstHeader("X-PacketProxy-HTTP2-Stream-Id"); + if (streamIdStr == null || streamIdStr.isEmpty()) { + return path; + } + int streamId; + try { + streamId = Integer.parseInt(streamIdStr); + } catch (NumberFormatException e) { + return path; + } + if ("/trailer-header-frame".equals(path)) { + String removed = grpcPathByStreamId.remove(streamId); + return removed != null ? removed : path; + } + if ("/data-frame".equals(path)) { + String mapped = grpcPathByStreamId.get(streamId); + return mapped != null ? mapped : path; + } + grpcPathByStreamId.put(streamId, path); + return path; + } + + private String resolveGrpcPathServer(Http http) { + String path = http.getPath(); + String streamIdStr = http.getFirstHeader("X-PacketProxy-HTTP2-Stream-Id"); + if (streamIdStr == null || streamIdStr.isEmpty()) { + return path; + } + int streamId; + try { + streamId = Integer.parseInt(streamIdStr); + } catch (NumberFormatException e) { + return path; + } + String mapped = grpcPathByStreamId.get(streamId); + return mapped != null ? mapped : path; + } public EncodeGRPCStreaming() throws Exception { super(); @@ -45,59 +101,17 @@ public String getName() { @Override protected Http decodeClientRequestHttp(Http inputHttp) throws Exception { + lastGrpcPath = resolveGrpcPathClient(inputHttp); byte[] raw = inputHttp.getBody(); - ByteArrayOutputStream body = new ByteArrayOutputStream(); - int pos = 0; - while (pos < raw.length) { - - compressedFlag = raw[pos]; - if (compressedFlag != 0) { - - throw new Exception("gRPC: compressed flag in gRPC message is not supported yet"); - } - pos += 1; - int messageLength = ByteBuffer.wrap(Arrays.copyOfRange(raw, pos, pos + 4)).getInt(); - pos += 4; - byte[] grpcMsg = Arrays.copyOfRange(raw, pos, pos + messageLength); - byte[] decodedMsg = decodeGrpcClientPayload(grpcMsg); - if (body.size() > 0) { - - body.write("\n".getBytes()); - } - body.write(Protobuf3.decode(decodedMsg).getBytes(StandardCharsets.UTF_8)); - pos += messageLength; - } - inputHttp.setBody(body.toByteArray()); + inputHttp.setBody(GrpcProtoWireFormat.decodeGrpcHttpBodyToUtf8(raw, registry, true, lastGrpcPath, null)); return inputHttp; } @Override protected Http encodeClientRequestHttp(Http inputHttp) throws Exception { + lastGrpcPath = resolveGrpcPathClient(inputHttp); byte[] body = inputHttp.getBody(); - ByteArrayOutputStream rawStream = new ByteArrayOutputStream(); - int pos = 0; - while (pos < body.length) { - - byte[] subBody; - int idx; - if ((idx = Utils.indexOf(body, pos, body.length, "\n}".getBytes())) > 0) { // split into gRPC messages - - subBody = ArrayUtils.subarray(body, pos, idx + 2); - pos = idx + 2; - } else { - - subBody = ArrayUtils.subarray(body, pos, body.length); - pos = body.length; - } - String msg = new String(subBody, StandardCharsets.UTF_8); - byte[] data = Protobuf3.encode(msg); - byte[] encodedData = encodeGrpcClientPayload(data); - int encodedDataLen = encodedData.length; - rawStream.write((byte) 0); // always compressed flag is zero - rawStream.write(ByteBuffer.allocate(4).putInt(encodedDataLen).array()); - rawStream.write(encodedData); - } - inputHttp.setBody(rawStream.toByteArray()); + inputHttp.setBody(GrpcProtoWireFormat.encodeClientRequestHttpBody(body, registry, lastGrpcPath)); return inputHttp; } @@ -105,31 +119,10 @@ protected Http encodeClientRequestHttp(Http inputHttp) throws Exception { protected Http decodeServerResponseHttp(Http inputHttp) throws Exception { byte[] raw = inputHttp.getBody(); if (raw.length == 0) { - return inputHttp; } - ByteArrayOutputStream body = new ByteArrayOutputStream(); - int pos = 0; - while (pos < raw.length) { - - compressedFlag = raw[pos]; - if (compressedFlag != 0) { - - throw new Exception("gRPC: compressed flag in gRPC message is not supported yet"); - } - pos += 1; - int messageLength = ByteBuffer.wrap(Arrays.copyOfRange(raw, pos, pos + 4)).getInt(); - pos += 4; - byte[] grpcMsg = Arrays.copyOfRange(raw, pos, pos + messageLength); - byte[] decodedMsg = decodeGrpcServerPayload(grpcMsg); - if (body.size() > 0) { - - body.write("\n".getBytes()); - } - body.write(Protobuf3.decode(decodedMsg).getBytes(StandardCharsets.UTF_8)); - pos += messageLength; - } - inputHttp.setBody(body.toByteArray()); + lastGrpcPath = resolveGrpcPathServer(inputHttp); + inputHttp.setBody(GrpcProtoWireFormat.decodeGrpcHttpBodyToUtf8(raw, registry, false, null, lastGrpcPath)); return inputHttp; } @@ -137,33 +130,10 @@ protected Http decodeServerResponseHttp(Http inputHttp) throws Exception { protected Http encodeServerResponseHttp(Http inputHttp) throws Exception { byte[] body = inputHttp.getBody(); if (body.length == 0) { - return inputHttp; } - ByteArrayOutputStream rawStream = new ByteArrayOutputStream(); - int pos = 0; - while (pos < body.length) { - - byte[] subBody; - int idx; - if ((idx = Utils.indexOf(body, pos, body.length, "\n}".getBytes())) > 0) { // split into gRPC messages - - subBody = ArrayUtils.subarray(body, pos, idx + 2); - pos = idx + 2; - } else { - - subBody = ArrayUtils.subarray(body, pos, body.length); - pos = body.length; - } - String msg = new String(subBody, StandardCharsets.UTF_8); - byte[] data = Protobuf3.encode(msg); - byte[] encodedData = encodeGrpcServerPayload(data); - int encodedDataLen = encodedData.length; - rawStream.write((byte) 0); // always compressed flag is zero - rawStream.write(ByteBuffer.allocate(4).putInt(encodedDataLen).array()); - rawStream.write(encodedData); - } - inputHttp.setBody(rawStream.toByteArray()); + lastGrpcPath = resolveGrpcPathServer(inputHttp); + inputHttp.setBody(GrpcProtoWireFormat.encodeServerResponseHttpBody(body, registry, lastGrpcPath)); return inputHttp; } From 505262abde868dffdbaae34965bfd113a224367b Mon Sep 17 00:00:00 2001 From: taka2233 Date: Fri, 24 Apr 2026 18:28:46 +0900 Subject: [PATCH 06/22] feat: enhance GUIOptionServerDialog with gRPC descriptor file management and add GUIOptionProtoCompileDialog for .proto file handling --- .../gui/GUIOptionServerDialog.java | 90 ++++++- .../gui/GUIOptionProtoCompileDialog.kt | 219 ++++++++++++++++++ 2 files changed, 308 insertions(+), 1 deletion(-) create mode 100644 src/main/kotlin/core/packetproxy/gui/GUIOptionProtoCompileDialog.kt diff --git a/src/main/java/core/packetproxy/gui/GUIOptionServerDialog.java b/src/main/java/core/packetproxy/gui/GUIOptionServerDialog.java index cd4f82fd..76024d84 100644 --- a/src/main/java/core/packetproxy/gui/GUIOptionServerDialog.java +++ b/src/main/java/core/packetproxy/gui/GUIOptionServerDialog.java @@ -22,7 +22,9 @@ import java.awt.Rectangle; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; +import java.io.File; import java.util.regex.*; +import javax.swing.Box; import javax.swing.BoxLayout; import javax.swing.JButton; import javax.swing.JCheckBox; @@ -33,7 +35,9 @@ import javax.swing.JLabel; import javax.swing.JOptionPane; import javax.swing.JPanel; +import javax.swing.filechooser.FileNameExtensionFilter; import packetproxy.EncoderManager; +import packetproxy.grpc.GrpcServiceRegistryStore; import packetproxy.common.I18nString; import packetproxy.model.Server; @@ -53,7 +57,13 @@ public class GUIOptionServerDialog extends JDialog { private JCheckBox checkbox_upstream_http_proxy = new JCheckBox( I18nString.get("Need to be defined as an Upstream Http Proxy")); JComboBox combo = new JComboBox(); - private int height = 500; + private HintTextField text_proto_path = new HintTextField("(ex.) /path/to/service.desc"); + private JButton button_proto_browse = new JButton(I18nString.get("Browse...")); + private JButton button_proto_generate = new JButton(I18nString.get("Generate from .proto...")); + private JButton button_proto_reload = new JButton(I18nString.get("Reload cache")); + private JPanel panelDescriptorPath; + private Integer editingServerId; + private int height = 580; private int width = 700; private Server server = null; @@ -78,6 +88,7 @@ private JComponent buttons() { } public Server showDialog(Server preset) { + editingServerId = preset.getId(); text_ip.setText(preset.getIp()); text_port.setText(Integer.toString(preset.getPort())); combo.setSelectedItem(preset.getEncoder()); @@ -86,6 +97,9 @@ public Server showDialog(Server preset) { checkbox_dns.setSelected(preset.isResolved()); checkbox_dns6.setSelected(preset.isResolved6()); text_comment.setText(preset.getComment()); + String dp = preset.getDescriptorPath(); + text_proto_path.setText(dp != null ? dp : ""); + updateGrpcDescriptorUiVisibility(); setModal(true); setVisible(true); if (server != null) { @@ -98,12 +112,17 @@ public Server showDialog(Server preset) { preset.setResolved6(checkbox_dns6.isSelected()); preset.setHttpProxy(checkbox_upstream_http_proxy.isSelected()); preset.setComment(text_comment.getText()); + String path = text_proto_path.getText().trim(); + preset.setDescriptorPath(path.isEmpty() ? null : path); return preset; } return server; } public Server showDialog() { + editingServerId = null; + text_proto_path.setText(""); + updateGrpcDescriptorUiVisibility(); EventQueue.invokeLater(new Runnable() { @Override @@ -171,6 +190,30 @@ private JComponent createCommentSetting() { return label_and_object(I18nString.get("Comments:"), text_comment); } + private JComponent createDescriptorPathSetting() { + JPanel row = new JPanel(); + row.setLayout(new BoxLayout(row, BoxLayout.X_AXIS)); + row.add(new JLabel(I18nString.get("gRPC descriptor (.desc):"))); + row.add(Box.createRigidArea(new Dimension(8, 0))); + text_proto_path.setMaximumSize(new Dimension(Short.MAX_VALUE, text_proto_path.getPreferredSize().height)); + row.add(text_proto_path); + row.add(Box.createRigidArea(new Dimension(8, 0))); + row.add(button_proto_browse); + row.add(button_proto_generate); + row.add(button_proto_reload); + panelDescriptorPath = row; + return row; + } + + private void updateGrpcDescriptorUiVisibility() { + boolean show = !checkbox_upstream_http_proxy.isSelected(); + Object enc = combo.getSelectedItem(); + show = show && enc != null && ("gRPC".equals(enc.toString()) || "gRPC Streaming".equals(enc.toString())); + if (panelDescriptorPath != null) { + panelDescriptorPath.setVisible(show); + } + } + public GUIOptionServerDialog(JFrame owner) throws Exception { super(owner); setTitle(I18nString.get("Server setting")); @@ -198,6 +241,47 @@ public void actionPerformed(ActionEvent e) { checkbox_dns.setEnabled(true); checkbox_dns6.setEnabled(true); } + updateGrpcDescriptorUiVisibility(); + } + }); + + combo.addActionListener(e -> updateGrpcDescriptorUiVisibility()); + + button_proto_browse.addActionListener(e -> { + try { + NativeFileChooser chooser = new NativeFileChooser(); + chooser.setDialogTitle(I18nString.get("Select descriptor file")); + chooser.addChoosableFileFilter(new FileNameExtensionFilter("Descriptor set (*.desc)", "desc")); + chooser.setAcceptAllFileFilterUsed(true); + if (chooser.showOpenDialog(this) == NativeFileChooser.APPROVE_OPTION) { + File f = chooser.getSelectedFile(); + if (f != null) { + text_proto_path.setText(f.getAbsolutePath()); + } + } + } catch (Exception ex) { + JOptionPane.showMessageDialog(this, ex.getMessage(), I18nString.get("Error"), + JOptionPane.ERROR_MESSAGE); + } + }); + + button_proto_generate.addActionListener(e -> { + try { + GUIOptionProtoCompileDialog dlg = new GUIOptionProtoCompileDialog((JFrame) getOwner(), editingServerId); + String path = dlg.showCompileDialog(); + if (path != null) { + text_proto_path.setText(path); + } + } catch (Exception ex) { + JOptionPane.showMessageDialog(this, ex.getMessage(), I18nString.get("Error"), + JOptionPane.ERROR_MESSAGE); + } + }); + + button_proto_reload.addActionListener(e -> { + String p = text_proto_path.getText().trim(); + if (!p.isEmpty()) { + GrpcServiceRegistryStore.getInstance().invalidate(new File(p)); } }); @@ -213,6 +297,7 @@ public void actionPerformed(ActionEvent e) { panel.add(createModuleAlert()); } panel.add(createModuleSetting()); + panel.add(createDescriptorPathSetting()); panel.add(createDNSSettinglabel()); panel.add(createDNSSetting()); panel.add(createDNS6Setting()); @@ -222,6 +307,7 @@ public void actionPerformed(ActionEvent e) { panel.add(buttons()); c.add(panel); + updateGrpcDescriptorUiVisibility(); button_cancel.addActionListener(new ActionListener() { @@ -248,6 +334,8 @@ public void actionPerformed(ActionEvent e) { server = new Server(text_ip.getText(), Integer.parseInt(text_port.getText()), checkbox_ssl.isSelected(), combo.getSelectedItem().toString(), checkbox_dns.isSelected(), checkbox_dns6.isSelected(), checkbox_upstream_http_proxy.isSelected(), text_comment.getText()); + String path = text_proto_path.getText().trim(); + server.setDescriptorPath(path.isEmpty() ? null : path); dispose(); } }); diff --git a/src/main/kotlin/core/packetproxy/gui/GUIOptionProtoCompileDialog.kt b/src/main/kotlin/core/packetproxy/gui/GUIOptionProtoCompileDialog.kt new file mode 100644 index 00000000..515d2419 --- /dev/null +++ b/src/main/kotlin/core/packetproxy/gui/GUIOptionProtoCompileDialog.kt @@ -0,0 +1,219 @@ +/* + * Copyright 2026 DeNA Co., Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package packetproxy.gui + +import java.awt.BorderLayout +import java.awt.Dimension +import java.io.File +import javax.swing.BorderFactory +import javax.swing.BoxLayout +import javax.swing.JButton +import javax.swing.JDialog +import javax.swing.JFrame +import javax.swing.JOptionPane +import javax.swing.JPanel +import javax.swing.JScrollPane +import javax.swing.JTable +import javax.swing.SwingUtilities +import javax.swing.SwingWorker +import javax.swing.filechooser.FileNameExtensionFilter +import javax.swing.table.DefaultTableModel +import packetproxy.common.I18nString +import packetproxy.grpc.GrpcServiceRegistryStore +import packetproxy.grpc.ProtoFileSet +import packetproxy.grpc.ProtocRunner + +class GUIOptionProtoCompileDialog(owner: JFrame?, private val serverId: Int?) : + JDialog(owner, I18nString.get("Generate .desc from .proto"), true) { + + private val protoSet = ProtoFileSet() + private val tableModel = + object : DefaultTableModel(arrayOf("Path"), 0) { + override fun isCellEditable(row: Int, column: Int) = false + } + private var resultPath: String? = null + + init { + val root = JPanel(BorderLayout(8, 8)) + root.border = BorderFactory.createEmptyBorder(8, 8, 8, 8) + + val top = JPanel() + top.layout = BoxLayout(top, BoxLayout.X_AXIS) + val addFile = JButton(I18nString.get("Add .proto file...")) + addFile.addActionListener { addProtoFiles() } + val addDir = JButton(I18nString.get("Add directory (1 level)...")) + addDir.addActionListener { addProtoDirectory() } + top.add(addFile) + top.add(addDir) + root.add(top, BorderLayout.NORTH) + + val table = JTable(tableModel) + table.preferredScrollableViewportSize = Dimension(520, 200) + root.add(JScrollPane(table), BorderLayout.CENTER) + + val remove = JButton(I18nString.get("Remove selected")) + remove.addActionListener { + val row = table.selectedRow + if (row >= 0) { + try { + val f = File(tableModel.getValueAt(row, 0) as String) + protoSet.remove(f) + tableModel.removeRow(row) + } catch (ex: Exception) { + JOptionPane.showMessageDialog( + this, + ex.message, + I18nString.get("Error"), + JOptionPane.ERROR_MESSAGE, + ) + } + } + } + + val bottom = JPanel() + bottom.layout = BoxLayout(bottom, BoxLayout.Y_AXIS) + bottom.add(remove) + + val actions = JPanel() + actions.layout = BoxLayout(actions, BoxLayout.X_AXIS) + val generate = JButton(I18nString.get("Generate .desc")) + generate.addActionListener { runGenerate() } + val cancel = JButton(I18nString.get("Cancel")) + cancel.addActionListener { + resultPath = null + dispose() + } + actions.add(generate) + actions.add(cancel) + bottom.add(actions) + root.add(bottom, BorderLayout.SOUTH) + + contentPane = root + pack() + setLocationRelativeTo(owner) + } + + private fun addProtoFiles() { + try { + val chooser = NativeFileChooser() + chooser.setDialogTitle(I18nString.get("Select .proto files")) + chooser.addChoosableFileFilter(FileNameExtensionFilter("Protocol Buffers (*.proto)", "proto")) + chooser.setAcceptAllFileFilterUsed(false) + if (chooser.showOpenDialog(this) == NativeFileChooser.APPROVE_OPTION) { + val f = chooser.selectedFile + if (f != null && protoSet.addFile(f)) { + tableModel.addRow(arrayOf(f.absolutePath)) + } + } + } catch (ex: Exception) { + JOptionPane.showMessageDialog( + this, + ex.message, + I18nString.get("Error"), + JOptionPane.ERROR_MESSAGE, + ) + } + } + + private fun addProtoDirectory() { + try { + val chooser = NativeFileChooser() + chooser.setDialogTitle(I18nString.get("Select directory")) + if (chooser.showDirectoryDialog(this) == NativeFileChooser.APPROVE_OPTION) { + val dir = chooser.selectedFile + if (dir != null && dir.isDirectory) { + val n = protoSet.addDirectoryShallow(dir) + if (n == 0) { + JOptionPane.showMessageDialog( + this, + I18nString.get("No .proto files found in the selected directory."), + I18nString.get("Info"), + JOptionPane.INFORMATION_MESSAGE, + ) + } else { + refreshTableFromSet() + } + } + } + } catch (ex: Exception) { + JOptionPane.showMessageDialog( + this, + ex.message, + I18nString.get("Error"), + JOptionPane.ERROR_MESSAGE, + ) + } + } + + @Throws(Exception::class) + private fun refreshTableFromSet() { + tableModel.rowCount = 0 + for (f in protoSet.list()) { + tableModel.addRow(arrayOf(f.absolutePath)) + } + } + + private fun runGenerate() { + val protos = protoSet.list() + if (protos.isEmpty()) { + JOptionPane.showMessageDialog( + this, + I18nString.get("Add at least one .proto file."), + I18nString.get("Error"), + JOptionPane.WARNING_MESSAGE, + ) + return + } + val worker = + object : SwingWorker() { + @Throws(Exception::class) + override fun doInBackground(): Void? { + ProtocRunner.checkProtocOnPath() + val includes = protoSet.includePaths() + val r = ProtocRunner.run(protos, includes, serverId) + if (!r.ok) { + throw Exception(if (r.stderr.isEmpty()) "exit ${r.exitCode}" else r.stderr) + } + GrpcServiceRegistryStore.getInstance().invalidate(r.descFile) + resultPath = r.descFile.absolutePath + return null + } + + override fun done() { + try { + get() + SwingUtilities.invokeLater { dispose() } + } catch (e: Exception) { + val c = e.cause ?: e + JOptionPane.showMessageDialog( + this@GUIOptionProtoCompileDialog, + c.message, + I18nString.get("protoc failed"), + JOptionPane.ERROR_MESSAGE, + ) + } + } + } + worker.execute() + } + + /** Opens modally; returns absolute path to generated `.desc`, or `null` if cancelled. */ + fun showCompileDialog(): String? { + resultPath = null + isVisible = true + return resultPath + } +} From 3dfd1587e28398b5352b217690818090849c3773 Mon Sep 17 00:00:00 2001 From: taka2233 Date: Fri, 24 Apr 2026 18:29:03 +0900 Subject: [PATCH 07/22] feat: update encoder instance creation in ResendController and SinglePacketAttackController to include server information --- .../java/core/packetproxy/controller/ResendController.java | 5 +++-- .../packetproxy/controller/SinglePacketAttackController.java | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/main/java/core/packetproxy/controller/ResendController.java b/src/main/java/core/packetproxy/controller/ResendController.java index f3d362ca..2c36ddd3 100644 --- a/src/main/java/core/packetproxy/controller/ResendController.java +++ b/src/main/java/core/packetproxy/controller/ResendController.java @@ -182,7 +182,8 @@ private class DataToBeSend { public DataToBeSend(OneShotPacket oneshot, Consumer onReceived) throws Exception { this.oneshot = oneshot; this.onReceived = onReceived; - Encoder encoder = EncoderManager.getInstance().createInstance(oneshot.getEncoder(), oneshot.getAlpn()); + Encoder encoder = EncoderManager.getInstance().createInstance(oneshot.getEncoder(), + oneshot.getAlpn(), oneshot.getServer()); if (encoder.useNewConnectionForResend() == false && encoder.useNewEncoderForResend() == false) { this.isDirectSend = true; @@ -251,7 +252,7 @@ public void send() throws Exception { /* 100 Continue 対策 */ Encoder encoder = EncoderManager.getInstance().createInstance(oneshot.getEncoder(), - oneshot.getAlpn()); + oneshot.getAlpn(), oneshot.getServer()); if (encoder instanceof EncodeHTTPBase) { EncodeHTTPBase httpEncoder = (EncodeHTTPBase) encoder; diff --git a/src/main/java/core/packetproxy/controller/SinglePacketAttackController.java b/src/main/java/core/packetproxy/controller/SinglePacketAttackController.java index a27a3d00..264cba76 100644 --- a/src/main/java/core/packetproxy/controller/SinglePacketAttackController.java +++ b/src/main/java/core/packetproxy/controller/SinglePacketAttackController.java @@ -164,7 +164,8 @@ private static AttackFrames generateAttackFrames(final OneShotPacket packet) thr } private static List convertPacketToFrames(final OneShotPacket packet) throws Exception { - final var encoder = EncoderManager.getInstance().createInstance(packet.getEncoder(), packet.getAlpn()); + final var encoder = EncoderManager.getInstance().createInstance(packet.getEncoder(), packet.getAlpn(), + packet.getServer()); if (encoder == null) { throw new IllegalStateException("Could not create encoder for target packet"); From b4534f19982bc28f1bb3f9be52c57fcffe62b751 Mon Sep 17 00:00:00 2001 From: taka2233 Date: Fri, 24 Apr 2026 18:29:46 +0900 Subject: [PATCH 08/22] feat: add unit tests for gRPC descriptor loading and encoding functionality --- .../grpc/DescriptorSetLoaderTest.kt | 69 ++++++++++++++++++ .../grpc/GrpcProtoWireFormatTest.kt | 72 +++++++++++++++++++ .../grpc/GrpcServiceRegistryStoreTest.kt | 52 ++++++++++++++ .../grpc/GrpcServiceRegistryTest.kt | 49 +++++++++++++ .../packetproxy/grpc/ProtoFileSetTest.kt | 64 +++++++++++++++++ .../resources/proto/multidir/common.proto | 7 ++ src/test/resources/proto/multidir/svc_a.proto | 9 +++ src/test/resources/proto/multidir/svc_b.proto | 9 +++ src/test/resources/proto/testsvc.proto | 15 ++++ 9 files changed, 346 insertions(+) create mode 100644 src/test/kotlin/packetproxy/grpc/DescriptorSetLoaderTest.kt create mode 100644 src/test/kotlin/packetproxy/grpc/GrpcProtoWireFormatTest.kt create mode 100644 src/test/kotlin/packetproxy/grpc/GrpcServiceRegistryStoreTest.kt create mode 100644 src/test/kotlin/packetproxy/grpc/GrpcServiceRegistryTest.kt create mode 100644 src/test/kotlin/packetproxy/grpc/ProtoFileSetTest.kt create mode 100644 src/test/resources/proto/multidir/common.proto create mode 100644 src/test/resources/proto/multidir/svc_a.proto create mode 100644 src/test/resources/proto/multidir/svc_b.proto create mode 100644 src/test/resources/proto/testsvc.proto diff --git a/src/test/kotlin/packetproxy/grpc/DescriptorSetLoaderTest.kt b/src/test/kotlin/packetproxy/grpc/DescriptorSetLoaderTest.kt new file mode 100644 index 00000000..f1b89d62 --- /dev/null +++ b/src/test/kotlin/packetproxy/grpc/DescriptorSetLoaderTest.kt @@ -0,0 +1,69 @@ +/* + * Copyright 2026 DeNA Co., Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package packetproxy.grpc + +import java.io.File +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class DescriptorSetLoaderTest { + private fun resource(classpathPath: String): File { + val u = + DescriptorSetLoaderTest::class.java.getResource(classpathPath) + ?: throw IllegalStateException("missing resource: $classpathPath") + return File(u.toURI()) + } + + @Test + fun loadAndBuild_missingFile_throws() { + assertThrows(Exception::class.java) { + DescriptorSetLoader.loadAndBuild(File("/nonexistent/path.desc")) + } + } + + @Test + fun loadAndBuild_invalidBytes_throws() { + val tmp = Files.createTempFile("bad", ".desc").toFile() + Files.writeString(tmp.toPath(), "not-a-protobuf-descriptor", StandardCharsets.UTF_8) + assertThrows(Exception::class.java) { DescriptorSetLoader.loadAndBuild(tmp) } + } + + @Test + fun loadAndBuild_withIncludeImports_ok() { + val f = resource("/proto/multidir/multi.desc") + val list = DescriptorSetLoader.loadAndBuild(f) + assertFalse(list.isEmpty()) + } + + // multi_without_imports.desc was built without --include_imports, so transitive deps are missing. + // loadAndBuild must detect this and throw rather than silently producing an incomplete registry. + @Test + fun loadAndBuild_withoutIncludeImports_throws() { + val f = resource("/proto/multidir/multi_without_imports.desc") + assertThrows(IllegalStateException::class.java) { DescriptorSetLoader.loadAndBuild(f) } + } + + @Test + fun loadAndBuild_testsvc_containsGreeterService() { + val f = resource("/proto/testsvc.desc") + val list = DescriptorSetLoader.loadAndBuild(f) + assertTrue(list.any { it.findServiceByName("Greeter") != null }) + } +} diff --git a/src/test/kotlin/packetproxy/grpc/GrpcProtoWireFormatTest.kt b/src/test/kotlin/packetproxy/grpc/GrpcProtoWireFormatTest.kt new file mode 100644 index 00000000..b7636fcd --- /dev/null +++ b/src/test/kotlin/packetproxy/grpc/GrpcProtoWireFormatTest.kt @@ -0,0 +1,72 @@ +/* + * Copyright 2026 DeNA Co., Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package packetproxy.grpc + +import java.io.File +import java.nio.charset.StandardCharsets +import org.junit.jupiter.api.Assertions.assertArrayEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class GrpcProtoWireFormatTest { + private fun resource(classpathPath: String): File { + val u = + GrpcProtoWireFormatTest::class.java.getResource(classpathPath) + ?: throw IllegalStateException("missing resource: $classpathPath") + return File(u.toURI()) + } + + private fun registry(): GrpcServiceRegistry = + GrpcServiceRegistry(DescriptorSetLoader.loadAndBuild(resource("/proto/testsvc.desc"))) + + @Test + fun decodeThenEncode_request_roundtrip() { + val reg = registry() + val grpcPath = "/pp.testsvc.Greeter/SayHello" + val json = "{\n \"name\": \"Alice\"\n}" + val encodedOnce = + GrpcProtoWireFormat.encodeClientRequestHttpBody( + json.toByteArray(StandardCharsets.UTF_8), + reg, + grpcPath, + ) + val utf8 = GrpcProtoWireFormat.decodeGrpcHttpBodyToUtf8(encodedOnce, reg, true, grpcPath, null) + val encodedTwice = GrpcProtoWireFormat.encodeClientRequestHttpBody(utf8, reg, grpcPath) + val decoded = String(utf8, StandardCharsets.UTF_8) + assertTrue(decoded.contains("\"name\"")) + assertTrue(decoded.contains("Alice")) + assertArrayEquals(encodedOnce, encodedTwice) + } + + @Test + fun decodeThenEncode_response_roundtrip() { + val reg = registry() + val lastRequestPath = "/pp.testsvc.Greeter/SayHello" + val json = "{\n \"message\": \"Hello\"\n}" + val encodedOnce = + GrpcProtoWireFormat.encodeServerResponseHttpBody( + json.toByteArray(StandardCharsets.UTF_8), + reg, + lastRequestPath, + ) + val utf8 = + GrpcProtoWireFormat.decodeGrpcHttpBodyToUtf8(encodedOnce, reg, false, null, lastRequestPath) + val encodedTwice = GrpcProtoWireFormat.encodeServerResponseHttpBody(utf8, reg, lastRequestPath) + val decoded = String(utf8, StandardCharsets.UTF_8) + assertTrue(decoded.contains("message") || decoded.contains("Hello")) + assertArrayEquals(encodedOnce, encodedTwice) + } +} diff --git a/src/test/kotlin/packetproxy/grpc/GrpcServiceRegistryStoreTest.kt b/src/test/kotlin/packetproxy/grpc/GrpcServiceRegistryStoreTest.kt new file mode 100644 index 00000000..f656be2d --- /dev/null +++ b/src/test/kotlin/packetproxy/grpc/GrpcServiceRegistryStoreTest.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2026 DeNA Co., Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package packetproxy.grpc + +import java.io.File +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test + +class GrpcServiceRegistryStoreTest { + private fun resource(classpathPath: String): File { + val u = + GrpcServiceRegistryStoreTest::class.java.getResource(classpathPath) + ?: throw IllegalStateException("missing resource: $classpathPath") + return File(u.toURI()) + } + + @AfterEach + fun tearDown() { + GrpcServiceRegistryStore.getInstance().invalidateAll() + } + + @Test + fun getCachesByCanonicalPath() { + val store = GrpcServiceRegistryStore.getInstance() + val f = resource("/proto/testsvc.desc") + val a = store.get(f) + val b = store.get(f) + assertSame(a, b) + store.invalidate(f) + } + + @Test + fun get_missingFile_throws() { + val store = GrpcServiceRegistryStore.getInstance() + assertThrows(Exception::class.java) { store.get(File("/nonexistent/does-not-exist.desc")) } + } +} diff --git a/src/test/kotlin/packetproxy/grpc/GrpcServiceRegistryTest.kt b/src/test/kotlin/packetproxy/grpc/GrpcServiceRegistryTest.kt new file mode 100644 index 00000000..042b0dfd --- /dev/null +++ b/src/test/kotlin/packetproxy/grpc/GrpcServiceRegistryTest.kt @@ -0,0 +1,49 @@ +/* + * Copyright 2026 DeNA Co., Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package packetproxy.grpc + +import java.io.File +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +class GrpcServiceRegistryTest { + private fun resource(classpathPath: String): File { + val u = + GrpcServiceRegistryTest::class.java.getResource(classpathPath) + ?: throw IllegalStateException("missing resource: $classpathPath") + return File(u.toURI()) + } + + @Test + fun mapsGrpcPathToInputOutput() { + val reg = GrpcServiceRegistry(DescriptorSetLoader.loadAndBuild(resource("/proto/testsvc.desc"))) + val input = reg.getInputType("/pp.testsvc.Greeter/SayHello") + val output = reg.getOutputType("/pp.testsvc.Greeter/SayHello") + assertNotNull(input) + assertNotNull(output) + assertEquals("HelloRequest", input!!.name) + assertEquals("HelloReply", output!!.name) + assertNull(reg.getInputType("/unknown.Service/Method")) + } + + @Test + fun findMessageByName_nestedIndexing() { + val reg = GrpcServiceRegistry(DescriptorSetLoader.loadAndBuild(resource("/proto/testsvc.desc"))) + assertNotNull(reg.findMessageByName("pp.testsvc.HelloRequest")) + } +} diff --git a/src/test/kotlin/packetproxy/grpc/ProtoFileSetTest.kt b/src/test/kotlin/packetproxy/grpc/ProtoFileSetTest.kt new file mode 100644 index 00000000..098a126e --- /dev/null +++ b/src/test/kotlin/packetproxy/grpc/ProtoFileSetTest.kt @@ -0,0 +1,64 @@ +/* + * Copyright 2026 DeNA Co., Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package packetproxy.grpc + +import java.io.File +import java.nio.file.Files +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class ProtoFileSetTest { + private fun resource(classpathPath: String): File { + val u = + ProtoFileSetTest::class.java.getResource(classpathPath) + ?: throw IllegalStateException("missing resource: $classpathPath") + return File(u.toURI()) + } + + @Test + fun addFile_rejectsNonProto() { + val set = ProtoFileSet() + val tmp = Files.createTempFile("x", ".txt").toFile() + assertFalse(set.addFile(tmp)) + } + + @Test + fun addFile_deduplicatesByCanonicalPath() { + val set = ProtoFileSet() + val f = resource("/proto/testsvc.proto") + assertTrue(set.addFile(f)) + assertFalse(set.addFile(f)) + assertEquals(1, set.list().size) + } + + @Test + fun includePaths_uniqueParents() { + val set = ProtoFileSet() + set.addFile(resource("/proto/testsvc.proto")) + set.addFile(resource("/proto/multidir/common.proto")) + val inc = set.includePaths() + assertEquals(2, inc.size) + } + + @Test + fun addDirectoryShallow_nonRecursive() { + val set = ProtoFileSet() + val n = set.addDirectoryShallow(resource("/proto/testsvc.proto").parentFile) + assertTrue(n >= 1) + } +} diff --git a/src/test/resources/proto/multidir/common.proto b/src/test/resources/proto/multidir/common.proto new file mode 100644 index 00000000..6b62ac82 --- /dev/null +++ b/src/test/resources/proto/multidir/common.proto @@ -0,0 +1,7 @@ +syntax = "proto3"; + +package pp.multidir; + +message Shared { + string x = 1; +} diff --git a/src/test/resources/proto/multidir/svc_a.proto b/src/test/resources/proto/multidir/svc_a.proto new file mode 100644 index 00000000..106f1ac0 --- /dev/null +++ b/src/test/resources/proto/multidir/svc_a.proto @@ -0,0 +1,9 @@ +syntax = "proto3"; + +package pp.multidir; + +import "common.proto"; + +service ServiceA { + rpc Call(Shared) returns (Shared); +} diff --git a/src/test/resources/proto/multidir/svc_b.proto b/src/test/resources/proto/multidir/svc_b.proto new file mode 100644 index 00000000..70d9ed82 --- /dev/null +++ b/src/test/resources/proto/multidir/svc_b.proto @@ -0,0 +1,9 @@ +syntax = "proto3"; + +package pp.multidir; + +import "common.proto"; + +service ServiceB { + rpc Ping(Shared) returns (Shared); +} diff --git a/src/test/resources/proto/testsvc.proto b/src/test/resources/proto/testsvc.proto new file mode 100644 index 00000000..4b792ebd --- /dev/null +++ b/src/test/resources/proto/testsvc.proto @@ -0,0 +1,15 @@ +syntax = "proto3"; + +package pp.testsvc; + +message HelloRequest { + string name = 1; +} + +message HelloReply { + string message = 1; +} + +service Greeter { + rpc SayHello(HelloRequest) returns (HelloReply); +} From 8eee1c907b50577d1639a3ec63ba8f75702e9047 Mon Sep 17 00:00:00 2001 From: taka2233 Date: Mon, 27 Apr 2026 11:42:53 +0900 Subject: [PATCH 09/22] feat: introduce GUIOptionGrpcDescriptorDialog for unified .desc management, replacing the previous ProtoCompileDialog --- .../gui/GUIOptionGrpcDescriptorDialog.kt | 365 ++++++++++++++++++ .../gui/GUIOptionProtoCompileDialog.kt | 219 ----------- 2 files changed, 365 insertions(+), 219 deletions(-) create mode 100644 src/main/kotlin/core/packetproxy/gui/GUIOptionGrpcDescriptorDialog.kt delete mode 100644 src/main/kotlin/core/packetproxy/gui/GUIOptionProtoCompileDialog.kt diff --git a/src/main/kotlin/core/packetproxy/gui/GUIOptionGrpcDescriptorDialog.kt b/src/main/kotlin/core/packetproxy/gui/GUIOptionGrpcDescriptorDialog.kt new file mode 100644 index 00000000..cc11c545 --- /dev/null +++ b/src/main/kotlin/core/packetproxy/gui/GUIOptionGrpcDescriptorDialog.kt @@ -0,0 +1,365 @@ +/* + * Copyright 2026 DeNA Co., Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package packetproxy.gui + +import java.awt.BorderLayout +import java.awt.Dimension +import java.io.File +import javax.swing.BorderFactory +import javax.swing.BoxLayout +import javax.swing.JButton +import javax.swing.JDialog +import javax.swing.JFrame +import javax.swing.JLabel +import javax.swing.JOptionPane +import javax.swing.JPanel +import javax.swing.JScrollPane +import javax.swing.JTable +import javax.swing.SwingUtilities +import javax.swing.SwingWorker +import javax.swing.filechooser.FileNameExtensionFilter +import javax.swing.table.DefaultTableModel +import packetproxy.common.I18nString +import packetproxy.grpc.GrpcServiceRegistryStore +import packetproxy.grpc.ProtoFileSet +import packetproxy.grpc.ProtocRunner + +data class GrpcDescriptorDialogOutcome( + @get:JvmName("isApplied") val applied: Boolean, + val descriptorPath: String?, +) + +/** + * Single dialog for .desc management: browse existing .desc, compile from .proto, inspect services. + * Replaces the previous two-dialog (GrpcDescriptorDialog → ProtoCompileDialog) flow. + */ +class GUIOptionGrpcDescriptorDialog( + private val frameOwner: JFrame?, + private val serverId: Int?, + initialPath: String?, +) : JDialog(frameOwner, I18nString.get("gRPC descriptor"), true) { + + private var workingPath: String? = initialPath?.trim()?.takeIf { it.isNotEmpty() } + private var outcome = GrpcDescriptorDialogOutcome(false, null) + + private val pathLabel = JLabel() + private val protoSet = ProtoFileSet() + private val protoTableModel = + object : DefaultTableModel(arrayOf("Path"), 0) { + override fun isCellEditable(row: Int, column: Int) = false + } + private val serviceTableModel = + object : DefaultTableModel(arrayOf("Service", "Method"), 0) { + override fun isCellEditable(row: Int, column: Int) = false + } + private val protoTable = JTable(protoTableModel) + + init { + val root = JPanel(BorderLayout(8, 8)) + root.border = BorderFactory.createEmptyBorder(8, 8, 8, 8) + + pathLabel.border = BorderFactory.createEmptyBorder(0, 0, 8, 0) + updatePathLabel() + root.add(pathLabel, BorderLayout.NORTH) + + val center = JPanel() + center.layout = BoxLayout(center, BoxLayout.Y_AXIS) + + // --- .proto file section --- + val protoPanel = JPanel(BorderLayout(4, 4)) + protoPanel.border = BorderFactory.createTitledBorder(I18nString.get(".proto files")) + + val protoButtons = JPanel() + protoButtons.layout = BoxLayout(protoButtons, BoxLayout.X_AXIS) + val addFile = JButton(I18nString.get("Add .proto file...")) + addFile.addActionListener { addProtoFiles() } + val addDir = JButton(I18nString.get("Add directory...")) + addDir.addActionListener { addProtoDirectory() } + val removeProto = JButton(I18nString.get("Remove item")) + removeProto.addActionListener { removeSelectedProto() } + protoButtons.add(addFile) + protoButtons.add(addDir) + protoButtons.add(removeProto) + protoPanel.add(protoButtons, BorderLayout.NORTH) + + protoTable.preferredScrollableViewportSize = Dimension(520, 100) + protoPanel.add(JScrollPane(protoTable), BorderLayout.CENTER) + center.add(protoPanel) + + // --- Action buttons --- + val actionRow = JPanel(java.awt.FlowLayout(java.awt.FlowLayout.LEFT, 0, 0)) + val generate = JButton(I18nString.get("Generate .desc")) + generate.addActionListener { runGenerate() } + val browse = JButton(I18nString.get("Browse .desc...")) + browse.addActionListener { browseDescFile() } + val remove = JButton(I18nString.get("Unregister")) + remove.addActionListener { removeDescriptor() } + actionRow.add(generate) + actionRow.add(browse) + actionRow.add(remove) + center.add(actionRow) + + // --- Service / method table --- + val serviceTable = JTable(serviceTableModel) + serviceTable.preferredScrollableViewportSize = Dimension(520, 160) + val serviceScroll = JScrollPane(serviceTable) + serviceScroll.border = BorderFactory.createTitledBorder(I18nString.get("Services / methods")) + center.add(serviceScroll) + + root.add(center, BorderLayout.CENTER) + + // --- Footer --- + val footer = JPanel() + footer.layout = BoxLayout(footer, BoxLayout.X_AXIS) + val ok = JButton(I18nString.get("OK")) + ok.addActionListener { + val p = workingPath?.trim()?.takeIf { it.isNotEmpty() } + outcome = GrpcDescriptorDialogOutcome(true, p) + dispose() + } + val cancel = JButton(I18nString.get("Cancel")) + cancel.addActionListener { + outcome = GrpcDescriptorDialogOutcome(false, null) + dispose() + } + footer.add(ok) + footer.add(cancel) + root.add(footer, BorderLayout.SOUTH) + + contentPane = root + pack() + setLocationRelativeTo(frameOwner) + refreshServiceList() + } + + private fun updatePathLabel() { + val p = workingPath?.trim()?.takeIf { it.isNotEmpty() } + pathLabel.text = + if (p == null) { + I18nString.get("No descriptor loaded") + } else { + "${I18nString.get("Current .desc:")}
${escapeHtml(p)}" + } + } + + private fun escapeHtml(s: String): String = + s.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """) + + // --- .proto management --- + + private fun addProtoFiles() { + try { + val chooser = NativeFileChooser() + chooser.setDialogTitle(I18nString.get("Select .proto files")) + chooser.addChoosableFileFilter(FileNameExtensionFilter("Protocol Buffers (*.proto)", "proto")) + chooser.setAcceptAllFileFilterUsed(false) + if (chooser.showOpenDialog(this) == NativeFileChooser.APPROVE_OPTION) { + val f = chooser.selectedFile + if (f != null && protoSet.addFile(f)) { + protoTableModel.addRow(arrayOf(f.absolutePath)) + } + } + } catch (ex: Exception) { + JOptionPane.showMessageDialog( + this, + ex.message, + I18nString.get("Error"), + JOptionPane.ERROR_MESSAGE, + ) + } + } + + private fun addProtoDirectory() { + try { + val chooser = NativeFileChooser() + chooser.setDialogTitle(I18nString.get("Select directory")) + if (chooser.showDirectoryDialog(this) == NativeFileChooser.APPROVE_OPTION) { + val dir = chooser.selectedFile + if (dir != null && dir.isDirectory) { + val n = protoSet.addDirectoryShallow(dir) + if (n == 0) { + JOptionPane.showMessageDialog( + this, + I18nString.get("No .proto files found in the selected directory."), + I18nString.get("Info"), + JOptionPane.INFORMATION_MESSAGE, + ) + } else { + protoTableModel.rowCount = 0 + for (f in protoSet.list()) { + protoTableModel.addRow(arrayOf(f.absolutePath)) + } + } + } + } + } catch (ex: Exception) { + JOptionPane.showMessageDialog( + this, + ex.message, + I18nString.get("Error"), + JOptionPane.ERROR_MESSAGE, + ) + } + } + + private fun removeSelectedProto() { + val row = protoTable.selectedRow + if (row >= 0) { + try { + val f = File(protoTableModel.getValueAt(row, 0) as String) + protoSet.remove(f) + protoTableModel.removeRow(row) + } catch (ex: Exception) { + JOptionPane.showMessageDialog( + this, + ex.message, + I18nString.get("Error"), + JOptionPane.ERROR_MESSAGE, + ) + } + } + } + + // --- .desc generation & selection --- + + private fun runGenerate() { + val protos = protoSet.list() + if (protos.isEmpty()) { + JOptionPane.showMessageDialog( + this, + I18nString.get("Add at least one .proto file."), + I18nString.get("Error"), + JOptionPane.WARNING_MESSAGE, + ) + return + } + val dialog = this + val worker = + object : SwingWorker() { + @Throws(Exception::class) + override fun doInBackground(): Void? { + ProtocRunner.checkProtocOnPath() + val includes = protoSet.includePaths() + val r = ProtocRunner.run(protos, includes, serverId) + if (!r.ok) { + throw Exception(if (r.stderr.isEmpty()) "exit ${r.exitCode}" else r.stderr) + } + workingPath = r.descFile.absolutePath + return null + } + + override fun done() { + try { + get() + SwingUtilities.invokeLater { + updatePathLabel() + refreshServiceList() + } + } catch (e: Exception) { + val c = e.cause ?: e + JOptionPane.showMessageDialog( + dialog, + c.message, + I18nString.get("protoc failed"), + JOptionPane.ERROR_MESSAGE, + ) + } + } + } + worker.execute() + } + + private fun browseDescFile() { + try { + val chooser = NativeFileChooser() + chooser.setDialogTitle(I18nString.get("Select descriptor file")) + workingPath + ?.let { File(it).parentFile } + ?.takeIf { it.isDirectory } + ?.let { chooser.setCurrentDirectory(it) } + chooser.addChoosableFileFilter(FileNameExtensionFilter("Descriptor set (*.desc)", "desc")) + chooser.setAcceptAllFileFilterUsed(true) + if (chooser.showOpenDialog(this) == NativeFileChooser.APPROVE_OPTION) { + val f = chooser.selectedFile + if (f != null && f.isFile) { + workingPath = f.absolutePath + updatePathLabel() + refreshServiceList() + } + } + } catch (ex: Exception) { + JOptionPane.showMessageDialog( + this, + ex.message, + I18nString.get("Error"), + JOptionPane.ERROR_MESSAGE, + ) + } + } + + /** Clears the selected path for this server; does not delete the `.desc` file on disk. */ + private fun removeDescriptor() { + val prev = workingPath?.trim()?.takeIf { it.isNotEmpty() } + if (prev != null) { + try { + GrpcServiceRegistryStore.getInstance().invalidate(File(prev)) + } catch (_: Exception) {} + } + workingPath = null + updatePathLabel() + serviceTableModel.rowCount = 0 + } + + private fun refreshServiceList() { + serviceTableModel.rowCount = 0 + val p = workingPath?.trim()?.takeIf { it.isNotEmpty() } ?: return + try { + val f = File(p) + if (!f.isFile) { + try { + GrpcServiceRegistryStore.getInstance().invalidate(f) + } catch (_: Exception) {} + JOptionPane.showMessageDialog( + this, + I18nString.get("Descriptor file does not exist."), + I18nString.get("Error"), + JOptionPane.WARNING_MESSAGE, + ) + return + } + // Drop in-memory parse so this dialog and encoders re-read the file from disk (same path may + // have + // been replaced outside PacketProxy, e.g. another protoc run). + GrpcServiceRegistryStore.getInstance().invalidate(f) + val registry = GrpcServiceRegistryStore.getInstance().get(f) + for ((service, method) in registry.getServiceMethodEntries()) { + serviceTableModel.addRow(arrayOf(service, method)) + } + } catch (ex: Exception) { + JOptionPane.showMessageDialog( + this, + ex.message, + I18nString.get("Error"), + JOptionPane.ERROR_MESSAGE, + ) + } + } + + fun showManageDialog(): GrpcDescriptorDialogOutcome { + isVisible = true + return outcome + } +} diff --git a/src/main/kotlin/core/packetproxy/gui/GUIOptionProtoCompileDialog.kt b/src/main/kotlin/core/packetproxy/gui/GUIOptionProtoCompileDialog.kt deleted file mode 100644 index 515d2419..00000000 --- a/src/main/kotlin/core/packetproxy/gui/GUIOptionProtoCompileDialog.kt +++ /dev/null @@ -1,219 +0,0 @@ -/* - * Copyright 2026 DeNA Co., Ltd. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package packetproxy.gui - -import java.awt.BorderLayout -import java.awt.Dimension -import java.io.File -import javax.swing.BorderFactory -import javax.swing.BoxLayout -import javax.swing.JButton -import javax.swing.JDialog -import javax.swing.JFrame -import javax.swing.JOptionPane -import javax.swing.JPanel -import javax.swing.JScrollPane -import javax.swing.JTable -import javax.swing.SwingUtilities -import javax.swing.SwingWorker -import javax.swing.filechooser.FileNameExtensionFilter -import javax.swing.table.DefaultTableModel -import packetproxy.common.I18nString -import packetproxy.grpc.GrpcServiceRegistryStore -import packetproxy.grpc.ProtoFileSet -import packetproxy.grpc.ProtocRunner - -class GUIOptionProtoCompileDialog(owner: JFrame?, private val serverId: Int?) : - JDialog(owner, I18nString.get("Generate .desc from .proto"), true) { - - private val protoSet = ProtoFileSet() - private val tableModel = - object : DefaultTableModel(arrayOf("Path"), 0) { - override fun isCellEditable(row: Int, column: Int) = false - } - private var resultPath: String? = null - - init { - val root = JPanel(BorderLayout(8, 8)) - root.border = BorderFactory.createEmptyBorder(8, 8, 8, 8) - - val top = JPanel() - top.layout = BoxLayout(top, BoxLayout.X_AXIS) - val addFile = JButton(I18nString.get("Add .proto file...")) - addFile.addActionListener { addProtoFiles() } - val addDir = JButton(I18nString.get("Add directory (1 level)...")) - addDir.addActionListener { addProtoDirectory() } - top.add(addFile) - top.add(addDir) - root.add(top, BorderLayout.NORTH) - - val table = JTable(tableModel) - table.preferredScrollableViewportSize = Dimension(520, 200) - root.add(JScrollPane(table), BorderLayout.CENTER) - - val remove = JButton(I18nString.get("Remove selected")) - remove.addActionListener { - val row = table.selectedRow - if (row >= 0) { - try { - val f = File(tableModel.getValueAt(row, 0) as String) - protoSet.remove(f) - tableModel.removeRow(row) - } catch (ex: Exception) { - JOptionPane.showMessageDialog( - this, - ex.message, - I18nString.get("Error"), - JOptionPane.ERROR_MESSAGE, - ) - } - } - } - - val bottom = JPanel() - bottom.layout = BoxLayout(bottom, BoxLayout.Y_AXIS) - bottom.add(remove) - - val actions = JPanel() - actions.layout = BoxLayout(actions, BoxLayout.X_AXIS) - val generate = JButton(I18nString.get("Generate .desc")) - generate.addActionListener { runGenerate() } - val cancel = JButton(I18nString.get("Cancel")) - cancel.addActionListener { - resultPath = null - dispose() - } - actions.add(generate) - actions.add(cancel) - bottom.add(actions) - root.add(bottom, BorderLayout.SOUTH) - - contentPane = root - pack() - setLocationRelativeTo(owner) - } - - private fun addProtoFiles() { - try { - val chooser = NativeFileChooser() - chooser.setDialogTitle(I18nString.get("Select .proto files")) - chooser.addChoosableFileFilter(FileNameExtensionFilter("Protocol Buffers (*.proto)", "proto")) - chooser.setAcceptAllFileFilterUsed(false) - if (chooser.showOpenDialog(this) == NativeFileChooser.APPROVE_OPTION) { - val f = chooser.selectedFile - if (f != null && protoSet.addFile(f)) { - tableModel.addRow(arrayOf(f.absolutePath)) - } - } - } catch (ex: Exception) { - JOptionPane.showMessageDialog( - this, - ex.message, - I18nString.get("Error"), - JOptionPane.ERROR_MESSAGE, - ) - } - } - - private fun addProtoDirectory() { - try { - val chooser = NativeFileChooser() - chooser.setDialogTitle(I18nString.get("Select directory")) - if (chooser.showDirectoryDialog(this) == NativeFileChooser.APPROVE_OPTION) { - val dir = chooser.selectedFile - if (dir != null && dir.isDirectory) { - val n = protoSet.addDirectoryShallow(dir) - if (n == 0) { - JOptionPane.showMessageDialog( - this, - I18nString.get("No .proto files found in the selected directory."), - I18nString.get("Info"), - JOptionPane.INFORMATION_MESSAGE, - ) - } else { - refreshTableFromSet() - } - } - } - } catch (ex: Exception) { - JOptionPane.showMessageDialog( - this, - ex.message, - I18nString.get("Error"), - JOptionPane.ERROR_MESSAGE, - ) - } - } - - @Throws(Exception::class) - private fun refreshTableFromSet() { - tableModel.rowCount = 0 - for (f in protoSet.list()) { - tableModel.addRow(arrayOf(f.absolutePath)) - } - } - - private fun runGenerate() { - val protos = protoSet.list() - if (protos.isEmpty()) { - JOptionPane.showMessageDialog( - this, - I18nString.get("Add at least one .proto file."), - I18nString.get("Error"), - JOptionPane.WARNING_MESSAGE, - ) - return - } - val worker = - object : SwingWorker() { - @Throws(Exception::class) - override fun doInBackground(): Void? { - ProtocRunner.checkProtocOnPath() - val includes = protoSet.includePaths() - val r = ProtocRunner.run(protos, includes, serverId) - if (!r.ok) { - throw Exception(if (r.stderr.isEmpty()) "exit ${r.exitCode}" else r.stderr) - } - GrpcServiceRegistryStore.getInstance().invalidate(r.descFile) - resultPath = r.descFile.absolutePath - return null - } - - override fun done() { - try { - get() - SwingUtilities.invokeLater { dispose() } - } catch (e: Exception) { - val c = e.cause ?: e - JOptionPane.showMessageDialog( - this@GUIOptionProtoCompileDialog, - c.message, - I18nString.get("protoc failed"), - JOptionPane.ERROR_MESSAGE, - ) - } - } - } - worker.execute() - } - - /** Opens modally; returns absolute path to generated `.desc`, or `null` if cancelled. */ - fun showCompileDialog(): String? { - resultPath = null - isVisible = true - return resultPath - } -} From 8445c6ff17f7a2807d93a2edca33a9b059c474f8 Mon Sep 17 00:00:00 2001 From: taka2233 Date: Mon, 27 Apr 2026 11:43:31 +0900 Subject: [PATCH 10/22] refactor: format --- src/main/java/core/packetproxy/DuplexFactory.java | 3 +-- .../java/core/packetproxy/controller/ResendController.java | 4 ++-- src/main/kotlin/core/packetproxy/grpc/GrpcProtoWireFormat.kt | 5 ++--- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/main/java/core/packetproxy/DuplexFactory.java b/src/main/java/core/packetproxy/DuplexFactory.java index a387e3ab..a46d10bb 100644 --- a/src/main/java/core/packetproxy/DuplexFactory.java +++ b/src/main/java/core/packetproxy/DuplexFactory.java @@ -69,8 +69,7 @@ private static void prepareDuplex(final Duplex duplex, Endpoint client_endpoint, duplex.addDuplexEventListener(new Duplex.DuplexEventListener() { private Packets packets = Packets.getInstance(); - private Encoder encoder = EncoderManager.getInstance().createInstance(encoder_name, ALPN, - server_addr); + private Encoder encoder = EncoderManager.getInstance().createInstance(encoder_name, ALPN, server_addr); private Modifications mods = Modifications.getInstance(); private Packet client_packet; diff --git a/src/main/java/core/packetproxy/controller/ResendController.java b/src/main/java/core/packetproxy/controller/ResendController.java index 2c36ddd3..b1243bde 100644 --- a/src/main/java/core/packetproxy/controller/ResendController.java +++ b/src/main/java/core/packetproxy/controller/ResendController.java @@ -182,8 +182,8 @@ private class DataToBeSend { public DataToBeSend(OneShotPacket oneshot, Consumer onReceived) throws Exception { this.oneshot = oneshot; this.onReceived = onReceived; - Encoder encoder = EncoderManager.getInstance().createInstance(oneshot.getEncoder(), - oneshot.getAlpn(), oneshot.getServer()); + Encoder encoder = EncoderManager.getInstance().createInstance(oneshot.getEncoder(), oneshot.getAlpn(), + oneshot.getServer()); if (encoder.useNewConnectionForResend() == false && encoder.useNewEncoderForResend() == false) { this.isDirectSend = true; diff --git a/src/main/kotlin/core/packetproxy/grpc/GrpcProtoWireFormat.kt b/src/main/kotlin/core/packetproxy/grpc/GrpcProtoWireFormat.kt index b4eaa539..2898efd5 100644 --- a/src/main/kotlin/core/packetproxy/grpc/GrpcProtoWireFormat.kt +++ b/src/main/kotlin/core/packetproxy/grpc/GrpcProtoWireFormat.kt @@ -15,9 +15,6 @@ */ package packetproxy.grpc -import packetproxy.common.Protobuf3 -import packetproxy.common.Utils - import com.fasterxml.jackson.core.JsonFactory import com.fasterxml.jackson.core.JsonToken import com.google.protobuf.Descriptors.Descriptor @@ -30,6 +27,8 @@ import java.util.ArrayList import java.util.Arrays import java.util.Collections import org.apache.commons.lang3.ArrayUtils +import packetproxy.common.Protobuf3 +import packetproxy.common.Utils /** * gRPC length-prefixed bodies to/from UTF-8 JSON using a [GrpcServiceRegistry], with schema-less From 1eef45851ec4f05c21265a5399dc84fdedd9a53f Mon Sep 17 00:00:00 2001 From: taka2233 Date: Mon, 27 Apr 2026 11:44:04 +0900 Subject: [PATCH 11/22] refactor: streamline gRPC descriptor management in GUIOptionServerDialog and enhance ProtocRunner for consistent file naming --- .../gui/GUIOptionServerDialog.java | 69 ++++++------------- .../packetproxy/grpc/GrpcServiceRegistry.kt | 16 +++++ .../core/packetproxy/grpc/ProtocRunner.kt | 14 +++- 3 files changed, 47 insertions(+), 52 deletions(-) diff --git a/src/main/java/core/packetproxy/gui/GUIOptionServerDialog.java b/src/main/java/core/packetproxy/gui/GUIOptionServerDialog.java index 76024d84..5bad5e6a 100644 --- a/src/main/java/core/packetproxy/gui/GUIOptionServerDialog.java +++ b/src/main/java/core/packetproxy/gui/GUIOptionServerDialog.java @@ -22,7 +22,6 @@ import java.awt.Rectangle; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; -import java.io.File; import java.util.regex.*; import javax.swing.Box; import javax.swing.BoxLayout; @@ -35,9 +34,7 @@ import javax.swing.JLabel; import javax.swing.JOptionPane; import javax.swing.JPanel; -import javax.swing.filechooser.FileNameExtensionFilter; import packetproxy.EncoderManager; -import packetproxy.grpc.GrpcServiceRegistryStore; import packetproxy.common.I18nString; import packetproxy.model.Server; @@ -57,11 +54,12 @@ public class GUIOptionServerDialog extends JDialog { private JCheckBox checkbox_upstream_http_proxy = new JCheckBox( I18nString.get("Need to be defined as an Upstream Http Proxy")); JComboBox combo = new JComboBox(); - private HintTextField text_proto_path = new HintTextField("(ex.) /path/to/service.desc"); - private JButton button_proto_browse = new JButton(I18nString.get("Browse...")); - private JButton button_proto_generate = new JButton(I18nString.get("Generate from .proto...")); - private JButton button_proto_reload = new JButton(I18nString.get("Reload cache")); + private JButton button_import_proto = new JButton(I18nString.get("Import Proto File")); private JPanel panelDescriptorPath; + + /** Working gRPC descriptor path; applied to [Server] on Save. */ + private String grpcDescriptorPath; + private Integer editingServerId; private int height = 580; private int width = 700; @@ -98,7 +96,7 @@ public Server showDialog(Server preset) { checkbox_dns6.setSelected(preset.isResolved6()); text_comment.setText(preset.getComment()); String dp = preset.getDescriptorPath(); - text_proto_path.setText(dp != null ? dp : ""); + grpcDescriptorPath = (dp != null && !dp.isEmpty()) ? dp : null; updateGrpcDescriptorUiVisibility(); setModal(true); setVisible(true); @@ -112,7 +110,7 @@ public Server showDialog(Server preset) { preset.setResolved6(checkbox_dns6.isSelected()); preset.setHttpProxy(checkbox_upstream_http_proxy.isSelected()); preset.setComment(text_comment.getText()); - String path = text_proto_path.getText().trim(); + String path = grpcDescriptorPath != null ? grpcDescriptorPath.trim() : ""; preset.setDescriptorPath(path.isEmpty() ? null : path); return preset; } @@ -121,7 +119,7 @@ public Server showDialog(Server preset) { public Server showDialog() { editingServerId = null; - text_proto_path.setText(""); + grpcDescriptorPath = null; updateGrpcDescriptorUiVisibility(); EventQueue.invokeLater(new Runnable() { @@ -193,14 +191,11 @@ private JComponent createCommentSetting() { private JComponent createDescriptorPathSetting() { JPanel row = new JPanel(); row.setLayout(new BoxLayout(row, BoxLayout.X_AXIS)); - row.add(new JLabel(I18nString.get("gRPC descriptor (.desc):"))); - row.add(Box.createRigidArea(new Dimension(8, 0))); - text_proto_path.setMaximumSize(new Dimension(Short.MAX_VALUE, text_proto_path.getPreferredSize().height)); - row.add(text_proto_path); - row.add(Box.createRigidArea(new Dimension(8, 0))); - row.add(button_proto_browse); - row.add(button_proto_generate); - row.add(button_proto_reload); + JLabel label = new JLabel(I18nString.get("gRPC descriptor (.desc):")); + label.setPreferredSize(new Dimension(150, label.getMaximumSize().height)); + row.add(label); + row.add(button_import_proto); + row.add(Box.createHorizontalGlue()); panelDescriptorPath = row; return row; } @@ -247,30 +242,13 @@ public void actionPerformed(ActionEvent e) { combo.addActionListener(e -> updateGrpcDescriptorUiVisibility()); - button_proto_browse.addActionListener(e -> { - try { - NativeFileChooser chooser = new NativeFileChooser(); - chooser.setDialogTitle(I18nString.get("Select descriptor file")); - chooser.addChoosableFileFilter(new FileNameExtensionFilter("Descriptor set (*.desc)", "desc")); - chooser.setAcceptAllFileFilterUsed(true); - if (chooser.showOpenDialog(this) == NativeFileChooser.APPROVE_OPTION) { - File f = chooser.getSelectedFile(); - if (f != null) { - text_proto_path.setText(f.getAbsolutePath()); - } - } - } catch (Exception ex) { - JOptionPane.showMessageDialog(this, ex.getMessage(), I18nString.get("Error"), - JOptionPane.ERROR_MESSAGE); - } - }); - - button_proto_generate.addActionListener(e -> { + button_import_proto.addActionListener(e -> { try { - GUIOptionProtoCompileDialog dlg = new GUIOptionProtoCompileDialog((JFrame) getOwner(), editingServerId); - String path = dlg.showCompileDialog(); - if (path != null) { - text_proto_path.setText(path); + GUIOptionGrpcDescriptorDialog dlg = new GUIOptionGrpcDescriptorDialog((JFrame) getOwner(), + editingServerId, grpcDescriptorPath); + GrpcDescriptorDialogOutcome r = dlg.showManageDialog(); + if (r.isApplied()) { + grpcDescriptorPath = r.getDescriptorPath(); } } catch (Exception ex) { JOptionPane.showMessageDialog(this, ex.getMessage(), I18nString.get("Error"), @@ -278,13 +256,6 @@ public void actionPerformed(ActionEvent e) { } }); - button_proto_reload.addActionListener(e -> { - String p = text_proto_path.getText().trim(); - if (!p.isEmpty()) { - GrpcServiceRegistryStore.getInstance().invalidate(new File(p)); - } - }); - Container c = getContentPane(); JPanel panel = new JPanel(); panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS)); @@ -334,7 +305,7 @@ public void actionPerformed(ActionEvent e) { server = new Server(text_ip.getText(), Integer.parseInt(text_port.getText()), checkbox_ssl.isSelected(), combo.getSelectedItem().toString(), checkbox_dns.isSelected(), checkbox_dns6.isSelected(), checkbox_upstream_http_proxy.isSelected(), text_comment.getText()); - String path = text_proto_path.getText().trim(); + String path = grpcDescriptorPath != null ? grpcDescriptorPath.trim() : ""; server.setDescriptorPath(path.isEmpty() ? null : path); dispose(); } diff --git a/src/main/kotlin/core/packetproxy/grpc/GrpcServiceRegistry.kt b/src/main/kotlin/core/packetproxy/grpc/GrpcServiceRegistry.kt index ad8ff52d..9bf24591 100644 --- a/src/main/kotlin/core/packetproxy/grpc/GrpcServiceRegistry.kt +++ b/src/main/kotlin/core/packetproxy/grpc/GrpcServiceRegistry.kt @@ -59,6 +59,22 @@ class GrpcServiceRegistry(fileDescriptors: List) { return messageByFullName[fullName] } + /** + * gRPC `:path` keys (`/full.ServiceName/MethodName`) as service + method pairs for UI listing. + */ + fun getServiceMethodEntries(): List> { + return inputByPath.keys + .map { grpcPath -> + val withoutLeading = grpcPath.removePrefix("/") + val idx = withoutLeading.lastIndexOf('/') + check(idx >= 0) { "invalid grpc path: $grpcPath" } + val service = withoutLeading.substring(0, idx) + val method = withoutLeading.substring(idx + 1) + Pair(service, method) + } + .sortedWith(compareBy({ it.first }, { it.second })) + } + companion object { private fun indexMessages(types: Iterable, out: MutableMap) { for (d in types) { diff --git a/src/main/kotlin/core/packetproxy/grpc/ProtocRunner.kt b/src/main/kotlin/core/packetproxy/grpc/ProtocRunner.kt index de282472..e12d4911 100644 --- a/src/main/kotlin/core/packetproxy/grpc/ProtocRunner.kt +++ b/src/main/kotlin/core/packetproxy/grpc/ProtocRunner.kt @@ -22,6 +22,7 @@ import java.nio.charset.StandardCharsets import java.util.ArrayList import java.util.concurrent.TimeUnit import org.apache.commons.io.IOUtils +import packetproxy.model.Database /** * Runs the `protoc` binary from `PATH` to emit a @@ -58,7 +59,8 @@ class ProtocRunner private constructor() { /** * Allocates an output path under `~/.packetproxy/grpc_desc/` and runs `protoc`. The file name - * encodes the optional [serverId] so callers can correlate the output with a server entry. + * is `{projectName}_{serverId}.desc` (or `{projectName}_unsaved.desc` when [serverId] is null) + * so re-generation overwrites the same file instead of accumulating timestamped `*.desc` files. */ @JvmStatic @Throws(Exception::class) @@ -66,8 +68,14 @@ class ProtocRunner private constructor() { if (!DEFAULT_DESC_DIR.exists() && !DEFAULT_DESC_DIR.mkdirs()) { throw IllegalStateException("Cannot create directory: ${DEFAULT_DESC_DIR.absolutePath}") } - val ts = System.currentTimeMillis() - val name = if (serverId != null) "server_${serverId}_$ts.desc" else "new_$ts.desc" + val projectName = + Database.getInstance().getDatabasePath().fileName.toString().removeSuffix(".sqlite3") + val name = + if (serverId != null) { + "${projectName}_${serverId}.desc" + } else { + "${projectName}_unsaved.desc" + } return run(protos, includes, File(DEFAULT_DESC_DIR, name)) } From af15a64a5822a149ff06e141f8e925b01c2b3bbd Mon Sep 17 00:00:00 2001 From: taka2233 Date: Mon, 27 Apr 2026 12:15:51 +0900 Subject: [PATCH 12/22] fix: update resource paths in tests to remove leading slashes for consistency --- .../packetproxy/grpc/DescriptorSetLoaderTest.kt | 6 +++--- .../packetproxy/grpc/GrpcProtoWireFormatTest.kt | 2 +- .../grpc/GrpcServiceRegistryStoreTest.kt | 2 +- .../packetproxy/grpc/GrpcServiceRegistryTest.kt | 4 ++-- .../kotlin/packetproxy/grpc/ProtoFileSetTest.kt | 8 ++++---- .../grpc}/proto/multidir/common.proto | 0 .../packetproxy/grpc/proto/multidir/multi.desc | 13 +++++++++++++ .../grpc/proto/multidir/multi_without_imports.desc | 5 +++++ .../grpc}/proto/multidir/svc_a.proto | 0 .../grpc}/proto/multidir/svc_b.proto | 0 .../resources/packetproxy/grpc/proto/testsvc.desc | 11 +++++++++++ .../{ => packetproxy/grpc}/proto/testsvc.proto | 0 12 files changed, 40 insertions(+), 11 deletions(-) rename src/test/resources/{ => packetproxy/grpc}/proto/multidir/common.proto (100%) create mode 100644 src/test/resources/packetproxy/grpc/proto/multidir/multi.desc create mode 100644 src/test/resources/packetproxy/grpc/proto/multidir/multi_without_imports.desc rename src/test/resources/{ => packetproxy/grpc}/proto/multidir/svc_a.proto (100%) rename src/test/resources/{ => packetproxy/grpc}/proto/multidir/svc_b.proto (100%) create mode 100644 src/test/resources/packetproxy/grpc/proto/testsvc.desc rename src/test/resources/{ => packetproxy/grpc}/proto/testsvc.proto (100%) diff --git a/src/test/kotlin/packetproxy/grpc/DescriptorSetLoaderTest.kt b/src/test/kotlin/packetproxy/grpc/DescriptorSetLoaderTest.kt index f1b89d62..c536b0ee 100644 --- a/src/test/kotlin/packetproxy/grpc/DescriptorSetLoaderTest.kt +++ b/src/test/kotlin/packetproxy/grpc/DescriptorSetLoaderTest.kt @@ -47,7 +47,7 @@ class DescriptorSetLoaderTest { @Test fun loadAndBuild_withIncludeImports_ok() { - val f = resource("/proto/multidir/multi.desc") + val f = resource("proto/multidir/multi.desc") val list = DescriptorSetLoader.loadAndBuild(f) assertFalse(list.isEmpty()) } @@ -56,13 +56,13 @@ class DescriptorSetLoaderTest { // loadAndBuild must detect this and throw rather than silently producing an incomplete registry. @Test fun loadAndBuild_withoutIncludeImports_throws() { - val f = resource("/proto/multidir/multi_without_imports.desc") + val f = resource("proto/multidir/multi_without_imports.desc") assertThrows(IllegalStateException::class.java) { DescriptorSetLoader.loadAndBuild(f) } } @Test fun loadAndBuild_testsvc_containsGreeterService() { - val f = resource("/proto/testsvc.desc") + val f = resource("proto/testsvc.desc") val list = DescriptorSetLoader.loadAndBuild(f) assertTrue(list.any { it.findServiceByName("Greeter") != null }) } diff --git a/src/test/kotlin/packetproxy/grpc/GrpcProtoWireFormatTest.kt b/src/test/kotlin/packetproxy/grpc/GrpcProtoWireFormatTest.kt index b7636fcd..4a79b9fc 100644 --- a/src/test/kotlin/packetproxy/grpc/GrpcProtoWireFormatTest.kt +++ b/src/test/kotlin/packetproxy/grpc/GrpcProtoWireFormatTest.kt @@ -30,7 +30,7 @@ class GrpcProtoWireFormatTest { } private fun registry(): GrpcServiceRegistry = - GrpcServiceRegistry(DescriptorSetLoader.loadAndBuild(resource("/proto/testsvc.desc"))) + GrpcServiceRegistry(DescriptorSetLoader.loadAndBuild(resource("proto/testsvc.desc"))) @Test fun decodeThenEncode_request_roundtrip() { diff --git a/src/test/kotlin/packetproxy/grpc/GrpcServiceRegistryStoreTest.kt b/src/test/kotlin/packetproxy/grpc/GrpcServiceRegistryStoreTest.kt index f656be2d..7bc8b22b 100644 --- a/src/test/kotlin/packetproxy/grpc/GrpcServiceRegistryStoreTest.kt +++ b/src/test/kotlin/packetproxy/grpc/GrpcServiceRegistryStoreTest.kt @@ -37,7 +37,7 @@ class GrpcServiceRegistryStoreTest { @Test fun getCachesByCanonicalPath() { val store = GrpcServiceRegistryStore.getInstance() - val f = resource("/proto/testsvc.desc") + val f = resource("proto/testsvc.desc") val a = store.get(f) val b = store.get(f) assertSame(a, b) diff --git a/src/test/kotlin/packetproxy/grpc/GrpcServiceRegistryTest.kt b/src/test/kotlin/packetproxy/grpc/GrpcServiceRegistryTest.kt index 042b0dfd..febe9323 100644 --- a/src/test/kotlin/packetproxy/grpc/GrpcServiceRegistryTest.kt +++ b/src/test/kotlin/packetproxy/grpc/GrpcServiceRegistryTest.kt @@ -31,7 +31,7 @@ class GrpcServiceRegistryTest { @Test fun mapsGrpcPathToInputOutput() { - val reg = GrpcServiceRegistry(DescriptorSetLoader.loadAndBuild(resource("/proto/testsvc.desc"))) + val reg = GrpcServiceRegistry(DescriptorSetLoader.loadAndBuild(resource("proto/testsvc.desc"))) val input = reg.getInputType("/pp.testsvc.Greeter/SayHello") val output = reg.getOutputType("/pp.testsvc.Greeter/SayHello") assertNotNull(input) @@ -43,7 +43,7 @@ class GrpcServiceRegistryTest { @Test fun findMessageByName_nestedIndexing() { - val reg = GrpcServiceRegistry(DescriptorSetLoader.loadAndBuild(resource("/proto/testsvc.desc"))) + val reg = GrpcServiceRegistry(DescriptorSetLoader.loadAndBuild(resource("proto/testsvc.desc"))) assertNotNull(reg.findMessageByName("pp.testsvc.HelloRequest")) } } diff --git a/src/test/kotlin/packetproxy/grpc/ProtoFileSetTest.kt b/src/test/kotlin/packetproxy/grpc/ProtoFileSetTest.kt index 098a126e..8960603f 100644 --- a/src/test/kotlin/packetproxy/grpc/ProtoFileSetTest.kt +++ b/src/test/kotlin/packetproxy/grpc/ProtoFileSetTest.kt @@ -40,7 +40,7 @@ class ProtoFileSetTest { @Test fun addFile_deduplicatesByCanonicalPath() { val set = ProtoFileSet() - val f = resource("/proto/testsvc.proto") + val f = resource("proto/testsvc.proto") assertTrue(set.addFile(f)) assertFalse(set.addFile(f)) assertEquals(1, set.list().size) @@ -49,8 +49,8 @@ class ProtoFileSetTest { @Test fun includePaths_uniqueParents() { val set = ProtoFileSet() - set.addFile(resource("/proto/testsvc.proto")) - set.addFile(resource("/proto/multidir/common.proto")) + set.addFile(resource("proto/testsvc.proto")) + set.addFile(resource("proto/multidir/common.proto")) val inc = set.includePaths() assertEquals(2, inc.size) } @@ -58,7 +58,7 @@ class ProtoFileSetTest { @Test fun addDirectoryShallow_nonRecursive() { val set = ProtoFileSet() - val n = set.addDirectoryShallow(resource("/proto/testsvc.proto").parentFile) + val n = set.addDirectoryShallow(resource("proto/testsvc.proto").parentFile) assertTrue(n >= 1) } } diff --git a/src/test/resources/proto/multidir/common.proto b/src/test/resources/packetproxy/grpc/proto/multidir/common.proto similarity index 100% rename from src/test/resources/proto/multidir/common.proto rename to src/test/resources/packetproxy/grpc/proto/multidir/common.proto diff --git a/src/test/resources/packetproxy/grpc/proto/multidir/multi.desc b/src/test/resources/packetproxy/grpc/proto/multidir/multi.desc new file mode 100644 index 00000000..3a4c5566 --- /dev/null +++ b/src/test/resources/packetproxy/grpc/proto/multidir/multi.desc @@ -0,0 +1,13 @@ + +; + common.proto pp.multidir" +Shared +x ( Rxbproto3 +n + svc_a.proto pp.multidir common.proto2< +ServiceA0 +Call.pp.multidir.Shared.pp.multidir.Sharedbproto3 +n + svc_b.proto pp.multidir common.proto2< +ServiceB0 +Ping.pp.multidir.Shared.pp.multidir.Sharedbproto3 \ No newline at end of file diff --git a/src/test/resources/packetproxy/grpc/proto/multidir/multi_without_imports.desc b/src/test/resources/packetproxy/grpc/proto/multidir/multi_without_imports.desc new file mode 100644 index 00000000..18dc239e --- /dev/null +++ b/src/test/resources/packetproxy/grpc/proto/multidir/multi_without_imports.desc @@ -0,0 +1,5 @@ + +n + svc_a.proto pp.multidir common.proto2< +ServiceA0 +Call.pp.multidir.Shared.pp.multidir.Sharedbproto3 \ No newline at end of file diff --git a/src/test/resources/proto/multidir/svc_a.proto b/src/test/resources/packetproxy/grpc/proto/multidir/svc_a.proto similarity index 100% rename from src/test/resources/proto/multidir/svc_a.proto rename to src/test/resources/packetproxy/grpc/proto/multidir/svc_a.proto diff --git a/src/test/resources/proto/multidir/svc_b.proto b/src/test/resources/packetproxy/grpc/proto/multidir/svc_b.proto similarity index 100% rename from src/test/resources/proto/multidir/svc_b.proto rename to src/test/resources/packetproxy/grpc/proto/multidir/svc_b.proto diff --git a/src/test/resources/packetproxy/grpc/proto/testsvc.desc b/src/test/resources/packetproxy/grpc/proto/testsvc.desc new file mode 100644 index 00000000..29a3e13f --- /dev/null +++ b/src/test/resources/packetproxy/grpc/proto/testsvc.desc @@ -0,0 +1,11 @@ + + + testsvc.proto +pp.testsvc"" + HelloRequest +name ( Rname"& + +HelloReply +message ( Rmessage2G +Greeter< +SayHello.pp.testsvc.HelloRequest.pp.testsvc.HelloReplybproto3 \ No newline at end of file diff --git a/src/test/resources/proto/testsvc.proto b/src/test/resources/packetproxy/grpc/proto/testsvc.proto similarity index 100% rename from src/test/resources/proto/testsvc.proto rename to src/test/resources/packetproxy/grpc/proto/testsvc.proto From 4c3fa86bcd25393dabf21a1a91cdd4db740049ef Mon Sep 17 00:00:00 2001 From: taka2233 Date: Mon, 27 Apr 2026 12:42:48 +0900 Subject: [PATCH 13/22] refactor: replace GrpcServiceRegistry with GrpcProtoWireFormat in EncodeGRPC and EncodeGRPCStreaming for improved gRPC body handling and remove DescriptorSetLoader references --- .../core/packetproxy/encode/EncodeGRPC.java | 25 +- .../encode/EncodeGRPCStreaming.java | 25 +- .../packetproxy/grpc/DescriptorSetLoader.kt | 59 --- .../packetproxy/grpc/GrpcProtoWireFormat.kt | 356 +++++++++--------- .../grpc/GrpcServiceRegistryStore.kt | 32 +- .../grpc/DescriptorSetLoaderTest.kt | 69 ---- .../grpc/GrpcProtoWireFormatTest.kt | 29 +- .../grpc/GrpcServiceRegistryStoreTest.kt | 50 ++- .../grpc/GrpcServiceRegistryTest.kt | 4 +- 9 files changed, 271 insertions(+), 378 deletions(-) delete mode 100644 src/main/kotlin/core/packetproxy/grpc/DescriptorSetLoader.kt delete mode 100644 src/test/kotlin/packetproxy/grpc/DescriptorSetLoaderTest.kt diff --git a/src/main/java/core/packetproxy/encode/EncodeGRPC.java b/src/main/java/core/packetproxy/encode/EncodeGRPC.java index 025a09a1..9541faf5 100644 --- a/src/main/java/core/packetproxy/encode/EncodeGRPC.java +++ b/src/main/java/core/packetproxy/encode/EncodeGRPC.java @@ -15,31 +15,18 @@ */ package packetproxy.encode; -import static packetproxy.util.Logging.errWithStackTrace; - import java.io.File; import packetproxy.grpc.GrpcProtoWireFormat; -import packetproxy.grpc.GrpcServiceRegistry; -import packetproxy.grpc.GrpcServiceRegistryStore; import packetproxy.http.Http; import packetproxy.http2.Grpc; public class EncodeGRPC extends EncodeHTTPBase { - private volatile GrpcServiceRegistry registry; + private volatile GrpcProtoWireFormat wireFormat = GrpcProtoWireFormat.create(); private volatile String lastGrpcPath; public synchronized void setDescriptorFile(File descFile) { - if (descFile == null || !descFile.isFile()) { - this.registry = null; - return; - } - try { - this.registry = GrpcServiceRegistryStore.getInstance().get(descFile); - } catch (Exception e) { - this.registry = null; - errWithStackTrace(e); - } + this.wireFormat = GrpcProtoWireFormat.create(descFile); } public EncodeGRPC() throws Exception { @@ -59,7 +46,7 @@ public String getName() { protected Http decodeClientRequestHttp(Http inputHttp) throws Exception { lastGrpcPath = inputHttp.getPath(); byte[] raw = inputHttp.getBody(); - inputHttp.setBody(GrpcProtoWireFormat.decodeGrpcHttpBodyToUtf8(raw, registry, true, lastGrpcPath, null)); + inputHttp.setBody(wireFormat.decodeBody(raw, true, lastGrpcPath, null)); return inputHttp; } @@ -67,7 +54,7 @@ protected Http decodeClientRequestHttp(Http inputHttp) throws Exception { protected Http encodeClientRequestHttp(Http inputHttp) throws Exception { lastGrpcPath = inputHttp.getPath(); byte[] body = inputHttp.getBody(); - inputHttp.setBody(GrpcProtoWireFormat.encodeClientRequestHttpBody(body, registry, lastGrpcPath)); + inputHttp.setBody(wireFormat.encodeRequestBody(body, lastGrpcPath)); return inputHttp; } @@ -77,7 +64,7 @@ protected Http decodeServerResponseHttp(Http inputHttp) throws Exception { if (raw.length == 0) { return inputHttp; } - inputHttp.setBody(GrpcProtoWireFormat.decodeGrpcHttpBodyToUtf8(raw, registry, false, null, lastGrpcPath)); + inputHttp.setBody(wireFormat.decodeBody(raw, false, null, lastGrpcPath)); return inputHttp; } @@ -87,7 +74,7 @@ protected Http encodeServerResponseHttp(Http inputHttp) throws Exception { if (body.length == 0) { return inputHttp; } - inputHttp.setBody(GrpcProtoWireFormat.encodeServerResponseHttpBody(body, registry, lastGrpcPath)); + inputHttp.setBody(wireFormat.encodeResponseBody(body, lastGrpcPath)); return inputHttp; } diff --git a/src/main/java/core/packetproxy/encode/EncodeGRPCStreaming.java b/src/main/java/core/packetproxy/encode/EncodeGRPCStreaming.java index bfb1f9ae..0c47e519 100644 --- a/src/main/java/core/packetproxy/encode/EncodeGRPCStreaming.java +++ b/src/main/java/core/packetproxy/encode/EncodeGRPCStreaming.java @@ -15,35 +15,22 @@ */ package packetproxy.encode; -import static packetproxy.util.Logging.errWithStackTrace; - import java.io.File; import java.util.concurrent.ConcurrentHashMap; import packetproxy.grpc.GrpcProtoWireFormat; -import packetproxy.grpc.GrpcServiceRegistry; -import packetproxy.grpc.GrpcServiceRegistryStore; import packetproxy.http.Http; import packetproxy.http2.GrpcStreaming; // gRPCでデータフレーム1つずつをメッセージと解釈して送受信するエンコーダ public class EncodeGRPCStreaming extends EncodeHTTPBase { - private volatile GrpcServiceRegistry registry; + private volatile GrpcProtoWireFormat wireFormat = GrpcProtoWireFormat.create(); private volatile String lastGrpcPath; private final ConcurrentHashMap grpcPathByStreamId = new ConcurrentHashMap<>(); public synchronized void setDescriptorFile(File descFile) { grpcPathByStreamId.clear(); - if (descFile == null || !descFile.isFile()) { - this.registry = null; - return; - } - try { - this.registry = GrpcServiceRegistryStore.getInstance().get(descFile); - } catch (Exception e) { - this.registry = null; - errWithStackTrace(e); - } + this.wireFormat = GrpcProtoWireFormat.create(descFile); } private String resolveGrpcPathClient(Http http) { @@ -103,7 +90,7 @@ public String getName() { protected Http decodeClientRequestHttp(Http inputHttp) throws Exception { lastGrpcPath = resolveGrpcPathClient(inputHttp); byte[] raw = inputHttp.getBody(); - inputHttp.setBody(GrpcProtoWireFormat.decodeGrpcHttpBodyToUtf8(raw, registry, true, lastGrpcPath, null)); + inputHttp.setBody(wireFormat.decodeBody(raw, true, lastGrpcPath, null)); return inputHttp; } @@ -111,7 +98,7 @@ protected Http decodeClientRequestHttp(Http inputHttp) throws Exception { protected Http encodeClientRequestHttp(Http inputHttp) throws Exception { lastGrpcPath = resolveGrpcPathClient(inputHttp); byte[] body = inputHttp.getBody(); - inputHttp.setBody(GrpcProtoWireFormat.encodeClientRequestHttpBody(body, registry, lastGrpcPath)); + inputHttp.setBody(wireFormat.encodeRequestBody(body, lastGrpcPath)); return inputHttp; } @@ -122,7 +109,7 @@ protected Http decodeServerResponseHttp(Http inputHttp) throws Exception { return inputHttp; } lastGrpcPath = resolveGrpcPathServer(inputHttp); - inputHttp.setBody(GrpcProtoWireFormat.decodeGrpcHttpBodyToUtf8(raw, registry, false, null, lastGrpcPath)); + inputHttp.setBody(wireFormat.decodeBody(raw, false, null, lastGrpcPath)); return inputHttp; } @@ -133,7 +120,7 @@ protected Http encodeServerResponseHttp(Http inputHttp) throws Exception { return inputHttp; } lastGrpcPath = resolveGrpcPathServer(inputHttp); - inputHttp.setBody(GrpcProtoWireFormat.encodeServerResponseHttpBody(body, registry, lastGrpcPath)); + inputHttp.setBody(wireFormat.encodeResponseBody(body, lastGrpcPath)); return inputHttp; } diff --git a/src/main/kotlin/core/packetproxy/grpc/DescriptorSetLoader.kt b/src/main/kotlin/core/packetproxy/grpc/DescriptorSetLoader.kt deleted file mode 100644 index ca3c67ab..00000000 --- a/src/main/kotlin/core/packetproxy/grpc/DescriptorSetLoader.kt +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Copyright 2026 DeNA Co., Ltd. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package packetproxy.grpc - -import com.google.protobuf.DescriptorProtos.FileDescriptorSet -import com.google.protobuf.Descriptors.DescriptorValidationException -import com.google.protobuf.Descriptors.FileDescriptor -import java.io.File -import java.io.IOException -import java.nio.file.Files - -/** - * Loads a `.desc` file produced by `protoc --include_imports` and builds a dependency-ordered list - * of [FileDescriptor] instances. - */ -class DescriptorSetLoader private constructor() { - companion object { - /** - * Reads [descFile], parses the [FileDescriptorSet], and resolves each file's dependencies in - * order. Requires `protoc --include_imports` so that all transitive deps are present. - */ - @JvmStatic - @Throws(IOException::class, DescriptorValidationException::class, IllegalStateException::class) - fun loadAndBuild(descFile: File): List { - val bytes = Files.readAllBytes(descFile.toPath()) - val fds = FileDescriptorSet.parseFrom(bytes) - val known = HashMap() - val ordered = ArrayList() - for (fdp in fds.fileList) { - val deps = - Array(fdp.dependencyCount) { i -> - val depName = fdp.getDependency(i) - known[depName] - ?: throw IllegalStateException( - "Missing dependency '$depName' while building '${fdp.name}'. " + - "Re-generate with: protoc --include_imports --descriptor_set_out=out.desc -I... your.proto" - ) - } - val built = FileDescriptor.buildFrom(fdp, deps) - ordered.add(built) - known[built.name] = built - } - return ordered - } - } -} diff --git a/src/main/kotlin/core/packetproxy/grpc/GrpcProtoWireFormat.kt b/src/main/kotlin/core/packetproxy/grpc/GrpcProtoWireFormat.kt index 2898efd5..f678be79 100644 --- a/src/main/kotlin/core/packetproxy/grpc/GrpcProtoWireFormat.kt +++ b/src/main/kotlin/core/packetproxy/grpc/GrpcProtoWireFormat.kt @@ -21,6 +21,7 @@ import com.google.protobuf.Descriptors.Descriptor import com.google.protobuf.DynamicMessage import com.google.protobuf.util.JsonFormat import java.io.ByteArrayOutputStream +import java.io.File import java.nio.ByteBuffer import java.nio.charset.StandardCharsets import java.util.ArrayList @@ -29,211 +30,214 @@ import java.util.Collections import org.apache.commons.lang3.ArrayUtils import packetproxy.common.Protobuf3 import packetproxy.common.Utils +import packetproxy.util.Logging /** - * gRPC length-prefixed bodies to/from UTF-8 JSON using a [GrpcServiceRegistry], with schema-less - * fallback. + * gRPC length-prefixed bodies to/from UTF-8 JSON using a [GrpcServiceRegistry] loaded from a + * `.desc` file, with schema-less fallback. */ -class GrpcProtoWireFormat private constructor() { - companion object { - private val JSON_PRINTER = - JsonFormat.printer().preservingProtoFieldNames().alwaysPrintFieldsWithNoPresence() +class GrpcProtoWireFormat private constructor(private val registry: GrpcServiceRegistry?) { - private val JSON_PARSER = JsonFormat.parser().ignoringUnknownFields() - - @JvmStatic - @Throws(Exception::class) - fun decodeGrpcHttpBodyToUtf8( - raw: ByteArray, - registry: GrpcServiceRegistry?, - isRequest: Boolean, - grpcPath: String?, - lastRequestGrpcPath: String?, - ): ByteArray { - if (registry == null) { - return decodeSchemalessGrpcBody(raw) - } - val type = - if (isRequest) registry.getInputType(grpcPath) - else registry.getOutputType(lastRequestGrpcPath) - if (type == null) { - return decodeSchemalessGrpcBody(raw) + @Throws(Exception::class) + fun decodeBody( + raw: ByteArray, + isRequest: Boolean, + grpcPath: String?, + lastRequestGrpcPath: String?, + ): ByteArray { + if (registry == null) { + return decodeSchemalessGrpcBody(raw) + } + val type = + if (isRequest) registry.getInputType(grpcPath) + else registry.getOutputType(lastRequestGrpcPath) + if (type == null) { + return decodeSchemalessGrpcBody(raw) + } + val body = ByteArrayOutputStream() + var pos = 0 + while (pos < raw.size) { + val compressedFlag = raw[pos] + if (compressedFlag.toInt() != 0) { + throw Exception("gRPC: compressed flag in gRPC message is not supported yet") } - val body = ByteArrayOutputStream() - var pos = 0 - while (pos < raw.size) { - val compressedFlag = raw[pos] - if (compressedFlag.toInt() != 0) { - throw Exception("gRPC: compressed flag in gRPC message is not supported yet") - } - pos += 1 - val messageLength = ByteBuffer.wrap(Arrays.copyOfRange(raw, pos, pos + 4)).int - pos += 4 - val grpcMsg = Arrays.copyOfRange(raw, pos, pos + messageLength) - pos += messageLength - if (body.size() > 0) { - body.write('\n'.code) - } - body.write(decodeOnePayload(grpcMsg, type).toByteArray(StandardCharsets.UTF_8)) + pos += 1 + val messageLength = ByteBuffer.wrap(Arrays.copyOfRange(raw, pos, pos + 4)).int + pos += 4 + val grpcMsg = Arrays.copyOfRange(raw, pos, pos + messageLength) + pos += messageLength + if (body.size() > 0) { + body.write('\n'.code) } - return body.toByteArray() + body.write(decodeOnePayload(grpcMsg, type).toByteArray(StandardCharsets.UTF_8)) } + return body.toByteArray() + } - @Throws(Exception::class) - private fun decodeSchemalessGrpcBody(raw: ByteArray): ByteArray { - val body = ByteArrayOutputStream() - var pos = 0 - while (pos < raw.size) { - val compressedFlag = raw[pos] - if (compressedFlag.toInt() != 0) { - throw Exception("gRPC: compressed flag in gRPC message is not supported yet") - } - pos += 1 - val messageLength = ByteBuffer.wrap(Arrays.copyOfRange(raw, pos, pos + 4)).int - pos += 4 - val grpcMsg = Arrays.copyOfRange(raw, pos, pos + messageLength) - pos += messageLength - if (body.size() > 0) { - body.write('\n'.code) - } - body.write(Protobuf3.decode(grpcMsg).toByteArray(StandardCharsets.UTF_8)) + @Throws(Exception::class) + private fun decodeSchemalessGrpcBody(raw: ByteArray): ByteArray { + val body = ByteArrayOutputStream() + var pos = 0 + while (pos < raw.size) { + val compressedFlag = raw[pos] + if (compressedFlag.toInt() != 0) { + throw Exception("gRPC: compressed flag in gRPC message is not supported yet") } - return body.toByteArray() + pos += 1 + val messageLength = ByteBuffer.wrap(Arrays.copyOfRange(raw, pos, pos + 4)).int + pos += 4 + val grpcMsg = Arrays.copyOfRange(raw, pos, pos + messageLength) + pos += messageLength + if (body.size() > 0) { + body.write('\n'.code) + } + body.write(Protobuf3.decode(grpcMsg).toByteArray(StandardCharsets.UTF_8)) } + return body.toByteArray() + } - @Throws(Exception::class) - private fun decodeOnePayload(payload: ByteArray, type: Descriptor): String { - return try { - val msg = DynamicMessage.parseFrom(type, payload) - JSON_PRINTER.print(msg) - } catch (_: Exception) { - Protobuf3.decode(payload) - } + @Throws(Exception::class) + private fun decodeOnePayload(payload: ByteArray, type: Descriptor): String { + return try { + val msg = DynamicMessage.parseFrom(type, payload) + JSON_PRINTER.print(msg) + } catch (_: Exception) { + Protobuf3.decode(payload) } + } - @JvmStatic - @Throws(Exception::class) - fun encodeClientRequestHttpBody( - body: ByteArray, - registry: GrpcServiceRegistry?, - grpcPath: String?, - ): ByteArray { - if (registry == null) { - return encodeSchemalessGrpcBody(body) - } - val type = registry.getInputType(grpcPath) ?: return encodeSchemalessGrpcBody(body) - return encodeGrpcBodyFromJsonChunks(body, type) + @Throws(Exception::class) + fun encodeRequestBody(body: ByteArray, grpcPath: String?): ByteArray { + if (registry == null) { + return encodeSchemalessGrpcBody(body) } + val type = registry.getInputType(grpcPath) ?: return encodeSchemalessGrpcBody(body) + return encodeGrpcBodyFromJsonChunks(body, type) + } - @JvmStatic - @Throws(Exception::class) - fun encodeServerResponseHttpBody( - body: ByteArray, - registry: GrpcServiceRegistry?, - lastRequestGrpcPath: String?, - ): ByteArray { - if (body.isEmpty()) { - return body - } - if (registry == null) { - return encodeSchemalessGrpcBody(body) - } - val type = - registry.getOutputType(lastRequestGrpcPath) ?: return encodeSchemalessGrpcBody(body) - return encodeGrpcBodyFromJsonChunks(body, type) + @Throws(Exception::class) + fun encodeResponseBody(body: ByteArray, lastRequestGrpcPath: String?): ByteArray { + if (body.isEmpty()) { + return body + } + if (registry == null) { + return encodeSchemalessGrpcBody(body) } + val type = registry.getOutputType(lastRequestGrpcPath) ?: return encodeSchemalessGrpcBody(body) + return encodeGrpcBodyFromJsonChunks(body, type) + } - @Throws(Exception::class) - private fun encodeSchemalessGrpcBody(body: ByteArray): ByteArray { - if (body.isEmpty()) { - return body - } - val rawStream = ByteArrayOutputStream() - var pos = 0 - while (pos < body.size) { - val subBody: ByteArray - val idx = Utils.indexOf(body, pos, body.size, "\n}".toByteArray(StandardCharsets.UTF_8)) - if (idx > 0) { - subBody = ArrayUtils.subarray(body, pos, idx + 2) - pos = idx + 2 - } else { - subBody = ArrayUtils.subarray(body, pos, body.size) - pos = body.size - } - val msg = String(subBody, StandardCharsets.UTF_8) - val data = Protobuf3.encode(msg) - writeGrpcFrame(rawStream, data) + @Throws(Exception::class) + private fun encodeSchemalessGrpcBody(body: ByteArray): ByteArray { + if (body.isEmpty()) { + return body + } + val rawStream = ByteArrayOutputStream() + var pos = 0 + while (pos < body.size) { + val subBody: ByteArray + val idx = Utils.indexOf(body, pos, body.size, "\n}".toByteArray(StandardCharsets.UTF_8)) + if (idx > 0) { + subBody = ArrayUtils.subarray(body, pos, idx + 2) + pos = idx + 2 + } else { + subBody = ArrayUtils.subarray(body, pos, body.size) + pos = body.size } - return rawStream.toByteArray() + val msg = String(subBody, StandardCharsets.UTF_8) + val data = Protobuf3.encode(msg) + writeGrpcFrame(rawStream, data) } + return rawStream.toByteArray() + } - /** - * Splits a UTF-8 body into top-level JSON objects. Uses Jackson's tokenizer so `\n` inside - * string values does not spuriously split (unlike the `"\n}"` heuristic in - * [encodeSchemalessGrpcBody]). - */ - private fun splitTopLevelJsonObjects(text: String?): List { - if (text.isNullOrEmpty()) { - return Collections.emptyList() - } - val out = ArrayList() - val factory = JsonFactory() - try { - factory.createParser(text).use { p -> - var depth = 0 - var start = -1 - while (p.nextToken() != null) { - if (p.currentToken() == JsonToken.START_OBJECT) { - if (depth == 0) { - start = p.currentLocation.charOffset.toInt() - } - depth++ - } else if (p.currentToken() == JsonToken.END_OBJECT) { - depth-- - if (depth == 0 && start >= 0) { - val end = p.currentLocation.charOffset.toInt() + 1 - out.add(text.substring(start, end)) - } + /** + * Splits a UTF-8 body into top-level JSON objects. Uses Jackson's tokenizer so `\n` inside string + * values does not spuriously split (unlike the `"\n}"` heuristic in [encodeSchemalessGrpcBody]). + */ + private fun splitTopLevelJsonObjects(text: String?): List { + if (text.isNullOrEmpty()) { + return Collections.emptyList() + } + val out = ArrayList() + val factory = JsonFactory() + try { + factory.createParser(text).use { p -> + var depth = 0 + var start = -1 + while (p.nextToken() != null) { + if (p.currentToken() == JsonToken.START_OBJECT) { + if (depth == 0) { + start = p.currentLocation.charOffset.toInt() + } + depth++ + } else if (p.currentToken() == JsonToken.END_OBJECT) { + depth-- + if (depth == 0 && start >= 0) { + val end = p.currentLocation.charOffset.toInt() + 1 + out.add(text.substring(start, end)) } } } - } catch (_: Exception) {} - return out + } + } catch (_: Exception) {} + return out + } + + @Throws(Exception::class) + private fun encodeGrpcBodyFromJsonChunks(body: ByteArray, type: Descriptor): ByteArray { + val s = String(body, StandardCharsets.UTF_8) + var objects = splitTopLevelJsonObjects(s) + if (objects.isEmpty() && s.trim().isNotEmpty()) { + objects = Collections.singletonList(s) + } + val rawStream = ByteArrayOutputStream() + for (json in objects) { + val trimmed = json.trim() + if (trimmed.isEmpty()) continue + val data = encodeOneJsonToBinary(trimmed, type) + writeGrpcFrame(rawStream, data) } + return rawStream.toByteArray() + } - @Throws(Exception::class) - private fun encodeGrpcBodyFromJsonChunks(body: ByteArray, type: Descriptor): ByteArray { - val s = String(body, StandardCharsets.UTF_8) - var objects = splitTopLevelJsonObjects(s) - if (objects.isEmpty() && s.trim().isNotEmpty()) { - objects = Collections.singletonList(s) - } - val rawStream = ByteArrayOutputStream() - for (json in objects) { - val trimmed = json.trim() - if (trimmed.isEmpty()) continue - val data = encodeOneJsonToBinary(trimmed, type) - writeGrpcFrame(rawStream, data) - } - return rawStream.toByteArray() + @Throws(Exception::class) + private fun encodeOneJsonToBinary(json: String, type: Descriptor): ByteArray { + return try { + val builder = DynamicMessage.newBuilder(type) + JSON_PARSER.merge(json, builder) + builder.build().toByteArray() + } catch (_: Exception) { + Protobuf3.encode(json) } + } + + @Throws(Exception::class) + private fun writeGrpcFrame(rawStream: ByteArrayOutputStream, payload: ByteArray) { + rawStream.write(0) + rawStream.write(ByteBuffer.allocate(4).putInt(payload.size).array()) + rawStream.write(payload) + } + + companion object { + private val JSON_PRINTER = + JsonFormat.printer().preservingProtoFieldNames().alwaysPrintFieldsWithNoPresence() - @Throws(Exception::class) - private fun encodeOneJsonToBinary(json: String, type: Descriptor): ByteArray { + private val JSON_PARSER = JsonFormat.parser().ignoringUnknownFields() + + @JvmStatic + @JvmOverloads + fun create(descFile: File? = null): GrpcProtoWireFormat { + if (descFile == null || !descFile.isFile) { + return GrpcProtoWireFormat(null) + } return try { - val builder = DynamicMessage.newBuilder(type) - JSON_PARSER.merge(json, builder) - builder.build().toByteArray() - } catch (_: Exception) { - Protobuf3.encode(json) + val reg = GrpcServiceRegistryStore.getInstance().get(descFile) + GrpcProtoWireFormat(reg) + } catch (e: Exception) { + Logging.errWithStackTrace(e) + GrpcProtoWireFormat(null) } } - - @Throws(Exception::class) - private fun writeGrpcFrame(rawStream: ByteArrayOutputStream, payload: ByteArray) { - rawStream.write(0) - rawStream.write(ByteBuffer.allocate(4).putInt(payload.size).array()) - rawStream.write(payload) - } } } diff --git a/src/main/kotlin/core/packetproxy/grpc/GrpcServiceRegistryStore.kt b/src/main/kotlin/core/packetproxy/grpc/GrpcServiceRegistryStore.kt index de1b17f8..ae588dc4 100644 --- a/src/main/kotlin/core/packetproxy/grpc/GrpcServiceRegistryStore.kt +++ b/src/main/kotlin/core/packetproxy/grpc/GrpcServiceRegistryStore.kt @@ -15,7 +15,14 @@ */ package packetproxy.grpc +import com.google.protobuf.DescriptorProtos.FileDescriptorSet +import com.google.protobuf.Descriptors.DescriptorValidationException +import com.google.protobuf.Descriptors.FileDescriptor import java.io.File +import java.io.IOException +import java.nio.file.Files +import java.util.ArrayList +import java.util.HashMap import java.util.concurrent.ConcurrentHashMap /** @@ -38,12 +45,35 @@ class GrpcServiceRegistryStore private constructor() { cache[key]?.let { return it } - val hit = GrpcServiceRegistry(DescriptorSetLoader.loadAndBuild(descFile)) + val hit = GrpcServiceRegistry(loadAndBuild(descFile)) cache[key] = hit return hit } } + @Throws(IOException::class, DescriptorValidationException::class, IllegalStateException::class) + private fun loadAndBuild(descFile: File): List { + val bytes = Files.readAllBytes(descFile.toPath()) + val fds = FileDescriptorSet.parseFrom(bytes) + val known = HashMap() + val ordered = ArrayList() + for (fdp in fds.fileList) { + val deps = + Array(fdp.dependencyCount) { i -> + val depName = fdp.getDependency(i) + known[depName] + ?: throw IllegalStateException( + "Missing dependency '$depName' while building '${fdp.name}'. " + + "Re-generate with: protoc --include_imports --descriptor_set_out=out.desc -I... your.proto" + ) + } + val built = FileDescriptor.buildFrom(fdp, deps) + ordered.add(built) + known[built.name] = built + } + return ordered + } + fun invalidate(descFile: File?) { if (descFile == null) return try { diff --git a/src/test/kotlin/packetproxy/grpc/DescriptorSetLoaderTest.kt b/src/test/kotlin/packetproxy/grpc/DescriptorSetLoaderTest.kt deleted file mode 100644 index c536b0ee..00000000 --- a/src/test/kotlin/packetproxy/grpc/DescriptorSetLoaderTest.kt +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright 2026 DeNA Co., Ltd. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package packetproxy.grpc - -import java.io.File -import java.nio.charset.StandardCharsets -import java.nio.file.Files -import org.junit.jupiter.api.Assertions.assertFalse -import org.junit.jupiter.api.Assertions.assertThrows -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.Test - -class DescriptorSetLoaderTest { - private fun resource(classpathPath: String): File { - val u = - DescriptorSetLoaderTest::class.java.getResource(classpathPath) - ?: throw IllegalStateException("missing resource: $classpathPath") - return File(u.toURI()) - } - - @Test - fun loadAndBuild_missingFile_throws() { - assertThrows(Exception::class.java) { - DescriptorSetLoader.loadAndBuild(File("/nonexistent/path.desc")) - } - } - - @Test - fun loadAndBuild_invalidBytes_throws() { - val tmp = Files.createTempFile("bad", ".desc").toFile() - Files.writeString(tmp.toPath(), "not-a-protobuf-descriptor", StandardCharsets.UTF_8) - assertThrows(Exception::class.java) { DescriptorSetLoader.loadAndBuild(tmp) } - } - - @Test - fun loadAndBuild_withIncludeImports_ok() { - val f = resource("proto/multidir/multi.desc") - val list = DescriptorSetLoader.loadAndBuild(f) - assertFalse(list.isEmpty()) - } - - // multi_without_imports.desc was built without --include_imports, so transitive deps are missing. - // loadAndBuild must detect this and throw rather than silently producing an incomplete registry. - @Test - fun loadAndBuild_withoutIncludeImports_throws() { - val f = resource("proto/multidir/multi_without_imports.desc") - assertThrows(IllegalStateException::class.java) { DescriptorSetLoader.loadAndBuild(f) } - } - - @Test - fun loadAndBuild_testsvc_containsGreeterService() { - val f = resource("proto/testsvc.desc") - val list = DescriptorSetLoader.loadAndBuild(f) - assertTrue(list.any { it.findServiceByName("Greeter") != null }) - } -} diff --git a/src/test/kotlin/packetproxy/grpc/GrpcProtoWireFormatTest.kt b/src/test/kotlin/packetproxy/grpc/GrpcProtoWireFormatTest.kt index 4a79b9fc..913ad1f4 100644 --- a/src/test/kotlin/packetproxy/grpc/GrpcProtoWireFormatTest.kt +++ b/src/test/kotlin/packetproxy/grpc/GrpcProtoWireFormatTest.kt @@ -29,22 +29,18 @@ class GrpcProtoWireFormatTest { return File(u.toURI()) } - private fun registry(): GrpcServiceRegistry = - GrpcServiceRegistry(DescriptorSetLoader.loadAndBuild(resource("proto/testsvc.desc"))) + private fun wireFormat(): GrpcProtoWireFormat = + GrpcProtoWireFormat.create(resource("proto/testsvc.desc")) @Test fun decodeThenEncode_request_roundtrip() { - val reg = registry() + val wireFormat = wireFormat() val grpcPath = "/pp.testsvc.Greeter/SayHello" val json = "{\n \"name\": \"Alice\"\n}" val encodedOnce = - GrpcProtoWireFormat.encodeClientRequestHttpBody( - json.toByteArray(StandardCharsets.UTF_8), - reg, - grpcPath, - ) - val utf8 = GrpcProtoWireFormat.decodeGrpcHttpBodyToUtf8(encodedOnce, reg, true, grpcPath, null) - val encodedTwice = GrpcProtoWireFormat.encodeClientRequestHttpBody(utf8, reg, grpcPath) + wireFormat.encodeRequestBody(json.toByteArray(StandardCharsets.UTF_8), grpcPath) + val utf8 = wireFormat.decodeBody(encodedOnce, true, grpcPath, null) + val encodedTwice = wireFormat.encodeRequestBody(utf8, grpcPath) val decoded = String(utf8, StandardCharsets.UTF_8) assertTrue(decoded.contains("\"name\"")) assertTrue(decoded.contains("Alice")) @@ -53,18 +49,13 @@ class GrpcProtoWireFormatTest { @Test fun decodeThenEncode_response_roundtrip() { - val reg = registry() + val wireFormat = wireFormat() val lastRequestPath = "/pp.testsvc.Greeter/SayHello" val json = "{\n \"message\": \"Hello\"\n}" val encodedOnce = - GrpcProtoWireFormat.encodeServerResponseHttpBody( - json.toByteArray(StandardCharsets.UTF_8), - reg, - lastRequestPath, - ) - val utf8 = - GrpcProtoWireFormat.decodeGrpcHttpBodyToUtf8(encodedOnce, reg, false, null, lastRequestPath) - val encodedTwice = GrpcProtoWireFormat.encodeServerResponseHttpBody(utf8, reg, lastRequestPath) + wireFormat.encodeResponseBody(json.toByteArray(StandardCharsets.UTF_8), lastRequestPath) + val utf8 = wireFormat.decodeBody(encodedOnce, false, null, lastRequestPath) + val encodedTwice = wireFormat.encodeResponseBody(utf8, lastRequestPath) val decoded = String(utf8, StandardCharsets.UTF_8) assertTrue(decoded.contains("message") || decoded.contains("Hello")) assertArrayEquals(encodedOnce, encodedTwice) diff --git a/src/test/kotlin/packetproxy/grpc/GrpcServiceRegistryStoreTest.kt b/src/test/kotlin/packetproxy/grpc/GrpcServiceRegistryStoreTest.kt index 7bc8b22b..6a07dc6b 100644 --- a/src/test/kotlin/packetproxy/grpc/GrpcServiceRegistryStoreTest.kt +++ b/src/test/kotlin/packetproxy/grpc/GrpcServiceRegistryStoreTest.kt @@ -16,9 +16,12 @@ package packetproxy.grpc import java.io.File -import org.junit.jupiter.api.AfterEach +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertSame import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test class GrpcServiceRegistryStoreTest { @@ -29,24 +32,43 @@ class GrpcServiceRegistryStoreTest { return File(u.toURI()) } - @AfterEach - fun tearDown() { - GrpcServiceRegistryStore.getInstance().invalidateAll() + private val store: GrpcServiceRegistryStore + get() = GrpcServiceRegistryStore.getInstance() + + @Test + fun get_missingFile_throws() { + assertThrows(Exception::class.java) { store.get(File("/nonexistent/path.desc")) } } @Test - fun getCachesByCanonicalPath() { - val store = GrpcServiceRegistryStore.getInstance() - val f = resource("proto/testsvc.desc") - val a = store.get(f) - val b = store.get(f) - assertSame(a, b) - store.invalidate(f) + fun get_invalidBytes_throws() { + val tmp = Files.createTempFile("bad", ".desc").toFile() + Files.writeString(tmp.toPath(), "not-a-protobuf-descriptor", StandardCharsets.UTF_8) + assertThrows(Exception::class.java) { store.get(tmp) } } @Test - fun get_missingFile_throws() { - val store = GrpcServiceRegistryStore.getInstance() - assertThrows(Exception::class.java) { store.get(File("/nonexistent/does-not-exist.desc")) } + fun get_withIncludeImports_ok_and_cacheReturnsSameInstance() { + val f = resource("proto/multidir/multi.desc") + val reg1 = store.get(f) + assertFalse(reg1.getServiceMethodEntries().isEmpty()) + val reg2 = store.get(f) + assertSame(reg1, reg2) + } + + // multi_without_imports.desc was built without --include_imports, so transitive deps are missing. + // loadAndBuild must detect this and throw rather than silently producing an incomplete registry. + @Test + fun get_withoutIncludeImports_throws() { + val f = resource("proto/multidir/multi_without_imports.desc") + assertThrows(IllegalStateException::class.java) { store.get(f) } + } + + @Test + fun get_testsvc_containsGreeterService() { + val f = resource("proto/testsvc.desc") + val reg = store.get(f) + val entries = reg.getServiceMethodEntries() + assertTrue(entries.any { it.first == "pp.testsvc.Greeter" && it.second == "SayHello" }) } } diff --git a/src/test/kotlin/packetproxy/grpc/GrpcServiceRegistryTest.kt b/src/test/kotlin/packetproxy/grpc/GrpcServiceRegistryTest.kt index febe9323..72e2b37b 100644 --- a/src/test/kotlin/packetproxy/grpc/GrpcServiceRegistryTest.kt +++ b/src/test/kotlin/packetproxy/grpc/GrpcServiceRegistryTest.kt @@ -31,7 +31,7 @@ class GrpcServiceRegistryTest { @Test fun mapsGrpcPathToInputOutput() { - val reg = GrpcServiceRegistry(DescriptorSetLoader.loadAndBuild(resource("proto/testsvc.desc"))) + val reg = GrpcServiceRegistryStore.getInstance().get(resource("proto/testsvc.desc")) val input = reg.getInputType("/pp.testsvc.Greeter/SayHello") val output = reg.getOutputType("/pp.testsvc.Greeter/SayHello") assertNotNull(input) @@ -43,7 +43,7 @@ class GrpcServiceRegistryTest { @Test fun findMessageByName_nestedIndexing() { - val reg = GrpcServiceRegistry(DescriptorSetLoader.loadAndBuild(resource("proto/testsvc.desc"))) + val reg = GrpcServiceRegistryStore.getInstance().get(resource("proto/testsvc.desc")) assertNotNull(reg.findMessageByName("pp.testsvc.HelloRequest")) } } From 6f42de7e45f68177d662f36e9e50a51e10c3a2d1 Mon Sep 17 00:00:00 2001 From: taka2233 Date: Mon, 27 Apr 2026 14:31:38 +0900 Subject: [PATCH 14/22] refactor: remove GrpcProtoWireFormat and related tests, replacing with GrpcServiceRegistry for improved gRPC body handling in EncodeGRPC and EncodeGRPCStreaming --- .../core/packetproxy/encode/EncodeGRPC.java | 211 +++++++++++++-- .../encode/EncodeGRPCStreaming.java | 211 +++++++++++++-- .../packetproxy/grpc/GrpcProtoWireFormat.kt | 243 ------------------ .../grpc/GrpcProtoWireFormatTest.kt | 63 ----- 4 files changed, 388 insertions(+), 340 deletions(-) delete mode 100644 src/main/kotlin/core/packetproxy/grpc/GrpcProtoWireFormat.kt delete mode 100644 src/test/kotlin/packetproxy/grpc/GrpcProtoWireFormatTest.kt diff --git a/src/main/java/core/packetproxy/encode/EncodeGRPC.java b/src/main/java/core/packetproxy/encode/EncodeGRPC.java index 9541faf5..7610f4f7 100644 --- a/src/main/java/core/packetproxy/encode/EncodeGRPC.java +++ b/src/main/java/core/packetproxy/encode/EncodeGRPC.java @@ -15,18 +15,49 @@ */ package packetproxy.encode; +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.JsonToken; +import com.google.protobuf.Descriptors.Descriptor; +import com.google.protobuf.DynamicMessage; +import com.google.protobuf.util.JsonFormat; +import java.io.ByteArrayOutputStream; import java.io.File; -import packetproxy.grpc.GrpcProtoWireFormat; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import org.apache.commons.lang3.ArrayUtils; +import packetproxy.common.Protobuf3; +import packetproxy.common.Utils; +import packetproxy.grpc.GrpcServiceRegistry; +import packetproxy.grpc.GrpcServiceRegistryStore; import packetproxy.http.Http; import packetproxy.http2.Grpc; +import packetproxy.util.Logging; public class EncodeGRPC extends EncodeHTTPBase { - private volatile GrpcProtoWireFormat wireFormat = GrpcProtoWireFormat.create(); + private static final JsonFormat.Printer JSON_PRINTER = + JsonFormat.printer().preservingProtoFieldNames().alwaysPrintFieldsWithNoPresence(); + + private static final JsonFormat.Parser JSON_PARSER = JsonFormat.parser().ignoringUnknownFields(); + + private volatile GrpcServiceRegistry registry; private volatile String lastGrpcPath; public synchronized void setDescriptorFile(File descFile) { - this.wireFormat = GrpcProtoWireFormat.create(descFile); + if (descFile == null || !descFile.isFile()) { + registry = null; + return; + } + try { + registry = GrpcServiceRegistryStore.getInstance().get(descFile); + } catch (Exception e) { + Logging.errWithStackTrace(e); + registry = null; + } } public EncodeGRPC() throws Exception { @@ -45,16 +76,16 @@ public String getName() { @Override protected Http decodeClientRequestHttp(Http inputHttp) throws Exception { lastGrpcPath = inputHttp.getPath(); - byte[] raw = inputHttp.getBody(); - inputHttp.setBody(wireFormat.decodeBody(raw, true, lastGrpcPath, null)); + Descriptor type = getInputType(lastGrpcPath); + inputHttp.setBody(decodeLengthPrefixedBody(inputHttp.getBody(), type)); return inputHttp; } @Override protected Http encodeClientRequestHttp(Http inputHttp) throws Exception { lastGrpcPath = inputHttp.getPath(); - byte[] body = inputHttp.getBody(); - inputHttp.setBody(wireFormat.encodeRequestBody(body, lastGrpcPath)); + Descriptor type = getInputType(lastGrpcPath); + inputHttp.setBody(encodeLengthPrefixedFromUtf8Json(inputHttp.getBody(), type)); return inputHttp; } @@ -64,7 +95,8 @@ protected Http decodeServerResponseHttp(Http inputHttp) throws Exception { if (raw.length == 0) { return inputHttp; } - inputHttp.setBody(wireFormat.decodeBody(raw, false, null, lastGrpcPath)); + Descriptor type = getOutputType(lastGrpcPath); + inputHttp.setBody(decodeLengthPrefixedBody(raw, type)); return inputHttp; } @@ -74,23 +106,168 @@ protected Http encodeServerResponseHttp(Http inputHttp) throws Exception { if (body.length == 0) { return inputHttp; } - inputHttp.setBody(wireFormat.encodeResponseBody(body, lastGrpcPath)); + Descriptor type = getOutputType(lastGrpcPath); + inputHttp.setBody(encodeLengthPrefixedFromUtf8Json(body, type)); return inputHttp; } - public byte[] decodeGrpcClientPayload(byte[] payload) throws Exception { - return payload; + private Descriptor getInputType(String grpcPath) { + if (registry == null) { + return null; + } + return registry.getInputType(grpcPath); + } + + private Descriptor getOutputType(String lastRequestGrpcPath) { + if (registry == null) { + return null; + } + return registry.getOutputType(lastRequestGrpcPath); + } + + private byte[] decodeLengthPrefixedBody(byte[] raw, Descriptor type) throws Exception { + if (type == null) { + return decodeSchemalessGrpcBody(raw); + } + ByteArrayOutputStream body = new ByteArrayOutputStream(); + int pos = 0; + while (pos < raw.length) { + byte compressedFlag = raw[pos]; + if (compressedFlag != 0) { + throw new Exception("gRPC: compressed flag in gRPC message is not supported yet"); + } + pos += 1; + int messageLength = ByteBuffer.wrap(Arrays.copyOfRange(raw, pos, pos + 4)).getInt(); + pos += 4; + byte[] grpcMsg = Arrays.copyOfRange(raw, pos, pos + messageLength); + pos += messageLength; + if (body.size() > 0) { + body.write('\n'); + } + body.write(decodeOnePayloadToUtf8String(grpcMsg, type).getBytes(StandardCharsets.UTF_8)); + } + return body.toByteArray(); } - public byte[] encodeGrpcClientPayload(byte[] payload) throws Exception { - return payload; + private byte[] decodeSchemalessGrpcBody(byte[] raw) throws Exception { + ByteArrayOutputStream body = new ByteArrayOutputStream(); + int pos = 0; + while (pos < raw.length) { + byte compressedFlag = raw[pos]; + if (compressedFlag != 0) { + throw new Exception("gRPC: compressed flag in gRPC message is not supported yet"); + } + pos += 1; + int messageLength = ByteBuffer.wrap(Arrays.copyOfRange(raw, pos, pos + 4)).getInt(); + pos += 4; + byte[] grpcMsg = Arrays.copyOfRange(raw, pos, pos + messageLength); + pos += messageLength; + if (body.size() > 0) { + body.write('\n'); + } + body.write(Protobuf3.decode(grpcMsg).getBytes(StandardCharsets.UTF_8)); + } + return body.toByteArray(); + } + + private String decodeOnePayloadToUtf8String(byte[] payload, Descriptor type) throws Exception { + try { + DynamicMessage msg = DynamicMessage.parseFrom(type, payload); + return JSON_PRINTER.print(msg); + } catch (Exception e) { + return Protobuf3.decode(payload); + } } - public byte[] decodeGrpcServerPayload(byte[] payload) throws Exception { - return payload; + private byte[] encodeLengthPrefixedFromUtf8Json(byte[] body, Descriptor type) throws Exception { + if (type == null) { + return encodeSchemalessGrpcBody(body); + } + return encodeBodyFromJsonChunksForSchema(body, type); + } + + private byte[] encodeSchemalessGrpcBody(byte[] body) throws Exception { + if (body.length == 0) { + return body; + } + ByteArrayOutputStream rawStream = new ByteArrayOutputStream(); + int pos = 0; + while (pos < body.length) { + byte[] subBody; + int idx = Utils.indexOf(body, pos, body.length, "\n}".getBytes(StandardCharsets.UTF_8)); + if (idx > 0) { + subBody = ArrayUtils.subarray(body, pos, idx + 2); + pos = idx + 2; + } else { + subBody = ArrayUtils.subarray(body, pos, body.length); + pos = body.length; + } + String msg = new String(subBody, StandardCharsets.UTF_8); + byte[] data = Protobuf3.encode(msg); + writeGrpcFrame(rawStream, data); + } + return rawStream.toByteArray(); + } + + private List splitTopLevelJsonObjects(String text) { + if (text == null || text.isEmpty()) { + return Collections.emptyList(); + } + List out = new ArrayList<>(); + JsonFactory factory = new JsonFactory(); + try (com.fasterxml.jackson.core.JsonParser p = factory.createParser(text)) { + int depth = 0; + int start = -1; + while (p.nextToken() != null) { + if (p.currentToken() == JsonToken.START_OBJECT) { + if (depth == 0) { + start = (int) p.getCurrentLocation().getCharOffset(); + } + depth++; + } else if (p.currentToken() == JsonToken.END_OBJECT) { + depth--; + if (depth == 0 && start >= 0) { + int end = (int) p.getCurrentLocation().getCharOffset() + 1; + out.add(text.substring(start, end)); + } + } + } + } catch (Exception ignored) { + } + return out; + } + + private byte[] encodeBodyFromJsonChunksForSchema(byte[] body, Descriptor type) throws Exception { + String s = new String(body, StandardCharsets.UTF_8); + List objects = splitTopLevelJsonObjects(s); + if (objects.isEmpty() && !s.trim().isEmpty()) { + objects = Collections.singletonList(s); + } + ByteArrayOutputStream rawStream = new ByteArrayOutputStream(); + for (String json : objects) { + String trimmed = json.trim(); + if (trimmed.isEmpty()) { + continue; + } + byte[] data = encodeOneJsonToBinary(trimmed, type); + writeGrpcFrame(rawStream, data); + } + return rawStream.toByteArray(); + } + + private byte[] encodeOneJsonToBinary(String json, Descriptor type) throws Exception { + try { + DynamicMessage.Builder builder = DynamicMessage.newBuilder(type); + JSON_PARSER.merge(json, builder); + return builder.build().toByteArray(); + } catch (Exception e) { + return Protobuf3.encode(json); + } } - public byte[] encodeGrpcServerPayload(byte[] payload) throws Exception { - return payload; + private void writeGrpcFrame(ByteArrayOutputStream rawStream, byte[] payload) throws Exception { + rawStream.write(0); + rawStream.write(ByteBuffer.allocate(4).putInt(payload.length).array()); + rawStream.write(payload); } } diff --git a/src/main/java/core/packetproxy/encode/EncodeGRPCStreaming.java b/src/main/java/core/packetproxy/encode/EncodeGRPCStreaming.java index 0c47e519..4d204ea0 100644 --- a/src/main/java/core/packetproxy/encode/EncodeGRPCStreaming.java +++ b/src/main/java/core/packetproxy/encode/EncodeGRPCStreaming.java @@ -15,22 +15,53 @@ */ package packetproxy.encode; +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.JsonToken; +import com.google.protobuf.Descriptors.Descriptor; +import com.google.protobuf.DynamicMessage; +import com.google.protobuf.util.JsonFormat; +import java.io.ByteArrayOutputStream; import java.io.File; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; import java.util.concurrent.ConcurrentHashMap; -import packetproxy.grpc.GrpcProtoWireFormat; +import org.apache.commons.lang3.ArrayUtils; +import packetproxy.common.Protobuf3; +import packetproxy.common.Utils; +import packetproxy.grpc.GrpcServiceRegistry; +import packetproxy.grpc.GrpcServiceRegistryStore; import packetproxy.http.Http; import packetproxy.http2.GrpcStreaming; +import packetproxy.util.Logging; // gRPCでデータフレーム1つずつをメッセージと解釈して送受信するエンコーダ public class EncodeGRPCStreaming extends EncodeHTTPBase { - private volatile GrpcProtoWireFormat wireFormat = GrpcProtoWireFormat.create(); + private static final JsonFormat.Printer JSON_PRINTER = + JsonFormat.printer().preservingProtoFieldNames().alwaysPrintFieldsWithNoPresence(); + + private static final JsonFormat.Parser JSON_PARSER = JsonFormat.parser().ignoringUnknownFields(); + + private volatile GrpcServiceRegistry registry; private volatile String lastGrpcPath; private final ConcurrentHashMap grpcPathByStreamId = new ConcurrentHashMap<>(); public synchronized void setDescriptorFile(File descFile) { grpcPathByStreamId.clear(); - this.wireFormat = GrpcProtoWireFormat.create(descFile); + if (descFile == null || !descFile.isFile()) { + registry = null; + return; + } + try { + registry = GrpcServiceRegistryStore.getInstance().get(descFile); + } catch (Exception e) { + Logging.errWithStackTrace(e); + registry = null; + } } private String resolveGrpcPathClient(Http http) { @@ -89,16 +120,16 @@ public String getName() { @Override protected Http decodeClientRequestHttp(Http inputHttp) throws Exception { lastGrpcPath = resolveGrpcPathClient(inputHttp); - byte[] raw = inputHttp.getBody(); - inputHttp.setBody(wireFormat.decodeBody(raw, true, lastGrpcPath, null)); + Descriptor type = getInputType(lastGrpcPath); + inputHttp.setBody(decodeLengthPrefixedBody(inputHttp.getBody(), type)); return inputHttp; } @Override protected Http encodeClientRequestHttp(Http inputHttp) throws Exception { lastGrpcPath = resolveGrpcPathClient(inputHttp); - byte[] body = inputHttp.getBody(); - inputHttp.setBody(wireFormat.encodeRequestBody(body, lastGrpcPath)); + Descriptor type = getInputType(lastGrpcPath); + inputHttp.setBody(encodeLengthPrefixedFromUtf8Json(inputHttp.getBody(), type)); return inputHttp; } @@ -109,7 +140,8 @@ protected Http decodeServerResponseHttp(Http inputHttp) throws Exception { return inputHttp; } lastGrpcPath = resolveGrpcPathServer(inputHttp); - inputHttp.setBody(wireFormat.decodeBody(raw, false, null, lastGrpcPath)); + Descriptor type = getOutputType(lastGrpcPath); + inputHttp.setBody(decodeLengthPrefixedBody(raw, type)); return inputHttp; } @@ -120,23 +152,168 @@ protected Http encodeServerResponseHttp(Http inputHttp) throws Exception { return inputHttp; } lastGrpcPath = resolveGrpcPathServer(inputHttp); - inputHttp.setBody(wireFormat.encodeResponseBody(body, lastGrpcPath)); + Descriptor type = getOutputType(lastGrpcPath); + inputHttp.setBody(encodeLengthPrefixedFromUtf8Json(body, type)); return inputHttp; } - public byte[] decodeGrpcClientPayload(byte[] payload) throws Exception { - return payload; + private Descriptor getInputType(String grpcPath) { + if (registry == null) { + return null; + } + return registry.getInputType(grpcPath); + } + + private Descriptor getOutputType(String lastRequestGrpcPath) { + if (registry == null) { + return null; + } + return registry.getOutputType(lastRequestGrpcPath); + } + + private byte[] decodeLengthPrefixedBody(byte[] raw, Descriptor type) throws Exception { + if (type == null) { + return decodeSchemalessGrpcBody(raw); + } + ByteArrayOutputStream body = new ByteArrayOutputStream(); + int pos = 0; + while (pos < raw.length) { + byte compressedFlag = raw[pos]; + if (compressedFlag != 0) { + throw new Exception("gRPC: compressed flag in gRPC message is not supported yet"); + } + pos += 1; + int messageLength = ByteBuffer.wrap(Arrays.copyOfRange(raw, pos, pos + 4)).getInt(); + pos += 4; + byte[] grpcMsg = Arrays.copyOfRange(raw, pos, pos + messageLength); + pos += messageLength; + if (body.size() > 0) { + body.write('\n'); + } + body.write(decodeOnePayloadToUtf8String(grpcMsg, type).getBytes(StandardCharsets.UTF_8)); + } + return body.toByteArray(); + } + + private byte[] decodeSchemalessGrpcBody(byte[] raw) throws Exception { + ByteArrayOutputStream body = new ByteArrayOutputStream(); + int pos = 0; + while (pos < raw.length) { + byte compressedFlag = raw[pos]; + if (compressedFlag != 0) { + throw new Exception("gRPC: compressed flag in gRPC message is not supported yet"); + } + pos += 1; + int messageLength = ByteBuffer.wrap(Arrays.copyOfRange(raw, pos, pos + 4)).getInt(); + pos += 4; + byte[] grpcMsg = Arrays.copyOfRange(raw, pos, pos + messageLength); + pos += messageLength; + if (body.size() > 0) { + body.write('\n'); + } + body.write(Protobuf3.decode(grpcMsg).getBytes(StandardCharsets.UTF_8)); + } + return body.toByteArray(); + } + + private String decodeOnePayloadToUtf8String(byte[] payload, Descriptor type) throws Exception { + try { + DynamicMessage msg = DynamicMessage.parseFrom(type, payload); + return JSON_PRINTER.print(msg); + } catch (Exception e) { + return Protobuf3.decode(payload); + } + } + + private byte[] encodeLengthPrefixedFromUtf8Json(byte[] body, Descriptor type) throws Exception { + if (type == null) { + return encodeSchemalessGrpcBody(body); + } + return encodeBodyFromJsonChunksForSchema(body, type); } - public byte[] encodeGrpcClientPayload(byte[] payload) throws Exception { - return payload; + private byte[] encodeSchemalessGrpcBody(byte[] body) throws Exception { + if (body.length == 0) { + return body; + } + ByteArrayOutputStream rawStream = new ByteArrayOutputStream(); + int pos = 0; + while (pos < body.length) { + byte[] subBody; + int idx = Utils.indexOf(body, pos, body.length, "\n}".getBytes(StandardCharsets.UTF_8)); + if (idx > 0) { + subBody = ArrayUtils.subarray(body, pos, idx + 2); + pos = idx + 2; + } else { + subBody = ArrayUtils.subarray(body, pos, body.length); + pos = body.length; + } + String msg = new String(subBody, StandardCharsets.UTF_8); + byte[] data = Protobuf3.encode(msg); + writeGrpcFrame(rawStream, data); + } + return rawStream.toByteArray(); } - public byte[] decodeGrpcServerPayload(byte[] payload) throws Exception { - return payload; + private List splitTopLevelJsonObjects(String text) { + if (text == null || text.isEmpty()) { + return Collections.emptyList(); + } + List out = new ArrayList<>(); + JsonFactory factory = new JsonFactory(); + try (com.fasterxml.jackson.core.JsonParser p = factory.createParser(text)) { + int depth = 0; + int start = -1; + while (p.nextToken() != null) { + if (p.currentToken() == JsonToken.START_OBJECT) { + if (depth == 0) { + start = (int) p.getCurrentLocation().getCharOffset(); + } + depth++; + } else if (p.currentToken() == JsonToken.END_OBJECT) { + depth--; + if (depth == 0 && start >= 0) { + int end = (int) p.getCurrentLocation().getCharOffset() + 1; + out.add(text.substring(start, end)); + } + } + } + } catch (Exception ignored) { + } + return out; + } + + private byte[] encodeBodyFromJsonChunksForSchema(byte[] body, Descriptor type) throws Exception { + String s = new String(body, StandardCharsets.UTF_8); + List objects = splitTopLevelJsonObjects(s); + if (objects.isEmpty() && !s.trim().isEmpty()) { + objects = Collections.singletonList(s); + } + ByteArrayOutputStream rawStream = new ByteArrayOutputStream(); + for (String json : objects) { + String trimmed = json.trim(); + if (trimmed.isEmpty()) { + continue; + } + byte[] data = encodeOneJsonToBinary(trimmed, type); + writeGrpcFrame(rawStream, data); + } + return rawStream.toByteArray(); + } + + private byte[] encodeOneJsonToBinary(String json, Descriptor type) throws Exception { + try { + DynamicMessage.Builder builder = DynamicMessage.newBuilder(type); + JSON_PARSER.merge(json, builder); + return builder.build().toByteArray(); + } catch (Exception e) { + return Protobuf3.encode(json); + } } - public byte[] encodeGrpcServerPayload(byte[] payload) throws Exception { - return payload; + private void writeGrpcFrame(ByteArrayOutputStream rawStream, byte[] payload) throws Exception { + rawStream.write(0); + rawStream.write(ByteBuffer.allocate(4).putInt(payload.length).array()); + rawStream.write(payload); } } diff --git a/src/main/kotlin/core/packetproxy/grpc/GrpcProtoWireFormat.kt b/src/main/kotlin/core/packetproxy/grpc/GrpcProtoWireFormat.kt deleted file mode 100644 index f678be79..00000000 --- a/src/main/kotlin/core/packetproxy/grpc/GrpcProtoWireFormat.kt +++ /dev/null @@ -1,243 +0,0 @@ -/* - * Copyright 2026 DeNA Co., Ltd. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package packetproxy.grpc - -import com.fasterxml.jackson.core.JsonFactory -import com.fasterxml.jackson.core.JsonToken -import com.google.protobuf.Descriptors.Descriptor -import com.google.protobuf.DynamicMessage -import com.google.protobuf.util.JsonFormat -import java.io.ByteArrayOutputStream -import java.io.File -import java.nio.ByteBuffer -import java.nio.charset.StandardCharsets -import java.util.ArrayList -import java.util.Arrays -import java.util.Collections -import org.apache.commons.lang3.ArrayUtils -import packetproxy.common.Protobuf3 -import packetproxy.common.Utils -import packetproxy.util.Logging - -/** - * gRPC length-prefixed bodies to/from UTF-8 JSON using a [GrpcServiceRegistry] loaded from a - * `.desc` file, with schema-less fallback. - */ -class GrpcProtoWireFormat private constructor(private val registry: GrpcServiceRegistry?) { - - @Throws(Exception::class) - fun decodeBody( - raw: ByteArray, - isRequest: Boolean, - grpcPath: String?, - lastRequestGrpcPath: String?, - ): ByteArray { - if (registry == null) { - return decodeSchemalessGrpcBody(raw) - } - val type = - if (isRequest) registry.getInputType(grpcPath) - else registry.getOutputType(lastRequestGrpcPath) - if (type == null) { - return decodeSchemalessGrpcBody(raw) - } - val body = ByteArrayOutputStream() - var pos = 0 - while (pos < raw.size) { - val compressedFlag = raw[pos] - if (compressedFlag.toInt() != 0) { - throw Exception("gRPC: compressed flag in gRPC message is not supported yet") - } - pos += 1 - val messageLength = ByteBuffer.wrap(Arrays.copyOfRange(raw, pos, pos + 4)).int - pos += 4 - val grpcMsg = Arrays.copyOfRange(raw, pos, pos + messageLength) - pos += messageLength - if (body.size() > 0) { - body.write('\n'.code) - } - body.write(decodeOnePayload(grpcMsg, type).toByteArray(StandardCharsets.UTF_8)) - } - return body.toByteArray() - } - - @Throws(Exception::class) - private fun decodeSchemalessGrpcBody(raw: ByteArray): ByteArray { - val body = ByteArrayOutputStream() - var pos = 0 - while (pos < raw.size) { - val compressedFlag = raw[pos] - if (compressedFlag.toInt() != 0) { - throw Exception("gRPC: compressed flag in gRPC message is not supported yet") - } - pos += 1 - val messageLength = ByteBuffer.wrap(Arrays.copyOfRange(raw, pos, pos + 4)).int - pos += 4 - val grpcMsg = Arrays.copyOfRange(raw, pos, pos + messageLength) - pos += messageLength - if (body.size() > 0) { - body.write('\n'.code) - } - body.write(Protobuf3.decode(grpcMsg).toByteArray(StandardCharsets.UTF_8)) - } - return body.toByteArray() - } - - @Throws(Exception::class) - private fun decodeOnePayload(payload: ByteArray, type: Descriptor): String { - return try { - val msg = DynamicMessage.parseFrom(type, payload) - JSON_PRINTER.print(msg) - } catch (_: Exception) { - Protobuf3.decode(payload) - } - } - - @Throws(Exception::class) - fun encodeRequestBody(body: ByteArray, grpcPath: String?): ByteArray { - if (registry == null) { - return encodeSchemalessGrpcBody(body) - } - val type = registry.getInputType(grpcPath) ?: return encodeSchemalessGrpcBody(body) - return encodeGrpcBodyFromJsonChunks(body, type) - } - - @Throws(Exception::class) - fun encodeResponseBody(body: ByteArray, lastRequestGrpcPath: String?): ByteArray { - if (body.isEmpty()) { - return body - } - if (registry == null) { - return encodeSchemalessGrpcBody(body) - } - val type = registry.getOutputType(lastRequestGrpcPath) ?: return encodeSchemalessGrpcBody(body) - return encodeGrpcBodyFromJsonChunks(body, type) - } - - @Throws(Exception::class) - private fun encodeSchemalessGrpcBody(body: ByteArray): ByteArray { - if (body.isEmpty()) { - return body - } - val rawStream = ByteArrayOutputStream() - var pos = 0 - while (pos < body.size) { - val subBody: ByteArray - val idx = Utils.indexOf(body, pos, body.size, "\n}".toByteArray(StandardCharsets.UTF_8)) - if (idx > 0) { - subBody = ArrayUtils.subarray(body, pos, idx + 2) - pos = idx + 2 - } else { - subBody = ArrayUtils.subarray(body, pos, body.size) - pos = body.size - } - val msg = String(subBody, StandardCharsets.UTF_8) - val data = Protobuf3.encode(msg) - writeGrpcFrame(rawStream, data) - } - return rawStream.toByteArray() - } - - /** - * Splits a UTF-8 body into top-level JSON objects. Uses Jackson's tokenizer so `\n` inside string - * values does not spuriously split (unlike the `"\n}"` heuristic in [encodeSchemalessGrpcBody]). - */ - private fun splitTopLevelJsonObjects(text: String?): List { - if (text.isNullOrEmpty()) { - return Collections.emptyList() - } - val out = ArrayList() - val factory = JsonFactory() - try { - factory.createParser(text).use { p -> - var depth = 0 - var start = -1 - while (p.nextToken() != null) { - if (p.currentToken() == JsonToken.START_OBJECT) { - if (depth == 0) { - start = p.currentLocation.charOffset.toInt() - } - depth++ - } else if (p.currentToken() == JsonToken.END_OBJECT) { - depth-- - if (depth == 0 && start >= 0) { - val end = p.currentLocation.charOffset.toInt() + 1 - out.add(text.substring(start, end)) - } - } - } - } - } catch (_: Exception) {} - return out - } - - @Throws(Exception::class) - private fun encodeGrpcBodyFromJsonChunks(body: ByteArray, type: Descriptor): ByteArray { - val s = String(body, StandardCharsets.UTF_8) - var objects = splitTopLevelJsonObjects(s) - if (objects.isEmpty() && s.trim().isNotEmpty()) { - objects = Collections.singletonList(s) - } - val rawStream = ByteArrayOutputStream() - for (json in objects) { - val trimmed = json.trim() - if (trimmed.isEmpty()) continue - val data = encodeOneJsonToBinary(trimmed, type) - writeGrpcFrame(rawStream, data) - } - return rawStream.toByteArray() - } - - @Throws(Exception::class) - private fun encodeOneJsonToBinary(json: String, type: Descriptor): ByteArray { - return try { - val builder = DynamicMessage.newBuilder(type) - JSON_PARSER.merge(json, builder) - builder.build().toByteArray() - } catch (_: Exception) { - Protobuf3.encode(json) - } - } - - @Throws(Exception::class) - private fun writeGrpcFrame(rawStream: ByteArrayOutputStream, payload: ByteArray) { - rawStream.write(0) - rawStream.write(ByteBuffer.allocate(4).putInt(payload.size).array()) - rawStream.write(payload) - } - - companion object { - private val JSON_PRINTER = - JsonFormat.printer().preservingProtoFieldNames().alwaysPrintFieldsWithNoPresence() - - private val JSON_PARSER = JsonFormat.parser().ignoringUnknownFields() - - @JvmStatic - @JvmOverloads - fun create(descFile: File? = null): GrpcProtoWireFormat { - if (descFile == null || !descFile.isFile) { - return GrpcProtoWireFormat(null) - } - return try { - val reg = GrpcServiceRegistryStore.getInstance().get(descFile) - GrpcProtoWireFormat(reg) - } catch (e: Exception) { - Logging.errWithStackTrace(e) - GrpcProtoWireFormat(null) - } - } - } -} diff --git a/src/test/kotlin/packetproxy/grpc/GrpcProtoWireFormatTest.kt b/src/test/kotlin/packetproxy/grpc/GrpcProtoWireFormatTest.kt deleted file mode 100644 index 913ad1f4..00000000 --- a/src/test/kotlin/packetproxy/grpc/GrpcProtoWireFormatTest.kt +++ /dev/null @@ -1,63 +0,0 @@ -/* - * Copyright 2026 DeNA Co., Ltd. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package packetproxy.grpc - -import java.io.File -import java.nio.charset.StandardCharsets -import org.junit.jupiter.api.Assertions.assertArrayEquals -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.Test - -class GrpcProtoWireFormatTest { - private fun resource(classpathPath: String): File { - val u = - GrpcProtoWireFormatTest::class.java.getResource(classpathPath) - ?: throw IllegalStateException("missing resource: $classpathPath") - return File(u.toURI()) - } - - private fun wireFormat(): GrpcProtoWireFormat = - GrpcProtoWireFormat.create(resource("proto/testsvc.desc")) - - @Test - fun decodeThenEncode_request_roundtrip() { - val wireFormat = wireFormat() - val grpcPath = "/pp.testsvc.Greeter/SayHello" - val json = "{\n \"name\": \"Alice\"\n}" - val encodedOnce = - wireFormat.encodeRequestBody(json.toByteArray(StandardCharsets.UTF_8), grpcPath) - val utf8 = wireFormat.decodeBody(encodedOnce, true, grpcPath, null) - val encodedTwice = wireFormat.encodeRequestBody(utf8, grpcPath) - val decoded = String(utf8, StandardCharsets.UTF_8) - assertTrue(decoded.contains("\"name\"")) - assertTrue(decoded.contains("Alice")) - assertArrayEquals(encodedOnce, encodedTwice) - } - - @Test - fun decodeThenEncode_response_roundtrip() { - val wireFormat = wireFormat() - val lastRequestPath = "/pp.testsvc.Greeter/SayHello" - val json = "{\n \"message\": \"Hello\"\n}" - val encodedOnce = - wireFormat.encodeResponseBody(json.toByteArray(StandardCharsets.UTF_8), lastRequestPath) - val utf8 = wireFormat.decodeBody(encodedOnce, false, null, lastRequestPath) - val encodedTwice = wireFormat.encodeResponseBody(utf8, lastRequestPath) - val decoded = String(utf8, StandardCharsets.UTF_8) - assertTrue(decoded.contains("message") || decoded.contains("Hello")) - assertArrayEquals(encodedOnce, encodedTwice) - } -} From 2c49ad1f09fe386a09d9eae2b784346a67c247b3 Mon Sep 17 00:00:00 2001 From: taka2233 Date: Mon, 27 Apr 2026 14:39:44 +0900 Subject: [PATCH 15/22] refactor: enhance gRPC message handling in EncodeGRPC and EncodeGRPCStreaming by implementing schema-aware body decoding and encoding, while maintaining compatibility with existing functionality --- .../core/packetproxy/encode/EncodeGRPC.java | 267 ++++++++++-------- .../encode/EncodeGRPCStreaming.java | 267 ++++++++++-------- 2 files changed, 312 insertions(+), 222 deletions(-) diff --git a/src/main/java/core/packetproxy/encode/EncodeGRPC.java b/src/main/java/core/packetproxy/encode/EncodeGRPC.java index 7610f4f7..3dc873aa 100644 --- a/src/main/java/core/packetproxy/encode/EncodeGRPC.java +++ b/src/main/java/core/packetproxy/encode/EncodeGRPC.java @@ -41,9 +41,9 @@ public class EncodeGRPC extends EncodeHTTPBase { private static final JsonFormat.Printer JSON_PRINTER = JsonFormat.printer().preservingProtoFieldNames().alwaysPrintFieldsWithNoPresence(); - private static final JsonFormat.Parser JSON_PARSER = JsonFormat.parser().ignoringUnknownFields(); + private byte compressedFlag; private volatile GrpcServiceRegistry registry; private volatile String lastGrpcPath; @@ -76,16 +76,70 @@ public String getName() { @Override protected Http decodeClientRequestHttp(Http inputHttp) throws Exception { lastGrpcPath = inputHttp.getPath(); - Descriptor type = getInputType(lastGrpcPath); - inputHttp.setBody(decodeLengthPrefixedBody(inputHttp.getBody(), type)); + Descriptor type = registry != null ? registry.getInputType(lastGrpcPath) : null; + if (type != null) { + inputHttp.setBody(decodeSchemaAwareBody(inputHttp.getBody(), type)); + return inputHttp; + } + byte[] raw = inputHttp.getBody(); + ByteArrayOutputStream body = new ByteArrayOutputStream(); + int pos = 0; + while (pos < raw.length) { + + compressedFlag = raw[pos]; + if (compressedFlag != 0) { + + throw new Exception("gRPC: compressed flag in gRPC message is not supported yet"); + } + pos += 1; + int messageLength = ByteBuffer.wrap(Arrays.copyOfRange(raw, pos, pos + 4)).getInt(); + pos += 4; + byte[] grpcMsg = Arrays.copyOfRange(raw, pos, pos + messageLength); + byte[] decodedMsg = decodeGrpcClientPayload(grpcMsg); + if (body.size() > 0) { + + body.write("\n".getBytes()); + } + body.write(Protobuf3.decode(decodedMsg).getBytes(StandardCharsets.UTF_8)); + pos += messageLength; + } + inputHttp.setBody(body.toByteArray()); return inputHttp; } @Override protected Http encodeClientRequestHttp(Http inputHttp) throws Exception { lastGrpcPath = inputHttp.getPath(); - Descriptor type = getInputType(lastGrpcPath); - inputHttp.setBody(encodeLengthPrefixedFromUtf8Json(inputHttp.getBody(), type)); + Descriptor type = registry != null ? registry.getInputType(lastGrpcPath) : null; + if (type != null) { + inputHttp.setBody(encodeSchemaAwareBody(inputHttp.getBody(), type)); + return inputHttp; + } + byte[] body = inputHttp.getBody(); + ByteArrayOutputStream rawStream = new ByteArrayOutputStream(); + int pos = 0; + while (pos < body.length) { + + byte[] subBody; + int idx; + if ((idx = Utils.indexOf(body, pos, body.length, "\n}".getBytes())) > 0) { // split into gRPC messages + + subBody = ArrayUtils.subarray(body, pos, idx + 2); + pos = idx + 2; + } else { + + subBody = ArrayUtils.subarray(body, pos, body.length); + pos = body.length; + } + String msg = new String(subBody, StandardCharsets.UTF_8); + byte[] data = Protobuf3.encode(msg); + byte[] encodedData = encodeGrpcClientPayload(data); + int encodedDataLen = encodedData.length; + rawStream.write((byte) 0); // always compressed flag is zero + rawStream.write(ByteBuffer.allocate(4).putInt(encodedDataLen).array()); + rawStream.write(encodedData); + } + inputHttp.setBody(rawStream.toByteArray()); return inputHttp; } @@ -93,10 +147,36 @@ protected Http encodeClientRequestHttp(Http inputHttp) throws Exception { protected Http decodeServerResponseHttp(Http inputHttp) throws Exception { byte[] raw = inputHttp.getBody(); if (raw.length == 0) { + + return inputHttp; + } + Descriptor type = registry != null ? registry.getOutputType(lastGrpcPath) : null; + if (type != null) { + inputHttp.setBody(decodeSchemaAwareBody(raw, type)); return inputHttp; } - Descriptor type = getOutputType(lastGrpcPath); - inputHttp.setBody(decodeLengthPrefixedBody(raw, type)); + ByteArrayOutputStream body = new ByteArrayOutputStream(); + int pos = 0; + while (pos < raw.length) { + + compressedFlag = raw[pos]; + if (compressedFlag != 0) { + + throw new Exception("gRPC: compressed flag in gRPC message is not supported yet"); + } + pos += 1; + int messageLength = ByteBuffer.wrap(Arrays.copyOfRange(raw, pos, pos + 4)).getInt(); + pos += 4; + byte[] grpcMsg = Arrays.copyOfRange(raw, pos, pos + messageLength); + byte[] decodedMsg = decodeGrpcServerPayload(grpcMsg); + if (body.size() > 0) { + + body.write("\n".getBytes()); + } + body.write(Protobuf3.decode(decodedMsg).getBytes(StandardCharsets.UTF_8)); + pos += messageLength; + } + inputHttp.setBody(body.toByteArray()); return inputHttp; } @@ -104,57 +184,62 @@ protected Http decodeServerResponseHttp(Http inputHttp) throws Exception { protected Http encodeServerResponseHttp(Http inputHttp) throws Exception { byte[] body = inputHttp.getBody(); if (body.length == 0) { + + return inputHttp; + } + Descriptor type = registry != null ? registry.getOutputType(lastGrpcPath) : null; + if (type != null) { + inputHttp.setBody(encodeSchemaAwareBody(body, type)); return inputHttp; } - Descriptor type = getOutputType(lastGrpcPath); - inputHttp.setBody(encodeLengthPrefixedFromUtf8Json(body, type)); + ByteArrayOutputStream rawStream = new ByteArrayOutputStream(); + int pos = 0; + while (pos < body.length) { + + byte[] subBody; + int idx; + if ((idx = Utils.indexOf(body, pos, body.length, "\n}".getBytes())) > 0) { // split into gRPC messages + + subBody = ArrayUtils.subarray(body, pos, idx + 2); + pos = idx + 2; + } else { + + subBody = ArrayUtils.subarray(body, pos, body.length); + pos = body.length; + } + String msg = new String(subBody, StandardCharsets.UTF_8); + byte[] data = Protobuf3.encode(msg); + byte[] encodedData = encodeGrpcServerPayload(data); + int encodedDataLen = encodedData.length; + rawStream.write((byte) 0); // always compressed flag is zero + rawStream.write(ByteBuffer.allocate(4).putInt(encodedDataLen).array()); + rawStream.write(encodedData); + } + inputHttp.setBody(rawStream.toByteArray()); return inputHttp; } - private Descriptor getInputType(String grpcPath) { - if (registry == null) { - return null; - } - return registry.getInputType(grpcPath); + public byte[] decodeGrpcClientPayload(byte[] payload) throws Exception { + return payload; } - private Descriptor getOutputType(String lastRequestGrpcPath) { - if (registry == null) { - return null; - } - return registry.getOutputType(lastRequestGrpcPath); + public byte[] encodeGrpcClientPayload(byte[] payload) throws Exception { + return payload; } - private byte[] decodeLengthPrefixedBody(byte[] raw, Descriptor type) throws Exception { - if (type == null) { - return decodeSchemalessGrpcBody(raw); - } - ByteArrayOutputStream body = new ByteArrayOutputStream(); - int pos = 0; - while (pos < raw.length) { - byte compressedFlag = raw[pos]; - if (compressedFlag != 0) { - throw new Exception("gRPC: compressed flag in gRPC message is not supported yet"); - } - pos += 1; - int messageLength = ByteBuffer.wrap(Arrays.copyOfRange(raw, pos, pos + 4)).getInt(); - pos += 4; - byte[] grpcMsg = Arrays.copyOfRange(raw, pos, pos + messageLength); - pos += messageLength; - if (body.size() > 0) { - body.write('\n'); - } - body.write(decodeOnePayloadToUtf8String(grpcMsg, type).getBytes(StandardCharsets.UTF_8)); - } - return body.toByteArray(); + public byte[] decodeGrpcServerPayload(byte[] payload) throws Exception { + return payload; + } + + public byte[] encodeGrpcServerPayload(byte[] payload) throws Exception { + return payload; } - private byte[] decodeSchemalessGrpcBody(byte[] raw) throws Exception { + private byte[] decodeSchemaAwareBody(byte[] raw, Descriptor type) throws Exception { ByteArrayOutputStream body = new ByteArrayOutputStream(); int pos = 0; while (pos < raw.length) { - byte compressedFlag = raw[pos]; - if (compressedFlag != 0) { + if (raw[pos] != 0) { throw new Exception("gRPC: compressed flag in gRPC message is not supported yet"); } pos += 1; @@ -165,46 +250,40 @@ private byte[] decodeSchemalessGrpcBody(byte[] raw) throws Exception { if (body.size() > 0) { body.write('\n'); } - body.write(Protobuf3.decode(grpcMsg).getBytes(StandardCharsets.UTF_8)); + String json; + try { + json = JSON_PRINTER.print(DynamicMessage.parseFrom(type, grpcMsg)); + } catch (Exception e) { + json = Protobuf3.decode(grpcMsg); + } + body.write(json.getBytes(StandardCharsets.UTF_8)); } return body.toByteArray(); } - private String decodeOnePayloadToUtf8String(byte[] payload, Descriptor type) throws Exception { - try { - DynamicMessage msg = DynamicMessage.parseFrom(type, payload); - return JSON_PRINTER.print(msg); - } catch (Exception e) { - return Protobuf3.decode(payload); - } - } - - private byte[] encodeLengthPrefixedFromUtf8Json(byte[] body, Descriptor type) throws Exception { - if (type == null) { - return encodeSchemalessGrpcBody(body); - } - return encodeBodyFromJsonChunksForSchema(body, type); - } - - private byte[] encodeSchemalessGrpcBody(byte[] body) throws Exception { - if (body.length == 0) { - return body; + private byte[] encodeSchemaAwareBody(byte[] body, Descriptor type) throws Exception { + String s = new String(body, StandardCharsets.UTF_8); + List objects = splitTopLevelJsonObjects(s); + if (objects.isEmpty() && !s.trim().isEmpty()) { + objects = Collections.singletonList(s); } ByteArrayOutputStream rawStream = new ByteArrayOutputStream(); - int pos = 0; - while (pos < body.length) { - byte[] subBody; - int idx = Utils.indexOf(body, pos, body.length, "\n}".getBytes(StandardCharsets.UTF_8)); - if (idx > 0) { - subBody = ArrayUtils.subarray(body, pos, idx + 2); - pos = idx + 2; - } else { - subBody = ArrayUtils.subarray(body, pos, body.length); - pos = body.length; + for (String json : objects) { + String trimmed = json.trim(); + if (trimmed.isEmpty()) { + continue; } - String msg = new String(subBody, StandardCharsets.UTF_8); - byte[] data = Protobuf3.encode(msg); - writeGrpcFrame(rawStream, data); + byte[] data; + try { + DynamicMessage.Builder builder = DynamicMessage.newBuilder(type); + JSON_PARSER.merge(trimmed, builder); + data = builder.build().toByteArray(); + } catch (Exception e) { + data = Protobuf3.encode(trimmed); + } + rawStream.write(0); + rawStream.write(ByteBuffer.allocate(4).putInt(data.length).array()); + rawStream.write(data); } return rawStream.toByteArray(); } @@ -236,38 +315,4 @@ private List splitTopLevelJsonObjects(String text) { } return out; } - - private byte[] encodeBodyFromJsonChunksForSchema(byte[] body, Descriptor type) throws Exception { - String s = new String(body, StandardCharsets.UTF_8); - List objects = splitTopLevelJsonObjects(s); - if (objects.isEmpty() && !s.trim().isEmpty()) { - objects = Collections.singletonList(s); - } - ByteArrayOutputStream rawStream = new ByteArrayOutputStream(); - for (String json : objects) { - String trimmed = json.trim(); - if (trimmed.isEmpty()) { - continue; - } - byte[] data = encodeOneJsonToBinary(trimmed, type); - writeGrpcFrame(rawStream, data); - } - return rawStream.toByteArray(); - } - - private byte[] encodeOneJsonToBinary(String json, Descriptor type) throws Exception { - try { - DynamicMessage.Builder builder = DynamicMessage.newBuilder(type); - JSON_PARSER.merge(json, builder); - return builder.build().toByteArray(); - } catch (Exception e) { - return Protobuf3.encode(json); - } - } - - private void writeGrpcFrame(ByteArrayOutputStream rawStream, byte[] payload) throws Exception { - rawStream.write(0); - rawStream.write(ByteBuffer.allocate(4).putInt(payload.length).array()); - rawStream.write(payload); - } } diff --git a/src/main/java/core/packetproxy/encode/EncodeGRPCStreaming.java b/src/main/java/core/packetproxy/encode/EncodeGRPCStreaming.java index 4d204ea0..fbe34287 100644 --- a/src/main/java/core/packetproxy/encode/EncodeGRPCStreaming.java +++ b/src/main/java/core/packetproxy/encode/EncodeGRPCStreaming.java @@ -43,9 +43,9 @@ public class EncodeGRPCStreaming extends EncodeHTTPBase { private static final JsonFormat.Printer JSON_PRINTER = JsonFormat.printer().preservingProtoFieldNames().alwaysPrintFieldsWithNoPresence(); - private static final JsonFormat.Parser JSON_PARSER = JsonFormat.parser().ignoringUnknownFields(); + private byte compressedFlag; private volatile GrpcServiceRegistry registry; private volatile String lastGrpcPath; private final ConcurrentHashMap grpcPathByStreamId = new ConcurrentHashMap<>(); @@ -120,16 +120,70 @@ public String getName() { @Override protected Http decodeClientRequestHttp(Http inputHttp) throws Exception { lastGrpcPath = resolveGrpcPathClient(inputHttp); - Descriptor type = getInputType(lastGrpcPath); - inputHttp.setBody(decodeLengthPrefixedBody(inputHttp.getBody(), type)); + Descriptor type = registry != null ? registry.getInputType(lastGrpcPath) : null; + if (type != null) { + inputHttp.setBody(decodeSchemaAwareBody(inputHttp.getBody(), type)); + return inputHttp; + } + byte[] raw = inputHttp.getBody(); + ByteArrayOutputStream body = new ByteArrayOutputStream(); + int pos = 0; + while (pos < raw.length) { + + compressedFlag = raw[pos]; + if (compressedFlag != 0) { + + throw new Exception("gRPC: compressed flag in gRPC message is not supported yet"); + } + pos += 1; + int messageLength = ByteBuffer.wrap(Arrays.copyOfRange(raw, pos, pos + 4)).getInt(); + pos += 4; + byte[] grpcMsg = Arrays.copyOfRange(raw, pos, pos + messageLength); + byte[] decodedMsg = decodeGrpcClientPayload(grpcMsg); + if (body.size() > 0) { + + body.write("\n".getBytes()); + } + body.write(Protobuf3.decode(decodedMsg).getBytes(StandardCharsets.UTF_8)); + pos += messageLength; + } + inputHttp.setBody(body.toByteArray()); return inputHttp; } @Override protected Http encodeClientRequestHttp(Http inputHttp) throws Exception { lastGrpcPath = resolveGrpcPathClient(inputHttp); - Descriptor type = getInputType(lastGrpcPath); - inputHttp.setBody(encodeLengthPrefixedFromUtf8Json(inputHttp.getBody(), type)); + Descriptor type = registry != null ? registry.getInputType(lastGrpcPath) : null; + if (type != null) { + inputHttp.setBody(encodeSchemaAwareBody(inputHttp.getBody(), type)); + return inputHttp; + } + byte[] body = inputHttp.getBody(); + ByteArrayOutputStream rawStream = new ByteArrayOutputStream(); + int pos = 0; + while (pos < body.length) { + + byte[] subBody; + int idx; + if ((idx = Utils.indexOf(body, pos, body.length, "\n}".getBytes())) > 0) { // split into gRPC messages + + subBody = ArrayUtils.subarray(body, pos, idx + 2); + pos = idx + 2; + } else { + + subBody = ArrayUtils.subarray(body, pos, body.length); + pos = body.length; + } + String msg = new String(subBody, StandardCharsets.UTF_8); + byte[] data = Protobuf3.encode(msg); + byte[] encodedData = encodeGrpcClientPayload(data); + int encodedDataLen = encodedData.length; + rawStream.write((byte) 0); // always compressed flag is zero + rawStream.write(ByteBuffer.allocate(4).putInt(encodedDataLen).array()); + rawStream.write(encodedData); + } + inputHttp.setBody(rawStream.toByteArray()); return inputHttp; } @@ -137,11 +191,37 @@ protected Http encodeClientRequestHttp(Http inputHttp) throws Exception { protected Http decodeServerResponseHttp(Http inputHttp) throws Exception { byte[] raw = inputHttp.getBody(); if (raw.length == 0) { + return inputHttp; } lastGrpcPath = resolveGrpcPathServer(inputHttp); - Descriptor type = getOutputType(lastGrpcPath); - inputHttp.setBody(decodeLengthPrefixedBody(raw, type)); + Descriptor type = registry != null ? registry.getOutputType(lastGrpcPath) : null; + if (type != null) { + inputHttp.setBody(decodeSchemaAwareBody(raw, type)); + return inputHttp; + } + ByteArrayOutputStream body = new ByteArrayOutputStream(); + int pos = 0; + while (pos < raw.length) { + + compressedFlag = raw[pos]; + if (compressedFlag != 0) { + + throw new Exception("gRPC: compressed flag in gRPC message is not supported yet"); + } + pos += 1; + int messageLength = ByteBuffer.wrap(Arrays.copyOfRange(raw, pos, pos + 4)).getInt(); + pos += 4; + byte[] grpcMsg = Arrays.copyOfRange(raw, pos, pos + messageLength); + byte[] decodedMsg = decodeGrpcServerPayload(grpcMsg); + if (body.size() > 0) { + + body.write("\n".getBytes()); + } + body.write(Protobuf3.decode(decodedMsg).getBytes(StandardCharsets.UTF_8)); + pos += messageLength; + } + inputHttp.setBody(body.toByteArray()); return inputHttp; } @@ -149,58 +229,63 @@ protected Http decodeServerResponseHttp(Http inputHttp) throws Exception { protected Http encodeServerResponseHttp(Http inputHttp) throws Exception { byte[] body = inputHttp.getBody(); if (body.length == 0) { + return inputHttp; } lastGrpcPath = resolveGrpcPathServer(inputHttp); - Descriptor type = getOutputType(lastGrpcPath); - inputHttp.setBody(encodeLengthPrefixedFromUtf8Json(body, type)); + Descriptor type = registry != null ? registry.getOutputType(lastGrpcPath) : null; + if (type != null) { + inputHttp.setBody(encodeSchemaAwareBody(body, type)); + return inputHttp; + } + ByteArrayOutputStream rawStream = new ByteArrayOutputStream(); + int pos = 0; + while (pos < body.length) { + + byte[] subBody; + int idx; + if ((idx = Utils.indexOf(body, pos, body.length, "\n}".getBytes())) > 0) { // split into gRPC messages + + subBody = ArrayUtils.subarray(body, pos, idx + 2); + pos = idx + 2; + } else { + + subBody = ArrayUtils.subarray(body, pos, body.length); + pos = body.length; + } + String msg = new String(subBody, StandardCharsets.UTF_8); + byte[] data = Protobuf3.encode(msg); + byte[] encodedData = encodeGrpcServerPayload(data); + int encodedDataLen = encodedData.length; + rawStream.write((byte) 0); // always compressed flag is zero + rawStream.write(ByteBuffer.allocate(4).putInt(encodedDataLen).array()); + rawStream.write(encodedData); + } + inputHttp.setBody(rawStream.toByteArray()); return inputHttp; } - private Descriptor getInputType(String grpcPath) { - if (registry == null) { - return null; - } - return registry.getInputType(grpcPath); + public byte[] decodeGrpcClientPayload(byte[] payload) throws Exception { + return payload; } - private Descriptor getOutputType(String lastRequestGrpcPath) { - if (registry == null) { - return null; - } - return registry.getOutputType(lastRequestGrpcPath); + public byte[] encodeGrpcClientPayload(byte[] payload) throws Exception { + return payload; } - private byte[] decodeLengthPrefixedBody(byte[] raw, Descriptor type) throws Exception { - if (type == null) { - return decodeSchemalessGrpcBody(raw); - } - ByteArrayOutputStream body = new ByteArrayOutputStream(); - int pos = 0; - while (pos < raw.length) { - byte compressedFlag = raw[pos]; - if (compressedFlag != 0) { - throw new Exception("gRPC: compressed flag in gRPC message is not supported yet"); - } - pos += 1; - int messageLength = ByteBuffer.wrap(Arrays.copyOfRange(raw, pos, pos + 4)).getInt(); - pos += 4; - byte[] grpcMsg = Arrays.copyOfRange(raw, pos, pos + messageLength); - pos += messageLength; - if (body.size() > 0) { - body.write('\n'); - } - body.write(decodeOnePayloadToUtf8String(grpcMsg, type).getBytes(StandardCharsets.UTF_8)); - } - return body.toByteArray(); + public byte[] decodeGrpcServerPayload(byte[] payload) throws Exception { + return payload; + } + + public byte[] encodeGrpcServerPayload(byte[] payload) throws Exception { + return payload; } - private byte[] decodeSchemalessGrpcBody(byte[] raw) throws Exception { + private byte[] decodeSchemaAwareBody(byte[] raw, Descriptor type) throws Exception { ByteArrayOutputStream body = new ByteArrayOutputStream(); int pos = 0; while (pos < raw.length) { - byte compressedFlag = raw[pos]; - if (compressedFlag != 0) { + if (raw[pos] != 0) { throw new Exception("gRPC: compressed flag in gRPC message is not supported yet"); } pos += 1; @@ -211,46 +296,40 @@ private byte[] decodeSchemalessGrpcBody(byte[] raw) throws Exception { if (body.size() > 0) { body.write('\n'); } - body.write(Protobuf3.decode(grpcMsg).getBytes(StandardCharsets.UTF_8)); + String json; + try { + json = JSON_PRINTER.print(DynamicMessage.parseFrom(type, grpcMsg)); + } catch (Exception e) { + json = Protobuf3.decode(grpcMsg); + } + body.write(json.getBytes(StandardCharsets.UTF_8)); } return body.toByteArray(); } - private String decodeOnePayloadToUtf8String(byte[] payload, Descriptor type) throws Exception { - try { - DynamicMessage msg = DynamicMessage.parseFrom(type, payload); - return JSON_PRINTER.print(msg); - } catch (Exception e) { - return Protobuf3.decode(payload); - } - } - - private byte[] encodeLengthPrefixedFromUtf8Json(byte[] body, Descriptor type) throws Exception { - if (type == null) { - return encodeSchemalessGrpcBody(body); - } - return encodeBodyFromJsonChunksForSchema(body, type); - } - - private byte[] encodeSchemalessGrpcBody(byte[] body) throws Exception { - if (body.length == 0) { - return body; + private byte[] encodeSchemaAwareBody(byte[] body, Descriptor type) throws Exception { + String s = new String(body, StandardCharsets.UTF_8); + List objects = splitTopLevelJsonObjects(s); + if (objects.isEmpty() && !s.trim().isEmpty()) { + objects = Collections.singletonList(s); } ByteArrayOutputStream rawStream = new ByteArrayOutputStream(); - int pos = 0; - while (pos < body.length) { - byte[] subBody; - int idx = Utils.indexOf(body, pos, body.length, "\n}".getBytes(StandardCharsets.UTF_8)); - if (idx > 0) { - subBody = ArrayUtils.subarray(body, pos, idx + 2); - pos = idx + 2; - } else { - subBody = ArrayUtils.subarray(body, pos, body.length); - pos = body.length; + for (String json : objects) { + String trimmed = json.trim(); + if (trimmed.isEmpty()) { + continue; } - String msg = new String(subBody, StandardCharsets.UTF_8); - byte[] data = Protobuf3.encode(msg); - writeGrpcFrame(rawStream, data); + byte[] data; + try { + DynamicMessage.Builder builder = DynamicMessage.newBuilder(type); + JSON_PARSER.merge(trimmed, builder); + data = builder.build().toByteArray(); + } catch (Exception e) { + data = Protobuf3.encode(trimmed); + } + rawStream.write(0); + rawStream.write(ByteBuffer.allocate(4).putInt(data.length).array()); + rawStream.write(data); } return rawStream.toByteArray(); } @@ -282,38 +361,4 @@ private List splitTopLevelJsonObjects(String text) { } return out; } - - private byte[] encodeBodyFromJsonChunksForSchema(byte[] body, Descriptor type) throws Exception { - String s = new String(body, StandardCharsets.UTF_8); - List objects = splitTopLevelJsonObjects(s); - if (objects.isEmpty() && !s.trim().isEmpty()) { - objects = Collections.singletonList(s); - } - ByteArrayOutputStream rawStream = new ByteArrayOutputStream(); - for (String json : objects) { - String trimmed = json.trim(); - if (trimmed.isEmpty()) { - continue; - } - byte[] data = encodeOneJsonToBinary(trimmed, type); - writeGrpcFrame(rawStream, data); - } - return rawStream.toByteArray(); - } - - private byte[] encodeOneJsonToBinary(String json, Descriptor type) throws Exception { - try { - DynamicMessage.Builder builder = DynamicMessage.newBuilder(type); - JSON_PARSER.merge(json, builder); - return builder.build().toByteArray(); - } catch (Exception e) { - return Protobuf3.encode(json); - } - } - - private void writeGrpcFrame(ByteArrayOutputStream rawStream, byte[] payload) throws Exception { - rawStream.write(0); - rawStream.write(ByteBuffer.allocate(4).putInt(payload.length).array()); - rawStream.write(payload); - } } From 2c44524272623d0a9a9b14127893c3b995ca334a Mon Sep 17 00:00:00 2001 From: taka2233 Date: Mon, 27 Apr 2026 14:41:20 +0900 Subject: [PATCH 16/22] refactor: improve code formatting in EncodeGRPC and EncodeGRPCStreaming for better readability --- src/main/java/core/packetproxy/encode/EncodeGRPC.java | 4 ++-- .../java/core/packetproxy/encode/EncodeGRPCStreaming.java | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/java/core/packetproxy/encode/EncodeGRPC.java b/src/main/java/core/packetproxy/encode/EncodeGRPC.java index 3dc873aa..746aa5ba 100644 --- a/src/main/java/core/packetproxy/encode/EncodeGRPC.java +++ b/src/main/java/core/packetproxy/encode/EncodeGRPC.java @@ -39,8 +39,8 @@ public class EncodeGRPC extends EncodeHTTPBase { - private static final JsonFormat.Printer JSON_PRINTER = - JsonFormat.printer().preservingProtoFieldNames().alwaysPrintFieldsWithNoPresence(); + private static final JsonFormat.Printer JSON_PRINTER = JsonFormat.printer().preservingProtoFieldNames() + .alwaysPrintFieldsWithNoPresence(); private static final JsonFormat.Parser JSON_PARSER = JsonFormat.parser().ignoringUnknownFields(); private byte compressedFlag; diff --git a/src/main/java/core/packetproxy/encode/EncodeGRPCStreaming.java b/src/main/java/core/packetproxy/encode/EncodeGRPCStreaming.java index fbe34287..07a55a1e 100644 --- a/src/main/java/core/packetproxy/encode/EncodeGRPCStreaming.java +++ b/src/main/java/core/packetproxy/encode/EncodeGRPCStreaming.java @@ -41,8 +41,8 @@ // gRPCでデータフレーム1つずつをメッセージと解釈して送受信するエンコーダ public class EncodeGRPCStreaming extends EncodeHTTPBase { - private static final JsonFormat.Printer JSON_PRINTER = - JsonFormat.printer().preservingProtoFieldNames().alwaysPrintFieldsWithNoPresence(); + private static final JsonFormat.Printer JSON_PRINTER = JsonFormat.printer().preservingProtoFieldNames() + .alwaysPrintFieldsWithNoPresence(); private static final JsonFormat.Parser JSON_PARSER = JsonFormat.parser().ignoringUnknownFields(); private byte compressedFlag; From 3c6ef58c6bc8b8e05ac289b6218becceb2b1fdb6 Mon Sep 17 00:00:00 2001 From: taka2233 Date: Mon, 27 Apr 2026 15:22:54 +0900 Subject: [PATCH 17/22] feat: enhance gRPC service definitions by adding new message types and methods for improved functionality in svc_a and svc_b --- .../grpc/proto/multidir/common.proto | 27 +++++++++ .../grpc/proto/multidir/multi.desc | Bin 285 -> 2090 bytes .../proto/multidir/multi_without_imports.desc | 53 +++++++++++++++++- .../grpc/proto/multidir/svc_a.proto | 23 ++++++++ .../grpc/proto/multidir/svc_b.proto | 28 ++++++++- .../packetproxy/grpc/proto/testsvc.desc | Bin 187 -> 1333 bytes .../packetproxy/grpc/proto/testsvc.proto | 44 +++++++++++++++ 7 files changed, 171 insertions(+), 4 deletions(-) diff --git a/src/test/resources/packetproxy/grpc/proto/multidir/common.proto b/src/test/resources/packetproxy/grpc/proto/multidir/common.proto index 6b62ac82..71e31b55 100644 --- a/src/test/resources/packetproxy/grpc/proto/multidir/common.proto +++ b/src/test/resources/packetproxy/grpc/proto/multidir/common.proto @@ -2,6 +2,33 @@ syntax = "proto3"; package pp.multidir; +enum Status { + STATUS_UNSPECIFIED = 0; + STATUS_ACTIVE = 1; + STATUS_INACTIVE = 2; + STATUS_DELETED = 3; +} + message Shared { string x = 1; + int32 id = 2; + Status status = 3; + repeated string labels = 4; + Detail detail = 5; + + message Detail { + string description = 1; + int64 created_at = 2; + } +} + +message Timestamp { + int64 seconds = 1; + int32 nanos = 2; +} + +message Pagination { + int32 page = 1; + int32 page_size = 2; + string cursor = 3; } diff --git a/src/test/resources/packetproxy/grpc/proto/multidir/multi.desc b/src/test/resources/packetproxy/grpc/proto/multidir/multi.desc index 3a4c5566ae6e827f765ecbb57ce8a99c4333dd64..2e0aa12d6efafa1d271402b95fd45d142a61a7d1 100644 GIT binary patch literal 2090 zcmbVN&5qMZ5XO_llaw>VP^=&rAx6G1fCwmP1q+wWW`Y!n9Y#)u146Pob~9Kwwz1n0 z+0$xYf-{e>_tiefKEpo5ivDvZ9+E|H>;AgBx~jgfs^O11wEbi@OX6;tB`iTJO}n!q zVqp+w&c6njJlwm0mtaTZ{lR4Pa=2&UWw@xJ(UAtXcu2b% zJRCkBPWav0)RdGzgij_|`P}!&8V%SLc{u+VurrWFbm{wo7Ypx0GoyJ@WWLJRI#0gj z&0QK{AGQ<8ktkt}^L(qqbH#i2g|ZyREL*tcJ-iCrLALO+B9`@PayjthBDSAEd-VqH zLG2w~XfcSTpl?p1LQ&9i)#l08W5b!lZuxeeCUH*j9&BrQNDmt_D{b({FZ5;gu2gbc z>T6YZzZ+mZ4|7%)^br8pg_e?j_-e(?@$%SRF~!HQ^CpZK%{<+UqCYqEVp~}%aXGJ` zts>Bg--brW=uBu|5Ol7a6`{%kZ-T`VMk3Eoidc13!vzTq1k(Ge^sPygk%|u^n?&g=c55_>~b1@KNJq2p+axbPKTcl;j$+f@& z!iaR)cI6r$ftC1t@bT|P4CuQ21U5Agl%_n&v)@O292*p`e;;QCd*fm%K8h(VDdHlg zA`MT(sZ`%`K|&LOe1B{8{yvlcFUXZBh&-pn6|tOCVukgep|uPgZ&Vb!#C1Y8R*oe_ W4-9+==1D4qh-=Q*w%>tKHo#{VV}WM? delta 110 zcmZ1_FqcW5%bJTPIX^cyKTofqD8D3Mh`XRbFSj(OBr_$mNJ(s>q8L{a8(7AeD{nFf ulLnKK&E!-T8)Q)(Rt*s&8!nFE)S|M?zN&>!Of diff --git a/src/test/resources/packetproxy/grpc/proto/multidir/multi_without_imports.desc b/src/test/resources/packetproxy/grpc/proto/multidir/multi_without_imports.desc index 18dc239e..dfd70fd9 100644 --- a/src/test/resources/packetproxy/grpc/proto/multidir/multi_without_imports.desc +++ b/src/test/resources/packetproxy/grpc/proto/multidir/multi_without_imports.desc @@ -1,5 +1,52 @@ -n - svc_a.proto pp.multidir common.proto2< + + svc_a.proto pp.multidir common.proto" + CreateRequest/ +resource ( 2.pp.multidir.SharedRresourceA +options ( 2'.pp.multidir.CreateRequest.OptionsEntryRoptions +dry_run (RdryRun: + OptionsEntry +key ( Rkey +value ( Rvalue:8"v +CreateResponse- +created ( 2.pp.multidir.SharedRcreated5 + +created_at ( 2.pp.multidir.TimestampR createdAt" + ListRequest7 + +pagination ( 2.pp.multidir.PaginationR +pagination8 + filter_status (2.pp.multidir.StatusR filterStatus"Z + ListResponse) +items ( 2.pp.multidir.SharedRitems + total_count (R +totalCount2 ServiceA0 -Call.pp.multidir.Shared.pp.multidir.Sharedbproto3 \ No newline at end of file +Call.pp.multidir.Shared.pp.multidir.SharedA +Create.pp.multidir.CreateRequest.pp.multidir.CreateResponse; +List.pp.multidir.ListRequest.pp.multidir.ListResponsebproto3 + + svc_b.proto pp.multidir common.proto"N + PingRequest- +payload ( 2.pp.multidir.SharedRpayload +ttl (Rttl" + PingResponse- +payload ( 2.pp.multidir.SharedRpayload9 + responded_at ( 2.pp.multidir.TimestampR respondedAt + +latency_ms (R latencyMs" +Event +sequence (Rsequence +type ( Rtype +data ( Rdata7 + occurred_at ( 2.pp.multidir.TimestampR +occurredAt+ +status (2.pp.multidir.StatusRstatus"m +SubscribeRequest + event_types ( R +eventTypes8 + filter_status (2.pp.multidir.StatusR filterStatus2 +ServiceB; +Ping.pp.multidir.PingRequest.pp.multidir.PingResponse@ + Subscribe.pp.multidir.SubscribeRequest.pp.multidir.Event03 +Upload.pp.multidir.Event.pp.multidir.Shared(bproto3 \ No newline at end of file diff --git a/src/test/resources/packetproxy/grpc/proto/multidir/svc_a.proto b/src/test/resources/packetproxy/grpc/proto/multidir/svc_a.proto index 106f1ac0..fa928d9b 100644 --- a/src/test/resources/packetproxy/grpc/proto/multidir/svc_a.proto +++ b/src/test/resources/packetproxy/grpc/proto/multidir/svc_a.proto @@ -4,6 +4,29 @@ package pp.multidir; import "common.proto"; +message CreateRequest { + Shared resource = 1; + map options = 2; + bool dry_run = 3; +} + +message CreateResponse { + Shared created = 1; + Timestamp created_at = 2; +} + +message ListRequest { + Pagination pagination = 1; + Status filter_status = 2; +} + +message ListResponse { + repeated Shared items = 1; + int32 total_count = 2; +} + service ServiceA { rpc Call(Shared) returns (Shared); + rpc Create(CreateRequest) returns (CreateResponse); + rpc List(ListRequest) returns (ListResponse); } diff --git a/src/test/resources/packetproxy/grpc/proto/multidir/svc_b.proto b/src/test/resources/packetproxy/grpc/proto/multidir/svc_b.proto index 70d9ed82..e0ec8505 100644 --- a/src/test/resources/packetproxy/grpc/proto/multidir/svc_b.proto +++ b/src/test/resources/packetproxy/grpc/proto/multidir/svc_b.proto @@ -4,6 +4,32 @@ package pp.multidir; import "common.proto"; +message PingRequest { + Shared payload = 1; + int32 ttl = 2; +} + +message PingResponse { + Shared payload = 1; + Timestamp responded_at = 2; + int64 latency_ms = 3; +} + +message Event { + int64 sequence = 1; + string type = 2; + bytes data = 3; + Timestamp occurred_at = 4; + Status status = 5; +} + +message SubscribeRequest { + repeated string event_types = 1; + Status filter_status = 2; +} + service ServiceB { - rpc Ping(Shared) returns (Shared); + rpc Ping(PingRequest) returns (PingResponse); + rpc Subscribe(SubscribeRequest) returns (stream Event); + rpc Upload(stream Event) returns (Shared); } diff --git a/src/test/resources/packetproxy/grpc/proto/testsvc.desc b/src/test/resources/packetproxy/grpc/proto/testsvc.desc index 29a3e13f124ffd61f11295267433e4287febb396..8fd7ddacedc81d2de3053a050b2c8f70de6866ac 100644 GIT binary patch literal 1333 zcmb7E+iuf95Ut~;aVAL#+X%Ul5NRYJ;vuTSO9ebN35}(uEgLGt3)a?NDhuDbUPtm* z@D2O{;tSbbUz$oS6>l?VXU>k#nKk?Y=y00wWbKS&7V!u{9FGfR_)CZOg8F`h=}juC z5Q6QH1k}_9+Ng1e6n(aAMX4-kQAHWZ9f+yetamA!iw``p^v8EFayLT;`6LrZqWuln1wp zHc8xn51l&~$WQ)3vqA+Kq8`-O#7}b>nmAA1)sD3OH!#X(v=0Vn#G$t57R?QnTP`|) zHunOdF$rQjNR*s9ZdKSak$1TCD!o>k8aPjeKWhMm7O|fp6B>dgk&fghm8sO&iQLjC z70c?FWPU_kMcT$?ieAHRk~)r{vR$Ep=L>Iy=Zh`eDe#$s(Gk=sV-XV`No@!BXe;I# z&t;7jevbr0-q1gVW`&~X0E)&|Gs@BHxT%n-7#arx z^WhD2R-921EOQ}HAB;q9LFnY7=(x(IDr&>z`nIu(|9^jnpP;cygMhFMy?_>vIPt}Y zq#;)()G;V%DiQP&b{Tc3x2CQwACvT2Zhfaj&m{Op>`nPlgiXB*Y@K84^H=-wY;`f8 zS|6?Xth*&Pv0a^w7hp!lEhuYPS`a{0`3uO6}WB>pF delta 63 zcmdnWwVP3aYX>72Z%Jx#NpV@SUO`cQNxl$QL4h7rL`i9)q7svm+T^2*YD`A%lm9Zi Lb0o2Wl^Fv7iFy<# diff --git a/src/test/resources/packetproxy/grpc/proto/testsvc.proto b/src/test/resources/packetproxy/grpc/proto/testsvc.proto index 4b792ebd..300b51ea 100644 --- a/src/test/resources/packetproxy/grpc/proto/testsvc.proto +++ b/src/test/resources/packetproxy/grpc/proto/testsvc.proto @@ -2,14 +2,58 @@ syntax = "proto3"; package pp.testsvc; +enum Priority { + PRIORITY_UNSPECIFIED = 0; + PRIORITY_LOW = 1; + PRIORITY_MEDIUM = 2; + PRIORITY_HIGH = 3; +} + message HelloRequest { string name = 1; + int32 age = 2; + Priority priority = 3; + repeated string tags = 4; + map metadata = 5; + Metadata request_meta = 6; + + message Metadata { + string trace_id = 1; + int64 timestamp_ms = 2; + bool debug = 3; + } } message HelloReply { string message = 1; + int32 code = 2; + bytes payload = 3; + + oneof result { + string success_detail = 4; + ErrorInfo error = 5; + } + + message ErrorInfo { + int32 error_code = 1; + string description = 2; + } +} + +message StreamMessage { + int64 sequence = 1; + bytes data = 2; + Priority priority = 3; +} + +message Summary { + int32 total_count = 1; + repeated string received_names = 2; } service Greeter { rpc SayHello(HelloRequest) returns (HelloReply); + rpc SayHelloServerStream(HelloRequest) returns (stream StreamMessage); + rpc SayHelloClientStream(stream StreamMessage) returns (Summary); + rpc SayHelloBidiStream(stream StreamMessage) returns (stream StreamMessage); } From 064001db84b665eeaedd93600552af0382c7c4a5 Mon Sep 17 00:00:00 2001 From: taka2233 Date: Tue, 28 Apr 2026 15:21:44 +0900 Subject: [PATCH 18/22] refactor: resolve gRPC descriptor lazily from HTTP authority headers instead of at encoder creation --- .../java/core/packetproxy/DuplexFactory.java | 8 +- .../java/core/packetproxy/EncoderManager.java | 34 +---- .../core/packetproxy/ProxySSLForward.java | 2 +- .../core/packetproxy/ProxySSLTransparent.java | 2 +- .../controller/ResendController.java | 5 +- .../SinglePacketAttackController.java | 3 +- .../core/packetproxy/encode/EncodeGRPC.java | 74 ++++++++--- .../encode/EncodeGRPCStreaming.java | 57 +++++++-- .../core/packetproxy/model/OneShotPacket.java | 12 +- .../java/core/packetproxy/model/Packet.java | 12 +- .../grpc/GrpcServiceRegistryStore.kt | 88 +++++++++++++ .../encode/EncodeGRPCMultiplexBugTest.kt | 120 ++++++++++++++++++ .../grpc/GrpcServiceRegistryStoreTest.kt | 92 ++++++++++++++ .../grpc/GrpcServiceRegistryTest.kt | 22 ++++ 14 files changed, 440 insertions(+), 91 deletions(-) create mode 100644 src/test/kotlin/packetproxy/encode/EncodeGRPCMultiplexBugTest.kt diff --git a/src/main/java/core/packetproxy/DuplexFactory.java b/src/main/java/core/packetproxy/DuplexFactory.java index a46d10bb..fe3b21b3 100644 --- a/src/main/java/core/packetproxy/DuplexFactory.java +++ b/src/main/java/core/packetproxy/DuplexFactory.java @@ -69,7 +69,7 @@ private static void prepareDuplex(final Duplex duplex, Endpoint client_endpoint, duplex.addDuplexEventListener(new Duplex.DuplexEventListener() { private Packets packets = Packets.getInstance(); - private Encoder encoder = EncoderManager.getInstance().createInstance(encoder_name, ALPN, server_addr); + private Encoder encoder = EncoderManager.getInstance().createInstance(encoder_name, ALPN); private Modifications mods = Modifications.getInstance(); private Packet client_packet; @@ -347,7 +347,7 @@ public static DuplexSync createDuplexSyncFromOneShotPacket(final OneShotPacket o private Packets packets = Packets.getInstance(); private Encoder encoder = EncoderManager.getInstance().createInstance(oneshot.getEncoder(), - oneshot.getAlpn(), oneshot.getServer()); + oneshot.getAlpn()); private Packet client_packet; private Packet server_packet; @@ -537,7 +537,7 @@ public static DuplexSync createDuplexSyncForSinglePacketAttack(final OneShotPack private Packets packets = Packets.getInstance(); private Encoder encoder = EncoderManager.getInstance().createInstance(oneshot.getEncoder(), - oneshot.getAlpn(), oneshot.getServer()); + oneshot.getAlpn()); private Packet client_packet; private Packet server_packet; @@ -697,7 +697,7 @@ public static Duplex createDuplexFromOriginalDuplex(Duplex original_duplex, OneS private Packets packets = Packets.getInstance(); private Encoder encoder = EncoderManager.getInstance().createInstance(oneshot.getEncoder(), - oneshot.getAlpn(), oneshot.getServer()); + oneshot.getAlpn()); private Packet client_packet; private Packet server_packet; diff --git a/src/main/java/core/packetproxy/EncoderManager.java b/src/main/java/core/packetproxy/EncoderManager.java index c1bd03c5..61699668 100644 --- a/src/main/java/core/packetproxy/EncoderManager.java +++ b/src/main/java/core/packetproxy/EncoderManager.java @@ -20,7 +20,6 @@ import com.google.common.collect.Sets; import java.io.File; import java.lang.reflect.Modifier; -import java.net.InetSocketAddress; import java.net.URL; import java.net.URLClassLoader; import java.nio.file.Path; @@ -38,11 +37,7 @@ import javax.tools.StandardLocation; import javax.tools.ToolProvider; import org.apache.commons.io.FilenameUtils; -import packetproxy.encode.EncodeGRPC; -import packetproxy.encode.EncodeGRPCStreaming; import packetproxy.encode.Encoder; -import packetproxy.model.Server; -import packetproxy.model.Servers; public class EncoderManager { @@ -193,37 +188,12 @@ private Encoder createInstance(Class klass, String ALPN) throws Excepti return klass.getConstructor(String.class).newInstance(ALPN); } - public Encoder createInstance(String encoderName, String ALPN, InetSocketAddress serverAddr) throws Exception { + public Encoder createInstance(String encoderName, String ALPN) throws Exception { Class klass = module_list.get(encoderName); if (klass == null) { return null; } - Encoder encoder = createInstance(klass, ALPN); - applyGrpcDescriptor(encoder, serverAddr); - return encoder; - } - - private void applyGrpcDescriptor(Encoder encoder, InetSocketAddress serverAddr) { - if (serverAddr == null) - return; - try { - Server server = Servers.getInstance().queryByAddress(serverAddr); - if (server == null) - return; - String path = server.getDescriptorPath(); - if (path == null || path.trim().isEmpty()) - return; - File f = new File(path.trim()); - if (!f.isFile()) - return; - if (encoder instanceof EncodeGRPC) { - ((EncodeGRPC) encoder).setDescriptorFile(f); - } else if (encoder instanceof EncodeGRPCStreaming) { - ((EncodeGRPCStreaming) encoder).setDescriptorFile(f); - } - } catch (Exception e) { - errWithStackTrace(e); - } + return createInstance(klass, ALPN); } } diff --git a/src/main/java/core/packetproxy/ProxySSLForward.java b/src/main/java/core/packetproxy/ProxySSLForward.java index 39894404..b4c6f329 100644 --- a/src/main/java/core/packetproxy/ProxySSLForward.java +++ b/src/main/java/core/packetproxy/ProxySSLForward.java @@ -104,7 +104,7 @@ public void createConnection(SSLSocketEndpoint client_e, SSLSocketEndpoint serve if (alpn == null || alpn.isEmpty()) { - Encoder encoder = EncoderManager.getInstance().createInstance(server.getEncoder(), "", null); + Encoder encoder = EncoderManager.getInstance().createInstance(server.getEncoder(), ""); if (encoder instanceof EncodeHTTPBase) { /* The client does not support ALPN. It seems to be an old HTTP client */ diff --git a/src/main/java/core/packetproxy/ProxySSLTransparent.java b/src/main/java/core/packetproxy/ProxySSLTransparent.java index d6e2a41a..8ad59482 100644 --- a/src/main/java/core/packetproxy/ProxySSLTransparent.java +++ b/src/main/java/core/packetproxy/ProxySSLTransparent.java @@ -219,7 +219,7 @@ public void createConnection(SSLSocketEndpoint client_e, SSLSocketEndpoint serve if (alpn == null || alpn.isEmpty()) { - Encoder encoder = EncoderManager.getInstance().createInstance(server.getEncoder(), "", null); + Encoder encoder = EncoderManager.getInstance().createInstance(server.getEncoder(), ""); if (encoder instanceof EncodeHTTPBase) { /* The client does not support ALPN. It seems to be an old HTTP client */ diff --git a/src/main/java/core/packetproxy/controller/ResendController.java b/src/main/java/core/packetproxy/controller/ResendController.java index b1243bde..f3d362ca 100644 --- a/src/main/java/core/packetproxy/controller/ResendController.java +++ b/src/main/java/core/packetproxy/controller/ResendController.java @@ -182,8 +182,7 @@ private class DataToBeSend { public DataToBeSend(OneShotPacket oneshot, Consumer onReceived) throws Exception { this.oneshot = oneshot; this.onReceived = onReceived; - Encoder encoder = EncoderManager.getInstance().createInstance(oneshot.getEncoder(), oneshot.getAlpn(), - oneshot.getServer()); + Encoder encoder = EncoderManager.getInstance().createInstance(oneshot.getEncoder(), oneshot.getAlpn()); if (encoder.useNewConnectionForResend() == false && encoder.useNewEncoderForResend() == false) { this.isDirectSend = true; @@ -252,7 +251,7 @@ public void send() throws Exception { /* 100 Continue 対策 */ Encoder encoder = EncoderManager.getInstance().createInstance(oneshot.getEncoder(), - oneshot.getAlpn(), oneshot.getServer()); + oneshot.getAlpn()); if (encoder instanceof EncodeHTTPBase) { EncodeHTTPBase httpEncoder = (EncodeHTTPBase) encoder; diff --git a/src/main/java/core/packetproxy/controller/SinglePacketAttackController.java b/src/main/java/core/packetproxy/controller/SinglePacketAttackController.java index 264cba76..a27a3d00 100644 --- a/src/main/java/core/packetproxy/controller/SinglePacketAttackController.java +++ b/src/main/java/core/packetproxy/controller/SinglePacketAttackController.java @@ -164,8 +164,7 @@ private static AttackFrames generateAttackFrames(final OneShotPacket packet) thr } private static List convertPacketToFrames(final OneShotPacket packet) throws Exception { - final var encoder = EncoderManager.getInstance().createInstance(packet.getEncoder(), packet.getAlpn(), - packet.getServer()); + final var encoder = EncoderManager.getInstance().createInstance(packet.getEncoder(), packet.getAlpn()); if (encoder == null) { throw new IllegalStateException("Could not create encoder for target packet"); diff --git a/src/main/java/core/packetproxy/encode/EncodeGRPC.java b/src/main/java/core/packetproxy/encode/EncodeGRPC.java index 746aa5ba..eb30f3a5 100644 --- a/src/main/java/core/packetproxy/encode/EncodeGRPC.java +++ b/src/main/java/core/packetproxy/encode/EncodeGRPC.java @@ -21,7 +21,6 @@ import com.google.protobuf.DynamicMessage; import com.google.protobuf.util.JsonFormat; import java.io.ByteArrayOutputStream; -import java.io.File; import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; import java.util.ArrayList; @@ -44,21 +43,19 @@ public class EncodeGRPC extends EncodeHTTPBase { private static final JsonFormat.Parser JSON_PARSER = JsonFormat.parser().ignoringUnknownFields(); private byte compressedFlag; - private volatile GrpcServiceRegistry registry; private volatile String lastGrpcPath; - public synchronized void setDescriptorFile(File descFile) { - if (descFile == null || !descFile.isFile()) { - registry = null; - return; - } - try { - registry = GrpcServiceRegistryStore.getInstance().get(descFile); - } catch (Exception e) { - Logging.errWithStackTrace(e); - registry = null; - } - } + /** + * Registry used when the server response Http lacks authority headers (paired + * request path). + */ + private volatile GrpcServiceRegistry lastResolvedRegistry; + + /** + * Same-package tests that build HTTP without Servers metadata (see + * EncodeGRPCMultiplexBugTest). + */ + volatile GrpcServiceRegistry registryOverrideForTest; public EncodeGRPC() throws Exception { super(); @@ -73,10 +70,45 @@ public String getName() { return "gRPC"; } + private GrpcServiceRegistry resolveRegistry(Http http) { + try { + if (registryOverrideForTest != null) { + return registryOverrideForTest; + } + String authority = http.getFirstHeader("X-PacketProxy-HTTP2-Host"); + if (authority.isEmpty()) { + authority = http.getFirstHeader("x-packetproxy-http3-host"); + } + if (authority.isEmpty()) { + String host = http.getHost(); + if (host != null && !host.isEmpty()) { + authority = host; + } + } + return GrpcServiceRegistryStore.getInstance().getByAuthority(authority); + } catch (Exception e) { + Logging.errWithStackTrace(e); + return null; + } + } + + private GrpcServiceRegistry effectiveRegistry(Http http) { + GrpcServiceRegistry reg = resolveRegistry(http); + if (reg != null) { + lastResolvedRegistry = reg; + return reg; + } + return lastResolvedRegistry; + } + @Override protected Http decodeClientRequestHttp(Http inputHttp) throws Exception { lastGrpcPath = inputHttp.getPath(); - Descriptor type = registry != null ? registry.getInputType(lastGrpcPath) : null; + GrpcServiceRegistry reg = resolveRegistry(inputHttp); + if (reg != null) { + lastResolvedRegistry = reg; + } + Descriptor type = reg != null ? reg.getInputType(lastGrpcPath) : null; if (type != null) { inputHttp.setBody(decodeSchemaAwareBody(inputHttp.getBody(), type)); return inputHttp; @@ -110,7 +142,11 @@ protected Http decodeClientRequestHttp(Http inputHttp) throws Exception { @Override protected Http encodeClientRequestHttp(Http inputHttp) throws Exception { lastGrpcPath = inputHttp.getPath(); - Descriptor type = registry != null ? registry.getInputType(lastGrpcPath) : null; + GrpcServiceRegistry reg = resolveRegistry(inputHttp); + if (reg != null) { + lastResolvedRegistry = reg; + } + Descriptor type = reg != null ? reg.getInputType(lastGrpcPath) : null; if (type != null) { inputHttp.setBody(encodeSchemaAwareBody(inputHttp.getBody(), type)); return inputHttp; @@ -150,7 +186,8 @@ protected Http decodeServerResponseHttp(Http inputHttp) throws Exception { return inputHttp; } - Descriptor type = registry != null ? registry.getOutputType(lastGrpcPath) : null; + GrpcServiceRegistry reg = effectiveRegistry(inputHttp); + Descriptor type = reg != null ? reg.getOutputType(lastGrpcPath) : null; if (type != null) { inputHttp.setBody(decodeSchemaAwareBody(raw, type)); return inputHttp; @@ -187,7 +224,8 @@ protected Http encodeServerResponseHttp(Http inputHttp) throws Exception { return inputHttp; } - Descriptor type = registry != null ? registry.getOutputType(lastGrpcPath) : null; + GrpcServiceRegistry reg = effectiveRegistry(inputHttp); + Descriptor type = reg != null ? reg.getOutputType(lastGrpcPath) : null; if (type != null) { inputHttp.setBody(encodeSchemaAwareBody(body, type)); return inputHttp; diff --git a/src/main/java/core/packetproxy/encode/EncodeGRPCStreaming.java b/src/main/java/core/packetproxy/encode/EncodeGRPCStreaming.java index 07a55a1e..cd69cc38 100644 --- a/src/main/java/core/packetproxy/encode/EncodeGRPCStreaming.java +++ b/src/main/java/core/packetproxy/encode/EncodeGRPCStreaming.java @@ -21,7 +21,6 @@ import com.google.protobuf.DynamicMessage; import com.google.protobuf.util.JsonFormat; import java.io.ByteArrayOutputStream; -import java.io.File; import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; import java.util.ArrayList; @@ -46,24 +45,44 @@ public class EncodeGRPCStreaming extends EncodeHTTPBase { private static final JsonFormat.Parser JSON_PARSER = JsonFormat.parser().ignoringUnknownFields(); private byte compressedFlag; - private volatile GrpcServiceRegistry registry; private volatile String lastGrpcPath; private final ConcurrentHashMap grpcPathByStreamId = new ConcurrentHashMap<>(); + private volatile GrpcServiceRegistry lastResolvedRegistry; - public synchronized void setDescriptorFile(File descFile) { - grpcPathByStreamId.clear(); - if (descFile == null || !descFile.isFile()) { - registry = null; - return; - } + /** Same-package tests that build HTTP without Servers metadata. */ + volatile GrpcServiceRegistry registryOverrideForTest; + + private GrpcServiceRegistry resolveRegistry(Http http) { try { - registry = GrpcServiceRegistryStore.getInstance().get(descFile); + if (registryOverrideForTest != null) { + return registryOverrideForTest; + } + String authority = http.getFirstHeader("X-PacketProxy-HTTP2-Host"); + if (authority.isEmpty()) { + authority = http.getFirstHeader("x-packetproxy-http3-host"); + } + if (authority.isEmpty()) { + String host = http.getHost(); + if (host != null && !host.isEmpty()) { + authority = host; + } + } + return GrpcServiceRegistryStore.getInstance().getByAuthority(authority); } catch (Exception e) { Logging.errWithStackTrace(e); - registry = null; + return null; } } + private GrpcServiceRegistry effectiveRegistry(Http http) { + GrpcServiceRegistry reg = resolveRegistry(http); + if (reg != null) { + lastResolvedRegistry = reg; + return reg; + } + return lastResolvedRegistry; + } + private String resolveGrpcPathClient(Http http) { String path = http.getPath(); String streamIdStr = http.getFirstHeader("X-PacketProxy-HTTP2-Stream-Id"); @@ -120,7 +139,11 @@ public String getName() { @Override protected Http decodeClientRequestHttp(Http inputHttp) throws Exception { lastGrpcPath = resolveGrpcPathClient(inputHttp); - Descriptor type = registry != null ? registry.getInputType(lastGrpcPath) : null; + GrpcServiceRegistry reg = resolveRegistry(inputHttp); + if (reg != null) { + lastResolvedRegistry = reg; + } + Descriptor type = reg != null ? reg.getInputType(lastGrpcPath) : null; if (type != null) { inputHttp.setBody(decodeSchemaAwareBody(inputHttp.getBody(), type)); return inputHttp; @@ -154,7 +177,11 @@ protected Http decodeClientRequestHttp(Http inputHttp) throws Exception { @Override protected Http encodeClientRequestHttp(Http inputHttp) throws Exception { lastGrpcPath = resolveGrpcPathClient(inputHttp); - Descriptor type = registry != null ? registry.getInputType(lastGrpcPath) : null; + GrpcServiceRegistry reg = resolveRegistry(inputHttp); + if (reg != null) { + lastResolvedRegistry = reg; + } + Descriptor type = reg != null ? reg.getInputType(lastGrpcPath) : null; if (type != null) { inputHttp.setBody(encodeSchemaAwareBody(inputHttp.getBody(), type)); return inputHttp; @@ -195,7 +222,8 @@ protected Http decodeServerResponseHttp(Http inputHttp) throws Exception { return inputHttp; } lastGrpcPath = resolveGrpcPathServer(inputHttp); - Descriptor type = registry != null ? registry.getOutputType(lastGrpcPath) : null; + GrpcServiceRegistry reg = effectiveRegistry(inputHttp); + Descriptor type = reg != null ? reg.getOutputType(lastGrpcPath) : null; if (type != null) { inputHttp.setBody(decodeSchemaAwareBody(raw, type)); return inputHttp; @@ -233,7 +261,8 @@ protected Http encodeServerResponseHttp(Http inputHttp) throws Exception { return inputHttp; } lastGrpcPath = resolveGrpcPathServer(inputHttp); - Descriptor type = registry != null ? registry.getOutputType(lastGrpcPath) : null; + GrpcServiceRegistry reg = effectiveRegistry(inputHttp); + Descriptor type = reg != null ? reg.getOutputType(lastGrpcPath) : null; if (type != null) { inputHttp.setBody(encodeSchemaAwareBody(body, type)); return inputHttp; diff --git a/src/main/java/core/packetproxy/model/OneShotPacket.java b/src/main/java/core/packetproxy/model/OneShotPacket.java index 51ab6704..8c199a7d 100644 --- a/src/main/java/core/packetproxy/model/OneShotPacket.java +++ b/src/main/java/core/packetproxy/model/OneShotPacket.java @@ -186,25 +186,21 @@ public Packet toPacket() throws Exception { } public String getSummarizedRequest() throws Exception { - Encoder encoder = EncoderManager.getInstance().createInstance(encoder_name, alpn, - new InetSocketAddress(server_ip, server_port)); + Encoder encoder = EncoderManager.getInstance().createInstance(encoder_name, alpn); if (encoder == null) { err("エンコードモジュール: %s が見当たらないので、Sample とみなしました", encoder_name); - encoder = EncoderManager.getInstance().createInstance("Sample", alpn, - new InetSocketAddress(server_ip, server_port)); + encoder = EncoderManager.getInstance().createInstance("Sample", alpn); } return (getDirection() == Direction.CLIENT) ? encoder.getSummarizedRequest(toPacket()) : ""; } public String getSummarizedResponse() throws Exception { - Encoder encoder = EncoderManager.getInstance().createInstance(encoder_name, alpn, - new InetSocketAddress(server_ip, server_port)); + Encoder encoder = EncoderManager.getInstance().createInstance(encoder_name, alpn); if (encoder == null) { err("エンコードモジュール: %s が見当たらないので、Sample とみなしました", encoder_name); - encoder = EncoderManager.getInstance().createInstance("Sample", alpn, - new InetSocketAddress(server_ip, server_port)); + encoder = EncoderManager.getInstance().createInstance("Sample", alpn); } return (getDirection() == Direction.SERVER) ? encoder.getSummarizedResponse(toPacket()) : ""; } diff --git a/src/main/java/core/packetproxy/model/Packet.java b/src/main/java/core/packetproxy/model/Packet.java index e6ea4b42..f3a15a8b 100644 --- a/src/main/java/core/packetproxy/model/Packet.java +++ b/src/main/java/core/packetproxy/model/Packet.java @@ -279,25 +279,21 @@ public void setColor(String color) { } public String getSummarizedRequest() throws Exception { - Encoder encoder = EncoderManager.getInstance().createInstance(encoder_name, null, - new InetSocketAddress(server_ip, server_port)); + Encoder encoder = EncoderManager.getInstance().createInstance(encoder_name, null); if (encoder == null) { err("エンコードモジュール: %s が見当たらないので、Sample とみなしました", encoder_name); - encoder = EncoderManager.getInstance().createInstance("Sample", null, - new InetSocketAddress(server_ip, server_port)); + encoder = EncoderManager.getInstance().createInstance("Sample", null); } return (getDirection() == Direction.CLIENT) ? encoder.getSummarizedRequest(this) : ""; } public String getSummarizedResponse() throws Exception { - Encoder encoder = EncoderManager.getInstance().createInstance(encoder_name, null, - new InetSocketAddress(server_ip, server_port)); + Encoder encoder = EncoderManager.getInstance().createInstance(encoder_name, null); if (encoder == null) { err("エンコードモジュール: %s が見当たらないので、Sample とみなしました", encoder_name); - encoder = EncoderManager.getInstance().createInstance("Sample", null, - new InetSocketAddress(server_ip, server_port)); + encoder = EncoderManager.getInstance().createInstance("Sample", null); } return (getDirection() == Direction.SERVER) ? encoder.getSummarizedResponse(this) : ""; } diff --git a/src/main/kotlin/core/packetproxy/grpc/GrpcServiceRegistryStore.kt b/src/main/kotlin/core/packetproxy/grpc/GrpcServiceRegistryStore.kt index ae588dc4..95f083bd 100644 --- a/src/main/kotlin/core/packetproxy/grpc/GrpcServiceRegistryStore.kt +++ b/src/main/kotlin/core/packetproxy/grpc/GrpcServiceRegistryStore.kt @@ -20,10 +20,15 @@ import com.google.protobuf.Descriptors.DescriptorValidationException import com.google.protobuf.Descriptors.FileDescriptor import java.io.File import java.io.IOException +import java.net.InetSocketAddress import java.nio.file.Files import java.util.ArrayList import java.util.HashMap import java.util.concurrent.ConcurrentHashMap +import packetproxy.model.ListenPort +import packetproxy.model.ListenPorts +import packetproxy.model.Server +import packetproxy.model.Servers /** * Caches [GrpcServiceRegistry] per descriptor file path so short-lived encoders do not re-parse @@ -32,6 +37,89 @@ import java.util.concurrent.ConcurrentHashMap class GrpcServiceRegistryStore private constructor() { private val cache = ConcurrentHashMap() + /** + * Resolves a [GrpcServiceRegistry] from HTTP `:authority` (e.g. `host:443` or `[ipv6]:443`). + * Tries [Servers.queryByHostNameAndPort] first, then [Servers.queryByAddress] for IP literals, + * then an enabled [ListenPort] on the same TCP port (covers transparent proxy where authority is + * the listener address, not the upstream server). + */ + fun getByAuthority(authority: String?): GrpcServiceRegistry? { + if (authority.isNullOrBlank()) return null + return try { + val parsed = parseAuthorityHostPort(authority.trim()) ?: return null + val (host, port) = parsed + var server = Servers.getInstance().queryByHostNameAndPort(host, port) + if (server == null) { + try { + val addr = InetSocketAddress(host, port) + server = Servers.getInstance().queryByAddress(addr) + } catch (_: Exception) { + // unresolved hostname / invalid socket + } + } + if (server == null) { + server = tryResolveServerViaListenPort(port, ListenPorts.getInstance()) + } + if (server == null) return null + val path = server.descriptorPath?.trim().takeUnless { it.isNullOrEmpty() } ?: return null + val f = File(path) + if (!f.isFile()) return null + get(f) + } catch (_: Exception) { + null + } + } + + /** + * Resolves a [Server] when the authority does not match [Servers] rows, by looking up an enabled + * [ListenPort] on the same TCP port (transparent listener case). Exposed for unit tests with a + * mock [ListenPorts] (static [ListenPorts.getInstance] is not mockable). + */ + internal fun tryResolveServerViaListenPort(port: Int, listenPorts: ListenPorts): Server? { + return try { + val listenPort = listenPorts.queryEnabledByPort(ListenPort.Protocol.TCP, port) ?: return null + listenPort.getServer() + } catch (_: Exception) { + null + } + } + + /** + * Parses `:authority` into host and port. Port defaults to 443 when omitted (typical for gRPC + * over TLS). + */ + internal fun parseAuthorityHostPort(authority: String): Pair? { + if (authority.isEmpty()) return null + if (authority.startsWith('[')) { + val close = authority.indexOf(']') + if (close < 0) return null + val host = authority.substring(1, close) + val rest = authority.substring(close + 1) + val port = + if (rest.startsWith(":")) { + rest.substring(1).toIntOrNull() ?: return null + } else { + 443 + } + return Pair(host, port) + } + val colonIdx = authority.lastIndexOf(':') + if (colonIdx < 0) { + return Pair(authority, 443) + } + if (colonIdx == authority.length - 1) { + return null + } + val hostPart = authority.substring(0, colonIdx) + val portPart = authority.substring(colonIdx + 1) + val port = portPart.toIntOrNull() + return if (port != null && portPart.isNotEmpty() && portPart.all { it.isDigit() }) { + Pair(hostPart, port) + } else { + Pair(authority, 443) + } + } + @Throws(Exception::class) fun get(descFile: File?): GrpcServiceRegistry { if (descFile == null) { diff --git a/src/test/kotlin/packetproxy/encode/EncodeGRPCMultiplexBugTest.kt b/src/test/kotlin/packetproxy/encode/EncodeGRPCMultiplexBugTest.kt new file mode 100644 index 00000000..1da2bd3f --- /dev/null +++ b/src/test/kotlin/packetproxy/encode/EncodeGRPCMultiplexBugTest.kt @@ -0,0 +1,120 @@ +/* + * Copyright 2026 DeNA Co., Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package packetproxy.encode + +import com.google.protobuf.DynamicMessage +import java.io.ByteArrayOutputStream +import java.io.File +import java.nio.ByteBuffer +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import packetproxy.grpc.GrpcServiceRegistryStore + +/** + * EncodeGRPC が lastGrpcPath (単一フィールド) でレスポンスのスキーマを選択しているため、 HTTP/2 多重化で複数の unary RPC + * が同一接続上に来ると、後続リクエストのパスで 先行リクエストのレスポンスをデコードしてしまうバグの再現テスト。 + * + * multi.desc に含まれるサービス: ServiceA/Call : input=Shared, output=Shared (Shared には "x", "id" フィールド) + * ServiceB/Ping : input=PingRequest, output=PingResponse (PingResponse には "payload", "latency_ms" + * フィールド) + */ +class EncodeGRPCMultiplexBugTest { + + private fun resource(classpathPath: String): File { + val u = + EncodeGRPCMultiplexBugTest::class.java.getResource(classpathPath) + ?: throw IllegalStateException("missing resource: $classpathPath") + return File(u.toURI()) + } + + private fun grpcFrame(protoBytes: ByteArray): ByteArray { + val out = ByteArrayOutputStream() + out.write(0) + out.write(ByteBuffer.allocate(4).putInt(protoBytes.size).array()) + out.write(protoBytes) + return out.toByteArray() + } + + private fun httpRequest(path: String, grpcBody: ByteArray): ByteArray { + val header = + "POST $path HTTP/1.1\r\n" + + "Host: example.com\r\n" + + "Content-Type: application/grpc\r\n" + + "Content-Length: ${grpcBody.size}\r\n" + + "\r\n" + return header.toByteArray() + grpcBody + } + + private fun httpResponse(grpcBody: ByteArray): ByteArray { + val header = + "HTTP/1.1 200 OK\r\n" + + "Content-Type: application/grpc\r\n" + + "Content-Length: ${grpcBody.size}\r\n" + + "\r\n" + return header.toByteArray() + grpcBody + } + + @Test + fun responseDecodedWithWrongSchema_whenMultipleUnaryRpcsInterleaved() { + val descFile = resource("/packetproxy/grpc/proto/multidir/multi.desc") + val registry = GrpcServiceRegistryStore.getInstance().get(descFile) + + val sharedType = registry.getInputType("/pp.multidir.ServiceA/Call")!! + val pingReqType = registry.getInputType("/pp.multidir.ServiceB/Ping")!! + + val sharedMsg = + DynamicMessage.newBuilder(sharedType) + .setField(sharedType.findFieldByName("x"), "hello") + .setField(sharedType.findFieldByName("id"), 42) + .build() + val pingReqMsg = + DynamicMessage.newBuilder(pingReqType) + .setField(pingReqType.findFieldByName("ttl"), 100) + .build() + + val sharedBody = grpcFrame(sharedMsg.toByteArray()) + val pingReqBody = grpcFrame(pingReqMsg.toByteArray()) + + // --- baseline: request → response (1 stream, no interleaving) --- + val baseline = EncodeGRPC() + baseline.registryOverrideForTest = registry + baseline.decodeClientRequest(httpRequest("/pp.multidir.ServiceA/Call", sharedBody)) + val baselineResp = String(baseline.decodeServerResponse(httpResponse(sharedBody))) + + // Shared 型でデコードされていれば "x" フィールドが JSON に含まれる + assertTrue( + baselineResp.contains("\"x\""), + "baseline should decode with Shared type containing field 'x'", + ) + + // --- buggy: request1 → request2 → response for request1 --- + val buggy = EncodeGRPC() + buggy.registryOverrideForTest = registry + buggy.decodeClientRequest(httpRequest("/pp.multidir.ServiceA/Call", sharedBody)) + buggy.decodeClientRequest(httpRequest("/pp.multidir.ServiceB/Ping", pingReqBody)) + val buggyResp = String(buggy.decodeServerResponse(httpResponse(sharedBody))) + + // lastGrpcPath が "/pp.multidir.ServiceB/Ping" に上書きされているので + // PingResponse 型でデコードされ、"x" フィールドは現れない + assertNotEquals( + baselineResp, + buggyResp, + "BUG REPRODUCED: lastGrpcPath was overwritten by the second request, " + + "causing the first request's response to be decoded with wrong schema (PingResponse instead of Shared)", + ) + } +} diff --git a/src/test/kotlin/packetproxy/grpc/GrpcServiceRegistryStoreTest.kt b/src/test/kotlin/packetproxy/grpc/GrpcServiceRegistryStoreTest.kt index 6a07dc6b..811ed4f8 100644 --- a/src/test/kotlin/packetproxy/grpc/GrpcServiceRegistryStoreTest.kt +++ b/src/test/kotlin/packetproxy/grpc/GrpcServiceRegistryStoreTest.kt @@ -18,11 +18,20 @@ package packetproxy.grpc import java.io.File import java.nio.charset.StandardCharsets import java.nio.file.Files +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertSame import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test +import org.mockito.Mockito.mock +import org.mockito.Mockito.`when` +import packetproxy.model.ListenPort +import packetproxy.model.ListenPorts +import packetproxy.model.Server class GrpcServiceRegistryStoreTest { private fun resource(classpathPath: String): File { @@ -35,6 +44,11 @@ class GrpcServiceRegistryStoreTest { private val store: GrpcServiceRegistryStore get() = GrpcServiceRegistryStore.getInstance() + @AfterEach + fun clearDescriptorCache() { + store.invalidateAll() + } + @Test fun get_missingFile_throws() { assertThrows(Exception::class.java) { store.get(File("/nonexistent/path.desc")) } @@ -71,4 +85,82 @@ class GrpcServiceRegistryStoreTest { val entries = reg.getServiceMethodEntries() assertTrue(entries.any { it.first == "pp.testsvc.Greeter" && it.second == "SayHello" }) } + + @Test + fun get_withIncludeImports_resolvesImportedTopLevelType() { + val f = resource("proto/multidir/multi.desc") + val reg = store.get(f) + val shared = reg.findMessageByName("pp.multidir.Shared") + val timestamp = reg.findMessageByName("pp.multidir.Timestamp") + assertNotNull(shared) + assertNotNull(timestamp) + assertEquals("Shared", shared!!.name) + assertEquals("Timestamp", timestamp!!.name) + } + + @Test + fun get_withIncludeImports_resolvesImportedNestedType() { + val f = resource("proto/multidir/multi.desc") + val reg = store.get(f) + val detail = reg.findMessageByName("pp.multidir.Shared.Detail") + assertNotNull(detail) + assertEquals("Detail", detail!!.name) + } + + @Test + fun parseAuthorityHostPort_hostOnly_defaultsTo443() { + val s = store + assertEquals("example.com" to 443, s.parseAuthorityHostPort("example.com")) + } + + @Test + fun parseAuthorityHostPort_hostAndPort() { + val s = store + assertEquals("api.example.com" to 8443, s.parseAuthorityHostPort("api.example.com:8443")) + } + + @Test + fun parseAuthorityHostPort_ipv6WithPort() { + val s = store + assertEquals("2001:db8::1" to 443, s.parseAuthorityHostPort("[2001:db8::1]:443")) + } + + @Test + fun parseAuthorityHostPort_ipv6WithoutPort_defaults443() { + val s = store + assertEquals("::1" to 443, s.parseAuthorityHostPort("[::1]")) + } + + @Test + fun get_withIncludeImports_resolvesMethodTypesUsingImportedMessages() { + val f = resource("proto/multidir/multi.desc") + val reg = store.get(f) + val input = reg.getInputType("/pp.multidir.ServiceA/Call") + val output = reg.getOutputType("/pp.multidir.ServiceA/Call") + assertNotNull(input) + assertNotNull(output) + assertEquals("Shared", input!!.name) + assertEquals("Shared", output!!.name) + } + + /** + * Logic shared by [GrpcServiceRegistryStore.getByAuthority] for the transparent-listener case. + */ + @Test + fun tryResolveServerViaListenPort_returnsServerFromEnabledListenPort() { + val listenPorts = mock(ListenPorts::class.java) + val server = mock(Server::class.java) + val listenPort = mock(ListenPort::class.java) + `when`(listenPorts.queryEnabledByPort(ListenPort.Protocol.TCP, 59999)).thenReturn(listenPort) + `when`(listenPort.getServer()).thenReturn(server) + val out = store.tryResolveServerViaListenPort(59999, listenPorts) + assertSame(server, out) + } + + @Test + fun tryResolveServerViaListenPort_returnsNullWhenNoMatchingListenPort() { + val listenPorts = mock(ListenPorts::class.java) + `when`(listenPorts.queryEnabledByPort(ListenPort.Protocol.TCP, 59999)).thenReturn(null) + assertNull(store.tryResolveServerViaListenPort(59999, listenPorts)) + } } diff --git a/src/test/kotlin/packetproxy/grpc/GrpcServiceRegistryTest.kt b/src/test/kotlin/packetproxy/grpc/GrpcServiceRegistryTest.kt index 72e2b37b..01d7d1e9 100644 --- a/src/test/kotlin/packetproxy/grpc/GrpcServiceRegistryTest.kt +++ b/src/test/kotlin/packetproxy/grpc/GrpcServiceRegistryTest.kt @@ -46,4 +46,26 @@ class GrpcServiceRegistryTest { val reg = GrpcServiceRegistryStore.getInstance().get(resource("proto/testsvc.desc")) assertNotNull(reg.findMessageByName("pp.testsvc.HelloRequest")) } + + @Test + fun findMessageByName_nestedMessage_returnsDescriptor() { + val reg = GrpcServiceRegistryStore.getInstance().get(resource("proto/testsvc.desc")) + val metadata = reg.findMessageByName("pp.testsvc.HelloRequest.Metadata") + assertNotNull(metadata) + assertEquals("Metadata", metadata!!.name) + } + + @Test + fun findMessageByName_nestedMessageInAnotherType_returnsDescriptor() { + val reg = GrpcServiceRegistryStore.getInstance().get(resource("proto/testsvc.desc")) + val errorInfo = reg.findMessageByName("pp.testsvc.HelloReply.ErrorInfo") + assertNotNull(errorInfo) + assertEquals("ErrorInfo", errorInfo!!.name) + } + + @Test + fun findMessageByName_unknownNestedMessage_returnsNull() { + val reg = GrpcServiceRegistryStore.getInstance().get(resource("proto/testsvc.desc")) + assertNull(reg.findMessageByName("pp.testsvc.HelloRequest.Unknown")) + } } From 919cabc14ce45f1c6405f927c6f4cc710313b2d2 Mon Sep 17 00:00:00 2001 From: taka2233 Date: Tue, 28 Apr 2026 15:23:51 +0900 Subject: [PATCH 19/22] refactor: remove unnecessary blank lines in DuplexFactory for improved code clarity --- src/main/java/core/packetproxy/DuplexFactory.java | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/main/java/core/packetproxy/DuplexFactory.java b/src/main/java/core/packetproxy/DuplexFactory.java index fe3b21b3..566e3e18 100644 --- a/src/main/java/core/packetproxy/DuplexFactory.java +++ b/src/main/java/core/packetproxy/DuplexFactory.java @@ -70,7 +70,6 @@ private static void prepareDuplex(final Duplex duplex, Endpoint client_endpoint, duplex.addDuplexEventListener(new Duplex.DuplexEventListener() { private Packets packets = Packets.getInstance(); private Encoder encoder = EncoderManager.getInstance().createInstance(encoder_name, ALPN); - private Modifications mods = Modifications.getInstance(); private Packet client_packet; private Packet server_packet; @@ -348,7 +347,6 @@ public static DuplexSync createDuplexSyncFromOneShotPacket(final OneShotPacket o private Packets packets = Packets.getInstance(); private Encoder encoder = EncoderManager.getInstance().createInstance(oneshot.getEncoder(), oneshot.getAlpn()); - private Packet client_packet; private Packet server_packet; @@ -538,7 +536,6 @@ public static DuplexSync createDuplexSyncForSinglePacketAttack(final OneShotPack private Packets packets = Packets.getInstance(); private Encoder encoder = EncoderManager.getInstance().createInstance(oneshot.getEncoder(), oneshot.getAlpn()); - private Packet client_packet; private Packet server_packet; @@ -698,7 +695,6 @@ public static Duplex createDuplexFromOriginalDuplex(Duplex original_duplex, OneS private Packets packets = Packets.getInstance(); private Encoder encoder = EncoderManager.getInstance().createInstance(oneshot.getEncoder(), oneshot.getAlpn()); - private Packet client_packet; private Packet server_packet; From cd113d44c27af08cf73480cabcf358995e50c4a1 Mon Sep 17 00:00:00 2001 From: taka2233 Date: Tue, 28 Apr 2026 15:56:51 +0900 Subject: [PATCH 20/22] refactor: remove outdated comments and improve code clarity in GrpcServiceRegistry and GrpcServiceRegistryStore --- .../packetproxy/grpc/GrpcServiceRegistry.kt | 4 ---- .../grpc/GrpcServiceRegistryStore.kt | 20 +------------------ 2 files changed, 1 insertion(+), 23 deletions(-) diff --git a/src/main/kotlin/core/packetproxy/grpc/GrpcServiceRegistry.kt b/src/main/kotlin/core/packetproxy/grpc/GrpcServiceRegistry.kt index 9bf24591..581642ba 100644 --- a/src/main/kotlin/core/packetproxy/grpc/GrpcServiceRegistry.kt +++ b/src/main/kotlin/core/packetproxy/grpc/GrpcServiceRegistry.kt @@ -19,7 +19,6 @@ import com.google.protobuf.Descriptors.Descriptor import com.google.protobuf.Descriptors.FileDescriptor import java.util.Collections -/** Resolves gRPC `:path` values (e.g. `/pkg.Service/Method`) to protobuf message descriptors. */ class GrpcServiceRegistry(fileDescriptors: List) { private val inputByPath: Map private val outputByPath: Map @@ -59,9 +58,6 @@ class GrpcServiceRegistry(fileDescriptors: List) { return messageByFullName[fullName] } - /** - * gRPC `:path` keys (`/full.ServiceName/MethodName`) as service + method pairs for UI listing. - */ fun getServiceMethodEntries(): List> { return inputByPath.keys .map { grpcPath -> diff --git a/src/main/kotlin/core/packetproxy/grpc/GrpcServiceRegistryStore.kt b/src/main/kotlin/core/packetproxy/grpc/GrpcServiceRegistryStore.kt index 95f083bd..b0d93c26 100644 --- a/src/main/kotlin/core/packetproxy/grpc/GrpcServiceRegistryStore.kt +++ b/src/main/kotlin/core/packetproxy/grpc/GrpcServiceRegistryStore.kt @@ -30,19 +30,10 @@ import packetproxy.model.ListenPorts import packetproxy.model.Server import packetproxy.model.Servers -/** - * Caches [GrpcServiceRegistry] per descriptor file path so short-lived encoders do not re-parse - * `.desc`. - */ class GrpcServiceRegistryStore private constructor() { private val cache = ConcurrentHashMap() - /** - * Resolves a [GrpcServiceRegistry] from HTTP `:authority` (e.g. `host:443` or `[ipv6]:443`). - * Tries [Servers.queryByHostNameAndPort] first, then [Servers.queryByAddress] for IP literals, - * then an enabled [ListenPort] on the same TCP port (covers transparent proxy where authority is - * the listener address, not the upstream server). - */ + /** Transparent proxy では authority がリスナーアドレスになるため、Servers → ListenPort の順でフォールバックする */ fun getByAuthority(authority: String?): GrpcServiceRegistry? { if (authority.isNullOrBlank()) return null return try { @@ -70,11 +61,6 @@ class GrpcServiceRegistryStore private constructor() { } } - /** - * Resolves a [Server] when the authority does not match [Servers] rows, by looking up an enabled - * [ListenPort] on the same TCP port (transparent listener case). Exposed for unit tests with a - * mock [ListenPorts] (static [ListenPorts.getInstance] is not mockable). - */ internal fun tryResolveServerViaListenPort(port: Int, listenPorts: ListenPorts): Server? { return try { val listenPort = listenPorts.queryEnabledByPort(ListenPort.Protocol.TCP, port) ?: return null @@ -84,10 +70,6 @@ class GrpcServiceRegistryStore private constructor() { } } - /** - * Parses `:authority` into host and port. Port defaults to 443 when omitted (typical for gRPC - * over TLS). - */ internal fun parseAuthorityHostPort(authority: String): Pair? { if (authority.isEmpty()) return null if (authority.startsWith('[')) { From 465be851c3bec81a4e88288b57be9032cb73e25d Mon Sep 17 00:00:00 2001 From: taka2233 Date: Tue, 28 Apr 2026 16:38:48 +0900 Subject: [PATCH 21/22] refactor: consolidate gRPC message handling by introducing GrpcSchemaResolver for improved schema-aware encoding and decoding in EncodeGRPC and EncodeGRPCStreaming --- .../core/packetproxy/encode/EncodeGRPC.java | 160 ++--------------- .../encode/EncodeGRPCStreaming.java | 152 ++-------------- .../packetproxy/grpc/GrpcSchemaResolver.kt | 164 ++++++++++++++++++ .../encode/EncodeGRPCMultiplexBugTest.kt | 120 ------------- 4 files changed, 184 insertions(+), 412 deletions(-) create mode 100644 src/main/kotlin/core/packetproxy/grpc/GrpcSchemaResolver.kt delete mode 100644 src/test/kotlin/packetproxy/encode/EncodeGRPCMultiplexBugTest.kt diff --git a/src/main/java/core/packetproxy/encode/EncodeGRPC.java b/src/main/java/core/packetproxy/encode/EncodeGRPC.java index eb30f3a5..8522b2b1 100644 --- a/src/main/java/core/packetproxy/encode/EncodeGRPC.java +++ b/src/main/java/core/packetproxy/encode/EncodeGRPC.java @@ -15,48 +15,26 @@ */ package packetproxy.encode; -import com.fasterxml.jackson.core.JsonFactory; -import com.fasterxml.jackson.core.JsonToken; import com.google.protobuf.Descriptors.Descriptor; -import com.google.protobuf.DynamicMessage; -import com.google.protobuf.util.JsonFormat; import java.io.ByteArrayOutputStream; import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; -import java.util.ArrayList; import java.util.Arrays; -import java.util.Collections; -import java.util.List; import org.apache.commons.lang3.ArrayUtils; import packetproxy.common.Protobuf3; import packetproxy.common.Utils; +import packetproxy.grpc.GrpcSchemaResolver; import packetproxy.grpc.GrpcServiceRegistry; -import packetproxy.grpc.GrpcServiceRegistryStore; import packetproxy.http.Http; import packetproxy.http2.Grpc; -import packetproxy.util.Logging; public class EncodeGRPC extends EncodeHTTPBase { - private static final JsonFormat.Printer JSON_PRINTER = JsonFormat.printer().preservingProtoFieldNames() - .alwaysPrintFieldsWithNoPresence(); - private static final JsonFormat.Parser JSON_PARSER = JsonFormat.parser().ignoringUnknownFields(); + private final GrpcSchemaResolver schemaResolver = new GrpcSchemaResolver(); private byte compressedFlag; private volatile String lastGrpcPath; - /** - * Registry used when the server response Http lacks authority headers (paired - * request path). - */ - private volatile GrpcServiceRegistry lastResolvedRegistry; - - /** - * Same-package tests that build HTTP without Servers metadata (see - * EncodeGRPCMultiplexBugTest). - */ - volatile GrpcServiceRegistry registryOverrideForTest; - public EncodeGRPC() throws Exception { super(); } @@ -70,47 +48,13 @@ public String getName() { return "gRPC"; } - private GrpcServiceRegistry resolveRegistry(Http http) { - try { - if (registryOverrideForTest != null) { - return registryOverrideForTest; - } - String authority = http.getFirstHeader("X-PacketProxy-HTTP2-Host"); - if (authority.isEmpty()) { - authority = http.getFirstHeader("x-packetproxy-http3-host"); - } - if (authority.isEmpty()) { - String host = http.getHost(); - if (host != null && !host.isEmpty()) { - authority = host; - } - } - return GrpcServiceRegistryStore.getInstance().getByAuthority(authority); - } catch (Exception e) { - Logging.errWithStackTrace(e); - return null; - } - } - - private GrpcServiceRegistry effectiveRegistry(Http http) { - GrpcServiceRegistry reg = resolveRegistry(http); - if (reg != null) { - lastResolvedRegistry = reg; - return reg; - } - return lastResolvedRegistry; - } - @Override protected Http decodeClientRequestHttp(Http inputHttp) throws Exception { lastGrpcPath = inputHttp.getPath(); - GrpcServiceRegistry reg = resolveRegistry(inputHttp); - if (reg != null) { - lastResolvedRegistry = reg; - } + GrpcServiceRegistry reg = schemaResolver.resolveRegistryForRequest(inputHttp); Descriptor type = reg != null ? reg.getInputType(lastGrpcPath) : null; if (type != null) { - inputHttp.setBody(decodeSchemaAwareBody(inputHttp.getBody(), type)); + inputHttp.setBody(schemaResolver.decodeSchemaAwareBody(inputHttp.getBody(), type)); return inputHttp; } byte[] raw = inputHttp.getBody(); @@ -142,13 +86,10 @@ protected Http decodeClientRequestHttp(Http inputHttp) throws Exception { @Override protected Http encodeClientRequestHttp(Http inputHttp) throws Exception { lastGrpcPath = inputHttp.getPath(); - GrpcServiceRegistry reg = resolveRegistry(inputHttp); - if (reg != null) { - lastResolvedRegistry = reg; - } + GrpcServiceRegistry reg = schemaResolver.resolveRegistryForRequest(inputHttp); Descriptor type = reg != null ? reg.getInputType(lastGrpcPath) : null; if (type != null) { - inputHttp.setBody(encodeSchemaAwareBody(inputHttp.getBody(), type)); + inputHttp.setBody(schemaResolver.encodeSchemaAwareBody(inputHttp.getBody(), type)); return inputHttp; } byte[] body = inputHttp.getBody(); @@ -186,10 +127,10 @@ protected Http decodeServerResponseHttp(Http inputHttp) throws Exception { return inputHttp; } - GrpcServiceRegistry reg = effectiveRegistry(inputHttp); + GrpcServiceRegistry reg = schemaResolver.effectiveRegistry(inputHttp); Descriptor type = reg != null ? reg.getOutputType(lastGrpcPath) : null; if (type != null) { - inputHttp.setBody(decodeSchemaAwareBody(raw, type)); + inputHttp.setBody(schemaResolver.decodeSchemaAwareBody(raw, type)); return inputHttp; } ByteArrayOutputStream body = new ByteArrayOutputStream(); @@ -224,10 +165,10 @@ protected Http encodeServerResponseHttp(Http inputHttp) throws Exception { return inputHttp; } - GrpcServiceRegistry reg = effectiveRegistry(inputHttp); + GrpcServiceRegistry reg = schemaResolver.effectiveRegistry(inputHttp); Descriptor type = reg != null ? reg.getOutputType(lastGrpcPath) : null; if (type != null) { - inputHttp.setBody(encodeSchemaAwareBody(body, type)); + inputHttp.setBody(schemaResolver.encodeSchemaAwareBody(body, type)); return inputHttp; } ByteArrayOutputStream rawStream = new ByteArrayOutputStream(); @@ -272,85 +213,4 @@ public byte[] decodeGrpcServerPayload(byte[] payload) throws Exception { public byte[] encodeGrpcServerPayload(byte[] payload) throws Exception { return payload; } - - private byte[] decodeSchemaAwareBody(byte[] raw, Descriptor type) throws Exception { - ByteArrayOutputStream body = new ByteArrayOutputStream(); - int pos = 0; - while (pos < raw.length) { - if (raw[pos] != 0) { - throw new Exception("gRPC: compressed flag in gRPC message is not supported yet"); - } - pos += 1; - int messageLength = ByteBuffer.wrap(Arrays.copyOfRange(raw, pos, pos + 4)).getInt(); - pos += 4; - byte[] grpcMsg = Arrays.copyOfRange(raw, pos, pos + messageLength); - pos += messageLength; - if (body.size() > 0) { - body.write('\n'); - } - String json; - try { - json = JSON_PRINTER.print(DynamicMessage.parseFrom(type, grpcMsg)); - } catch (Exception e) { - json = Protobuf3.decode(grpcMsg); - } - body.write(json.getBytes(StandardCharsets.UTF_8)); - } - return body.toByteArray(); - } - - private byte[] encodeSchemaAwareBody(byte[] body, Descriptor type) throws Exception { - String s = new String(body, StandardCharsets.UTF_8); - List objects = splitTopLevelJsonObjects(s); - if (objects.isEmpty() && !s.trim().isEmpty()) { - objects = Collections.singletonList(s); - } - ByteArrayOutputStream rawStream = new ByteArrayOutputStream(); - for (String json : objects) { - String trimmed = json.trim(); - if (trimmed.isEmpty()) { - continue; - } - byte[] data; - try { - DynamicMessage.Builder builder = DynamicMessage.newBuilder(type); - JSON_PARSER.merge(trimmed, builder); - data = builder.build().toByteArray(); - } catch (Exception e) { - data = Protobuf3.encode(trimmed); - } - rawStream.write(0); - rawStream.write(ByteBuffer.allocate(4).putInt(data.length).array()); - rawStream.write(data); - } - return rawStream.toByteArray(); - } - - private List splitTopLevelJsonObjects(String text) { - if (text == null || text.isEmpty()) { - return Collections.emptyList(); - } - List out = new ArrayList<>(); - JsonFactory factory = new JsonFactory(); - try (com.fasterxml.jackson.core.JsonParser p = factory.createParser(text)) { - int depth = 0; - int start = -1; - while (p.nextToken() != null) { - if (p.currentToken() == JsonToken.START_OBJECT) { - if (depth == 0) { - start = (int) p.getCurrentLocation().getCharOffset(); - } - depth++; - } else if (p.currentToken() == JsonToken.END_OBJECT) { - depth--; - if (depth == 0 && start >= 0) { - int end = (int) p.getCurrentLocation().getCharOffset() + 1; - out.add(text.substring(start, end)); - } - } - } - } catch (Exception ignored) { - } - return out; - } } diff --git a/src/main/java/core/packetproxy/encode/EncodeGRPCStreaming.java b/src/main/java/core/packetproxy/encode/EncodeGRPCStreaming.java index cd69cc38..dea31ba5 100644 --- a/src/main/java/core/packetproxy/encode/EncodeGRPCStreaming.java +++ b/src/main/java/core/packetproxy/encode/EncodeGRPCStreaming.java @@ -15,73 +15,28 @@ */ package packetproxy.encode; -import com.fasterxml.jackson.core.JsonFactory; -import com.fasterxml.jackson.core.JsonToken; import com.google.protobuf.Descriptors.Descriptor; -import com.google.protobuf.DynamicMessage; -import com.google.protobuf.util.JsonFormat; import java.io.ByteArrayOutputStream; import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; -import java.util.ArrayList; import java.util.Arrays; -import java.util.Collections; -import java.util.List; import java.util.concurrent.ConcurrentHashMap; import org.apache.commons.lang3.ArrayUtils; import packetproxy.common.Protobuf3; import packetproxy.common.Utils; +import packetproxy.grpc.GrpcSchemaResolver; import packetproxy.grpc.GrpcServiceRegistry; -import packetproxy.grpc.GrpcServiceRegistryStore; import packetproxy.http.Http; import packetproxy.http2.GrpcStreaming; -import packetproxy.util.Logging; // gRPCでデータフレーム1つずつをメッセージと解釈して送受信するエンコーダ public class EncodeGRPCStreaming extends EncodeHTTPBase { - private static final JsonFormat.Printer JSON_PRINTER = JsonFormat.printer().preservingProtoFieldNames() - .alwaysPrintFieldsWithNoPresence(); - private static final JsonFormat.Parser JSON_PARSER = JsonFormat.parser().ignoringUnknownFields(); + private final GrpcSchemaResolver schemaResolver = new GrpcSchemaResolver(); private byte compressedFlag; private volatile String lastGrpcPath; private final ConcurrentHashMap grpcPathByStreamId = new ConcurrentHashMap<>(); - private volatile GrpcServiceRegistry lastResolvedRegistry; - - /** Same-package tests that build HTTP without Servers metadata. */ - volatile GrpcServiceRegistry registryOverrideForTest; - - private GrpcServiceRegistry resolveRegistry(Http http) { - try { - if (registryOverrideForTest != null) { - return registryOverrideForTest; - } - String authority = http.getFirstHeader("X-PacketProxy-HTTP2-Host"); - if (authority.isEmpty()) { - authority = http.getFirstHeader("x-packetproxy-http3-host"); - } - if (authority.isEmpty()) { - String host = http.getHost(); - if (host != null && !host.isEmpty()) { - authority = host; - } - } - return GrpcServiceRegistryStore.getInstance().getByAuthority(authority); - } catch (Exception e) { - Logging.errWithStackTrace(e); - return null; - } - } - - private GrpcServiceRegistry effectiveRegistry(Http http) { - GrpcServiceRegistry reg = resolveRegistry(http); - if (reg != null) { - lastResolvedRegistry = reg; - return reg; - } - return lastResolvedRegistry; - } private String resolveGrpcPathClient(Http http) { String path = http.getPath(); @@ -139,13 +94,10 @@ public String getName() { @Override protected Http decodeClientRequestHttp(Http inputHttp) throws Exception { lastGrpcPath = resolveGrpcPathClient(inputHttp); - GrpcServiceRegistry reg = resolveRegistry(inputHttp); - if (reg != null) { - lastResolvedRegistry = reg; - } + GrpcServiceRegistry reg = schemaResolver.resolveRegistryForRequest(inputHttp); Descriptor type = reg != null ? reg.getInputType(lastGrpcPath) : null; if (type != null) { - inputHttp.setBody(decodeSchemaAwareBody(inputHttp.getBody(), type)); + inputHttp.setBody(schemaResolver.decodeSchemaAwareBody(inputHttp.getBody(), type)); return inputHttp; } byte[] raw = inputHttp.getBody(); @@ -177,13 +129,10 @@ protected Http decodeClientRequestHttp(Http inputHttp) throws Exception { @Override protected Http encodeClientRequestHttp(Http inputHttp) throws Exception { lastGrpcPath = resolveGrpcPathClient(inputHttp); - GrpcServiceRegistry reg = resolveRegistry(inputHttp); - if (reg != null) { - lastResolvedRegistry = reg; - } + GrpcServiceRegistry reg = schemaResolver.resolveRegistryForRequest(inputHttp); Descriptor type = reg != null ? reg.getInputType(lastGrpcPath) : null; if (type != null) { - inputHttp.setBody(encodeSchemaAwareBody(inputHttp.getBody(), type)); + inputHttp.setBody(schemaResolver.encodeSchemaAwareBody(inputHttp.getBody(), type)); return inputHttp; } byte[] body = inputHttp.getBody(); @@ -222,10 +171,10 @@ protected Http decodeServerResponseHttp(Http inputHttp) throws Exception { return inputHttp; } lastGrpcPath = resolveGrpcPathServer(inputHttp); - GrpcServiceRegistry reg = effectiveRegistry(inputHttp); + GrpcServiceRegistry reg = schemaResolver.effectiveRegistry(inputHttp); Descriptor type = reg != null ? reg.getOutputType(lastGrpcPath) : null; if (type != null) { - inputHttp.setBody(decodeSchemaAwareBody(raw, type)); + inputHttp.setBody(schemaResolver.decodeSchemaAwareBody(raw, type)); return inputHttp; } ByteArrayOutputStream body = new ByteArrayOutputStream(); @@ -261,10 +210,10 @@ protected Http encodeServerResponseHttp(Http inputHttp) throws Exception { return inputHttp; } lastGrpcPath = resolveGrpcPathServer(inputHttp); - GrpcServiceRegistry reg = effectiveRegistry(inputHttp); + GrpcServiceRegistry reg = schemaResolver.effectiveRegistry(inputHttp); Descriptor type = reg != null ? reg.getOutputType(lastGrpcPath) : null; if (type != null) { - inputHttp.setBody(encodeSchemaAwareBody(body, type)); + inputHttp.setBody(schemaResolver.encodeSchemaAwareBody(body, type)); return inputHttp; } ByteArrayOutputStream rawStream = new ByteArrayOutputStream(); @@ -309,85 +258,4 @@ public byte[] decodeGrpcServerPayload(byte[] payload) throws Exception { public byte[] encodeGrpcServerPayload(byte[] payload) throws Exception { return payload; } - - private byte[] decodeSchemaAwareBody(byte[] raw, Descriptor type) throws Exception { - ByteArrayOutputStream body = new ByteArrayOutputStream(); - int pos = 0; - while (pos < raw.length) { - if (raw[pos] != 0) { - throw new Exception("gRPC: compressed flag in gRPC message is not supported yet"); - } - pos += 1; - int messageLength = ByteBuffer.wrap(Arrays.copyOfRange(raw, pos, pos + 4)).getInt(); - pos += 4; - byte[] grpcMsg = Arrays.copyOfRange(raw, pos, pos + messageLength); - pos += messageLength; - if (body.size() > 0) { - body.write('\n'); - } - String json; - try { - json = JSON_PRINTER.print(DynamicMessage.parseFrom(type, grpcMsg)); - } catch (Exception e) { - json = Protobuf3.decode(grpcMsg); - } - body.write(json.getBytes(StandardCharsets.UTF_8)); - } - return body.toByteArray(); - } - - private byte[] encodeSchemaAwareBody(byte[] body, Descriptor type) throws Exception { - String s = new String(body, StandardCharsets.UTF_8); - List objects = splitTopLevelJsonObjects(s); - if (objects.isEmpty() && !s.trim().isEmpty()) { - objects = Collections.singletonList(s); - } - ByteArrayOutputStream rawStream = new ByteArrayOutputStream(); - for (String json : objects) { - String trimmed = json.trim(); - if (trimmed.isEmpty()) { - continue; - } - byte[] data; - try { - DynamicMessage.Builder builder = DynamicMessage.newBuilder(type); - JSON_PARSER.merge(trimmed, builder); - data = builder.build().toByteArray(); - } catch (Exception e) { - data = Protobuf3.encode(trimmed); - } - rawStream.write(0); - rawStream.write(ByteBuffer.allocate(4).putInt(data.length).array()); - rawStream.write(data); - } - return rawStream.toByteArray(); - } - - private List splitTopLevelJsonObjects(String text) { - if (text == null || text.isEmpty()) { - return Collections.emptyList(); - } - List out = new ArrayList<>(); - JsonFactory factory = new JsonFactory(); - try (com.fasterxml.jackson.core.JsonParser p = factory.createParser(text)) { - int depth = 0; - int start = -1; - while (p.nextToken() != null) { - if (p.currentToken() == JsonToken.START_OBJECT) { - if (depth == 0) { - start = (int) p.getCurrentLocation().getCharOffset(); - } - depth++; - } else if (p.currentToken() == JsonToken.END_OBJECT) { - depth--; - if (depth == 0 && start >= 0) { - int end = (int) p.getCurrentLocation().getCharOffset() + 1; - out.add(text.substring(start, end)); - } - } - } - } catch (Exception ignored) { - } - return out; - } } diff --git a/src/main/kotlin/core/packetproxy/grpc/GrpcSchemaResolver.kt b/src/main/kotlin/core/packetproxy/grpc/GrpcSchemaResolver.kt new file mode 100644 index 00000000..ce5f035b --- /dev/null +++ b/src/main/kotlin/core/packetproxy/grpc/GrpcSchemaResolver.kt @@ -0,0 +1,164 @@ +/* + * Copyright 2026 DeNA Co., Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package packetproxy.grpc + +import com.fasterxml.jackson.core.JsonFactory +import com.fasterxml.jackson.core.JsonToken +import com.google.protobuf.Descriptors.Descriptor +import com.google.protobuf.DynamicMessage +import com.google.protobuf.util.JsonFormat +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer +import java.nio.charset.StandardCharsets +import packetproxy.common.Protobuf3 +import packetproxy.http.Http +import packetproxy.util.Logging + +/** + * gRPC body の JSON⇔protobuf 変換と [GrpcServiceRegistry] 解決を担う。 + * エンコーダごとに1インスタンス持ち、[lastResolvedRegistry] を通じて リクエスト時に解決した registry をレスポンス処理でも再利用する。 + */ +class GrpcSchemaResolver { + + @Volatile private var lastResolvedRegistry: GrpcServiceRegistry? = null + + @Volatile @JvmField internal var registryOverrideForTest: GrpcServiceRegistry? = null + + fun resolveRegistry(http: Http): GrpcServiceRegistry? { + return try { + registryOverrideForTest?.let { + return it + } + var authority = http.getFirstHeader("X-PacketProxy-HTTP2-Host") + if (authority.isEmpty()) { + authority = http.getFirstHeader("x-packetproxy-http3-host") + } + if (authority.isEmpty()) { + val host = http.host + if (!host.isNullOrEmpty()) { + authority = host + } + } + GrpcServiceRegistryStore.getInstance().getByAuthority(authority) + } catch (e: Exception) { + Logging.errWithStackTrace(e) + null + } + } + + fun effectiveRegistry(http: Http): GrpcServiceRegistry? { + val reg = resolveRegistry(http) + if (reg != null) { + lastResolvedRegistry = reg + return reg + } + return lastResolvedRegistry + } + + fun resolveRegistryForRequest(http: Http): GrpcServiceRegistry? { + val reg = resolveRegistry(http) + if (reg != null) { + lastResolvedRegistry = reg + } + return reg + } + + @Throws(Exception::class) + fun decodeSchemaAwareBody(raw: ByteArray, type: Descriptor): ByteArray { + val body = ByteArrayOutputStream() + var pos = 0 + while (pos < raw.size) { + if (raw[pos] != 0.toByte()) { + throw Exception("gRPC: compressed flag in gRPC message is not supported yet") + } + pos += 1 + val messageLength = ByteBuffer.wrap(raw, pos, 4).int + pos += 4 + val grpcMsg = raw.copyOfRange(pos, pos + messageLength) + pos += messageLength + if (body.size() > 0) { + body.write('\n'.code) + } + val json = + try { + JSON_PRINTER.print(DynamicMessage.parseFrom(type, grpcMsg)) + } catch (_: Exception) { + Protobuf3.decode(grpcMsg) + } + body.write(json.toByteArray(StandardCharsets.UTF_8)) + } + return body.toByteArray() + } + + @Throws(Exception::class) + fun encodeSchemaAwareBody(body: ByteArray, type: Descriptor): ByteArray { + val s = String(body, StandardCharsets.UTF_8) + var objects = splitTopLevelJsonObjects(s) + if (objects.isEmpty() && s.trim().isNotEmpty()) { + objects = listOf(s) + } + val rawStream = ByteArrayOutputStream() + for (json in objects) { + val trimmed = json.trim() + if (trimmed.isEmpty()) continue + val data = + try { + val builder = DynamicMessage.newBuilder(type) + JSON_PARSER.merge(trimmed, builder) + builder.build().toByteArray() + } catch (_: Exception) { + Protobuf3.encode(trimmed) + } + rawStream.write(0) + rawStream.write(ByteBuffer.allocate(4).putInt(data.size).array()) + rawStream.write(data) + } + return rawStream.toByteArray() + } + + private fun splitTopLevelJsonObjects(text: String): List { + if (text.isEmpty()) return emptyList() + val out = mutableListOf() + val factory = JsonFactory() + try { + factory.createParser(text).use { p -> + var depth = 0 + var start = -1 + while (p.nextToken() != null) { + if (p.currentToken() == JsonToken.START_OBJECT) { + if (depth == 0) { + start = p.currentLocation.charOffset.toInt() + } + depth++ + } else if (p.currentToken() == JsonToken.END_OBJECT) { + depth-- + if (depth == 0 && start >= 0) { + val end = p.currentLocation.charOffset.toInt() + 1 + out.add(text.substring(start, end)) + } + } + } + } + } catch (_: Exception) {} + return out + } + + companion object { + private val JSON_PRINTER: JsonFormat.Printer = + JsonFormat.printer().preservingProtoFieldNames().alwaysPrintFieldsWithNoPresence() + private val JSON_PARSER: JsonFormat.Parser = JsonFormat.parser().ignoringUnknownFields() + } +} diff --git a/src/test/kotlin/packetproxy/encode/EncodeGRPCMultiplexBugTest.kt b/src/test/kotlin/packetproxy/encode/EncodeGRPCMultiplexBugTest.kt deleted file mode 100644 index 1da2bd3f..00000000 --- a/src/test/kotlin/packetproxy/encode/EncodeGRPCMultiplexBugTest.kt +++ /dev/null @@ -1,120 +0,0 @@ -/* - * Copyright 2026 DeNA Co., Ltd. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package packetproxy.encode - -import com.google.protobuf.DynamicMessage -import java.io.ByteArrayOutputStream -import java.io.File -import java.nio.ByteBuffer -import org.junit.jupiter.api.Assertions.assertNotEquals -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.Test -import packetproxy.grpc.GrpcServiceRegistryStore - -/** - * EncodeGRPC が lastGrpcPath (単一フィールド) でレスポンスのスキーマを選択しているため、 HTTP/2 多重化で複数の unary RPC - * が同一接続上に来ると、後続リクエストのパスで 先行リクエストのレスポンスをデコードしてしまうバグの再現テスト。 - * - * multi.desc に含まれるサービス: ServiceA/Call : input=Shared, output=Shared (Shared には "x", "id" フィールド) - * ServiceB/Ping : input=PingRequest, output=PingResponse (PingResponse には "payload", "latency_ms" - * フィールド) - */ -class EncodeGRPCMultiplexBugTest { - - private fun resource(classpathPath: String): File { - val u = - EncodeGRPCMultiplexBugTest::class.java.getResource(classpathPath) - ?: throw IllegalStateException("missing resource: $classpathPath") - return File(u.toURI()) - } - - private fun grpcFrame(protoBytes: ByteArray): ByteArray { - val out = ByteArrayOutputStream() - out.write(0) - out.write(ByteBuffer.allocate(4).putInt(protoBytes.size).array()) - out.write(protoBytes) - return out.toByteArray() - } - - private fun httpRequest(path: String, grpcBody: ByteArray): ByteArray { - val header = - "POST $path HTTP/1.1\r\n" + - "Host: example.com\r\n" + - "Content-Type: application/grpc\r\n" + - "Content-Length: ${grpcBody.size}\r\n" + - "\r\n" - return header.toByteArray() + grpcBody - } - - private fun httpResponse(grpcBody: ByteArray): ByteArray { - val header = - "HTTP/1.1 200 OK\r\n" + - "Content-Type: application/grpc\r\n" + - "Content-Length: ${grpcBody.size}\r\n" + - "\r\n" - return header.toByteArray() + grpcBody - } - - @Test - fun responseDecodedWithWrongSchema_whenMultipleUnaryRpcsInterleaved() { - val descFile = resource("/packetproxy/grpc/proto/multidir/multi.desc") - val registry = GrpcServiceRegistryStore.getInstance().get(descFile) - - val sharedType = registry.getInputType("/pp.multidir.ServiceA/Call")!! - val pingReqType = registry.getInputType("/pp.multidir.ServiceB/Ping")!! - - val sharedMsg = - DynamicMessage.newBuilder(sharedType) - .setField(sharedType.findFieldByName("x"), "hello") - .setField(sharedType.findFieldByName("id"), 42) - .build() - val pingReqMsg = - DynamicMessage.newBuilder(pingReqType) - .setField(pingReqType.findFieldByName("ttl"), 100) - .build() - - val sharedBody = grpcFrame(sharedMsg.toByteArray()) - val pingReqBody = grpcFrame(pingReqMsg.toByteArray()) - - // --- baseline: request → response (1 stream, no interleaving) --- - val baseline = EncodeGRPC() - baseline.registryOverrideForTest = registry - baseline.decodeClientRequest(httpRequest("/pp.multidir.ServiceA/Call", sharedBody)) - val baselineResp = String(baseline.decodeServerResponse(httpResponse(sharedBody))) - - // Shared 型でデコードされていれば "x" フィールドが JSON に含まれる - assertTrue( - baselineResp.contains("\"x\""), - "baseline should decode with Shared type containing field 'x'", - ) - - // --- buggy: request1 → request2 → response for request1 --- - val buggy = EncodeGRPC() - buggy.registryOverrideForTest = registry - buggy.decodeClientRequest(httpRequest("/pp.multidir.ServiceA/Call", sharedBody)) - buggy.decodeClientRequest(httpRequest("/pp.multidir.ServiceB/Ping", pingReqBody)) - val buggyResp = String(buggy.decodeServerResponse(httpResponse(sharedBody))) - - // lastGrpcPath が "/pp.multidir.ServiceB/Ping" に上書きされているので - // PingResponse 型でデコードされ、"x" フィールドは現れない - assertNotEquals( - baselineResp, - buggyResp, - "BUG REPRODUCED: lastGrpcPath was overwritten by the second request, " + - "causing the first request's response to be decoded with wrong schema (PingResponse instead of Shared)", - ) - } -} From 5721877d697eeae199a02b29734f04ed24929c6e Mon Sep 17 00:00:00 2001 From: taka2233 Date: Tue, 28 Apr 2026 17:02:34 +0900 Subject: [PATCH 22/22] =?UTF-8?q?fix:=20token=E3=81=AE=E9=96=8B=E5=A7=8B?= =?UTF-8?q?=E4=BD=8D=E7=BD=AE=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/core/packetproxy/grpc/GrpcSchemaResolver.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/core/packetproxy/grpc/GrpcSchemaResolver.kt b/src/main/kotlin/core/packetproxy/grpc/GrpcSchemaResolver.kt index ce5f035b..1282403d 100644 --- a/src/main/kotlin/core/packetproxy/grpc/GrpcSchemaResolver.kt +++ b/src/main/kotlin/core/packetproxy/grpc/GrpcSchemaResolver.kt @@ -140,7 +140,7 @@ class GrpcSchemaResolver { while (p.nextToken() != null) { if (p.currentToken() == JsonToken.START_OBJECT) { if (depth == 0) { - start = p.currentLocation.charOffset.toInt() + start = p.tokenLocation.charOffset.toInt() } depth++ } else if (p.currentToken() == JsonToken.END_OBJECT) {