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..ab98c1be5f5 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,24 @@ public class CommonParameter { public int jsonRpcMaxLogFilterNum = 20000; @Getter @Setter + public boolean adminRpcEnable = false; + @Getter + @Setter + public String adminListenAddress = Constant.LOCAL_HOST; + @Getter + @Setter + public int adminListenPort = 8575; + @Getter + @Setter + public List adminVirtualHosts = 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..79a6f4de6f5 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 AdminRpcConfig rpc = new AdminRpcConfig(); + } + + @Getter + @Setter + public static class AdminIpcConfig { + + private boolean enable = false; + private String socketDirectory = ""; + } + + @Getter + @Setter + public static class AdminRpcConfig { + + 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..2ee42f4a803 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. + rpc { + # 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..6ca43981fde 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().getRpc().isEnable()); + assertEquals("127.0.0.1", nc.getAdmin().getRpc().getListenAddress()); + assertEquals(8575, nc.getAdmin().getRpc().getPort()); + assertEquals(Collections.singletonList("localhost"), + nc.getAdmin().getRpc().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 testAdminRpcAndIpcBinding() { + Config config = withRef( + "node.admin { ipc { enable = true, socketDirectory = \"/tmp/tron-ipc\" }," + + " rpc { 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().getRpc().isEnable()); + assertEquals("127.0.0.2", nc.getAdmin().getRpc().getListenAddress()); + assertEquals(18575, nc.getAdmin().getRpc().getPort()); + assertEquals(Arrays.asList("admin.example.com", "localhost"), + nc.getAdmin().getRpc().getVirtualHosts()); + } + @Test public void testBackupSubBean() { Config config = withRef( diff --git a/framework/build.gradle b/framework/build.gradle index 8255fc30d18..df5ad1d699c 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") 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..d8cddff2109 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 @@ -159,6 +165,10 @@ public static void setParam(final String[] args, final String confFileName) { Args.printHelp(jc); exit(0); } + List assignedParameters = getAssignedParameters(jc); + if (tryApplyAttachParams(cmd, assignedParameters)) { + return; + } // Resolve config file path configFilePath = StringUtils.isNoneBlank(cmd.shellConfFileName) @@ -169,7 +179,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); @@ -181,6 +191,47 @@ public static void setParam(final String[] args, final String confFileName) { initLocalWitnesses(config, cmd); } + private static List getAssignedParameters(JCommander jc) { + return jc.getParameters().stream() + .filter(ParameterDescription::isAssigned) + .collect(Collectors.toList()); + } + + private static boolean tryApplyAttachParams(CLIParameter cmd, + List assignedParameters) { + boolean attachAssigned = isParameterAssigned(assignedParameters, "ipcSocketFile"); + if (!attachAssigned) { + if (isParameterAssigned(assignedParameters, "ipcExecCommand")) { + throwAttachParameterError("Error: --exec requires --attach "); + } + return false; + } + if (isParameterAssigned(assignedParameters, "shellConfFileName")) { + throwAttachParameterError("Error: --attach cannot be combined with: --config"); + } + if (StringUtils.isBlank(cmd.ipcSocketFile)) { + throwAttachParameterError("Error: --attach requires a non-empty "); + } + // Node-only CLI options are irrelevant to the standalone IPC client and are ignored. + ipcSocketFile = cmd.ipcSocketFile; + ipcExecCommand = cmd.ipcExecCommand; + if (StringUtils.isNotEmpty(cmd.logbackPath)) { + PARAMETER.logbackPath = cmd.logbackPath; + } + return true; + } + + private static void throwAttachParameterError(String message) { + System.err.println(message); + throw new TronError(message, TronError.ErrCode.PARAMETER_INIT); + } + + private static boolean isParameterAssigned(List assignedParameters, + String fieldName) { + return assignedParameters.stream() + .anyMatch(pd -> fieldName.equals(pd.getParameterized().getName())); + } + /** * Bridge VmConfig bean values to CommonParameter fields. * Temporary until Phase 2 moves fields into domain config objects. @@ -561,6 +612,16 @@ private static void applyNodeConfig(NodeConfig nc) { PARAMETER.jsonRpcMaxLogFilterNum = jsonrpc.getMaxLogFilterNum(); PARAMETER.jsonRpcMaxMessageSize = jsonrpc.getMaxMessageSize(); + // ---- Admin RPC / IPC ---- + NodeConfig.AdminIpcConfig adminIpc = nc.getAdmin().getIpc(); + NodeConfig.AdminRpcConfig adminRpc = nc.getAdmin().getRpc(); + PARAMETER.adminRpcEnable = adminRpc.isEnable(); + PARAMETER.adminListenAddress = adminRpc.getListenAddress(); + PARAMETER.adminListenPort = adminRpc.getPort(); + PARAMETER.adminVirtualHosts = new ArrayList<>(adminRpc.getVirtualHosts()); + PARAMETER.ipcEnable = adminIpc.isEnable(); + PARAMETER.ipcSocketDirectory = adminIpc.getSocketDirectory(); + // ---- P2P sub-bean ---- PARAMETER.nodeP2pVersion = nc.getP2p().getVersion(); @@ -769,14 +830,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 +1006,8 @@ public static void clearParam() { rateLimiterConfig = null; metricsConfig = null; eventConfig = null; + ipcSocketFile = null; + ipcExecCommand = null; } // getProposalExpirationTime removed — logic moved to BlockConfig.fromConfig() @@ -1292,7 +1354,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 +1378,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/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..d4c6cc5f33e --- /dev/null +++ b/framework/src/main/java/org/tron/core/services/admin/http/AdminRpcHttpService.java @@ -0,0 +1,63 @@ +package org.tron.core.services.admin.http; + +import java.net.InetAddress; +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().isAdminRpcEnable(); + listenAddress = Args.getInstance().getAdminListenAddress(); + port = Args.getInstance().getAdminListenPort(); + contextPath = "/"; + } + + @Override + public void innerStart() throws Exception { + if (enable && !isLoopbackListenAddress(listenAddress)) { + logger.warn("Admin RPC is enabled on {} and may be accessible remotely. " + + "Restrict access to trusted networks.", listenAddress); + } + super.innerStart(); + } + + static boolean isLoopbackListenAddress(String listenAddress) { + if (listenAddress == null) { + return false; + } + InetAddress address = InetUtil.resolveInetAddress(listenAddress); + return address != null && address.isLoopbackAddress(); + } + + @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..3d2b1ab10f0 --- /dev/null +++ b/framework/src/main/java/org/tron/core/services/admin/http/AdminRpcServlet.java @@ -0,0 +1,159 @@ +package org.tron.core.services.admin.http; + +import com.google.common.net.InetAddresses; +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 java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +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; + +@Component +@Slf4j(topic = "API") +public class AdminRpcServlet extends RateLimiterServlet { + + private static final long serialVersionUID = 0L; + + private JsonRpcServer rpcServer = null; + private Set virtualHosts = Collections.emptySet(); + + @Autowired + private AdminJsonRpc adminJsonRpc; + + @Autowired + private JsonRpcInterceptor interceptor; + + @Override + public void init(ServletConfig config) throws ServletException { + super.init(config); + + ClassLoader cl = Thread.currentThread().getContextClassLoader(); + Object compositeService = ProxyUtil.createCompositeServiceProxy(cl, + new Object[] {adminJsonRpc}, + new Class[] {AdminJsonRpc.class}, + true); + + rpcServer = new JsonRpcServer(JsonRpcMapper.create(), compositeService); + rpcServer.setErrorResolver(JsonRpcErrorResolver.INSTANCE); + + 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)); + } + virtualHosts = normalizeVirtualHosts(CommonParameter.getInstance().getAdminVirtualHosts()); + } + + @Override + protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException { + if (!isAllowedHost(req.getHeader("Host"))) { + resp.sendError(HttpServletResponse.SC_FORBIDDEN, "Invalid Host header"); + return; + } + if (!JsonRpcMediaType.isSupported(req.getContentType())) { + resp.setStatus(HttpServletResponse.SC_UNSUPPORTED_MEDIA_TYPE); + resp.setContentLength(0); + return; + } + rpcServer.handle(req, resp); + } + + private 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); + } + + // IPv4 + 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/IpcClient.java b/framework/src/main/java/org/tron/core/services/admin/ipc/IpcClient.java new file mode 100644 index 00000000000..66c535c9197 --- /dev/null +++ b/framework/src/main/java/org/tron/core/services/admin/ipc/IpcClient.java @@ -0,0 +1,551 @@ +package org.tron.core.services.admin.ipc; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.googlecode.jsonrpc4j.JsonRpcMethod; +import com.googlecode.jsonrpc4j.JsonRpcParam; +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.lang.annotation.Annotation; +import java.lang.reflect.Method; +import java.lang.reflect.Type; +import java.net.Socket; +import java.net.SocketTimeoutException; +import java.nio.charset.StandardCharsets; +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 java.util.concurrent.atomic.AtomicBoolean; +import org.apache.commons.lang3.StringUtils; +import org.jline.reader.Completer; +import org.jline.reader.EndOfFileException; +import org.jline.reader.LineReader; +import org.jline.reader.LineReaderBuilder; +import org.jline.reader.ParsedLine; +import org.jline.reader.Parser; +import org.jline.reader.SyntaxError; +import org.jline.reader.UserInterruptException; +import org.jline.reader.impl.DefaultParser; +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.program.Version; + +/** + * Standalone IPC console client. + * + *

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 ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + 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 Map adminCommands; + private final DefaultParser commandParser = new DefaultParser().eofOnUnclosedQuote(true); + private int requestId = 0; + + public IpcClient(String socketFilePath) { + this(socketFilePath, AdminJsonRpc.class); + } + + IpcClient(String socketFilePath, Class adminApi) { + this.socketFilePath = socketFilePath; + this.adminCommands = collectAdminCommands(adminApi); + } + + public static int start(String socketFilePath) { + return start(socketFilePath, null); + } + + public static int start(String socketFilePath, String execCommand) { + IpcClient ipcClient = new IpcClient(socketFilePath); + try { + return ipcClient.run(execCommand); + } catch (IOException e) { + System.err.println("Failed to communicate with IPC server."); + return EXIT_FAILURE; + } + } + + 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 parameterNames = new ArrayList<>(); + List parameterTypes = new ArrayList<>(); + Annotation[][] paramAnnotations = method.getParameterAnnotations(); + Type[] genericParameterTypes = method.getGenericParameterTypes(); + for (int i = 0; i < paramAnnotations.length; i++) { + String parameterName = null; + for (Annotation anno : paramAnnotations[i]) { + if (anno instanceof JsonRpcParam) { + parameterName = ((JsonRpcParam) anno).value(); + break; + } + } + if (StringUtils.isEmpty(parameterName)) { + throw new IllegalStateException("Missing @JsonRpcParam on " + method.getName() + + " parameter " + i); + } + parameterNames.add(parameterName); + parameterTypes.add(OBJECT_MAPPER.getTypeFactory().constructType(genericParameterTypes[i])); + } + + AdminCommand command = new AdminCommand(rpcMethod.value(), parameterNames, parameterTypes); + commands.put(rpcMethod.value().toLowerCase(Locale.ROOT), command); + } + return commands; + } + + private void printHelp() { + System.out.println("Available commands:"); + for (String usage : buildHelpLines()) { + System.out.println(" " + usage); + } + } + + List buildHelpLines() { + List commands = new ArrayList<>(); + for (AdminCommand command : adminCommands.values()) { + commands.add(command.name); + } + Collections.sort(commands); + List helpLines = new ArrayList<>(); + for (String command : commands) { + helpLines.add(formatUsage(adminCommands.get(command.toLowerCase(Locale.ROOT)))); + } + helpLines.add("help [command]"); + helpLines.add("exit/quit"); + return helpLines; + } + + private String formatUsage(AdminCommand command) { + if (command.parameterNames.isEmpty()) { + return command.name; + } + List typedParameters = new ArrayList<>(); + for (int i = 0; i < command.parameterNames.size(); i++) { + typedParameters.add(command.parameterNames.get(i) + ":" + + formatType(command.parameterTypes.get(i))); + } + return command.name + " <" + StringUtils.join(typedParameters, "> <") + ">"; + } + + 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 { + List commandWords; + try { + commandWords = parseCommandLine(commandLine); + } catch (SyntaxError e) { + System.err.println("Invalid command syntax."); + return EXIT_FAILURE; + } + if (commandWords.isEmpty()) { + System.err.println("No command specified for --exec."); + return EXIT_FAILURE; + } + if (isExitCommand(commandWords.get(0))) { + return EXIT_SUCCESS; + } + + String request; + try { + request = buildRequest(commandWords); + } catch (JsonProcessingException e) { + System.err.println("Failed to build IPC request."); + return EXIT_FAILURE; + } catch (IllegalArgumentException e) { + System.err.println(e.getMessage()); + return EXIT_FAILURE; + } + if (request == null) { + return "help".equalsIgnoreCase(commandWords.get(0)) ? EXIT_SUCCESS : EXIT_FAILURE; + } + + 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))) { + serverWriter.write(request); + serverWriter.newLine(); + serverWriter.flush(); + + 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; + } + ParsedResponse parsedResponse = parseResponse(response); + if (parsedResponse.successful) { + System.out.println(parsedResponse.formatted); + return EXIT_SUCCESS; + } + System.err.println(parsedResponse.formatted); + return EXIT_FAILURE; + } + } + + void runSession(Socket socket, LineReader reader) throws IOException { + AtomicBoolean connected = new AtomicBoolean(true); + outputResponse(socket, reader, connected, Thread.currentThread()); + try { + inputRequest(socket, reader, connected); + } finally { + connected.set(false); + } + } + + /** + * start a thread to receive response from IPC server + */ + private void outputResponse(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(formatResponse(response)); + } + } + } 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(getCompletionCommandNames()); + ArgumentCompleter completer = new ArgumentCompleter( + commandCompleter, + NullCompleter.INSTANCE + ); + return LineReaderBuilder.builder() + .terminal(terminal) + .completer(completer) + .parser(commandParser) + .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(); + } + + String[] getCompletionCommandNames() { + return adminCommands.values().stream() + .map(command -> command.name) + .sorted() + .toArray(String[]::new); + } + + 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."); + } + + /** + * read from System.in and send command to IPC server + */ + private void inputRequest(final Socket socket, LineReader reader, AtomicBoolean connected) { + String prompt = "> "; + + try { + BufferedWriter serverWriter = new BufferedWriter( + new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8)); + while (connected.get()) { + try { + List commandWords = parseCommandLine(reader.readLine(prompt)); + if (commandWords.isEmpty()) { + continue; + } + if (isExitCommand(commandWords.get(0))) { + break; + } + + String request = buildRequest(commandWords); + if (request == null) { + continue; + } + serverWriter.write(request); + serverWriter.newLine(); + serverWriter.flush(); + } catch (UserInterruptException | EndOfFileException e) { + // Ctrl + C, Ctrl + D, or server disconnected + break; + } catch (JsonProcessingException e) { + System.err.println("Failed to build IPC request."); + } catch (SyntaxError e) { + System.err.println("Invalid command syntax."); + } catch (IllegalArgumentException e) { + System.err.println(e.getMessage()); + } catch (IOException e) { + notifyDisconnected(connected, reader); + break; + } + } + } catch (IOException e) { + notifyDisconnected(connected, reader); + } + } + + private boolean notifyDisconnected(AtomicBoolean connected, LineReader reader) { + if (connected.compareAndSet(true, false)) { + reader.printAbove("Disconnected from server."); + return true; + } + return false; + } + + List parseCommandLine(String commandLine) { + if (commandLine == null) { + return Collections.emptyList(); + } + String normalizedCommandLine = commandLine.trim(); + if (normalizedCommandLine.isEmpty()) { + return Collections.emptyList(); + } + ParsedLine parsedLine = commandParser.parse( + normalizedCommandLine, normalizedCommandLine.length(), Parser.ParseContext.ACCEPT_LINE); + return parsedLine.words(); + } + + private boolean isExitCommand(String command) { + return "exit".equalsIgnoreCase(command) || "quit".equalsIgnoreCase(command); + } + + private String buildRequest(List commandWords) throws JsonProcessingException { + String command = commandWords.get(0); + String commandLowerCase = command.toLowerCase(Locale.ROOT); + if ("help".equals(commandLowerCase)) { + if (commandWords.size() == 2 + && adminCommands.containsKey(commandWords.get(1).toLowerCase(Locale.ROOT))) { + String rpcMethod = commandWords.get(1).toLowerCase(Locale.ROOT); + System.out.println("usage: " + formatUsage(adminCommands.get(rpcMethod))); + } else { + printHelp(); + } + return null; + } + AdminCommand adminCommand = adminCommands.get(commandLowerCase); + if (adminCommand == null) { + System.err.println("Invalid cmd: " + command); + printHelp(); + return null; + } + if (commandWords.size() - 1 != adminCommand.parameterNames.size()) { + System.err.println("Invalid parameter, usage: " + + formatUsage(adminCommand)); + return null; + } + + List rawValues = new ArrayList<>( + commandWords.subList(1, commandWords.size())); + List values = convertArguments(adminCommand, rawValues); + return buildJsonWithParameter(adminCommand.name, values); + } + + private List convertArguments(AdminCommand command, List values) { + List convertedValues = new ArrayList<>(); + for (int i = 0; i < values.size(); i++) { + convertedValues.add(convertArgument(values.get(i), command.parameterTypes.get(i), + command.parameterNames.get(i))); + } + return convertedValues; + } + + 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 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 (java.util.Map.class.isAssignableFrom(rawClass)) { + return "object"; + } + return rawClass.getSimpleName(); + } + + private IllegalArgumentException invalidParameterType(String parameterName, JavaType targetType, + Throwable cause) { + return new IllegalArgumentException( + "Invalid value for <" + parameterName + ">; expected " + targetType.toCanonical(), cause); + } + + String formatResponse(String response) { + return parseResponse(response).formatted; + } + + private ParsedResponse parseResponse(String response) { + try { + JsonNode root = OBJECT_MAPPER.readTree(response); + if (root == null || root.isMissingNode()) { + return new ParsedResponse(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 ParsedResponse("Error" + code + ": " + message, false); + } + if (root.has("result")) { + return new ParsedResponse(formatJsonValue(root.get("result")), true); + } + return new ParsedResponse(formatJsonValue(root), false); + } catch (JsonProcessingException e) { + return new ParsedResponse(response, false); + } + } + + private 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); + } + + private String buildJsonWithParameter(String cmd, List values) + throws JsonProcessingException { + Map params = new LinkedHashMap<>(); + params.put("jsonrpc", "2.0"); + params.put("method", cmd); + params.put("params", values); + params.put("id", ++requestId); + return OBJECT_MAPPER.writeValueAsString(params); + } + + private static class ParsedResponse { + + private final String formatted; + private final boolean successful; + + private ParsedResponse(String formatted, boolean successful) { + this.formatted = formatted; + this.successful = successful; + } + } + + private static class AdminCommand { + + private final String name; + private final List parameterNames; + private final List parameterTypes; + + private AdminCommand(String name, List parameterNames, + List parameterTypes) { + this.name = name; + this.parameterNames = parameterNames; + this.parameterTypes = parameterTypes; + } + } +} diff --git a/framework/src/main/java/org/tron/core/services/admin/ipc/IpcCommandCompleter.java b/framework/src/main/java/org/tron/core/services/admin/ipc/IpcCommandCompleter.java new file mode 100644 index 00000000000..999b03a6906 --- /dev/null +++ b/framework/src/main/java/org/tron/core/services/admin/ipc/IpcCommandCompleter.java @@ -0,0 +1,36 @@ +package org.tron.core.services.admin.ipc; + +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/IpcService.java b/framework/src/main/java/org/tron/core/services/admin/ipc/IpcService.java new file mode 100644 index 00000000000..310cc422939 --- /dev/null +++ b/framework/src/main/java/org/tron/core/services/admin/ipc/IpcService.java @@ -0,0 +1,479 @@ +package org.tron.core.services.admin.ipc; + +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.BufferedInputStream; +import java.io.BufferedWriter; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStreamWriter; +import java.lang.management.ManagementFactory; +import java.net.SocketTimeoutException; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +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 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.exception.TronError; +import org.tron.core.exception.TronError.ErrCode; +import org.tron.core.services.admin.AdminJsonRpc; +import org.tron.core.services.jsonrpc.JsonRpcErrorResolver; +import org.tron.core.services.jsonrpc.JsonRpcMapper; + +@Component +@Slf4j(topic = "API") +public class IpcService extends AbstractService { + + private static final ObjectMapper OBJECT_MAPPER = JsonRpcMapper.create(); + 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 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; + + private final JsonRpcServer jsonRpcServer; + private final int maxRequestSize; + + 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(); + maxRequestSize = Args.getInstance().maxMessageSize; + jsonRpcServer = new JsonRpcServer(OBJECT_MAPPER, adminJsonRpc, AdminJsonRpc.class); + jsonRpcServer.setErrorResolver(JsonRpcErrorResolver.INSTANCE); + jsonRpcServer.setShouldLogInvocationErrors(false); + } + + @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 { + socketFilePath = resolveSocketFilePath(Args.getInstance(), getPid()); + Path socketDirectory = socketFilePath.getParent(); + validateSocketRootDirectory(socketDirectory.getParent()); + try { + recreateSocketDirectory(socketDirectory); + File socketFile = socketFilePath.toFile(); + AFUNIXSocketAddress address = AFUNIXSocketAddress.of(socketFile); + unixServerSocket = AFUNIXServerSocket.bindOn(address); + setOwnerOnlyPermissions(socketFilePath); + unixServerSocket.setShutdownOnClose(true); + + logger.info("IpcService started, listening on {}", socketFile.getAbsolutePath()); + } catch (IOException | RuntimeException e) { + throw cleanupFailedStart(e); + } + Runnable runnable = () -> { + while (isRunning) { + try { + registerClient(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 cleanupFailedStart(e); + } + } + + private void registerClient(AFUNIXSocket client) { + try { + client.setSoTimeout(CLIENT_IDLE_TIMEOUT_MILLIS); + } catch (IOException e) { + closeClientSocket(client); + if (isRunning) { + logger.warn("Failed to configure IPC client idle timeout"); + } + return; + } + activeClientSockets.add(client); + if (!isRunning) { + closeAndRemoveClient(client); + return; + } + try { + ExecutorServiceManager.submit(clientExecutor, () -> { + try { + handleClient(client); + } finally { + closeAndRemoveClient(client); + } + }); + } catch (RejectedExecutionException e) { + closeAndRemoveClient(client); + if (isRunning) { + logger.warn("Too many IPC clients; rejecting connection"); + } + } catch (RuntimeException e) { + closeAndRemoveClient(client); + throw e; + } + } + + 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 = readRequest(input)) != null) { + String cmd = line.trim(); + logger.debug("Received IPC request"); + String response = 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 of {} bytes", maxRequestSize); + } catch (IOException e) { + if (isRunning) { + logger.error("Client disconnected {}", client); + } + } + } + + private 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 { + dispatchRequest(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); + } + } + + void dispatchRequest(ByteArrayInputStream input, ByteArrayOutputStream output) + throws IOException { + jsonRpcServer.handleRequest(input, output); + } + + 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); + } + } + + @Override + public void innerStop() throws Exception { + logger.info("Begin to stop IpcService ..."); + isRunning = false; + + Exception failure = null; + failure = runCleanup(failure, this::closeServerSocket); + failure = runCleanup(failure, this::shutdownActiveClients); + failure = runCleanup(failure, this::shutdownExecutors); + activeClientSockets.clear(); + failure = runCleanup(failure, this::deleteSocketFile); + failure = runCleanup(failure, this::deleteSocketDirectory); + + 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) { + shutdownClientSocket(client); + } + } + + private void shutdownExecutors() { + // The accept and client workers can be blocked in native socket reads. Closing a junixsocket + // from another thread does not always wake those reads promptly, while the shared shutdown + // helper waits 60 seconds before interrupting them. Interrupt first so IPC shutdown remains + // bounded instead of intermittently stalling until that fallback timeout. + acceptorExecutor.shutdownNow(); + clientExecutor.shutdownNow(); + ExecutorServiceManager.shutdownAndAwaitTermination(acceptorExecutor, ACCEPTOR_EXECUTOR_NAME); + ExecutorServiceManager.shutdownAndAwaitTermination(clientExecutor, CLIENT_EXECUTOR_NAME); + } + + private void deleteSocketFile() throws IOException { + if (socketFilePath != null) { + Files.deleteIfExists(socketFilePath); + } + } + + private void deleteSocketDirectory() throws IOException { + if (socketFilePath != null) { + Files.deleteIfExists(socketFilePath.getParent()); + } + } + + private Exception cleanupFailedStart(Exception failure) { + if (unixServerSocket != null) { + failure = runCleanup(failure, this::closeServerSocket); + failure = runCleanup(failure, this::deleteSocketFile); + } + return runCleanup(failure, this::deleteSocketDirectory); + } + + private Exception runCleanup(Exception failure, CleanupAction action) { + try { + action.run(); + } catch (Exception cleanupFailure) { + if (failure == null) { + return cleanupFailure; + } + failure.addSuppressed(cleanupFailure); + } + return failure; + } + + private void closeClientSocket(AFUNIXSocket client) { + if (client == null) { + return; + } + try { + client.close(); + } catch (IOException e) { + logger.warn("Failed to close IPC client socket", e); + } + } + + private void shutdownClientSocket(AFUNIXSocket client) { + if (client == null) { + return; + } + 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"); + } + closeClientSocket(client); + } + + private void closeAndRemoveClient(AFUNIXSocket client) { + closeClientSocket(client); + activeClientSockets.remove(client); + } + + private Path resolveSocketFilePath(CommonParameter parameter, String pid) { + String configuredDirectory = parameter.getIpcSocketDirectory(); + Path socketRootDirectory; + if (configuredDirectory == null || configuredDirectory.trim().isEmpty()) { + socketRootDirectory = Paths.get(parameter.getOutputDirectory()); + } 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 " + socketFile + " 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; + } + + private 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); + } + } + + private 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); + } + + private void setOwnerOnlyPermissions(Path socketFilePath) throws IOException { + Files.setPosixFilePermissions(socketFilePath, + EnumSet.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE)); + } + + private String getPid() { + String name = ManagementFactory.getRuntimeMXBean().getName(); + return name.split("@")[0]; + } + + @FunctionalInterface + private interface CleanupAction { + + void run() throws Exception; + } + + private static final class RequestTooLargeException extends IOException { + + private static final long serialVersionUID = 1L; + } +} 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..b5a0d17979a 100644 --- a/framework/src/main/java/org/tron/program/FullNode.java +++ b/framework/src/main/java/org/tron/program/FullNode.java @@ -14,6 +14,7 @@ import org.tron.core.config.DefaultConfig; import org.tron.core.config.args.Args; import org.tron.core.exception.TronError; +import org.tron.core.services.admin.ipc.IpcClient; @Slf4j(topic = "app") public class FullNode { @@ -25,8 +26,15 @@ public static void main(String[] args) { ExitManager.initExceptionHandler(); checkJdkVersion(); Args.setParam(args, "config.conf"); - CommonParameter parameter = Args.getInstance(); + if (StringUtils.isNotEmpty(Args.getIpcSocketFile())) { + int exitCode = IpcClient.start(Args.getIpcSocketFile(), Args.getIpcExecCommand()); + if (exitCode != 0) { + System.exit(exitCode); + } + return; + } + CommonParameter parameter = Args.getInstance(); LogService.load(parameter.getLogbackPath()); if (parameter.isKeystoreFactory()) { 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..0f9b076922e 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 testAdminRpcAndIpcConfigBinding() { + 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.rpc.enable", "true"); + override.put("node.admin.rpc.listenAddress", "127.0.0.2"); + override.put("node.admin.rpc.port", "18575"); + override.put("node.admin.rpc.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().isAdminRpcEnable()); + Assert.assertEquals("127.0.0.2", Args.getInstance().getAdminListenAddress()); + Assert.assertEquals(18575, Args.getInstance().getAdminListenPort()); + Assert.assertEquals(Arrays.asList("admin.example.com", "localhost"), + Args.getInstance().getAdminVirtualHosts()); + } finally { + Args.clearParam(); + } + } + /** * Verify that CLI storage parameters correctly override config file values. * diff --git a/framework/src/test/java/org/tron/core/services/admin/http/AdminRpcHttpServiceTest.java b/framework/src/test/java/org/tron/core/services/admin/http/AdminRpcHttpServiceTest.java new file mode 100644 index 00000000000..34cdab6539d --- /dev/null +++ b/framework/src/test/java/org/tron/core/services/admin/http/AdminRpcHttpServiceTest.java @@ -0,0 +1,21 @@ +package org.tron.core.services.admin.http; + +import org.junit.Assert; +import org.junit.Test; + +public class AdminRpcHttpServiceTest { + + @Test + public void testLoopbackListenAddressesAreRecognized() { + Assert.assertTrue(AdminRpcHttpService.isLoopbackListenAddress("127.0.0.1")); + Assert.assertTrue(AdminRpcHttpService.isLoopbackListenAddress("::1")); + Assert.assertTrue(AdminRpcHttpService.isLoopbackListenAddress("localhost")); + } + + @Test + public void testNonLoopbackListenAddressesAreRejected() { + Assert.assertFalse(AdminRpcHttpService.isLoopbackListenAddress(null)); + Assert.assertFalse(AdminRpcHttpService.isLoopbackListenAddress("0.0.0.0")); + Assert.assertFalse(AdminRpcHttpService.isLoopbackListenAddress("192.0.2.1")); + } +} 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..a429ca8c0ed --- /dev/null +++ b/framework/src/test/java/org/tron/core/services/admin/http/AdminRpcServletTest.java @@ -0,0 +1,175 @@ +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 java.util.HashSet; +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("virtualHosts", new HashSet<>(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/ipc/IpcClientTest.java b/framework/src/test/java/org/tron/core/services/admin/ipc/IpcClientTest.java new file mode 100644 index 00000000000..1c9e4e0a9e3 --- /dev/null +++ b/framework/src/test/java/org/tron/core/services/admin/ipc/IpcClientTest.java @@ -0,0 +1,398 @@ +package org.tron.core.services.admin.ipc; + +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.type.TypeFactory; +import com.googlecode.jsonrpc4j.JsonRpcMethod; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.PrintStream; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.SocketTimeoutException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import org.jline.reader.LineReader; +import org.jline.reader.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 testBuildHelpLinesIncludesSortedCommandParameters() { + IpcClient client = new IpcClient("unused"); + + Assert.assertEquals(Arrays.asList( + "admin_example ", + "help [command]", + "exit/quit"), client.buildHelpLines()); + } + + @Test + public void testCompletionUsesCanonicalMethodNames() { + IpcClient client = new IpcClient("unused"); + + Assert.assertArrayEquals(new String[] { + "admin_example" + }, client.getCompletionCommandNames()); + } + + @Test + public void testMissingJsonRpcParameterAnnotationIsRejected() { + try { + new IpcClient("unused", 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 testParseCommandLinePreservesQuotedArguments() { + IpcClient client = new IpcClient("unused"); + + Assert.assertEquals(Arrays.asList("admin_example", " hello world ", "second value"), + client.parseCommandLine(" \tadmin_example \" hello world \" 'second value' ")); + } + + @Test + public void testConvertTypedArguments() { + IpcClient client = new IpcClient("unused"); + TypeFactory typeFactory = TypeFactory.defaultInstance(); + + Assert.assertEquals(42, client.convertArgument("42", + typeFactory.constructType(Integer.TYPE), "number")); + Assert.assertEquals(true, client.convertArgument("true", + typeFactory.constructType(Boolean.TYPE), "enabled")); + JavaType listType = typeFactory.constructCollectionType(java.util.List.class, Integer.class); + Assert.assertEquals(Arrays.asList(1, 2), + client.convertArgument("[1,2]", listType, "numbers")); + + try { + client.convertArgument("null", typeFactory.constructType(Integer.TYPE), "number"); + Assert.fail("Expected null to be rejected for a primitive parameter"); + } catch (IllegalArgumentException e) { + Assert.assertEquals("Invalid value for ; expected int", e.getMessage()); + } + + try { + client.convertArgument("sensitive-value", typeFactory.constructType(Integer.TYPE), "number"); + Assert.fail("Expected an invalid typed parameter"); + } catch (IllegalArgumentException e) { + Assert.assertEquals("Invalid value for ; expected int", e.getMessage()); + Assert.assertFalse(e.getMessage().contains("sensitive-value")); + } + } + + @Test + public void testFormatResponseShowsResultOrStructuredError() { + IpcClient client = new IpcClient("unused"); + + Assert.assertEquals("done", client.formatResponse( + "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":\"done\"}")); + String formattedObject = client.formatResponse( + "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"height\":10,\"ready\":true}}"); + Assert.assertTrue(formattedObject, formattedObject.contains(System.lineSeparator())); + Assert.assertTrue(formattedObject, formattedObject.contains("\"height\" : 10")); + Assert.assertEquals("Error -32602: Invalid params", client.formatResponse( + "{\"jsonrpc\":\"2.0\",\"id\":1," + + "\"error\":{\"code\":-32602,\"message\":\"Invalid params\"}}")); + Assert.assertEquals("", client.formatResponse("")); + } + + @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 { + 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(Mockito.mock(Socket.class), + "admin_example \"sensitive-value")); + } finally { + System.setErr(originalErr); + capturedErr.close(); + } + + Assert.assertEquals("Invalid command syntax." + System.lineSeparator(), + errorOutput.toString("UTF-8")); + Assert.assertFalse(errorOutput.toString("UTF-8").contains("sensitive-value")); + } + + @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 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 interface MissingParameterAnnotationApi { + + @JsonRpcMethod("admin_invalid") + String invalid(String value); + } +} diff --git a/framework/src/test/java/org/tron/core/services/admin/ipc/IpcServiceTest.java b/framework/src/test/java/org/tron/core/services/admin/ipc/IpcServiceTest.java new file mode 100644 index 00000000000..385512bac3d --- /dev/null +++ b/framework/src/test/java/org/tron/core/services/admin/ipc/IpcServiceTest.java @@ -0,0 +1,817 @@ +package org.tron.core.services.admin.ipc; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +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.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.Arrays; +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.Constant; +import org.tron.core.config.args.Args; +import org.tron.core.exception.TronError; +import org.tron.core.exception.jsonrpc.JsonRpcInvalidParamsException; +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 + public void testRequestSizePreservesConfiguredZero() throws Exception { + Args.getInstance().maxMessageSize = 0; + + Assert.assertEquals(0, getIntField(newIpcService(), "maxRequestSize")); + } + + @Test + public void testResolveSocketFilePathUsesOutputDirectory() throws Exception { + IpcService service = newIpcService(); + CommonParameter parameter = new CommonParameter(); + parameter.outputDirectory = "/tmp/node-output"; + + Path socketFilePath = resolveSocketFilePath(service, parameter, "1234"); + + Assert.assertEquals( + Paths.get("/tmp/node-output", ".ipc", "1234.sock"), + socketFilePath); + } + + @Test + public void testResolveSocketFilePathRejectsLongOutputPath() throws Exception { + IpcService service = newIpcService(); + CommonParameter parameter = new CommonParameter(); + parameter.outputDirectory = Paths.get("/tmp", + "a-very-long-output-directory-name-that-makes-the-resulting-unix-domain-socket-path-" + + "exceed-the-portable-limit").toString(); + + try { + resolveSocketFilePath(service, parameter, "1234"); + Assert.fail("Expected an overlong IPC socket path to be rejected"); + } catch (TronError e) { + Path expectedSocketFile = Paths.get(parameter.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.assertTrue(e.getMessage().contains(expectedSocketFile.toString())); + } + } + + @Test + public void testSocketPathLengthCountsUtf8Bytes() { + Path socketPath = Paths.get("/tmp/目录.sock"); + + int encodedLength = IpcService.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 { + IpcService service = newIpcService(); + CommonParameter parameter = new CommonParameter(); + parameter.outputDirectory = "node-output"; + parameter.ipcSocketDirectory = "/tmp/tron-ipc"; + + Path socketFilePath = resolveSocketFilePath(service, parameter, "1234"); + + Assert.assertEquals(Paths.get("/tmp/tron-ipc/.ipc/1234.sock"), + socketFilePath); + } + + @Test + public void testResolveSocketFilePathRejectsRelativeConfiguredDirectory() throws Exception { + IpcService service = newIpcService(); + CommonParameter parameter = new CommonParameter(); + parameter.ipcSocketDirectory = "relative-ipc"; + + try { + resolveSocketFilePath(service, parameter, "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 { + IpcService service = newIpcService(); + CommonParameter parameter = new CommonParameter(); + parameter.outputDirectory = "/tmp"; + parameter.ipcSocketDirectory = Paths.get("/tmp", + "a-very-long-explicit-ipc-directory-that-makes-the-resulting-unix-domain-socket-path-" + + "exceed-the-portable-limit").toString(); + + try { + resolveSocketFilePath(service, parameter, "1234"); + Assert.fail("Expected an overlong configured IPC socket path to be rejected"); + } catch (TronError e) { + Path expectedSocketFile = Paths.get(parameter.ipcSocketDirectory, ".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.assertTrue(e.getMessage().contains(expectedSocketFile.toString())); + } + } + + @Test + public void testValidateSocketRootDirectoryRejectsMissingDirectory() throws Exception { + IpcService service = newIpcService(); + Path outputDirectory = Files.createTempDirectory("ipc-missing-output-test-"); + Files.delete(outputDirectory); + + try { + validateSocketRootDirectory(service, 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 { + IpcService service = newIpcService(); + Path outputDirectory = Files.createTempDirectory("ipc-regular-file-test-"); + Path socketDirectory = outputDirectory.resolve(".ipc"); + Files.createFile(socketDirectory); + try { + recreateSocketDirectory(service, 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(); + IpcService service = newIpcService(); + 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 { + recreateSocketDirectory(service, 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(); + IpcService service = newIpcService(); + Path outputDirectory = Files.createTempDirectory("ipc-stale-directory-test-"); + Path socketDirectory = Files.createDirectory(outputDirectory.resolve(".ipc")); + Files.createFile(socketDirectory.resolve("1234.sock")); + try { + recreateSocketDirectory(service, 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(); + IpcService service = newIpcService(); + Path outputDirectory = Files.createTempDirectory("ipc-posix-output-test-"); + try { + validateSocketRootDirectory(service, outputDirectory); + } finally { + Files.deleteIfExists(outputDirectory); + } + } + + @Test + public void testHandleCommandReturnsSingleLineJsonResponse() throws Exception { + IpcService service = new IpcService( + new AdminJsonRpcImpl()); + + String response = service.handleCommand( + "{\"jsonrpc\":\"2.0\",\"method\":\"admin_example\"," + + "\"params\":[\"a\",\"b\"],\"id\":7}"); + + Assert.assertFalse(response, response.contains("\n")); + Assert.assertFalse(response, response.contains("\r")); + Assert.assertEquals("a:b", new ObjectMapper().readTree(response).get("result").asText()); + } + + @Test + public void testHandleCommandReturnsJsonRpcErrorOnDispatcherFailure() throws Exception { + IpcService service = Mockito.spy(new IpcService( + new AdminJsonRpcImpl())); + Mockito.doThrow(new IOException("sensitive-detail")) + .when(service).dispatchRequest(Mockito.any(ByteArrayInputStream.class), + Mockito.any(ByteArrayOutputStream.class)); + + String response = service.handleCommand( + "{\"jsonrpc\":\"2.0\",\"method\":\"admin_example\"," + + "\"params\":[\"a\",\"b\"],\"id\":9}"); + JsonNode responseNode = new ObjectMapper().readTree(response); + + Assert.assertEquals("2.0", responseNode.get("jsonrpc").asText()); + Assert.assertEquals(-32603, responseNode.get("error").get("code").asInt()); + Assert.assertEquals("Internal error", responseNode.get("error").get("message").asText()); + Assert.assertEquals(9, responseNode.get("id").asInt()); + Assert.assertFalse(response, response.contains("sensitive-detail")); + Assert.assertFalse(response, response.contains("\n")); + } + + @Test + public void testHandleCommandUsesAnnotatedErrorResolver() throws Exception { + AdminJsonRpc adminJsonRpc = Mockito.mock(AdminJsonRpc.class); + Mockito.when(adminJsonRpc.adminExample("a", "b")) + .thenThrow(new JsonRpcInvalidParamsException("Invalid admin parameters")); + IpcService service = new IpcService(adminJsonRpc); + + String response = service.handleCommand( + "{\"jsonrpc\":\"2.0\",\"method\":\"admin_example\"," + + "\"params\":[\"a\",\"b\"],\"id\":10}"); + JsonNode responseNode = new ObjectMapper().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 testIpcMapperRejectsExcessiveNesting() 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(']'); + } + + ObjectMapper mapper = getStaticObjectMapper("OBJECT_MAPPER"); + try { + mapper.readTree(request.toString()); + Assert.fail("Expected excessive IPC JSON nesting to be rejected"); + } catch (IOException e) { + Assert.assertTrue(e.getMessage().contains("nesting depth")); + } + } + + @Test + public void testReadRequestAcceptsMaximumSize() throws Exception { + IpcService service = newIpcService(); + int maxRequestSize = getIntField(service, "maxRequestSize"); + byte[] request = new byte[maxRequestSize + 1]; + Arrays.fill(request, 0, maxRequestSize, (byte) '1'); + request[maxRequestSize] = '\n'; + + Assert.assertEquals(maxRequestSize, + readRequest(service, new ByteArrayInputStream(request)).length()); + } + + @Test(expected = IOException.class) + public void testReadRequestRejectsOversizedInputWithoutNewline() throws Exception { + IpcService service = newIpcService(); + int maxRequestSize = getIntField(service, "maxRequestSize"); + ByteArrayInputStream input = new ByteArrayInputStream(new byte[maxRequestSize + 1]); + + readRequest(service, input); + } + + @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(service, 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(service, 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(service, 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(service, 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 testRegisterClientUsesDefaultIdleTimeout() throws Exception { + IpcService service = newIpcService(); + AFUNIXSocket client = Mockito.mock(AFUNIXSocket.class); + Mockito.doThrow(new IOException("closed")).when(client).getInputStream(); + try { + setField(service, "isRunning", true); + registerClient(service, client); + + Mockito.verify(client).setSoTimeout(10 * 60 * 1000); + } 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"); + }); + registerClient(service, client); + } + Assert.assertTrue("Expected all IPC handlers to start without queueing", + handlersStarted.await(5, TimeUnit.SECONDS)); + + AFUNIXSocket rejectedClient = Mockito.mock(AFUNIXSocket.class); + registerClient(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(service, 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 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.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 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(IpcService service, CommonParameter parameter, String pid) + throws Exception { + return (Path) invokePrivate(service, "resolveSocketFilePath", + new Class[] {CommonParameter.class, String.class}, parameter, pid); + } + + private void validateSocketRootDirectory(IpcService service, Path outputDirectory) + throws Exception { + invokePrivate(service, "validateSocketRootDirectory", new Class[] {Path.class}, + outputDirectory); + } + + private void recreateSocketDirectory(IpcService service, Path socketDirectory) throws Exception { + invokePrivate(service, "recreateSocketDirectory", new Class[] {Path.class}, + socketDirectory); + } + + private String readRequest(IpcService service, InputStream input) throws Exception { + return (String) invokePrivate(service, "readRequest", + new Class[] {InputStream.class}, input); + } + + private int getIntField(IpcService service, String fieldName) throws Exception { + Field field = IpcService.class.getDeclaredField(fieldName); + field.setAccessible(true); + return field.getInt(service); + } + + private ObjectMapper getStaticObjectMapper(String fieldName) throws Exception { + Field field = IpcService.class.getDeclaredField(fieldName); + field.setAccessible(true); + return (ObjectMapper) field.get(null); + } + + 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 registerClient(IpcService service, AFUNIXSocket client) throws Exception { + invokePrivate(service, "registerClient", 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/program/FullNodeTest.java b/framework/src/test/java/org/tron/program/FullNodeTest.java new file mode 100644 index 00000000000..1f364bb4a99 --- /dev/null +++ b/framework/src/test/java/org/tron/program/FullNodeTest.java @@ -0,0 +1,43 @@ +package org.tron.program; + +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.Assert; +import org.junit.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.tron.common.arch.Arch; +import org.tron.common.exit.ExitManager; +import org.tron.common.log.LogService; +import org.tron.core.config.args.Args; +import org.tron.core.services.admin.ipc.IpcClient; + +public class FullNodeTest { + + @Test + public void testAttachStartsBeforeLogService() { + AtomicBoolean logServiceLoaded = new AtomicBoolean(false); + 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); + MockedStatic ipcClient = Mockito.mockStatic(IpcClient.class)) { + args.when(Args::getIpcSocketFile).thenReturn("/tmp/java-tron.sock"); + args.when(Args::getIpcExecCommand).thenReturn(null); + logService.when(() -> LogService.load(Mockito.anyString())) + .thenAnswer(invocation -> { + logServiceLoaded.set(true); + return null; + }); + ipcClient.when(() -> IpcClient.start("/tmp/java-tron.sock", null)) + .thenAnswer(invocation -> { + Assert.assertFalse("Attach initialized node logging", logServiceLoaded.get()); + return 0; + }); + + FullNode.main(new String[] {"--attach", "/tmp/java-tron.sock"}); + + ipcClient.verify(() -> IpcClient.start("/tmp/java-tron.sock", null)); + logService.verifyNoInteractions(); + } + } +} diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 6a3e641d5d6..6632954e6ae 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -873,6 +873,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -2384,6 +2420,14 @@ + + + + + + + +