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 e64a92ad4b2..47f0bf08804 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; @@ -13,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; @@ -480,6 +482,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 d35f123696f..90ec401fa48 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(); @@ -254,6 +257,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 633632ef2c6..28228e38a08 100644 --- a/common/src/main/resources/reference.conf +++ b/common/src/main/resources/reference.conf @@ -441,10 +441,48 @@ 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, 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 + # 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. + # 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"] + } + } + # 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 76589b4a82b..adad7de3a9f 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 @@ -8,6 +8,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; @@ -31,6 +33,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()); @@ -80,6 +89,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/docs/configuration.md b/docs/configuration.md index 28baf408375..ba60d0b31dd 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -210,6 +210,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/build.gradle b/framework/build.gradle index cce59231825..960227b938d 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') { @@ -62,6 +57,9 @@ dependencies { testImplementation group: 'jakarta.servlet', name: 'jakarta.servlet-api', version: '5.0.0' implementation group: 'org.zeromq', name: 'jeromq', version: '0.5.3' + 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") api project(":protocol") api project(":actuator") @@ -141,6 +139,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 a61cd4c6b46..7060f1f135a 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 @@ -88,7 +91,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) { ConnectionLimit connectionLimit = 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 3f51dc11456..a594576c96d 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 @@ -145,20 +145,25 @@ 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); + } + + /** + * Initializes node configuration from parsed options. FullNode handles attach mode separately. + */ + 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); } cmd.validateRemovedOptions(); + List assignedParameters = arguments.getAssignedParameters(); // Resolve config file path configFilePath = StringUtils.isNoneBlank(cmd.shellConfFileName) @@ -169,7 +174,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); @@ -560,6 +565,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(); @@ -768,14 +783,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()) @@ -1295,7 +1309,7 @@ private static String getCommitIdAbbrev() { private static Map getOptionGroup() { String[] tronOption = new String[] {"version", "help", "shellConfFileName", "logbackPath", - "eventSubscribe", "solidityNode"}; + "eventSubscribe", "solidityNode", "ipcSocketFile", "ipcExecCommand"}; String[] dbOption = new String[] {"outputDirectory"}; String[] witnessOption = new String[] {"witness", "privateKey"}; String[] vmOption = new String[] {"debug"}; 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 74742707254..29e47e6a7f2 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 @@ -62,6 +62,14 @@ public class CLIParameter { @Parameter(names = {"--keystore-factory"}, description = "removed; use Toolkit.jar keystore") 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..07f8b8d2fe9 --- /dev/null +++ b/framework/src/main/java/org/tron/core/config/args/CommandLineArguments.java @@ -0,0 +1,60 @@ +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; + } + parameters.validateRemovedOptions(); + 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..01ee98ec585 --- /dev/null +++ b/framework/src/main/java/org/tron/core/services/admin/AdminJsonRpc.java @@ -0,0 +1,23 @@ +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; + +/** + * 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"; + + @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/AdminJsonRpcRequestHandler.java b/framework/src/main/java/org/tron/core/services/admin/AdminJsonRpcRequestHandler.java new file mode 100644 index 00000000000..a5de694f25d --- /dev/null +++ b/framework/src/main/java/org/tron/core/services/admin/AdminJsonRpcRequestHandler.java @@ -0,0 +1,125 @@ +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.exc.MismatchedInputException; +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; + 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; + } + ((ObjectNode) response).set("id", id); + return response; + } catch (Exception e) { + logger.debug("Failed to dispatch Admin request"); + return notification ? null : errorResponse(JsonRpcErrorResolver.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/AdminRpcHttpService.java b/framework/src/main/java/org/tron/core/services/admin/http/AdminRpcHttpService.java new file mode 100644 index 00000000000..ce9fa03eede --- /dev/null +++ b/framework/src/main/java/org/tron/core/services/admin/http/AdminRpcHttpService.java @@ -0,0 +1,57 @@ +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 = "/"; + maxRequestSize = Args.getInstance().getJsonRpcMaxMessageSize(); + } + + @Override + public void innerStart() throws Exception { + if (enable && !InetUtil.isLoopbackAddress(listenAddress)) { + logger.warn("Admin HTTP is enabled on {} and may be accessible remotely. " + + "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(); + } + + @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..116a5eea1dc --- /dev/null +++ b/framework/src/main/java/org/tron/core/services/admin/http/AdminRpcServlet.java @@ -0,0 +1,120 @@ +package org.tron.core.services.admin.http; + +import com.google.common.io.ByteStreams; +import com.googlecode.jsonrpc4j.JsonRpcInterceptor; +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.admin.AdminJsonRpcRequestHandler; +import org.tron.core.services.jsonrpc.JsonRpcHttpResponse; +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 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}; + * JSON parser limits come from {@link JsonRpcMapper}. + */ +@Component +@Slf4j(topic = "API") +public class AdminRpcServlet extends HttpServlet { + + private static final long serialVersionUID = 0L; + private AdminJsonRpcRequestHandler requestHandler; + 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); + + requestHandler = new AdminJsonRpcRequestHandler(adminJsonRpc); + if (CommonParameter.getInstance().isMetricsPrometheusEnable()) { + requestHandler.setInterceptorList(Collections.singletonList(interceptor)); + } + virtualHostValidator = new VirtualHostValidator( + 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); + } + } + + /** + * Validates HTTP headers and JSON input before dispatching through jsonrpc4j. + */ + @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"); + 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; + } + byte[] body = ByteStreams.toByteArray(req.getInputStream()); + writeResponse(resp, requestHandler.handleRequest(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/http/VirtualHostValidator.java b/framework/src/main/java/org/tron/core/services/admin/http/VirtualHostValidator.java new file mode 100644 index 00000000000..728f014f4a0 --- /dev/null +++ b/framework/src/main/java/org/tron/core/services/admin/http/VirtualHostValidator.java @@ -0,0 +1,91 @@ +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. + * 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 { + + 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..693c6f9e627 --- /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, command.getRequestId()); + 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..de4c436d5fa --- /dev/null +++ b/framework/src/main/java/org/tron/core/services/admin/ipc/client/IpcConsoleCommands.java @@ -0,0 +1,250 @@ +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, null); + } + String name = words.get(0).toLowerCase(Locale.ROOT); + if ("exit".equals(name) || "quit".equals(name)) { + 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, null, help, null); + } + AdminCommand command = adminCommands.get(name); + if (command == null) { + return new Command(Action.ERROR, null, null, buildHelp(), "Invalid command."); + } + 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); + int id = ++requestId; + request.put("id", id); + return new Command(Action.REQUEST, OBJECT_MAPPER.writeValueAsString(request), id, 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, null, message); + } + + enum Action { + EMPTY, EXIT, HELP, REQUEST, ERROR + } + + /** 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; + } + + @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..3dc31c62312 --- /dev/null +++ b/framework/src/main/java/org/tron/core/services/admin/ipc/client/IpcResponse.java @@ -0,0 +1,90 @@ +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; +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; + } + + /** + * Validates a JSON-RPC response and formats it for the interactive console. + * + *

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.reader() + .with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS).readTree(response); + if (!isValidEnvelope(root)) { + return invalidResponse(); + } + 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("error")) { + return parseError(root.get("error")); + } + // only output column "result" of jsonrpc and ignore other columns + return new IpcResponse(formatJsonValue(root.get("result")), true); + } catch (JsonProcessingException e) { + 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 { + 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..f92cb37243e --- /dev/null +++ b/framework/src/main/java/org/tron/core/services/admin/ipc/server/IpcRequestHandler.java @@ -0,0 +1,67 @@ +package org.tron.core.services.admin.ipc.server; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import lombok.AccessLevel; +import lombok.Getter; +import org.tron.core.services.admin.AdminJsonRpc; +import org.tron.core.services.admin.AdminJsonRpcRequestHandler; + +/** + * 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}. + */ +final class IpcRequestHandler { + + private final AdminJsonRpcRequestHandler requestHandler; + @Getter(AccessLevel.PACKAGE) + private final long maxRequestSize; + + IpcRequestHandler(AdminJsonRpc adminJsonRpc, long maxRequestSize) { + this.maxRequestSize = maxRequestSize; + requestHandler = new AdminJsonRpcRequestHandler(adminJsonRpc); + } + + String readRequest(InputStream input) throws IOException { + ByteArrayOutputStream request = new ByteArrayOutputStream(); + int value; + while ((value = input.read()) != -1) { + if (value == '\n') { + break; + } + // 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); + } + if (value == -1 && request.size() == 0) { + return null; + } + byte[] bytes = request.toByteArray(); + int length = bytes.length; + 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); + } + + 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 { + + 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..aa3973b91d1 --- /dev/null +++ b/framework/src/main/java/org/tron/core/services/admin/ipc/server/IpcService.java @@ -0,0 +1,307 @@ +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; + // Record paths only after this instance successfully creates the corresponding resource. + private Path socketDirectoryPath; + private Path socketFilePath; + + private volatile boolean isRunning; + + @Autowired + public IpcService(AdminJsonRpc adminJsonRpc) { + enable = isFullNode() && Args.getInstance().isIpcEnable(); + requestHandler = new IpcRequestHandler(adminJsonRpc, + Args.getInstance().getJsonRpcMaxMessageSize()); + } + + @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(); + Path resolvedSocketFile = socketFiles.resolveSocketFilePath(parameter.getOutputDirectory(), + parameter.getIpcSocketDirectory(), getPid()); + Path socketDirectory = resolvedSocketFile.getParent(); + socketFiles.validateSocketRootDirectory(socketDirectory.getParent()); + try { + 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); + + 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, + this::deleteSocketFiles); + + if (failure != null) { + throw failure; + } + logger.info("IpcService stopped"); + } + + private void closeServerSocket() throws IOException { + if (unixServerSocket != null) { + AFUNIXServerSocket serverSocket = unixServerSocket; + unixServerSocket = null; + serverSocket.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) { + 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; + } + } + + /** + * 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..6bbda4ab602 --- /dev/null +++ b/framework/src/main/java/org/tron/core/services/admin/ipc/server/IpcSocketFiles.java @@ -0,0 +1,104 @@ +package org.tron.core.services.admin.ipc.server; + +import java.io.IOException; +import java.nio.charset.Charset; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +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. + * 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 { + + 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 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) { + // 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); + } + } + + 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 socketDirectory) throws IOException { + if (socketDirectory != null) { + Files.deleteIfExists(socketDirectory); + } + } +} 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/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/JsonRpcMapper.java b/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcMapper.java new file mode 100644 index 00000000000..6d461798907 --- /dev/null +++ b/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcMapper.java @@ -0,0 +1,36 @@ +package org.tron.core.services.jsonrpc; + +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 { + + 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); + } + + /** 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/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 615b714df00..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 @@ -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.http.servlets.RateLimiterServlet; @@ -33,19 +30,9 @@ @Slf4j(topic = "API") public class JsonRpcServlet extends RateLimiterServlet { - private static final ObjectMapper MAPPER = buildMapper(); + private static final ObjectMapper MAPPER = JsonRpcMapper.create(); private static final ObjectMapper OUTBOUND_MAPPER = new ObjectMapper(); - 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 enum JsonRpcError { PARSE_ERROR(-32700), INVALID_REQUEST(-32600), @@ -98,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; } } @@ -329,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/main/java/org/tron/program/FullNode.java b/framework/src/main/java/org/tron/program/FullNode.java index 46c525fd476..52509e0581f 100644 --- a/framework/src/main/java/org/tron/program/FullNode.java +++ b/framework/src/main/java/org/tron/program/FullNode.java @@ -1,7 +1,9 @@ package org.tron.program; -import lombok.extern.slf4j.Slf4j; +import com.beust.jcommander.ParameterException; 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 +15,28 @@ 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) { + CommandLineArguments arguments = handleCommandLine(args); + if (arguments == null) { + 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.isSolidityNode()) { logger.info("Solidity node is running."); @@ -63,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/main/resources/config.conf b/framework/src/main/resources/config.conf index 327a89823c6..64e70964a6f 100644 --- a/framework/src/main/resources/config.conf +++ b/framework/src/main/resources/config.conf @@ -160,6 +160,16 @@ node { maxMessageSize = 4194304 } + # Local administrative API over a Unix-domain socket. Disabled by default. + admin { + ipc { + enable = false + # Existing absolute parent directory; empty uses output-directory. + # The ipc subdirectory must not exist at startup. + socketDirectory = "" + } + } + disabledApi = [ # "getaccount", # "getnowblock2" 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 107625d4893..07714b240ad 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 @@ -330,6 +330,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..e938e8ae0b8 --- /dev/null +++ b/framework/src/test/java/org/tron/core/services/admin/http/AdminRpcServletTest.java @@ -0,0 +1,654 @@ +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.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; +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; +import org.tron.core.services.jsonrpc.JsonRpcMediaType; +import org.tron.core.services.ratelimiter.GlobalRateLimiter; + +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\":"; + + private AdminRpcServlet servlet; + private AdminJsonRpc adminJsonRpc; + + @Before + public void setUp() throws Exception { + 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 { + assertValidRequestWithExtra(); + 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(']'); + } + + assertParseError(doPost(REQUEST_PREFIX + request + "}")); + } + + @Test + public void requestWithTooManyTokensIsRejected() throws Exception { + assertValidRequestWithExtra(); + 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(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, "[" + 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"); + 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 + 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( + "{\"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()); + } + + @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); + 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]}"); + 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 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"); + } + + 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 { + 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"); + request.setContentType(contentType); + request.setContent(body.getBytes(StandardCharsets.UTF_8)); + if (host != null) { + request.addHeader("Host", host); + } + servlet.service(request, response); + } + + 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 { + 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" + + 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" + + (accept == null ? "" : "Accept: " + accept + "\r\n") + + "Connection: close\r\n" + framing + "\r\n" + content; + return connector.getResponse(request, 5, TimeUnit.SECONDS); + } + } + +} 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..007699dc9a3 --- /dev/null +++ b/framework/src/test/java/org/tron/core/services/admin/ipc/client/IpcClientTest.java @@ -0,0 +1,471 @@ +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.Arrays; +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 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 + 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 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); + 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)); + 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); + + 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 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); + 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..d8f51ed95a5 --- /dev/null +++ b/framework/src/test/java/org/tron/core/services/admin/ipc/client/IpcConsoleCommandsTest.java @@ -0,0 +1,227 @@ +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.getRequestId()); + 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.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()); + } + } + + @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 command."); + 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()); + 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()); + } + + 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..b2d6c60b5fb --- /dev/null +++ b/framework/src/test/java/org/tron/core/services/admin/ipc/client/IpcResponseTest.java @@ -0,0 +1,124 @@ +package org.tron.core.services.admin.ipc.client; + +import org.junit.Assert; +import org.junit.Test; + +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); + } + + @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(RESPONSE_PREFIX + "\"result\":null}", "null", true); + assertResponse(RESPONSE_PREFIX + "\"result\":false}", "false", true); + assertResponse(RESPONSE_PREFIX + "\"result\":42}", "42", true); + } + + @Test + public void testValidRpcErrorsAreFormattedAndFail() { + assertResponse(RESPONSE_PREFIX + + "\"error\":{\"code\":-32602,\"message\":\"Invalid params\",\"data\":{}}}", + "Error -32602: Invalid params", false); + assertResponse("{\"jsonrpc\":\"2.0\",\"id\":null," + + "\"error\":{\"code\":-32700,\"message\":\"Parse error\"}}", + "Error -32700: Parse error", false); + } + + @Test + public void testMalformedResponseUsesFixedMessageAndFails() { + for (String input : new String[] {"", " ", "response", "{broken json"}) { + 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 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) { + 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/AdminJsonRpcRequestTest.java b/framework/src/test/java/org/tron/core/services/admin/ipc/server/AdminJsonRpcRequestTest.java new file mode 100644 index 00000000000..d70d258b6bf --- /dev/null +++ b/framework/src/test/java/org/tron/core/services/admin/ipc/server/AdminJsonRpcRequestTest.java @@ -0,0 +1,338 @@ +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; +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 parameterBindingFailuresRetainIdsAndSilenceNotifications() throws Exception { + 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); + } + } + } + + @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()); + 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") + + ",\"id\":" + id + "}"), -32601, id); + Mockito.verifyNoInteractions(adminJsonRpc); + } + } + + @Test + public void unmappedInvocationFailureIsSanitizedAndSilencesNotifications() throws Exception { + 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); + } + } + + @Test + public void dispatcherFailureReturnsSanitizedErrorAndSilencesNotifications() throws Exception { + JsonRpcInterceptor interceptor = Mockito.mock(JsonRpcInterceptor.class); + requestHandler.setInterceptorList(Collections.singletonList(interceptor)); + 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); + } + } + + @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 + 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 new file mode 100644 index 00000000000..a3db639da2e --- /dev/null +++ b/framework/src/test/java/org/tron/core/services/admin/ipc/server/IpcRequestHandlerTest.java @@ -0,0 +1,287 @@ +package org.tron.core.services.admin.ipc.server; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import org.junit.Assert; +import org.junit.Test; +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 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); + + @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 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 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); + 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 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(']'); + } + 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, "[" + valid, + "[" + 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[] 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) + 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); + 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"); + } catch (RequestTooLargeException expected) { + // A character count would incorrectly accept both characters. + } + } + + @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]))); + 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)))); + } + } + + 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")); + } + + 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); + } +} 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..a8a272190c8 --- /dev/null +++ b/framework/src/test/java/org/tron/core/services/admin/ipc/server/IpcServiceTest.java @@ -0,0 +1,882 @@ +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.management.ManagementFactory; +import java.lang.management.RuntimeMXBean; +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.LinkOption; +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.exception.TronError; +import org.tron.core.services.admin.AdminJsonRpc; +import org.tron.core.services.admin.AdminJsonRpcImpl; + +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 + public void testServiceIsNotRunningBeforeStart() throws Exception { + Assert.assertFalse(isRunning(newIpcService())); + } + + @Test(timeout = 10_000) + public void testRequestSizeUsesJsonRpcConfiguration() throws Exception { + assumePosixFileSystem(); + CommonParameter parameter = Args.getInstance(); + 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); + } + } + } + + @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 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, directory); + 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, root.resolve("ipc")); + } + 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(); + 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); + setField(service, "socketDirectoryPath", socketDirectory); + 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")); + setField(service, "socketDirectoryPath", socketDirectory); + + 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); + Assert.assertTrue("Unrelated files must survive cleanup", Files.exists(childFile)); + } 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 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)); + } + } + + 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; + 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..ac51314e1de --- /dev/null +++ b/framework/src/test/java/org/tron/core/services/admin/ipc/server/IpcSocketFilesTest.java @@ -0,0 +1,257 @@ +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 java.util.Set; +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 testCreateSocketDirectoryRejectsRegularFile() throws Exception { + assumePosixFileSystem(); + Path outputDirectory = Files.createTempDirectory("ipc-regular-file-test-"); + Path socketDirectory = outputDirectory.resolve("ipc"); + Files.createFile(socketDirectory); + try { + assertExistingPathRejected(socketDirectory); + Assert.assertTrue(Files.isRegularFile(socketDirectory, LinkOption.NOFOLLOW_LINKS)); + } finally { + Files.deleteIfExists(socketDirectory); + Files.deleteIfExists(outputDirectory); + } + } + + @Test + public void testCreateSocketDirectoryRejectsSymbolicLink() 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 { + 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); + Files.deleteIfExists(outputDirectory); + } + } + + @Test + public void testCreateSocketDirectoryUsesOwnerOnlyPermissions() throws Exception { + assumePosixFileSystem(); + Path outputDirectory = Files.createTempDirectory("ipc-directory-test-"); + Path socketDirectory = outputDirectory.resolve("ipc"); + try { + socketFiles.createSocketDirectory(socketDirectory); + + Assert.assertTrue(Files.isDirectory(socketDirectory)); + Assert.assertEquals( + EnumSet.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE, + PosixFilePermission.OWNER_EXECUTE), + Files.getPosixFilePermissions(socketDirectory)); + } 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(); + 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(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(directory); + socketFiles.deleteSocketFile(null); + socketFiles.deleteSocketDirectory(null); + Assert.assertTrue(Files.isDirectory(root)); + } finally { + Files.deleteIfExists(socketFile); + Files.deleteIfExists(directory); + Files.deleteIfExists(root); + } + } + + 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.assertTrue(e.getMessage().contains(path.toString())); + } + } + + 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..ddb21d6bc97 --- /dev/null +++ b/framework/src/test/java/org/tron/program/FullNodeTest.java @@ -0,0 +1,252 @@ +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.application.Application; +import org.tron.common.application.ApplicationFactory; +import org.tron.common.application.TronApplicationContext; +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.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; + +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.logbackPath = "node-logback.xml"; + Application application = Mockito.mock(Application.class); + 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 metrics = Mockito.mockStatic(Metrics.class); + MockedStatic factory = Mockito.mockStatic(ApplicationFactory.class); + MockedConstruction contexts = + Mockito.mockConstruction(TronApplicationContext.class); + MockedConstruction ipcClients = Mockito.mockConstruction(IpcClient.class)) { + parameters.when(CommonParameter::getInstance).thenReturn(parameter); + factory.when(() -> ApplicationFactory.create(Mockito.any())).thenReturn(application); + + FullNode.main(new String[] {"--log-config", "node-logback.xml"}); + + exitManager.verify(ExitManager::initExceptionHandler); + arch.verify(Arch::throwIfUnsupportedJavaVersion); + args.verify(() -> Args.setParam(Mockito.argThat((CommandLineArguments parsed) -> + "node-logback.xml".equals(parsed.getParameters().logbackPath)), + Mockito.eq("config.conf"))); + logService.verify(() -> LogService.load("node-logback.xml")); + metrics.verify(Metrics::init); + Assert.assertEquals(1, contexts.constructed().size()); + Mockito.verify(contexts.constructed().get(0)).refresh(); + Mockito.verify(application).startup(); + Mockito.verify(application).blockUntilShutdown(); + 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 { + assertArgumentError("Error: --attach requires a non-empty ", + "--attach", ""); + assertArgumentError("Error: --attach requires a non-empty ", + "--attach", " "); + assertArgumentError("Error: --attach cannot be combined with: --config", + "--attach", "missing.sock", "--config", "config.conf"); + 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 { + String migration = "--keystore-factory was removed." + System.lineSeparator() + + "Use: java -jar Toolkit.jar keystore "; + assertArgumentError(migration, + "--attach", "missing.sock", "--keystore-factory"); + 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")); + 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 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"); + command.add(classpath); + command.add(FullNode.class.getName()); + command.addAll(Arrays.asList(args)); + Process process = new ProcessBuilder(command).directory(directory.toFile()) + .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 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", + process.waitFor(5, TimeUnit.SECONDS)); + } + } +} diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index b2544eb9f9b..c231bcaffda 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -234,6 +234,9 @@ + + + @@ -883,6 +886,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1699,27 +1738,6 @@ - - - - - - - - - - - - - - - - - - - - - @@ -1741,6 +1759,27 @@ + + + + + + + + + + + + + + + + + + + + + @@ -1796,16 +1835,16 @@ - - - - - + + + + + @@ -2366,17 +2405,17 @@ - - - + + + - - + + - - - + + + @@ -2414,6 +2453,14 @@ + + + + + + + + @@ -2470,6 +2517,14 @@ + + + + + + + + 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