package com.oracle.truffle.tools.chromeinspector.instrument;
import java.io.File;
import java.io.IOException;
import java.io.PrintWriter;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.UnknownHostException;
import java.net.URI;
import java.net.URISyntaxException;
import java.security.SecureRandom;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
import java.util.function.Supplier;
import org.graalvm.options.OptionCategory;
import org.graalvm.options.OptionDescriptor;
import org.graalvm.options.OptionDescriptors;
import org.graalvm.options.OptionKey;
import org.graalvm.options.OptionStability;
import org.graalvm.options.OptionType;
import org.graalvm.options.OptionValues;
import org.graalvm.polyglot.io.MessageEndpoint;
import org.graalvm.polyglot.io.MessageTransport;
import com.oracle.truffle.api.CompilerDirectives.TruffleBoundary;
import com.oracle.truffle.api.TruffleContext;
import com.oracle.truffle.api.instrumentation.ContextsListener;
import com.oracle.truffle.api.instrumentation.EventBinding;
import com.oracle.truffle.api.instrumentation.TruffleInstrument;
import com.oracle.truffle.api.interop.TruffleObject;
import com.oracle.truffle.api.nodes.LanguageInfo;
import com.oracle.truffle.tools.chromeinspector.InspectorExecutionContext;
import com.oracle.truffle.tools.chromeinspector.objects.Inspector;
import com.oracle.truffle.tools.chromeinspector.client.InspectWSClient;
import com.oracle.truffle.tools.chromeinspector.server.ConnectionWatcher;
import com.oracle.truffle.tools.chromeinspector.server.InspectServerSession;
import com.oracle.truffle.tools.chromeinspector.server.WSInterceptorServer;
import com.oracle.truffle.tools.chromeinspector.server.InspectorServer;
import com.oracle.truffle.tools.chromeinspector.server.InspectorServerConnection;
@TruffleInstrument.Registration(id = InspectorInstrument.INSTRUMENT_ID, name = "Chrome Inspector", version = InspectorInstrument.VERSION, services = TruffleObject.class)
public final class InspectorInstrument extends TruffleInstrument {
private static final int DEFAULT_PORT = 9229;
private static final HostAndPort DEFAULT_ADDRESS = new HostAndPort(null, DEFAULT_PORT);
private static final String HELP_URL = "https://www.graalvm.org/docs/tools/chrome-debugger";
private Server server;
private ConnectionWatcher connectionWatcher;
static final OptionType<HostAndPort> ADDRESS_OR_BOOLEAN = new OptionType<>("[[host:]port]", (address) -> {
if (address.isEmpty() || address.equals("true")) {
return DEFAULT_ADDRESS;
} else {
int colon = address.indexOf(':');
String port;
String host;
if (colon >= 0) {
port = address.substring(colon + 1);
host = address.substring(0, colon);
} else {
try {
Integer.parseInt(address);
port = address;
host = null;
} catch (NumberFormatException e) {
port = Integer.toString(DEFAULT_PORT);
host = address;
}
}
return new HostAndPort(host, port);
}
}, (Consumer<HostAndPort>) ((address) -> address.verify()));
static final OptionType<List<URI>> SOURCE_PATH = new OptionType<>("folder" + File.pathSeparator + "file.zip" + File.pathSeparator + "...", (str) -> {
if (str.isEmpty()) {
return Collections.emptyList();
}
List<URI> uris = new ArrayList<>();
int i1 = 0;
while (i1 < str.length()) {
int i2 = str.indexOf(File.pathSeparatorChar, i1);
if (i2 < 0) {
i2 = str.length();
}
String path = str.substring(i1, i2);
try {
uris.add(createURIFromPath(path));
} catch (URISyntaxException ex) {
throw new IllegalArgumentException("Wrong path: " + path, ex);
}
i1 = i2 + 1;
}
return uris;
});
private static URI createURIFromPath(String path) throws URISyntaxException {
String lpath = path.toLowerCase();
int index = 0;
File jarFile = null;
while (index < lpath.length()) {
int zi = lpath.indexOf(".zip", index);
int ji = lpath.indexOf(".jar", index);
if (zi >= 0 && zi < ji || ji < 0) {
ji = zi;
}
if (ji >= 0) {
index = ji + 4;
File jar = new File(path.substring(0, index));
if (jar.isFile()) {
jarFile = jar;
break;
}
} else {
index = path.length();
}
}
if (jarFile != null) {
StringBuilder ssp = new StringBuilder("file://").append(jarFile.toPath().toUri().getPath());
if (index < path.length()) {
if (path.charAt(index) != '!') {
ssp.append('!');
}
ssp.append(path.substring(index));
} else {
ssp.append("!/");
}
return new URI("jar", ssp.toString(), null);
} else {
return new File(path).toPath().toUri();
}
}
@com.oracle.truffle.api.Option(name = "", help = "Start the Chrome inspector on [[host:]port]. (default: <loopback address>:" + DEFAULT_PORT +
")", category = OptionCategory.USER, stability = OptionStability.STABLE)
static final OptionKey<HostAndPort> Inspect = new OptionKey<>(DEFAULT_ADDRESS, ADDRESS_OR_BOOLEAN);
@com.oracle.truffle.api.Option(help = "Attach to an existing endpoint instead of creating a new one. (default:false)", category = OptionCategory.INTERNAL)
static final OptionKey<Boolean> Attach = new OptionKey<>(false);
@com.oracle.truffle.api.Option(help = "Suspend the execution at first executed source line. (default:true)", category = OptionCategory.USER, stability = OptionStability.STABLE)
static final OptionKey<Boolean> Suspend = new OptionKey<>(true);
@com.oracle.truffle.api.Option(help = "Do not execute any source code until inspector client is attached. (default:false)", category = OptionCategory.USER, stability = OptionStability.STABLE)
static final OptionKey<Boolean> WaitAttached = new OptionKey<>(false);
@com.oracle.truffle.api.Option(help = "Specifies list of directories or ZIP/JAR files representing source path. (default:none)", category = OptionCategory.USER, stability = OptionStability.STABLE)
static final OptionKey<List<URI>> SourcePath = new OptionKey<>(Collections.emptyList(), SOURCE_PATH);
@com.oracle.truffle.api.Option(help = "Hide internal errors that can occur as a result of debugger inspection. (default:false)", category = OptionCategory.EXPERT)
static final OptionKey<Boolean> HideErrors = new OptionKey<>(false);
@com.oracle.truffle.api.Option(help = "Path to the chrome inspect. This path should be unpredictable. Do note that any website opened in your browser that has knowledge of the URL can connect to the debugger. A predictable path can thus be " +
"abused by a malicious website to execute arbitrary code on your computer, even if you are behind a firewall. (default: randomly generated)", category = OptionCategory.USER, stability = OptionStability.STABLE)
static final OptionKey<String> Path = new OptionKey<>("");
@com.oracle.truffle.api.Option(help = "Inspect internal sources. (default:false)", category = OptionCategory.INTERNAL)
static final OptionKey<Boolean> Internal = new OptionKey<>(false);
@com.oracle.truffle.api.Option(help = "Inspect language initialization. (default:false)", category = OptionCategory.INTERNAL)
static final OptionKey<Boolean> Initialization = new OptionKey<>(false);
@com.oracle.truffle.api.Option(help = "Use TLS/SSL. (default: false for loopback address, true otherwise)", category = OptionCategory.USER, stability = OptionStability.STABLE)
static final OptionKey<Boolean> Secure = new OptionKey<>(true);
@com.oracle.truffle.api.Option(help = "File path to keystore used for secure connection. (default:javax.net.ssl.keyStore system property)", category = OptionCategory.USER, stability = OptionStability.STABLE)
static final OptionKey<String> KeyStore = new OptionKey<>("");
@com.oracle.truffle.api.Option(help = "The keystore type. (default:javax.net.ssl.keyStoreType system property, or \\\"JKS\\\")", category = OptionCategory.USER, stability = OptionStability.STABLE)
static final OptionKey<String> KeyStoreType = new OptionKey<>("");
@com.oracle.truffle.api.Option(help = "The keystore password. (default:javax.net.ssl.keyStorePassword system property)", category = OptionCategory.USER, stability = OptionStability.STABLE)
static final OptionKey<String> KeyStorePassword = new OptionKey<>("");
@com.oracle.truffle.api.Option(help = "Password for recovering keys from a keystore. (default:javax.net.ssl.keyPassword system property, or keystore password)", category = OptionCategory.USER, stability = OptionStability.STABLE)
static final OptionKey<String> KeyPassword = new OptionKey<>("");
public static final String INSTRUMENT_ID = "inspect";
static final String VERSION = "0.1";
@Override
protected void onCreate(Env env) {
OptionValues options = env.getOptions();
if (options.hasSetOptions()) {
HostAndPort hostAndPort = options.get(Inspect);
connectionWatcher = new ConnectionWatcher();
try {
server = new Server(env, "Main Context", hostAndPort, options.get(Attach), options.get(Suspend), options.get(WaitAttached), options.get(HideErrors), options.get(Internal),
options.get(Initialization), options.get(Path), options.hasBeenSet(Secure), options.get(Secure), new KeyStoreOptions(options), options.get(SourcePath),
connectionWatcher);
} catch (IOException e) {
throw new InspectorIOException(hostAndPort.getHostPort(), e);
}
}
env.registerService(new Inspector(env, server != null ? server.getConnection() : null, new InspectorServerConnection.Open() {
@Override
@SuppressWarnings("all")
public synchronized InspectorServerConnection open(int port, String host, boolean wait) {
if (server != null && server.wss != null) {
return null;
}
HostAndPort hostAndPort = options.get(Inspect);
if (port < 0) {
port = hostAndPort.port;
}
if (host == null) {
host = hostAndPort.host;
}
connectionWatcher = new ConnectionWatcher();
hostAndPort = new HostAndPort(host, port);
try {
server = new Server(env, "Main Context", hostAndPort, false, false, wait, options.get(HideErrors), options.get(Internal),
options.get(Initialization), null, options.hasBeenSet(Secure), options.get(Secure), new KeyStoreOptions(options), options.get(SourcePath), connectionWatcher);
} catch (IOException e) {
PrintWriter info = new PrintWriter(env.err());
info.println(new InspectorIOException(hostAndPort.getHostPort(), e).getLocalizedMessage());
info.flush();
}
return server != null ? server.getConnection() : null;
}
}, new Supplier<InspectorExecutionContext>() {
@Override
public InspectorExecutionContext get() {
if (server != null) {
return server.getConnection().getExecutionContext();
} else {
PrintWriter err = (options.get(HideErrors)) ? null : new PrintWriter(env.err(), true);
return new InspectorExecutionContext("Main Context", options.get(Internal), options.get(Initialization), env, Collections.emptyList(), err);
}
}
}));
}
@Override
protected void onFinalize(Env env) {
if (connectionWatcher != null && connectionWatcher.shouldWaitForClose()) {
PrintWriter info = new PrintWriter(env.out());
info.println("Waiting for the debugger to disconnect...");
info.flush();
connectionWatcher.waitForClose();
}
if (server != null) {
server.doFinalize();
}
}
@Override
protected OptionDescriptors getOptionDescriptors() {
OptionDescriptors descriptors = new InspectorInstrumentOptionDescriptors();
return new OptionDescriptors() {
@Override
public OptionDescriptor get(String optionName) {
return descriptors.get(optionName);
}
@Override
public Iterator<OptionDescriptor> iterator() {
Iterator<OptionDescriptor> iterator = descriptors.iterator();
return new Iterator<OptionDescriptor>() {
@Override
public boolean hasNext() {
return iterator.hasNext();
}
@Override
public OptionDescriptor next() {
OptionDescriptor descriptor = iterator.next();
if (descriptor.getKey() == SourcePath) {
String example = " Example: " + File.separator + "projects" + File.separator + "foo" + File.separator + "src" + File.pathSeparator + "sources.jar" + File.pathSeparator +
"package.zip!/src";
descriptor = OptionDescriptor.newBuilder(SourcePath, descriptor.getName()).deprecated(descriptor.isDeprecated()).category(descriptor.getCategory()).help(
descriptor.getHelp() + example).build();
}
return descriptor;
}
};
}
};
}
private static final class HostAndPort {
private final String host;
private String portStr;
private int port;
private InetAddress inetAddress;
HostAndPort(String host, int port) {
this.host = host;
this.port = port;
}
HostAndPort(String host, String portStr) {
this.host = host;
this.portStr = portStr;
}
void verify() {
if (port == 0 && portStr != null) {
try {
port = Integer.parseInt(portStr);
} catch (NumberFormatException e) {
throw new IllegalArgumentException("Port is not a number: " + portStr);
}
}
if (port != 0 && (port < 1024 || 65535 < port)) {
throw new IllegalArgumentException("Invalid port number: " + port + ". Needs to be 0, or in range from 1024 to 65535.");
}
if (host != null && !host.isEmpty()) {
try {
inetAddress = InetAddress.getByName(host);
} catch (UnknownHostException ex) {
throw new IllegalArgumentException(ex.getLocalizedMessage(), ex);
}
}
}
String getHostPort() {
String hostName = host;
if (hostName == null || hostName.isEmpty()) {
if (inetAddress != null) {
hostName = inetAddress.toString();
} else {
hostName = InetAddress.getLoopbackAddress().toString();
}
}
return hostName + ":" + port;
}
InetSocketAddress createSocket() {
InetAddress ia;
if (inetAddress == null) {
ia = InetAddress.getLoopbackAddress();
} else {
ia = inetAddress;
}
return new InetSocketAddress(ia, port);
}
}
private static final class Server {
private InspectorWSConnection wss;
private final Token token;
private final String urlContainingToken;
private final InspectorExecutionContext executionContext;
Server(final Env env, final String contextName, final HostAndPort hostAndPort, final boolean attach, final boolean debugBreak, final boolean waitAttached, final boolean hideErrors,
final boolean inspectInternal, final boolean inspectInitialization, final String pathOrNull, final boolean secureHasBeenSet, final boolean secureValue,
final KeyStoreOptions keyStoreOptions, final List<URI> sourcePath, final ConnectionWatcher connectionWatcher) throws IOException {
InetSocketAddress socketAddress = hostAndPort.createSocket();
PrintWriter info = new PrintWriter(env.err(), true);
final String pathContainingToken;
if (pathOrNull == null || pathOrNull.isEmpty()) {
pathContainingToken = "/" + generateSecureToken();
} else {
String head = pathOrNull.startsWith("/") ? "" : "/";
pathContainingToken = head + pathOrNull;
}
token = Token.createHashedTokenFromString(pathContainingToken);
boolean secure = (!secureHasBeenSet && socketAddress.getAddress().isLoopbackAddress()) ? false : secureValue;
PrintWriter err = (hideErrors) ? null : info;
executionContext = new InspectorExecutionContext(contextName, inspectInternal, inspectInitialization, env, sourcePath, err);
if (attach) {
wss = new InspectWSClient(socketAddress, pathContainingToken, executionContext, debugBreak, secure, keyStoreOptions, connectionWatcher, info);
urlContainingToken = ((InspectWSClient) wss).getURI().toString();
} else {
final URI wsuri;
try {
wsuri = new URI(secure ? "wss" : "ws", null, socketAddress.getAddress().getHostAddress(), socketAddress.getPort(), pathContainingToken, null, null);
} catch (URISyntaxException ex) {
throw new IOException(ex);
}
InspectServerSession iss = InspectServerSession.create(executionContext, debugBreak, connectionWatcher);
boolean disposeIss = true;
try {
WSInterceptorServer interceptor = new WSInterceptorServer(socketAddress.getPort(), token, iss, connectionWatcher);
MessageEndpoint serverEndpoint;
try {
serverEndpoint = env.startServer(wsuri, iss);
} catch (MessageTransport.VetoException vex) {
throw new IOException(vex.getLocalizedMessage());
}
if (serverEndpoint == null) {
InspectorServer server;
interceptor.resetSessionEndpoint();
server = InspectorServer.get(socketAddress, token, pathContainingToken, executionContext, debugBreak, secure, keyStoreOptions, connectionWatcher, iss);
String wsAddress = server.getWSAddress(token);
wss = server;
urlContainingToken = wsAddress;
info.println("Debugger listening on " + wsAddress);
info.println("For help, see: " + HELP_URL);
info.println("E.g. in Chrome open: " + server.getDevtoolsAddress(token));
info.flush();
} else {
restartServerEndpointOnClose(hostAndPort, env, wsuri, executionContext, connectionWatcher, iss, interceptor);
interceptor.opened(serverEndpoint);
wss = interceptor;
urlContainingToken = wsuri.toString();
}
disposeIss = false;
} finally {
if (disposeIss) {
iss.dispose();
}
}
}
if (debugBreak || waitAttached) {
final AtomicReference<EventBinding<?>> execEnter = new AtomicReference<>();
final AtomicBoolean disposeBinding = new AtomicBoolean(false);
execEnter.set(env.getInstrumenter().attachContextsListener(new ContextsListener() {
@Override
public void onContextCreated(TruffleContext context) {
}
@Override
public void onLanguageContextCreated(TruffleContext context, LanguageInfo language) {
if (inspectInitialization) {
waitForRunPermission();
}
}
@Override
public void onLanguageContextInitialized(TruffleContext context, LanguageInfo language) {
if (!inspectInitialization) {
waitForRunPermission();
}
}
@Override
public void onLanguageContextFinalized(TruffleContext context, LanguageInfo language) {
}
@Override
public void onLanguageContextDisposed(TruffleContext context, LanguageInfo language) {
}
@Override
public void onContextClosed(TruffleContext context) {
}
@TruffleBoundary
private void waitForRunPermission() {
try {
executionContext.waitForRunPermission();
} catch (InterruptedException ex) {
}
final EventBinding<?> binding = execEnter.getAndSet(null);
if (binding != null) {
binding.dispose();
} else {
disposeBinding.set(true);
}
}
}, true));
if (disposeBinding.get()) {
execEnter.get().dispose();
}
}
}
private static String generateSecureToken() {
final byte[] tokenRaw = generateSecureRawToken();
return Base64.getEncoder().withoutPadding().encodeToString(tokenRaw).replace('/', '_').replace('+', '-');
}
private static byte[] generateSecureRawToken() {
final byte[] tokenRaw = new byte[32];
new SecureRandom().nextBytes(tokenRaw);
return tokenRaw;
}
private static void restartServerEndpointOnClose(HostAndPort hostAndPort, Env env, URI wsuri, InspectorExecutionContext executionContext, ConnectionWatcher connectionWatcher,
InspectServerSession iss, WSInterceptorServer interceptor) {
iss.onClose(() -> {
InspectServerSession newSession = InspectServerSession.create(executionContext, false, connectionWatcher);
interceptor.newSession(newSession);
MessageEndpoint serverEndpoint;
try {
serverEndpoint = env.startServer(wsuri, newSession);
} catch (MessageTransport.VetoException vex) {
newSession.dispose();
return;
} catch (IOException ioex) {
throw new InspectorIOException(hostAndPort.getHostPort(), ioex);
}
interceptor.opened(serverEndpoint);
restartServerEndpointOnClose(hostAndPort, env, wsuri, executionContext, connectionWatcher, newSession, interceptor);
});
}
public void close() throws IOException {
if (wss != null) {
wss.close(token);
wss = null;
}
}
void doFinalize() {
if (wss != null) {
try {
wss.close(token);
} catch (IOException ioex) {
}
wss.dispose();
wss = null;
}
}
InspectorServerConnection getConnection() {
return new InspectorServerConnection() {
@Override
public String getURL() {
return urlContainingToken;
}
@Override
public void close() throws IOException {
Server.this.close();
}
@Override
public InspectorExecutionContext getExecutionContext() {
return executionContext;
}
@Override
public void consoleAPICall(String type, Object text) {
if (wss != null) {
wss.consoleAPICall(token, type, text);
}
}
};
}
}
}