From b3a7b20c6521b57efc3aedb61ef7c85605f4df5f Mon Sep 17 00:00:00 2001 From: 317787106 <317787106@qq.com> Date: Wed, 16 Sep 2026 22:28:43 +0800 Subject: [PATCH 01/19] feat(framework): support admin JSON-RPC over IPC and HTTP Add HTTP and Unix-domain-socket transports for the shared Admin JSON-RPC API, with an attach console for trusted node operators. Include transport configuration, socket permissions, request handling, bounded client lifecycle, and startup isolation regression tests. --- .../common/parameter/CommonParameter.java | 20 + .../org/tron/core/config/args/NodeConfig.java | 29 + common/src/main/resources/reference.conf | 26 + .../tron/core/config/args/NodeConfigTest.java | 25 + framework/build.gradle | 4 + .../tron/common/application/HttpService.java | 11 +- .../java/org/tron/core/config/args/Args.java | 56 +- .../tron/core/config/args/CLIParameter.java | 8 + .../config/args/CommandLineArguments.java | 59 ++ .../org/tron/core/config/args/InetUtil.java | 14 + .../core/services/admin/AdminJsonRpc.java | 17 + .../core/services/admin/AdminJsonRpcImpl.java | 15 + .../admin/http/AdminRpcHttpService.java | 54 ++ .../services/admin/http/AdminRpcServlet.java | 106 +++ .../admin/http/VirtualHostValidator.java | 89 +++ .../services/admin/ipc/client/IpcClient.java | 247 +++++++ .../admin/ipc/client/IpcCommandCompleter.java | 28 + .../admin/ipc/client/IpcConsoleCommands.java | 248 +++++++ .../admin/ipc/client/IpcResponse.java | 61 ++ .../admin/ipc/server/IpcRequestHandler.java | 111 +++ .../services/admin/ipc/server/IpcService.java | 293 ++++++++ .../admin/ipc/server/IpcSocketFiles.java | 115 +++ .../core/services/jsonrpc/JsonRpcMapper.java | 22 + .../services/jsonrpc/JsonRpcMediaType.java | 26 + .../core/services/jsonrpc/JsonRpcServlet.java | 15 +- .../main/java/org/tron/program/FullNode.java | 24 +- .../common/application/HttpServiceTest.java | 78 ++ .../org/tron/core/config/args/ArgsTest.java | 105 +++ .../config/args/CommandLineArgumentsTest.java | 43 ++ .../tron/core/config/args/InetUtilTest.java | 58 ++ .../admin/http/AdminRpcServletTest.java | 174 +++++ .../admin/http/VirtualHostValidatorTest.java | 74 ++ .../admin/ipc/client/IpcClientTest.java | 381 ++++++++++ .../ipc/client/IpcConsoleCommandsTest.java | 219 ++++++ .../admin/ipc/client/IpcResponseTest.java | 59 ++ .../ipc/server/IpcRequestHandlerTest.java | 178 +++++ .../admin/ipc/server/IpcServiceTest.java | 665 ++++++++++++++++++ .../admin/ipc/server/IpcSocketFilesTest.java | 220 ++++++ .../java/org/tron/program/FullNodeTest.java | 190 +++++ gradle/verification-metadata.xml | 44 ++ 40 files changed, 4179 insertions(+), 32 deletions(-) create mode 100644 framework/src/main/java/org/tron/core/config/args/CommandLineArguments.java create mode 100644 framework/src/main/java/org/tron/core/services/admin/AdminJsonRpc.java create mode 100644 framework/src/main/java/org/tron/core/services/admin/AdminJsonRpcImpl.java create mode 100644 framework/src/main/java/org/tron/core/services/admin/http/AdminRpcHttpService.java create mode 100644 framework/src/main/java/org/tron/core/services/admin/http/AdminRpcServlet.java create mode 100644 framework/src/main/java/org/tron/core/services/admin/http/VirtualHostValidator.java create mode 100644 framework/src/main/java/org/tron/core/services/admin/ipc/client/IpcClient.java create mode 100644 framework/src/main/java/org/tron/core/services/admin/ipc/client/IpcCommandCompleter.java create mode 100644 framework/src/main/java/org/tron/core/services/admin/ipc/client/IpcConsoleCommands.java create mode 100644 framework/src/main/java/org/tron/core/services/admin/ipc/client/IpcResponse.java create mode 100644 framework/src/main/java/org/tron/core/services/admin/ipc/server/IpcRequestHandler.java create mode 100644 framework/src/main/java/org/tron/core/services/admin/ipc/server/IpcService.java create mode 100644 framework/src/main/java/org/tron/core/services/admin/ipc/server/IpcSocketFiles.java create mode 100644 framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcMapper.java create mode 100644 framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcMediaType.java create mode 100644 framework/src/test/java/org/tron/common/application/HttpServiceTest.java create mode 100644 framework/src/test/java/org/tron/core/config/args/CommandLineArgumentsTest.java create mode 100644 framework/src/test/java/org/tron/core/services/admin/http/AdminRpcServletTest.java create mode 100644 framework/src/test/java/org/tron/core/services/admin/http/VirtualHostValidatorTest.java create mode 100644 framework/src/test/java/org/tron/core/services/admin/ipc/client/IpcClientTest.java create mode 100644 framework/src/test/java/org/tron/core/services/admin/ipc/client/IpcConsoleCommandsTest.java create mode 100644 framework/src/test/java/org/tron/core/services/admin/ipc/client/IpcResponseTest.java create mode 100644 framework/src/test/java/org/tron/core/services/admin/ipc/server/IpcRequestHandlerTest.java create mode 100644 framework/src/test/java/org/tron/core/services/admin/ipc/server/IpcServiceTest.java create mode 100644 framework/src/test/java/org/tron/core/services/admin/ipc/server/IpcSocketFilesTest.java create mode 100644 framework/src/test/java/org/tron/program/FullNodeTest.java diff --git a/common/src/main/java/org/tron/common/parameter/CommonParameter.java b/common/src/main/java/org/tron/common/parameter/CommonParameter.java index eeb92fdbd60..f5d0d483875 100644 --- a/common/src/main/java/org/tron/common/parameter/CommonParameter.java +++ b/common/src/main/java/org/tron/common/parameter/CommonParameter.java @@ -4,6 +4,7 @@ import java.net.InetAddress; import java.net.InetSocketAddress; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import lombok.Getter; import lombok.Setter; @@ -490,6 +491,25 @@ public class CommonParameter { public int jsonRpcMaxLogFilterNum = 20000; @Getter @Setter + public boolean adminHttpEnable = false; + @Getter + @Setter + public String adminHttpListenAddress = Constant.LOCAL_HOST; + @Getter + @Setter + public int adminHttpListenPort = 8575; + @Getter + @Setter + public List adminHttpVirtualHosts = + new ArrayList<>(Collections.singletonList("localhost")); + @Getter + @Setter + public boolean ipcEnable = false; + @Getter + @Setter + public String ipcSocketDirectory = ""; + @Getter + @Setter public int maxTransactionPendingSize; @Getter @Setter diff --git a/common/src/main/java/org/tron/core/config/args/NodeConfig.java b/common/src/main/java/org/tron/core/config/args/NodeConfig.java index 91945b5a73b..4971af55f8e 100644 --- a/common/src/main/java/org/tron/core/config/args/NodeConfig.java +++ b/common/src/main/java/org/tron/core/config/args/NodeConfig.java @@ -7,10 +7,12 @@ import com.typesafe.config.ConfigBeanFactory; import com.typesafe.config.ConfigValueFactory; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import lombok.Getter; import lombok.Setter; import lombok.extern.slf4j.Slf4j; +import org.tron.core.Constant; import org.tron.core.exception.TronError; // Node configuration bean for the "node" section of config.conf. @@ -128,6 +130,7 @@ public int getValidContractProtoThreads() { private HttpConfig http = new HttpConfig(); private RpcConfig rpc = new RpcConfig(); private JsonRpcConfig jsonrpc = new JsonRpcConfig(); + private AdminConfig admin = new AdminConfig(); private NodeBackupConfig backup = new NodeBackupConfig(); private DynamicConfigSection dynamicConfig = new DynamicConfigSection(); private DnsConfig dns = new DnsConfig(); @@ -252,6 +255,32 @@ public static class JsonRpcConfig { private long maxMessageSize = 4194304; } + @Getter + @Setter + public static class AdminConfig { + + private AdminIpcConfig ipc = new AdminIpcConfig(); + private AdminHttpConfig http = new AdminHttpConfig(); + } + + @Getter + @Setter + public static class AdminIpcConfig { + + private boolean enable = false; + private String socketDirectory = ""; + } + + @Getter + @Setter + public static class AdminHttpConfig { + + private boolean enable = false; + private String listenAddress = Constant.LOCAL_HOST; + private int port = 8575; + private List virtualHosts = new ArrayList<>(Collections.singletonList("localhost")); + } + @Getter @Setter public static class NodeBackupConfig { diff --git a/common/src/main/resources/reference.conf b/common/src/main/resources/reference.conf index d8c483d932a..4834f63f811 100644 --- a/common/src/main/resources/reference.conf +++ b/common/src/main/resources/reference.conf @@ -442,6 +442,32 @@ node { maxMessageSize = 4194304 } + # Administrative API settings. Disabled by default. + admin { + # Local Unix-domain socket administrative API. + ipc { + # Whether to enable the local Unix-domain socket admin API. Default: false. + enable = false + # Parent directory for the private ipc directory. It must be an absolute path + # when set. Empty means output-directory. The node fails to start if the resulting socket + # path exceeds the portable Unix-domain socket path limit. + socketDirectory = "" + } + + # Administrative JSON-RPC HTTP API. + http { + # Whether to enable the administrative JSON-RPC HTTP service. Default: false. + enable = false + # Address on which the service listens. Keep the default loopback address for security. + listenAddress = "127.0.0.1" + # TCP port on which the administrative JSON-RPC HTTP service listens. Default: 8575. + port = 8575 + # Allowed HTTP Host header names. Matching is case-insensitive and ignores the port. + # IP address literals are always allowed. Use ["*"] only to explicitly allow any hostname. + virtualHosts = ["localhost"] + } + } + # Disabled API list (works for http, rpc and pbft, not jsonrpc). Case insensitive. disabledApi = [ # "getaccount", diff --git a/common/src/test/java/org/tron/core/config/args/NodeConfigTest.java b/common/src/test/java/org/tron/core/config/args/NodeConfigTest.java index bcb8b09dd7a..93db46e25f4 100644 --- a/common/src/test/java/org/tron/core/config/args/NodeConfigTest.java +++ b/common/src/test/java/org/tron/core/config/args/NodeConfigTest.java @@ -7,6 +7,8 @@ import com.typesafe.config.Config; import com.typesafe.config.ConfigFactory; +import java.util.Arrays; +import java.util.Collections; import org.junit.Test; import org.tron.core.exception.TronError; @@ -30,6 +32,13 @@ public void testDefaults() { assertEquals(8, nc.getMinConnections()); assertEquals(4, nc.getMaxFastForwardNum()); assertFalse(nc.isOpenFullTcpDisconnect()); + assertFalse(nc.getAdmin().getIpc().isEnable()); + assertEquals("", nc.getAdmin().getIpc().getSocketDirectory()); + assertFalse(nc.getAdmin().getHttp().isEnable()); + assertEquals("127.0.0.1", nc.getAdmin().getHttp().getListenAddress()); + assertEquals(8575, nc.getAdmin().getHttp().getPort()); + assertEquals(Collections.singletonList("localhost"), + nc.getAdmin().getHttp().getVirtualHosts()); // reference.conf matches code default: discovery disabled when not configured assertFalse(nc.isDiscoveryEnable()); assertFalse(nc.isDiscoveryPersist()); @@ -79,6 +88,22 @@ public void testRpcSubBean() { assertEquals(60071, nc.getRpc().getPBFTPort()); } + @Test + public void testAdminHttpAndIpcBinding() { + Config config = withRef( + "node.admin { ipc { enable = true, socketDirectory = \"/tmp/tron-ipc\" }," + + " http { enable = true, listenAddress = \"127.0.0.2\", port = 18575," + + " virtualHosts = [\"admin.example.com\", \"localhost\"] } }"); + NodeConfig nc = NodeConfig.fromConfig(config); + assertTrue(nc.getAdmin().getIpc().isEnable()); + assertEquals("/tmp/tron-ipc", nc.getAdmin().getIpc().getSocketDirectory()); + assertTrue(nc.getAdmin().getHttp().isEnable()); + assertEquals("127.0.0.2", nc.getAdmin().getHttp().getListenAddress()); + assertEquals(18575, nc.getAdmin().getHttp().getPort()); + assertEquals(Arrays.asList("admin.example.com", "localhost"), + nc.getAdmin().getHttp().getVirtualHosts()); + } + @Test public void testBackupSubBean() { Config config = withRef( diff --git a/framework/build.gradle b/framework/build.gradle index 8255fc30d18..6c373858cf7 100644 --- a/framework/build.gradle +++ b/framework/build.gradle @@ -62,6 +62,8 @@ dependencies { testImplementation group: 'org.springframework', name: 'spring-test', version: "${springVersion}" testImplementation group: 'javax.portlet', name: 'portlet-api', version: '3.0.1' implementation group: 'org.zeromq', name: 'jeromq', version: '0.5.3' + implementation group: 'com.kohlschutter.junixsocket', name: 'junixsocket-core', version: '2.10.1' + implementation group: 'org.jline', name: 'jline', version: '3.21.0' api project(":chainbase") api project(":protocol") api project(":actuator") @@ -131,6 +133,8 @@ def configureTestTask = { Task t -> System.getProperty('runPrecompileBenchmark', 'false') t.doFirst { t.forkEvery = 100 + // Fresh-JVM startup tests must use production resources, including logback.xml. + t.systemProperty 'fullNode.runtimeClasspath', sourceSets.main.runtimeClasspath.asPath } } diff --git a/framework/src/main/java/org/tron/common/application/HttpService.java b/framework/src/main/java/org/tron/common/application/HttpService.java index 1dea271ec69..82ce0aff622 100644 --- a/framework/src/main/java/org/tron/common/application/HttpService.java +++ b/framework/src/main/java/org/tron/common/application/HttpService.java @@ -28,6 +28,7 @@ import org.eclipse.jetty.server.ConnectionLimit; import org.eclipse.jetty.server.Request; import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.ServerConnector; import org.eclipse.jetty.server.handler.ErrorHandler; import org.eclipse.jetty.server.handler.SizeLimitHandler; import org.eclipse.jetty.servlet.ServletContextHandler; @@ -39,6 +40,8 @@ public abstract class HttpService extends AbstractService { protected Server apiServer; + protected String listenAddress; + protected String contextPath; protected long maxRequestSize = 4 * 1024 * 1024; // 4MB @@ -77,7 +80,13 @@ public CompletableFuture start() { } protected void initServer() { - this.apiServer = new Server(this.port); + this.apiServer = new Server(); + ServerConnector connector = new ServerConnector(this.apiServer); + connector.setPort(this.port); + if (this.listenAddress != null) { + connector.setHost(this.listenAddress); + } + this.apiServer.addConnector(connector); int maxHttpConnectNumber = Args.getInstance().getMaxHttpConnectNumber(); if (maxHttpConnectNumber > 0) { this.apiServer.addBean(new ConnectionLimit(maxHttpConnectNumber, this.apiServer)); diff --git a/framework/src/main/java/org/tron/core/config/args/Args.java b/framework/src/main/java/org/tron/core/config/args/Args.java index 0bca242606e..cc686d660fe 100644 --- a/framework/src/main/java/org/tron/core/config/args/Args.java +++ b/framework/src/main/java/org/tron/core/config/args/Args.java @@ -104,6 +104,12 @@ public class Args extends CommonParameter { @Getter private static String configFilePath = ""; + @Getter + private static String ipcSocketFile; + + @Getter + private static String ipcExecCommand; + // Singleton config beans — populated at startup, read-only after init. // New code can read directly from these beans instead of CommonParameter. @Getter @@ -146,19 +152,32 @@ public class Args extends CommonParameter { * set parameters. */ public static void setParam(final String[] args, final String confFileName) { - // 1. Parse CLI args into a separate object - CLIParameter cmd = new CLIParameter(); - JCommander jc = JCommander.newBuilder().addObject(cmd).build(); - jc.parse(args); + setParam(new CommandLineArguments(args), confFileName); + } + + /** + * Reuses the options parsed by FullNode before it chooses client or node startup. + */ + public static void setParam(CommandLineArguments arguments, final String confFileName) { + CLIParameter cmd = arguments.getParameters(); if (cmd.version) { printVersion(); exit(0); } if (cmd.help) { - Args.printHelp(jc); + Args.printHelp(arguments.getCommander()); exit(0); } + List assignedParameters = arguments.getAssignedParameters(); + if (arguments.isAttachMode()) { + ipcSocketFile = cmd.ipcSocketFile; + ipcExecCommand = cmd.ipcExecCommand; + if (StringUtils.isNotEmpty(cmd.logbackPath)) { + PARAMETER.logbackPath = cmd.logbackPath; + } + return; + } // Resolve config file path configFilePath = StringUtils.isNoneBlank(cmd.shellConfFileName) @@ -169,7 +188,7 @@ public static void setParam(final String[] args, final String confFileName) { applyConfigParams(config); // 3. CLI overrides Config (highest priority, including --es → eventSubscribe) - applyCLIParams(cmd, jc); + applyCLIParams(cmd, assignedParameters); // 4. Apply event config after CLI applyEventConfig(eventConfig); @@ -561,6 +580,16 @@ private static void applyNodeConfig(NodeConfig nc) { PARAMETER.jsonRpcMaxLogFilterNum = jsonrpc.getMaxLogFilterNum(); PARAMETER.jsonRpcMaxMessageSize = jsonrpc.getMaxMessageSize(); + // ---- Admin HTTP / IPC ---- + NodeConfig.AdminIpcConfig adminIpc = nc.getAdmin().getIpc(); + NodeConfig.AdminHttpConfig adminHttp = nc.getAdmin().getHttp(); + PARAMETER.adminHttpEnable = adminHttp.isEnable(); + PARAMETER.adminHttpListenAddress = adminHttp.getListenAddress(); + PARAMETER.adminHttpListenPort = adminHttp.getPort(); + PARAMETER.adminHttpVirtualHosts = new ArrayList<>(adminHttp.getVirtualHosts()); + PARAMETER.ipcEnable = adminIpc.isEnable(); + PARAMETER.ipcSocketDirectory = adminIpc.getSocketDirectory(); + // ---- P2P sub-bean ---- PARAMETER.nodeP2pVersion = nc.getP2p().getVersion(); @@ -769,14 +798,13 @@ public static void applyConfigParams( * Apply CLI parameters that were explicitly passed. * Only assigned parameters override Config values. */ - private static void applyCLIParams(CLIParameter cmd, JCommander jc) { - Set assigned = jc.getParameters().stream() - .filter(ParameterDescription::isAssigned) + private static void applyCLIParams(CLIParameter cmd, + List assignedParameters) { + Set assigned = assignedParameters.stream() .map(ParameterDescription::getLongestName) .collect(Collectors.toSet()); - jc.getParameters().stream() - .filter(ParameterDescription::isAssigned) + assignedParameters.stream() .filter(pd -> { try { return CLIParameter.class.getDeclaredField(pd.getParameterized().getName()) @@ -946,6 +974,8 @@ public static void clearParam() { rateLimiterConfig = null; metricsConfig = null; eventConfig = null; + ipcSocketFile = null; + ipcExecCommand = null; } // getProposalExpirationTime removed — logic moved to BlockConfig.fromConfig() @@ -1292,7 +1322,8 @@ private static String getCommitIdAbbrev() { private static Map getOptionGroup() { String[] tronOption = new String[] {"version", "help", "shellConfFileName", "logbackPath", - "eventSubscribe", "solidityNode", "keystoreFactory"}; + "eventSubscribe", "solidityNode", "keystoreFactory", "ipcSocketFile", + "ipcExecCommand"}; String[] dbOption = new String[] {"outputDirectory"}; String[] witnessOption = new String[] {"witness", "privateKey"}; String[] vmOption = new String[] {"debug"}; @@ -1315,4 +1346,3 @@ private static Map getOptionGroup() { return optionGroupMap; } } - diff --git a/framework/src/main/java/org/tron/core/config/args/CLIParameter.java b/framework/src/main/java/org/tron/core/config/args/CLIParameter.java index 4f056a32e3a..441248ef3b9 100644 --- a/framework/src/main/java/org/tron/core/config/args/CLIParameter.java +++ b/framework/src/main/java/org/tron/core/config/args/CLIParameter.java @@ -53,6 +53,14 @@ public class CLIParameter { @Parameter(names = {"--keystore-factory"}, description = "running KeystoreFactory") public boolean keystoreFactory; + @Parameter(names = {"--attach"}, + description = "running an IPC client to interact with FullNode") + public String ipcSocketFile; + + @Parameter(names = {"--exec"}, + description = "execute one Admin IPC command and exit (requires --attach)") + public String ipcExecCommand; + @Deprecated @Parameter(names = {"--fast-forward"}) public boolean fastForward; diff --git a/framework/src/main/java/org/tron/core/config/args/CommandLineArguments.java b/framework/src/main/java/org/tron/core/config/args/CommandLineArguments.java new file mode 100644 index 00000000000..35cf2ef8d86 --- /dev/null +++ b/framework/src/main/java/org/tron/core/config/args/CommandLineArguments.java @@ -0,0 +1,59 @@ +package org.tron.core.config.args; + +import com.beust.jcommander.JCommander; +import com.beust.jcommander.ParameterDescription; +import java.util.List; +import java.util.stream.Collectors; +import lombok.Getter; +import org.apache.commons.lang3.StringUtils; +import org.tron.core.exception.TronError; + +/** + * Parses startup options without initializing node configuration or logging. Keep this class + * independent of Args, CommonParameter, and logging so FullNode can dispatch the standalone client. + */ +@Getter +public final class CommandLineArguments { + + private final CLIParameter parameters = new CLIParameter(); + private final JCommander commander = JCommander.newBuilder().addObject(parameters).build(); + private final List assignedParameters; + + public CommandLineArguments(String[] args) { + commander.parse(args); + assignedParameters = commander.getParameters().stream() + .filter(ParameterDescription::isAssigned) + .collect(Collectors.toList()); + } + + public boolean isAttachMode() { + // Preserve the existing help/version precedence over attach validation. + if (parameters.help || parameters.version) { + return false; + } + if (!isAssigned("ipcSocketFile")) { + if (isAssigned("ipcExecCommand")) { + throwAttachParameterError("Error: --exec requires --attach "); + } + return false; + } + if (isAssigned("shellConfFileName")) { + throwAttachParameterError("Error: --attach cannot be combined with: --config"); + } + if (StringUtils.isBlank(parameters.ipcSocketFile)) { + throwAttachParameterError("Error: --attach requires a non-empty "); + } + // Node-only CLI options are irrelevant to the standalone IPC client and are ignored. + return true; + } + + private boolean isAssigned(String fieldName) { + return assignedParameters.stream() + .anyMatch(pd -> fieldName.equals(pd.getParameterized().getName())); + } + + private static void throwAttachParameterError(String message) { + System.err.println(message); + throw new TronError(message, TronError.ErrCode.PARAMETER_INIT); + } +} diff --git a/framework/src/main/java/org/tron/core/config/args/InetUtil.java b/framework/src/main/java/org/tron/core/config/args/InetUtil.java index cdde93c73ed..70faa6c2321 100644 --- a/framework/src/main/java/org/tron/core/config/args/InetUtil.java +++ b/framework/src/main/java/org/tron/core/config/args/InetUtil.java @@ -188,6 +188,20 @@ public static InetAddress resolveInetAddress(String ipOrDomain) { return address; } + /** + * Checks whether a configured IP literal or hostname resolves to a loopback address. + * + *

Uses {@link #resolveInetAddress(String)} for hostname resolution. A null or unresolvable + * address returns false. + */ + public static boolean isLoopbackAddress(String ipOrDomain) { + if (ipOrDomain == null) { + return false; + } + InetAddress address = resolveInetAddress(ipOrDomain); + return address != null && address.isLoopbackAddress(); + } + private static boolean isIpLiteral(String host) { return NetUtil.validIpV4(host) || NetUtil.validIpV6(host); } diff --git a/framework/src/main/java/org/tron/core/services/admin/AdminJsonRpc.java b/framework/src/main/java/org/tron/core/services/admin/AdminJsonRpc.java new file mode 100644 index 00000000000..73a43f35c53 --- /dev/null +++ b/framework/src/main/java/org/tron/core/services/admin/AdminJsonRpc.java @@ -0,0 +1,17 @@ +package org.tron.core.services.admin; + +import com.googlecode.jsonrpc4j.JsonRpcError; +import com.googlecode.jsonrpc4j.JsonRpcErrors; +import com.googlecode.jsonrpc4j.JsonRpcMethod; +import com.googlecode.jsonrpc4j.JsonRpcParam; +import org.tron.core.exception.jsonrpc.JsonRpcInvalidParamsException; + +public interface AdminJsonRpc { + + @JsonRpcMethod("admin_example") + @JsonRpcErrors({ + @JsonRpcError(exception = JsonRpcInvalidParamsException.class, code = -32602, data = "{}"), + }) + String adminExample(@JsonRpcParam("param1") String param1, @JsonRpcParam("param2") String param2) + throws JsonRpcInvalidParamsException; +} diff --git a/framework/src/main/java/org/tron/core/services/admin/AdminJsonRpcImpl.java b/framework/src/main/java/org/tron/core/services/admin/AdminJsonRpcImpl.java new file mode 100644 index 00000000000..dba646bce07 --- /dev/null +++ b/framework/src/main/java/org/tron/core/services/admin/AdminJsonRpcImpl.java @@ -0,0 +1,15 @@ +package org.tron.core.services.admin; + +import org.springframework.stereotype.Component; +import org.tron.core.exception.jsonrpc.JsonRpcInvalidParamsException; + +@Component +public class AdminJsonRpcImpl implements AdminJsonRpc { + @Override + public String adminExample(String param1, String param2) throws JsonRpcInvalidParamsException { + if ("".equals(param1) || "".equals(param2)) { + throw new JsonRpcInvalidParamsException("param1 or param2 should not be empty"); + } + return param1 + ":" + param2; + } +} diff --git a/framework/src/main/java/org/tron/core/services/admin/http/AdminRpcHttpService.java b/framework/src/main/java/org/tron/core/services/admin/http/AdminRpcHttpService.java new file mode 100644 index 00000000000..f231b7eedf9 --- /dev/null +++ b/framework/src/main/java/org/tron/core/services/admin/http/AdminRpcHttpService.java @@ -0,0 +1,54 @@ +package org.tron.core.services.admin.http; + +import java.util.EnumSet; +import javax.servlet.DispatcherType; +import lombok.extern.slf4j.Slf4j; +import org.eclipse.jetty.servlet.FilterHolder; +import org.eclipse.jetty.servlet.ServletContextHandler; +import org.eclipse.jetty.servlet.ServletHandler; +import org.eclipse.jetty.servlet.ServletHolder; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.tron.common.application.HttpService; +import org.tron.core.config.args.Args; +import org.tron.core.config.args.InetUtil; +import org.tron.core.services.filter.HttpInterceptor; + +@Component +@Slf4j(topic = "API") +public class AdminRpcHttpService extends HttpService { + + @Autowired + private AdminRpcServlet adminRpcServlet; + + public AdminRpcHttpService() { + enable = isFullNode() && Args.getInstance().isAdminHttpEnable(); + listenAddress = Args.getInstance().getAdminHttpListenAddress(); + port = Args.getInstance().getAdminHttpListenPort(); + contextPath = "/"; + } + + @Override + public void innerStart() throws Exception { + if (enable && !InetUtil.isLoopbackAddress(listenAddress)) { + logger.warn("Admin HTTP is enabled on {} and may be accessible remotely. " + + "Restrict access to trusted networks.", listenAddress); + } + super.innerStart(); + } + + @Override + protected void addServlet(ServletContextHandler context) { + context.addServlet(new ServletHolder(adminRpcServlet), "/admin"); + } + + @Override + protected void addFilter(ServletContextHandler context) { + // filter + ServletHandler handler = new ServletHandler(); + FilterHolder fh = handler + .addFilterWithMapping(HttpInterceptor.class, "/*", + EnumSet.of(DispatcherType.REQUEST)); + context.addFilter(fh, "/*", EnumSet.of(DispatcherType.REQUEST)); + } +} diff --git a/framework/src/main/java/org/tron/core/services/admin/http/AdminRpcServlet.java b/framework/src/main/java/org/tron/core/services/admin/http/AdminRpcServlet.java new file mode 100644 index 00000000000..f5bdd760659 --- /dev/null +++ b/framework/src/main/java/org/tron/core/services/admin/http/AdminRpcServlet.java @@ -0,0 +1,106 @@ +package org.tron.core.services.admin.http; + +import com.googlecode.jsonrpc4j.HttpStatusCodeProvider; +import com.googlecode.jsonrpc4j.JsonRpcInterceptor; +import com.googlecode.jsonrpc4j.JsonRpcServer; +import com.googlecode.jsonrpc4j.ProxyUtil; +import java.io.IOException; +import java.util.Collections; +import javax.servlet.ServletConfig; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.tron.common.parameter.CommonParameter; +import org.tron.core.services.admin.AdminJsonRpc; +import org.tron.core.services.http.RateLimiterServlet; +import org.tron.core.services.jsonrpc.JsonRpcErrorResolver; +import org.tron.core.services.jsonrpc.JsonRpcMapper; +import org.tron.core.services.jsonrpc.JsonRpcMediaType; + +/** + * Serves the {@link AdminJsonRpc} API at {@code POST /admin} through jsonrpc4j. + * + *

This endpoint is intended for trusted node operators. Deployments must restrict access to + * loopback or a controlled management network. It intentionally does not apply the public JSON-RPC + * batch-size or response-size limits. HTTP request-size limits are enforced by + * {@link org.tron.common.application.HttpService}; JSON parser limits come from + * {@link JsonRpcMapper}. + */ +@Component +@Slf4j(topic = "API") +public class AdminRpcServlet extends RateLimiterServlet { + + private static final long serialVersionUID = 0L; + + private JsonRpcServer rpcServer = null; + private VirtualHostValidator virtualHostValidator = + new VirtualHostValidator(Collections.emptyList()); + + @Autowired + private AdminJsonRpc adminJsonRpc; + + @Autowired + private JsonRpcInterceptor interceptor; + + /** + * Initializes the HTTP dispatcher from the Admin API and snapshots the virtual-host policy. + */ + @Override + public void init(ServletConfig config) throws ServletException { + super.init(config); + + ClassLoader cl = Thread.currentThread().getContextClassLoader(); + // Expose the annotated Admin interface through the same proxy mechanism as public JSON-RPC. + Object compositeService = ProxyUtil.createCompositeServiceProxy(cl, + new Object[] {adminJsonRpc}, + new Class[] {AdminJsonRpc.class}, + true); + + // Keep parser constraints and annotation-based error mapping consistent with the IPC transport. + rpcServer = new JsonRpcServer(JsonRpcMapper.create(), compositeService); + rpcServer.setErrorResolver(JsonRpcErrorResolver.INSTANCE); + + // JSON-RPC result codes belong in the response body. HTTP validation below still uses 403/415. + HttpStatusCodeProvider httpStatusCodeProvider = new HttpStatusCodeProvider() { + @Override + public int getHttpStatusCode(int resultCode) { + return 200; + } + + @Override + public Integer getJsonRpcCode(int httpStatusCode) { + return null; + } + }; + rpcServer.setHttpStatusCodeProvider(httpStatusCodeProvider); + + rpcServer.setShouldLogInvocationErrors(false); + if (CommonParameter.getInstance().isMetricsPrometheusEnable()) { + rpcServer.setInterceptorList(Collections.singletonList(interceptor)); + } + virtualHostValidator = new VirtualHostValidator( + CommonParameter.getInstance().getAdminHttpVirtualHosts()); + } + + /** + * Applies HTTP-specific checks before handing request parsing and dispatch to jsonrpc4j. + */ + @Override + protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException { + // Check the requested hostname before dispatch to guard against DNS rebinding. + if (!virtualHostValidator.isAllowedHost(req.getHeader("Host"))) { + resp.sendError(HttpServletResponse.SC_FORBIDDEN, "Invalid Host header"); + return; + } + // Require JSON media types so browser form and text/plain submissions are rejected. + if (!JsonRpcMediaType.isSupported(req.getContentType())) { + resp.setStatus(HttpServletResponse.SC_UNSUPPORTED_MEDIA_TYPE); + resp.setContentLength(0); + return; + } + rpcServer.handle(req, resp); + } +} diff --git a/framework/src/main/java/org/tron/core/services/admin/http/VirtualHostValidator.java b/framework/src/main/java/org/tron/core/services/admin/http/VirtualHostValidator.java new file mode 100644 index 00000000000..402a931d49a --- /dev/null +++ b/framework/src/main/java/org/tron/core/services/admin/http/VirtualHostValidator.java @@ -0,0 +1,89 @@ +package org.tron.core.services.admin.http; + +import com.google.common.net.InetAddresses; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +/** + * Applies the Admin HTTP virtual-host policy without resolving request hostnames through DNS. + */ +final class VirtualHostValidator { + + private final Set virtualHosts; + + VirtualHostValidator(List configuredHosts) { + virtualHosts = normalizeVirtualHosts(configuredHosts); + } + + boolean isAllowedHost(String hostHeader) { + if (hostHeader == null || hostHeader.isEmpty()) { + // A browser always sends Host. Preserve compatibility for non-browser HTTP/1.0 clients. + return true; + } + String host = extractHost(hostHeader); + if (host == null) { + return false; + } + if (InetAddresses.isInetAddress(host)) { + return true; + } + return virtualHosts.contains("*") + || virtualHosts.contains(host.toLowerCase(Locale.ROOT)); + } + + private String extractHost(String hostHeader) { + // IPv6 + if (hostHeader.startsWith("[")) { + int closingBracket = hostHeader.indexOf(']'); + if (closingBracket <= 1) { + return null; + } + String suffix = hostHeader.substring(closingBracket + 1); + if (!suffix.isEmpty() && !isPortSuffix(suffix)) { + return null; + } + return hostHeader.substring(1, closingBracket); + } + + // Hostname or IPv4, with an optional port. + int firstColon = hostHeader.indexOf(':'); + if (firstColon < 0) { + return hostHeader; + } + if (firstColon != hostHeader.lastIndexOf(':')) { + return hostHeader; + } + String suffix = hostHeader.substring(firstColon); + if (!isPortSuffix(suffix)) { + return null; + } + return hostHeader.substring(0, firstColon); + } + + private boolean isPortSuffix(String suffix) { + if (suffix.length() <= 1 || suffix.charAt(0) != ':') { + return false; + } + for (int i = 1; i < suffix.length(); i++) { + if (!Character.isDigit(suffix.charAt(i))) { + return false; + } + } + return true; + } + + private Set normalizeVirtualHosts(List configuredHosts) { + Set normalizedHosts = new HashSet<>(); + if (configuredHosts == null) { + return normalizedHosts; + } + for (String configuredHost : configuredHosts) { + if (configuredHost != null && !configuredHost.trim().isEmpty()) { + normalizedHosts.add(configuredHost.trim().toLowerCase(Locale.ROOT)); + } + } + return normalizedHosts; + } +} diff --git a/framework/src/main/java/org/tron/core/services/admin/ipc/client/IpcClient.java b/framework/src/main/java/org/tron/core/services/admin/ipc/client/IpcClient.java new file mode 100644 index 00000000000..f60e8703fc8 --- /dev/null +++ b/framework/src/main/java/org/tron/core/services/admin/ipc/client/IpcClient.java @@ -0,0 +1,247 @@ +package org.tron.core.services.admin.ipc.client; + +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.File; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; +import java.net.Socket; +import java.net.SocketTimeoutException; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.atomic.AtomicBoolean; +import org.jline.reader.Completer; +import org.jline.reader.EndOfFileException; +import org.jline.reader.LineReader; +import org.jline.reader.LineReaderBuilder; +import org.jline.reader.SyntaxError; +import org.jline.reader.UserInterruptException; +import org.jline.reader.impl.completer.ArgumentCompleter; +import org.jline.reader.impl.completer.NullCompleter; +import org.jline.terminal.Terminal; +import org.jline.terminal.TerminalBuilder; +import org.newsclub.net.unix.AFUNIXSocket; +import org.newsclub.net.unix.AFUNIXSocketAddress; +import org.tron.core.services.admin.AdminJsonRpc; +import org.tron.core.services.admin.ipc.client.IpcConsoleCommands.Action; +import org.tron.core.services.admin.ipc.client.IpcConsoleCommands.Command; +import org.tron.program.Version; + +/** + * Owns the IPC connection and console session for interactive and single-command execution. + * Command preparation and response formatting are shared by both modes. + * + *

Keep this class independent of SLF4J, including Lombok's {@code @Slf4j}. Client diagnostics + * must be written to the console through {@link System#out}, {@link System#err}, or JLine so the + * client does not initialize or write to the node's Logback appenders. + */ +public class IpcClient { + + private static final int EXEC_RESPONSE_TIMEOUT_MILLIS = 30_000; + static final int EXIT_SUCCESS = 0; + static final int EXIT_FAILURE = 1; + + private final String socketFilePath; + private final IpcConsoleCommands commands = new IpcConsoleCommands(AdminJsonRpc.class); + + public IpcClient(String socketFilePath) { + this.socketFilePath = socketFilePath; + } + + public int start(String execCommand) { + try { + return run(execCommand); + } catch (IOException e) { + System.err.println("Failed to communicate with IPC server."); + return EXIT_FAILURE; + } + } + + public int run() throws IOException { + return run(null); + } + + int run(String execCommand) throws IOException { + File socketFile = new File(socketFilePath); + if (!socketFile.exists()) { + System.err.println("Error: IPC socket file does not exist: " + socketFile.getName()); + return EXIT_FAILURE; + } + AFUNIXSocketAddress address = AFUNIXSocketAddress.of(socketFile); + try (Socket socket = AFUNIXSocket.newInstance()) { + socket.connect(address); + if (execCommand != null) { + return runExec(socket, execCommand); + } + printWelcome(socketFile); + try (Terminal terminal = TerminalBuilder.builder().system(true).build()) { + LineReader reader = createLineReader(terminal); + runSession(socket, reader); + } + return EXIT_SUCCESS; + } + } + + int runExec(Socket socket, String commandLine) throws IOException { + Command command = prepareCommand(commandLine); + switch (command.getAction()) { + case EMPTY: + System.err.println("No command specified for --exec."); + return EXIT_FAILURE; + case EXIT: + case HELP: + return EXIT_SUCCESS; + case ERROR: + return EXIT_FAILURE; + case REQUEST: + break; + default: + throw new IllegalStateException("Unexpected IPC command action"); + } + + socket.setSoTimeout(EXEC_RESPONSE_TIMEOUT_MILLIS); + try (BufferedWriter serverWriter = new BufferedWriter( + new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8)); + BufferedReader serverReader = new BufferedReader( + new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8))) { + sendRequest(serverWriter, command.getRequest()); + + String response; + do { + try { + response = serverReader.readLine(); + } catch (SocketTimeoutException e) { + System.err.println("Timed out waiting for IPC response."); + return EXIT_FAILURE; + } + } while (response != null && response.trim().isEmpty()); + if (response == null) { + System.err.println("Disconnected from server before receiving a response."); + return EXIT_FAILURE; + } + IpcResponse parsedResponse = IpcResponse.parse(response); + if (parsedResponse.isSuccessful()) { + System.out.println(parsedResponse.getFormatted()); + return EXIT_SUCCESS; + } + System.err.println(parsedResponse.getFormatted()); + return EXIT_FAILURE; + } + } + + void runSession(Socket socket, LineReader reader) throws IOException { + AtomicBoolean connected = new AtomicBoolean(true); + startResponseReader(socket, reader, connected, Thread.currentThread()); + try { + readCommands(socket, reader, connected); + } finally { + // Mark a local exit before run() closes the socket, so it cannot look like a remote loss. + connected.set(false); + } + } + + /** Receives responses without blocking terminal input; remote loss wakes the input thread. */ + private void startResponseReader(final Socket socket, LineReader reader, AtomicBoolean connected, + Thread inputThread) throws IOException { + final BufferedReader serverReader = new BufferedReader( + new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8)); + + Thread readerThread = new Thread(() -> { + try { + String response; + while ((response = serverReader.readLine()) != null) { + if (!response.trim().isEmpty()) { + reader.printAbove(IpcResponse.parse(response).getFormatted()); + } + } + } catch (IOException e) { + // The socket closing is reported to the console by notifyDisconnected below. + } finally { + if (notifyDisconnected(connected, reader)) { + inputThread.interrupt(); + } + } + }, "admin-ipc-client-reader"); + readerThread.setDaemon(true); + readerThread.start(); + } + + private LineReader createLineReader(Terminal terminal) { + Completer commandCompleter = + new IpcCommandCompleter(commands.getCompletionCommandNames()); + ArgumentCompleter completer = new ArgumentCompleter( + commandCompleter, + NullCompleter.INSTANCE + ); + return LineReaderBuilder.builder() + .terminal(terminal) + .completer(completer) + .parser(commands.getParser()) + .variable(LineReader.INDENTATION, 2) + .option(LineReader.Option.AUTO_FRESH_LINE, true) + .option(LineReader.Option.CASE_INSENSITIVE, true) + .option(LineReader.Option.HISTORY_IGNORE_DUPS, true) + .option(LineReader.Option.HISTORY_REDUCE_BLANKS, true) + .build(); + } + + void printWelcome(File socketFile) { + System.out.println("Welcome to the java-tron admin console."); + System.out.println("Client: java-tron/" + Version.getVersion()); + System.out.println("IPC endpoint: " + socketFile.getAbsolutePath()); + System.out.println("Type \"help\" for available commands; \"exit\" or Ctrl-D to quit."); + } + + /** Reads commands until local exit or disconnection. The enclosing run() owns the socket. */ + private void readCommands(Socket socket, LineReader reader, AtomicBoolean connected) { + try { + BufferedWriter serverWriter = new BufferedWriter( + new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8)); + while (connected.get()) { + try { + Command command = prepareCommand(reader.readLine("> ")); + if (command.getAction() == Action.EXIT) { + break; + } + if (command.getAction() == Action.REQUEST) { + sendRequest(serverWriter, command.getRequest()); + } + } catch (SyntaxError e) { + // JLine can reject input before returning a line to the command parser. + System.err.println("Invalid command syntax."); + } catch (IllegalArgumentException e) { + System.err.println(e.getMessage()); + } + } + } catch (UserInterruptException | EndOfFileException e) { + // Ctrl-C, Ctrl-D, or server disconnection ends the interactive session. + } catch (IOException e) { + notifyDisconnected(connected, reader); + } + } + + private Command prepareCommand(String commandLine) { + Command command = commands.prepare(commandLine); + if (command.getError() != null) { + System.err.println(command.getError()); + } + if (command.getOutput() != null) { + System.out.println(command.getOutput()); + } + return command; + } + + private void sendRequest(BufferedWriter writer, String request) throws IOException { + writer.write(request); + writer.newLine(); + writer.flush(); + } + + private boolean notifyDisconnected(AtomicBoolean connected, LineReader reader) { + if (connected.compareAndSet(true, false)) { + reader.printAbove("Disconnected from server."); + return true; + } + return false; + } +} diff --git a/framework/src/main/java/org/tron/core/services/admin/ipc/client/IpcCommandCompleter.java b/framework/src/main/java/org/tron/core/services/admin/ipc/client/IpcCommandCompleter.java new file mode 100644 index 00000000000..7b5d1ce9d65 --- /dev/null +++ b/framework/src/main/java/org/tron/core/services/admin/ipc/client/IpcCommandCompleter.java @@ -0,0 +1,28 @@ +package org.tron.core.services.admin.ipc.client; + +import java.util.List; +import java.util.Locale; +import org.jline.reader.Candidate; +import org.jline.reader.Completer; +import org.jline.reader.LineReader; +import org.jline.reader.ParsedLine; + +public class IpcCommandCompleter implements Completer { + + private final String[] commands; + + public IpcCommandCompleter(String... commands) { + this.commands = commands; + } + + @Override + public void complete(LineReader reader, ParsedLine line, List candidates) { + String buffer = line.word().toLowerCase(Locale.ROOT); + + for (String cmd : commands) { + if (cmd.toLowerCase(Locale.ROOT).startsWith(buffer)) { + candidates.add(new Candidate(cmd, cmd, null, null, null, null, true)); + } + } + } +} diff --git a/framework/src/main/java/org/tron/core/services/admin/ipc/client/IpcConsoleCommands.java b/framework/src/main/java/org/tron/core/services/admin/ipc/client/IpcConsoleCommands.java new file mode 100644 index 00000000000..4fdd390842b --- /dev/null +++ b/framework/src/main/java/org/tron/core/services/admin/ipc/client/IpcConsoleCommands.java @@ -0,0 +1,248 @@ +package org.tron.core.services.admin.ipc.client; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.googlecode.jsonrpc4j.JsonRpcMethod; +import com.googlecode.jsonrpc4j.JsonRpcParam; +import java.lang.annotation.Annotation; +import java.lang.reflect.Method; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.jline.reader.Parser; +import org.jline.reader.SyntaxError; +import org.jline.reader.impl.DefaultParser; + +/** + * Parses console commands using the annotated admin API, without console or socket I/O. + * + *

Like {@link IpcClient}, this class must not initialize the node logging system. + */ +final class IpcConsoleCommands { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private final Map adminCommands; + private final DefaultParser parser = new DefaultParser().eofOnUnclosedQuote(true); + private int requestId; + + IpcConsoleCommands(Class adminApi) { + adminCommands = collectAdminCommands(adminApi); + } + + Parser getParser() { + return parser; + } + + String[] getCompletionCommandNames() { + return adminCommands.values().stream() + .map(command -> command.name) + .sorted() + .toArray(String[]::new); + } + + /** Returns an explicit action so help and invalid input cannot be mistaken for a request. */ + Command prepare(String commandLine) { + try { + return prepare(parseCommandLine(commandLine)); + } catch (SyntaxError e) { + return error("Invalid command syntax."); + } catch (JsonProcessingException e) { + return error("Failed to build IPC request."); + } catch (IllegalArgumentException e) { + return error(e.getMessage()); + } + } + + private Command prepare(List words) throws JsonProcessingException { + if (words.isEmpty()) { + return new Command(Action.EMPTY, null, null, null); + } + String name = words.get(0).toLowerCase(Locale.ROOT); + if ("exit".equals(name) || "quit".equals(name)) { + return new Command(Action.EXIT, null, null, null); + } + if ("help".equals(name)) { + AdminCommand command = words.size() == 2 + ? adminCommands.get(words.get(1).toLowerCase(Locale.ROOT)) : null; + String help = command == null ? buildHelp() : "usage: " + formatUsage(command); + return new Command(Action.HELP, null, help, null); + } + AdminCommand command = adminCommands.get(name); + if (command == null) { + return new Command(Action.ERROR, null, buildHelp(), "Invalid cmd: " + words.get(0)); + } + if (words.size() - 1 != command.parameters.size()) { + return error("Invalid parameter, usage: " + formatUsage(command)); + } + + List values = new ArrayList<>(); + for (int i = 0; i < command.parameters.size(); i++) { + Parameter parameter = command.parameters.get(i); + values.add(convertArgument(words.get(i + 1), parameter.type, parameter.name)); + } + Map request = new LinkedHashMap<>(); + request.put("jsonrpc", "2.0"); + request.put("method", command.name); + request.put("params", values); + request.put("id", ++requestId); + return new Command(Action.REQUEST, OBJECT_MAPPER.writeValueAsString(request), null, null); + } + + private List parseCommandLine(String commandLine) { + if (commandLine == null || commandLine.trim().isEmpty()) { + return Collections.emptyList(); + } + String normalized = commandLine.trim(); + return parser.parse(normalized, normalized.length(), Parser.ParseContext.ACCEPT_LINE).words(); + } + + private Map collectAdminCommands(Class adminApi) { + Map commands = new HashMap<>(); + for (Method method : adminApi.getDeclaredMethods()) { + JsonRpcMethod rpcMethod = method.getAnnotation(JsonRpcMethod.class); + if (rpcMethod == null || rpcMethod.value() == null) { + continue; + } + + List parameters = new ArrayList<>(); + Annotation[][] annotations = method.getParameterAnnotations(); + Type[] types = method.getGenericParameterTypes(); + for (int i = 0; i < annotations.length; i++) { + String name = null; + for (Annotation annotation : annotations[i]) { + if (annotation instanceof JsonRpcParam) { + name = ((JsonRpcParam) annotation).value(); + break; + } + } + if (StringUtils.isEmpty(name)) { + throw new IllegalStateException("Missing @JsonRpcParam on " + method.getName() + + " parameter " + i); + } + parameters.add(new Parameter(name, OBJECT_MAPPER.getTypeFactory().constructType(types[i]))); + } + commands.put(rpcMethod.value().toLowerCase(Locale.ROOT), + new AdminCommand(rpcMethod.value(), parameters)); + } + return commands; + } + + private String buildHelp() { + StringBuilder help = new StringBuilder("Available commands:"); + for (String name : getCompletionCommandNames()) { + help.append(System.lineSeparator()).append(" ") + .append(formatUsage(adminCommands.get(name.toLowerCase(Locale.ROOT)))); + } + return help.append(System.lineSeparator()).append(" help [command]") + .append(System.lineSeparator()).append(" exit/quit").toString(); + } + + private String formatUsage(AdminCommand command) { + StringBuilder usage = new StringBuilder(command.name); + for (Parameter parameter : command.parameters) { + usage.append(" <").append(parameter.name).append(":") + .append(formatType(parameter.type)).append(">"); + } + return usage.toString(); + } + + private String formatType(JavaType type) { + Class rawClass = type.getRawClass(); + if (String.class.equals(rawClass) || CharSequence.class.equals(rawClass)) { + return "string"; + } + if (Boolean.class.equals(rawClass) || Boolean.TYPE.equals(rawClass)) { + return "boolean"; + } + if (Number.class.isAssignableFrom(rawClass) || rawClass.isPrimitive()) { + return rawClass.getSimpleName().toLowerCase(Locale.ROOT); + } + if (rawClass.isArray() || java.util.Collection.class.isAssignableFrom(rawClass)) { + return "array"; + } + if (Map.class.isAssignableFrom(rawClass)) { + return "object"; + } + return rawClass.getSimpleName(); + } + + private Object convertArgument(String value, JavaType targetType, String parameterName) { + Class rawClass = targetType.getRawClass(); + if (String.class.equals(rawClass) || CharSequence.class.equals(rawClass)) { + return value; + } + if (Character.class.equals(rawClass) || Character.TYPE.equals(rawClass)) { + if (value.length() == 1) { + return value.charAt(0); + } + throw invalidParameterType(parameterName, targetType, null); + } + if (rawClass.isPrimitive() && "null".equals(value.trim())) { + throw invalidParameterType(parameterName, targetType, null); + } + Object convertedValue; + try { + if (rawClass.isEnum()) { + convertedValue = OBJECT_MAPPER.convertValue(value, targetType); + } else { + convertedValue = OBJECT_MAPPER.readValue(value, targetType); + } + } catch (JsonProcessingException | IllegalArgumentException e) { + throw invalidParameterType(parameterName, targetType, e); + } + if (convertedValue == null && rawClass.isPrimitive()) { + throw invalidParameterType(parameterName, targetType, null); + } + return convertedValue; + } + + private IllegalArgumentException invalidParameterType(String parameterName, JavaType targetType, + Throwable cause) { + return new IllegalArgumentException( + "Invalid value for <" + parameterName + ">; expected " + targetType.toCanonical(), cause); + } + + private Command error(String message) { + return new Command(Action.ERROR, null, null, message); + } + + enum Action { + EMPTY, EXIT, HELP, REQUEST, ERROR + } + + /** Immutable result; only REQUEST carries wire data, while help/errors carry console text. */ + @Getter(AccessLevel.PACKAGE) + @RequiredArgsConstructor(access = AccessLevel.PRIVATE) + static final class Command { + + private final Action action; + private final String request; + private final String output; + private final String error; + } + + @RequiredArgsConstructor(access = AccessLevel.PRIVATE) + private static final class AdminCommand { + + private final String name; + private final List parameters; + } + + @RequiredArgsConstructor(access = AccessLevel.PRIVATE) + private static final class Parameter { + + private final String name; + private final JavaType type; + } +} diff --git a/framework/src/main/java/org/tron/core/services/admin/ipc/client/IpcResponse.java b/framework/src/main/java/org/tron/core/services/admin/ipc/client/IpcResponse.java new file mode 100644 index 00000000000..af2c47f2394 --- /dev/null +++ b/framework/src/main/java/org/tron/core/services/admin/ipc/client/IpcResponse.java @@ -0,0 +1,61 @@ +package org.tron.core.services.admin.ipc.client; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.AccessLevel; +import lombok.Getter; + +/** A console-ready JSON-RPC response, parsed without initializing node logging. */ +@Getter(AccessLevel.PACKAGE) +final class IpcResponse { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private final String formatted; + private final boolean successful; + + private IpcResponse(String formatted, boolean successful) { + this.formatted = formatted; + this.successful = successful; + } + + /** + * Parses a JSON-RPC response into console text and a success flag. + * + *

A non-null error takes precedence over any result; otherwise, a result field, including + * a null value, marks the response as successful. Text results are unquoted and other values + * are formatted as JSON. Responses without a result are unsuccessful; empty or malformed + * input is preserved verbatim. + */ + static IpcResponse parse(String response) { + try { + JsonNode root = OBJECT_MAPPER.readTree(response); + if (root == null || root.isMissingNode()) { + return new IpcResponse(response, false); + } + JsonNode error = root.get("error"); + if (error != null && !error.isNull()) { + String code = error.has("code") ? " " + error.get("code").asText() : ""; + String message = error.has("message") ? error.get("message").asText() : "Unknown error"; + return new IpcResponse("Error" + code + ": " + message, false); + } + if (root.has("result")) { + return new IpcResponse(formatJsonValue(root.get("result")), true); + } + return new IpcResponse(formatJsonValue(root), false); + } catch (JsonProcessingException e) { + return new IpcResponse(response, false); + } + } + + private static String formatJsonValue(JsonNode value) throws JsonProcessingException { + if (value == null || value.isNull()) { + return "null"; + } + if (value.isTextual()) { + return value.asText(); + } + return OBJECT_MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(value); + } +} diff --git a/framework/src/main/java/org/tron/core/services/admin/ipc/server/IpcRequestHandler.java b/framework/src/main/java/org/tron/core/services/admin/ipc/server/IpcRequestHandler.java new file mode 100644 index 00000000000..ec6d946805c --- /dev/null +++ b/framework/src/main/java/org/tron/core/services/admin/ipc/server/IpcRequestHandler.java @@ -0,0 +1,111 @@ +package org.tron.core.services.admin.ipc.server; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.NullNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.googlecode.jsonrpc4j.JsonRpcServer; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.tron.core.services.admin.AdminJsonRpc; +import org.tron.core.services.jsonrpc.JsonRpcErrorResolver; +import org.tron.core.services.jsonrpc.JsonRpcMapper; + +/** + * Reads bounded IPC request lines and dispatches Admin JSON-RPC calls. + * Per-request buffers are local so one handler can serve concurrent client connections. + * Stream and socket ownership remains with {@link IpcService}. + */ +@Slf4j(topic = "API") +final class IpcRequestHandler { + + private static final ObjectMapper OBJECT_MAPPER = JsonRpcMapper.create(); + + private final JsonRpcServer jsonRpcServer; + @Getter(AccessLevel.PACKAGE) + private final int maxRequestSize; + + IpcRequestHandler(AdminJsonRpc adminJsonRpc, int maxRequestSize) { + this.maxRequestSize = maxRequestSize; + jsonRpcServer = new JsonRpcServer(OBJECT_MAPPER, adminJsonRpc, AdminJsonRpc.class); + jsonRpcServer.setErrorResolver(JsonRpcErrorResolver.INSTANCE); + jsonRpcServer.setShouldLogInvocationErrors(false); + } + + String readRequest(InputStream input) throws IOException { + ByteArrayOutputStream request = new ByteArrayOutputStream(); + int value; + while ((value = input.read()) != -1) { + if (value == '\n') { + break; + } + if (request.size() >= maxRequestSize) { + throw new RequestTooLargeException(); + } + request.write(value); + } + if (value == -1 && request.size() == 0) { + return null; + } + byte[] bytes = request.toByteArray(); + int length = bytes.length; + if (length > 0 && bytes[length - 1] == '\r') { + length--; + } + return new String(bytes, 0, length, StandardCharsets.UTF_8); + } + + String handleCommand(String jsonRequest) { + ByteArrayInputStream input = + new ByteArrayInputStream(jsonRequest.getBytes(StandardCharsets.UTF_8)); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + + try { + jsonRpcServer.handleRequest(input, output); + if (output.size() == 0) { + return ""; + } + JsonNode response = OBJECT_MAPPER.readTree(output.toByteArray()); + return response == null ? "" : OBJECT_MAPPER.writeValueAsString(response); + } catch (Exception e) { + logger.debug("Failed to dispatch IPC request"); + return buildInternalErrorResponse(jsonRequest); + } + } + + private String buildInternalErrorResponse(String jsonRequest) { + JsonNode requestId = NullNode.getInstance(); + try { + JsonNode request = OBJECT_MAPPER.readTree(jsonRequest); + if (request != null && request.has("id")) { + requestId = request.get("id"); + } + } catch (IOException e) { + logger.debug("Unable to read request id from invalid IPC request"); + } + + ObjectNode error = OBJECT_MAPPER.createObjectNode(); + error.put("code", -32603); + error.put("message", "Internal error"); + ObjectNode response = OBJECT_MAPPER.createObjectNode(); + response.put("jsonrpc", "2.0"); + response.set("error", error); + response.set("id", requestId); + try { + return OBJECT_MAPPER.writeValueAsString(response); + } catch (IOException e) { + throw new IllegalStateException("Failed to serialize IPC error response", e); + } + } + + static final class RequestTooLargeException extends IOException { + + private static final long serialVersionUID = 1L; + } +} diff --git a/framework/src/main/java/org/tron/core/services/admin/ipc/server/IpcService.java b/framework/src/main/java/org/tron/core/services/admin/ipc/server/IpcService.java new file mode 100644 index 00000000000..5ec86d30230 --- /dev/null +++ b/framework/src/main/java/org/tron/core/services/admin/ipc/server/IpcService.java @@ -0,0 +1,293 @@ +package org.tron.core.services.admin.ipc.server; + +import java.io.BufferedInputStream; +import java.io.BufferedWriter; +import java.io.File; +import java.io.IOException; +import java.io.OutputStreamWriter; +import java.lang.management.ManagementFactory; +import java.net.SocketTimeoutException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.SynchronousQueue; +import java.util.concurrent.TimeUnit; +import lombok.extern.slf4j.Slf4j; +import org.newsclub.net.unix.AFUNIXServerSocket; +import org.newsclub.net.unix.AFUNIXSocket; +import org.newsclub.net.unix.AFUNIXSocketAddress; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.tron.common.application.AbstractService; +import org.tron.common.es.ExecutorServiceManager; +import org.tron.common.exit.ExitManager; +import org.tron.common.parameter.CommonParameter; +import org.tron.core.config.args.Args; +import org.tron.core.services.admin.AdminJsonRpc; +import org.tron.core.services.admin.ipc.server.IpcRequestHandler.RequestTooLargeException; + +/** + * Provides the local Admin JSON-RPC endpoint over a Unix domain socket. It accepts and dispatches + * client connections and owns the startup, worker, and cleanup lifecycle. Filesystem operations + * and request handling are delegated to package-local components. + */ +@Component +@Slf4j(topic = "API") +public class IpcService extends AbstractService { + + private static final String ACCEPTOR_EXECUTOR_NAME = "admin-ipc-acceptor"; + private static final String CLIENT_EXECUTOR_NAME = "admin-ipc-client"; + private static final int CLIENT_IDLE_TIMEOUT_MILLIS = 10 * 60 * 1000; + private static final int EXECUTOR_SHUTDOWN_TIMEOUT_SECONDS = 1; + + private final IpcSocketFiles socketFiles = new IpcSocketFiles(); + private final IpcRequestHandler requestHandler; + + private final ExecutorService acceptorExecutor = + ExecutorServiceManager.newSingleThreadExecutor(ACCEPTOR_EXECUTOR_NAME, true); + private final ExecutorService clientExecutor = + ExecutorServiceManager.newThreadPoolExecutor(4, 16, 60L, TimeUnit.SECONDS, + new SynchronousQueue<>(), CLIENT_EXECUTOR_NAME, true); + + private final Set activeClientSockets = ConcurrentHashMap.newKeySet(); + private AFUNIXServerSocket unixServerSocket; + private Path socketFilePath; + + private volatile boolean isRunning; + + @Autowired + public IpcService(AdminJsonRpc adminJsonRpc) { + enable = isFullNode() && Args.getInstance().isIpcEnable(); + requestHandler = new IpcRequestHandler(adminJsonRpc, Args.getInstance().maxMessageSize); + } + + @Override + public CompletableFuture start() { + CompletableFuture resultFuture = new CompletableFuture<>(); + try { + innerStart(); + resultFuture.complete(true); + } catch (Exception e) { + resultFuture.completeExceptionally(e); + } + return resultFuture; + } + + @Override + public void innerStart() throws Exception { + CommonParameter parameter = Args.getInstance(); + socketFilePath = socketFiles.resolveSocketFilePath(parameter.getOutputDirectory(), + parameter.getIpcSocketDirectory(), getPid()); + Path socketDirectory = socketFilePath.getParent(); + socketFiles.validateSocketRootDirectory(socketDirectory.getParent()); + try { + socketFiles.recreateSocketDirectory(socketDirectory); + File socketFile = socketFilePath.toFile(); + AFUNIXSocketAddress address = AFUNIXSocketAddress.of(socketFile); + unixServerSocket = AFUNIXServerSocket.bindOn(address); + socketFiles.setOwnerOnlyPermissions(socketFilePath); + unixServerSocket.setShutdownOnClose(true); + + logger.info("IpcService started, listening on {}", socketFile.getAbsolutePath()); + } catch (IOException | RuntimeException e) { + throw rollbackStartup(e); + } + Runnable runnable = () -> { + while (isRunning) { + try { + dispatchClient(unixServerSocket.accept()); + } catch (Throwable throwable) { + ExitManager.findTronError(throwable).ifPresent(e -> { + throw e; + }); + if (isRunning) { + logger.error("Handle IPC request error", throwable); + try { + TimeUnit.MILLISECONDS.sleep(5_000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } + } + } + }; + isRunning = true; + try { + ExecutorServiceManager.submit(acceptorExecutor, runnable); + } catch (RuntimeException e) { + isRunning = false; + throw rollbackStartup(e); + } + } + + private void dispatchClient(AFUNIXSocket client) { + boolean submitted = false; + try { + client.setSoTimeout(CLIENT_IDLE_TIMEOUT_MILLIS); + activeClientSockets.add(client); + if (!isRunning) { + return; + } + ExecutorServiceManager.submit(clientExecutor, () -> handleClient(client)); + submitted = true; + } catch (IOException e) { + if (isRunning) { + logger.warn("Failed to configure IPC client idle timeout"); + } + } catch (RejectedExecutionException e) { + if (isRunning) { + logger.warn("Too many IPC clients; rejecting connection"); + } + } finally { + // Once submitted, handleClient owns the connection and releases it on exit. + if (!submitted) { + closeClient(client); + } + } + } + + private void handleClient(AFUNIXSocket client) { + try (BufferedInputStream input = new BufferedInputStream(client.getInputStream()); + BufferedWriter writer = new BufferedWriter( + new OutputStreamWriter(client.getOutputStream(), StandardCharsets.UTF_8))) { + + String line; + while ((line = requestHandler.readRequest(input)) != null) { + String cmd = line.trim(); + logger.debug("Received IPC request"); + String response = requestHandler.handleCommand(cmd); + if (!response.isEmpty()) { + writer.write(response); + writer.newLine(); + writer.flush(); + logger.debug("Sent IPC response"); + } + } + } catch (SocketTimeoutException e) { + logger.debug("Closing IPC client after {} ms without input", CLIENT_IDLE_TIMEOUT_MILLIS); + } catch (RequestTooLargeException e) { + logger.warn("IPC request exceeds maximum size {} bytes", requestHandler.getMaxRequestSize()); + } catch (IOException e) { + if (isRunning) { + logger.error("Client disconnected {}", client); + } + } finally { + closeClient(client); + } + } + + @Override + public void innerStop() throws Exception { + logger.info("Begin to stop IpcService ..."); + isRunning = false; + + Exception failure = runCleanup(null, + this::closeServerSocket, + this::shutdownActiveClients, + this::shutdownExecutors, + activeClientSockets::clear, + () -> socketFiles.deleteSocketFile(socketFilePath), + () -> socketFiles.deleteSocketDirectory(socketFilePath)); + + if (failure != null) { + throw failure; + } + logger.info("IpcService stopped"); + } + + private void closeServerSocket() throws IOException { + if (unixServerSocket != null) { + unixServerSocket.close(); + } + } + + private void shutdownActiveClients() { + for (AFUNIXSocket client : activeClientSockets) { + // Wake blocked reads and writes before closing the socket from the stopping thread. + try { + client.shutdownInput(); + } catch (IOException e) { + logger.debug("Failed to shut down IPC client input"); + } + try { + client.shutdownOutput(); + } catch (IOException e) { + logger.debug("Failed to shut down IPC client output"); + } + closeClient(client); + } + } + + private void shutdownExecutors() { + // Closing a junixsocket from another thread does not always wake a native read promptly. The + // workers are daemon threads, so interrupt them and use a short bounded wait instead of the + // shared executor shutdown helper's 60-second wait. + acceptorExecutor.shutdownNow(); + clientExecutor.shutdownNow(); + awaitExecutorTermination(acceptorExecutor, ACCEPTOR_EXECUTOR_NAME); + awaitExecutorTermination(clientExecutor, CLIENT_EXECUTOR_NAME); + } + + private void awaitExecutorTermination(ExecutorService executor, String name) { + try { + if (!executor.awaitTermination(EXECUTOR_SHUTDOWN_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + logger.warn("Pool {} did not terminate within {} second", name, + EXECUTOR_SHUTDOWN_TIMEOUT_SECONDS); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + private Exception rollbackStartup(Exception failure) { + if (unixServerSocket != null) { + failure = runCleanup(failure, this::closeServerSocket, + () -> socketFiles.deleteSocketFile(socketFilePath)); + } + return runCleanup(failure, () -> socketFiles.deleteSocketDirectory(socketFilePath)); + } + + /** + * Runs every cleanup action in order, retaining the first failure and suppressing later ones. + */ + private Exception runCleanup(Exception failure, CleanupAction... actions) { + for (CleanupAction action : actions) { + try { + action.run(); + } catch (Exception cleanupFailure) { + if (failure == null) { + failure = cleanupFailure; + } else { + failure.addSuppressed(cleanupFailure); + } + } + } + return failure; + } + + private void closeClient(AFUNIXSocket client) { + try { + client.close(); + } catch (IOException e) { + logger.warn("Failed to close IPC client socket"); + } finally { + activeClientSockets.remove(client); + } + } + + private String getPid() { + String name = ManagementFactory.getRuntimeMXBean().getName(); + return name.split("@")[0]; + } + + @FunctionalInterface + private interface CleanupAction { + + void run() throws Exception; + } +} diff --git a/framework/src/main/java/org/tron/core/services/admin/ipc/server/IpcSocketFiles.java b/framework/src/main/java/org/tron/core/services/admin/ipc/server/IpcSocketFiles.java new file mode 100644 index 00000000000..e31806cda18 --- /dev/null +++ b/framework/src/main/java/org/tron/core/services/admin/ipc/server/IpcSocketFiles.java @@ -0,0 +1,115 @@ +package org.tron.core.services.admin.ipc.server; + +import java.io.IOException; +import java.nio.charset.Charset; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.attribute.BasicFileAttributes; +import java.nio.file.attribute.PosixFileAttributeView; +import java.nio.file.attribute.PosixFilePermission; +import java.nio.file.attribute.PosixFilePermissions; +import java.util.EnumSet; +import org.newsclub.net.unix.AFUNIXSocketAddress; +import org.tron.core.exception.TronError; +import org.tron.core.exception.TronError.ErrCode; + +/** + * Resolves and manages IPC socket files and their private directory. + * The service owns the socket and decides when to prepare or remove these files. + */ +final class IpcSocketFiles { + + private static final String IPC_DIRECTORY_NAME = "ipc"; + + // macOS/Linux sun_path buffers are 104/108 bytes. Reserve one byte for the terminating null + // and three bytes of portability margin below the smaller macOS limit. + private static final int MAX_SOCKET_PATH_BYTES = 100; + + Path resolveSocketFilePath(String outputDirectory, String configuredDirectory, String pid) { + Path socketRootDirectory; + if (configuredDirectory == null || configuredDirectory.trim().isEmpty()) { + socketRootDirectory = Paths.get(outputDirectory); + } else { + socketRootDirectory = Paths.get(configuredDirectory); + if (!socketRootDirectory.isAbsolute()) { + throw new TronError("node.admin.ipc.socketDirectory must be an absolute path", + ErrCode.API_SERVER_INIT); + } + } + + Path socketFile = socketRootDirectory.resolve(IPC_DIRECTORY_NAME) + .resolve(pid + ".sock").toAbsolutePath().normalize(); + int socketPathLength = getSocketPathLength(socketFile); + if (socketPathLength > MAX_SOCKET_PATH_BYTES) { + throw new TronError("IPC socket path is " + socketPathLength + + " bytes, exceeding the portable limit of " + MAX_SOCKET_PATH_BYTES + + " bytes. Configure node.admin.ipc.socketDirectory to a shorter absolute directory", + ErrCode.API_SERVER_INIT); + } + return socketFile; + } + + private int getSocketPathLength(Path socketFile) { + return getSocketPathLength(socketFile, AFUNIXSocketAddress.addressCharset()); + } + + static int getSocketPathLength(Path socketFile, Charset charset) { + return socketFile.toString().getBytes(charset).length; + } + + void validateSocketRootDirectory(Path socketRootDirectory) throws IOException { + if (socketRootDirectory == null || !Files.isDirectory(socketRootDirectory)) { + throw new TronError("IPC socket root directory does not exist or is not a directory", + ErrCode.API_SERVER_INIT); + } + if (!Files.getFileStore(socketRootDirectory) + .supportsFileAttributeView(PosixFileAttributeView.class)) { + throw new TronError("IPC requires a POSIX-compatible socket root directory", + ErrCode.API_SERVER_INIT); + } + } + + void recreateSocketDirectory(Path socketDirectory) throws IOException { + if (Files.exists(socketDirectory, LinkOption.NOFOLLOW_LINKS)) { + BasicFileAttributes attributes = Files.readAttributes(socketDirectory, + BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); + if (attributes.isSymbolicLink() || !attributes.isDirectory()) { + throw new TronError("Refusing to replace a non-directory IPC path", + ErrCode.API_SERVER_INIT); + } + deleteDirectoryWithDirectEntries(socketDirectory); + } + Files.createDirectory(socketDirectory, PosixFilePermissions.asFileAttribute( + EnumSet.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE, + PosixFilePermission.OWNER_EXECUTE))); + } + + private void deleteDirectoryWithDirectEntries(Path directory) throws IOException { + try (DirectoryStream entries = Files.newDirectoryStream(directory)) { + for (Path entry : entries) { + Files.delete(entry); + } + } + Files.delete(directory); + } + + void setOwnerOnlyPermissions(Path socketFilePath) throws IOException { + Files.setPosixFilePermissions(socketFilePath, + EnumSet.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE)); + } + + void deleteSocketFile(Path socketFilePath) throws IOException { + if (socketFilePath != null) { + Files.deleteIfExists(socketFilePath); + } + } + + void deleteSocketDirectory(Path socketFilePath) throws IOException { + if (socketFilePath != null) { + Files.deleteIfExists(socketFilePath.getParent()); + } + } +} diff --git a/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcMapper.java b/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcMapper.java new file mode 100644 index 00000000000..b5bcc8fd4d9 --- /dev/null +++ b/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcMapper.java @@ -0,0 +1,22 @@ +package org.tron.core.services.jsonrpc; + +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.StreamReadConstraints; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.tron.core.Constant; + +public final class JsonRpcMapper { + + private JsonRpcMapper() { + } + + public static ObjectMapper create() { + JsonFactory factory = JsonFactory.builder() + .streamReadConstraints(StreamReadConstraints.builder() + .maxNestingDepth(Constant.MAX_NESTING_DEPTH) + .maxTokenCount(Constant.MAX_TOKEN_COUNT) + .build()) + .build(); + return new ObjectMapper(factory); + } +} diff --git a/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcMediaType.java b/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcMediaType.java new file mode 100644 index 00000000000..780a08d2f48 --- /dev/null +++ b/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcMediaType.java @@ -0,0 +1,26 @@ +package org.tron.core.services.jsonrpc; + +import java.util.Locale; + +public final class JsonRpcMediaType { + + private static final String APPLICATION_JSON = "application/json"; + private static final String APPLICATION_JSON_RPC = "application/json-rpc"; + + private JsonRpcMediaType() { + } + + public static boolean isSupported(String contentType) { + if (contentType == null) { + return false; + } + int parameterSeparator = contentType.indexOf(';'); + String mediaType = (parameterSeparator < 0 + ? contentType : contentType.substring(0, parameterSeparator)) + .trim() + .toLowerCase(Locale.ROOT); + return APPLICATION_JSON.equals(mediaType) + || APPLICATION_JSON_RPC.equals(mediaType) + || mediaType.startsWith("application/") && mediaType.endsWith("+json"); + } +} diff --git a/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcServlet.java b/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcServlet.java index ca249da4e5d..249e5372765 100644 --- a/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcServlet.java +++ b/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcServlet.java @@ -1,8 +1,6 @@ package org.tron.core.services.jsonrpc; -import com.fasterxml.jackson.core.JsonFactory; import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.core.StreamReadConstraints; import com.fasterxml.jackson.core.exc.StreamConstraintsException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -25,7 +23,6 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import org.tron.common.parameter.CommonParameter; -import org.tron.core.Constant; import org.tron.core.services.filter.BufferedResponseWrapper; import org.tron.core.services.filter.CachedBodyRequestWrapper; import org.tron.core.services.http.RateLimiterServlet; @@ -34,17 +31,7 @@ @Slf4j(topic = "API") public class JsonRpcServlet extends RateLimiterServlet { - private static final ObjectMapper MAPPER = buildMapper(); - - private static ObjectMapper buildMapper() { - JsonFactory factory = JsonFactory.builder() - .streamReadConstraints(StreamReadConstraints.builder() - .maxNestingDepth(Constant.MAX_NESTING_DEPTH) - .maxTokenCount(Constant.MAX_TOKEN_COUNT) - .build()) - .build(); - return new ObjectMapper(factory); - } + private static final ObjectMapper MAPPER = JsonRpcMapper.create(); private enum JsonRpcError { PARSE_ERROR(-32700), diff --git a/framework/src/main/java/org/tron/program/FullNode.java b/framework/src/main/java/org/tron/program/FullNode.java index 96b9f73d577..7a38fbe5424 100644 --- a/framework/src/main/java/org/tron/program/FullNode.java +++ b/framework/src/main/java/org/tron/program/FullNode.java @@ -1,7 +1,8 @@ package org.tron.program; -import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.support.DefaultListableBeanFactory; import org.tron.common.application.Application; import org.tron.common.application.ApplicationFactory; @@ -13,21 +14,36 @@ import org.tron.common.prometheus.Metrics; import org.tron.core.config.DefaultConfig; import org.tron.core.config.args.Args; +import org.tron.core.config.args.CLIParameter; +import org.tron.core.config.args.CommandLineArguments; import org.tron.core.exception.TronError; +import org.tron.core.services.admin.ipc.client.IpcClient; -@Slf4j(topic = "app") public class FullNode { /** * Start the FullNode. */ public static void main(String[] args) { + // Do not initialize loggers, Args, Arch, or ExitManager before dispatching attach mode: + // their static initializers open the node's Logback appenders. + CommandLineArguments arguments = new CommandLineArguments(args); + if (arguments.isAttachMode()) { + CLIParameter clientParameters = arguments.getParameters(); + IpcClient ipcClient = new IpcClient(clientParameters.ipcSocketFile); + int exitCode = ipcClient.start(clientParameters.ipcExecCommand); + if (exitCode != 0) { + System.exit(exitCode); + } + return; + } + ExitManager.initExceptionHandler(); checkJdkVersion(); - Args.setParam(args, "config.conf"); + Args.setParam(arguments, "config.conf"); CommonParameter parameter = Args.getInstance(); - LogService.load(parameter.getLogbackPath()); + Logger logger = LoggerFactory.getLogger("app"); if (parameter.isKeystoreFactory()) { KeystoreFactory.start(); diff --git a/framework/src/test/java/org/tron/common/application/HttpServiceTest.java b/framework/src/test/java/org/tron/common/application/HttpServiceTest.java new file mode 100644 index 00000000000..ace49654418 --- /dev/null +++ b/framework/src/test/java/org/tron/common/application/HttpServiceTest.java @@ -0,0 +1,78 @@ +package org.tron.common.application; + +import java.net.Socket; +import java.util.concurrent.TimeUnit; +import org.eclipse.jetty.server.ServerConnector; +import org.eclipse.jetty.servlet.ServletContextHandler; +import org.junit.Assert; +import org.junit.Test; + +public class HttpServiceTest { + + @Test + public void testInitServerPreservesLiteralListenAddress() { + TestHttpService service = new TestHttpService("127.0.0.1", 0); + try { + service.initializeServer(); + + Assert.assertEquals("127.0.0.1", service.getConnector().getHost()); + } finally { + service.destroyServer(); + } + } + + @Test + public void testInitServerLeavesHostUnsetForWildcardBinding() { + TestHttpService service = new TestHttpService(null, 0); + try { + service.initializeServer(); + + Assert.assertNull(service.getConnector().getHost()); + } finally { + service.destroyServer(); + } + } + + @Test(timeout = 10_000) + public void testServerBindsConfiguredIpv4Address() throws Exception { + TestHttpService service = new TestHttpService("127.0.0.1", 0); + try { + service.start().get(10, TimeUnit.SECONDS); + + int localPort = service.getConnector().getLocalPort(); + Assert.assertTrue(localPort > 0); + try (Socket ignored = new Socket("127.0.0.1", localPort)) { + // Successful construction proves that the configured address accepts connections. + } + } finally { + service.stop().get(10, TimeUnit.SECONDS); + } + } + + private static class TestHttpService extends HttpService { + + TestHttpService(String listenAddress, int port) { + this.listenAddress = listenAddress; + this.port = port; + this.contextPath = "/"; + } + + void initializeServer() { + initServer(); + } + + ServerConnector getConnector() { + return (ServerConnector) apiServer.getConnectors()[0]; + } + + void destroyServer() { + if (apiServer != null) { + apiServer.destroy(); + } + } + + @Override + protected void addServlet(ServletContextHandler context) { + } + } +} diff --git a/framework/src/test/java/org/tron/core/config/args/ArgsTest.java b/framework/src/test/java/org/tron/core/config/args/ArgsTest.java index 36b8a3269c1..46cdb55626f 100644 --- a/framework/src/test/java/org/tron/core/config/args/ArgsTest.java +++ b/framework/src/test/java/org/tron/core/config/args/ArgsTest.java @@ -20,6 +20,8 @@ import com.typesafe.config.ConfigFactory; import io.grpc.internal.GrpcUtil; import io.grpc.netty.NettyServerBuilder; +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.net.InetAddress; @@ -48,6 +50,81 @@ public class ArgsTest { @Rule public ExpectedException thrown = ExpectedException.none(); + @Test + public void testAttachWithExecParameters() { + Args.clearParam(); + try { + Args.setParam(new String[] { + "--attach", "/tmp/java-tron.sock", + "--exec", "admin_example one two", + "--log-config", "attach-logback.xml" + }, TestConstants.TEST_CONF); + + Assert.assertEquals("/tmp/java-tron.sock", Args.getIpcSocketFile()); + Assert.assertEquals("admin_example one two", Args.getIpcExecCommand()); + Assert.assertEquals("attach-logback.xml", Args.getInstance().getLogbackPath()); + Assert.assertNull(Args.getNodeConfig()); + Assert.assertNull(Args.getLocalWitnesses()); + } finally { + Args.clearParam(); + } + Assert.assertNull(Args.getIpcSocketFile()); + Assert.assertNull(Args.getIpcExecCommand()); + } + + @Test + public void testAttachRejectsNodeConfigOption() { + Args.clearParam(); + try { + assertAttachParameterError(new String[] { + "--attach", "/tmp/java-tron.sock", + "--config", "config.conf" + }, "Error: --attach cannot be combined with: --config"); + } finally { + Args.clearParam(); + } + } + + @Test + public void testAttachRejectsEmptySocketPath() { + Args.clearParam(); + try { + assertAttachParameterError(new String[] {"--attach", ""}, + "Error: --attach requires a non-empty "); + } finally { + Args.clearParam(); + } + } + + @Test + public void testExecRequiresAttach() { + Args.clearParam(); + try { + assertAttachParameterError(new String[] {"--exec", "admin_example"}, + "Error: --exec requires --attach "); + } finally { + Args.clearParam(); + } + } + + private void assertAttachParameterError(String[] args, String expectedMessage) { + PrintStream originalErr = System.err; + ByteArrayOutputStream errorOutput = new ByteArrayOutputStream(); + PrintStream capturedErr = new PrintStream(errorOutput); + try { + System.setErr(capturedErr); + Args.setParam(args, TestConstants.TEST_CONF); + Assert.fail("Expected invalid attach parameters to fail"); + } catch (TronError e) { + Assert.assertEquals(TronError.ErrCode.PARAMETER_INIT, e.getErrCode()); + Assert.assertEquals(expectedMessage, e.getMessage()); + Assert.assertEquals(expectedMessage + System.lineSeparator(), errorOutput.toString()); + } finally { + System.setErr(originalErr); + capturedErr.close(); + } + } + @Test public void get() { Args.setParam(new String[] {"--keystore-factory"}, TestConstants.TEST_CONF); @@ -280,6 +357,34 @@ public void testInitService() { Args.clearParam(); } + @Test + public void testAdminHttpAndIpcConfigBinding() { + Map override = new HashMap<>(); + override.put("storage.db.directory", "database"); + override.put("node.admin.ipc.enable", "true"); + override.put("node.admin.ipc.socketDirectory", "/tmp/tron-ipc"); + override.put("node.admin.http.enable", "true"); + override.put("node.admin.http.listenAddress", "127.0.0.2"); + override.put("node.admin.http.port", "18575"); + override.put("node.admin.http.virtualHosts", + Arrays.asList("admin.example.com", "localhost")); + Config config = ConfigFactory.parseMap(override) + .withFallback(ConfigFactory.defaultReference()); + + try { + Args.applyConfigParams(config); + Assert.assertTrue(Args.getInstance().isIpcEnable()); + Assert.assertEquals("/tmp/tron-ipc", Args.getInstance().getIpcSocketDirectory()); + Assert.assertTrue(Args.getInstance().isAdminHttpEnable()); + Assert.assertEquals("127.0.0.2", Args.getInstance().getAdminHttpListenAddress()); + Assert.assertEquals(18575, Args.getInstance().getAdminHttpListenPort()); + Assert.assertEquals(Arrays.asList("admin.example.com", "localhost"), + Args.getInstance().getAdminHttpVirtualHosts()); + } finally { + Args.clearParam(); + } + } + /** * Verify that CLI storage parameters correctly override config file values. * diff --git a/framework/src/test/java/org/tron/core/config/args/CommandLineArgumentsTest.java b/framework/src/test/java/org/tron/core/config/args/CommandLineArgumentsTest.java new file mode 100644 index 00000000000..6e575329a18 --- /dev/null +++ b/framework/src/test/java/org/tron/core/config/args/CommandLineArgumentsTest.java @@ -0,0 +1,43 @@ +package org.tron.core.config.args; + +import org.junit.Assert; +import org.junit.Test; + +public class CommandLineArgumentsTest { + + @Test + public void testNodeModePreservesExplicitOptions() { + CommandLineArguments arguments = new CommandLineArguments(new String[] { + "--config", "node.conf", "--p2p-disable", "false" + }); + + Assert.assertFalse(arguments.isAttachMode()); + Assert.assertEquals("node.conf", arguments.getParameters().shellConfFileName); + Assert.assertFalse(arguments.getParameters().p2pDisable); + Assert.assertEquals(2, arguments.getAssignedParameters().size()); + Assert.assertTrue(arguments.getAssignedParameters().stream() + .anyMatch(pd -> "p2pDisable".equals(pd.getParameterized().getName()))); + } + + @Test + public void testAttachIgnoresNodeOnlyOptions() { + CommandLineArguments arguments = new CommandLineArguments(new String[] { + "--attach", "node.sock", "--exec", "admin_example one two", "--witness", + "--log-config", "unused.xml" + }); + + Assert.assertTrue(arguments.isAttachMode()); + Assert.assertEquals("node.sock", arguments.getParameters().ipcSocketFile); + Assert.assertEquals("admin_example one two", arguments.getParameters().ipcExecCommand); + } + + @Test + public void testHelpAndVersionRetainPrecedenceOverAttachValidation() { + Assert.assertFalse(new CommandLineArguments(new String[] { + "--help", "--attach", "" + }).isAttachMode()); + Assert.assertFalse(new CommandLineArguments(new String[] { + "--version", "--exec", "help" + }).isAttachMode()); + } +} diff --git a/framework/src/test/java/org/tron/core/config/args/InetUtilTest.java b/framework/src/test/java/org/tron/core/config/args/InetUtilTest.java index 4611947211c..8f051282726 100644 --- a/framework/src/test/java/org/tron/core/config/args/InetUtilTest.java +++ b/framework/src/test/java/org/tron/core/config/args/InetUtilTest.java @@ -1,6 +1,7 @@ package org.tron.core.config.args; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; @@ -303,4 +304,61 @@ public void testResolveInetAddressUnresolvableReturnsNull() { InetAddress result = InetUtil.resolveInetAddress("bad.invalid"); assertNull(result); } + + @Test + public void testLoopbackIpLiteralsAreRecognizedWithoutDns() { + InetUtil.dnsLookup = (host, ipv4) -> { + throw new AssertionError("IP literals must not require DNS resolution"); + }; + + assertTrue(InetUtil.isLoopbackAddress("127.0.0.1")); + assertTrue(InetUtil.isLoopbackAddress("::1")); + } + + @Test + public void testNullAndNonLoopbackAddressesAreRejected() throws Exception { + InetAddress ipv4Wildcard = InetAddress.getByName("0.0.0.0"); + InetAddress ipv6Wildcard = InetAddress.getByName("::"); + InetUtil.dnsLookup = (host, ipv4) -> { + // NetUtil's literal validation excludes wildcard addresses, so preserve that resolver path. + if ("0.0.0.0".equals(host)) { + return ipv4Wildcard; + } + if ("::".equals(host)) { + return ipv6Wildcard; + } + throw new AssertionError("Unexpected DNS lookup"); + }; + + assertFalse(InetUtil.isLoopbackAddress(null)); + assertFalse(InetUtil.isLoopbackAddress("0.0.0.0")); + assertFalse(InetUtil.isLoopbackAddress("::")); + assertFalse(InetUtil.isLoopbackAddress("192.0.2.1")); + assertFalse(InetUtil.isLoopbackAddress("2001:db8::1")); + } + + @Test + public void testLoopbackHostnamesSupportIpv4AndIpv6Resolution() throws Exception { + InetAddress ipv4Loopback = InetAddress.getByName("127.0.0.1"); + InetAddress ipv6Loopback = InetAddress.getByName("::1"); + InetUtil.dnsLookup = (host, ipv4) -> { + if ("localhost".equals(host) && ipv4) { + return ipv4Loopback; + } + return "ipv6-loopback.invalid".equals(host) && !ipv4 ? ipv6Loopback : null; + }; + + assertTrue(InetUtil.isLoopbackAddress("localhost")); + assertTrue(InetUtil.isLoopbackAddress("ipv6-loopback.invalid")); + } + + @Test + public void testNonLoopbackAndUnresolvableHostnamesAreRejected() throws Exception { + InetAddress remoteAddress = InetAddress.getByName("192.0.2.1"); + InetUtil.dnsLookup = (host, ipv4) -> + "remote.invalid".equals(host) && ipv4 ? remoteAddress : null; + + assertFalse(InetUtil.isLoopbackAddress("remote.invalid")); + assertFalse(InetUtil.isLoopbackAddress("unresolvable.invalid")); + } } diff --git a/framework/src/test/java/org/tron/core/services/admin/http/AdminRpcServletTest.java b/framework/src/test/java/org/tron/core/services/admin/http/AdminRpcServletTest.java new file mode 100644 index 00000000000..87fa02dab04 --- /dev/null +++ b/framework/src/test/java/org/tron/core/services/admin/http/AdminRpcServletTest.java @@ -0,0 +1,174 @@ +package org.tron.core.services.admin.http; + +import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.mock; + +import com.googlecode.jsonrpc4j.JsonRpcInterceptor; +import java.io.IOException; +import java.lang.reflect.Field; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import org.junit.Before; +import org.junit.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.mock.web.MockServletConfig; +import org.tron.core.Constant; +import org.tron.core.services.admin.AdminJsonRpc; + +public class AdminRpcServletTest { + + private TestableServlet servlet; + + @Before + public void setUp() throws Exception { + servlet = new TestableServlet(); + setField("adminJsonRpc", mock(AdminJsonRpc.class)); + setField("interceptor", mock(JsonRpcInterceptor.class)); + servlet.init(new MockServletConfig()); + setVirtualHosts("localhost"); + } + + @Test + public void excessivelyNestedRequestIsRejected() throws Exception { + StringBuilder request = new StringBuilder(); + for (int i = 0; i <= Constant.MAX_NESTING_DEPTH; i++) { + request.append('['); + } + request.append('0'); + for (int i = 0; i <= Constant.MAX_NESTING_DEPTH; i++) { + request.append(']'); + } + + MockHttpServletResponse response = doPost(request.toString()); + assertEquals(HttpServletResponse.SC_OK, response.getStatus()); + assertEquals(0, response.getContentAsByteArray().length); + } + + @Test + public void requestWithTooManyTokensIsRejected() throws Exception { + StringBuilder request = new StringBuilder("{\"params\":["); + for (int i = 0; i < Constant.MAX_TOKEN_COUNT; i++) { + if (i > 0) { + request.append(','); + } + request.append('0'); + } + request.append("]}"); + + MockHttpServletResponse response = doPost(request.toString()); + assertEquals(HttpServletResponse.SC_OK, response.getStatus()); + assertEquals(0, response.getContentAsByteArray().length); + } + + @Test + public void nonJsonContentTypeIsRejected() throws Exception { + MockHttpServletResponse response = doPost( + "{\"jsonrpc\":\"2.0\",\"method\":\"admin_example\",\"id\":1}", "text/plain"); + + assertEquals(HttpServletResponse.SC_UNSUPPORTED_MEDIA_TYPE, response.getStatus()); + assertEquals(0, response.getContentAsByteArray().length); + } + + @Test + public void missingContentTypeIsRejected() throws Exception { + MockHttpServletResponse response = doPost( + "{\"jsonrpc\":\"2.0\",\"method\":\"admin_example\",\"id\":1}", null); + + assertEquals(HttpServletResponse.SC_UNSUPPORTED_MEDIA_TYPE, response.getStatus()); + assertEquals(0, response.getContentAsByteArray().length); + } + + @Test + public void jsonContentTypesAreAccepted() throws Exception { + String body = "{\"jsonrpc\":\"2.0\",\"method\":\"admin_example\",\"id\":1}"; + + assertEquals(HttpServletResponse.SC_OK, + doPost(body, "application/json; charset=UTF-8").getStatus()); + assertEquals(HttpServletResponse.SC_OK, + doPost(body, "application/json-rpc").getStatus()); + assertEquals(HttpServletResponse.SC_OK, + doPost(body, "application/vnd.tron+json").getStatus()); + } + + @Test + public void unlistedVirtualHostIsRejected() throws Exception { + MockHttpServletResponse response = doPost( + "{\"jsonrpc\":\"2.0\",\"method\":\"admin_example\",\"id\":1}", + "application/json", "evil.example:8575"); + + assertEquals(HttpServletResponse.SC_FORBIDDEN, response.getStatus()); + } + + @Test + public void listedVirtualHostIsAcceptedCaseInsensitivelyAndWithoutPort() throws Exception { + setVirtualHosts("admin.example.com"); + + MockHttpServletResponse response = doPost( + "{\"jsonrpc\":\"2.0\",\"method\":\"admin_example\",\"id\":1}", + "application/json", "ADMIN.EXAMPLE.COM:8575"); + + assertEquals(HttpServletResponse.SC_OK, response.getStatus()); + } + + @Test + public void ipLiteralHostsAreAccepted() throws Exception { + String body = "{\"jsonrpc\":\"2.0\",\"method\":\"admin_example\",\"id\":1}"; + + assertEquals(HttpServletResponse.SC_OK, + doPost(body, "application/json", "127.0.0.1:8575").getStatus()); + assertEquals(HttpServletResponse.SC_OK, + doPost(body, "application/json", "[::1]:8575").getStatus()); + } + + @Test + public void wildcardVirtualHostAcceptsAnyHostname() throws Exception { + setVirtualHosts("*"); + + MockHttpServletResponse response = doPost( + "{\"jsonrpc\":\"2.0\",\"method\":\"admin_example\",\"id\":1}", + "application/json", "any.example:8575"); + + assertEquals(HttpServletResponse.SC_OK, response.getStatus()); + } + + private void setField(String name, Object value) throws Exception { + Field field = AdminRpcServlet.class.getDeclaredField(name); + field.setAccessible(true); + field.set(servlet, value); + } + + private MockHttpServletResponse doPost(String body) throws Exception { + return doPost(body, "application/json"); + } + + private MockHttpServletResponse doPost(String body, String contentType) throws Exception { + return doPost(body, contentType, null); + } + + private MockHttpServletResponse doPost(String body, String contentType, String host) + throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/admin"); + request.setContentType(contentType); + request.setContent(body.getBytes(StandardCharsets.UTF_8)); + if (host != null) { + request.addHeader("Host", host); + } + MockHttpServletResponse response = new MockHttpServletResponse(); + servlet.callDoPost(request, response); + return response; + } + + private void setVirtualHosts(String... hosts) throws Exception { + setField("virtualHostValidator", new VirtualHostValidator(Arrays.asList(hosts))); + } + + private static class TestableServlet extends AdminRpcServlet { + + void callDoPost(HttpServletRequest request, HttpServletResponse response) throws IOException { + doPost(request, response); + } + } +} diff --git a/framework/src/test/java/org/tron/core/services/admin/http/VirtualHostValidatorTest.java b/framework/src/test/java/org/tron/core/services/admin/http/VirtualHostValidatorTest.java new file mode 100644 index 00000000000..1fa6a891cf4 --- /dev/null +++ b/framework/src/test/java/org/tron/core/services/admin/http/VirtualHostValidatorTest.java @@ -0,0 +1,74 @@ +package org.tron.core.services.admin.http; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import org.junit.Assert; +import org.junit.Test; + +public class VirtualHostValidatorTest { + + @Test + public void testConfiguredHostnamesAreNormalizedAndMatchedExactly() { + VirtualHostValidator validator = new VirtualHostValidator( + Arrays.asList(null, "", " ", " Admin.Example.Invalid ", "LOCALHOST")); + + Assert.assertTrue(validator.isAllowedHost("ADMIN.EXAMPLE.INVALID:8575")); + Assert.assertTrue(validator.isAllowedHost("localhost")); + Assert.assertFalse(validator.isAllowedHost("admin.example.invalid.attacker.invalid")); + Assert.assertFalse(validator.isAllowedHost("attacker.invalid")); + } + + @Test + public void testIpLiteralsDoNotRequireAnAllowlistEntry() { + VirtualHostValidator validator = new VirtualHostValidator(Collections.emptyList()); + + for (String host : Arrays.asList("192.0.2.1", "192.0.2.1:8575", "::1", "[::1]", + "[::1]:8575", "[2001:db8::1]:8575")) { + Assert.assertTrue(host, validator.isAllowedHost(host)); + } + } + + @Test + public void testMissingConfigurationPreservesHttp10AndIpCompatibility() { + VirtualHostValidator[] validators = { + new VirtualHostValidator(null), new VirtualHostValidator(Collections.emptyList()) + }; + for (VirtualHostValidator validator : validators) { + Assert.assertTrue(validator.isAllowedHost(null)); + Assert.assertTrue(validator.isAllowedHost("")); + Assert.assertTrue(validator.isAllowedHost("127.0.0.1:8575")); + Assert.assertFalse(validator.isAllowedHost("localhost")); + } + } + + @Test + public void testWildcardAllowsUnlistedHostnames() { + VirtualHostValidator validator = new VirtualHostValidator(Collections.singletonList(" * ")); + + Assert.assertTrue(validator.isAllowedHost("unlisted.example.invalid")); + Assert.assertTrue(validator.isAllowedHost("unlisted.example.invalid:8575")); + } + + @Test + public void testMalformedBracketsAndPortSuffixesAreRejectedEvenWithWildcard() { + VirtualHostValidator validator = new VirtualHostValidator(Collections.singletonList("*")); + + for (String host : Arrays.asList("localhost:", "localhost:http", "localhost:-1", + "[::1", "[]", "[::1]suffix", "[::1]:", "[::1]:http", "[::1]:8575/path")) { + Assert.assertFalse(host, validator.isAllowedHost(host)); + } + } + + @Test + public void testConfigurationChangesDoNotMutateTheInitializedAllowlist() { + List configuredHosts = new ArrayList<>(Collections.singletonList("localhost")); + VirtualHostValidator validator = new VirtualHostValidator(configuredHosts); + configuredHosts.clear(); + configuredHosts.add("*"); + + Assert.assertTrue(validator.isAllowedHost("localhost")); + Assert.assertFalse(validator.isAllowedHost("unlisted.example.invalid")); + } +} diff --git a/framework/src/test/java/org/tron/core/services/admin/ipc/client/IpcClientTest.java b/framework/src/test/java/org/tron/core/services/admin/ipc/client/IpcClientTest.java new file mode 100644 index 00000000000..16bcfde02aa --- /dev/null +++ b/framework/src/test/java/org/tron/core/services/admin/ipc/client/IpcClientTest.java @@ -0,0 +1,381 @@ +package org.tron.core.services.admin.ipc.client; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.PrintStream; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.SocketTimeoutException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import org.jline.reader.LineReader; +import org.jline.reader.SyntaxError; +import org.jline.reader.UserInterruptException; +import org.junit.Assert; +import org.junit.Test; +import org.mockito.Mockito; + +public class IpcClientTest { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + @Test + public void testClientDoesNotDeclareLogger() { + try { + IpcClient.class.getDeclaredField("logger"); + Assert.fail("IPC client must not initialize the node logging system"); + } catch (NoSuchFieldException expected) { + // No logger field means loading IpcClient cannot initialize SLF4J through this class. + } + } + + @Test + public void testExecSendsCommandAndPrintsFormattedResult() throws Exception { + Socket socket = Mockito.mock(Socket.class); + ByteArrayOutputStream requestOutput = new ByteArrayOutputStream(); + Mockito.when(socket.getOutputStream()).thenReturn(requestOutput); + Mockito.when(socket.getInputStream()).thenReturn(new ByteArrayInputStream( + "\n{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":\"hello world:b\"}\n" + .getBytes(StandardCharsets.UTF_8))); + + PrintStream originalOut = System.out; + ByteArrayOutputStream consoleOutput = new ByteArrayOutputStream(); + PrintStream capturedOut = new PrintStream(consoleOutput, true, "UTF-8"); + try { + System.setOut(capturedOut); + Assert.assertEquals(IpcClient.EXIT_SUCCESS, + new IpcClient("unused").runExec(socket, " admin_example \"hello world\" b \t")); + } finally { + System.setOut(originalOut); + capturedOut.close(); + } + + JsonNode request = OBJECT_MAPPER.readTree(requestOutput.toString("UTF-8")); + Assert.assertEquals("admin_example", request.get("method").asText()); + Assert.assertEquals("hello world", request.get("params").get(0).asText()); + Assert.assertEquals("b", request.get("params").get(1).asText()); + Assert.assertEquals("hello world:b" + System.lineSeparator(), + consoleOutput.toString("UTF-8")); + } + + @Test + public void testWelcomeShowsConnectionAndUsageHint() throws Exception { + Path temporaryDirectory = Files.createTempDirectory("ipc-welcome-test-"); + File socketFile = temporaryDirectory.resolve("java-tron.1234.sock").toFile(); + PrintStream originalOut = System.out; + ByteArrayOutputStream consoleOutput = new ByteArrayOutputStream(); + PrintStream capturedOut = new PrintStream(consoleOutput, true, "UTF-8"); + try { + System.setOut(capturedOut); + new IpcClient(socketFile.getPath()).printWelcome(socketFile); + } finally { + System.setOut(originalOut); + capturedOut.close(); + Files.deleteIfExists(temporaryDirectory); + } + + String welcome = consoleOutput.toString("UTF-8"); + Assert.assertTrue(welcome, welcome.contains("Welcome to the java-tron admin console.")); + Assert.assertTrue(welcome, welcome.contains("IPC endpoint: " + socketFile.getAbsolutePath())); + Assert.assertFalse(welcome, welcome.contains("History:")); + Assert.assertTrue(welcome, welcome.contains("Type \"help\" for available commands")); + } + + @Test + public void testExecWithInvalidSyntaxDoesNotExposeCommand() throws Exception { + assertLocalExec("admin_example \"sensitive-value", IpcClient.EXIT_FAILURE, "", + "Invalid command syntax." + System.lineSeparator()); + } + + @Test + public void testExecHelpAndExitSucceedWithoutSendingRequest() throws Exception { + assertLocalExec("HELP ADMIN_EXAMPLE", IpcClient.EXIT_SUCCESS, + "usage: admin_example " + System.lineSeparator(), ""); + assertLocalExec("QUIT", IpcClient.EXIT_SUCCESS, "", ""); + } + + @Test + public void testExecInvalidCommandsFailWithoutSendingRequest() throws Exception { + assertLocalExec(" \t ", IpcClient.EXIT_FAILURE, "", + "No command specified for --exec." + System.lineSeparator()); + assertLocalExec("admin_example missing", IpcClient.EXIT_FAILURE, "", + "Invalid parameter, usage: admin_example " + + System.lineSeparator()); + assertLocalExec("unknown secret", IpcClient.EXIT_FAILURE, + String.join(System.lineSeparator(), "Available commands:", + " admin_example ", + " help [command]", " exit/quit", ""), + "Invalid cmd: unknown" + System.lineSeparator()); + } + + @Test + public void testMissingSocketFilePrintsConsoleError() throws Exception { + Path temporaryDirectory = Files.createTempDirectory("ipc-client-test-"); + Path missingSocket = temporaryDirectory.resolve("missing.sock"); + PrintStream originalErr = System.err; + ByteArrayOutputStream errorOutput = new ByteArrayOutputStream(); + PrintStream capturedErr = new PrintStream(errorOutput, true, "UTF-8"); + try { + System.setErr(capturedErr); + Assert.assertEquals(IpcClient.EXIT_FAILURE, + new IpcClient(missingSocket.toString()).run()); + } finally { + System.setErr(originalErr); + capturedErr.close(); + Files.deleteIfExists(temporaryDirectory); + } + + Assert.assertEquals("Error: IPC socket file does not exist: missing.sock" + + System.lineSeparator(), + errorOutput.toString("UTF-8")); + } + + @Test + public void testExecReturnsFailureForRpcError() throws Exception { + Socket socket = Mockito.mock(Socket.class); + Mockito.when(socket.getOutputStream()).thenReturn(new ByteArrayOutputStream()); + Mockito.when(socket.getInputStream()).thenReturn(new ByteArrayInputStream( + ("{\"jsonrpc\":\"2.0\",\"id\":1," + + "\"error\":{\"code\":-32603,\"message\":\"Internal error\"}}\n") + .getBytes(StandardCharsets.UTF_8))); + + PrintStream originalErr = System.err; + ByteArrayOutputStream errorOutput = new ByteArrayOutputStream(); + PrintStream capturedErr = new PrintStream(errorOutput, true, "UTF-8"); + try { + System.setErr(capturedErr); + Assert.assertEquals(IpcClient.EXIT_FAILURE, + new IpcClient("unused").runExec(socket, "admin_example a b")); + } finally { + System.setErr(originalErr); + capturedErr.close(); + } + + Assert.assertEquals("Error -32603: Internal error" + System.lineSeparator(), + errorOutput.toString("UTF-8")); + } + + @Test + public void testExecReturnsFailureWhenServerDisconnects() throws Exception { + Socket socket = Mockito.mock(Socket.class); + Mockito.when(socket.getOutputStream()).thenReturn(new ByteArrayOutputStream()); + Mockito.when(socket.getInputStream()).thenReturn(new ByteArrayInputStream(new byte[0])); + + PrintStream originalErr = System.err; + ByteArrayOutputStream errorOutput = new ByteArrayOutputStream(); + PrintStream capturedErr = new PrintStream(errorOutput, true, "UTF-8"); + try { + System.setErr(capturedErr); + Assert.assertEquals(IpcClient.EXIT_FAILURE, + new IpcClient("unused").runExec(socket, "admin_example a b")); + } finally { + System.setErr(originalErr); + capturedErr.close(); + } + + Assert.assertEquals( + "Disconnected from server before receiving a response." + System.lineSeparator(), + errorOutput.toString("UTF-8")); + } + + @Test + public void testExecTimesOutWaitingForResponse() throws Exception { + Socket socket = Mockito.mock(Socket.class); + Mockito.when(socket.getOutputStream()).thenReturn(new ByteArrayOutputStream()); + Mockito.when(socket.getInputStream()).thenReturn(new InputStream() { + @Override + public int read() throws IOException { + throw new SocketTimeoutException("timed out"); + } + }); + + PrintStream originalErr = System.err; + ByteArrayOutputStream errorOutput = new ByteArrayOutputStream(); + PrintStream capturedErr = new PrintStream(errorOutput, true, "UTF-8"); + try { + System.setErr(capturedErr); + Assert.assertEquals(IpcClient.EXIT_FAILURE, + new IpcClient("unused").runExec(socket, "admin_example a b")); + } finally { + System.setErr(originalErr); + capturedErr.close(); + } + + Mockito.verify(socket).setSoTimeout(30_000); + Assert.assertEquals("Timed out waiting for IPC response." + System.lineSeparator(), + errorOutput.toString("UTF-8")); + } + + @Test(timeout = 10_000) + public void testSessionContinuesAfterHelpAndInvalidInput() throws Exception { + LineReader reader = Mockito.mock(LineReader.class); + Mockito.when(reader.readLine("> ")) + .thenThrow(new SyntaxError(0, 0, "sensitive terminal input")) + .thenReturn("", "help admin_example", "admin_example \"secret", + "admin_example missing", "admin_example 'hello world' b", "exit"); + PrintStream originalOut = System.out; + PrintStream originalErr = System.err; + ByteArrayOutputStream consoleOutput = new ByteArrayOutputStream(); + ByteArrayOutputStream errorOutput = new ByteArrayOutputStream(); + try (PrintStream capturedOut = new PrintStream(consoleOutput, true, "UTF-8"); + PrintStream capturedErr = new PrintStream(errorOutput, true, "UTF-8"); + ServerSocket serverSocket = new ServerSocket(0); + Socket clientSocket = new Socket("127.0.0.1", serverSocket.getLocalPort()); + Socket serverConnection = serverSocket.accept()) { + System.setOut(capturedOut); + System.setErr(capturedErr); + new IpcClient("unused").runSession(clientSocket, reader); + + Assert.assertFalse("The enclosing run() must own the socket", clientSocket.isClosed()); + clientSocket.shutdownOutput(); + serverConnection.setSoTimeout(1_000); + BufferedReader requests = new BufferedReader( + new InputStreamReader(serverConnection.getInputStream(), StandardCharsets.UTF_8)); + JsonNode request = OBJECT_MAPPER.readTree(requests.readLine()); + Assert.assertEquals("admin_example", request.get("method").asText()); + Assert.assertEquals(OBJECT_MAPPER.readTree("[\"hello world\",\"b\"]"), request.get("params")); + Assert.assertEquals(1, request.get("id").asInt()); + Assert.assertNull("Only the valid command should reach the server", requests.readLine()); + Mockito.verify(reader, Mockito.never()).printAbove("Disconnected from server."); + } finally { + System.setOut(originalOut); + System.setErr(originalErr); + } + + Assert.assertEquals("usage: admin_example " + + System.lineSeparator(), consoleOutput.toString("UTF-8")); + Assert.assertEquals(String.join(System.lineSeparator(), + "Invalid command syntax.", "Invalid command syntax.", + "Invalid parameter, usage: admin_example ", ""), + errorOutput.toString("UTF-8")); + } + + @Test(timeout = 10_000) + public void testSessionPrintsResponseAndExitsWhenServerDisconnects() throws Exception { + CountDownLatch inputStarted = new CountDownLatch(1); + CountDownLatch waitForInterrupt = new CountDownLatch(1); + LineReader reader = Mockito.mock(LineReader.class); + Mockito.when(reader.readLine("> ")).thenAnswer(invocation -> { + inputStarted.countDown(); + try { + waitForInterrupt.await(); + return ""; + } catch (InterruptedException e) { + throw new UserInterruptException(""); + } + }); + + try (ServerSocket serverSocket = new ServerSocket(0); + Socket clientSocket = new Socket("127.0.0.1", serverSocket.getLocalPort()); + Socket serverConnection = serverSocket.accept()) { + IpcClient client = new IpcClient("unused"); + AtomicReference failure = new AtomicReference<>(); + Thread sessionThread = new Thread(() -> { + try { + client.runSession(clientSocket, reader); + } catch (Throwable throwable) { + failure.set(throwable); + } + }, "ipc-client-test-session"); + sessionThread.setDaemon(true); + sessionThread.start(); + + Assert.assertTrue("IPC client did not start reading terminal input", + inputStarted.await(5, TimeUnit.SECONDS)); + serverConnection.getOutputStream().write("\nresponse\n\n".getBytes(StandardCharsets.UTF_8)); + serverConnection.getOutputStream().flush(); + Mockito.verify(reader, Mockito.timeout(5_000)).printAbove("response"); + + serverConnection.close(); + sessionThread.join(5_000); + + Assert.assertFalse("IPC client did not exit after server disconnected", + sessionThread.isAlive()); + Assert.assertNull("IPC client session failed", failure.get()); + Mockito.verify(reader, Mockito.never()).printAbove("null"); + Mockito.verify(reader, Mockito.never()).printAbove(""); + Mockito.verify(reader).printAbove("Disconnected from server."); + } + } + + @Test(timeout = 10_000) + public void testSessionExitDoesNotInterruptInputThread() throws Exception { + LineReader reader = Mockito.mock(LineReader.class); + Mockito.when(reader.readLine("> ")).thenReturn("exit"); + + try (ServerSocket serverSocket = new ServerSocket(0); + Socket clientSocket = new Socket("127.0.0.1", serverSocket.getLocalPort()); + Socket serverConnection = serverSocket.accept()) { + IpcClient client = new IpcClient("unused"); + AtomicReference failure = new AtomicReference<>(); + AtomicBoolean interrupted = new AtomicBoolean(true); + Thread sessionThread = new Thread(() -> { + try { + client.runSession(clientSocket, reader); + interrupted.set(Thread.currentThread().isInterrupted()); + } catch (Throwable throwable) { + failure.set(throwable); + } + }, "ipc-client-clean-exit-test-session"); + sessionThread.setDaemon(true); + sessionThread.start(); + sessionThread.join(5_000); + + Assert.assertFalse("IPC client did not exit after the exit command", sessionThread.isAlive()); + Assert.assertNull("IPC client session failed", failure.get()); + Assert.assertFalse("Clean IPC client exit left the thread interrupted", interrupted.get()); + Mockito.verify(reader, Mockito.never()).printAbove("Disconnected from server."); + } + } + + @Test(timeout = 10_000) + public void testSessionDoesNotSwallowUnexpectedRuntimeException() throws Exception { + LineReader reader = Mockito.mock(LineReader.class); + IllegalStateException expected = new IllegalStateException("unexpected failure"); + Mockito.when(reader.readLine("> ")).thenThrow(expected); + + try (ServerSocket serverSocket = new ServerSocket(0); + Socket clientSocket = new Socket("127.0.0.1", serverSocket.getLocalPort()); + Socket serverConnection = serverSocket.accept()) { + try { + new IpcClient("unused").runSession(clientSocket, reader); + Assert.fail("Expected the unexpected runtime exception to propagate"); + } catch (IllegalStateException e) { + Assert.assertSame(expected, e); + } + } + } + + private void assertLocalExec(String input, int exitCode, String output, String error) + throws Exception { + Socket socket = Mockito.mock(Socket.class); + PrintStream originalOut = System.out; + PrintStream originalErr = System.err; + ByteArrayOutputStream consoleOutput = new ByteArrayOutputStream(); + ByteArrayOutputStream errorOutput = new ByteArrayOutputStream(); + try (PrintStream capturedOut = new PrintStream(consoleOutput, true, "UTF-8"); + PrintStream capturedErr = new PrintStream(errorOutput, true, "UTF-8")) { + System.setOut(capturedOut); + System.setErr(capturedErr); + Assert.assertEquals(exitCode, new IpcClient("unused").runExec(socket, input)); + } finally { + System.setOut(originalOut); + System.setErr(originalErr); + } + Assert.assertEquals(output, consoleOutput.toString("UTF-8")); + Assert.assertEquals(error, errorOutput.toString("UTF-8")); + Mockito.verifyNoInteractions(socket); + } +} diff --git a/framework/src/test/java/org/tron/core/services/admin/ipc/client/IpcConsoleCommandsTest.java b/framework/src/test/java/org/tron/core/services/admin/ipc/client/IpcConsoleCommandsTest.java new file mode 100644 index 00000000000..750a160731f --- /dev/null +++ b/framework/src/test/java/org/tron/core/services/admin/ipc/client/IpcConsoleCommandsTest.java @@ -0,0 +1,219 @@ +package org.tron.core.services.admin.ipc.client; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.googlecode.jsonrpc4j.JsonRpcMethod; +import com.googlecode.jsonrpc4j.JsonRpcParam; +import java.util.List; +import java.util.Map; +import org.junit.Assert; +import org.junit.Test; +import org.tron.core.services.admin.AdminJsonRpc; +import org.tron.core.services.admin.ipc.client.IpcConsoleCommands.Action; +import org.tron.core.services.admin.ipc.client.IpcConsoleCommands.Command; + +public class IpcConsoleCommandsTest { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private final IpcConsoleCommands commands = new IpcConsoleCommands(AdminJsonRpc.class); + + @Test + public void testHelpUsesAnnotatedParameters() { + Command help = commands.prepare("help"); + + Assert.assertEquals(Action.HELP, help.getAction()); + Assert.assertNull(help.getRequest()); + Assert.assertNull(help.getError()); + Assert.assertEquals(String.join(System.lineSeparator(), + "Available commands:", " admin_example ", + " help [command]", " exit/quit"), help.getOutput()); + Assert.assertEquals("usage: admin_example ", + commands.prepare("HELP ADMIN_EXAMPLE").getOutput()); + Assert.assertEquals(help.getOutput(), commands.prepare("help unknown").getOutput()); + } + + @Test + public void testCompletionAndHelpAreSortedAndUseCanonicalNames() { + IpcConsoleCommands typedCommands = new IpcConsoleCommands(TypedApi.class); + + Assert.assertArrayEquals(new String[] {"admin_example"}, commands.getCompletionCommandNames()); + Assert.assertArrayEquals(new String[] {"admin_Nullable", "admin_Ping", "admin_Typed"}, + typedCommands.getCompletionCommandNames()); + Assert.assertEquals(String.join(System.lineSeparator(), "Available commands:", + " admin_Nullable ", " admin_Ping", + " admin_Typed " + + " ", + " help [command]", " exit/quit"), typedCommands.prepare("help").getOutput()); + } + + @Test + public void testMissingParameterAnnotationIsRejected() { + try { + new IpcConsoleCommands(MissingParameterAnnotationApi.class); + Assert.fail("Expected an unannotated JSON-RPC parameter to be rejected"); + } catch (IllegalStateException e) { + Assert.assertEquals("Missing @JsonRpcParam on invalid parameter 0", e.getMessage()); + } + } + + @Test + public void testEmptyParameterAnnotationIsRejected() { + try { + new IpcConsoleCommands(EmptyParameterAnnotationApi.class); + Assert.fail("Expected an empty JSON-RPC parameter annotation to be rejected"); + } catch (IllegalStateException e) { + Assert.assertEquals("Missing @JsonRpcParam on invalid parameter 0", e.getMessage()); + } + } + + @Test + public void testQuotedArgumentsAndCanonicalRequestName() throws Exception { + JsonNode request = request(commands.prepare( + " \tADMIN_EXAMPLE \" hello world \" 'second value' ")); + + Assert.assertEquals("2.0", request.get("jsonrpc").asText()); + Assert.assertEquals("admin_example", request.get("method").asText()); + Assert.assertEquals(" hello world ", request.get("params").get(0).asText()); + Assert.assertEquals("second value", request.get("params").get(1).asText()); + Assert.assertEquals(1, request.get("id").asInt()); + } + + @Test + public void testStringParametersRemainStringsAndAreJsonEscaped() throws Exception { + JsonNode request = request(commands.prepare("admin_example 'a\"b' null")); + + Assert.assertEquals("a\"b", request.get("params").get(0).asText()); + Assert.assertTrue(request.get("params").get(1).isTextual()); + Assert.assertEquals("null", request.get("params").get(1).asText()); + } + + @Test + public void testLocalCommandsHaveNoRequest() { + for (String input : new String[] {null, "", " \t "}) { + Command command = commands.prepare(input); + Assert.assertEquals(Action.EMPTY, command.getAction()); + Assert.assertNull(command.getRequest()); + Assert.assertNull(command.getError()); + } + for (String input : new String[] {"exit", "QUIT", "Exit ignored"}) { + Command command = commands.prepare(input); + Assert.assertEquals(Action.EXIT, command.getAction()); + Assert.assertNull(command.getRequest()); + Assert.assertNull(command.getOutput()); + Assert.assertNull(command.getError()); + } + } + + @Test + public void testInvalidSyntaxDoesNotExposeArguments() { + assertError(commands.prepare("admin_example \"sensitive-value"), "Invalid command syntax."); + } + + @Test + public void testUnknownCommandIncludesHelpButArityErrorOnlyIncludesUsage() { + Command unknown = commands.prepare("unknown secret"); + assertError(unknown, "Invalid cmd: unknown"); + Assert.assertEquals(commands.prepare("help").getOutput(), unknown.getOutput()); + + Command wrongArity = commands.prepare("admin_example secret"); + assertError(wrongArity, + "Invalid parameter, usage: admin_example "); + Assert.assertNull(wrongArity.getOutput()); + } + + @Test + public void testOnlyRequestsConsumeIds() throws Exception { + Assert.assertEquals(1, request(commands.prepare("admin_example a b")).get("id").asInt()); + commands.prepare("help"); + commands.prepare("unknown"); + commands.prepare("admin_example missing"); + commands.prepare("exit"); + commands.prepare(""); + Assert.assertEquals(2, request(commands.prepare("admin_example c d")).get("id").asInt()); + } + + @Test + public void testTypedArgumentsRetainDeclaredGenericTypes() throws Exception { + IpcConsoleCommands typedCommands = new IpcConsoleCommands(TypedApi.class); + JsonNode request = request(typedCommands.prepare( + "admin_typed 42 true '[1,2]' x ON '{\"height\":10}' '[3,4]' ' hello '")); + + Assert.assertEquals("admin_Typed", request.get("method").asText()); + Assert.assertEquals(OBJECT_MAPPER.readTree( + "[42,true,[1,2],\"x\",\"ON\",{\"height\":10},[3,4],\" hello \"]"), + request.get("params")); + } + + @Test + public void testNullableAndNoArgumentCommands() throws Exception { + IpcConsoleCommands typedCommands = new IpcConsoleCommands(TypedApi.class); + + Assert.assertEquals(OBJECT_MAPPER.readTree("[null]"), + request(typedCommands.prepare("admin_nullable null")).get("params")); + Assert.assertEquals(OBJECT_MAPPER.readTree("[]"), + request(typedCommands.prepare("admin_ping")).get("params")); + } + + @Test + public void testInvalidTypedArgumentsProduceSanitizedErrors() throws Exception { + IpcConsoleCommands typedCommands = new IpcConsoleCommands(TypedApi.class); + for (String value : new String[] {"null", "sensitive-value"}) { + assertError(typedCommands.prepare("admin_typed " + value + " true '[1]' x ON '{}' '[]' text"), + "Invalid value for ; expected int"); + } + assertError(typedCommands.prepare("admin_typed 1 true '[\"secret\"]' x ON '{}' '[]' text"), + "Invalid value for ; expected java.util.List"); + assertError(typedCommands.prepare("admin_typed 1 true '[1]' secret ON '{}' '[]' text"), + "Invalid value for ; expected char"); + assertError(typedCommands.prepare("admin_typed 1 true '[1]' x secret '{}' '[]' text"), + "Invalid value for ; expected " + Mode.class.getName()); + Assert.assertEquals(1, request(typedCommands.prepare("admin_ping")).get("id").asInt()); + } + + private JsonNode request(Command command) throws Exception { + Assert.assertEquals(command.getError(), Action.REQUEST, command.getAction()); + Assert.assertNull(command.getError()); + Assert.assertNull(command.getOutput()); + Assert.assertNotNull(command.getRequest()); + return OBJECT_MAPPER.readTree(command.getRequest()); + } + + private void assertError(Command command, String error) { + Assert.assertEquals(Action.ERROR, command.getAction()); + Assert.assertNull(command.getRequest()); + Assert.assertEquals(error, command.getError()); + } + + private enum Mode { + ON, OFF + } + + private interface TypedApi { + + @JsonRpcMethod("admin_Typed") + void typed(@JsonRpcParam("number") int number, @JsonRpcParam("enabled") boolean enabled, + @JsonRpcParam("numbers") List numbers, @JsonRpcParam("letter") char letter, + @JsonRpcParam("mode") Mode mode, @JsonRpcParam("labels") Map labels, + @JsonRpcParam("ids") int[] ids, @JsonRpcParam("text") CharSequence text); + + @JsonRpcMethod("admin_Ping") + void ping(); + + @JsonRpcMethod("admin_Nullable") + void nullable(@JsonRpcParam("number") Integer number); + + void ignored(String unannotated); + } + + private interface MissingParameterAnnotationApi { + + @JsonRpcMethod("admin_invalid") + void invalid(String value); + } + + private interface EmptyParameterAnnotationApi { + + @JsonRpcMethod("admin_invalid") + void invalid(@JsonRpcParam("") String value); + } +} diff --git a/framework/src/test/java/org/tron/core/services/admin/ipc/client/IpcResponseTest.java b/framework/src/test/java/org/tron/core/services/admin/ipc/client/IpcResponseTest.java new file mode 100644 index 00000000000..7eda23e5740 --- /dev/null +++ b/framework/src/test/java/org/tron/core/services/admin/ipc/client/IpcResponseTest.java @@ -0,0 +1,59 @@ +package org.tron.core.services.admin.ipc.client; + +import org.junit.Assert; +import org.junit.Test; + +public class IpcResponseTest { + + @Test + public void testTextResultIsUnquoted() { + assertResponse("{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":\"done\"}", "done", true); + } + + @Test + public void testStructuredResultIsPrettyPrinted() { + IpcResponse response = IpcResponse.parse( + "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"height\":10,\"ready\":true}}"); + + Assert.assertTrue(response.isSuccessful()); + Assert.assertEquals(String.join(System.lineSeparator(), + "{", " \"height\" : 10,", " \"ready\" : true", "}"), response.getFormatted()); + } + + @Test + public void testNullAndScalarResultsAreSuccessful() { + assertResponse("{\"result\":null}", "null", true); + assertResponse("{\"result\":false}", "false", true); + assertResponse("{\"result\":42,\"error\":null}", "42", true); + } + + @Test + public void testErrorTakesPrecedenceOverResult() { + assertResponse("{\"result\":\"ignored\"," + + "\"error\":{\"code\":-32602,\"message\":\"Invalid params\"}}", + "Error -32602: Invalid params", false); + assertResponse("{\"error\":{\"message\":\"Failed\"}}", "Error: Failed", false); + assertResponse("{\"error\":{}}", "Error: Unknown error", false); + } + + @Test + public void testMalformedResponsePreservesTextAndFails() { + for (String input : new String[] {"", " ", "response", "{broken json"}) { + assertResponse(input, input, false); + } + } + + @Test + public void testResponseWithoutResultFails() { + assertResponse("null", "null", false); + assertResponse("\"message\"", "message", false); + assertResponse("{\"id\":1}", String.join(System.lineSeparator(), + "{", " \"id\" : 1", "}"), false); + } + + private void assertResponse(String input, String formatted, boolean successful) { + IpcResponse response = IpcResponse.parse(input); + Assert.assertEquals(formatted, response.getFormatted()); + Assert.assertEquals(successful, response.isSuccessful()); + } +} diff --git a/framework/src/test/java/org/tron/core/services/admin/ipc/server/IpcRequestHandlerTest.java b/framework/src/test/java/org/tron/core/services/admin/ipc/server/IpcRequestHandlerTest.java new file mode 100644 index 00000000000..bdb93b21df6 --- /dev/null +++ b/framework/src/test/java/org/tron/core/services/admin/ipc/server/IpcRequestHandlerTest.java @@ -0,0 +1,178 @@ +package org.tron.core.services.admin.ipc.server; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.googlecode.jsonrpc4j.JsonRpcServer; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import org.junit.Assert; +import org.junit.Test; +import org.mockito.MockedConstruction; +import org.mockito.Mockito; +import org.tron.core.Constant; +import org.tron.core.exception.jsonrpc.JsonRpcInvalidParamsException; +import org.tron.core.services.admin.AdminJsonRpc; +import org.tron.core.services.admin.AdminJsonRpcImpl; +import org.tron.core.services.admin.ipc.server.IpcRequestHandler.RequestTooLargeException; + +public class IpcRequestHandlerTest { + + private static final int MAX_REQUEST_SIZE = 128; + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private final IpcRequestHandler handler = + new IpcRequestHandler(new AdminJsonRpcImpl(), MAX_REQUEST_SIZE); + + @Test + public void testHandleCommandReturnsSingleLineJsonResponse() throws Exception { + String response = handler.handleCommand( + "{\"jsonrpc\":\"2.0\",\"method\":\"admin_example\"," + + "\"params\":[\"a\\nb\",\"c\\rd\"],\"id\":7}"); + + Assert.assertFalse(response, response.contains("\n")); + Assert.assertFalse(response, response.contains("\r")); + JsonNode result = OBJECT_MAPPER.readTree(response); + Assert.assertEquals("a\nb:c\rd", result.get("result").asText()); + Assert.assertEquals(7, result.get("id").asInt()); + } + + @Test + public void testHandleCommandReturnsJsonRpcErrorOnDispatcherFailure() throws Exception { + try (MockedConstruction servers = Mockito.mockConstruction(JsonRpcServer.class, + (server, context) -> Mockito.doThrow(new IOException("sensitive-detail")) + .when(server).handleRequest(Mockito.any(InputStream.class), + Mockito.any(OutputStream.class)))) { + IpcRequestHandler failingHandler = + new IpcRequestHandler(new AdminJsonRpcImpl(), MAX_REQUEST_SIZE); + String response = failingHandler.handleCommand( + "{\"jsonrpc\":\"2.0\",\"method\":\"admin_example\"," + + "\"params\":[\"a\",\"b\"],\"id\":9}"); + JsonNode responseNode = OBJECT_MAPPER.readTree(response); + + Assert.assertEquals(1, servers.constructed().size()); + Assert.assertEquals("2.0", responseNode.get("jsonrpc").asText()); + Assert.assertEquals(-32603, responseNode.get("error").get("code").asInt()); + Assert.assertEquals("Internal error", responseNode.get("error").get("message").asText()); + Assert.assertEquals(9, responseNode.get("id").asInt()); + Assert.assertFalse(response, response.contains("sensitive-detail")); + Assert.assertFalse(response, response.contains("\n")); + + JsonNode malformed = OBJECT_MAPPER.readTree(failingHandler.handleCommand("{broken")); + Assert.assertEquals(-32603, malformed.get("error").get("code").asInt()); + Assert.assertTrue(malformed.get("id").isNull()); + } + } + + @Test + public void testHandleCommandUsesAnnotatedErrorResolver() throws Exception { + AdminJsonRpc adminJsonRpc = Mockito.mock(AdminJsonRpc.class); + Mockito.when(adminJsonRpc.adminExample("a", "b")) + .thenThrow(new JsonRpcInvalidParamsException("Invalid admin parameters")); + IpcRequestHandler errorHandler = new IpcRequestHandler(adminJsonRpc, MAX_REQUEST_SIZE); + + String response = errorHandler.handleCommand( + "{\"jsonrpc\":\"2.0\",\"method\":\"admin_example\"," + + "\"params\":[\"a\",\"b\"],\"id\":10}"); + JsonNode responseNode = OBJECT_MAPPER.readTree(response); + + Assert.assertEquals(-32602, responseNode.get("error").get("code").asInt()); + Assert.assertEquals("Invalid admin parameters", + responseNode.get("error").get("message").asText()); + Assert.assertEquals(10, responseNode.get("id").asInt()); + } + + @Test + public void testNotificationInvokesMethodWithoutResponse() throws Exception { + AdminJsonRpc adminJsonRpc = Mockito.mock(AdminJsonRpc.class); + IpcRequestHandler notificationHandler = new IpcRequestHandler(adminJsonRpc, MAX_REQUEST_SIZE); + + Assert.assertEquals("", notificationHandler.handleCommand( + "{\"jsonrpc\":\"2.0\",\"method\":\"admin_example\",\"params\":[\"a\",\"b\"]}")); + Mockito.verify(adminJsonRpc).adminExample("a", "b"); + } + + @Test + public void testIpcMapperRejectsExcessiveNestingBeforeInvocation() throws Exception { + AdminJsonRpc adminJsonRpc = Mockito.mock(AdminJsonRpc.class); + Mockito.when(adminJsonRpc.adminExample("a", "b")).thenReturn("a:b"); + IpcRequestHandler constrainedHandler = new IpcRequestHandler(adminJsonRpc, MAX_REQUEST_SIZE); + String requestPrefix = "{\"jsonrpc\":\"2.0\",\"method\":\"admin_example\"," + + "\"params\":[\"a\",\"b\"],\"id\":11,\"extra\":"; + JsonNode valid = OBJECT_MAPPER.readTree( + constrainedHandler.handleCommand(requestPrefix + "[0]}")); + Assert.assertEquals("a:b", valid.get("result").asText()); + Mockito.verify(adminJsonRpc).adminExample("a", "b"); + Mockito.clearInvocations(adminJsonRpc); + + StringBuilder nested = new StringBuilder(); + for (int i = 0; i <= Constant.MAX_NESTING_DEPTH; i++) { + nested.append('['); + } + nested.append('0'); + for (int i = 0; i <= Constant.MAX_NESTING_DEPTH; i++) { + nested.append(']'); + } + JsonNode rejected = OBJECT_MAPPER.readTree( + constrainedHandler.handleCommand(requestPrefix + nested + "}")); + Assert.assertTrue(rejected.toString(), rejected.has("error")); + Assert.assertFalse(rejected.has("result")); + Mockito.verifyNoInteractions(adminJsonRpc); + } + + @Test + public void testReadRequestAcceptsMaximumSize() throws Exception { + byte[] request = new byte[MAX_REQUEST_SIZE + 1]; + Arrays.fill(request, 0, MAX_REQUEST_SIZE, (byte) '1'); + request[MAX_REQUEST_SIZE] = '\n'; + + Assert.assertEquals(MAX_REQUEST_SIZE, + handler.readRequest(new ByteArrayInputStream(request)).length()); + } + + @Test(expected = RequestTooLargeException.class) + public void testReadRequestRejectsOversizedInputWithoutNewline() throws Exception { + handler.readRequest(new ByteArrayInputStream(new byte[MAX_REQUEST_SIZE + 1])); + } + + @Test + public void testReadRequestPreservesFramesAndDistinguishesEmptyLineFromEof() throws Exception { + ByteArrayInputStream input = new ByteArrayInputStream( + "first\r\n\n second \nlast".getBytes(StandardCharsets.UTF_8)); + + Assert.assertEquals("first", handler.readRequest(input)); + Assert.assertEquals("", handler.readRequest(input)); + Assert.assertEquals(" second ", handler.readRequest(input)); + Assert.assertEquals("last", handler.readRequest(input)); + Assert.assertNull(handler.readRequest(input)); + } + + @Test + public void testReadRequestCountsBytesAndDecodesUtf8() throws Exception { + IpcRequestHandler limited = new IpcRequestHandler(new AdminJsonRpcImpl(), 3); + Assert.assertEquals("中", limited.readRequest(new ByteArrayInputStream( + "中\n".getBytes(StandardCharsets.UTF_8)))); + try { + limited.readRequest(new ByteArrayInputStream("中文\n".getBytes(StandardCharsets.UTF_8))); + Assert.fail("Expected multi-byte input to exceed the byte limit"); + } catch (RequestTooLargeException expected) { + // A character count would incorrectly accept both characters. + } + } + + @Test + public void testZeroLimitOnlyAcceptsEmptyLines() throws Exception { + IpcRequestHandler zeroLimit = new IpcRequestHandler(new AdminJsonRpcImpl(), 0); + Assert.assertEquals(0, zeroLimit.getMaxRequestSize()); + Assert.assertEquals("", zeroLimit.readRequest(new ByteArrayInputStream(new byte[] {'\n'}))); + Assert.assertNull(zeroLimit.readRequest(new ByteArrayInputStream(new byte[0]))); + try { + zeroLimit.readRequest(new ByteArrayInputStream(new byte[] {'a'})); + Assert.fail("Expected a configured zero limit to reject nonempty input"); + } catch (RequestTooLargeException expected) { + // Zero must not silently fall back to a default limit. + } + } +} diff --git a/framework/src/test/java/org/tron/core/services/admin/ipc/server/IpcServiceTest.java b/framework/src/test/java/org/tron/core/services/admin/ipc/server/IpcServiceTest.java new file mode 100644 index 00000000000..3d2c4d228c3 --- /dev/null +++ b/framework/src/test/java/org/tron/core/services/admin/ipc/server/IpcServiceTest.java @@ -0,0 +1,665 @@ +package org.tron.core.services.admin.ipc.server; + +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.net.SocketException; +import java.nio.charset.StandardCharsets; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.attribute.PosixFilePermission; +import java.util.EnumSet; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.TimeUnit; +import org.junit.After; +import org.junit.Assert; +import org.junit.Assume; +import org.junit.Before; +import org.junit.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.newsclub.net.unix.AFUNIXServerSocket; +import org.newsclub.net.unix.AFUNIXSocket; +import org.newsclub.net.unix.AFUNIXSocketAddress; +import org.tron.common.parameter.CommonParameter; +import org.tron.core.config.args.Args; +import org.tron.core.services.admin.AdminJsonRpc; +import org.tron.core.services.admin.AdminJsonRpcImpl; + +public class IpcServiceTest { + + private int originalMaxMessageSize; + + @Before + public void setUp() { + originalMaxMessageSize = Args.getInstance().maxMessageSize; + Args.getInstance().maxMessageSize = 4 * 1024 * 1024; + } + + @After + public void tearDown() { + Args.getInstance().maxMessageSize = originalMaxMessageSize; + } + + @Test + public void testServiceIsNotRunningBeforeStart() throws Exception { + Assert.assertFalse(isRunning(newIpcService())); + } + + @Test(timeout = 10_000) + public void testRequestSizePreservesConfiguredZero() throws Exception { + assumePosixFileSystem(); + Args.getInstance().maxMessageSize = 0; + CommonParameter parameter = Args.getInstance(); + String originalOutputDirectory = parameter.outputDirectory; + String originalSocketDirectory = parameter.ipcSocketDirectory; + Path outputDirectory = Files.createTempDirectory(Paths.get("/tmp"), "ipc-zero-"); + AdminJsonRpc adminJsonRpc = Mockito.mock(AdminJsonRpc.class); + IpcService service = new IpcService(adminJsonRpc); + boolean started = false; + Path socketFile = null; + try { + parameter.outputDirectory = outputDirectory.toString(); + parameter.ipcSocketDirectory = ""; + service.innerStart(); + started = true; + socketFile = resolveSocketFilePath(parameter, getPid(service)); + + try (AFUNIXSocket client = AFUNIXSocket.newInstance()) { + client.connect(AFUNIXSocketAddress.of(socketFile.toFile())); + client.setSoTimeout(2_000); + client.getOutputStream().write('{'); + client.getOutputStream().flush(); + Assert.assertEquals("A zero limit must close the connection on its first byte", + -1, client.getInputStream().read()); + Mockito.verifyNoInteractions(adminJsonRpc); + } + } finally { + parameter.ipcSocketDirectory = originalSocketDirectory; + cleanupIpcService(service, started, parameter, originalOutputDirectory, socketFile, + outputDirectory); + } + } + + @Test(timeout = 10_000) + public void testSocketFileUsesOwnerOnlyPermissions() throws Exception { + assumePosixFileSystem(); + CommonParameter parameter = Args.getInstance(); + String originalOutputDirectory = parameter.outputDirectory; + Path outputDirectory = Files.createTempDirectory(Paths.get("/tmp"), "ipc-permission-test-"); + IpcService service = new IpcService( + new AdminJsonRpcImpl()); + boolean started = false; + Path socketFile = null; + try { + parameter.outputDirectory = outputDirectory.toString(); + Assert.assertTrue(service.start().get()); + started = true; + + socketFile = resolveSocketFilePath(parameter, getPid(service)); + Assert.assertEquals( + EnumSet.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE), + Files.getPosixFilePermissions(socketFile)); + } finally { + cleanupIpcService(service, started, parameter, originalOutputDirectory, socketFile, + outputDirectory); + } + } + + @Test(timeout = 10_000) + public void testInnerStartCleansSocketWhenPermissionUpdateFails() throws Exception { + assumePosixFileSystem(); + CommonParameter parameter = Args.getInstance(); + String originalOutputDirectory = parameter.outputDirectory; + String originalSocketDirectory = parameter.ipcSocketDirectory; + Path socketDirectory = Files.createTempDirectory(Paths.get("/tmp"), "ipc-perm-fail-"); + IpcService service = newIpcService(); + Path socketFile = null; + try { + parameter.outputDirectory = socketDirectory.toString(); + parameter.ipcSocketDirectory = ""; + socketFile = resolveSocketFilePath(parameter, getPid(service)); + Set permissions = EnumSet.of(PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE); + try (MockedStatic files = Mockito.mockStatic(Files.class, + Mockito.CALLS_REAL_METHODS)) { + Path expectedSocketFile = socketFile; + files.when(() -> Files.setPosixFilePermissions(expectedSocketFile, permissions)) + .thenThrow(new IOException("permission update failed")); + + try { + service.innerStart(); + Assert.fail("Expected the permission update failure to be preserved"); + } catch (IOException e) { + Assert.assertEquals("permission update failed", e.getMessage()); + } + } + + Assert.assertFalse(Files.exists(socketFile)); + } finally { + parameter.outputDirectory = originalOutputDirectory; + parameter.ipcSocketDirectory = originalSocketDirectory; + if (socketFile != null) { + Files.deleteIfExists(socketFile); + Files.deleteIfExists(socketFile.getParent()); + } + Files.deleteIfExists(socketDirectory); + } + } + + @Test(timeout = 10_000) + public void testInnerStartRollsBackWhenAcceptorSubmissionFails() throws Exception { + assumePosixFileSystem(); + CommonParameter parameter = Args.getInstance(); + String originalOutputDirectory = parameter.outputDirectory; + String originalSocketDirectory = parameter.ipcSocketDirectory; + Path outputDirectory = Files.createTempDirectory(Paths.get("/tmp"), + "ipc-submit-fail-"); + IpcService service = newIpcService(); + Path socketFile = null; + try { + parameter.outputDirectory = outputDirectory.toString(); + parameter.ipcSocketDirectory = ""; + socketFile = resolveSocketFilePath(parameter, getPid(service)); + getExecutorService(service, "acceptorExecutor").shutdownNow(); + + try { + service.innerStart(); + Assert.fail("Expected acceptor submission to fail"); + } catch (RejectedExecutionException e) { + Assert.assertFalse(isRunning(service)); + } + + Assert.assertFalse(Files.exists(socketFile)); + Assert.assertFalse(Files.exists(socketFile.getParent())); + } finally { + parameter.outputDirectory = originalOutputDirectory; + parameter.ipcSocketDirectory = originalSocketDirectory; + service.innerStop(); + if (socketFile != null) { + Files.deleteIfExists(socketFile); + Files.deleteIfExists(socketFile.getParent()); + } + Files.deleteIfExists(outputDirectory); + } + } + + @Test(timeout = 10_000) + public void testHandlesMultipleClientsConcurrently() throws Exception { + assumePosixFileSystem(); + CommonParameter parameter = Args.getInstance(); + String originalOutputDirectory = parameter.outputDirectory; + Path outputDirectory = Files.createTempDirectory(Paths.get("/tmp"), "ipc-multi-client-test-"); + IpcService service = new IpcService( + new AdminJsonRpcImpl()); + boolean started = false; + Path socketFile = null; + try { + parameter.outputDirectory = outputDirectory.toString(); + service.innerStart(); + started = true; + + socketFile = resolveSocketFilePath(parameter, getPid(service)); + AFUNIXSocketAddress address = AFUNIXSocketAddress.of(socketFile.toFile()); + try (AFUNIXSocket firstClient = AFUNIXSocket.newInstance(); + AFUNIXSocket secondClient = AFUNIXSocket.newInstance()) { + firstClient.connect(address); + firstClient.setSoTimeout(5_000); + BufferedWriter firstWriter = new BufferedWriter( + new OutputStreamWriter(firstClient.getOutputStream(), StandardCharsets.UTF_8)); + BufferedReader firstReader = new BufferedReader( + new InputStreamReader(firstClient.getInputStream(), StandardCharsets.UTF_8)); + assertSuccessfulResponse(sendRequest(firstWriter, firstReader, 1), 1); + assertSuccessfulResponse(sendRequest(firstWriter, firstReader, 2), 2); + + secondClient.connect(address); + secondClient.setSoTimeout(5_000); + BufferedWriter secondWriter = new BufferedWriter( + new OutputStreamWriter(secondClient.getOutputStream(), StandardCharsets.UTF_8)); + BufferedReader secondReader = new BufferedReader( + new InputStreamReader(secondClient.getInputStream(), StandardCharsets.UTF_8)); + assertSuccessfulResponse(sendRequest(secondWriter, secondReader, 3), 3); + } + } finally { + cleanupIpcService(service, started, parameter, originalOutputDirectory, socketFile, + outputDirectory); + } + } + + @Test(timeout = 10_000) + public void testDispatchClientSetsIdleTimeoutAndReleasesFailedHandler() throws Exception { + IpcService service = newIpcService(); + AFUNIXSocket client = Mockito.mock(AFUNIXSocket.class); + Mockito.doThrow(new IOException("closed")).when(client).getInputStream(); + try { + setField(service, "isRunning", true); + dispatchClient(service, client); + + ExecutorService clientExecutor = getExecutorService(service, "clientExecutor"); + clientExecutor.shutdown(); + Assert.assertTrue("Expected the failed handler to finish before stopping the service", + clientExecutor.awaitTermination(2, TimeUnit.SECONDS)); + Mockito.verify(client).setSoTimeout(10 * 60 * 1000); + Mockito.verify(client).close(); + Assert.assertTrue(getActiveClientSockets(service).isEmpty()); + } finally { + service.innerStop(); + } + } + + @Test(timeout = 5_000) + public void testDispatchClientClosesSocketWhenTimeoutConfigurationFails() throws Exception { + IpcService service = newIpcService(); + AFUNIXSocket client = Mockito.mock(AFUNIXSocket.class); + Mockito.doThrow(new SocketException("timeout configuration failed")) + .when(client).setSoTimeout(Mockito.anyInt()); + try { + setField(service, "isRunning", true); + + dispatchClient(service, client); + + Mockito.verify(client).close(); + Mockito.verify(client, Mockito.never()).getInputStream(); + Assert.assertTrue(getActiveClientSockets(service).isEmpty()); + } finally { + service.innerStop(); + } + } + + @Test(timeout = 5_000) + public void testDispatchClientDoesNotSubmitWhenStopped() throws Exception { + IpcService service = newIpcService(); + ExecutorService clientExecutor = Mockito.mock(ExecutorService.class); + AFUNIXSocket client = Mockito.mock(AFUNIXSocket.class); + service.innerStop(); + setField(service, "clientExecutor", clientExecutor); + + dispatchClient(service, client); + + Mockito.verifyNoInteractions(clientExecutor); + Mockito.verify(client).close(); + Assert.assertTrue(getActiveClientSockets(service).isEmpty()); + } + + @Test(timeout = 5_000) + public void testDispatchClientReleasesSocketWhenSubmissionThrows() throws Exception { + IpcService service = newIpcService(); + ExecutorService clientExecutor = Mockito.mock(ExecutorService.class); + AFUNIXSocket client = Mockito.mock(AFUNIXSocket.class); + IllegalStateException failure = new IllegalStateException("submission failed"); + Mockito.when(clientExecutor.submit(Mockito.any(Runnable.class))).thenThrow(failure); + Mockito.when(clientExecutor.awaitTermination(Mockito.anyLong(), Mockito.any(TimeUnit.class))) + .thenReturn(true); + try { + setField(service, "isRunning", true); + setField(service, "clientExecutor", clientExecutor); + + try { + dispatchClient(service, client); + Assert.fail("Expected the submission failure to be preserved"); + } catch (IllegalStateException e) { + Assert.assertSame(failure, e); + } + + Mockito.verify(client).close(); + Assert.assertTrue(getActiveClientSockets(service).isEmpty()); + } finally { + service.innerStop(); + } + } + + @Test(timeout = 10_000) + public void testRejectsClientImmediatelyWhenAllHandlersAreBusy() throws Exception { + IpcService service = newIpcService(); + CountDownLatch handlersStarted = new CountDownLatch(16); + CountDownLatch releaseHandlers = new CountDownLatch(1); + try { + setField(service, "isRunning", true); + for (int i = 0; i < 16; i++) { + AFUNIXSocket client = Mockito.mock(AFUNIXSocket.class); + Mockito.when(client.getInputStream()).thenAnswer(invocation -> { + handlersStarted.countDown(); + try { + releaseHandlers.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + throw new IOException("closed"); + }); + dispatchClient(service, client); + } + Assert.assertTrue("Expected all IPC handlers to start without queueing", + handlersStarted.await(5, TimeUnit.SECONDS)); + + AFUNIXSocket rejectedClient = Mockito.mock(AFUNIXSocket.class); + dispatchClient(service, rejectedClient); + + Mockito.verify(rejectedClient).close(); + Assert.assertFalse(getActiveClientSockets(service).contains(rejectedClient)); + } finally { + releaseHandlers.countDown(); + service.innerStop(); + } + } + + @Test(timeout = 10_000) + public void testStopClosesActiveClientSocket() throws Exception { + assumePosixFileSystem(); + CommonParameter parameter = Args.getInstance(); + String originalOutputDirectory = parameter.outputDirectory; + Path outputDirectory = Files.createTempDirectory(Paths.get("/tmp"), "ipc-test-"); + IpcService service = new IpcService( + new AdminJsonRpcImpl()); + boolean started = false; + Path socketFile = null; + try { + parameter.outputDirectory = outputDirectory.toString(); + service.innerStart(); + started = true; + + socketFile = resolveSocketFilePath(parameter, getPid(service)); + AFUNIXSocketAddress address = AFUNIXSocketAddress.of(socketFile.toFile()); + try (AFUNIXSocket client = AFUNIXSocket.newInstance()) { + client.connect(address); + client.setSoTimeout(5_000); + try (BufferedWriter writer = new BufferedWriter( + new OutputStreamWriter(client.getOutputStream(), StandardCharsets.UTF_8)); + BufferedReader reader = new BufferedReader( + new InputStreamReader(client.getInputStream(), StandardCharsets.UTF_8))) { + writer.write("{\"jsonrpc\":\"2.0\",\"method\":\"admin_example\"," + + "\"params\":[\"a\",\"b\"],\"id\":1}"); + writer.newLine(); + writer.flush(); + Assert.assertNotNull(reader.readLine()); + + service.innerStop(); + started = false; + } + } + } finally { + cleanupIpcService(service, started, parameter, originalOutputDirectory, socketFile, + outputDirectory); + } + } + + @Test(timeout = 5_000) + public void testStopDoesNotWaitForUnresponsiveClientWorker() throws Exception { + IpcService service = newIpcService(); + ExecutorService clientExecutor = getExecutorService(service, "clientExecutor"); + CountDownLatch workerStarted = new CountDownLatch(1); + CountDownLatch releaseWorker = new CountDownLatch(1); + clientExecutor.submit(() -> { + workerStarted.countDown(); + boolean interrupted = false; + while (true) { + try { + releaseWorker.await(); + break; + } catch (InterruptedException e) { + interrupted = true; + } + } + if (interrupted) { + Thread.currentThread().interrupt(); + } + }); + Assert.assertTrue("Expected the client worker to start", + workerStarted.await(2, TimeUnit.SECONDS)); + + try { + service.innerStop(); + + Assert.assertTrue(clientExecutor.isShutdown()); + Assert.assertFalse("The unresponsive worker should still be running", + clientExecutor.isTerminated()); + } finally { + releaseWorker.countDown(); + Assert.assertTrue("Expected the released client worker to terminate", + clientExecutor.awaitTermination(2, TimeUnit.SECONDS)); + } + } + + @Test(timeout = 5_000) + public void testInnerStopContinuesCleanupAfterServerCloseFailure() throws Exception { + IpcService service = newIpcService(); + AFUNIXServerSocket serverSocket = Mockito.mock(AFUNIXServerSocket.class); + AFUNIXSocket clientSocket = Mockito.mock(AFUNIXSocket.class); + Path socketRootDirectory = Files.createTempDirectory("ipc-stop-failure-test-"); + Path socketDirectory = Files.createDirectory(socketRootDirectory.resolve("ipc")); + Path socketFile = Files.createFile(socketDirectory.resolve("1234.sock")); + Mockito.doThrow(new IOException("server close failed")).when(serverSocket).close(); + setField(service, "unixServerSocket", serverSocket); + setField(service, "socketFilePath", socketFile); + getActiveClientSockets(service).add(clientSocket); + + try { + try { + service.innerStop(); + Assert.fail("Expected the server socket close failure to be preserved"); + } catch (IOException e) { + Assert.assertEquals("server close failed", e.getMessage()); + } + + Mockito.verify(clientSocket).shutdownInput(); + Mockito.verify(clientSocket).shutdownOutput(); + Mockito.verify(clientSocket).close(); + Assert.assertTrue(getActiveClientSockets(service).isEmpty()); + Assert.assertTrue(getExecutorService(service, "acceptorExecutor").isShutdown()); + Assert.assertTrue(getExecutorService(service, "clientExecutor").isShutdown()); + Assert.assertFalse(Files.exists(socketFile)); + Assert.assertFalse(Files.exists(socketDirectory)); + } finally { + Files.deleteIfExists(socketFile); + Files.deleteIfExists(socketDirectory); + Files.deleteIfExists(socketRootDirectory); + } + } + + @Test(timeout = 5_000) + public void testInnerStopContinuesAfterClientCloseFailures() throws Exception { + IpcService service = newIpcService(); + AFUNIXSocket[] clients = { + Mockito.mock(AFUNIXSocket.class), Mockito.mock(AFUNIXSocket.class) + }; + for (AFUNIXSocket client : clients) { + Mockito.doThrow(new IOException("input shutdown failed")).when(client).shutdownInput(); + Mockito.doThrow(new IOException("output shutdown failed")).when(client).shutdownOutput(); + Mockito.doThrow(new IOException("client close failed")).when(client).close(); + getActiveClientSockets(service).add(client); + } + + try { + service.innerStop(); + + for (AFUNIXSocket client : clients) { + Mockito.verify(client).shutdownInput(); + Mockito.verify(client).shutdownOutput(); + Mockito.verify(client).close(); + } + Assert.assertTrue(getActiveClientSockets(service).isEmpty()); + Assert.assertTrue(getExecutorService(service, "acceptorExecutor").isShutdown()); + Assert.assertTrue(getExecutorService(service, "clientExecutor").isShutdown()); + } finally { + service.innerStop(); + } + } + + @Test(timeout = 5_000) + public void testInnerStopSuppressesLaterCleanupFailure() throws Exception { + IpcService service = newIpcService(); + AFUNIXServerSocket serverSocket = Mockito.mock(AFUNIXServerSocket.class); + Path socketRootDirectory = Files.createTempDirectory("ipc-stop-suppressed-test-"); + Path socketDirectory = Files.createDirectory( + socketRootDirectory.resolve("ipc")); + Path childFile = Files.createFile(socketDirectory.resolve("child")); + Mockito.doThrow(new IOException("server close failed")).when(serverSocket).close(); + setField(service, "unixServerSocket", serverSocket); + setField(service, "socketFilePath", socketDirectory.resolve("1234.sock")); + + try { + service.innerStop(); + Assert.fail("Expected cleanup failures to be preserved"); + } catch (IOException e) { + Assert.assertEquals("server close failed", e.getMessage()); + Assert.assertEquals(1, e.getSuppressed().length); + Assert.assertTrue(e.getSuppressed()[0] instanceof IOException); + } finally { + Files.deleteIfExists(childFile); + Files.deleteIfExists(socketDirectory); + Files.deleteIfExists(socketRootDirectory); + } + } + + @Test + public void testCleanupRestoresOutputDirectoryWhenStopFails() throws Exception { + CommonParameter parameter = new CommonParameter(); + String originalOutputDirectory = parameter.outputDirectory; + Path outputDirectory = Files.createTempDirectory("ipc-cleanup-test-"); + Path socketDirectory = Files.createDirectory(outputDirectory.resolve("ipc")); + Path socketFile = Files.createFile(socketDirectory.resolve("1234.sock")); + parameter.outputDirectory = outputDirectory.toString(); + IpcService service = Mockito.mock(IpcService.class); + Mockito.doThrow(new IOException("stop failed")).when(service).innerStop(); + + try { + cleanupIpcService(service, true, parameter, originalOutputDirectory, socketFile, + outputDirectory); + Assert.fail("Expected the stop failure to be preserved"); + } catch (IOException e) { + Assert.assertEquals("stop failed", e.getMessage()); + } + + Assert.assertEquals(originalOutputDirectory, parameter.outputDirectory); + Assert.assertFalse(Files.exists(socketFile)); + Assert.assertFalse(Files.exists(socketDirectory)); + Assert.assertFalse(Files.exists(outputDirectory)); + } + + private IpcService newIpcService() { + return new IpcService(new AdminJsonRpcImpl()); + } + + private void cleanupIpcService(IpcService service, boolean started, CommonParameter parameter, + String originalOutputDirectory, Path socketFile, Path outputDirectory) throws Exception { + parameter.outputDirectory = originalOutputDirectory; + Exception failure = null; + if (started) { + try { + service.innerStop(); + } catch (Exception e) { + failure = e; + } + } + try { + if (socketFile != null) { + Files.deleteIfExists(socketFile); + Files.deleteIfExists(socketFile.getParent()); + } + } catch (IOException e) { + failure = mergeCleanupFailure(failure, e); + } + try { + Files.deleteIfExists(outputDirectory); + } catch (IOException e) { + failure = mergeCleanupFailure(failure, e); + } + if (failure != null) { + throw failure; + } + } + + private Exception mergeCleanupFailure(Exception failure, IOException cleanupFailure) { + if (failure == null) { + return cleanupFailure; + } + failure.addSuppressed(cleanupFailure); + return failure; + } + + private Path resolveSocketFilePath(CommonParameter parameter, String pid) { + return new IpcSocketFiles().resolveSocketFilePath(parameter.getOutputDirectory(), + parameter.getIpcSocketDirectory(), pid); + } + + private boolean isRunning(IpcService service) throws Exception { + Field field = IpcService.class.getDeclaredField("isRunning"); + field.setAccessible(true); + return field.getBoolean(service); + } + + private ExecutorService getExecutorService(IpcService service, String fieldName) + throws Exception { + Field field = IpcService.class.getDeclaredField(fieldName); + field.setAccessible(true); + return (ExecutorService) field.get(service); + } + + @SuppressWarnings("unchecked") + private Set getActiveClientSockets(IpcService service) throws Exception { + Field field = IpcService.class.getDeclaredField("activeClientSockets"); + field.setAccessible(true); + return (Set) field.get(service); + } + + private void setField(IpcService service, String fieldName, Object value) throws Exception { + Field field = IpcService.class.getDeclaredField(fieldName); + field.setAccessible(true); + field.set(service, value); + } + + private String getPid(IpcService service) throws Exception { + return (String) invokePrivate(service, "getPid", new Class[0]); + } + + private void dispatchClient(IpcService service, AFUNIXSocket client) throws Exception { + invokePrivate(service, "dispatchClient", new Class[] {AFUNIXSocket.class}, client); + } + + private Object invokePrivate(IpcService service, String methodName, Class[] parameterTypes, + Object... arguments) throws Exception { + Method method = IpcService.class.getDeclaredMethod(methodName, parameterTypes); + method.setAccessible(true); + try { + return method.invoke(service, arguments); + } catch (InvocationTargetException e) { + Throwable cause = e.getCause(); + if (cause instanceof Exception) { + throw (Exception) cause; + } + if (cause instanceof Error) { + throw (Error) cause; + } + throw new IllegalStateException(cause); + } + } + + private String sendRequest(BufferedWriter writer, BufferedReader reader, int requestId) + throws IOException { + writer.write("{\"jsonrpc\":\"2.0\",\"method\":\"admin_example\"," + + "\"params\":[\"a\",\"b\"],\"id\":" + requestId + "}"); + writer.newLine(); + writer.flush(); + return reader.readLine(); + } + + private void assertSuccessfulResponse(String response, int requestId) { + Assert.assertNotNull(response); + Assert.assertTrue(response, response.contains("\"result\":\"a:b\"")); + Assert.assertTrue(response, response.contains("\"id\":" + requestId)); + } + + private void assumePosixFileSystem() { + Assume.assumeTrue("IPC requires POSIX file permissions", + FileSystems.getDefault().supportedFileAttributeViews().contains("posix")); + } +} diff --git a/framework/src/test/java/org/tron/core/services/admin/ipc/server/IpcSocketFilesTest.java b/framework/src/test/java/org/tron/core/services/admin/ipc/server/IpcSocketFilesTest.java new file mode 100644 index 00000000000..34e3ed108a0 --- /dev/null +++ b/framework/src/test/java/org/tron/core/services/admin/ipc/server/IpcSocketFilesTest.java @@ -0,0 +1,220 @@ +package org.tron.core.services.admin.ipc.server; + +import java.nio.charset.StandardCharsets; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.attribute.PosixFilePermission; +import java.util.EnumSet; +import org.junit.Assert; +import org.junit.Assume; +import org.junit.Test; +import org.tron.core.exception.TronError; + +public class IpcSocketFilesTest { + + private final IpcSocketFiles socketFiles = new IpcSocketFiles(); + + @Test + public void testResolveSocketFilePathUsesOutputDirectory() throws Exception { + String outputDirectory = "/tmp/node-output"; + + for (String configuredDirectory : new String[] {null, "", " \t "}) { + Path socketFilePath = socketFiles.resolveSocketFilePath( + outputDirectory, configuredDirectory, "1234"); + Assert.assertEquals(Paths.get("/tmp/node-output", "ipc", "1234.sock"), socketFilePath); + } + } + + @Test + public void testResolveSocketFilePathRejectsLongOutputPath() throws Exception { + String outputDirectory = Paths.get("/tmp", + "a-very-long-output-directory-name-that-makes-the-resulting-unix-domain-socket-path-" + + "exceed-the-portable-limit").toString(); + + try { + socketFiles.resolveSocketFilePath(outputDirectory, null, "1234"); + Assert.fail("Expected an overlong IPC socket path to be rejected"); + } catch (TronError e) { + Path expectedSocketFile = Paths.get(outputDirectory, "ipc", "1234.sock") + .toAbsolutePath().normalize(); + Assert.assertTrue(e.getMessage().contains("exceeding the portable limit of 100 bytes")); + Assert.assertTrue(e.getMessage().contains("node.admin.ipc.socketDirectory")); + Assert.assertFalse(e.getMessage().contains(expectedSocketFile.toString())); + } + } + + @Test + public void testSocketPathLengthCountsUtf8Bytes() { + Path socketPath = Paths.get("/tmp/目录.sock"); + + int encodedLength = IpcSocketFiles.getSocketPathLength(socketPath, StandardCharsets.UTF_8); + + Assert.assertEquals(socketPath.toString().getBytes(StandardCharsets.UTF_8).length, + encodedLength); + Assert.assertTrue(encodedLength > socketPath.toString().length()); + } + + @Test + public void testResolveSocketFilePathUsesConfiguredDirectory() throws Exception { + String outputDirectory = "node-output"; + String configuredDirectory = "/tmp/tron-ipc"; + + Path socketFilePath = socketFiles.resolveSocketFilePath( + outputDirectory, configuredDirectory, "1234"); + + Assert.assertEquals(Paths.get("/tmp/tron-ipc/ipc/1234.sock"), + socketFilePath); + } + + @Test + public void testResolveSocketFilePathRejectsRelativeConfiguredDirectory() throws Exception { + String configuredDirectory = "relative-ipc"; + + try { + socketFiles.resolveSocketFilePath("unused", configuredDirectory, "1234"); + Assert.fail("Expected a relative IPC socket directory to be rejected"); + } catch (TronError e) { + Assert.assertEquals("node.admin.ipc.socketDirectory must be an absolute path", + e.getMessage()); + } + } + + @Test + public void testResolveSocketFilePathRejectsLongConfiguredDirectory() throws Exception { + String outputDirectory = "/tmp"; + String configuredDirectory = Paths.get("/tmp", + "a-very-long-explicit-ipc-directory-that-makes-the-resulting-unix-domain-socket-path-" + + "exceed-the-portable-limit").toString(); + + try { + socketFiles.resolveSocketFilePath(outputDirectory, configuredDirectory, "1234"); + Assert.fail("Expected an overlong configured IPC socket path to be rejected"); + } catch (TronError e) { + Path expectedSocketFile = Paths.get(configuredDirectory, "ipc", "1234.sock") + .toAbsolutePath().normalize(); + Assert.assertTrue(e.getMessage().contains("exceeding the portable limit of 100 bytes")); + Assert.assertTrue(e.getMessage().contains("node.admin.ipc.socketDirectory")); + Assert.assertFalse(e.getMessage().contains(expectedSocketFile.toString())); + } + } + + @Test + public void testValidateSocketRootDirectoryRejectsMissingDirectory() throws Exception { + Path outputDirectory = Files.createTempDirectory("ipc-missing-output-test-"); + Files.delete(outputDirectory); + + try { + socketFiles.validateSocketRootDirectory(outputDirectory); + Assert.fail("Expected a missing output directory to be rejected"); + } catch (TronError e) { + Assert.assertEquals("IPC socket root directory does not exist or is not a directory", + e.getMessage()); + } + } + + @Test + public void testRecreateSocketDirectoryRejectsRegularFile() throws Exception { + Path outputDirectory = Files.createTempDirectory("ipc-regular-file-test-"); + Path socketDirectory = outputDirectory.resolve("ipc"); + Files.createFile(socketDirectory); + try { + socketFiles.recreateSocketDirectory(socketDirectory); + Assert.fail("Expected a regular file at the reserved directory path to be preserved"); + } catch (TronError e) { + Assert.assertEquals("Refusing to replace a non-directory IPC path", e.getMessage()); + Assert.assertTrue(Files.isRegularFile(socketDirectory, LinkOption.NOFOLLOW_LINKS)); + } finally { + Files.deleteIfExists(socketDirectory); + Files.deleteIfExists(outputDirectory); + } + } + + @Test + public void testRecreateSocketDirectoryRejectsSymbolicLink() throws Exception { + assumePosixFileSystem(); + Path outputDirectory = Files.createTempDirectory("ipc-symbolic-link-test-"); + Path targetFile = outputDirectory.resolve("target"); + Path socketDirectory = outputDirectory.resolve("ipc"); + Files.createFile(targetFile); + Files.createSymbolicLink(socketDirectory, targetFile.getFileName()); + try { + socketFiles.recreateSocketDirectory(socketDirectory); + Assert.fail("Expected a symbolic link to be preserved"); + } catch (TronError e) { + Assert.assertEquals("Refusing to replace a non-directory IPC path", e.getMessage()); + Assert.assertTrue(Files.isSymbolicLink(socketDirectory)); + Assert.assertTrue(Files.exists(targetFile)); + } finally { + Files.deleteIfExists(socketDirectory); + Files.deleteIfExists(targetFile); + Files.deleteIfExists(outputDirectory); + } + } + + @Test + public void testRecreateSocketDirectoryRemovesStaleFilesAndUsesOwnerOnlyPermissions() + throws Exception { + assumePosixFileSystem(); + Path outputDirectory = Files.createTempDirectory("ipc-stale-directory-test-"); + Path socketDirectory = Files.createDirectory(outputDirectory.resolve("ipc")); + Files.createFile(socketDirectory.resolve("1234.sock")); + try { + socketFiles.recreateSocketDirectory(socketDirectory); + + Assert.assertTrue(Files.isDirectory(socketDirectory)); + Assert.assertEquals( + EnumSet.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE, + PosixFilePermission.OWNER_EXECUTE), + Files.getPosixFilePermissions(socketDirectory)); + Assert.assertFalse(Files.exists(socketDirectory.resolve("1234.sock"))); + } finally { + Files.deleteIfExists(socketDirectory); + Files.deleteIfExists(outputDirectory); + } + } + + @Test + public void testValidateSocketRootDirectorySupportsPosixPermissions() throws Exception { + assumePosixFileSystem(); + Path outputDirectory = Files.createTempDirectory("ipc-posix-output-test-"); + try { + socketFiles.validateSocketRootDirectory(outputDirectory); + } finally { + Files.deleteIfExists(outputDirectory); + } + } + + @Test + public void testDeleteSocketFilesPreservesRootDirectory() throws Exception { + Path root = Files.createTempDirectory("ipc-delete-test-"); + Path directory = Files.createDirectory(root.resolve("ipc")); + Path socketFile = Files.createFile(directory.resolve("1234.sock")); + try { + socketFiles.deleteSocketFile(socketFile); + Assert.assertFalse(Files.exists(socketFile)); + Assert.assertTrue(Files.isDirectory(directory)); + socketFiles.deleteSocketDirectory(socketFile); + Assert.assertFalse(Files.exists(directory)); + Assert.assertTrue(Files.isDirectory(root)); + + // Cleanup also runs before bind or after an earlier cleanup already removed the files. + socketFiles.deleteSocketFile(socketFile); + socketFiles.deleteSocketDirectory(socketFile); + socketFiles.deleteSocketFile(null); + socketFiles.deleteSocketDirectory(null); + Assert.assertTrue(Files.isDirectory(root)); + } finally { + Files.deleteIfExists(socketFile); + Files.deleteIfExists(directory); + Files.deleteIfExists(root); + } + } + + private void assumePosixFileSystem() { + Assume.assumeTrue("IPC requires POSIX file permissions", + FileSystems.getDefault().supportedFileAttributeViews().contains("posix")); + } +} diff --git a/framework/src/test/java/org/tron/program/FullNodeTest.java b/framework/src/test/java/org/tron/program/FullNodeTest.java new file mode 100644 index 00000000000..17debf5e964 --- /dev/null +++ b/framework/src/test/java/org/tron/program/FullNodeTest.java @@ -0,0 +1,190 @@ +package org.tron.program; + +import java.io.BufferedReader; +import java.io.File; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import org.junit.Assert; +import org.junit.Assume; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.mockito.MockedConstruction; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.newsclub.net.unix.AFUNIXServerSocket; +import org.newsclub.net.unix.AFUNIXSocket; +import org.newsclub.net.unix.AFUNIXSocketAddress; +import org.tron.common.arch.Arch; +import org.tron.common.exit.ExitManager; +import org.tron.common.log.LogService; +import org.tron.common.parameter.CommonParameter; +import org.tron.core.config.args.Args; +import org.tron.core.config.args.CommandLineArguments; +import org.tron.core.services.admin.ipc.client.IpcClient; +import org.tron.core.services.jsonrpc.JsonRpcMapper; + +public class FullNodeTest { + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void testAttachSkipsNodeInitialization() { + try (MockedStatic exitManager = Mockito.mockStatic(ExitManager.class); + MockedStatic arch = Mockito.mockStatic(Arch.class); + MockedStatic args = Mockito.mockStatic(Args.class); + MockedStatic logService = Mockito.mockStatic(LogService.class); + MockedConstruction ipcClients = Mockito.mockConstruction(IpcClient.class, + (client, context) -> Assert.assertEquals(Arrays.asList("/tmp/java-tron.sock"), + context.arguments()))) { + FullNode.main(new String[] {"--attach", "/tmp/java-tron.sock", "--exec", "help"}); + + Assert.assertEquals(1, ipcClients.constructed().size()); + Mockito.verify(ipcClients.constructed().get(0)).start("help"); + exitManager.verifyNoInteractions(); + arch.verifyNoInteractions(); + args.verifyNoInteractions(); + logService.verifyNoInteractions(); + } + } + + @Test + public void testNodeModeRetainsInitialization() { + CommonParameter parameter = new CommonParameter(); + parameter.keystoreFactory = true; + parameter.logbackPath = "node-logback.xml"; + try (MockedStatic exitManager = Mockito.mockStatic(ExitManager.class); + MockedStatic arch = Mockito.mockStatic(Arch.class); + MockedStatic args = Mockito.mockStatic(Args.class); + MockedStatic parameters = Mockito.mockStatic(CommonParameter.class); + MockedStatic logService = Mockito.mockStatic(LogService.class); + MockedStatic keystore = Mockito.mockStatic(KeystoreFactory.class); + MockedConstruction ipcClients = Mockito.mockConstruction(IpcClient.class)) { + parameters.when(CommonParameter::getInstance).thenReturn(parameter); + + FullNode.main(new String[] {"--keystore-factory"}); + + exitManager.verify(ExitManager::initExceptionHandler); + arch.verify(Arch::throwIfUnsupportedJavaVersion); + args.verify(() -> Args.setParam(Mockito.argThat((CommandLineArguments parsed) -> + parsed.getParameters().keystoreFactory), Mockito.eq("config.conf"))); + logService.verify(() -> LogService.load("node-logback.xml")); + keystore.verify(KeystoreFactory::start); + Assert.assertTrue(ipcClients.constructed().isEmpty()); + } + } + + @Test(timeout = 30_000) + public void testMissingAttachSocketDoesNotCreateNodeLogs() throws Exception { + runClientWithoutNodeLogs(1, "IPC socket file does not exist: missing.sock", "", + "--attach", "missing.sock", "--exec", "help"); + } + + @Test(timeout = 60_000) + public void testInvalidAttachOptionsDoNotCreateNodeLogs() throws Exception { + runClientWithoutNodeLogs(1, "--attach requires a non-empty ", "", + "--attach", ""); + runClientWithoutNodeLogs(1, "--attach requires a non-empty ", "", + "--attach", " "); + runClientWithoutNodeLogs(1, "--attach cannot be combined with: --config", "", + "--attach", "missing.sock", "--config", "config.conf"); + runClientWithoutNodeLogs(1, "--exec requires --attach ", "", + "--exec", "help"); + } + + @Test(timeout = 30_000) + public void testExecResponseDoesNotCreateNodeLogs() throws Exception { + Assume.assumeTrue(FileSystems.getDefault().supportedFileAttributeViews().contains("posix")); + Assume.assumeTrue(AFUNIXSocket.isSupported()); + // Keep the socket path below the macOS sun_path limit, even with a long JVM temp directory. + Path socketDirectory = Files.createTempDirectory(Paths.get("/tmp"), "attach-test-"); + Path socketFile = socketDirectory.resolve("node.sock"); + ExecutorService executor = Executors.newSingleThreadExecutor(); + try (AFUNIXServerSocket server = AFUNIXServerSocket.bindOn( + AFUNIXSocketAddress.of(socketFile.toFile()))) { + server.setSoTimeout(10_000); + Future request = executor.submit(() -> { + try (AFUNIXSocket connection = server.accept(); + BufferedReader reader = new BufferedReader(new InputStreamReader( + connection.getInputStream(), StandardCharsets.UTF_8))) { + connection.setSoTimeout(10_000); + String line = reader.readLine(); + connection.getOutputStream().write( + "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":\"one:two\"}\n" + .getBytes(StandardCharsets.UTF_8)); + connection.getOutputStream().flush(); + return line; + } + }); + + runClientWithoutNodeLogs(0, "one:two", "", "--attach", socketFile.toString(), + "--exec", "admin_example one two"); + Assert.assertEquals("admin_example", + JsonRpcMapper.create().readTree(request.get(5, TimeUnit.SECONDS)).get("method").asText()); + } finally { + executor.shutdownNow(); + Files.deleteIfExists(socketFile); + Files.deleteIfExists(socketDirectory); + } + } + + @Test(timeout = 30_000) + public void testInteractiveExitDoesNotCreateNodeLogs() throws Exception { + Assume.assumeTrue(FileSystems.getDefault().supportedFileAttributeViews().contains("posix")); + Assume.assumeTrue(AFUNIXSocket.isSupported()); + Path socketDirectory = Files.createTempDirectory(Paths.get("/tmp"), "attach-test-"); + Path socketFile = socketDirectory.resolve("node.sock"); + try (AFUNIXServerSocket server = AFUNIXServerSocket.bindOn( + AFUNIXSocketAddress.of(socketFile.toFile()))) { + runClientWithoutNodeLogs(0, "Welcome to the java-tron admin console.", "exit\n", + "--attach", socketFile.toString()); + } finally { + Files.deleteIfExists(socketFile); + Files.deleteIfExists(socketDirectory); + } + } + + private void runClientWithoutNodeLogs(int expectedExitCode, String expectedOutput, + String input, String... args) throws Exception { + String classpath = System.getProperty("fullNode.runtimeClasspath"); + Assert.assertNotNull("The child JVM must use the production runtime classpath", classpath); + Path directory = temporaryFolder.newFolder().toPath(); + File outputFile = directory.resolve("console.txt").toFile(); + List command = new ArrayList<>(); + command.add(Paths.get(System.getProperty("java.home"), "bin", "java").toString()); + command.add("-cp"); + command.add(classpath); + command.add(FullNode.class.getName()); + command.addAll(Arrays.asList(args)); + Process process = new ProcessBuilder(command).directory(directory.toFile()) + .redirectErrorStream(true).redirectOutput(outputFile).start(); + try { + process.getOutputStream().write(input.getBytes(StandardCharsets.UTF_8)); + process.getOutputStream().close(); + Assert.assertTrue("Attach subprocess did not exit", process.waitFor(20, TimeUnit.SECONDS)); + String output = new String(Files.readAllBytes(outputFile.toPath()), StandardCharsets.UTF_8); + Assert.assertEquals(output, expectedExitCode, process.exitValue()); + Assert.assertTrue(output, output.contains(expectedOutput)); + Assert.assertFalse("Attach created node log files: " + output, + Files.exists(directory.resolve("logs"))); + Assert.assertFalse("Attach initialized the node data directory", + Files.exists(directory.resolve("output-directory"))); + } finally { + process.destroyForcibly(); + Assert.assertTrue("Attach subprocess did not terminate", + process.waitFor(5, TimeUnit.SECONDS)); + } + } +} diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 2e30496116f..d975dfbfb94 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -873,6 +873,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -2380,6 +2416,14 @@ + + + + + + + + From 0e624eaf0ab80492a9db65e72c70eb2a4135e562 Mon Sep 17 00:00:00 2001 From: 317787106 <317787106@qq.com> Date: Thu, 24 Sep 2026 20:17:26 +0800 Subject: [PATCH 02/19] AdminRpcServlet don't extend RateLimiterServlet --- .../services/admin/http/AdminRpcServlet.java | 25 ++++++-- .../admin/http/AdminRpcServletTest.java | 61 +++++++++++++++---- 2 files changed, 70 insertions(+), 16 deletions(-) diff --git a/framework/src/main/java/org/tron/core/services/admin/http/AdminRpcServlet.java b/framework/src/main/java/org/tron/core/services/admin/http/AdminRpcServlet.java index f5bdd760659..52ca3ac0634 100644 --- a/framework/src/main/java/org/tron/core/services/admin/http/AdminRpcServlet.java +++ b/framework/src/main/java/org/tron/core/services/admin/http/AdminRpcServlet.java @@ -4,18 +4,21 @@ import com.googlecode.jsonrpc4j.JsonRpcInterceptor; import com.googlecode.jsonrpc4j.JsonRpcServer; import com.googlecode.jsonrpc4j.ProxyUtil; +import io.prometheus.client.Histogram; import java.io.IOException; import java.util.Collections; import javax.servlet.ServletConfig; import javax.servlet.ServletException; +import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import org.tron.common.parameter.CommonParameter; +import org.tron.common.prometheus.MetricKeys; +import org.tron.common.prometheus.Metrics; import org.tron.core.services.admin.AdminJsonRpc; -import org.tron.core.services.http.RateLimiterServlet; import org.tron.core.services.jsonrpc.JsonRpcErrorResolver; import org.tron.core.services.jsonrpc.JsonRpcMapper; import org.tron.core.services.jsonrpc.JsonRpcMediaType; @@ -24,14 +27,15 @@ * Serves the {@link AdminJsonRpc} API at {@code POST /admin} through jsonrpc4j. * *

This endpoint is intended for trusted node operators. Deployments must restrict access to - * loopback or a controlled management network. It intentionally does not apply the public JSON-RPC - * batch-size or response-size limits. HTTP request-size limits are enforced by + * loopback or a controlled management network. This low-frequency management endpoint intentionally + * bypasses public API rate limiting and JSON-RPC batch-size and response-size limits. + * HTTP request-size limits are enforced by * {@link org.tron.common.application.HttpService}; JSON parser limits come from * {@link JsonRpcMapper}. */ @Component @Slf4j(topic = "API") -public class AdminRpcServlet extends RateLimiterServlet { +public class AdminRpcServlet extends HttpServlet { private static final long serialVersionUID = 0L; @@ -85,6 +89,19 @@ public Integer getJsonRpcCode(int httpStatusCode) { CommonParameter.getInstance().getAdminHttpVirtualHosts()); } + @Override + protected void service(HttpServletRequest req, HttpServletResponse resp) + throws ServletException, IOException { + resp.setContentType("application/json; charset=utf-8"); + Histogram.Timer requestTimer = Metrics.histogramStartTimer( + MetricKeys.Histogram.HTTP_SERVICE_LATENCY, req.getContextPath() + req.getServletPath()); + try { + super.service(req, resp); + } finally { + Metrics.histogramObserve(requestTimer); + } + } + /** * Applies HTTP-specific checks before handing request parsing and dispatch to jsonrpc4j. */ diff --git a/framework/src/test/java/org/tron/core/services/admin/http/AdminRpcServletTest.java b/framework/src/test/java/org/tron/core/services/admin/http/AdminRpcServletTest.java index 87fa02dab04..b8f5918f815 100644 --- a/framework/src/test/java/org/tron/core/services/admin/http/AdminRpcServletTest.java +++ b/framework/src/test/java/org/tron/core/services/admin/http/AdminRpcServletTest.java @@ -1,36 +1,77 @@ package org.tron.core.services.admin.http; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import com.fasterxml.jackson.databind.JsonNode; import com.googlecode.jsonrpc4j.JsonRpcInterceptor; -import java.io.IOException; import java.lang.reflect.Field; import java.nio.charset.StandardCharsets; import java.util.Arrays; -import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import org.junit.Before; import org.junit.Test; +import org.mockito.MockedStatic; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.mock.web.MockServletConfig; +import org.tron.common.parameter.CommonParameter; import org.tron.core.Constant; import org.tron.core.services.admin.AdminJsonRpc; +import org.tron.core.services.jsonrpc.JsonRpcMapper; +import org.tron.core.services.jsonrpc.JsonRpcMediaType; +import org.tron.core.services.ratelimiter.GlobalRateLimiter; public class AdminRpcServletTest { - private TestableServlet servlet; + private AdminRpcServlet servlet; + private AdminJsonRpc adminJsonRpc; @Before public void setUp() throws Exception { - servlet = new TestableServlet(); - setField("adminJsonRpc", mock(AdminJsonRpc.class)); + servlet = new AdminRpcServlet(); + adminJsonRpc = mock(AdminJsonRpc.class); + setField("adminJsonRpc", adminJsonRpc); setField("interceptor", mock(JsonRpcInterceptor.class)); servlet.init(new MockServletConfig()); setVirtualHosts("localhost"); } + @Test + public void publicRateLimitDoesNotBlockAdminRequests() throws Exception { + when(adminJsonRpc.adminExample("left", "right")).thenReturn("left:right"); + String body = "{\"jsonrpc\":\"2.0\",\"method\":\"admin_example\"," + + "\"params\":[\"left\",\"right\"],\"id\":7}"; + CommonParameter parameter = CommonParameter.getInstance(); + boolean previousNonBlocking = parameter.isRateLimiterApiNonBlocking(); + try (MockedStatic global = mockStatic(GlobalRateLimiter.class)) { + global.when(() -> GlobalRateLimiter.acquirePermit(any())).thenReturn(false); + for (boolean nonBlocking : new boolean[]{false, true}) { + parameter.setRateLimiterApiNonBlocking(nonBlocking); + + MockHttpServletResponse response = doPost(body, "application/json", "localhost:8575"); + + assertEquals(HttpServletResponse.SC_OK, response.getStatus()); + assertTrue(JsonRpcMediaType.isSupported(response.getContentType())); + assertEquals("UTF-8", response.getCharacterEncoding()); + JsonNode result = JsonRpcMapper.create().readTree(response.getContentAsString()); + assertEquals("2.0", result.get("jsonrpc").asText()); + assertEquals(7, result.get("id").asInt()); + assertEquals("left:right", result.get("result").asText()); + } + global.verifyNoInteractions(); + verify(adminJsonRpc, times(2)).adminExample("left", "right"); + } finally { + parameter.setRateLimiterApiNonBlocking(previousNonBlocking); + } + } + @Test public void excessivelyNestedRequestIsRejected() throws Exception { StringBuilder request = new StringBuilder(); @@ -151,13 +192,15 @@ private MockHttpServletResponse doPost(String body, String contentType) throws E private MockHttpServletResponse doPost(String body, String contentType, String host) throws Exception { MockHttpServletRequest request = new MockHttpServletRequest("POST", "/admin"); + request.setServletPath("/admin"); + request.setRemoteAddr("127.0.0.1"); request.setContentType(contentType); request.setContent(body.getBytes(StandardCharsets.UTF_8)); if (host != null) { request.addHeader("Host", host); } MockHttpServletResponse response = new MockHttpServletResponse(); - servlet.callDoPost(request, response); + servlet.service(request, response); return response; } @@ -165,10 +208,4 @@ private void setVirtualHosts(String... hosts) throws Exception { setField("virtualHostValidator", new VirtualHostValidator(Arrays.asList(hosts))); } - private static class TestableServlet extends AdminRpcServlet { - - void callDoPost(HttpServletRequest request, HttpServletResponse response) throws IOException { - doPost(request, response); - } - } } From 6152d3e7a99371b57cd7e151ff91dad4fdf5e6d0 Mon Sep 17 00:00:00 2001 From: 317787106 <317787106@qq.com> Date: Thu, 24 Sep 2026 20:43:37 +0800 Subject: [PATCH 03/19] remove isAttachMode in Args --- .../java/org/tron/core/config/args/Args.java | 18 +---- .../org/tron/core/config/args/ArgsTest.java | 77 ------------------- 2 files changed, 1 insertion(+), 94 deletions(-) diff --git a/framework/src/main/java/org/tron/core/config/args/Args.java b/framework/src/main/java/org/tron/core/config/args/Args.java index 8624ba6393b..42cc96ada84 100644 --- a/framework/src/main/java/org/tron/core/config/args/Args.java +++ b/framework/src/main/java/org/tron/core/config/args/Args.java @@ -104,12 +104,6 @@ public class Args extends CommonParameter { @Getter private static String configFilePath = ""; - @Getter - private static String ipcSocketFile; - - @Getter - private static String ipcExecCommand; - // Singleton config beans — populated at startup, read-only after init. // New code can read directly from these beans instead of CommonParameter. @Getter @@ -156,7 +150,7 @@ public static void setParam(final String[] args, final String confFileName) { } /** - * Reuses the options parsed by FullNode before it chooses client or node startup. + * Initializes node configuration from parsed options. FullNode handles attach mode separately. */ public static void setParam(CommandLineArguments arguments, final String confFileName) { CLIParameter cmd = arguments.getParameters(); @@ -170,14 +164,6 @@ public static void setParam(CommandLineArguments arguments, final String confFil exit(0); } List assignedParameters = arguments.getAssignedParameters(); - if (arguments.isAttachMode()) { - ipcSocketFile = cmd.ipcSocketFile; - ipcExecCommand = cmd.ipcExecCommand; - if (StringUtils.isNotEmpty(cmd.logbackPath)) { - PARAMETER.logbackPath = cmd.logbackPath; - } - return; - } // Resolve config file path configFilePath = StringUtils.isNoneBlank(cmd.shellConfFileName) @@ -974,8 +960,6 @@ public static void clearParam() { rateLimiterConfig = null; metricsConfig = null; eventConfig = null; - ipcSocketFile = null; - ipcExecCommand = null; } // getProposalExpirationTime removed — logic moved to BlockConfig.fromConfig() diff --git a/framework/src/test/java/org/tron/core/config/args/ArgsTest.java b/framework/src/test/java/org/tron/core/config/args/ArgsTest.java index 919e21e6fa0..f123b5c853d 100644 --- a/framework/src/test/java/org/tron/core/config/args/ArgsTest.java +++ b/framework/src/test/java/org/tron/core/config/args/ArgsTest.java @@ -20,8 +20,6 @@ import com.typesafe.config.ConfigFactory; import io.grpc.internal.GrpcUtil; import io.grpc.netty.NettyServerBuilder; -import java.io.ByteArrayOutputStream; -import java.io.PrintStream; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.net.InetAddress; @@ -50,81 +48,6 @@ public class ArgsTest { @Rule public ExpectedException thrown = ExpectedException.none(); - @Test - public void testAttachWithExecParameters() { - Args.clearParam(); - try { - Args.setParam(new String[] { - "--attach", "/tmp/java-tron.sock", - "--exec", "admin_example one two", - "--log-config", "attach-logback.xml" - }, TestConstants.TEST_CONF); - - Assert.assertEquals("/tmp/java-tron.sock", Args.getIpcSocketFile()); - Assert.assertEquals("admin_example one two", Args.getIpcExecCommand()); - Assert.assertEquals("attach-logback.xml", Args.getInstance().getLogbackPath()); - Assert.assertNull(Args.getNodeConfig()); - Assert.assertNull(Args.getLocalWitnesses()); - } finally { - Args.clearParam(); - } - Assert.assertNull(Args.getIpcSocketFile()); - Assert.assertNull(Args.getIpcExecCommand()); - } - - @Test - public void testAttachRejectsNodeConfigOption() { - Args.clearParam(); - try { - assertAttachParameterError(new String[] { - "--attach", "/tmp/java-tron.sock", - "--config", "config.conf" - }, "Error: --attach cannot be combined with: --config"); - } finally { - Args.clearParam(); - } - } - - @Test - public void testAttachRejectsEmptySocketPath() { - Args.clearParam(); - try { - assertAttachParameterError(new String[] {"--attach", ""}, - "Error: --attach requires a non-empty "); - } finally { - Args.clearParam(); - } - } - - @Test - public void testExecRequiresAttach() { - Args.clearParam(); - try { - assertAttachParameterError(new String[] {"--exec", "admin_example"}, - "Error: --exec requires --attach "); - } finally { - Args.clearParam(); - } - } - - private void assertAttachParameterError(String[] args, String expectedMessage) { - PrintStream originalErr = System.err; - ByteArrayOutputStream errorOutput = new ByteArrayOutputStream(); - PrintStream capturedErr = new PrintStream(errorOutput); - try { - System.setErr(capturedErr); - Args.setParam(args, TestConstants.TEST_CONF); - Assert.fail("Expected invalid attach parameters to fail"); - } catch (TronError e) { - Assert.assertEquals(TronError.ErrCode.PARAMETER_INIT, e.getErrCode()); - Assert.assertEquals(expectedMessage, e.getMessage()); - Assert.assertEquals(expectedMessage + System.lineSeparator(), errorOutput.toString()); - } finally { - System.setErr(originalErr); - capturedErr.close(); - } - } - @Test public void get() { Args.setParam(new String[] {"--keystore-factory"}, TestConstants.TEST_CONF); From 280653e0c5d48e92701eb356a2ce7ef9c14a12df Mon Sep 17 00:00:00 2001 From: 317787106 <317787106@qq.com> Date: Thu, 24 Sep 2026 23:01:59 +0800 Subject: [PATCH 04/19] use node.jsonrpc.maxMessageSize for IpcService request --- common/src/main/resources/reference.conf | 10 ++- .../admin/ipc/server/IpcRequestHandler.java | 4 +- .../services/admin/ipc/server/IpcService.java | 3 +- framework/src/main/resources/config.conf | 10 +++ .../admin/ipc/server/IpcServiceTest.java | 73 ++++++++++++------- 5 files changed, 65 insertions(+), 35 deletions(-) diff --git a/common/src/main/resources/reference.conf b/common/src/main/resources/reference.conf index 91f1e9b6e86..96af22483f3 100644 --- a/common/src/main/resources/reference.conf +++ b/common/src/main/resources/reference.conf @@ -438,7 +438,8 @@ node { maxResponseSize = 26214400 # Allowed maximum number for newFilter, <=0 means no limit maxLogFilterNum = 20000 - # Maximum JSON-RPC request body size in bytes (default 4194304, ~4MB). Independent from rpc.maxMessageSize. + # Maximum public JSON-RPC HTTP and Admin IPC request size in bytes (default 4194304, ~4MB). + # Independent from rpc.maxMessageSize. Setting to 0 rejects all non-empty requests. maxMessageSize = 4194304 } @@ -448,10 +449,11 @@ node { ipc { # Whether to enable the local Unix-domain socket admin API. Default: false. enable = false - # Parent directory for the private ipc directory. It must be an absolute path - # when set. Empty means output-directory. The node fails to start if the resulting socket - # path exceeds the portable Unix-domain socket path limit. + # Parent directory for the private ipc directory. When set, it must be an + # existing absolute directory. Empty means output-directory. + # The full /ipc/.sock path must not exceed 100 encoded bytes. socketDirectory = "" + # Request size is controlled by node.jsonrpc.maxMessageSize, shared with JSON-RPC HTTP. } # Administrative JSON-RPC HTTP API. diff --git a/framework/src/main/java/org/tron/core/services/admin/ipc/server/IpcRequestHandler.java b/framework/src/main/java/org/tron/core/services/admin/ipc/server/IpcRequestHandler.java index ec6d946805c..9e8282f835b 100644 --- a/framework/src/main/java/org/tron/core/services/admin/ipc/server/IpcRequestHandler.java +++ b/framework/src/main/java/org/tron/core/services/admin/ipc/server/IpcRequestHandler.java @@ -29,9 +29,9 @@ final class IpcRequestHandler { private final JsonRpcServer jsonRpcServer; @Getter(AccessLevel.PACKAGE) - private final int maxRequestSize; + private final long maxRequestSize; - IpcRequestHandler(AdminJsonRpc adminJsonRpc, int maxRequestSize) { + IpcRequestHandler(AdminJsonRpc adminJsonRpc, long maxRequestSize) { this.maxRequestSize = maxRequestSize; jsonRpcServer = new JsonRpcServer(OBJECT_MAPPER, adminJsonRpc, AdminJsonRpc.class); jsonRpcServer.setErrorResolver(JsonRpcErrorResolver.INSTANCE); diff --git a/framework/src/main/java/org/tron/core/services/admin/ipc/server/IpcService.java b/framework/src/main/java/org/tron/core/services/admin/ipc/server/IpcService.java index 5ec86d30230..64371261cd4 100644 --- a/framework/src/main/java/org/tron/core/services/admin/ipc/server/IpcService.java +++ b/framework/src/main/java/org/tron/core/services/admin/ipc/server/IpcService.java @@ -62,7 +62,8 @@ public class IpcService extends AbstractService { @Autowired public IpcService(AdminJsonRpc adminJsonRpc) { enable = isFullNode() && Args.getInstance().isIpcEnable(); - requestHandler = new IpcRequestHandler(adminJsonRpc, Args.getInstance().maxMessageSize); + requestHandler = new IpcRequestHandler(adminJsonRpc, + Args.getInstance().getJsonRpcMaxMessageSize()); } @Override diff --git a/framework/src/main/resources/config.conf b/framework/src/main/resources/config.conf index 1176dd46311..9c64096d39d 100644 --- a/framework/src/main/resources/config.conf +++ b/framework/src/main/resources/config.conf @@ -165,6 +165,16 @@ node { maxMessageSize = 4194304 } + # Local administrative API over a Unix-domain socket. Disabled by default. + admin { + ipc { + enable = false + # Parent directory for the private ipc directory. When set, it must be an + # existing absolute directory. Empty means output-directory. + socketDirectory = "" + } + } + disabledApi = [ # "getaccount", # "getnowblock2" diff --git a/framework/src/test/java/org/tron/core/services/admin/ipc/server/IpcServiceTest.java b/framework/src/test/java/org/tron/core/services/admin/ipc/server/IpcServiceTest.java index 3d2c4d228c3..c4ed3082bd1 100644 --- a/framework/src/test/java/org/tron/core/services/admin/ipc/server/IpcServiceTest.java +++ b/framework/src/test/java/org/tron/core/services/admin/ipc/server/IpcServiceTest.java @@ -39,16 +39,20 @@ public class IpcServiceTest { private int originalMaxMessageSize; + private long originalJsonRpcMaxMessageSize; @Before public void setUp() { originalMaxMessageSize = Args.getInstance().maxMessageSize; + originalJsonRpcMaxMessageSize = Args.getInstance().jsonRpcMaxMessageSize; Args.getInstance().maxMessageSize = 4 * 1024 * 1024; + Args.getInstance().jsonRpcMaxMessageSize = 4 * 1024 * 1024; } @After public void tearDown() { Args.getInstance().maxMessageSize = originalMaxMessageSize; + Args.getInstance().jsonRpcMaxMessageSize = originalJsonRpcMaxMessageSize; } @Test @@ -57,37 +61,50 @@ public void testServiceIsNotRunningBeforeStart() throws Exception { } @Test(timeout = 10_000) - public void testRequestSizePreservesConfiguredZero() throws Exception { + public void testRequestSizeUsesJsonRpcConfiguration() throws Exception { assumePosixFileSystem(); - Args.getInstance().maxMessageSize = 0; CommonParameter parameter = Args.getInstance(); - String originalOutputDirectory = parameter.outputDirectory; - String originalSocketDirectory = parameter.ipcSocketDirectory; - Path outputDirectory = Files.createTempDirectory(Paths.get("/tmp"), "ipc-zero-"); - AdminJsonRpc adminJsonRpc = Mockito.mock(AdminJsonRpc.class); - IpcService service = new IpcService(adminJsonRpc); - boolean started = false; - Path socketFile = null; - try { - parameter.outputDirectory = outputDirectory.toString(); - parameter.ipcSocketDirectory = ""; - service.innerStart(); - started = true; - socketFile = resolveSocketFilePath(parameter, getPid(service)); - - try (AFUNIXSocket client = AFUNIXSocket.newInstance()) { - client.connect(AFUNIXSocketAddress.of(socketFile.toFile())); - client.setSoTimeout(2_000); - client.getOutputStream().write('{'); - client.getOutputStream().flush(); - Assert.assertEquals("A zero limit must close the connection on its first byte", - -1, client.getInputStream().read()); - Mockito.verifyNoInteractions(adminJsonRpc); + for (long limit : new long[] {0, (long) Integer.MAX_VALUE + 1}) { + parameter.jsonRpcMaxMessageSize = limit; + parameter.maxMessageSize = limit == 0 ? 4 * 1024 * 1024 : 0; + String originalOutputDirectory = parameter.outputDirectory; + String originalSocketDirectory = parameter.ipcSocketDirectory; + Path outputDirectory = Files.createTempDirectory(Paths.get("/tmp"), "ipc-limit-"); + AdminJsonRpc adminJsonRpc = Mockito.mock(AdminJsonRpc.class); + IpcService service = new IpcService(adminJsonRpc); + boolean started = false; + Path socketFile = null; + try { + parameter.outputDirectory = outputDirectory.toString(); + parameter.ipcSocketDirectory = ""; + service.innerStart(); + started = true; + socketFile = resolveSocketFilePath(parameter, getPid(service)); + + try (AFUNIXSocket client = AFUNIXSocket.newInstance()) { + client.connect(AFUNIXSocketAddress.of(socketFile.toFile())); + client.setSoTimeout(2_000); + if (limit == 0) { + client.getOutputStream().write('{'); + client.getOutputStream().flush(); + Assert.assertEquals("A zero limit must close the connection on its first byte", + -1, client.getInputStream().read()); + Mockito.verifyNoInteractions(adminJsonRpc); + } else { + Mockito.when(adminJsonRpc.adminExample("a", "b")).thenReturn("a:b"); + BufferedWriter writer = new BufferedWriter( + new OutputStreamWriter(client.getOutputStream(), StandardCharsets.UTF_8)); + BufferedReader reader = new BufferedReader( + new InputStreamReader(client.getInputStream(), StandardCharsets.UTF_8)); + assertSuccessfulResponse(sendRequest(writer, reader, 1), 1); + Mockito.verify(adminJsonRpc).adminExample("a", "b"); + } + } + } finally { + parameter.ipcSocketDirectory = originalSocketDirectory; + cleanupIpcService(service, started, parameter, originalOutputDirectory, socketFile, + outputDirectory); } - } finally { - parameter.ipcSocketDirectory = originalSocketDirectory; - cleanupIpcService(service, started, parameter, originalOutputDirectory, socketFile, - outputDirectory); } } From 468d9abf1f23f548b0ff607341f0bd7f8812a415 Mon Sep 17 00:00:00 2001 From: 317787106 <317787106@qq.com> Date: Fri, 25 Sep 2026 21:18:30 +0800 Subject: [PATCH 05/19] require ipc not exist in socketDirectory --- common/src/main/resources/reference.conf | 6 +- .../services/admin/ipc/server/IpcService.java | 35 +++- .../admin/ipc/server/IpcSocketFiles.java | 40 ++-- framework/src/main/resources/config.conf | 5 +- .../admin/ipc/server/IpcServiceTest.java | 198 ++++++++++++++++++ .../admin/ipc/server/IpcSocketFilesTest.java | 75 +++++-- 6 files changed, 297 insertions(+), 62 deletions(-) diff --git a/common/src/main/resources/reference.conf b/common/src/main/resources/reference.conf index 96af22483f3..9ea023cb923 100644 --- a/common/src/main/resources/reference.conf +++ b/common/src/main/resources/reference.conf @@ -449,9 +449,9 @@ node { ipc { # Whether to enable the local Unix-domain socket admin API. Default: false. enable = false - # Parent directory for the private ipc directory. When set, it must be an - # existing absolute directory. Empty means output-directory. - # The full /ipc/.sock path must not exceed 100 encoded bytes. + # Existing absolute parent directory; empty uses output-directory. + # The ipc subdirectory must not exist at startup. + # Full socket path limit: 100 encoded bytes. socketDirectory = "" # Request size is controlled by node.jsonrpc.maxMessageSize, shared with JSON-RPC HTTP. } diff --git a/framework/src/main/java/org/tron/core/services/admin/ipc/server/IpcService.java b/framework/src/main/java/org/tron/core/services/admin/ipc/server/IpcService.java index 64371261cd4..aa3973b91d1 100644 --- a/framework/src/main/java/org/tron/core/services/admin/ipc/server/IpcService.java +++ b/framework/src/main/java/org/tron/core/services/admin/ipc/server/IpcService.java @@ -55,6 +55,8 @@ public class IpcService extends AbstractService { private final Set activeClientSockets = ConcurrentHashMap.newKeySet(); private AFUNIXServerSocket unixServerSocket; + // Record paths only after this instance successfully creates the corresponding resource. + private Path socketDirectoryPath; private Path socketFilePath; private volatile boolean isRunning; @@ -81,15 +83,17 @@ public CompletableFuture start() { @Override public void innerStart() throws Exception { CommonParameter parameter = Args.getInstance(); - socketFilePath = socketFiles.resolveSocketFilePath(parameter.getOutputDirectory(), + Path resolvedSocketFile = socketFiles.resolveSocketFilePath(parameter.getOutputDirectory(), parameter.getIpcSocketDirectory(), getPid()); - Path socketDirectory = socketFilePath.getParent(); + Path socketDirectory = resolvedSocketFile.getParent(); socketFiles.validateSocketRootDirectory(socketDirectory.getParent()); try { - socketFiles.recreateSocketDirectory(socketDirectory); - File socketFile = socketFilePath.toFile(); + socketFiles.createSocketDirectory(socketDirectory); + socketDirectoryPath = socketDirectory; + File socketFile = resolvedSocketFile.toFile(); AFUNIXSocketAddress address = AFUNIXSocketAddress.of(socketFile); unixServerSocket = AFUNIXServerSocket.bindOn(address); + socketFilePath = resolvedSocketFile; socketFiles.setOwnerOnlyPermissions(socketFilePath); unixServerSocket.setShutdownOnClose(true); @@ -192,8 +196,7 @@ public void innerStop() throws Exception { this::shutdownActiveClients, this::shutdownExecutors, activeClientSockets::clear, - () -> socketFiles.deleteSocketFile(socketFilePath), - () -> socketFiles.deleteSocketDirectory(socketFilePath)); + this::deleteSocketFiles); if (failure != null) { throw failure; @@ -203,7 +206,9 @@ public void innerStop() throws Exception { private void closeServerSocket() throws IOException { if (unixServerSocket != null) { - unixServerSocket.close(); + AFUNIXServerSocket serverSocket = unixServerSocket; + unixServerSocket = null; + serverSocket.close(); } } @@ -246,11 +251,19 @@ private void awaitExecutorTermination(ExecutorService executor, String name) { } private Exception rollbackStartup(Exception failure) { - if (unixServerSocket != null) { - failure = runCleanup(failure, this::closeServerSocket, - () -> socketFiles.deleteSocketFile(socketFilePath)); + return runCleanup(failure, this::closeServerSocket, this::deleteSocketFiles); + } + + private void deleteSocketFiles() throws Exception { + Exception failure = runCleanup(null, + () -> socketFiles.deleteSocketFile(socketFilePath), + () -> socketFiles.deleteSocketDirectory(socketDirectoryPath)); + // A later stop must not remove resources created by another instance at the same paths. + socketFilePath = null; + socketDirectoryPath = null; + if (failure != null) { + throw failure; } - return runCleanup(failure, () -> socketFiles.deleteSocketDirectory(socketFilePath)); } /** diff --git a/framework/src/main/java/org/tron/core/services/admin/ipc/server/IpcSocketFiles.java b/framework/src/main/java/org/tron/core/services/admin/ipc/server/IpcSocketFiles.java index e31806cda18..8659f4b570a 100644 --- a/framework/src/main/java/org/tron/core/services/admin/ipc/server/IpcSocketFiles.java +++ b/framework/src/main/java/org/tron/core/services/admin/ipc/server/IpcSocketFiles.java @@ -2,12 +2,10 @@ import java.io.IOException; import java.nio.charset.Charset; -import java.nio.file.DirectoryStream; +import java.nio.file.FileAlreadyExistsException; import java.nio.file.Files; -import java.nio.file.LinkOption; import java.nio.file.Path; import java.nio.file.Paths; -import java.nio.file.attribute.BasicFileAttributes; import java.nio.file.attribute.PosixFileAttributeView; import java.nio.file.attribute.PosixFilePermission; import java.nio.file.attribute.PosixFilePermissions; @@ -72,28 +70,16 @@ void validateSocketRootDirectory(Path socketRootDirectory) throws IOException { } } - void recreateSocketDirectory(Path socketDirectory) throws IOException { - if (Files.exists(socketDirectory, LinkOption.NOFOLLOW_LINKS)) { - BasicFileAttributes attributes = Files.readAttributes(socketDirectory, - BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); - if (attributes.isSymbolicLink() || !attributes.isDirectory()) { - throw new TronError("Refusing to replace a non-directory IPC path", - ErrCode.API_SERVER_INIT); - } - deleteDirectoryWithDirectEntries(socketDirectory); - } - Files.createDirectory(socketDirectory, PosixFilePermissions.asFileAttribute( - EnumSet.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE, - PosixFilePermission.OWNER_EXECUTE))); - } - - private void deleteDirectoryWithDirectEntries(Path directory) throws IOException { - try (DirectoryStream entries = Files.newDirectoryStream(directory)) { - for (Path entry : entries) { - Files.delete(entry); - } + void createSocketDirectory(Path socketDirectory) throws IOException { + try { + Files.createDirectory(socketDirectory, PosixFilePermissions.asFileAttribute( + EnumSet.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE, + PosixFilePermission.OWNER_EXECUTE))); + } catch (FileAlreadyExistsException e) { + throw new TronError("IPC directory already exists. Startup aborted. " + + "Confirm that no node is using it, then remove it manually and retry.", + ErrCode.API_SERVER_INIT); } - Files.delete(directory); } void setOwnerOnlyPermissions(Path socketFilePath) throws IOException { @@ -107,9 +93,9 @@ void deleteSocketFile(Path socketFilePath) throws IOException { } } - void deleteSocketDirectory(Path socketFilePath) throws IOException { - if (socketFilePath != null) { - Files.deleteIfExists(socketFilePath.getParent()); + void deleteSocketDirectory(Path socketDirectory) throws IOException { + if (socketDirectory != null) { + Files.deleteIfExists(socketDirectory); } } } diff --git a/framework/src/main/resources/config.conf b/framework/src/main/resources/config.conf index 9c64096d39d..8eef0fd7958 100644 --- a/framework/src/main/resources/config.conf +++ b/framework/src/main/resources/config.conf @@ -169,8 +169,9 @@ node { admin { ipc { enable = false - # Parent directory for the private ipc directory. When set, it must be an - # existing absolute directory. Empty means output-directory. + # Existing absolute parent directory; empty uses output-directory. + # The ipc subdirectory must not exist at startup. + # Full socket path limit: 100 encoded bytes. socketDirectory = "" } } diff --git a/framework/src/test/java/org/tron/core/services/admin/ipc/server/IpcServiceTest.java b/framework/src/test/java/org/tron/core/services/admin/ipc/server/IpcServiceTest.java index c4ed3082bd1..57b555812bf 100644 --- a/framework/src/test/java/org/tron/core/services/admin/ipc/server/IpcServiceTest.java +++ b/framework/src/test/java/org/tron/core/services/admin/ipc/server/IpcServiceTest.java @@ -5,6 +5,8 @@ import java.io.IOException; import java.io.InputStreamReader; import java.io.OutputStreamWriter; +import java.lang.management.ManagementFactory; +import java.lang.management.RuntimeMXBean; import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; @@ -12,6 +14,7 @@ import java.nio.charset.StandardCharsets; import java.nio.file.FileSystems; import java.nio.file.Files; +import java.nio.file.LinkOption; import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.attribute.PosixFilePermission; @@ -33,6 +36,7 @@ import org.newsclub.net.unix.AFUNIXSocketAddress; import org.tron.common.parameter.CommonParameter; import org.tron.core.config.args.Args; +import org.tron.core.exception.TronError; import org.tron.core.services.admin.AdminJsonRpc; import org.tron.core.services.admin.AdminJsonRpcImpl; @@ -133,6 +137,173 @@ public void testSocketFileUsesOwnerOnlyPermissions() throws Exception { } } + @Test(timeout = 10_000) + public void testRejectedStartupAndStopPreserveExistingPaths() throws Exception { + assumePosixFileSystem(); + CommonParameter parameter = Args.getInstance(); + String originalSocketDirectory = parameter.ipcSocketDirectory; + Path root = Files.createTempDirectory(Paths.get("/tmp"), "ipc-existing-"); + Path directory = root.resolve("ipc"); + Path target = Files.createDirectory(root.resolve("target")); + Path unrelated = directory.resolve("operator-note.txt"); + byte[] contents = "keep me".getBytes(StandardCharsets.UTF_8); + try { + parameter.ipcSocketDirectory = root.toString(); + for (int kind = 0; kind < 4; kind++) { + IpcService service = newIpcService(); + Path socketFile = resolveSocketFilePath(parameter, getPid(service)); + if (kind == 0 || kind == 1) { + Files.createDirectory(directory); + if (kind == 1) { + Files.write(socketFile, contents); + Files.write(unrelated, contents); + } + } else if (kind == 2) { + Files.write(directory, contents); + } else { + Files.createSymbolicLink(directory, target); + } + try { + assertStartupRejectsExistingDirectory(service); + service.innerStop(); + + Assert.assertTrue(Files.exists(directory, LinkOption.NOFOLLOW_LINKS)); + if (kind == 1) { + Assert.assertArrayEquals(contents, Files.readAllBytes(socketFile)); + Assert.assertArrayEquals(contents, Files.readAllBytes(unrelated)); + } else if (kind == 2) { + Assert.assertArrayEquals(contents, Files.readAllBytes(directory)); + } else if (kind == 3) { + Assert.assertEquals(target, Files.readSymbolicLink(directory)); + } + } finally { + service.innerStop(); + if (kind == 1) { + Files.deleteIfExists(socketFile); + Files.deleteIfExists(unrelated); + } + Files.deleteIfExists(directory); + } + } + } finally { + parameter.ipcSocketDirectory = originalSocketDirectory; + Files.deleteIfExists(target); + Files.deleteIfExists(root); + } + } + + @Test(timeout = 15_000) + public void testSecondNodeRejectionPreservesFirstNodeEndpoint() throws Exception { + assumePosixFileSystem(); + CommonParameter parameter = Args.getInstance(); + String originalSocketDirectory = parameter.ipcSocketDirectory; + Path root = Files.createTempDirectory(Paths.get("/tmp"), "ipc-two-nodes-"); + IpcService first = newIpcService(); + IpcService second = newIpcService(); + Path socketFile = null; + try { + parameter.ipcSocketDirectory = root.toString(); + first.innerStart(); + String firstPid = getPid(first); + socketFile = resolveSocketFilePath(parameter, firstPid); + assertNewConnectionSucceeds(socketFile); + + // Model another JVM's PID while exercising the real service and Unix socket. + RuntimeMXBean secondRuntime = Mockito.mock(RuntimeMXBean.class); + Mockito.when(secondRuntime.getName()).thenReturn(firstPid + "0@localhost"); + try (MockedStatic management = Mockito.mockStatic( + ManagementFactory.class, Mockito.CALLS_REAL_METHODS)) { + management.when(ManagementFactory::getRuntimeMXBean).thenReturn(secondRuntime); + Assert.assertNotEquals(firstPid, getPid(second)); + assertStartupRejectsExistingDirectory(second); + } + assertNewConnectionSucceeds(socketFile); + second.innerStop(); + assertNewConnectionSucceeds(socketFile); + first.innerStop(); + Assert.assertFalse(Files.exists(socketFile)); + Assert.assertFalse(Files.exists(socketFile.getParent())); + } finally { + parameter.ipcSocketDirectory = originalSocketDirectory; + try { + second.innerStop(); + } finally { + first.innerStop(); + if (socketFile != null) { + Files.deleteIfExists(socketFile); + Files.deleteIfExists(socketFile.getParent()); + } + Files.deleteIfExists(root); + } + } + } + + @Test(timeout = 10_000) + public void testBindFailureCleansOwnedDirectoryAndReleasesOwnership() throws Exception { + assumePosixFileSystem(); + CommonParameter parameter = Args.getInstance(); + String originalSocketDirectory = parameter.ipcSocketDirectory; + Path root = Files.createTempDirectory(Paths.get("/tmp"), "ipc-bind-fail-"); + Path directory = root.resolve("ipc"); + IpcService service = newIpcService(); + try { + parameter.ipcSocketDirectory = root.toString(); + try (MockedStatic sockets = Mockito.mockStatic( + AFUNIXServerSocket.class)) { + sockets.when(() -> AFUNIXServerSocket.bindOn(Mockito.any(AFUNIXSocketAddress.class))) + .thenThrow(new IOException("bind failed")); + try { + service.innerStart(); + Assert.fail("Expected bind failure"); + } catch (IOException e) { + Assert.assertEquals("bind failed", e.getMessage()); + } + } + Assert.assertFalse(Files.exists(directory)); + Files.createDirectory(directory); + service.innerStop(); + Assert.assertTrue("Stop must not remove a replacement directory", + Files.isDirectory(directory)); + } finally { + parameter.ipcSocketDirectory = originalSocketDirectory; + service.innerStop(); + Files.deleteIfExists(directory); + Files.deleteIfExists(root); + } + } + + @Test(timeout = 10_000) + public void testRepeatedStopPreservesReplacementPaths() throws Exception { + assumePosixFileSystem(); + CommonParameter parameter = Args.getInstance(); + String originalSocketDirectory = parameter.ipcSocketDirectory; + Path root = Files.createTempDirectory(Paths.get("/tmp"), "ipc-repeat-stop-"); + IpcService service = newIpcService(); + Path socketFile = null; + try { + parameter.ipcSocketDirectory = root.toString(); + service.innerStart(); + socketFile = resolveSocketFilePath(parameter, getPid(service)); + service.innerStop(); + Assert.assertFalse(Files.exists(socketFile.getParent())); + Files.createDirectory(socketFile.getParent()); + byte[] contents = "replacement".getBytes(StandardCharsets.UTF_8); + Files.write(socketFile, contents); + + service.innerStop(); + + Assert.assertArrayEquals(contents, Files.readAllBytes(socketFile)); + } finally { + parameter.ipcSocketDirectory = originalSocketDirectory; + service.innerStop(); + if (socketFile != null) { + Files.deleteIfExists(socketFile); + Files.deleteIfExists(socketFile.getParent()); + } + Files.deleteIfExists(root); + } + } + @Test(timeout = 10_000) public void testInnerStartCleansSocketWhenPermissionUpdateFails() throws Exception { assumePosixFileSystem(); @@ -457,6 +628,7 @@ public void testInnerStopContinuesCleanupAfterServerCloseFailure() throws Except Mockito.doThrow(new IOException("server close failed")).when(serverSocket).close(); setField(service, "unixServerSocket", serverSocket); setField(service, "socketFilePath", socketFile); + setField(service, "socketDirectoryPath", socketDirectory); getActiveClientSockets(service).add(clientSocket); try { @@ -522,6 +694,7 @@ public void testInnerStopSuppressesLaterCleanupFailure() throws Exception { Mockito.doThrow(new IOException("server close failed")).when(serverSocket).close(); setField(service, "unixServerSocket", serverSocket); setField(service, "socketFilePath", socketDirectory.resolve("1234.sock")); + setField(service, "socketDirectoryPath", socketDirectory); try { service.innerStop(); @@ -530,6 +703,7 @@ public void testInnerStopSuppressesLaterCleanupFailure() throws Exception { Assert.assertEquals("server close failed", e.getMessage()); Assert.assertEquals(1, e.getSuppressed().length); Assert.assertTrue(e.getSuppressed()[0] instanceof IOException); + Assert.assertTrue("Unrelated files must survive cleanup", Files.exists(childFile)); } finally { Files.deleteIfExists(childFile); Files.deleteIfExists(socketDirectory); @@ -566,6 +740,30 @@ private IpcService newIpcService() { return new IpcService(new AdminJsonRpcImpl()); } + private void assertStartupRejectsExistingDirectory(IpcService service) throws Exception { + try { + service.innerStart(); + Assert.fail("Expected startup to reject an existing IPC directory"); + } catch (TronError e) { + Assert.assertEquals(TronError.ErrCode.API_SERVER_INIT, e.getErrCode()); + Assert.assertTrue(e.getMessage().contains("IPC directory already exists")); + Assert.assertTrue(e.getMessage().contains("remove it manually")); + Assert.assertFalse(isRunning(service)); + } + } + + private void assertNewConnectionSucceeds(Path socketFile) throws IOException { + try (AFUNIXSocket client = AFUNIXSocket.newInstance()) { + client.connect(AFUNIXSocketAddress.of(socketFile.toFile())); + client.setSoTimeout(2_000); + BufferedWriter writer = new BufferedWriter( + new OutputStreamWriter(client.getOutputStream(), StandardCharsets.UTF_8)); + BufferedReader reader = new BufferedReader( + new InputStreamReader(client.getInputStream(), StandardCharsets.UTF_8)); + assertSuccessfulResponse(sendRequest(writer, reader, 1), 1); + } + } + private void cleanupIpcService(IpcService service, boolean started, CommonParameter parameter, String originalOutputDirectory, Path socketFile, Path outputDirectory) throws Exception { parameter.outputDirectory = originalOutputDirectory; diff --git a/framework/src/test/java/org/tron/core/services/admin/ipc/server/IpcSocketFilesTest.java b/framework/src/test/java/org/tron/core/services/admin/ipc/server/IpcSocketFilesTest.java index 34e3ed108a0..737dabfb0e2 100644 --- a/framework/src/test/java/org/tron/core/services/admin/ipc/server/IpcSocketFilesTest.java +++ b/framework/src/test/java/org/tron/core/services/admin/ipc/server/IpcSocketFilesTest.java @@ -8,6 +8,7 @@ import java.nio.file.Paths; import java.nio.file.attribute.PosixFilePermission; import java.util.EnumSet; +import java.util.Set; import org.junit.Assert; import org.junit.Assume; import org.junit.Test; @@ -116,15 +117,13 @@ public void testValidateSocketRootDirectoryRejectsMissingDirectory() throws Exce } @Test - public void testRecreateSocketDirectoryRejectsRegularFile() throws Exception { + public void testCreateSocketDirectoryRejectsRegularFile() throws Exception { + assumePosixFileSystem(); Path outputDirectory = Files.createTempDirectory("ipc-regular-file-test-"); Path socketDirectory = outputDirectory.resolve("ipc"); Files.createFile(socketDirectory); try { - socketFiles.recreateSocketDirectory(socketDirectory); - Assert.fail("Expected a regular file at the reserved directory path to be preserved"); - } catch (TronError e) { - Assert.assertEquals("Refusing to replace a non-directory IPC path", e.getMessage()); + assertExistingPathRejected(socketDirectory); Assert.assertTrue(Files.isRegularFile(socketDirectory, LinkOption.NOFOLLOW_LINKS)); } finally { Files.deleteIfExists(socketDirectory); @@ -133,7 +132,7 @@ public void testRecreateSocketDirectoryRejectsRegularFile() throws Exception { } @Test - public void testRecreateSocketDirectoryRejectsSymbolicLink() throws Exception { + public void testCreateSocketDirectoryRejectsSymbolicLink() throws Exception { assumePosixFileSystem(); Path outputDirectory = Files.createTempDirectory("ipc-symbolic-link-test-"); Path targetFile = outputDirectory.resolve("target"); @@ -141,12 +140,12 @@ public void testRecreateSocketDirectoryRejectsSymbolicLink() throws Exception { Files.createFile(targetFile); Files.createSymbolicLink(socketDirectory, targetFile.getFileName()); try { - socketFiles.recreateSocketDirectory(socketDirectory); - Assert.fail("Expected a symbolic link to be preserved"); - } catch (TronError e) { - Assert.assertEquals("Refusing to replace a non-directory IPC path", e.getMessage()); + assertExistingPathRejected(socketDirectory); Assert.assertTrue(Files.isSymbolicLink(socketDirectory)); Assert.assertTrue(Files.exists(targetFile)); + Files.delete(targetFile); + assertExistingPathRejected(socketDirectory); + Assert.assertTrue(Files.isSymbolicLink(socketDirectory)); } finally { Files.deleteIfExists(socketDirectory); Files.deleteIfExists(targetFile); @@ -155,27 +154,52 @@ public void testRecreateSocketDirectoryRejectsSymbolicLink() throws Exception { } @Test - public void testRecreateSocketDirectoryRemovesStaleFilesAndUsesOwnerOnlyPermissions() - throws Exception { + public void testCreateSocketDirectoryUsesOwnerOnlyPermissions() throws Exception { assumePosixFileSystem(); - Path outputDirectory = Files.createTempDirectory("ipc-stale-directory-test-"); - Path socketDirectory = Files.createDirectory(outputDirectory.resolve("ipc")); - Files.createFile(socketDirectory.resolve("1234.sock")); + Path outputDirectory = Files.createTempDirectory("ipc-directory-test-"); + Path socketDirectory = outputDirectory.resolve("ipc"); try { - socketFiles.recreateSocketDirectory(socketDirectory); + socketFiles.createSocketDirectory(socketDirectory); Assert.assertTrue(Files.isDirectory(socketDirectory)); Assert.assertEquals( EnumSet.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE, PosixFilePermission.OWNER_EXECUTE), Files.getPosixFilePermissions(socketDirectory)); - Assert.assertFalse(Files.exists(socketDirectory.resolve("1234.sock"))); } finally { Files.deleteIfExists(socketDirectory); Files.deleteIfExists(outputDirectory); } } + @Test + public void testCreateSocketDirectoryPreservesExistingDirectoryAndContents() throws Exception { + assumePosixFileSystem(); + Path root = Files.createTempDirectory("ipc-existing-test-"); + Path directory = Files.createDirectory(root.resolve("ipc")); + Path socketFile = directory.resolve("1234.sock"); + Path unrelatedFile = directory.resolve("operator-note.txt"); + byte[] contents = "keep me".getBytes(StandardCharsets.UTF_8); + try { + Set permissions = Files.getPosixFilePermissions(directory); + assertExistingPathRejected(directory); + Assert.assertTrue(Files.isDirectory(directory)); + Files.write(socketFile, contents); + Files.write(unrelatedFile, contents); + + assertExistingPathRejected(directory); + + Assert.assertArrayEquals(contents, Files.readAllBytes(socketFile)); + Assert.assertArrayEquals(contents, Files.readAllBytes(unrelatedFile)); + Assert.assertEquals(permissions, Files.getPosixFilePermissions(directory)); + } finally { + Files.deleteIfExists(socketFile); + Files.deleteIfExists(unrelatedFile); + Files.deleteIfExists(directory); + Files.deleteIfExists(root); + } + } + @Test public void testValidateSocketRootDirectorySupportsPosixPermissions() throws Exception { assumePosixFileSystem(); @@ -196,13 +220,13 @@ public void testDeleteSocketFilesPreservesRootDirectory() throws Exception { socketFiles.deleteSocketFile(socketFile); Assert.assertFalse(Files.exists(socketFile)); Assert.assertTrue(Files.isDirectory(directory)); - socketFiles.deleteSocketDirectory(socketFile); + socketFiles.deleteSocketDirectory(directory); Assert.assertFalse(Files.exists(directory)); Assert.assertTrue(Files.isDirectory(root)); // Cleanup also runs before bind or after an earlier cleanup already removed the files. socketFiles.deleteSocketFile(socketFile); - socketFiles.deleteSocketDirectory(socketFile); + socketFiles.deleteSocketDirectory(directory); socketFiles.deleteSocketFile(null); socketFiles.deleteSocketDirectory(null); Assert.assertTrue(Files.isDirectory(root)); @@ -213,6 +237,19 @@ public void testDeleteSocketFilesPreservesRootDirectory() throws Exception { } } + private void assertExistingPathRejected(Path path) throws Exception { + try { + socketFiles.createSocketDirectory(path); + Assert.fail("Expected an existing IPC path to be preserved"); + } catch (TronError e) { + Assert.assertEquals(TronError.ErrCode.API_SERVER_INIT, e.getErrCode()); + Assert.assertTrue(e.getMessage().contains("IPC directory already exists")); + Assert.assertTrue(e.getMessage().contains("Confirm that no node is using it")); + Assert.assertTrue(e.getMessage().contains("remove it manually")); + Assert.assertFalse(e.getMessage().contains(path.toString())); + } + } + private void assumePosixFileSystem() { Assume.assumeTrue("IPC requires POSIX file permissions", FileSystems.getDefault().supportedFileAttributeViews().contains("posix")); From 2e73278300192fd68d5e98930c04985a225d533e Mon Sep 17 00:00:00 2001 From: 317787106 <317787106@qq.com> Date: Fri, 25 Sep 2026 22:28:44 +0800 Subject: [PATCH 06/19] ipc and http get the same error code when request violate depth or token count restrict --- .../services/admin/http/AdminRpcServlet.java | 59 +++++-- .../admin/ipc/server/IpcRequestHandler.java | 39 ++--- .../core/services/jsonrpc/JsonRpcMapper.java | 14 ++ .../admin/http/AdminRpcServletTest.java | 156 +++++++++++++++++- .../ipc/server/IpcRequestHandlerTest.java | 74 +++++++-- 5 files changed, 287 insertions(+), 55 deletions(-) diff --git a/framework/src/main/java/org/tron/core/services/admin/http/AdminRpcServlet.java b/framework/src/main/java/org/tron/core/services/admin/http/AdminRpcServlet.java index 52ca3ac0634..efa611d6f91 100644 --- a/framework/src/main/java/org/tron/core/services/admin/http/AdminRpcServlet.java +++ b/framework/src/main/java/org/tron/core/services/admin/http/AdminRpcServlet.java @@ -1,10 +1,17 @@ package org.tron.core.services.admin.http; -import com.googlecode.jsonrpc4j.HttpStatusCodeProvider; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.io.ByteStreams; +import com.googlecode.jsonrpc4j.ErrorResolver.JsonError; import com.googlecode.jsonrpc4j.JsonRpcInterceptor; import com.googlecode.jsonrpc4j.JsonRpcServer; import com.googlecode.jsonrpc4j.ProxyUtil; import io.prometheus.client.Histogram; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; import java.io.IOException; import java.util.Collections; import javax.servlet.ServletConfig; @@ -38,6 +45,7 @@ public class AdminRpcServlet extends HttpServlet { private static final long serialVersionUID = 0L; + private static final ObjectMapper OBJECT_MAPPER = JsonRpcMapper.create(); private JsonRpcServer rpcServer = null; private VirtualHostValidator virtualHostValidator = @@ -64,23 +72,9 @@ public void init(ServletConfig config) throws ServletException { true); // Keep parser constraints and annotation-based error mapping consistent with the IPC transport. - rpcServer = new JsonRpcServer(JsonRpcMapper.create(), compositeService); + rpcServer = new JsonRpcServer(OBJECT_MAPPER, compositeService); rpcServer.setErrorResolver(JsonRpcErrorResolver.INSTANCE); - // JSON-RPC result codes belong in the response body. HTTP validation below still uses 403/415. - HttpStatusCodeProvider httpStatusCodeProvider = new HttpStatusCodeProvider() { - @Override - public int getHttpStatusCode(int resultCode) { - return 200; - } - - @Override - public Integer getJsonRpcCode(int httpStatusCode) { - return null; - } - }; - rpcServer.setHttpStatusCodeProvider(httpStatusCodeProvider); - rpcServer.setShouldLogInvocationErrors(false); if (CommonParameter.getInstance().isMetricsPrometheusEnable()) { rpcServer.setInterceptorList(Collections.singletonList(interceptor)); @@ -103,7 +97,7 @@ protected void service(HttpServletRequest req, HttpServletResponse resp) } /** - * Applies HTTP-specific checks before handing request parsing and dispatch to jsonrpc4j. + * Validates HTTP headers and JSON input before dispatching through jsonrpc4j. */ @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException { @@ -118,6 +112,35 @@ protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws I resp.setContentLength(0); return; } - rpcServer.handle(req, resp); + byte[] body = ByteStreams.toByteArray(req.getInputStream()); + try { + JsonNode request = OBJECT_MAPPER.reader() + .with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS).readTree(body); + if (request == null || request.isMissingNode()) { + writeParseError(resp); + return; + } + } catch (JsonProcessingException e) { + writeParseError(resp); + return; + } + ByteArrayOutputStream output = new ByteArrayOutputStream(); + rpcServer.handleRequest(new ByteArrayInputStream(body), output); + writeResponse(resp, output.toByteArray()); + } + + private void writeParseError(HttpServletResponse resp) throws IOException { + byte[] body = OBJECT_MAPPER.writeValueAsBytes(JsonRpcMapper.createErrorResponse( + JsonError.PARSE_ERROR.code, JsonError.PARSE_ERROR.message, null)); + writeResponse(resp, body); + } + + private void writeResponse(HttpServletResponse resp, byte[] body) throws IOException { + // JSON-RPC result codes belong in the response body. HTTP validation still uses 403/415. + resp.setContentType("application/json-rpc"); + resp.setStatus(HttpServletResponse.SC_OK); + resp.setContentLength(body.length); + resp.getOutputStream().write(body); + resp.getOutputStream().flush(); } } diff --git a/framework/src/main/java/org/tron/core/services/admin/ipc/server/IpcRequestHandler.java b/framework/src/main/java/org/tron/core/services/admin/ipc/server/IpcRequestHandler.java index 9e8282f835b..8f29967e1cd 100644 --- a/framework/src/main/java/org/tron/core/services/admin/ipc/server/IpcRequestHandler.java +++ b/framework/src/main/java/org/tron/core/services/admin/ipc/server/IpcRequestHandler.java @@ -1,9 +1,10 @@ package org.tron.core.services.admin.ipc.server; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.NullNode; -import com.fasterxml.jackson.databind.node.ObjectNode; +import com.googlecode.jsonrpc4j.ErrorResolver.JsonError; import com.googlecode.jsonrpc4j.JsonRpcServer; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; @@ -62,6 +63,16 @@ String readRequest(InputStream input) throws IOException { } String handleCommand(String jsonRequest) { + JsonNode request; + try { + request = OBJECT_MAPPER.reader() + .with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS).readTree(jsonRequest); + if (request == null || request.isMissingNode()) { + return buildErrorResponse(JsonError.PARSE_ERROR.code, JsonError.PARSE_ERROR.message, null); + } + } catch (JsonProcessingException e) { + return buildErrorResponse(JsonError.PARSE_ERROR.code, JsonError.PARSE_ERROR.message, null); + } ByteArrayInputStream input = new ByteArrayInputStream(jsonRequest.getBytes(StandardCharsets.UTF_8)); ByteArrayOutputStream output = new ByteArrayOutputStream(); @@ -75,30 +86,14 @@ String handleCommand(String jsonRequest) { return response == null ? "" : OBJECT_MAPPER.writeValueAsString(response); } catch (Exception e) { logger.debug("Failed to dispatch IPC request"); - return buildInternalErrorResponse(jsonRequest); + return buildErrorResponse(-32603, "Internal error", request.get("id")); } } - private String buildInternalErrorResponse(String jsonRequest) { - JsonNode requestId = NullNode.getInstance(); - try { - JsonNode request = OBJECT_MAPPER.readTree(jsonRequest); - if (request != null && request.has("id")) { - requestId = request.get("id"); - } - } catch (IOException e) { - logger.debug("Unable to read request id from invalid IPC request"); - } - - ObjectNode error = OBJECT_MAPPER.createObjectNode(); - error.put("code", -32603); - error.put("message", "Internal error"); - ObjectNode response = OBJECT_MAPPER.createObjectNode(); - response.put("jsonrpc", "2.0"); - response.set("error", error); - response.set("id", requestId); + private String buildErrorResponse(int code, String message, JsonNode requestId) { try { - return OBJECT_MAPPER.writeValueAsString(response); + return OBJECT_MAPPER.writeValueAsString( + JsonRpcMapper.createErrorResponse(code, message, requestId)); } catch (IOException e) { throw new IllegalStateException("Failed to serialize IPC error response", e); } diff --git a/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcMapper.java b/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcMapper.java index b5bcc8fd4d9..6d461798907 100644 --- a/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcMapper.java +++ b/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcMapper.java @@ -2,7 +2,10 @@ import com.fasterxml.jackson.core.JsonFactory; import com.fasterxml.jackson.core.StreamReadConstraints; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; import org.tron.core.Constant; public final class JsonRpcMapper { @@ -19,4 +22,15 @@ public static ObjectMapper create() { .build(); return new ObjectMapper(factory); } + + /** Creates a JSON-RPC error response, encoding an unknown ID as JSON null. */ + public static ObjectNode createErrorResponse(int code, String message, JsonNode id) { + ObjectNode response = JsonNodeFactory.instance.objectNode(); + response.put("jsonrpc", "2.0"); + response.set("id", id); + ObjectNode error = response.putObject("error"); + error.put("code", code); + error.put("message", message); + return response; + } } diff --git a/framework/src/test/java/org/tron/core/services/admin/http/AdminRpcServletTest.java b/framework/src/test/java/org/tron/core/services/admin/http/AdminRpcServletTest.java index b8f5918f815..d9659dff596 100644 --- a/framework/src/test/java/org/tron/core/services/admin/http/AdminRpcServletTest.java +++ b/framework/src/test/java/org/tron/core/services/admin/http/AdminRpcServletTest.java @@ -1,19 +1,27 @@ package org.tron.core.services.admin.http; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.clearInvocations; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; import com.fasterxml.jackson.databind.JsonNode; import com.googlecode.jsonrpc4j.JsonRpcInterceptor; +import java.io.IOException; import java.lang.reflect.Field; import java.nio.charset.StandardCharsets; import java.util.Arrays; +import javax.servlet.ServletInputStream; import javax.servlet.http.HttpServletResponse; import org.junit.Before; import org.junit.Test; @@ -23,6 +31,7 @@ import org.springframework.mock.web.MockServletConfig; import org.tron.common.parameter.CommonParameter; import org.tron.core.Constant; +import org.tron.core.exception.jsonrpc.JsonRpcInvalidParamsException; import org.tron.core.services.admin.AdminJsonRpc; import org.tron.core.services.jsonrpc.JsonRpcMapper; import org.tron.core.services.jsonrpc.JsonRpcMediaType; @@ -30,6 +39,10 @@ public class AdminRpcServletTest { + private static final String REQUEST_PREFIX = + "{\"jsonrpc\":\"2.0\",\"method\":\"admin_example\"," + + "\"params\":[\"a\",\"b\"],\"id\":7,\"extra\":"; + private AdminRpcServlet servlet; private AdminJsonRpc adminJsonRpc; @@ -74,6 +87,7 @@ public void publicRateLimitDoesNotBlockAdminRequests() throws Exception { @Test public void excessivelyNestedRequestIsRejected() throws Exception { + assertValidRequestWithExtra(); StringBuilder request = new StringBuilder(); for (int i = 0; i <= Constant.MAX_NESTING_DEPTH; i++) { request.append('['); @@ -83,14 +97,13 @@ public void excessivelyNestedRequestIsRejected() throws Exception { request.append(']'); } - MockHttpServletResponse response = doPost(request.toString()); - assertEquals(HttpServletResponse.SC_OK, response.getStatus()); - assertEquals(0, response.getContentAsByteArray().length); + assertParseError(doPost(REQUEST_PREFIX + request + "}")); } @Test public void requestWithTooManyTokensIsRejected() throws Exception { - StringBuilder request = new StringBuilder("{\"params\":["); + assertValidRequestWithExtra(); + StringBuilder request = new StringBuilder(REQUEST_PREFIX + "["); for (int i = 0; i < Constant.MAX_TOKEN_COUNT; i++) { if (i > 0) { request.append(','); @@ -99,9 +112,118 @@ public void requestWithTooManyTokensIsRejected() throws Exception { } request.append("]}"); - MockHttpServletResponse response = doPost(request.toString()); + assertParseError(doPost(request.toString())); + } + + @Test + public void invalidJsonReturnsParseErrorBeforeInvocation() throws Exception { + String valid = REQUEST_PREFIX + "0}"; + for (String body : new String[] {"", " \t\r\n", "{broken-sensitive-detail", REQUEST_PREFIX, + valid + " {broken", valid + " " + valid}) { + assertParseError(doPost(body)); + } + } + + @Test + public void stringNullRequestIdIsPreserved() throws Exception { + when(adminJsonRpc.adminExample("a", "b")).thenReturn("a:b"); + MockHttpServletResponse response = doPost( + "{\"jsonrpc\":\"2.0\",\"method\":\"admin_example\"," + + "\"params\":[\"a\",\"b\"],\"id\":\"null\"}"); + + assertEquals(HttpServletResponse.SC_OK, response.getStatus()); + JsonNode result = JsonRpcMapper.create().readTree(response.getContentAsString()); + assertTrue(result.get("id").isTextual()); + assertEquals("null", result.get("id").asText()); + assertEquals("a:b", result.get("result").asText()); + verify(adminJsonRpc).adminExample("a", "b"); + } + + @Test + public void notificationInvokesMethodWithoutResponse() throws Exception { + MockHttpServletResponse response = doPost( + "{\"jsonrpc\":\"2.0\",\"method\":\"admin_example\",\"params\":[\"a\",\"b\"]}"); + assertEquals(HttpServletResponse.SC_OK, response.getStatus()); assertEquals(0, response.getContentAsByteArray().length); + assertEquals(0, response.getContentLength()); + assertTrue(JsonRpcMediaType.isSupported(response.getContentType())); + verify(adminJsonRpc).adminExample("a", "b"); + } + + @Test + public void utf8ResponseUsesByteContentLength() throws Exception { + when(adminJsonRpc.adminExample("左", "右")).thenReturn("左:右"); + + MockHttpServletResponse response = doPost( + "{\"jsonrpc\":\"2.0\",\"method\":\"admin_example\"," + + "\"params\":[\"左\",\"右\"],\"id\":8}"); + + assertEquals(HttpServletResponse.SC_OK, response.getStatus()); + assertTrue(JsonRpcMediaType.isSupported(response.getContentType())); + assertEquals("UTF-8", response.getCharacterEncoding()); + assertEquals(response.getContentAsByteArray().length, response.getContentLength()); + JsonNode result = JsonRpcMapper.create().readTree(response.getContentAsByteArray()); + assertEquals("左:右", result.get("result").asText()); + assertEquals(8, result.get("id").asInt()); + verify(adminJsonRpc).adminExample("左", "右"); + } + + @Test + public void streamDispatchRetainsConfiguredInterceptor() throws Exception { + CommonParameter parameter = CommonParameter.getInstance(); + boolean previousMetricsEnabled = parameter.isMetricsPrometheusEnable(); + JsonRpcInterceptor interceptor = mock(JsonRpcInterceptor.class); + try { + parameter.setMetricsPrometheusEnable(true); + setField("interceptor", interceptor); + servlet.init(new MockServletConfig()); + assertValidRequestWithExtra(); + + verify(interceptor).preHandleJson(any(JsonNode.class)); + } finally { + parameter.setMetricsPrometheusEnable(previousMetricsEnabled); + } + } + + @Test + public void annotatedBusinessErrorIsPreserved() throws Exception { + when(adminJsonRpc.adminExample("a", "b")) + .thenThrow(new JsonRpcInvalidParamsException("Invalid admin parameters")); + + MockHttpServletResponse response = doPost(REQUEST_PREFIX + "0}"); + + assertEquals(HttpServletResponse.SC_OK, response.getStatus()); + JsonNode result = JsonRpcMapper.create().readTree(response.getContentAsString()); + assertEquals(-32602, result.get("error").get("code").asInt()); + assertEquals(response.getContentAsByteArray().length, response.getContentLength()); + assertEquals("Invalid admin parameters", result.get("error").get("message").asText()); + assertEquals(7, result.get("id").asInt()); + assertFalse(result.has("result")); + verify(adminJsonRpc).adminExample("a", "b"); + } + + @Test + public void transportReadFailureIsNotReportedAsParseError() throws Exception { + ServletInputStream input = mock(ServletInputStream.class); + IOException failure = new IOException("transport read failed"); + when(input.read(any(byte[].class), anyInt(), anyInt())).thenThrow(failure); + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/admin") { + @Override + public ServletInputStream getInputStream() { + return input; + } + }; + request.setContentType("application/json"); + MockHttpServletResponse response = new MockHttpServletResponse(); + try { + servlet.service(request, response); + fail("Expected the transport failure to propagate"); + } catch (IOException e) { + assertSame(failure, e); + } + assertEquals(0, response.getContentAsByteArray().length); + verifyNoInteractions(adminJsonRpc); } @Test @@ -181,6 +303,30 @@ private void setField(String name, Object value) throws Exception { field.set(servlet, value); } + private void assertValidRequestWithExtra() throws Exception { + when(adminJsonRpc.adminExample("a", "b")).thenReturn("a:b"); + MockHttpServletResponse response = doPost(REQUEST_PREFIX + "[0]}"); + JsonNode result = JsonRpcMapper.create().readTree(response.getContentAsString()); + assertEquals("a:b", result.get("result").asText()); + assertEquals(7, result.get("id").asInt()); + verify(adminJsonRpc).adminExample("a", "b"); + clearInvocations(adminJsonRpc); + } + + private void assertParseError(MockHttpServletResponse response) throws Exception { + assertEquals(HttpServletResponse.SC_OK, response.getStatus()); + assertTrue(JsonRpcMediaType.isSupported(response.getContentType())); + assertEquals(response.getContentAsByteArray().length, response.getContentLength()); + JsonNode error = JsonRpcMapper.create().readTree(response.getContentAsString()); + assertEquals("2.0", error.get("jsonrpc").asText()); + assertTrue(error.get("id").isNull()); + assertEquals(-32700, error.get("error").get("code").asInt()); + assertEquals("JSON parse error", error.get("error").get("message").asText()); + assertEquals(2, error.get("error").size()); + assertFalse(error.has("result")); + verifyNoInteractions(adminJsonRpc); + } + private MockHttpServletResponse doPost(String body) throws Exception { return doPost(body, "application/json"); } diff --git a/framework/src/test/java/org/tron/core/services/admin/ipc/server/IpcRequestHandlerTest.java b/framework/src/test/java/org/tron/core/services/admin/ipc/server/IpcRequestHandlerTest.java index bdb93b21df6..c968315bd3b 100644 --- a/framework/src/test/java/org/tron/core/services/admin/ipc/server/IpcRequestHandlerTest.java +++ b/framework/src/test/java/org/tron/core/services/admin/ipc/server/IpcRequestHandlerTest.java @@ -23,6 +23,9 @@ public class IpcRequestHandlerTest { private static final int MAX_REQUEST_SIZE = 128; private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final String REQUEST_PREFIX = + "{\"jsonrpc\":\"2.0\",\"method\":\"admin_example\"," + + "\"params\":[\"a\",\"b\"],\"id\":11,\"extra\":"; private final IpcRequestHandler handler = new IpcRequestHandler(new AdminJsonRpcImpl(), MAX_REQUEST_SIZE); @@ -60,9 +63,6 @@ public void testHandleCommandReturnsJsonRpcErrorOnDispatcherFailure() throws Exc Assert.assertFalse(response, response.contains("sensitive-detail")); Assert.assertFalse(response, response.contains("\n")); - JsonNode malformed = OBJECT_MAPPER.readTree(failingHandler.handleCommand("{broken")); - Assert.assertEquals(-32603, malformed.get("error").get("code").asInt()); - Assert.assertTrue(malformed.get("id").isNull()); } } @@ -99,10 +99,8 @@ public void testIpcMapperRejectsExcessiveNestingBeforeInvocation() throws Except AdminJsonRpc adminJsonRpc = Mockito.mock(AdminJsonRpc.class); Mockito.when(adminJsonRpc.adminExample("a", "b")).thenReturn("a:b"); IpcRequestHandler constrainedHandler = new IpcRequestHandler(adminJsonRpc, MAX_REQUEST_SIZE); - String requestPrefix = "{\"jsonrpc\":\"2.0\",\"method\":\"admin_example\"," - + "\"params\":[\"a\",\"b\"],\"id\":11,\"extra\":"; JsonNode valid = OBJECT_MAPPER.readTree( - constrainedHandler.handleCommand(requestPrefix + "[0]}")); + constrainedHandler.handleCommand(REQUEST_PREFIX + "[0]}")); Assert.assertEquals("a:b", valid.get("result").asText()); Mockito.verify(adminJsonRpc).adminExample("a", "b"); Mockito.clearInvocations(adminJsonRpc); @@ -115,13 +113,57 @@ public void testIpcMapperRejectsExcessiveNestingBeforeInvocation() throws Except for (int i = 0; i <= Constant.MAX_NESTING_DEPTH; i++) { nested.append(']'); } - JsonNode rejected = OBJECT_MAPPER.readTree( - constrainedHandler.handleCommand(requestPrefix + nested + "}")); - Assert.assertTrue(rejected.toString(), rejected.has("error")); - Assert.assertFalse(rejected.has("result")); + assertParseError(constrainedHandler.handleCommand(REQUEST_PREFIX + nested + "}")); Mockito.verifyNoInteractions(adminJsonRpc); } + @Test + public void testIpcMapperRejectsExcessiveTokensBeforeInvocation() throws Exception { + AdminJsonRpc adminJsonRpc = Mockito.mock(AdminJsonRpc.class); + Mockito.when(adminJsonRpc.adminExample("a", "b")).thenReturn("a:b"); + IpcRequestHandler constrainedHandler = new IpcRequestHandler(adminJsonRpc, MAX_REQUEST_SIZE); + JsonNode valid = OBJECT_MAPPER.readTree( + constrainedHandler.handleCommand(REQUEST_PREFIX + "[0]}")); + Assert.assertEquals("a:b", valid.get("result").asText()); + Mockito.verify(adminJsonRpc).adminExample("a", "b"); + Mockito.clearInvocations(adminJsonRpc); + + StringBuilder request = new StringBuilder(REQUEST_PREFIX + "["); + for (int i = 0; i < Constant.MAX_TOKEN_COUNT; i++) { + if (i > 0) { + request.append(','); + } + request.append('0'); + } + request.append("]}"); + + assertParseError(constrainedHandler.handleCommand(request.toString())); + Mockito.verifyNoInteractions(adminJsonRpc); + } + + @Test + public void testInvalidJsonReturnsParseErrorBeforeInvocation() throws Exception { + AdminJsonRpc adminJsonRpc = Mockito.mock(AdminJsonRpc.class); + IpcRequestHandler constrainedHandler = new IpcRequestHandler(adminJsonRpc, MAX_REQUEST_SIZE); + String valid = REQUEST_PREFIX + "0}"; + for (String body : new String[] {"", " \t\r\n", "{broken-sensitive-detail", REQUEST_PREFIX, + valid + " {broken", valid + " " + valid}) { + assertParseError(constrainedHandler.handleCommand(body)); + } + Mockito.verifyNoInteractions(adminJsonRpc); + } + + @Test + public void testStringNullRequestIdIsPreserved() throws Exception { + JsonNode response = OBJECT_MAPPER.readTree(handler.handleCommand( + "{\"jsonrpc\":\"2.0\",\"method\":\"admin_example\"," + + "\"params\":[\"a\",\"b\"],\"id\":\"null\"}")); + + Assert.assertTrue(response.get("id").isTextual()); + Assert.assertEquals("null", response.get("id").asText()); + Assert.assertEquals("a:b", response.get("result").asText()); + } + @Test public void testReadRequestAcceptsMaximumSize() throws Exception { byte[] request = new byte[MAX_REQUEST_SIZE + 1]; @@ -175,4 +217,16 @@ public void testZeroLimitOnlyAcceptsEmptyLines() throws Exception { // Zero must not silently fall back to a default limit. } } + + private void assertParseError(String response) throws Exception { + Assert.assertFalse(response.contains("\n")); + Assert.assertFalse(response.contains("\r")); + JsonNode error = OBJECT_MAPPER.readTree(response); + Assert.assertEquals("2.0", error.get("jsonrpc").asText()); + Assert.assertTrue(error.get("id").isNull()); + Assert.assertEquals(-32700, error.get("error").get("code").asInt()); + Assert.assertEquals("JSON parse error", error.get("error").get("message").asText()); + Assert.assertEquals(2, error.get("error").size()); + Assert.assertFalse(error.has("result")); + } } From 5823f3115c6254968068338c8bdde48f06ab7ec7 Mon Sep 17 00:00:00 2001 From: 317787106 <317787106@qq.com> Date: Fri, 25 Sep 2026 22:53:58 +0800 Subject: [PATCH 07/19] reject batch request in admin jsonrpc --- common/src/main/resources/reference.conf | 1 + .../core/services/admin/AdminJsonRpc.java | 2 + .../services/admin/http/AdminRpcServlet.java | 22 ++++--- .../admin/ipc/server/IpcRequestHandler.java | 5 ++ .../admin/http/AdminRpcServletTest.java | 54 +++++++++++++++++- .../ipc/server/IpcRequestHandlerTest.java | 57 ++++++++++++++++++- 6 files changed, 132 insertions(+), 9 deletions(-) diff --git a/common/src/main/resources/reference.conf b/common/src/main/resources/reference.conf index 9ea023cb923..04ec14b2dd7 100644 --- a/common/src/main/resources/reference.conf +++ b/common/src/main/resources/reference.conf @@ -444,6 +444,7 @@ node { } # Administrative API settings. Disabled by default. + # Batch requests are not supported. admin { # Local Unix-domain socket administrative API. ipc { diff --git a/framework/src/main/java/org/tron/core/services/admin/AdminJsonRpc.java b/framework/src/main/java/org/tron/core/services/admin/AdminJsonRpc.java index 73a43f35c53..9eae6306a37 100644 --- a/framework/src/main/java/org/tron/core/services/admin/AdminJsonRpc.java +++ b/framework/src/main/java/org/tron/core/services/admin/AdminJsonRpc.java @@ -8,6 +8,8 @@ public interface AdminJsonRpc { + String BATCH_NOT_SUPPORTED_MESSAGE = "Batch requests are not supported"; + @JsonRpcMethod("admin_example") @JsonRpcErrors({ @JsonRpcError(exception = JsonRpcInvalidParamsException.class, code = -32602, data = "{}"), diff --git a/framework/src/main/java/org/tron/core/services/admin/http/AdminRpcServlet.java b/framework/src/main/java/org/tron/core/services/admin/http/AdminRpcServlet.java index efa611d6f91..8165dfb2d2c 100644 --- a/framework/src/main/java/org/tron/core/services/admin/http/AdminRpcServlet.java +++ b/framework/src/main/java/org/tron/core/services/admin/http/AdminRpcServlet.java @@ -35,7 +35,8 @@ * *

This endpoint is intended for trusted node operators. Deployments must restrict access to * loopback or a controlled management network. This low-frequency management endpoint intentionally - * bypasses public API rate limiting and JSON-RPC batch-size and response-size limits. + * bypasses public API rate limiting and JSON-RPC response-size limits. + * Only individual request objects are supported; batch requests are rejected. * HTTP request-size limits are enforced by * {@link org.tron.common.application.HttpService}; JSON parser limits come from * {@link JsonRpcMapper}. @@ -113,15 +114,21 @@ protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws I return; } byte[] body = ByteStreams.toByteArray(req.getInputStream()); + JsonNode request; try { - JsonNode request = OBJECT_MAPPER.reader() + request = OBJECT_MAPPER.reader() .with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS).readTree(body); if (request == null || request.isMissingNode()) { - writeParseError(resp); + writeErrorResponse(resp, JsonError.PARSE_ERROR.code, JsonError.PARSE_ERROR.message); return; } } catch (JsonProcessingException e) { - writeParseError(resp); + writeErrorResponse(resp, JsonError.PARSE_ERROR.code, JsonError.PARSE_ERROR.message); + return; + } + if (request.isArray()) { + writeErrorResponse(resp, JsonError.INVALID_REQUEST.code, + AdminJsonRpc.BATCH_NOT_SUPPORTED_MESSAGE); return; } ByteArrayOutputStream output = new ByteArrayOutputStream(); @@ -129,9 +136,10 @@ protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws I writeResponse(resp, output.toByteArray()); } - private void writeParseError(HttpServletResponse resp) throws IOException { - byte[] body = OBJECT_MAPPER.writeValueAsBytes(JsonRpcMapper.createErrorResponse( - JsonError.PARSE_ERROR.code, JsonError.PARSE_ERROR.message, null)); + private void writeErrorResponse(HttpServletResponse resp, int code, String message) + throws IOException { + byte[] body = OBJECT_MAPPER.writeValueAsBytes( + JsonRpcMapper.createErrorResponse(code, message, null)); writeResponse(resp, body); } diff --git a/framework/src/main/java/org/tron/core/services/admin/ipc/server/IpcRequestHandler.java b/framework/src/main/java/org/tron/core/services/admin/ipc/server/IpcRequestHandler.java index 8f29967e1cd..2bd065b16f4 100644 --- a/framework/src/main/java/org/tron/core/services/admin/ipc/server/IpcRequestHandler.java +++ b/framework/src/main/java/org/tron/core/services/admin/ipc/server/IpcRequestHandler.java @@ -20,6 +20,7 @@ /** * Reads bounded IPC request lines and dispatches Admin JSON-RPC calls. + * Only individual request objects are supported; batch requests are rejected. * Per-request buffers are local so one handler can serve concurrent client connections. * Stream and socket ownership remains with {@link IpcService}. */ @@ -73,6 +74,10 @@ String handleCommand(String jsonRequest) { } catch (JsonProcessingException e) { return buildErrorResponse(JsonError.PARSE_ERROR.code, JsonError.PARSE_ERROR.message, null); } + if (request.isArray()) { + return buildErrorResponse( + JsonError.INVALID_REQUEST.code, AdminJsonRpc.BATCH_NOT_SUPPORTED_MESSAGE, null); + } ByteArrayInputStream input = new ByteArrayInputStream(jsonRequest.getBytes(StandardCharsets.UTF_8)); ByteArrayOutputStream output = new ByteArrayOutputStream(); diff --git a/framework/src/test/java/org/tron/core/services/admin/http/AdminRpcServletTest.java b/framework/src/test/java/org/tron/core/services/admin/http/AdminRpcServletTest.java index d9659dff596..ebc366f379e 100644 --- a/framework/src/test/java/org/tron/core/services/admin/http/AdminRpcServletTest.java +++ b/framework/src/test/java/org/tron/core/services/admin/http/AdminRpcServletTest.java @@ -119,11 +119,46 @@ public void requestWithTooManyTokensIsRejected() throws Exception { public void invalidJsonReturnsParseErrorBeforeInvocation() throws Exception { String valid = REQUEST_PREFIX + "0}"; for (String body : new String[] {"", " \t\r\n", "{broken-sensitive-detail", REQUEST_PREFIX, - valid + " {broken", valid + " " + valid}) { + valid + " {broken", valid + " " + valid, "[" + valid, + "[" + valid + ",{broken]", "[" + valid + "] " + valid}) { assertParseError(doPost(body)); } } + @Test + public void emptyBatchIsRejectedBeforeInvocation() throws Exception { + assertBatchRejected("[]"); + } + + @Test + public void batchRequestsAreRejectedBeforeInvocation() throws Exception { + String first = REQUEST_PREFIX + "0}"; + String second = "{\"jsonrpc\":\"2.0\",\"method\":\"admin_example\"," + + "\"params\":[\"c\",\"d\"],\"id\":8}"; + + assertBatchRejected("[" + first + "]"); + assertBatchRejected("[" + first + "," + second + "]"); + } + + @Test + public void notificationOnlyBatchIsRejectedBeforeInvocation() throws Exception { + String notification = "{\"jsonrpc\":\"2.0\",\"method\":\"admin_example\"," + + "\"params\":[\"a\",\"b\"]}"; + + assertBatchRejected("[" + notification + "]"); + assertBatchRejected("[" + notification + "," + notification + "]"); + } + + @Test + public void mixedBatchIsRejectedBeforeInvocation() throws Exception { + String notification = "{\"jsonrpc\":\"2.0\",\"method\":\"admin_example\"," + + "\"params\":[\"c\",\"d\"]}"; + String request = REQUEST_PREFIX + "0}"; + + assertBatchRejected("[" + notification + "," + request + "]"); + assertBatchRejected("[" + request + "," + notification + "]"); + } + @Test public void stringNullRequestIdIsPreserved() throws Exception { when(adminJsonRpc.adminExample("a", "b")).thenReturn("a:b"); @@ -327,6 +362,23 @@ private void assertParseError(MockHttpServletResponse response) throws Exception verifyNoInteractions(adminJsonRpc); } + private void assertBatchRejected(String body) throws Exception { + MockHttpServletResponse response = doPost(body); + + assertEquals(HttpServletResponse.SC_OK, response.getStatus()); + assertTrue(JsonRpcMediaType.isSupported(response.getContentType())); + assertEquals(response.getContentAsByteArray().length, response.getContentLength()); + JsonNode error = JsonRpcMapper.create().readTree(response.getContentAsByteArray()); + assertTrue(error.isObject()); + assertEquals("2.0", error.get("jsonrpc").asText()); + assertTrue(error.get("id").isNull()); + assertEquals(-32600, error.get("error").get("code").asInt()); + assertEquals("Batch requests are not supported", error.get("error").get("message").asText()); + assertEquals(2, error.get("error").size()); + assertFalse(error.has("result")); + verifyNoInteractions(adminJsonRpc); + } + private MockHttpServletResponse doPost(String body) throws Exception { return doPost(body, "application/json"); } diff --git a/framework/src/test/java/org/tron/core/services/admin/ipc/server/IpcRequestHandlerTest.java b/framework/src/test/java/org/tron/core/services/admin/ipc/server/IpcRequestHandlerTest.java index c968315bd3b..a055e579375 100644 --- a/framework/src/test/java/org/tron/core/services/admin/ipc/server/IpcRequestHandlerTest.java +++ b/framework/src/test/java/org/tron/core/services/admin/ipc/server/IpcRequestHandlerTest.java @@ -94,6 +94,40 @@ public void testNotificationInvokesMethodWithoutResponse() throws Exception { Mockito.verify(adminJsonRpc).adminExample("a", "b"); } + @Test + public void testEmptyBatchIsRejectedBeforeInvocation() throws Exception { + assertBatchRejected("[]"); + } + + @Test + public void testBatchRequestsAreRejectedBeforeInvocation() throws Exception { + String first = REQUEST_PREFIX + "0}"; + String second = "{\"jsonrpc\":\"2.0\",\"method\":\"admin_example\"," + + "\"params\":[\"c\",\"d\"],\"id\":12}"; + + assertBatchRejected("[" + first + "]"); + assertBatchRejected("[" + first + "," + second + "]"); + } + + @Test + public void testNotificationOnlyBatchIsRejectedBeforeInvocation() throws Exception { + String notification = "{\"jsonrpc\":\"2.0\",\"method\":\"admin_example\"," + + "\"params\":[\"a\",\"b\"]}"; + + assertBatchRejected("[" + notification + "]"); + assertBatchRejected("[" + notification + "," + notification + "]"); + } + + @Test + public void testMixedBatchIsRejectedBeforeInvocation() throws Exception { + String notification = "{\"jsonrpc\":\"2.0\",\"method\":\"admin_example\"," + + "\"params\":[\"c\",\"d\"]}"; + String request = REQUEST_PREFIX + "0}"; + + assertBatchRejected("[" + notification + "," + request + "]"); + assertBatchRejected("[" + request + "," + notification + "]"); + } + @Test public void testIpcMapperRejectsExcessiveNestingBeforeInvocation() throws Exception { AdminJsonRpc adminJsonRpc = Mockito.mock(AdminJsonRpc.class); @@ -147,7 +181,8 @@ public void testInvalidJsonReturnsParseErrorBeforeInvocation() throws Exception IpcRequestHandler constrainedHandler = new IpcRequestHandler(adminJsonRpc, MAX_REQUEST_SIZE); String valid = REQUEST_PREFIX + "0}"; for (String body : new String[] {"", " \t\r\n", "{broken-sensitive-detail", REQUEST_PREFIX, - valid + " {broken", valid + " " + valid}) { + valid + " {broken", valid + " " + valid, "[" + valid, + "[" + valid + ",{broken]", "[" + valid + "] " + valid}) { assertParseError(constrainedHandler.handleCommand(body)); } Mockito.verifyNoInteractions(adminJsonRpc); @@ -229,4 +264,24 @@ private void assertParseError(String response) throws Exception { Assert.assertEquals(2, error.get("error").size()); Assert.assertFalse(error.has("result")); } + + private void assertBatchRejected(String body) throws Exception { + AdminJsonRpc adminJsonRpc = Mockito.mock(AdminJsonRpc.class); + IpcRequestHandler rejectingHandler = new IpcRequestHandler(adminJsonRpc, MAX_REQUEST_SIZE); + + String response = rejectingHandler.handleCommand(body); + + Assert.assertFalse(response.contains("\n")); + Assert.assertFalse(response.contains("\r")); + JsonNode error = OBJECT_MAPPER.readTree(response); + Assert.assertTrue(error.isObject()); + Assert.assertEquals("2.0", error.get("jsonrpc").asText()); + Assert.assertTrue(error.get("id").isNull()); + Assert.assertEquals(-32600, error.get("error").get("code").asInt()); + Assert.assertEquals("Batch requests are not supported", + error.get("error").get("message").asText()); + Assert.assertEquals(2, error.get("error").size()); + Assert.assertFalse(error.has("result")); + Mockito.verifyNoInteractions(adminJsonRpc); + } } From 0d884cdd5811e7c2abba8f9eb7c6c3c39cebafcf Mon Sep 17 00:00:00 2001 From: 317787106 <317787106@qq.com> Date: Fri, 25 Sep 2026 23:40:17 +0800 Subject: [PATCH 08/19] imporve the validation of success for IpcResponse --- .../services/admin/ipc/client/IpcClient.java | 2 +- .../admin/ipc/client/IpcConsoleCommands.java | 18 ++-- .../admin/ipc/client/IpcResponse.java | 63 ++++++++---- .../admin/ipc/client/IpcClientTest.java | 94 +++++++++++++++++- .../ipc/client/IpcConsoleCommandsTest.java | 12 ++- .../admin/ipc/client/IpcResponseTest.java | 95 ++++++++++++++++--- 6 files changed, 239 insertions(+), 45 deletions(-) diff --git a/framework/src/main/java/org/tron/core/services/admin/ipc/client/IpcClient.java b/framework/src/main/java/org/tron/core/services/admin/ipc/client/IpcClient.java index f60e8703fc8..693c6f9e627 100644 --- a/framework/src/main/java/org/tron/core/services/admin/ipc/client/IpcClient.java +++ b/framework/src/main/java/org/tron/core/services/admin/ipc/client/IpcClient.java @@ -119,7 +119,7 @@ int runExec(Socket socket, String commandLine) throws IOException { System.err.println("Disconnected from server before receiving a response."); return EXIT_FAILURE; } - IpcResponse parsedResponse = IpcResponse.parse(response); + IpcResponse parsedResponse = IpcResponse.parse(response, command.getRequestId()); if (parsedResponse.isSuccessful()) { System.out.println(parsedResponse.getFormatted()); return EXIT_SUCCESS; diff --git a/framework/src/main/java/org/tron/core/services/admin/ipc/client/IpcConsoleCommands.java b/framework/src/main/java/org/tron/core/services/admin/ipc/client/IpcConsoleCommands.java index 4fdd390842b..de4c436d5fa 100644 --- a/framework/src/main/java/org/tron/core/services/admin/ipc/client/IpcConsoleCommands.java +++ b/framework/src/main/java/org/tron/core/services/admin/ipc/client/IpcConsoleCommands.java @@ -66,21 +66,21 @@ Command prepare(String commandLine) { private Command prepare(List words) throws JsonProcessingException { if (words.isEmpty()) { - return new Command(Action.EMPTY, null, null, null); + return new Command(Action.EMPTY, null, null, null, null); } String name = words.get(0).toLowerCase(Locale.ROOT); if ("exit".equals(name) || "quit".equals(name)) { - return new Command(Action.EXIT, null, null, null); + return new Command(Action.EXIT, null, null, null, null); } if ("help".equals(name)) { AdminCommand command = words.size() == 2 ? adminCommands.get(words.get(1).toLowerCase(Locale.ROOT)) : null; String help = command == null ? buildHelp() : "usage: " + formatUsage(command); - return new Command(Action.HELP, null, help, null); + return new Command(Action.HELP, null, null, help, null); } AdminCommand command = adminCommands.get(name); if (command == null) { - return new Command(Action.ERROR, null, buildHelp(), "Invalid cmd: " + words.get(0)); + return new Command(Action.ERROR, null, null, buildHelp(), "Invalid command."); } if (words.size() - 1 != command.parameters.size()) { return error("Invalid parameter, usage: " + formatUsage(command)); @@ -95,8 +95,9 @@ private Command prepare(List words) throws JsonProcessingException { request.put("jsonrpc", "2.0"); request.put("method", command.name); request.put("params", values); - request.put("id", ++requestId); - return new Command(Action.REQUEST, OBJECT_MAPPER.writeValueAsString(request), null, null); + int id = ++requestId; + request.put("id", id); + return new Command(Action.REQUEST, OBJECT_MAPPER.writeValueAsString(request), id, null, null); } private List parseCommandLine(String commandLine) { @@ -214,20 +215,21 @@ private IllegalArgumentException invalidParameterType(String parameterName, Java } private Command error(String message) { - return new Command(Action.ERROR, null, null, message); + return new Command(Action.ERROR, null, null, null, message); } enum Action { EMPTY, EXIT, HELP, REQUEST, ERROR } - /** Immutable result; only REQUEST carries wire data, while help/errors carry console text. */ + /** Immutable command; only REQUEST carries wire data and a request ID. */ @Getter(AccessLevel.PACKAGE) @RequiredArgsConstructor(access = AccessLevel.PRIVATE) static final class Command { private final Action action; private final String request; + private final Integer requestId; private final String output; private final String error; } diff --git a/framework/src/main/java/org/tron/core/services/admin/ipc/client/IpcResponse.java b/framework/src/main/java/org/tron/core/services/admin/ipc/client/IpcResponse.java index af2c47f2394..3dc31c62312 100644 --- a/framework/src/main/java/org/tron/core/services/admin/ipc/client/IpcResponse.java +++ b/framework/src/main/java/org/tron/core/services/admin/ipc/client/IpcResponse.java @@ -1,6 +1,7 @@ package org.tron.core.services.admin.ipc.client; import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.AccessLevel; @@ -21,32 +22,60 @@ private IpcResponse(String formatted, boolean successful) { } /** - * Parses a JSON-RPC response into console text and a success flag. + * Validates a JSON-RPC response and formats it for the interactive console. * - *

A non-null error takes precedence over any result; otherwise, a result field, including - * a null value, marks the response as successful. Text results are unquoted and other values - * are formatted as JSON. Responses without a result are unsuccessful; empty or malformed - * input is preserved verbatim. + *

A response must declare version 2.0, contain an ID and exactly one of result or error. + * A result field, including a null value, marks a valid response as successful. Text results + * are unquoted and other values are formatted as JSON. Invalid responses use a fixed message. */ static IpcResponse parse(String response) { + return parse(response, null); + } + + /** Also verifies the numeric request ID when called for single-command execution. */ + static IpcResponse parse(String response, Integer expectedId) { try { - JsonNode root = OBJECT_MAPPER.readTree(response); - if (root == null || root.isMissingNode()) { - return new IpcResponse(response, false); + JsonNode root = OBJECT_MAPPER.reader() + .with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS).readTree(response); + if (!isValidEnvelope(root)) { + return invalidResponse(); } - JsonNode error = root.get("error"); - if (error != null && !error.isNull()) { - String code = error.has("code") ? " " + error.get("code").asText() : ""; - String message = error.has("message") ? error.get("message").asText() : "Unknown error"; - return new IpcResponse("Error" + code + ": " + message, false); + JsonNode id = root.get("id"); + if (expectedId != null && (!id.isIntegralNumber() || !id.canConvertToInt() + || id.intValue() != expectedId)) { + return new IpcResponse("IPC response ID does not match request.", false); } - if (root.has("result")) { - return new IpcResponse(formatJsonValue(root.get("result")), true); + if (root.has("error")) { + return parseError(root.get("error")); } - return new IpcResponse(formatJsonValue(root), false); + // only output column "result" of jsonrpc and ignore other columns + return new IpcResponse(formatJsonValue(root.get("result")), true); } catch (JsonProcessingException e) { - return new IpcResponse(response, false); + return invalidResponse(); + } + } + + private static boolean isValidEnvelope(JsonNode root) { + if (root == null || !root.isObject() + || !"2.0".equals(root.path("jsonrpc").textValue()) + || !root.has("id") || root.has("result") == root.has("error")) { + return false; } + JsonNode id = root.get("id"); + return id.isTextual() || id.isNumber() || id.isNull(); + } + + private static IpcResponse parseError(JsonNode error) { + if (!error.isObject() || !error.path("code").isIntegralNumber() + || !error.path("message").isTextual()) { + return invalidResponse(); + } + return new IpcResponse("Error " + error.get("code").asText() + + ": " + error.get("message").textValue(), false); + } + + private static IpcResponse invalidResponse() { + return new IpcResponse("Invalid IPC response.", false); } private static String formatJsonValue(JsonNode value) throws JsonProcessingException { diff --git a/framework/src/test/java/org/tron/core/services/admin/ipc/client/IpcClientTest.java b/framework/src/test/java/org/tron/core/services/admin/ipc/client/IpcClientTest.java index 16bcfde02aa..007699dc9a3 100644 --- a/framework/src/test/java/org/tron/core/services/admin/ipc/client/IpcClientTest.java +++ b/framework/src/test/java/org/tron/core/services/admin/ipc/client/IpcClientTest.java @@ -16,6 +16,7 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.Arrays; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; @@ -117,7 +118,19 @@ public void testExecInvalidCommandsFailWithoutSendingRequest() throws Exception String.join(System.lineSeparator(), "Available commands:", " admin_example ", " help [command]", " exit/quit", ""), - "Invalid cmd: unknown" + System.lineSeparator()); + "Invalid command." + System.lineSeparator()); + } + + @Test(timeout = 10_000) + public void testExecLongUnknownCommandDoesNotEchoInputOrSendRequest() throws Exception { + char[] command = new char[100_000]; + Arrays.fill(command, 'x'); + + assertLocalExec(new String(command), IpcClient.EXIT_FAILURE, + String.join(System.lineSeparator(), "Available commands:", + " admin_example ", + " help [command]", " exit/quit", ""), + "Invalid command." + System.lineSeparator()); } @Test @@ -167,6 +180,52 @@ public void testExecReturnsFailureForRpcError() throws Exception { errorOutput.toString("UTF-8")); } + @Test + public void testExecRejectsInvalidResponses() throws Exception { + String valid = "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":true}"; + for (String response : new String[] {"{\"result\":true}", + "{\"id\":1,\"result\":true}", "{\"jsonrpc\":\"2.0\",\"result\":true}", + "{\"jsonrpc\":\"1.0\",\"id\":1,\"result\":true}", + "{\"jsonrpc\":2.0,\"id\":1,\"result\":true}", + "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":true,\"error\":null}", + "{\"jsonrpc\":\"2.0\",\"id\":1}", + "{\"jsonrpc\":\"2.0\",\"id\":1,\"error\":{}}", + "{broken-sensitive-detail", "[]", valid + " garbage", valid + " " + valid}) { + assertExecResponse(new IpcClient("unused"), 1, response, IpcClient.EXIT_FAILURE, "", + "Invalid IPC response." + System.lineSeparator()); + } + } + + @Test + public void testExecRejectsMismatchedResponseIds() throws Exception { + for (String id : new String[] {"2", "\"1\"", "null", "4294967297", "1.5"}) { + String response = "{\"jsonrpc\":\"2.0\",\"id\":" + id + ",\"result\":true}"; + assertExecResponse(new IpcClient("unused"), 1, response, IpcClient.EXIT_FAILURE, "", + "IPC response ID does not match request." + System.lineSeparator()); + } + } + + @Test + public void testExecAcceptsNullAndFalseResults() throws Exception { + for (String value : new String[] {"null", "false"}) { + String response = "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":" + value + "}"; + assertExecResponse(new IpcClient("unused"), 1, response, IpcClient.EXIT_SUCCESS, + value + System.lineSeparator(), ""); + } + } + + @Test + public void testExecUsesEachCommandsRequestId() throws Exception { + IpcClient client = new IpcClient("unused"); + assertExecResponse(client, 1, "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":true}", + IpcClient.EXIT_SUCCESS, "true" + System.lineSeparator(), ""); + String secondResponse = "{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":true}"; + assertExecResponse(client, 2, secondResponse, + IpcClient.EXIT_SUCCESS, "true" + System.lineSeparator(), ""); + assertExecResponse(client, 3, secondResponse, IpcClient.EXIT_FAILURE, "", + "IPC response ID does not match request." + System.lineSeparator()); + } + @Test public void testExecReturnsFailureWhenServerDisconnects() throws Exception { Socket socket = Mockito.mock(Socket.class); @@ -294,9 +353,12 @@ public void testSessionPrintsResponseAndExitsWhenServerDisconnects() throws Exce Assert.assertTrue("IPC client did not start reading terminal input", inputStarted.await(5, TimeUnit.SECONDS)); - serverConnection.getOutputStream().write("\nresponse\n\n".getBytes(StandardCharsets.UTF_8)); + String responses = "\ninvalid-response\n" + + "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":\"response\"}\n\n"; + serverConnection.getOutputStream().write(responses.getBytes(StandardCharsets.UTF_8)); serverConnection.getOutputStream().flush(); Mockito.verify(reader, Mockito.timeout(5_000)).printAbove("response"); + Mockito.verify(reader).printAbove("Invalid IPC response."); serverConnection.close(); sessionThread.join(5_000); @@ -358,6 +420,34 @@ public void testSessionDoesNotSwallowUnexpectedRuntimeException() throws Excepti } } + private void assertExecResponse(IpcClient client, int requestId, String response, int exitCode, + String output, String error) throws Exception { + Socket socket = Mockito.mock(Socket.class); + ByteArrayOutputStream requestOutput = new ByteArrayOutputStream(); + Mockito.when(socket.getOutputStream()).thenReturn(requestOutput); + Mockito.when(socket.getInputStream()).thenReturn(new ByteArrayInputStream( + (response + "\n").getBytes(StandardCharsets.UTF_8))); + PrintStream originalOut = System.out; + PrintStream originalErr = System.err; + ByteArrayOutputStream consoleOutput = new ByteArrayOutputStream(); + ByteArrayOutputStream errorOutput = new ByteArrayOutputStream(); + try (PrintStream capturedOut = new PrintStream(consoleOutput, true, "UTF-8"); + PrintStream capturedErr = new PrintStream(errorOutput, true, "UTF-8")) { + System.setOut(capturedOut); + System.setErr(capturedErr); + Assert.assertEquals(response, exitCode, client.runExec(socket, "admin_example a b")); + } finally { + System.setOut(originalOut); + System.setErr(originalErr); + } + JsonNode request = OBJECT_MAPPER.readTree(requestOutput.toByteArray()); + Assert.assertEquals(requestId, request.get("id").asInt()); + Assert.assertEquals("admin_example", request.get("method").asText()); + Assert.assertEquals(response, output, consoleOutput.toString("UTF-8")); + Assert.assertEquals(response, error, errorOutput.toString("UTF-8")); + Mockito.verify(socket).setSoTimeout(30_000); + } + private void assertLocalExec(String input, int exitCode, String output, String error) throws Exception { Socket socket = Mockito.mock(Socket.class); diff --git a/framework/src/test/java/org/tron/core/services/admin/ipc/client/IpcConsoleCommandsTest.java b/framework/src/test/java/org/tron/core/services/admin/ipc/client/IpcConsoleCommandsTest.java index 750a160731f..d8f51ed95a5 100644 --- a/framework/src/test/java/org/tron/core/services/admin/ipc/client/IpcConsoleCommandsTest.java +++ b/framework/src/test/java/org/tron/core/services/admin/ipc/client/IpcConsoleCommandsTest.java @@ -23,6 +23,7 @@ public void testHelpUsesAnnotatedParameters() { Assert.assertEquals(Action.HELP, help.getAction()); Assert.assertNull(help.getRequest()); + Assert.assertNull(help.getRequestId()); Assert.assertNull(help.getError()); Assert.assertEquals(String.join(System.lineSeparator(), "Available commands:", " admin_example ", @@ -93,12 +94,14 @@ public void testLocalCommandsHaveNoRequest() { Command command = commands.prepare(input); Assert.assertEquals(Action.EMPTY, command.getAction()); Assert.assertNull(command.getRequest()); + Assert.assertNull(command.getRequestId()); Assert.assertNull(command.getError()); } for (String input : new String[] {"exit", "QUIT", "Exit ignored"}) { Command command = commands.prepare(input); Assert.assertEquals(Action.EXIT, command.getAction()); Assert.assertNull(command.getRequest()); + Assert.assertNull(command.getRequestId()); Assert.assertNull(command.getOutput()); Assert.assertNull(command.getError()); } @@ -112,7 +115,7 @@ public void testInvalidSyntaxDoesNotExposeArguments() { @Test public void testUnknownCommandIncludesHelpButArityErrorOnlyIncludesUsage() { Command unknown = commands.prepare("unknown secret"); - assertError(unknown, "Invalid cmd: unknown"); + assertError(unknown, "Invalid command."); Assert.assertEquals(commands.prepare("help").getOutput(), unknown.getOutput()); Command wrongArity = commands.prepare("admin_example secret"); @@ -175,12 +178,17 @@ private JsonNode request(Command command) throws Exception { Assert.assertNull(command.getError()); Assert.assertNull(command.getOutput()); Assert.assertNotNull(command.getRequest()); - return OBJECT_MAPPER.readTree(command.getRequest()); + JsonNode request = OBJECT_MAPPER.readTree(command.getRequest()); + Assert.assertNotNull(command.getRequestId()); + Assert.assertTrue(request.get("id").isIntegralNumber()); + Assert.assertEquals(command.getRequestId().intValue(), request.get("id").intValue()); + return request; } private void assertError(Command command, String error) { Assert.assertEquals(Action.ERROR, command.getAction()); Assert.assertNull(command.getRequest()); + Assert.assertNull(command.getRequestId()); Assert.assertEquals(error, command.getError()); } diff --git a/framework/src/test/java/org/tron/core/services/admin/ipc/client/IpcResponseTest.java b/framework/src/test/java/org/tron/core/services/admin/ipc/client/IpcResponseTest.java index 7eda23e5740..b2d6c60b5fb 100644 --- a/framework/src/test/java/org/tron/core/services/admin/ipc/client/IpcResponseTest.java +++ b/framework/src/test/java/org/tron/core/services/admin/ipc/client/IpcResponseTest.java @@ -5,6 +5,8 @@ public class IpcResponseTest { + private static final String RESPONSE_PREFIX = "{\"jsonrpc\":\"2.0\",\"id\":1,"; + @Test public void testTextResultIsUnquoted() { assertResponse("{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":\"done\"}", "done", true); @@ -22,33 +24,96 @@ public void testStructuredResultIsPrettyPrinted() { @Test public void testNullAndScalarResultsAreSuccessful() { - assertResponse("{\"result\":null}", "null", true); - assertResponse("{\"result\":false}", "false", true); - assertResponse("{\"result\":42,\"error\":null}", "42", true); + assertResponse(RESPONSE_PREFIX + "\"result\":null}", "null", true); + assertResponse(RESPONSE_PREFIX + "\"result\":false}", "false", true); + assertResponse(RESPONSE_PREFIX + "\"result\":42}", "42", true); } @Test - public void testErrorTakesPrecedenceOverResult() { - assertResponse("{\"result\":\"ignored\"," - + "\"error\":{\"code\":-32602,\"message\":\"Invalid params\"}}", + public void testValidRpcErrorsAreFormattedAndFail() { + assertResponse(RESPONSE_PREFIX + + "\"error\":{\"code\":-32602,\"message\":\"Invalid params\",\"data\":{}}}", "Error -32602: Invalid params", false); - assertResponse("{\"error\":{\"message\":\"Failed\"}}", "Error: Failed", false); - assertResponse("{\"error\":{}}", "Error: Unknown error", false); + assertResponse("{\"jsonrpc\":\"2.0\",\"id\":null," + + "\"error\":{\"code\":-32700,\"message\":\"Parse error\"}}", + "Error -32700: Parse error", false); } @Test - public void testMalformedResponsePreservesTextAndFails() { + public void testMalformedResponseUsesFixedMessageAndFails() { for (String input : new String[] {"", " ", "response", "{broken json"}) { - assertResponse(input, input, false); + assertInvalidResponse(input); + } + } + + @Test + public void testInvalidEnvelopeIsRejected() { + for (String input : new String[] {"null", "[]", "true", "\"message\"", "{\"result\":true}", + "{\"id\":1,\"result\":true}", "{\"jsonrpc\":\"2.0\",\"result\":true}", + "{\"jsonrpc\":\"1.0\",\"id\":1,\"result\":true}", + "{\"jsonrpc\":2.0,\"id\":1,\"result\":true}", + "{\"jsonrpc\":\"2.0\",\"id\":1}", + RESPONSE_PREFIX + "\"result\":true,\"error\":null}", + RESPONSE_PREFIX + "\"result\":null,\"error\":{\"code\":1,\"message\":\"error\"}}"}) { + assertInvalidResponse(input); + } + for (String id : new String[] {"true", "{}", "[]"}) { + assertInvalidResponse("{\"jsonrpc\":\"2.0\",\"id\":" + id + ",\"result\":true}"); + } + } + + @Test + public void testErrorRequiresIntegerCodeAndStringMessage() { + for (String error : new String[] {"null", "false", "[]", "\"error\"", "{}", + "{\"code\":1}", "{\"message\":\"Failed\"}", + "{\"code\":\"1\",\"message\":\"Failed\"}", + "{\"code\":1.5,\"message\":\"Failed\"}", + "{\"code\":1,\"message\":null}", "{\"code\":1,\"message\":7}"}) { + assertInvalidResponse(RESPONSE_PREFIX + "\"error\":" + error + "}"); } } @Test - public void testResponseWithoutResultFails() { - assertResponse("null", "null", false); - assertResponse("\"message\"", "message", false); - assertResponse("{\"id\":1}", String.join(System.lineSeparator(), - "{", " \"id\" : 1", "}"), false); + public void testTrailingContentIsRejected() { + String valid = RESPONSE_PREFIX + "\"result\":true}"; + assertResponse(valid + " \t\r\n", "true", true); + for (String suffix : new String[] {" garbage", " {}", " null", " " + valid}) { + assertInvalidResponse(valid + suffix); + } + } + + @Test + public void testExpectedIdMustMatchWithoutCoercion() { + for (String id : new String[] {"2", "\"1\"", "null", "4294967297", "1.5"}) { + IpcResponse response = IpcResponse.parse( + "{\"jsonrpc\":\"2.0\",\"id\":" + id + ",\"result\":true}", 1); + Assert.assertFalse(response.isSuccessful()); + Assert.assertEquals("IPC response ID does not match request.", response.getFormatted()); + } + IpcResponse matching = IpcResponse.parse( + "{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":null}", 2); + Assert.assertTrue(matching.isSuccessful()); + Assert.assertEquals("null", matching.getFormatted()); + assertResponse("{\"jsonrpc\":\"2.0\",\"id\":\"one\",\"result\":true}", "true", true); + } + + @Test + public void testErrorIdMustAlsoMatchRequest() { + String error = "{\"jsonrpc\":\"2.0\",\"id\":2," + + "\"error\":{\"code\":-32602,\"message\":\"Invalid params\"}}"; + IpcResponse mismatched = IpcResponse.parse(error, 1); + Assert.assertFalse(mismatched.isSuccessful()); + Assert.assertEquals("IPC response ID does not match request.", mismatched.getFormatted()); + IpcResponse matching = IpcResponse.parse(error, 2); + Assert.assertFalse(matching.isSuccessful()); + Assert.assertEquals("Error -32602: Invalid params", matching.getFormatted()); + } + + private void assertInvalidResponse(String input) { + assertResponse(input, "Invalid IPC response.", false); + IpcResponse response = IpcResponse.parse(input, 1); + Assert.assertFalse(response.isSuccessful()); + Assert.assertEquals("Invalid IPC response.", response.getFormatted()); } private void assertResponse(String input, String formatted, boolean successful) { From f2ce52e6720b85dca15d80247458af8fee297cd3 Mon Sep 17 00:00:00 2001 From: 317787106 <317787106@qq.com> Date: Fri, 25 Sep 2026 23:55:39 +0800 Subject: [PATCH 09/19] remove length comment of socketDirectory --- framework/src/main/resources/config.conf | 1 - 1 file changed, 1 deletion(-) diff --git a/framework/src/main/resources/config.conf b/framework/src/main/resources/config.conf index 8eef0fd7958..74c64418309 100644 --- a/framework/src/main/resources/config.conf +++ b/framework/src/main/resources/config.conf @@ -171,7 +171,6 @@ node { enable = false # Existing absolute parent directory; empty uses output-directory. # The ipc subdirectory must not exist at startup. - # Full socket path limit: 100 encoded bytes. socketDirectory = "" } } From 448d1c857a907f45e5991eae117078e8b3f0c322 Mon Sep 17 00:00:00 2001 From: 317787106 <317787106@qq.com> Date: Tue, 29 Sep 2026 17:05:31 +0800 Subject: [PATCH 10/19] extract AdminJsonRpcRequestHandler for http and ipc --- .../admin/AdminJsonRpcRequestHandler.java | 118 ++++++++ .../services/admin/http/AdminRpcServlet.java | 58 +--- .../admin/ipc/server/IpcRequestHandler.java | 62 +--- .../ipc/server/AdminJsonRpcRequestTest.java | 267 ++++++++++++++++++ .../ipc/server/IpcRequestHandlerTest.java | 28 -- 5 files changed, 396 insertions(+), 137 deletions(-) create mode 100644 framework/src/main/java/org/tron/core/services/admin/AdminJsonRpcRequestHandler.java create mode 100644 framework/src/test/java/org/tron/core/services/admin/ipc/server/AdminJsonRpcRequestTest.java diff --git a/framework/src/main/java/org/tron/core/services/admin/AdminJsonRpcRequestHandler.java b/framework/src/main/java/org/tron/core/services/admin/AdminJsonRpcRequestHandler.java new file mode 100644 index 00000000000..07a23f31ff9 --- /dev/null +++ b/framework/src/main/java/org/tron/core/services/admin/AdminJsonRpcRequestHandler.java @@ -0,0 +1,118 @@ +package org.tron.core.services.admin; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.googlecode.jsonrpc4j.ErrorResolver.JsonError; +import com.googlecode.jsonrpc4j.JsonRpcBasicServer; +import com.googlecode.jsonrpc4j.JsonRpcInterceptor; +import java.io.IOException; +import java.math.BigDecimal; +import lombok.extern.slf4j.Slf4j; +import org.tron.core.services.jsonrpc.JsonRpcErrorResolver; +import org.tron.core.services.jsonrpc.JsonRpcMapper; + +/** + * Validates and dispatches individual Admin JSON-RPC requests for both HTTP and IPC. + * Numeric IDs must be within the signed 64-bit range; accepted IDs retain their JSON value. + * Only requests without an ID member are notifications, including when invocation fails. + */ +@Slf4j(topic = "API") +public final class AdminJsonRpcRequestHandler extends JsonRpcBasicServer { + + private static final ObjectMapper OBJECT_MAPPER = JsonRpcMapper.create(); + private static final BigDecimal MIN_ID = BigDecimal.valueOf(Long.MIN_VALUE); + private static final BigDecimal MAX_ID = BigDecimal.valueOf(Long.MAX_VALUE); + + public AdminJsonRpcRequestHandler(AdminJsonRpc adminJsonRpc) { + super(OBJECT_MAPPER, adminJsonRpc, AdminJsonRpc.class); + setErrorResolver(JsonRpcErrorResolver.INSTANCE); + setShouldLogInvocationErrors(false); + } + + /** Parses the complete input before dispatch, returning an empty body for valid notifications. */ + public byte[] handleRequest(byte[] body) throws IOException { + JsonNode request; + try { + request = OBJECT_MAPPER.reader() + .with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS) + .with(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS).readTree(body); + } catch (JsonProcessingException e) { + return OBJECT_MAPPER.writeValueAsBytes(errorResponse(JsonError.PARSE_ERROR, null)); + } + + JsonNode response = check(request); + if (response == null) { + response = dispatch((ObjectNode) request, request.get("id")); + } + return response == null ? new byte[0] : OBJECT_MAPPER.writeValueAsBytes(response); + } + + /** Returns an error response for invalid input, or null when the request can be dispatched. */ + private JsonNode check(JsonNode request) { + if (request == null || request.isMissingNode()) { + return errorResponse(JsonError.PARSE_ERROR, null); + } + if (request.isArray()) { + return JsonRpcMapper.createErrorResponse( + JsonError.INVALID_REQUEST.code, AdminJsonRpc.BATCH_NOT_SUPPORTED_MESSAGE, null); + } + JsonNode id = request.get("id"); + if (!isValidId(id)) { + return errorResponse(JsonError.INVALID_REQUEST, null); + } + if (!isValidEnvelope(request)) { + return errorResponse(JsonError.INVALID_REQUEST, id); + } + return null; + } + + private boolean isValidEnvelope(JsonNode request) { + JsonNode version = request.path("jsonrpc"); + JsonNode params = request.get("params"); + return request.isObject() && version.isTextual() && "2.0".equals(version.textValue()) + && request.path("method").isTextual() + && (params == null || params.isArray() || params.isObject()); + } + + private boolean isValidId(JsonNode id) { + if (id == null || id.isNull() || id.isTextual()) { + return true; + } + return id.isNumber() && id.decimalValue().compareTo(MIN_ID) >= 0 + && id.decimalValue().compareTo(MAX_ID) <= 0; + } + + private JsonNode dispatch(ObjectNode request, JsonNode id) { + boolean notification = !request.has("id"); + try { + for (JsonRpcInterceptor interceptor : getInterceptorList()) { + interceptor.preHandleJson(request); + } + // jsonrpc4j treats null IDs as notifications. Use a request-local ID during invocation, + // then restore the original value directly on the response without parsing it again. + if (id != null && id.isNull()) { + request.put("id", 0); + } + JsonNode response = super.handleJsonNodeRequest(request).getResponse(); + if (notification) { + return null; + } + ((ObjectNode) response).set("id", id); + return response; + } catch (Exception e) { + logger.debug("Failed to dispatch Admin request"); + return notification ? null : JsonRpcMapper.createErrorResponse(-32603, "Internal error", id); + } finally { + if (id != null && id.isNull()) { + request.set("id", id); + } + } + } + + private JsonNode errorResponse(JsonError error, JsonNode id) { + return JsonRpcMapper.createErrorResponse(error.code, error.message, id); + } +} diff --git a/framework/src/main/java/org/tron/core/services/admin/http/AdminRpcServlet.java b/framework/src/main/java/org/tron/core/services/admin/http/AdminRpcServlet.java index 8165dfb2d2c..2b1c510143d 100644 --- a/framework/src/main/java/org/tron/core/services/admin/http/AdminRpcServlet.java +++ b/framework/src/main/java/org/tron/core/services/admin/http/AdminRpcServlet.java @@ -1,17 +1,8 @@ package org.tron.core.services.admin.http; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.DeserializationFeature; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; import com.google.common.io.ByteStreams; -import com.googlecode.jsonrpc4j.ErrorResolver.JsonError; import com.googlecode.jsonrpc4j.JsonRpcInterceptor; -import com.googlecode.jsonrpc4j.JsonRpcServer; -import com.googlecode.jsonrpc4j.ProxyUtil; import io.prometheus.client.Histogram; -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; import java.io.IOException; import java.util.Collections; import javax.servlet.ServletConfig; @@ -26,7 +17,7 @@ import org.tron.common.prometheus.MetricKeys; import org.tron.common.prometheus.Metrics; import org.tron.core.services.admin.AdminJsonRpc; -import org.tron.core.services.jsonrpc.JsonRpcErrorResolver; +import org.tron.core.services.admin.AdminJsonRpcRequestHandler; import org.tron.core.services.jsonrpc.JsonRpcMapper; import org.tron.core.services.jsonrpc.JsonRpcMediaType; @@ -46,9 +37,7 @@ public class AdminRpcServlet extends HttpServlet { private static final long serialVersionUID = 0L; - private static final ObjectMapper OBJECT_MAPPER = JsonRpcMapper.create(); - - private JsonRpcServer rpcServer = null; + private AdminJsonRpcRequestHandler requestHandler; private VirtualHostValidator virtualHostValidator = new VirtualHostValidator(Collections.emptyList()); @@ -65,20 +54,9 @@ public class AdminRpcServlet extends HttpServlet { public void init(ServletConfig config) throws ServletException { super.init(config); - ClassLoader cl = Thread.currentThread().getContextClassLoader(); - // Expose the annotated Admin interface through the same proxy mechanism as public JSON-RPC. - Object compositeService = ProxyUtil.createCompositeServiceProxy(cl, - new Object[] {adminJsonRpc}, - new Class[] {AdminJsonRpc.class}, - true); - - // Keep parser constraints and annotation-based error mapping consistent with the IPC transport. - rpcServer = new JsonRpcServer(OBJECT_MAPPER, compositeService); - rpcServer.setErrorResolver(JsonRpcErrorResolver.INSTANCE); - - rpcServer.setShouldLogInvocationErrors(false); + requestHandler = new AdminJsonRpcRequestHandler(adminJsonRpc); if (CommonParameter.getInstance().isMetricsPrometheusEnable()) { - rpcServer.setInterceptorList(Collections.singletonList(interceptor)); + requestHandler.setInterceptorList(Collections.singletonList(interceptor)); } virtualHostValidator = new VirtualHostValidator( CommonParameter.getInstance().getAdminHttpVirtualHosts()); @@ -114,33 +92,7 @@ protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws I return; } byte[] body = ByteStreams.toByteArray(req.getInputStream()); - JsonNode request; - try { - request = OBJECT_MAPPER.reader() - .with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS).readTree(body); - if (request == null || request.isMissingNode()) { - writeErrorResponse(resp, JsonError.PARSE_ERROR.code, JsonError.PARSE_ERROR.message); - return; - } - } catch (JsonProcessingException e) { - writeErrorResponse(resp, JsonError.PARSE_ERROR.code, JsonError.PARSE_ERROR.message); - return; - } - if (request.isArray()) { - writeErrorResponse(resp, JsonError.INVALID_REQUEST.code, - AdminJsonRpc.BATCH_NOT_SUPPORTED_MESSAGE); - return; - } - ByteArrayOutputStream output = new ByteArrayOutputStream(); - rpcServer.handleRequest(new ByteArrayInputStream(body), output); - writeResponse(resp, output.toByteArray()); - } - - private void writeErrorResponse(HttpServletResponse resp, int code, String message) - throws IOException { - byte[] body = OBJECT_MAPPER.writeValueAsBytes( - JsonRpcMapper.createErrorResponse(code, message, null)); - writeResponse(resp, body); + writeResponse(resp, requestHandler.handleRequest(body)); } private void writeResponse(HttpServletResponse resp, byte[] body) throws IOException { diff --git a/framework/src/main/java/org/tron/core/services/admin/ipc/server/IpcRequestHandler.java b/framework/src/main/java/org/tron/core/services/admin/ipc/server/IpcRequestHandler.java index 2bd065b16f4..ba2a0822e54 100644 --- a/framework/src/main/java/org/tron/core/services/admin/ipc/server/IpcRequestHandler.java +++ b/framework/src/main/java/org/tron/core/services/admin/ipc/server/IpcRequestHandler.java @@ -1,22 +1,13 @@ package org.tron.core.services.admin.ipc.server; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.DeserializationFeature; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.googlecode.jsonrpc4j.ErrorResolver.JsonError; -import com.googlecode.jsonrpc4j.JsonRpcServer; -import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; import lombok.AccessLevel; import lombok.Getter; -import lombok.extern.slf4j.Slf4j; import org.tron.core.services.admin.AdminJsonRpc; -import org.tron.core.services.jsonrpc.JsonRpcErrorResolver; -import org.tron.core.services.jsonrpc.JsonRpcMapper; +import org.tron.core.services.admin.AdminJsonRpcRequestHandler; /** * Reads bounded IPC request lines and dispatches Admin JSON-RPC calls. @@ -24,20 +15,15 @@ * Per-request buffers are local so one handler can serve concurrent client connections. * Stream and socket ownership remains with {@link IpcService}. */ -@Slf4j(topic = "API") final class IpcRequestHandler { - private static final ObjectMapper OBJECT_MAPPER = JsonRpcMapper.create(); - - private final JsonRpcServer jsonRpcServer; + private final AdminJsonRpcRequestHandler requestHandler; @Getter(AccessLevel.PACKAGE) private final long maxRequestSize; IpcRequestHandler(AdminJsonRpc adminJsonRpc, long maxRequestSize) { this.maxRequestSize = maxRequestSize; - jsonRpcServer = new JsonRpcServer(OBJECT_MAPPER, adminJsonRpc, AdminJsonRpc.class); - jsonRpcServer.setErrorResolver(JsonRpcErrorResolver.INSTANCE); - jsonRpcServer.setShouldLogInvocationErrors(false); + requestHandler = new AdminJsonRpcRequestHandler(adminJsonRpc); } String readRequest(InputStream input) throws IOException { @@ -63,45 +49,9 @@ String readRequest(InputStream input) throws IOException { return new String(bytes, 0, length, StandardCharsets.UTF_8); } - String handleCommand(String jsonRequest) { - JsonNode request; - try { - request = OBJECT_MAPPER.reader() - .with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS).readTree(jsonRequest); - if (request == null || request.isMissingNode()) { - return buildErrorResponse(JsonError.PARSE_ERROR.code, JsonError.PARSE_ERROR.message, null); - } - } catch (JsonProcessingException e) { - return buildErrorResponse(JsonError.PARSE_ERROR.code, JsonError.PARSE_ERROR.message, null); - } - if (request.isArray()) { - return buildErrorResponse( - JsonError.INVALID_REQUEST.code, AdminJsonRpc.BATCH_NOT_SUPPORTED_MESSAGE, null); - } - ByteArrayInputStream input = - new ByteArrayInputStream(jsonRequest.getBytes(StandardCharsets.UTF_8)); - ByteArrayOutputStream output = new ByteArrayOutputStream(); - - try { - jsonRpcServer.handleRequest(input, output); - if (output.size() == 0) { - return ""; - } - JsonNode response = OBJECT_MAPPER.readTree(output.toByteArray()); - return response == null ? "" : OBJECT_MAPPER.writeValueAsString(response); - } catch (Exception e) { - logger.debug("Failed to dispatch IPC request"); - return buildErrorResponse(-32603, "Internal error", request.get("id")); - } - } - - private String buildErrorResponse(int code, String message, JsonNode requestId) { - try { - return OBJECT_MAPPER.writeValueAsString( - JsonRpcMapper.createErrorResponse(code, message, requestId)); - } catch (IOException e) { - throw new IllegalStateException("Failed to serialize IPC error response", e); - } + String handleCommand(String jsonRequest) throws IOException { + return new String(requestHandler.handleRequest(jsonRequest.getBytes(StandardCharsets.UTF_8)), + StandardCharsets.UTF_8); } static final class RequestTooLargeException extends IOException { diff --git a/framework/src/test/java/org/tron/core/services/admin/ipc/server/AdminJsonRpcRequestTest.java b/framework/src/test/java/org/tron/core/services/admin/ipc/server/AdminJsonRpcRequestTest.java new file mode 100644 index 00000000000..f7152f78531 --- /dev/null +++ b/framework/src/test/java/org/tron/core/services/admin/ipc/server/AdminJsonRpcRequestTest.java @@ -0,0 +1,267 @@ +package org.tron.core.services.admin.ipc.server; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.googlecode.jsonrpc4j.JsonRpcInterceptor; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameters; +import org.mockito.Mockito; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.mock.web.MockServletConfig; +import org.springframework.test.util.ReflectionTestUtils; +import org.tron.core.exception.jsonrpc.JsonRpcInvalidParamsException; +import org.tron.core.services.admin.AdminJsonRpc; +import org.tron.core.services.admin.AdminJsonRpcRequestHandler; +import org.tron.core.services.admin.http.AdminRpcServlet; +import org.tron.core.services.filter.HttpInterceptor; +import org.tron.core.services.jsonrpc.JsonRpcMediaType; + +/** Runs identical protocol assertions through the HTTP and IPC entry points. */ +@RunWith(Parameterized.class) +public class AdminJsonRpcRequestTest { + + private static final ObjectMapper MAPPER = new ObjectMapper() + .enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS); + private static final String REQUEST = "{\"jsonrpc\":\"2.0\",\"method\":\"admin_example\"," + + "\"params\":[\"a\",\"b\"]"; + + private final boolean http; + private AdminJsonRpc adminJsonRpc; + private RequestSender sender; + private AdminJsonRpcRequestHandler requestHandler; + + public AdminJsonRpcRequestTest(String transport) { + http = "HTTP".equals(transport); + } + + @Parameters(name = "{0}") + public static Collection transports() { + return Arrays.asList(new Object[][] {{"HTTP"}, {"IPC"}}); + } + + @Before + public void setUp() throws Exception { + adminJsonRpc = Mockito.mock(AdminJsonRpc.class); + Mockito.when(adminJsonRpc.adminExample("a", "b")).thenReturn("a:b"); + if (http) { + AdminRpcServlet servlet = new AdminRpcServlet(); + ReflectionTestUtils.setField(servlet, "adminJsonRpc", adminJsonRpc); + servlet.init(new MockServletConfig()); + sender = body -> { + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/admin"); + request.setServletPath("/admin"); + request.setContentType("application/json"); + request.addHeader("Host", "127.0.0.1:8575"); + request.setContent(body.getBytes(StandardCharsets.UTF_8)); + MockHttpServletResponse response = new MockHttpServletResponse(); + new HttpInterceptor().doFilter(request, response, servlet::service); + Assert.assertEquals(200, response.getStatus()); + Assert.assertTrue(JsonRpcMediaType.isSupported(response.getContentType())); + Assert.assertEquals("UTF-8", response.getCharacterEncoding()); + Assert.assertEquals(response.getContentAsByteArray().length, response.getContentLength()); + return response.getContentAsString(); + }; + requestHandler = (AdminJsonRpcRequestHandler) + ReflectionTestUtils.getField(servlet, "requestHandler"); + } else { + IpcRequestHandler handler = new IpcRequestHandler(adminJsonRpc, 4194304); + sender = handler::handleCommand; + requestHandler = (AdminJsonRpcRequestHandler) + ReflectionTestUtils.getField(handler, "requestHandler"); + } + } + + @Test + public void invalidIdTypesAreRejectedBeforeInvocation() throws Exception { + for (String id : new String[] {"true", "false", "{}", "[]"}) { + assertError(sender.send(REQUEST + ",\"id\":" + id + "}"), -32600, "null"); + } + Mockito.verifyNoInteractions(adminJsonRpc); + } + + @Test + public void outOfRangeNumericIdsAreRejectedBeforeInvocation() throws Exception { + for (String id : new String[] {"9223372036854775808", "-9223372036854775809", + "18446744073709551617", "9223372036854775807.1", "-9223372036854775808.1", + "1e40", "-1e40"}) { + assertError(sender.send(REQUEST + ",\"id\":" + id + "}"), -32600, "null"); + } + Mockito.verifyNoInteractions(adminJsonRpc); + } + + @Test + public void acceptedIdsRetainTheirValueAndType() throws Exception { + for (String id : new String[] {"null", "\"null\"", "\"\"", "\"请求-7\"", "7", "0", + "9223372036854775807", "-9223372036854775808", "9007199254740993", + "0.12345678901234567890123456789", "9223372036854775807.0", "-0.5", "1e-20"}) { + JsonNode response = parse(sender.send(REQUEST + ",\"id\":" + id + "}")); + assertId(id, response.get("id")); + Assert.assertEquals("a:b", response.path("result").asText()); + Assert.assertFalse(response.has("error")); + Mockito.verify(adminJsonRpc).adminExample("a", "b"); + Mockito.clearInvocations(adminJsonRpc); + } + } + + @Test + public void invalidVersionsAreRejectedBeforeInvocation() throws Exception { + for (String version : new String[] {"\"3.0\"", "2.0", "null", "true", "{}", "[]"}) { + assertError(sender.send(REQUEST.replace("\"2.0\"", version) + ",\"id\":7}"), + -32600, "7"); + } + assertError(sender.send("{\"method\":\"admin_example\",\"params\":[\"a\",\"b\"],\"id\":7}"), + -32600, "7"); + Mockito.verifyNoInteractions(adminJsonRpc); + } + + @Test + public void invalidMethodsAreRejectedBeforeInvocation() throws Exception { + for (String method : new String[] {"7", "null", "true", "{}", "[]"}) { + assertError(sender.send(REQUEST.replace("\"admin_example\"", method) + ",\"id\":7}"), + -32600, "7"); + } + assertError(sender.send("{\"jsonrpc\":\"2.0\",\"id\":7}"), -32600, "7"); + Mockito.verifyNoInteractions(adminJsonRpc); + } + + @Test + public void scalarParamsAreRejectedBeforeInvocation() throws Exception { + for (String params : new String[] {"\"text\"", "null", "7", "true"}) { + assertError(sender.send(REQUEST.replace("[\"a\",\"b\"]", params) + ",\"id\":7}"), + -32600, "7"); + } + Mockito.verifyNoInteractions(adminJsonRpc); + } + + @Test + public void nonObjectInputsReturnJsonNullId() throws Exception { + for (String body : new String[] {"null", "true", "7", "\"text\""}) { + assertError(sender.send(body), -32600, "null"); + } + Mockito.verifyNoInteractions(adminJsonRpc); + } + + @Test + public void invalidRequestsWithoutIdAreNotTreatedAsNotifications() throws Exception { + for (String body : new String[] {"{}", REQUEST.replace("2.0", "3.0") + "}", + REQUEST.replace("[\"a\",\"b\"]", "\"text\"") + "}"}) { + assertError(sender.send(body), -32600, "null"); + } + Mockito.verifyNoInteractions(adminJsonRpc); + } + + @Test + public void successfulNotificationHasNoResponse() throws Exception { + Assert.assertEquals("", sender.send(REQUEST + "}")); + Mockito.verify(adminJsonRpc).adminExample("a", "b"); + } + + @Test + public void unknownMethodNotificationHasNoResponse() throws Exception { + Assert.assertEquals("", sender.send(REQUEST.replace("admin_example", "admin_missing") + "}")); + Mockito.verifyNoInteractions(adminJsonRpc); + } + + @Test + public void invalidMethodParamsNotificationHasNoResponse() throws Exception { + Assert.assertEquals("", sender.send(REQUEST.replace("[\"a\",\"b\"]", "[]") + "}")); + Mockito.verifyNoInteractions(adminJsonRpc); + } + + @Test + public void businessErrorNotificationHasNoResponse() throws Exception { + Mockito.when(adminJsonRpc.adminExample("a", "b")) + .thenThrow(new JsonRpcInvalidParamsException("Invalid admin parameters")); + Assert.assertEquals("", sender.send(REQUEST + "}")); + Mockito.verify(adminJsonRpc).adminExample("a", "b"); + } + + @Test + public void errorResponsesRetainAcceptedIds() throws Exception { + Mockito.when(adminJsonRpc.adminExample("a", "b")) + .thenThrow(new JsonRpcInvalidParamsException("Invalid admin parameters")); + for (String id : new String[] {"null", "\"null\"", "9223372036854775807", + "0.12345678901234567890123456789"}) { + JsonNode response = assertError(sender.send(REQUEST + ",\"id\":" + id + "}"), -32602, id); + Assert.assertEquals("Invalid admin parameters", response.path("error").path("message") + .asText()); + Mockito.verify(adminJsonRpc).adminExample("a", "b"); + Mockito.clearInvocations(adminJsonRpc); + assertError(sender.send(REQUEST.replace("admin_example", "admin_missing") + + ",\"id\":" + id + "}"), -32601, id); + Mockito.verifyNoInteractions(adminJsonRpc); + } + } + + @Test + public void dispatcherFailureReturnsSanitizedErrorAndSilencesNotifications() throws Exception { + JsonRpcInterceptor interceptor = Mockito.mock(JsonRpcInterceptor.class); + Mockito.doThrow(new IllegalStateException("sensitive-detail")) + .when(interceptor).preHandleJson(Mockito.any(JsonNode.class)); + requestHandler.setInterceptorList(Collections.singletonList(interceptor)); + for (String id : new String[] {"9", "null", "\"request\""}) { + String body = sender.send(REQUEST + ",\"id\":" + id + "}"); + JsonNode response = assertError(body, -32603, id); + Assert.assertEquals("Internal error", response.path("error").path("message").asText()); + Assert.assertFalse(body.contains("sensitive-detail")); + } + Assert.assertEquals("", sender.send(REQUEST + "}")); + Mockito.verify(interceptor, Mockito.times(4)).preHandleJson(Mockito.any(JsonNode.class)); + Mockito.verifyNoInteractions(adminJsonRpc); + } + + @Test + public void namedAndOmittedParamsRetainDispatchBehavior() throws Exception { + String named = REQUEST.replace("[\"a\",\"b\"]", "{\"param1\":\"a\",\"param2\":\"b\"}"); + Assert.assertEquals("a:b", parse(sender.send(named + ",\"id\":7}")).path("result").asText()); + Mockito.verify(adminJsonRpc).adminExample("a", "b"); + Mockito.clearInvocations(adminJsonRpc); + assertError(sender.send("{\"jsonrpc\":\"2.0\",\"method\":\"admin_example\",\"id\":7}"), + -32602, "7"); + Mockito.verifyNoInteractions(adminJsonRpc); + } + + private JsonNode assertError(String body, int code, String id) throws Exception { + JsonNode response = parse(body); + Assert.assertEquals(code, response.path("error").path("code").asInt()); + assertId(id, response.get("id")); + Assert.assertFalse(response.has("result")); + return response; + } + + private JsonNode parse(String body) throws Exception { + Assert.assertFalse("Expected a JSON-RPC response", body.isEmpty()); + Assert.assertFalse(body.contains("\n")); + Assert.assertFalse(body.contains("\r")); + JsonNode response = MAPPER.readTree(body); + Assert.assertTrue(response.isObject()); + Assert.assertEquals("2.0", response.path("jsonrpc").asText()); + return response; + } + + private void assertId(String id, JsonNode actual) throws Exception { + JsonNode expected = MAPPER.readTree(id); + Assert.assertNotNull(actual); + if (expected.isNumber()) { + Assert.assertTrue(actual.isNumber()); + Assert.assertEquals(0, expected.decimalValue().compareTo(actual.decimalValue())); + } else { + Assert.assertEquals(expected, actual); + } + } + + @FunctionalInterface + private interface RequestSender { + String send(String body) throws Exception; + } +} diff --git a/framework/src/test/java/org/tron/core/services/admin/ipc/server/IpcRequestHandlerTest.java b/framework/src/test/java/org/tron/core/services/admin/ipc/server/IpcRequestHandlerTest.java index a055e579375..560e6fbf3c0 100644 --- a/framework/src/test/java/org/tron/core/services/admin/ipc/server/IpcRequestHandlerTest.java +++ b/framework/src/test/java/org/tron/core/services/admin/ipc/server/IpcRequestHandlerTest.java @@ -2,16 +2,12 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import com.googlecode.jsonrpc4j.JsonRpcServer; import java.io.ByteArrayInputStream; import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; import java.nio.charset.StandardCharsets; import java.util.Arrays; import org.junit.Assert; import org.junit.Test; -import org.mockito.MockedConstruction; import org.mockito.Mockito; import org.tron.core.Constant; import org.tron.core.exception.jsonrpc.JsonRpcInvalidParamsException; @@ -42,30 +38,6 @@ public void testHandleCommandReturnsSingleLineJsonResponse() throws Exception { Assert.assertEquals(7, result.get("id").asInt()); } - @Test - public void testHandleCommandReturnsJsonRpcErrorOnDispatcherFailure() throws Exception { - try (MockedConstruction servers = Mockito.mockConstruction(JsonRpcServer.class, - (server, context) -> Mockito.doThrow(new IOException("sensitive-detail")) - .when(server).handleRequest(Mockito.any(InputStream.class), - Mockito.any(OutputStream.class)))) { - IpcRequestHandler failingHandler = - new IpcRequestHandler(new AdminJsonRpcImpl(), MAX_REQUEST_SIZE); - String response = failingHandler.handleCommand( - "{\"jsonrpc\":\"2.0\",\"method\":\"admin_example\"," - + "\"params\":[\"a\",\"b\"],\"id\":9}"); - JsonNode responseNode = OBJECT_MAPPER.readTree(response); - - Assert.assertEquals(1, servers.constructed().size()); - Assert.assertEquals("2.0", responseNode.get("jsonrpc").asText()); - Assert.assertEquals(-32603, responseNode.get("error").get("code").asInt()); - Assert.assertEquals("Internal error", responseNode.get("error").get("message").asText()); - Assert.assertEquals(9, responseNode.get("id").asInt()); - Assert.assertFalse(response, response.contains("sensitive-detail")); - Assert.assertFalse(response, response.contains("\n")); - - } - } - @Test public void testHandleCommandUsesAnnotatedErrorResolver() throws Exception { AdminJsonRpc adminJsonRpc = Mockito.mock(AdminJsonRpc.class); From 3e54e1eaeb656e963acb95234a6de188c7ed1c21 Mon Sep 17 00:00:00 2001 From: 317787106 <317787106@qq.com> Date: Sun, 11 Oct 2026 21:50:11 +0800 Subject: [PATCH 11/19] update the package of Constant.LOCAL_HOST --- .../src/main/java/org/tron/common/parameter/CommonParameter.java | 1 + 1 file changed, 1 insertion(+) diff --git a/common/src/main/java/org/tron/common/parameter/CommonParameter.java b/common/src/main/java/org/tron/common/parameter/CommonParameter.java index f1b19cd1bb2..b3513fb7870 100644 --- a/common/src/main/java/org/tron/common/parameter/CommonParameter.java +++ b/common/src/main/java/org/tron/common/parameter/CommonParameter.java @@ -14,6 +14,7 @@ import org.tron.common.logsfilter.EventPluginConfig; import org.tron.common.logsfilter.FilterQuery; import org.tron.common.setting.RocksDbSettings; +import org.tron.core.Constant; import org.tron.core.config.args.SeedNode; import org.tron.core.config.args.Storage; import org.tron.p2p.P2pConfig; From 70f08ffba6f6f4ff14653323ddb2baa8d05982d1 Mon Sep 17 00:00:00 2001 From: 317787106 <317787106@qq.com> Date: Mon, 12 Oct 2026 14:04:09 +0800 Subject: [PATCH 12/19] use maxRequestSize for AdminRpcServlet --- .../admin/http/AdminRpcHttpService.java | 1 + .../services/admin/http/AdminRpcServlet.java | 6 +- .../admin/http/AdminRpcServletTest.java | 101 ++++++++++++++++++ 3 files changed, 105 insertions(+), 3 deletions(-) diff --git a/framework/src/main/java/org/tron/core/services/admin/http/AdminRpcHttpService.java b/framework/src/main/java/org/tron/core/services/admin/http/AdminRpcHttpService.java index f231b7eedf9..2f0e321cd63 100644 --- a/framework/src/main/java/org/tron/core/services/admin/http/AdminRpcHttpService.java +++ b/framework/src/main/java/org/tron/core/services/admin/http/AdminRpcHttpService.java @@ -26,6 +26,7 @@ public AdminRpcHttpService() { listenAddress = Args.getInstance().getAdminHttpListenAddress(); port = Args.getInstance().getAdminHttpListenPort(); contextPath = "/"; + maxRequestSize = Args.getInstance().getJsonRpcMaxMessageSize(); } @Override diff --git a/framework/src/main/java/org/tron/core/services/admin/http/AdminRpcServlet.java b/framework/src/main/java/org/tron/core/services/admin/http/AdminRpcServlet.java index 2b1c510143d..8d44adc836f 100644 --- a/framework/src/main/java/org/tron/core/services/admin/http/AdminRpcServlet.java +++ b/framework/src/main/java/org/tron/core/services/admin/http/AdminRpcServlet.java @@ -28,9 +28,9 @@ * loopback or a controlled management network. This low-frequency management endpoint intentionally * bypasses public API rate limiting and JSON-RPC response-size limits. * Only individual request objects are supported; batch requests are rejected. - * HTTP request-size limits are enforced by - * {@link org.tron.common.application.HttpService}; JSON parser limits come from - * {@link JsonRpcMapper}. + * HTTP request-size limits use {@code node.jsonrpc.maxMessageSize}, shared with Admin IPC and + * public JSON-RPC HTTP, and are enforced by {@link org.tron.common.application.HttpService}; + * JSON parser limits come from {@link JsonRpcMapper}. */ @Component @Slf4j(topic = "API") diff --git a/framework/src/test/java/org/tron/core/services/admin/http/AdminRpcServletTest.java b/framework/src/test/java/org/tron/core/services/admin/http/AdminRpcServletTest.java index ebc366f379e..12072c3d32c 100644 --- a/framework/src/test/java/org/tron/core/services/admin/http/AdminRpcServletTest.java +++ b/framework/src/test/java/org/tron/core/services/admin/http/AdminRpcServletTest.java @@ -13,24 +13,33 @@ import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.google.common.base.Strings; import com.googlecode.jsonrpc4j.JsonRpcInterceptor; import java.io.IOException; import java.lang.reflect.Field; import java.nio.charset.StandardCharsets; import java.util.Arrays; +import java.util.concurrent.TimeUnit; import javax.servlet.ServletInputStream; import javax.servlet.http.HttpServletResponse; +import org.eclipse.jetty.server.Connector; +import org.eclipse.jetty.server.LocalConnector; import org.junit.Before; import org.junit.Test; import org.mockito.MockedStatic; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.mock.web.MockServletConfig; +import org.springframework.test.util.ReflectionTestUtils; import org.tron.common.parameter.CommonParameter; import org.tron.core.Constant; +import org.tron.core.config.args.Args; import org.tron.core.exception.jsonrpc.JsonRpcInvalidParamsException; import org.tron.core.services.admin.AdminJsonRpc; import org.tron.core.services.jsonrpc.JsonRpcMapper; @@ -39,6 +48,8 @@ public class AdminRpcServletTest { + private static final int REQUEST_LIMIT = 1024; + private static final ObjectMapper MAPPER = JsonRpcMapper.create(); private static final String REQUEST_PREFIX = "{\"jsonrpc\":\"2.0\",\"method\":\"admin_example\"," + "\"params\":[\"a\",\"b\"],\"id\":7,\"extra\":"; @@ -332,6 +343,71 @@ public void wildcardVirtualHostAcceptsAnyHostname() throws Exception { assertEquals(HttpServletResponse.SC_OK, response.getStatus()); } + @Test(timeout = 10_000) + public void testContentLengthRequestsUseJsonRpcLimit() throws Exception { + assertRequestLimit(false, REQUEST_LIMIT - 1, REQUEST_LIMIT, REQUEST_LIMIT + 1); + } + + @Test(timeout = 10_000) + public void testChunkedRequestsUseJsonRpcLimit() throws Exception { + // Jetty 9's streaming interceptor can count the same content more than once. + // Check a small valid body and an oversized body; exact boundaries are covered above. + assertRequestLimit(true, REQUEST_LIMIT / 4, REQUEST_LIMIT + 1); + } + + private void assertRequestLimit(boolean chunked, int... sizes) throws Exception { + CommonParameter parameter = Args.getInstance(); + long originalJsonRpcLimit = parameter.getJsonRpcMaxMessageSize(); + long originalHttpLimit = parameter.getHttpMaxMessageSize(); + TestAdminRpcHttpService service = null; + try { + parameter.setJsonRpcMaxMessageSize(REQUEST_LIMIT); + parameter.setHttpMaxMessageSize(REQUEST_LIMIT * 2); + when(adminJsonRpc.adminExample("a", "b")).thenReturn("a:b"); + service = new TestAdminRpcHttpService(); + ReflectionTestUtils.setField(service, "adminRpcServlet", servlet); + service.start().get(5, TimeUnit.SECONDS); + + for (int size : sizes) { + clearInvocations(adminJsonRpc); + String response = service.post(requestBody(size), chunked); + if (size <= REQUEST_LIMIT) { + assertTrue(response, response.startsWith("HTTP/1.1 200 ")); + int headerEnd = response.indexOf("\r\n\r\n"); + assertTrue("Response must include complete headers", headerEnd >= 0); + JsonNode result = MAPPER.readTree(response.substring(headerEnd + 4)); + assertEquals(1, result.path("id").asInt()); + assertEquals("a:b", result.path("result").asText()); + verify(adminJsonRpc).adminExample("a", "b"); + verifyNoMoreInteractions(adminJsonRpc); + } else { + assertTrue(response, response.startsWith("HTTP/1.1 413 ")); + verifyNoInteractions(adminJsonRpc); + } + } + } finally { + try { + if (service != null) { + service.stop().get(5, TimeUnit.SECONDS); + } + } finally { + parameter.setJsonRpcMaxMessageSize(originalJsonRpcLimit); + parameter.setHttpMaxMessageSize(originalHttpLimit); + } + } + } + + private String requestBody(int size) throws Exception { + ObjectNode request = MAPPER.createObjectNode(); + request.put("jsonrpc", "2.0"); + request.put("method", "admin_example"); + request.putArray("params").add("a").add("b"); + request.put("id", 1); + String json = MAPPER.writeValueAsString(request); + // The ASCII body is padded with valid JSON whitespace to exercise exact byte boundaries. + return json + Strings.repeat(" ", size - json.length()); + } + private void setField(String name, Object value) throws Exception { Field field = AdminRpcServlet.class.getDeclaredField(name); field.setAccessible(true); @@ -406,4 +482,29 @@ private void setVirtualHosts(String... hosts) throws Exception { setField("virtualHostValidator", new VirtualHostValidator(Arrays.asList(hosts))); } + private static class TestAdminRpcHttpService extends AdminRpcHttpService { + + private LocalConnector connector; + + @Override + protected void initServer() { + super.initServer(); + // Keep the production handler, servlet and filters, without binding a network port. + connector = new LocalConnector(apiServer); + apiServer.setConnectors(new Connector[] {connector}); + } + + String post(String body, boolean chunked) throws Exception { + String framing = chunked ? "Transfer-Encoding: chunked\r\n" + : "Content-Length: " + body.length() + "\r\n"; + String content = chunked ? Integer.toHexString(body.length()) + "\r\n" + + body + "\r\n0\r\n\r\n" : body; + String request = "POST /admin HTTP/1.1\r\n" + + "Host: 127.0.0.1\r\n" + + "Content-Type: application/json\r\n" + + "Connection: close\r\n" + framing + "\r\n" + content; + return connector.getResponse(request, 5, TimeUnit.SECONDS); + } + } + } From 55465c26d0b19810d0251b64dfbb15ced6a4c496 Mon Sep 17 00:00:00 2001 From: 317787106 <317787106@qq.com> Date: Mon, 12 Oct 2026 14:47:03 +0800 Subject: [PATCH 13/19] extract handleCommandLine for FullNode.main --- .../main/java/org/tron/program/FullNode.java | 46 +++++++++++---- .../java/org/tron/program/FullNodeTest.java | 59 ++++++++++++++++--- 2 files changed, 86 insertions(+), 19 deletions(-) diff --git a/framework/src/main/java/org/tron/program/FullNode.java b/framework/src/main/java/org/tron/program/FullNode.java index 618fd7ba1fe..52509e0581f 100644 --- a/framework/src/main/java/org/tron/program/FullNode.java +++ b/framework/src/main/java/org/tron/program/FullNode.java @@ -1,5 +1,6 @@ package org.tron.program; +import com.beust.jcommander.ParameterException; import org.apache.commons.lang3.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -25,16 +26,8 @@ public class FullNode { * Start the FullNode. */ public static void main(String[] args) { - // Do not initialize loggers, Args, Arch, or ExitManager before dispatching attach mode: - // their static initializers open the node's Logback appenders. - CommandLineArguments arguments = new CommandLineArguments(args); - if (arguments.isAttachMode()) { - CLIParameter clientParameters = arguments.getParameters(); - IpcClient ipcClient = new IpcClient(clientParameters.ipcSocketFile); - int exitCode = ipcClient.start(clientParameters.ipcExecCommand); - if (exitCode != 0) { - System.exit(exitCode); - } + CommandLineArguments arguments = handleCommandLine(args); + if (arguments == null) { return; } @@ -79,6 +72,39 @@ public static void main(String[] args) { appT.blockUntilShutdown(); } + /** Returns node arguments, or null when command-line handling has completed. */ + private static CommandLineArguments handleCommandLine(String[] args) { + // Do not initialize loggers, Args, Arch, or ExitManager before dispatching attach mode: + // their static initializers open the node's Logback appenders. + CommandLineArguments arguments; + boolean attachMode; + try { + arguments = new CommandLineArguments(args); + attachMode = arguments.isAttachMode(); + } catch (ParameterException e) { + System.err.println("Error: " + e.getMessage()); + System.exit(1); + return null; + } catch (TronError e) { + if (e.getErrCode() != TronError.ErrCode.PARAMETER_INIT) { + throw e; + } + // Argument validators already printed the error; do not initialize node logging here. + System.exit(1); + return null; + } + if (!attachMode) { + return arguments; + } + CLIParameter clientParameters = arguments.getParameters(); + IpcClient ipcClient = new IpcClient(clientParameters.ipcSocketFile); + int exitCode = ipcClient.start(clientParameters.ipcExecCommand); + if (exitCode != 0) { + System.exit(exitCode); + } + return null; + } + private static void checkJdkVersion() { try { Arch.throwIfUnsupportedJavaVersion(); diff --git a/framework/src/test/java/org/tron/program/FullNodeTest.java b/framework/src/test/java/org/tron/program/FullNodeTest.java index 55308ed2a2a..ddb21d6bc97 100644 --- a/framework/src/test/java/org/tron/program/FullNodeTest.java +++ b/framework/src/test/java/org/tron/program/FullNodeTest.java @@ -36,6 +36,7 @@ import org.tron.common.prometheus.Metrics; import org.tron.core.config.args.Args; import org.tron.core.config.args.CommandLineArguments; +import org.tron.core.exception.TronError; import org.tron.core.services.admin.ipc.client.IpcClient; import org.tron.core.services.jsonrpc.JsonRpcMapper; @@ -107,24 +108,52 @@ public void testMissingAttachSocketDoesNotCreateNodeLogs() throws Exception { @Test(timeout = 60_000) public void testInvalidAttachOptionsDoNotCreateNodeLogs() throws Exception { - runClientWithoutNodeLogs(1, "--attach requires a non-empty ", "", + assertArgumentError("Error: --attach requires a non-empty ", "--attach", ""); - runClientWithoutNodeLogs(1, "--attach requires a non-empty ", "", + assertArgumentError("Error: --attach requires a non-empty ", "--attach", " "); - runClientWithoutNodeLogs(1, "--attach cannot be combined with: --config", "", + assertArgumentError("Error: --attach cannot be combined with: --config", "--attach", "missing.sock", "--config", "config.conf"); - runClientWithoutNodeLogs(1, "--exec requires --attach ", "", + assertArgumentError("Error: --exec requires --attach ", "--exec", "help"); } + @Test(timeout = 60_000) + public void testArgumentParsingErrorsDoNotPrintStackTraces() throws Exception { + assertArgumentError("Error: Expected a value after parameter --attach", "--attach"); + assertArgumentError("Error: Expected a value after parameter --exec", + "--attach", "missing.sock", "--exec"); + String invalidInteger = + "Error: \"--rpc-thread\": couldn't convert \"not-a-number\" to an integer"; + assertArgumentError(invalidInteger, + "--attach", "missing.sock", "--rpc-thread", "not-a-number"); + assertArgumentError(invalidInteger, "--rpc-thread", "not-a-number"); + } + @Test(timeout = 60_000) public void testRemovedOptionsInAttachModeDoNotCreateNodeLogs() throws Exception { - runClientWithoutNodeLogs(1, "Toolkit.jar keystore ", "", + String migration = "--keystore-factory was removed." + System.lineSeparator() + + "Use: java -jar Toolkit.jar keystore "; + assertArgumentError(migration, "--attach", "missing.sock", "--keystore-factory"); - runClientWithoutNodeLogs(1, "Toolkit.jar keystore ", "", + assertArgumentError(migration, "--attach", "missing.sock", "--keystore-factory", "--keystore-factory"); } + @Test + public void testUnexpectedArgumentFailuresAreNotSuppressed() { + for (Throwable failure : new Throwable[] {new IllegalStateException("unexpected failure"), + new TronError("unexpected node error", TronError.ErrCode.JDK_VERSION)}) { + try (MockedConstruction arguments = Mockito.mockConstruction( + CommandLineArguments.class, (parsed, context) -> + Mockito.when(parsed.isAttachMode()).thenThrow(failure))) { + Assert.assertSame(failure, + Assert.assertThrows(failure.getClass(), () -> FullNode.main(new String[0]))); + Assert.assertEquals(1, arguments.constructed().size()); + } + } + } + @Test(timeout = 30_000) public void testExecResponseDoesNotCreateNodeLogs() throws Exception { Assume.assumeTrue(FileSystems.getDefault().supportedFileAttributeViews().contains("posix")); @@ -177,12 +206,18 @@ public void testInteractiveExitDoesNotCreateNodeLogs() throws Exception { } } - private void runClientWithoutNodeLogs(int expectedExitCode, String expectedOutput, + private void assertArgumentError(String expectedError, String... args) throws Exception { + String errorOutput = runClientWithoutNodeLogs(1, expectedError, "", args); + Assert.assertEquals(expectedError + System.lineSeparator(), errorOutput); + } + + private String runClientWithoutNodeLogs(int expectedExitCode, String expectedOutput, String input, String... args) throws Exception { String classpath = System.getProperty("fullNode.runtimeClasspath"); Assert.assertNotNull("The child JVM must use the production runtime classpath", classpath); Path directory = temporaryFolder.newFolder().toPath(); File outputFile = directory.resolve("console.txt").toFile(); + File errorFile = directory.resolve("stderr.txt").toFile(); List command = new ArrayList<>(); command.add(Paths.get(System.getProperty("java.home"), "bin", "java").toString()); command.add("-cp"); @@ -190,18 +225,24 @@ private void runClientWithoutNodeLogs(int expectedExitCode, String expectedOutpu command.add(FullNode.class.getName()); command.addAll(Arrays.asList(args)); Process process = new ProcessBuilder(command).directory(directory.toFile()) - .redirectErrorStream(true).redirectOutput(outputFile).start(); + .redirectError(errorFile).redirectOutput(outputFile).start(); try { process.getOutputStream().write(input.getBytes(StandardCharsets.UTF_8)); process.getOutputStream().close(); Assert.assertTrue("Attach subprocess did not exit", process.waitFor(20, TimeUnit.SECONDS)); - String output = new String(Files.readAllBytes(outputFile.toPath()), StandardCharsets.UTF_8); + String stdout = new String(Files.readAllBytes(outputFile.toPath()), StandardCharsets.UTF_8); + String stderr = new String(Files.readAllBytes(errorFile.toPath()), StandardCharsets.UTF_8); + String output = stdout + stderr; Assert.assertEquals(output, expectedExitCode, process.exitValue()); Assert.assertTrue(output, output.contains(expectedOutput)); + if (expectedExitCode != 0) { + Assert.assertEquals("Errors must be written to stderr", "", stdout); + } Assert.assertFalse("Attach created node log files: " + output, Files.exists(directory.resolve("logs"))); Assert.assertFalse("Attach initialized the node data directory", Files.exists(directory.resolve("output-directory"))); + return stderr; } finally { process.destroyForcibly(); Assert.assertTrue("Attach subprocess did not terminate", From 5f0de18a2b02312c58addadd6526314d132c9cda Mon Sep 17 00:00:00 2001 From: 317787106 <317787106@qq.com> Date: Mon, 12 Oct 2026 17:04:30 +0800 Subject: [PATCH 14/19] fix CRLF count; add admin in docs/configuration.md; --- common/src/main/resources/reference.conf | 11 +++- docs/configuration.md | 28 ++++++++++ .../core/services/admin/AdminJsonRpc.java | 4 ++ .../admin/AdminJsonRpcRequestHandler.java | 5 +- .../admin/http/AdminRpcHttpService.java | 4 +- .../services/admin/http/AdminRpcServlet.java | 8 ++- .../admin/http/VirtualHostValidator.java | 2 + .../admin/ipc/server/IpcRequestHandler.java | 10 +++- .../admin/ipc/server/IpcSocketFiles.java | 5 +- .../ipc/server/AdminJsonRpcRequestTest.java | 17 ++++++ .../ipc/server/IpcRequestHandlerTest.java | 54 ++++++++++++++----- .../admin/ipc/server/IpcServiceTest.java | 8 +-- .../admin/ipc/server/IpcSocketFilesTest.java | 2 +- 13 files changed, 133 insertions(+), 25 deletions(-) diff --git a/common/src/main/resources/reference.conf b/common/src/main/resources/reference.conf index 7fdbe4e113c..3165546b75b 100644 --- a/common/src/main/resources/reference.conf +++ b/common/src/main/resources/reference.conf @@ -430,26 +430,33 @@ node { maxResponseSize = 26214400 # Allowed maximum number for newFilter, <=0 means no limit maxLogFilterNum = 20000 - # Maximum public JSON-RPC HTTP and Admin IPC request size in bytes (default 4194304, ~4MB). + # Maximum public JSON-RPC HTTP, Admin HTTP and Admin IPC request size in bytes (~4MB). # Independent from rpc.maxMessageSize. Setting to 0 rejects all non-empty requests. maxMessageSize = 4194304 } # Administrative API settings. Disabled by default. # Batch requests are not supported. + # Security and recovery procedures: docs/configuration.md, Administrative API. admin { # Local Unix-domain socket administrative API. ipc { # Whether to enable the local Unix-domain socket admin API. Default: false. enable = false # Existing absolute parent directory; empty uses output-directory. + # Use a trusted root whose path cannot be replaced by untrusted users, including via symlinks. + # Root ownership, ancestor permissions and ACLs are not checked automatically. # The ipc subdirectory must not exist at startup. + # An unclean exit can leave it behind and block automatic restart; manual recovery is required. + # Stop automatic restarts, confirm no node uses this root, then remove only verified stale files + # and the empty ipc directory. Never delete it unconditionally in a supervisor startup hook. # Full socket path limit: 100 encoded bytes. socketDirectory = "" # Request size is controlled by node.jsonrpc.maxMessageSize, shared with JSON-RPC HTTP. } # Administrative JSON-RPC HTTP API. + # No built-in authentication. Enforce access control externally; use a trusted management network. http { # Whether to enable the administrative JSON-RPC HTTP service. Default: false. enable = false @@ -459,6 +466,8 @@ node { port = 8575 # Allowed HTTP Host header names. Matching is case-insensitive and ignores the port. # IP address literals are always allowed. Use ["*"] only to explicitly allow any hostname. + # Missing or empty Host headers are accepted for HTTP/1.0 compatibility. + # This allowlist guards against DNS rebinding; it is not an authentication mechanism. virtualHosts = ["localhost"] } } diff --git a/docs/configuration.md b/docs/configuration.md index a9db0ddc425..771356542c4 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -192,6 +192,34 @@ node.jsonrpc { } ``` +### Administrative API (`node.admin`) + +Admin HTTP and IPC are FullNode services, independently enabled and disabled by default. +Configure `node.admin.http` and `node.admin.ipc` using the settings in +[reference.conf](../common/src/main/resources/reference.conf). + +- **HTTP:** `POST /admin` defaults to `127.0.0.1:8575` and has no built-in authentication. + Remote access requires external access control on a trusted management network. The Host + allowlist guards against DNS rebinding; it is not authentication. IP literals are allowed, + as are missing or empty Host headers for HTTP/1.0 compatibility. +- **IPC:** `socketDirectory` must be an existing absolute directory on a POSIX filesystem; + empty uses `output-directory`. The endpoint is `/ipc/.sock`, with a full + path limit of 100 encoded bytes. Use a root controlled by the node user or a trusted administrator. + Its path must not be replaceable by untrusted users through ancestor permissions, ACLs or + symlinks. Trusted root-owned ancestors are acceptable. These trust requirements are not + automatically checked; mode `0700` on `ipc` does not protect against parent-directory replacement. +- **Request size:** both transports use `node.jsonrpc.maxMessageSize` (default 4 MiB). + IPC counts UTF-8 message bytes excluding LF or CRLF terminators; other CR bytes count as payload. + +An unclean exit can leave `ipc` behind and block automatic restart. Recovery is manual: + +1. Stop automatic restarts and prevent concurrent starts using the same socket root. +2. Check the directory shown in the startup error and confirm no running node uses it. + A missing PID or refused connection alone does not establish that it is safe to remove. +3. Remove only verified stale socket files and the empty `ipc` directory, preserving unrelated + files. Restart one node, verify IPC access, then resume supervision. Do not use unconditional + recursive deletion in startup hooks. + ### Event Subscription (`event.subscribe`) ```hocon diff --git a/framework/src/main/java/org/tron/core/services/admin/AdminJsonRpc.java b/framework/src/main/java/org/tron/core/services/admin/AdminJsonRpc.java index 9eae6306a37..01ee98ec585 100644 --- a/framework/src/main/java/org/tron/core/services/admin/AdminJsonRpc.java +++ b/framework/src/main/java/org/tron/core/services/admin/AdminJsonRpc.java @@ -6,6 +6,10 @@ import com.googlecode.jsonrpc4j.JsonRpcParam; import org.tron.core.exception.jsonrpc.JsonRpcInvalidParamsException; +/** + * Shared Admin API. Explicit error mappings are client-visible: their messages and data, including + * exception-derived values, must be safe to disclose. Unmapped invocation errors are sanitized. + */ public interface AdminJsonRpc { String BATCH_NOT_SUPPORTED_MESSAGE = "Batch requests are not supported"; diff --git a/framework/src/main/java/org/tron/core/services/admin/AdminJsonRpcRequestHandler.java b/framework/src/main/java/org/tron/core/services/admin/AdminJsonRpcRequestHandler.java index 07a23f31ff9..904cc0d20c5 100644 --- a/framework/src/main/java/org/tron/core/services/admin/AdminJsonRpcRequestHandler.java +++ b/framework/src/main/java/org/tron/core/services/admin/AdminJsonRpcRequestHandler.java @@ -28,7 +28,10 @@ public final class AdminJsonRpcRequestHandler extends JsonRpcBasicServer { public AdminJsonRpcRequestHandler(AdminJsonRpc adminJsonRpc) { super(OBJECT_MAPPER, adminJsonRpc, AdminJsonRpc.class); - setErrorResolver(JsonRpcErrorResolver.INSTANCE); + setErrorResolver((exception, method, arguments) -> { + JsonError error = JsonRpcErrorResolver.INSTANCE.resolveError(exception, method, arguments); + return error == null ? JsonError.INTERNAL_ERROR : error; + }); setShouldLogInvocationErrors(false); } diff --git a/framework/src/main/java/org/tron/core/services/admin/http/AdminRpcHttpService.java b/framework/src/main/java/org/tron/core/services/admin/http/AdminRpcHttpService.java index 2f0e321cd63..ce9fa03eede 100644 --- a/framework/src/main/java/org/tron/core/services/admin/http/AdminRpcHttpService.java +++ b/framework/src/main/java/org/tron/core/services/admin/http/AdminRpcHttpService.java @@ -33,7 +33,9 @@ public AdminRpcHttpService() { public void innerStart() throws Exception { if (enable && !InetUtil.isLoopbackAddress(listenAddress)) { logger.warn("Admin HTTP is enabled on {} and may be accessible remotely. " - + "Restrict access to trusted networks.", listenAddress); + + "Admin HTTP has no built-in authentication; the Host allowlist is not authentication. " + + "Enforce access control externally and restrict access to trusted networks.", + listenAddress); } super.innerStart(); } diff --git a/framework/src/main/java/org/tron/core/services/admin/http/AdminRpcServlet.java b/framework/src/main/java/org/tron/core/services/admin/http/AdminRpcServlet.java index 8d44adc836f..9b8ad0be69d 100644 --- a/framework/src/main/java/org/tron/core/services/admin/http/AdminRpcServlet.java +++ b/framework/src/main/java/org/tron/core/services/admin/http/AdminRpcServlet.java @@ -25,8 +25,12 @@ * Serves the {@link AdminJsonRpc} API at {@code POST /admin} through jsonrpc4j. * *

This endpoint is intended for trusted node operators. Deployments must restrict access to - * loopback or a controlled management network. This low-frequency management endpoint intentionally - * bypasses public API rate limiting and JSON-RPC response-size limits. + * loopback or a controlled management network and enforce access control externally. There is no + * built-in authentication; Host validation protects against DNS rebinding and does not authenticate + * callers. + * Missing or empty Host headers are accepted for HTTP/1.0 compatibility. + * This low-frequency management endpoint intentionally bypasses public API rate limiting and + * JSON-RPC response-size limits. * Only individual request objects are supported; batch requests are rejected. * HTTP request-size limits use {@code node.jsonrpc.maxMessageSize}, shared with Admin IPC and * public JSON-RPC HTTP, and are enforced by {@link org.tron.common.application.HttpService}; diff --git a/framework/src/main/java/org/tron/core/services/admin/http/VirtualHostValidator.java b/framework/src/main/java/org/tron/core/services/admin/http/VirtualHostValidator.java index 402a931d49a..728f014f4a0 100644 --- a/framework/src/main/java/org/tron/core/services/admin/http/VirtualHostValidator.java +++ b/framework/src/main/java/org/tron/core/services/admin/http/VirtualHostValidator.java @@ -8,6 +8,8 @@ /** * Applies the Admin HTTP virtual-host policy without resolving request hostnames through DNS. + * IP literals and missing or empty Host headers are allowed, the latter for HTTP/1.0 compatibility. + * This policy is not an authentication mechanism. */ final class VirtualHostValidator { diff --git a/framework/src/main/java/org/tron/core/services/admin/ipc/server/IpcRequestHandler.java b/framework/src/main/java/org/tron/core/services/admin/ipc/server/IpcRequestHandler.java index ba2a0822e54..f92cb37243e 100644 --- a/framework/src/main/java/org/tron/core/services/admin/ipc/server/IpcRequestHandler.java +++ b/framework/src/main/java/org/tron/core/services/admin/ipc/server/IpcRequestHandler.java @@ -11,6 +11,7 @@ /** * Reads bounded IPC request lines and dispatches Admin JSON-RPC calls. + * The byte limit excludes LF or CRLF terminators; other CR bytes count toward the limit. * Only individual request objects are supported; batch requests are rejected. * Per-request buffers are local so one handler can serve concurrent client connections. * Stream and socket ownership remains with {@link IpcService}. @@ -33,7 +34,9 @@ String readRequest(InputStream input) throws IOException { if (value == '\n') { break; } - if (request.size() >= maxRequestSize) { + // Allow one extra CR until the next byte determines whether it belongs to CRLF. + if (request.size() > maxRequestSize + || (request.size() == maxRequestSize && value != '\r')) { throw new RequestTooLargeException(); } request.write(value); @@ -43,9 +46,12 @@ String readRequest(InputStream input) throws IOException { } byte[] bytes = request.toByteArray(); int length = bytes.length; - if (length > 0 && bytes[length - 1] == '\r') { + if (value == '\n' && length > 0 && bytes[length - 1] == '\r') { length--; } + if (length > maxRequestSize) { + throw new RequestTooLargeException(); + } return new String(bytes, 0, length, StandardCharsets.UTF_8); } diff --git a/framework/src/main/java/org/tron/core/services/admin/ipc/server/IpcSocketFiles.java b/framework/src/main/java/org/tron/core/services/admin/ipc/server/IpcSocketFiles.java index 8659f4b570a..6bbda4ab602 100644 --- a/framework/src/main/java/org/tron/core/services/admin/ipc/server/IpcSocketFiles.java +++ b/framework/src/main/java/org/tron/core/services/admin/ipc/server/IpcSocketFiles.java @@ -17,6 +17,8 @@ /** * Resolves and manages IPC socket files and their private directory. * The service owns the socket and decides when to prepare or remove these files. + * Operators must ensure the root and its path cannot be replaced by untrusted users; these checks + * do not validate ownership, ancestor permissions, ACLs, or symlink targets. */ final class IpcSocketFiles { @@ -76,7 +78,8 @@ void createSocketDirectory(Path socketDirectory) throws IOException { EnumSet.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE, PosixFilePermission.OWNER_EXECUTE))); } catch (FileAlreadyExistsException e) { - throw new TronError("IPC directory already exists. Startup aborted. " + // Local startup diagnostics identify the operator-managed path for manual recovery. + throw new TronError("IPC directory already exists: " + socketDirectory + ". Startup aborted. " + "Confirm that no node is using it, then remove it manually and retry.", ErrCode.API_SERVER_INIT); } diff --git a/framework/src/test/java/org/tron/core/services/admin/ipc/server/AdminJsonRpcRequestTest.java b/framework/src/test/java/org/tron/core/services/admin/ipc/server/AdminJsonRpcRequestTest.java index f7152f78531..21143489133 100644 --- a/framework/src/test/java/org/tron/core/services/admin/ipc/server/AdminJsonRpcRequestTest.java +++ b/framework/src/test/java/org/tron/core/services/admin/ipc/server/AdminJsonRpcRequestTest.java @@ -195,6 +195,7 @@ public void errorResponsesRetainAcceptedIds() throws Exception { JsonNode response = assertError(sender.send(REQUEST + ",\"id\":" + id + "}"), -32602, id); Assert.assertEquals("Invalid admin parameters", response.path("error").path("message") .asText()); + Assert.assertEquals("{}", response.path("error").path("data").asText()); Mockito.verify(adminJsonRpc).adminExample("a", "b"); Mockito.clearInvocations(adminJsonRpc); assertError(sender.send(REQUEST.replace("admin_example", "admin_missing") @@ -203,6 +204,22 @@ public void errorResponsesRetainAcceptedIds() throws Exception { } } + @Test + public void unmappedInvocationFailureIsSanitizedAndSilencesNotifications() throws Exception { + Mockito.when(adminJsonRpc.adminExample("a", "b")) + .thenThrow(new IllegalStateException("sensitive-detail")); + for (String id : new String[] {"9", "null", "\"request\""}) { + String body = sender.send(REQUEST + ",\"id\":" + id + "}"); + JsonNode error = assertError(body, -32603, id).get("error"); + Assert.assertEquals("internal error", error.get("message").asText()); + Assert.assertEquals(2, error.size()); + Assert.assertFalse(body.contains("sensitive-detail")); + Assert.assertFalse(body.contains(IllegalStateException.class.getName())); + } + Assert.assertEquals("", sender.send(REQUEST + "}")); + Mockito.verify(adminJsonRpc, Mockito.times(4)).adminExample("a", "b"); + } + @Test public void dispatcherFailureReturnsSanitizedErrorAndSilencesNotifications() throws Exception { JsonRpcInterceptor interceptor = Mockito.mock(JsonRpcInterceptor.class); diff --git a/framework/src/test/java/org/tron/core/services/admin/ipc/server/IpcRequestHandlerTest.java b/framework/src/test/java/org/tron/core/services/admin/ipc/server/IpcRequestHandlerTest.java index 560e6fbf3c0..a3db639da2e 100644 --- a/framework/src/test/java/org/tron/core/services/admin/ipc/server/IpcRequestHandlerTest.java +++ b/framework/src/test/java/org/tron/core/services/admin/ipc/server/IpcRequestHandlerTest.java @@ -173,12 +173,17 @@ public void testStringNullRequestIdIsPreserved() throws Exception { @Test public void testReadRequestAcceptsMaximumSize() throws Exception { - byte[] request = new byte[MAX_REQUEST_SIZE + 1]; - Arrays.fill(request, 0, MAX_REQUEST_SIZE, (byte) '1'); - request[MAX_REQUEST_SIZE] = '\n'; - - Assert.assertEquals(MAX_REQUEST_SIZE, - handler.readRequest(new ByteArrayInputStream(request)).length()); + byte[] payload = new byte[MAX_REQUEST_SIZE]; + Arrays.fill(payload, (byte) '1'); + for (String terminator : new String[] {"\n", "\r\n"}) { + String frames = new String(payload, StandardCharsets.UTF_8) + terminator + "next\n"; + ByteArrayInputStream input = + new ByteArrayInputStream(frames.getBytes(StandardCharsets.UTF_8)); + + Assert.assertEquals(MAX_REQUEST_SIZE, handler.readRequest(input).length()); + Assert.assertEquals("next", handler.readRequest(input)); + Assert.assertNull(handler.readRequest(input)); + } } @Test(expected = RequestTooLargeException.class) @@ -201,8 +206,10 @@ public void testReadRequestPreservesFramesAndDistinguishesEmptyLineFromEof() thr @Test public void testReadRequestCountsBytesAndDecodesUtf8() throws Exception { IpcRequestHandler limited = new IpcRequestHandler(new AdminJsonRpcImpl(), 3); - Assert.assertEquals("中", limited.readRequest(new ByteArrayInputStream( - "中\n".getBytes(StandardCharsets.UTF_8)))); + for (String terminator : new String[] {"\n", "\r\n"}) { + Assert.assertEquals("中", limited.readRequest(new ByteArrayInputStream( + ("中" + terminator).getBytes(StandardCharsets.UTF_8)))); + } try { limited.readRequest(new ByteArrayInputStream("中文\n".getBytes(StandardCharsets.UTF_8))); Assert.fail("Expected multi-byte input to exceed the byte limit"); @@ -211,17 +218,38 @@ public void testReadRequestCountsBytesAndDecodesUtf8() throws Exception { } } + @Test + public void testReadRequestCountsCarriageReturnsOutsideCrlf() throws Exception { + IpcRequestHandler limited = new IpcRequestHandler(new AdminJsonRpcImpl(), 3); + for (String terminator : new String[] {"\n", "\r\n", ""}) { + Assert.assertEquals("a\rb", limited.readRequest(new ByteArrayInputStream( + ("a\rb" + terminator).getBytes(StandardCharsets.UTF_8)))); + } + Assert.assertEquals("ab\r", limited.readRequest(new ByteArrayInputStream( + "ab\r".getBytes(StandardCharsets.UTF_8)))); + } + + @Test + public void testReadRequestRejectsOversizedPayloadsWithCarriageReturns() { + IpcRequestHandler limited = new IpcRequestHandler(new AdminJsonRpcImpl(), 3); + for (String frame : new String[] {"abcd\n", "abcd\r\n", "abc\rX\n", "abc\r\r\n", + "abc\r", "abcd"}) { + Assert.assertThrows(RequestTooLargeException.class, () -> limited.readRequest( + new ByteArrayInputStream(frame.getBytes(StandardCharsets.UTF_8)))); + } + } + @Test public void testZeroLimitOnlyAcceptsEmptyLines() throws Exception { IpcRequestHandler zeroLimit = new IpcRequestHandler(new AdminJsonRpcImpl(), 0); Assert.assertEquals(0, zeroLimit.getMaxRequestSize()); Assert.assertEquals("", zeroLimit.readRequest(new ByteArrayInputStream(new byte[] {'\n'}))); + Assert.assertEquals("", zeroLimit.readRequest( + new ByteArrayInputStream(new byte[] {'\r', '\n'}))); Assert.assertNull(zeroLimit.readRequest(new ByteArrayInputStream(new byte[0]))); - try { - zeroLimit.readRequest(new ByteArrayInputStream(new byte[] {'a'})); - Assert.fail("Expected a configured zero limit to reject nonempty input"); - } catch (RequestTooLargeException expected) { - // Zero must not silently fall back to a default limit. + for (String frame : new String[] {"a", "\r", "\r\r\n", "a\r\n"}) { + Assert.assertThrows(RequestTooLargeException.class, () -> zeroLimit.readRequest( + new ByteArrayInputStream(frame.getBytes(StandardCharsets.UTF_8)))); } } diff --git a/framework/src/test/java/org/tron/core/services/admin/ipc/server/IpcServiceTest.java b/framework/src/test/java/org/tron/core/services/admin/ipc/server/IpcServiceTest.java index 57b555812bf..a8a272190c8 100644 --- a/framework/src/test/java/org/tron/core/services/admin/ipc/server/IpcServiceTest.java +++ b/framework/src/test/java/org/tron/core/services/admin/ipc/server/IpcServiceTest.java @@ -164,7 +164,7 @@ public void testRejectedStartupAndStopPreserveExistingPaths() throws Exception { Files.createSymbolicLink(directory, target); } try { - assertStartupRejectsExistingDirectory(service); + assertStartupRejectsExistingDirectory(service, directory); service.innerStop(); Assert.assertTrue(Files.exists(directory, LinkOption.NOFOLLOW_LINKS)); @@ -215,7 +215,7 @@ public void testSecondNodeRejectionPreservesFirstNodeEndpoint() throws Exception ManagementFactory.class, Mockito.CALLS_REAL_METHODS)) { management.when(ManagementFactory::getRuntimeMXBean).thenReturn(secondRuntime); Assert.assertNotEquals(firstPid, getPid(second)); - assertStartupRejectsExistingDirectory(second); + assertStartupRejectsExistingDirectory(second, root.resolve("ipc")); } assertNewConnectionSucceeds(socketFile); second.innerStop(); @@ -740,13 +740,15 @@ private IpcService newIpcService() { return new IpcService(new AdminJsonRpcImpl()); } - private void assertStartupRejectsExistingDirectory(IpcService service) throws Exception { + private void assertStartupRejectsExistingDirectory(IpcService service, Path directory) + throws Exception { try { service.innerStart(); Assert.fail("Expected startup to reject an existing IPC directory"); } catch (TronError e) { Assert.assertEquals(TronError.ErrCode.API_SERVER_INIT, e.getErrCode()); Assert.assertTrue(e.getMessage().contains("IPC directory already exists")); + Assert.assertTrue(e.getMessage().contains(directory.toString())); Assert.assertTrue(e.getMessage().contains("remove it manually")); Assert.assertFalse(isRunning(service)); } diff --git a/framework/src/test/java/org/tron/core/services/admin/ipc/server/IpcSocketFilesTest.java b/framework/src/test/java/org/tron/core/services/admin/ipc/server/IpcSocketFilesTest.java index 737dabfb0e2..ac51314e1de 100644 --- a/framework/src/test/java/org/tron/core/services/admin/ipc/server/IpcSocketFilesTest.java +++ b/framework/src/test/java/org/tron/core/services/admin/ipc/server/IpcSocketFilesTest.java @@ -246,7 +246,7 @@ private void assertExistingPathRejected(Path path) throws Exception { Assert.assertTrue(e.getMessage().contains("IPC directory already exists")); Assert.assertTrue(e.getMessage().contains("Confirm that no node is using it")); Assert.assertTrue(e.getMessage().contains("remove it manually")); - Assert.assertFalse(e.getMessage().contains(path.toString())); + Assert.assertTrue(e.getMessage().contains(path.toString())); } } From 45ce80677e24404fd4471c182ebda55492851051 Mon Sep 17 00:00:00 2001 From: 317787106 <317787106@qq.com> Date: Tue, 13 Oct 2026 15:38:02 +0800 Subject: [PATCH 15/19] distintch Invalid params from Internal error --- .../admin/AdminJsonRpcRequestHandler.java | 16 ++-- .../jsonrpc/JsonRpcErrorResolver.java | 7 +- .../ipc/server/AdminJsonRpcRequestTest.java | 90 ++++++++++++++----- 3 files changed, 84 insertions(+), 29 deletions(-) diff --git a/framework/src/main/java/org/tron/core/services/admin/AdminJsonRpcRequestHandler.java b/framework/src/main/java/org/tron/core/services/admin/AdminJsonRpcRequestHandler.java index 904cc0d20c5..a5de694f25d 100644 --- a/framework/src/main/java/org/tron/core/services/admin/AdminJsonRpcRequestHandler.java +++ b/framework/src/main/java/org/tron/core/services/admin/AdminJsonRpcRequestHandler.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.exc.MismatchedInputException; import com.fasterxml.jackson.databind.node.ObjectNode; import com.googlecode.jsonrpc4j.ErrorResolver.JsonError; import com.googlecode.jsonrpc4j.JsonRpcBasicServer; @@ -28,10 +29,7 @@ public final class AdminJsonRpcRequestHandler extends JsonRpcBasicServer { public AdminJsonRpcRequestHandler(AdminJsonRpc adminJsonRpc) { super(OBJECT_MAPPER, adminJsonRpc, AdminJsonRpc.class); - setErrorResolver((exception, method, arguments) -> { - JsonError error = JsonRpcErrorResolver.INSTANCE.resolveError(exception, method, arguments); - return error == null ? JsonError.INTERNAL_ERROR : error; - }); + setErrorResolver(JsonRpcErrorResolver.INSTANCE); setShouldLogInvocationErrors(false); } @@ -99,7 +97,13 @@ private JsonNode dispatch(ObjectNode request, JsonNode id) { if (id != null && id.isNull()) { request.put("id", 0); } - JsonNode response = super.handleJsonNodeRequest(request).getResponse(); + JsonNode response; + try { + response = super.handleJsonNodeRequest(request).getResponse(); + } catch (MismatchedInputException e) { + // jsonrpc4j propagates input conversion failures before invoking the method. + response = errorResponse(JsonError.METHOD_PARAMS_INVALID, id); + } if (notification) { return null; } @@ -107,7 +111,7 @@ private JsonNode dispatch(ObjectNode request, JsonNode id) { return response; } catch (Exception e) { logger.debug("Failed to dispatch Admin request"); - return notification ? null : JsonRpcMapper.createErrorResponse(-32603, "Internal error", id); + return notification ? null : errorResponse(JsonRpcErrorResolver.INTERNAL_ERROR, id); } finally { if (id != null && id.isNull()) { request.set("id", id); diff --git a/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcErrorResolver.java b/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcErrorResolver.java index e5dc76402ea..58226521856 100644 --- a/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcErrorResolver.java +++ b/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcErrorResolver.java @@ -20,6 +20,9 @@ public enum JsonRpcErrorResolver implements ErrorResolver { INSTANCE; + public static final JsonError INTERNAL_ERROR = + new JsonError(JsonError.INTERNAL_ERROR.code, "Internal error", null); + /** * {@inheritDoc} */ @@ -35,7 +38,7 @@ public JsonError resolveError( ? null : getResolverForException(thrownException, method); if (resolver == null) { logUnhandledException(method, thrownException); - return new JsonError(JsonError.INTERNAL_ERROR.code, "Internal error", null); + return INTERNAL_ERROR; } String message = hasErrorMessage(resolver) ? resolver.message() : thrownException.getMessage(); @@ -124,7 +127,7 @@ private static String defaultMessageFor(int code) { case -32602: return "Invalid params"; default: - return "Internal error"; + return INTERNAL_ERROR.message; } } diff --git a/framework/src/test/java/org/tron/core/services/admin/ipc/server/AdminJsonRpcRequestTest.java b/framework/src/test/java/org/tron/core/services/admin/ipc/server/AdminJsonRpcRequestTest.java index 21143489133..2fe8c34bfe4 100644 --- a/framework/src/test/java/org/tron/core/services/admin/ipc/server/AdminJsonRpcRequestTest.java +++ b/framework/src/test/java/org/tron/core/services/admin/ipc/server/AdminJsonRpcRequestTest.java @@ -1,8 +1,11 @@ package org.tron.core.services.admin.ipc.server; +import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.exc.InvalidDefinitionException; +import com.fasterxml.jackson.databind.exc.MismatchedInputException; import com.googlecode.jsonrpc4j.JsonRpcInterceptor; import java.nio.charset.StandardCharsets; import java.util.Arrays; @@ -178,6 +181,23 @@ public void invalidMethodParamsNotificationHasNoResponse() throws Exception { Mockito.verifyNoInteractions(adminJsonRpc); } + @Test + public void parameterBindingFailuresRetainIdsAndSilenceNotifications() throws Exception { + for (String params : new String[] {"[{},\"b\"]", "[\"a\",[]]", + "{\"param1\":{},\"param2\":\"b\"}", "{\"param1\":\"a\",\"param2\":[]}"}) { + String request = REQUEST.replace("[\"a\",\"b\"]", params); + for (String id : new String[] {"9", "null", "\"request\"", "9223372036854775807", + "0.12345678901234567890123456789"}) { + JsonNode error = assertError(sender.send(request + ",\"id\":" + id + "}"), -32602, id) + .get("error"); + Assert.assertEquals("method parameters invalid", error.get("message").asText()); + Assert.assertEquals(2, error.size()); + } + Assert.assertEquals("", sender.send(request + "}")); + Mockito.verifyNoInteractions(adminJsonRpc); + } + } + @Test public void businessErrorNotificationHasNoResponse() throws Exception { Mockito.when(adminJsonRpc.adminExample("a", "b")) @@ -206,35 +226,63 @@ public void errorResponsesRetainAcceptedIds() throws Exception { @Test public void unmappedInvocationFailureIsSanitizedAndSilencesNotifications() throws Exception { - Mockito.when(adminJsonRpc.adminExample("a", "b")) - .thenThrow(new IllegalStateException("sensitive-detail")); - for (String id : new String[] {"9", "null", "\"request\""}) { - String body = sender.send(REQUEST + ",\"id\":" + id + "}"); - JsonNode error = assertError(body, -32603, id).get("error"); - Assert.assertEquals("internal error", error.get("message").asText()); - Assert.assertEquals(2, error.size()); - Assert.assertFalse(body.contains("sensitive-detail")); - Assert.assertFalse(body.contains(IllegalStateException.class.getName())); + for (Exception failure : new Exception[] {new IllegalStateException("sensitive-detail"), + MismatchedInputException.from(null, String.class, "sensitive-detail")}) { + Mockito.doAnswer(invocation -> { + throw failure; + }).when(adminJsonRpc).adminExample("a", "b"); + for (String id : new String[] {"9", "null", "\"request\""}) { + String body = sender.send(REQUEST + ",\"id\":" + id + "}"); + JsonNode error = assertError(body, -32603, id).get("error"); + Assert.assertEquals("Internal error", error.get("message").asText()); + Assert.assertEquals(2, error.size()); + Assert.assertFalse(body.contains("sensitive-detail")); + Assert.assertFalse(body.contains(failure.getClass().getName())); + } + Assert.assertEquals("", sender.send(REQUEST + "}")); + Mockito.verify(adminJsonRpc, Mockito.times(4)).adminExample("a", "b"); + Mockito.clearInvocations(adminJsonRpc); } - Assert.assertEquals("", sender.send(REQUEST + "}")); - Mockito.verify(adminJsonRpc, Mockito.times(4)).adminExample("a", "b"); } @Test public void dispatcherFailureReturnsSanitizedErrorAndSilencesNotifications() throws Exception { JsonRpcInterceptor interceptor = Mockito.mock(JsonRpcInterceptor.class); - Mockito.doThrow(new IllegalStateException("sensitive-detail")) - .when(interceptor).preHandleJson(Mockito.any(JsonNode.class)); requestHandler.setInterceptorList(Collections.singletonList(interceptor)); - for (String id : new String[] {"9", "null", "\"request\""}) { - String body = sender.send(REQUEST + ",\"id\":" + id + "}"); - JsonNode response = assertError(body, -32603, id); - Assert.assertEquals("Internal error", response.path("error").path("message").asText()); - Assert.assertFalse(body.contains("sensitive-detail")); + for (Exception failure : new Exception[] {new IllegalStateException("sensitive-detail"), + MismatchedInputException.from(null, String.class, "sensitive-detail")}) { + Mockito.doAnswer(invocation -> { + throw failure; + }).when(interceptor).preHandleJson(Mockito.any(JsonNode.class)); + for (String id : new String[] {"9", "null", "\"request\""}) { + String body = sender.send(REQUEST + ",\"id\":" + id + "}"); + JsonNode response = assertError(body, -32603, id); + Assert.assertEquals("Internal error", response.path("error").path("message").asText()); + Assert.assertFalse(body.contains("sensitive-detail")); + } + Assert.assertEquals("", sender.send(REQUEST + "}")); + Mockito.verify(interceptor, Mockito.times(4)).preHandleJson(Mockito.any(JsonNode.class)); + Mockito.verifyNoInteractions(adminJsonRpc); + Mockito.clearInvocations(interceptor); } - Assert.assertEquals("", sender.send(REQUEST + "}")); - Mockito.verify(interceptor, Mockito.times(4)).preHandleJson(Mockito.any(JsonNode.class)); - Mockito.verifyNoInteractions(adminJsonRpc); + } + + @Test + public void serverMappingDefinitionFailureRemainsInternalError() throws Exception { + JsonRpcInterceptor interceptor = Mockito.mock(JsonRpcInterceptor.class); + Mockito.doAnswer(invocation -> { + throw InvalidDefinitionException.from((JsonParser) null, "sensitive-detail", + MAPPER.constructType(String.class)); + }).when(interceptor).postHandleJson(Mockito.any(JsonNode.class)); + requestHandler.setInterceptorList(Collections.singletonList(interceptor)); + + String body = sender.send(REQUEST + ",\"id\":\"request\"}"); + JsonNode error = assertError(body, -32603, "\"request\"").get("error"); + Assert.assertEquals("Internal error", error.path("message").asText()); + Assert.assertEquals(2, error.size()); + Assert.assertFalse(body.contains("sensitive-detail")); + Mockito.verify(adminJsonRpc).adminExample("a", "b"); + Mockito.verify(interceptor, Mockito.atLeastOnce()).postHandleJson(Mockito.any(JsonNode.class)); } @Test From 9334b4f60068bc2deac150630a685508bd97b99d Mon Sep 17 00:00:00 2001 From: 317787106 <317787106@qq.com> Date: Tue, 13 Oct 2026 22:03:01 +0800 Subject: [PATCH 16/19] use commitBareInternalServerError for AdminRpcServlet --- .../services/admin/http/AdminRpcServlet.java | 10 ++ .../services/jsonrpc/JsonRpcHttpResponse.java | 30 ++++ .../core/services/jsonrpc/JsonRpcServlet.java | 18 +-- .../admin/http/AdminRpcServletTest.java | 148 +++++++++++++++++- 4 files changed, 187 insertions(+), 19 deletions(-) create mode 100644 framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcHttpResponse.java diff --git a/framework/src/main/java/org/tron/core/services/admin/http/AdminRpcServlet.java b/framework/src/main/java/org/tron/core/services/admin/http/AdminRpcServlet.java index 9b8ad0be69d..116a5eea1dc 100644 --- a/framework/src/main/java/org/tron/core/services/admin/http/AdminRpcServlet.java +++ b/framework/src/main/java/org/tron/core/services/admin/http/AdminRpcServlet.java @@ -18,6 +18,7 @@ import org.tron.common.prometheus.Metrics; import org.tron.core.services.admin.AdminJsonRpc; import org.tron.core.services.admin.AdminJsonRpcRequestHandler; +import org.tron.core.services.jsonrpc.JsonRpcHttpResponse; import org.tron.core.services.jsonrpc.JsonRpcMapper; import org.tron.core.services.jsonrpc.JsonRpcMediaType; @@ -84,6 +85,15 @@ protected void service(HttpServletRequest req, HttpServletResponse resp) */ @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException { + try { + doPostInternal(req, resp); + } catch (Error fatal) { + JsonRpcHttpResponse.commitBareInternalServerError(resp); + throw fatal; + } + } + + private void doPostInternal(HttpServletRequest req, HttpServletResponse resp) throws IOException { // Check the requested hostname before dispatch to guard against DNS rebinding. if (!virtualHostValidator.isAllowedHost(req.getHeader("Host"))) { resp.sendError(HttpServletResponse.SC_FORBIDDEN, "Invalid Host header"); diff --git a/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcHttpResponse.java b/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcHttpResponse.java new file mode 100644 index 00000000000..c436f4ce416 --- /dev/null +++ b/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcHttpResponse.java @@ -0,0 +1,30 @@ +package org.tron.core.services.jsonrpc; + +import javax.servlet.http.HttpServletResponse; + +/** HTTP response cleanup shared by the public and Admin JSON-RPC servlets. */ +public final class JsonRpcHttpResponse { + + private JsonRpcHttpResponse() { + } + + /** + * Best-effort empty HTTP 500 before the caller rethrows a fatal error. Already committed + * responses are left untouched, and cleanup failures never replace the original error. + */ + public static void commitBareInternalServerError(HttpServletResponse resp) { + try { + if (resp.isCommitted()) { + return; + } + // Commit before rethrowing so the container does not render its default error page, + // which may expose Throwable details. + resp.resetBuffer(); + resp.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + resp.setContentLength(0); + resp.flushBuffer(); + } catch (Throwable ignored) { + // Cleanup must never replace the original fatal Error. + } + } +} diff --git a/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcServlet.java b/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcServlet.java index a3ed951d080..00abb042640 100644 --- a/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcServlet.java +++ b/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcServlet.java @@ -85,7 +85,7 @@ protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws I try { doPostInternal(req, resp); } catch (Error fatal) { - commitBareInternalServerError(resp); + JsonRpcHttpResponse.commitBareInternalServerError(resp); throw fatal; } } @@ -316,22 +316,6 @@ private static void rethrowIfFatal(Throwable exception) { } } - private static void commitBareInternalServerError(HttpServletResponse resp) { - try { - if (resp.isCommitted()) { - return; - } - // Best effort: commit an empty response before rethrowing the Error so the container does - // not render its default error page, which may expose Throwable details. - resp.resetBuffer(); - resp.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); - resp.setContentLength(0); - resp.flushBuffer(); - } catch (Throwable ignored) { - // Cleanup must never replace the original fatal Error. - } - } - private byte[] readBody(InputStream in) throws IOException { ByteArrayOutputStream buffer = new ByteArrayOutputStream(); byte[] tmp = new byte[4096]; diff --git a/framework/src/test/java/org/tron/core/services/admin/http/AdminRpcServletTest.java b/framework/src/test/java/org/tron/core/services/admin/http/AdminRpcServletTest.java index 12072c3d32c..e938e8ae0b8 100644 --- a/framework/src/test/java/org/tron/core/services/admin/http/AdminRpcServletTest.java +++ b/framework/src/test/java/org/tron/core/services/admin/http/AdminRpcServletTest.java @@ -3,13 +3,16 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; @@ -272,6 +275,120 @@ public ServletInputStream getInputStream() { verifyNoInteractions(adminJsonRpc); } + @Test + public void fatalDispatchCommitsEmpty500AndRethrowsOriginal() throws Exception { + MockHttpServletResponse response = new MockHttpServletResponse(); + + assertFatalDispatch(new StackOverflowError("fatal-dispatch-marker"), response); + + assertEmptyInternalServerError(response); + } + + @Test + public void fatalReadCommitsEmpty500BeforeDispatch() throws Exception { + ServletInputStream input = mock(ServletInputStream.class); + OutOfMemoryError fatal = new OutOfMemoryError("fatal-read-marker"); + when(input.read(any(byte[].class), anyInt(), anyInt())).thenThrow(fatal); + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/admin") { + @Override + public ServletInputStream getInputStream() { + return input; + } + }; + request.setContentType("application/json"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + Error thrown = assertThrows(Error.class, () -> servlet.service(request, response)); + + assertSame(fatal, thrown); + assertEmptyInternalServerError(response); + verifyNoInteractions(adminJsonRpc); + } + + @Test + public void cleanupIOExceptionDoesNotReplaceFatalError() throws Exception { + HttpServletResponse response = mock(HttpServletResponse.class); + doThrow(new IOException("cleanup-io-marker")).when(response).flushBuffer(); + + assertFatalDispatch(new StackOverflowError("original-fatal-marker"), response); + + verify(response).flushBuffer(); + } + + @Test + public void cleanupErrorDoesNotReplaceFatalError() throws Exception { + HttpServletResponse response = mock(HttpServletResponse.class); + doThrow(new OutOfMemoryError("cleanup-error-marker")).when(response).flushBuffer(); + + assertFatalDispatch(new StackOverflowError("original-fatal-marker"), response); + + verify(response).flushBuffer(); + } + + @Test + public void fatalErrorDiscardsUncommittedOutput() throws Exception { + MockHttpServletResponse response = new MockHttpServletResponse(); + byte[] partial = "partial-response-marker".getBytes(StandardCharsets.UTF_8); + response.setContentLength(partial.length); + response.getOutputStream().write(partial); + assertFalse(response.isCommitted()); + + assertFatalDispatch(new StackOverflowError("fatal-marker"), response); + + assertEmptyInternalServerError(response); + } + + @Test + public void fatalErrorLeavesCommittedResponseUntouched() throws Exception { + HttpServletResponse response = mock(HttpServletResponse.class); + when(response.isCommitted()).thenReturn(true); + + assertFatalDispatch(new StackOverflowError("fatal-marker"), response); + + verify(response, never()).resetBuffer(); + verify(response, never()).setStatus(anyInt()); + verify(response, never()).setContentLength(anyInt()); + verify(response, never()).flushBuffer(); + } + + @Test(timeout = 30_000) + public void fatalErrorWithProductionFilterReturnsEmpty500() throws Exception { + TestAdminRpcHttpService service = new TestAdminRpcHttpService(); + service.setMaxRequestSize(REQUEST_LIMIT); + ReflectionTestUtils.setField(service, "adminRpcServlet", servlet); + try { + service.start().get(5, TimeUnit.SECONDS); + for (String accept : new String[] {"application/json", "text/html", "text/plain"}) { + clearInvocations(adminJsonRpc); + when(adminJsonRpc.adminExample("a", "b")) + .thenThrow(new StackOverflowError("fatal-response-marker")) + .thenReturn("a:b"); + + String response = service.post(REQUEST_PREFIX + "0}", false, accept); + + assertTrue(response, response.startsWith("HTTP/1.1 500 ")); + assertTrue(response, response.contains("\r\nContent-Length: 0\r\n")); + int headerEnd = response.indexOf("\r\n\r\n"); + assertTrue("Response must include complete headers", headerEnd >= 0); + assertEquals(accept, "", response.substring(headerEnd + 4)); + verify(adminJsonRpc).adminExample("a", "b"); + + // A later successful request must still return its JSON-RPC result. + String success = service.post(REQUEST_PREFIX + "0}", false, accept); + assertTrue(success, success.startsWith("HTTP/1.1 200 ")); + int successHeaderEnd = success.indexOf("\r\n\r\n"); + assertTrue("Response must include complete headers", successHeaderEnd >= 0); + JsonNode result = MAPPER.readTree(success.substring(successHeaderEnd + 4)); + assertEquals("a:b", result.path("result").asText()); + assertEquals(7, result.path("id").asInt()); + verify(adminJsonRpc, times(2)).adminExample("a", "b"); + verifyNoMoreInteractions(adminJsonRpc); + } + } finally { + service.stop().get(5, TimeUnit.SECONDS); + } + } + @Test public void nonJsonContentTypeIsRejected() throws Exception { MockHttpServletResponse response = doPost( @@ -414,6 +531,23 @@ private void setField(String name, Object value) throws Exception { field.set(servlet, value); } + private void assertFatalDispatch(Error fatal, HttpServletResponse response) throws Exception { + when(adminJsonRpc.adminExample("a", "b")).thenThrow(fatal); + + Error thrown = assertThrows(Error.class, + () -> doPost(REQUEST_PREFIX + "0}", "application/json", "localhost", response)); + + assertSame(fatal, thrown); + verify(adminJsonRpc).adminExample("a", "b"); + } + + private void assertEmptyInternalServerError(MockHttpServletResponse response) { + assertEquals(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, response.getStatus()); + assertEquals(0, response.getContentLength()); + assertEquals(0, response.getContentAsByteArray().length); + assertTrue(response.isCommitted()); + } + private void assertValidRequestWithExtra() throws Exception { when(adminJsonRpc.adminExample("a", "b")).thenReturn("a:b"); MockHttpServletResponse response = doPost(REQUEST_PREFIX + "[0]}"); @@ -465,6 +599,13 @@ private MockHttpServletResponse doPost(String body, String contentType) throws E private MockHttpServletResponse doPost(String body, String contentType, String host) throws Exception { + MockHttpServletResponse response = new MockHttpServletResponse(); + doPost(body, contentType, host, response); + return response; + } + + private void doPost(String body, String contentType, String host, HttpServletResponse response) + throws Exception { MockHttpServletRequest request = new MockHttpServletRequest("POST", "/admin"); request.setServletPath("/admin"); request.setRemoteAddr("127.0.0.1"); @@ -473,9 +614,7 @@ private MockHttpServletResponse doPost(String body, String contentType, String h if (host != null) { request.addHeader("Host", host); } - MockHttpServletResponse response = new MockHttpServletResponse(); servlet.service(request, response); - return response; } private void setVirtualHosts(String... hosts) throws Exception { @@ -495,6 +634,10 @@ protected void initServer() { } String post(String body, boolean chunked) throws Exception { + return post(body, chunked, null); + } + + String post(String body, boolean chunked, String accept) throws Exception { String framing = chunked ? "Transfer-Encoding: chunked\r\n" : "Content-Length: " + body.length() + "\r\n"; String content = chunked ? Integer.toHexString(body.length()) + "\r\n" @@ -502,6 +645,7 @@ String post(String body, boolean chunked) throws Exception { String request = "POST /admin HTTP/1.1\r\n" + "Host: 127.0.0.1\r\n" + "Content-Type: application/json\r\n" + + (accept == null ? "" : "Accept: " + accept + "\r\n") + "Connection: close\r\n" + framing + "\r\n" + content; return connector.getResponse(request, 5, TimeUnit.SECONDS); } From 24dc6b27911f0fb2b07f0c2eb671ee529ba2d37a Mon Sep 17 00:00:00 2001 From: 317787106 <317787106@qq.com> Date: Wed, 14 Oct 2026 00:45:34 +0800 Subject: [PATCH 17/19] bump org.jline:jline to 3.30.17 --- framework/build.gradle | 8 +--- gradle/verification-metadata.xml | 65 +++++++++++++++++++------------- plugins/build.gradle | 6 ++- 3 files changed, 46 insertions(+), 33 deletions(-) diff --git a/framework/build.gradle b/framework/build.gradle index 769df25acca..2bacacc3054 100644 --- a/framework/build.gradle +++ b/framework/build.gradle @@ -10,13 +10,8 @@ apply plugin: 'checkstyle' mainClassName = 'org.tron.program.FullNode' - - - - configurations { checkstyleConfig - } configurations.getByName('checkstyleConfig') { @@ -61,7 +56,8 @@ dependencies { implementation group: 'org.zeromq', name: 'jeromq', version: '0.5.3' implementation group: 'com.kohlschutter.junixsocket', name: 'junixsocket-core', version: '2.10.1' - implementation group: 'org.jline', name: 'jline', version: '3.21.0' + // Exclude the Java 22 FFM provider so both JDK 8 and JDK 17 can use the console. + implementation group: 'org.jline', name: 'jline', version: '3.30.17', classifier: 'jdk8' api project(":chainbase") api project(":protocol") api project(":actuator") diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index df204f61104..8c192db1b1d 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -1735,27 +1735,6 @@ - - - - - - - - - - - - - - - - - - - - - @@ -1777,6 +1756,27 @@ + + + + + + + + + + + + + + + + + + + + + @@ -1832,16 +1832,16 @@ - - - - - + + + + + @@ -2405,11 +2405,24 @@ + + + + + + + + + + + + + diff --git a/plugins/build.gradle b/plugins/build.gradle index c6881f3131c..23e1a5394b1 100644 --- a/plugins/build.gradle +++ b/plugins/build.gradle @@ -49,7 +49,11 @@ dependencies { } implementation group: 'info.picocli', name: 'picocli', version: '4.6.3' implementation group: 'com.typesafe', name: 'config', version: '1.3.2' - implementation group: 'me.tongfei', name: 'progressbar', version: '0.9.3' + implementation(group: 'me.tongfei', name: 'progressbar', version: '0.9.3') { + exclude group: 'org.jline', module: 'jline' + } + // Use the same Java 8-compatible bundle as the Admin console, without Java 22 FFM classes. + implementation group: 'org.jline', name: 'jline', version: '3.30.17', classifier: 'jdk8' implementation group: 'org.bouncycastle', name: 'bcprov-jdk18on', version: '1.84' if (rootProject.archInfo.isArm64) { testRuntimeOnly group: 'org.fusesource.hawtjni', name: 'hawtjni-runtime', version: '1.18' // for test From 9aa09c1d7dc0082dbe2339585c0839e52afd7989 Mon Sep 17 00:00:00 2001 From: 317787106 <317787106@qq.com> Date: Wed, 14 Oct 2026 01:16:50 +0800 Subject: [PATCH 18/19] bump junixsocket-core to 2.11.1 --- framework/build.gradle | 2 +- gradle/verification-metadata.xml | 65 +++++++++++++++----------------- 2 files changed, 31 insertions(+), 36 deletions(-) diff --git a/framework/build.gradle b/framework/build.gradle index 2bacacc3054..8aac9f64f90 100644 --- a/framework/build.gradle +++ b/framework/build.gradle @@ -55,7 +55,7 @@ dependencies { testImplementation group: 'javax.portlet', name: 'portlet-api', version: '3.0.1' implementation group: 'org.zeromq', name: 'jeromq', version: '0.5.3' - implementation group: 'com.kohlschutter.junixsocket', name: 'junixsocket-core', version: '2.10.1' + implementation group: 'com.kohlschutter.junixsocket', name: 'junixsocket-core', version: '2.11.1' // Exclude the Java 22 FFM provider so both JDK 8 and JDK 17 can use the console. implementation group: 'org.jline', name: 'jline', version: '3.30.17', classifier: 'jdk8' api project(":chainbase") diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 8c192db1b1d..7537499054b 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -886,40 +886,40 @@ - - - + + + - - - + + + - - - + + + - - - + + + - - + + - - - + + + - - - + + + - - + + @@ -2397,14 +2397,6 @@ - - - - - - - - @@ -2413,11 +2405,6 @@ - - - - - @@ -2514,6 +2501,14 @@ + + + + + + + + From 6d51360423ab2acb8e69a76d4a44c5d287dd99f7 Mon Sep 17 00:00:00 2001 From: 317787106 <317787106@qq.com> Date: Wed, 14 Oct 2026 17:16:51 +0800 Subject: [PATCH 19/19] fix AdminJsonRpcRequestTest due to bump jsonrpc4j 1.7 --- .../ipc/server/AdminJsonRpcRequestTest.java | 28 +++++++++++-------- gradle/verification-metadata.xml | 3 ++ 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/framework/src/test/java/org/tron/core/services/admin/ipc/server/AdminJsonRpcRequestTest.java b/framework/src/test/java/org/tron/core/services/admin/ipc/server/AdminJsonRpcRequestTest.java index 2fe8c34bfe4..d70d258b6bf 100644 --- a/framework/src/test/java/org/tron/core/services/admin/ipc/server/AdminJsonRpcRequestTest.java +++ b/framework/src/test/java/org/tron/core/services/admin/ipc/server/AdminJsonRpcRequestTest.java @@ -183,18 +183,24 @@ public void invalidMethodParamsNotificationHasNoResponse() throws Exception { @Test public void parameterBindingFailuresRetainIdsAndSilenceNotifications() throws Exception { - for (String params : new String[] {"[{},\"b\"]", "[\"a\",[]]", - "{\"param1\":{},\"param2\":\"b\"}", "{\"param1\":\"a\",\"param2\":[]}"}) { - String request = REQUEST.replace("[\"a\",\"b\"]", params); - for (String id : new String[] {"9", "null", "\"request\"", "9223372036854775807", - "0.12345678901234567890123456789"}) { - JsonNode error = assertError(sender.send(request + ",\"id\":" + id + "}"), -32602, id) - .get("error"); - Assert.assertEquals("method parameters invalid", error.get("message").asText()); - Assert.assertEquals(2, error.size()); + String[][] invalidParams = { + {"[{},\"b\"]", "{\"param1\":{},\"param2\":\"b\"}"}, + {"[\"a\",[]]", "{\"param1\":\"a\",\"param2\":[]}"} + }; + for (int index = 0; index < invalidParams.length; index++) { + for (String params : invalidParams[index]) { + String request = REQUEST.replace("[\"a\",\"b\"]", params); + for (String id : new String[] {"9", "null", "\"request\"", "9223372036854775807", + "0.12345678901234567890123456789"}) { + JsonNode error = assertError(sender.send(request + ",\"id\":" + id + "}"), -32602, id) + .get("error"); + Assert.assertEquals("Failed to read method parameter at index " + index, + error.get("message").asText()); + Assert.assertEquals(2, error.size()); + } + Assert.assertEquals("", sender.send(request + "}")); + Mockito.verifyNoInteractions(adminJsonRpc); } - Assert.assertEquals("", sender.send(request + "}")); - Mockito.verifyNoInteractions(adminJsonRpc); } } diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 59de4ea01fe..c231bcaffda 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -234,6 +234,9 @@ + + +