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' diff --git a/src/main/java/core/packetproxy/encode/EncodeGRPC.java b/src/main/java/core/packetproxy/encode/EncodeGRPC.java index 068490d9..8522b2b1 100644 --- a/src/main/java/core/packetproxy/encode/EncodeGRPC.java +++ b/src/main/java/core/packetproxy/encode/EncodeGRPC.java @@ -15,6 +15,7 @@ */ package packetproxy.encode; +import com.google.protobuf.Descriptors.Descriptor; import java.io.ByteArrayOutputStream; import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; @@ -22,12 +23,17 @@ import org.apache.commons.lang3.ArrayUtils; import packetproxy.common.Protobuf3; import packetproxy.common.Utils; +import packetproxy.grpc.GrpcSchemaResolver; +import packetproxy.grpc.GrpcServiceRegistry; import packetproxy.http.Http; import packetproxy.http2.Grpc; public class EncodeGRPC extends EncodeHTTPBase { + private final GrpcSchemaResolver schemaResolver = new GrpcSchemaResolver(); + private byte compressedFlag; + private volatile String lastGrpcPath; public EncodeGRPC() throws Exception { super(); @@ -44,6 +50,13 @@ public String getName() { @Override protected Http decodeClientRequestHttp(Http inputHttp) throws Exception { + lastGrpcPath = inputHttp.getPath(); + GrpcServiceRegistry reg = schemaResolver.resolveRegistryForRequest(inputHttp); + Descriptor type = reg != null ? reg.getInputType(lastGrpcPath) : null; + if (type != null) { + inputHttp.setBody(schemaResolver.decodeSchemaAwareBody(inputHttp.getBody(), type)); + return inputHttp; + } byte[] raw = inputHttp.getBody(); ByteArrayOutputStream body = new ByteArrayOutputStream(); int pos = 0; @@ -72,6 +85,13 @@ protected Http decodeClientRequestHttp(Http inputHttp) throws Exception { @Override protected Http encodeClientRequestHttp(Http inputHttp) throws Exception { + lastGrpcPath = inputHttp.getPath(); + GrpcServiceRegistry reg = schemaResolver.resolveRegistryForRequest(inputHttp); + Descriptor type = reg != null ? reg.getInputType(lastGrpcPath) : null; + if (type != null) { + inputHttp.setBody(schemaResolver.encodeSchemaAwareBody(inputHttp.getBody(), type)); + return inputHttp; + } byte[] body = inputHttp.getBody(); ByteArrayOutputStream rawStream = new ByteArrayOutputStream(); int pos = 0; @@ -107,6 +127,12 @@ protected Http decodeServerResponseHttp(Http inputHttp) throws Exception { return inputHttp; } + GrpcServiceRegistry reg = schemaResolver.effectiveRegistry(inputHttp); + Descriptor type = reg != null ? reg.getOutputType(lastGrpcPath) : null; + if (type != null) { + inputHttp.setBody(schemaResolver.decodeSchemaAwareBody(raw, type)); + return inputHttp; + } ByteArrayOutputStream body = new ByteArrayOutputStream(); int pos = 0; while (pos < raw.length) { @@ -139,6 +165,12 @@ protected Http encodeServerResponseHttp(Http inputHttp) throws Exception { return inputHttp; } + GrpcServiceRegistry reg = schemaResolver.effectiveRegistry(inputHttp); + Descriptor type = reg != null ? reg.getOutputType(lastGrpcPath) : null; + if (type != null) { + inputHttp.setBody(schemaResolver.encodeSchemaAwareBody(body, type)); + return inputHttp; + } ByteArrayOutputStream rawStream = new ByteArrayOutputStream(); int pos = 0; while (pos < body.length) { diff --git a/src/main/java/core/packetproxy/encode/EncodeGRPCStreaming.java b/src/main/java/core/packetproxy/encode/EncodeGRPCStreaming.java index fc80ad55..dea31ba5 100644 --- a/src/main/java/core/packetproxy/encode/EncodeGRPCStreaming.java +++ b/src/main/java/core/packetproxy/encode/EncodeGRPCStreaming.java @@ -15,20 +15,68 @@ */ package packetproxy.encode; +import com.google.protobuf.Descriptors.Descriptor; import java.io.ByteArrayOutputStream; import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; import java.util.Arrays; +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.http.Http; import packetproxy.http2.GrpcStreaming; // gRPCでデータフレーム1つずつをメッセージと解釈して送受信するエンコーダ public class EncodeGRPCStreaming extends EncodeHTTPBase { + private final GrpcSchemaResolver schemaResolver = new GrpcSchemaResolver(); + private byte compressedFlag; + private volatile String lastGrpcPath; + private final ConcurrentHashMap grpcPathByStreamId = new ConcurrentHashMap<>(); + + 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,6 +93,13 @@ public String getName() { @Override protected Http decodeClientRequestHttp(Http inputHttp) throws Exception { + lastGrpcPath = resolveGrpcPathClient(inputHttp); + GrpcServiceRegistry reg = schemaResolver.resolveRegistryForRequest(inputHttp); + Descriptor type = reg != null ? reg.getInputType(lastGrpcPath) : null; + if (type != null) { + inputHttp.setBody(schemaResolver.decodeSchemaAwareBody(inputHttp.getBody(), type)); + return inputHttp; + } byte[] raw = inputHttp.getBody(); ByteArrayOutputStream body = new ByteArrayOutputStream(); int pos = 0; @@ -73,6 +128,13 @@ protected Http decodeClientRequestHttp(Http inputHttp) throws Exception { @Override protected Http encodeClientRequestHttp(Http inputHttp) throws Exception { + lastGrpcPath = resolveGrpcPathClient(inputHttp); + GrpcServiceRegistry reg = schemaResolver.resolveRegistryForRequest(inputHttp); + Descriptor type = reg != null ? reg.getInputType(lastGrpcPath) : null; + if (type != null) { + inputHttp.setBody(schemaResolver.encodeSchemaAwareBody(inputHttp.getBody(), type)); + return inputHttp; + } byte[] body = inputHttp.getBody(); ByteArrayOutputStream rawStream = new ByteArrayOutputStream(); int pos = 0; @@ -108,6 +170,13 @@ protected Http decodeServerResponseHttp(Http inputHttp) throws Exception { return inputHttp; } + lastGrpcPath = resolveGrpcPathServer(inputHttp); + GrpcServiceRegistry reg = schemaResolver.effectiveRegistry(inputHttp); + Descriptor type = reg != null ? reg.getOutputType(lastGrpcPath) : null; + if (type != null) { + inputHttp.setBody(schemaResolver.decodeSchemaAwareBody(raw, type)); + return inputHttp; + } ByteArrayOutputStream body = new ByteArrayOutputStream(); int pos = 0; while (pos < raw.length) { @@ -140,6 +209,13 @@ protected Http encodeServerResponseHttp(Http inputHttp) throws Exception { return inputHttp; } + lastGrpcPath = resolveGrpcPathServer(inputHttp); + GrpcServiceRegistry reg = schemaResolver.effectiveRegistry(inputHttp); + Descriptor type = reg != null ? reg.getOutputType(lastGrpcPath) : null; + if (type != null) { + inputHttp.setBody(schemaResolver.encodeSchemaAwareBody(body, type)); + return inputHttp; + } ByteArrayOutputStream rawStream = new ByteArrayOutputStream(); int pos = 0; while (pos < body.length) { diff --git a/src/main/java/core/packetproxy/gui/GUIOptionServerDialog.java b/src/main/java/core/packetproxy/gui/GUIOptionServerDialog.java index cd4f82fd..5bad5e6a 100644 --- a/src/main/java/core/packetproxy/gui/GUIOptionServerDialog.java +++ b/src/main/java/core/packetproxy/gui/GUIOptionServerDialog.java @@ -23,6 +23,7 @@ import java.awt.event.ActionEvent; import java.awt.event.ActionListener; import java.util.regex.*; +import javax.swing.Box; import javax.swing.BoxLayout; import javax.swing.JButton; import javax.swing.JCheckBox; @@ -53,7 +54,14 @@ 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 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; private Server server = null; @@ -78,6 +86,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 +95,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(); + grpcDescriptorPath = (dp != null && !dp.isEmpty()) ? dp : null; + updateGrpcDescriptorUiVisibility(); setModal(true); setVisible(true); if (server != null) { @@ -98,12 +110,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 = grpcDescriptorPath != null ? grpcDescriptorPath.trim() : ""; + preset.setDescriptorPath(path.isEmpty() ? null : path); return preset; } return server; } public Server showDialog() { + editingServerId = null; + grpcDescriptorPath = null; + updateGrpcDescriptorUiVisibility(); EventQueue.invokeLater(new Runnable() { @Override @@ -171,6 +188,27 @@ 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)); + 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; + } + + 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 +236,23 @@ public void actionPerformed(ActionEvent e) { checkbox_dns.setEnabled(true); checkbox_dns6.setEnabled(true); } + updateGrpcDescriptorUiVisibility(); + } + }); + + combo.addActionListener(e -> updateGrpcDescriptorUiVisibility()); + + button_import_proto.addActionListener(e -> { + try { + 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"), + JOptionPane.ERROR_MESSAGE); } }); @@ -213,6 +268,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 +278,7 @@ public void actionPerformed(ActionEvent e) { panel.add(buttons()); c.add(panel); + updateGrpcDescriptorUiVisibility(); button_cancel.addActionListener(new ActionListener() { @@ -248,6 +305,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 = grpcDescriptorPath != null ? grpcDescriptorPath.trim() : ""; + server.setDescriptorPath(path.isEmpty() ? null : path); dispose(); } }); 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; + } + } } 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; 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..1282403d --- /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.tokenLocation.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/main/kotlin/core/packetproxy/grpc/GrpcServiceRegistry.kt b/src/main/kotlin/core/packetproxy/grpc/GrpcServiceRegistry.kt new file mode 100644 index 00000000..581642ba --- /dev/null +++ b/src/main/kotlin/core/packetproxy/grpc/GrpcServiceRegistry.kt @@ -0,0 +1,82 @@ +/* + * 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 + +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] + } + + 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) { + 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..b0d93c26 --- /dev/null +++ b/src/main/kotlin/core/packetproxy/grpc/GrpcServiceRegistryStore.kt @@ -0,0 +1,165 @@ +/* + * 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.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 + +class GrpcServiceRegistryStore private constructor() { + private val cache = ConcurrentHashMap() + + /** Transparent proxy では authority がリスナーアドレスになるため、Servers → ListenPort の順でフォールバックする */ + 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 + } + } + + 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 + } + } + + 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) { + throw IllegalArgumentException("descFile is null") + } + val key = descFile.canonicalPath + cache[key]?.let { + return it + } + synchronized(this) { + cache[key]?.let { + return it + } + 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 { + 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..e12d4911 --- /dev/null +++ b/src/main/kotlin/core/packetproxy/grpc/ProtocRunner.kt @@ -0,0 +1,140 @@ +/* + * 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 +import packetproxy.model.Database + +/** + * 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 + * 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) + 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 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)) + } + + @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) + } + } +} 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/test/kotlin/packetproxy/grpc/GrpcServiceRegistryStoreTest.kt b/src/test/kotlin/packetproxy/grpc/GrpcServiceRegistryStoreTest.kt new file mode 100644 index 00000000..811ed4f8 --- /dev/null +++ b/src/test/kotlin/packetproxy/grpc/GrpcServiceRegistryStoreTest.kt @@ -0,0 +1,166 @@ +/* + * 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.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 { + val u = + GrpcServiceRegistryStoreTest::class.java.getResource(classpathPath) + ?: throw IllegalStateException("missing resource: $classpathPath") + return File(u.toURI()) + } + + 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")) } + } + + @Test + 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_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" }) + } + + @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 new file mode 100644 index 00000000..01d7d1e9 --- /dev/null +++ b/src/test/kotlin/packetproxy/grpc/GrpcServiceRegistryTest.kt @@ -0,0 +1,71 @@ +/* + * 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 = 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) + assertNotNull(output) + assertEquals("HelloRequest", input!!.name) + assertEquals("HelloReply", output!!.name) + assertNull(reg.getInputType("/unknown.Service/Method")) + } + + @Test + fun findMessageByName_nestedIndexing() { + 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")) + } +} diff --git a/src/test/kotlin/packetproxy/grpc/ProtoFileSetTest.kt b/src/test/kotlin/packetproxy/grpc/ProtoFileSetTest.kt new file mode 100644 index 00000000..8960603f --- /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/packetproxy/grpc/proto/multidir/common.proto b/src/test/resources/packetproxy/grpc/proto/multidir/common.proto new file mode 100644 index 00000000..71e31b55 --- /dev/null +++ b/src/test/resources/packetproxy/grpc/proto/multidir/common.proto @@ -0,0 +1,34 @@ +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 new file mode 100644 index 00000000..2e0aa12d Binary files /dev/null and b/src/test/resources/packetproxy/grpc/proto/multidir/multi.desc differ 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..dfd70fd9 --- /dev/null +++ b/src/test/resources/packetproxy/grpc/proto/multidir/multi_without_imports.desc @@ -0,0 +1,52 @@ + + + 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.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 new file mode 100644 index 00000000..fa928d9b --- /dev/null +++ b/src/test/resources/packetproxy/grpc/proto/multidir/svc_a.proto @@ -0,0 +1,32 @@ +syntax = "proto3"; + +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 new file mode 100644 index 00000000..e0ec8505 --- /dev/null +++ b/src/test/resources/packetproxy/grpc/proto/multidir/svc_b.proto @@ -0,0 +1,35 @@ +syntax = "proto3"; + +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(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 new file mode 100644 index 00000000..8fd7ddac Binary files /dev/null and b/src/test/resources/packetproxy/grpc/proto/testsvc.desc differ diff --git a/src/test/resources/packetproxy/grpc/proto/testsvc.proto b/src/test/resources/packetproxy/grpc/proto/testsvc.proto new file mode 100644 index 00000000..300b51ea --- /dev/null +++ b/src/test/resources/packetproxy/grpc/proto/testsvc.proto @@ -0,0 +1,59 @@ +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); +}