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

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

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

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

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

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