com.github.alexdlaird.ngrok.NgrokClient Maven / Gradle / Ivy
/*
* Copyright (c) 2021-2024 Alex Laird
*
* SPDX-License-Identifier: MIT
*/
package com.github.alexdlaird.ngrok;
import com.github.alexdlaird.exception.JavaNgrokException;
import com.github.alexdlaird.exception.JavaNgrokHTTPException;
import com.github.alexdlaird.exception.JavaNgrokSecurityException;
import com.github.alexdlaird.http.DefaultHttpClient;
import com.github.alexdlaird.http.HttpClient;
import com.github.alexdlaird.http.HttpClientException;
import com.github.alexdlaird.http.Response;
import com.github.alexdlaird.ngrok.conf.JavaNgrokConfig;
import com.github.alexdlaird.ngrok.conf.JavaNgrokVersion;
import com.github.alexdlaird.ngrok.installer.NgrokInstaller;
import com.github.alexdlaird.ngrok.installer.NgrokVersion;
import com.github.alexdlaird.ngrok.process.NgrokProcess;
import com.github.alexdlaird.ngrok.protocol.BindTls;
import com.github.alexdlaird.ngrok.protocol.CreateTunnel;
import com.github.alexdlaird.ngrok.protocol.Proto;
import com.github.alexdlaird.ngrok.protocol.Tunnel;
import com.github.alexdlaird.ngrok.protocol.Tunnels;
import com.github.alexdlaird.ngrok.protocol.Version;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.logging.Logger;
import static java.util.Objects.isNull;
import static java.util.Objects.nonNull;
/**
* A client for interacting with ngrok, its binary, and its APIs.
* Can be configured with {@link JavaNgrokConfig}.
* Basic Usage
* Open a Tunnel
* To open a tunnel, use the {@link NgrokClient#connect(CreateTunnel) NgrokClient.connect()} method, which returns a
* {@link Tunnel}, and this returned object has a reference to the public URL generated by ngrok
in its
* {@link Tunnel#getPublicUrl()} method.
*
*
* final NgrokClient ngrokClient = new NgrokClient.Builder().build();
*
* // Open a HTTP tunnel on the default port 80
* // <Tunnel: "https://<public_sub>.ngrok.io" -> "http://localhost:80">
* final Tunnel httpTunnel = ngrokClient.connect();
*
* // Open a SSH tunnel
* // <Tunnel: "tcp://0.tcp.ngrok.io:12345" -> "localhost:22">
* final CreateTunnel sshCreateTunnel = new CreateTunnel.Builder()
* .withProto(Proto.TCP)
* .withAddr(22)
* .build();
* final Tunnel sshTunnel = ngrokClient.connect(sshCreateTunnel);
*
* // Open a named tunnel from the config file
* final CreateTunnel createNamedTunnel = new CreateTunnel.Builder()
* .withName("my_tunnel_name")
* .build();
* final Tunnel namedTunnel = ngrokClient.connect(createNamedTunnel);
*
*
* The {@link NgrokClient#connect(CreateTunnel) NgrokClient.connect()} method can also take a {@link CreateTunnel}
* (which can be built through {@link CreateTunnel.Builder its Builder}) that allows us to pass additional properties
* that are supported by ngrok.
*
*
*
java-ngrok
is compatible with ngrok
v2 and v3, but by default it will install v3. To
* install v2 instead, set the version with {@link JavaNgrokConfig.Builder#withNgrokVersion(NgrokVersion)} and
* {@link CreateTunnel.Builder#withNgrokVersion(NgrokVersion)}.
*
*
Note: ngrok
v2's default behavior for http
when no additional
* properties are passed is to open two tunnels, one http
and one https
. This method
* will return a reference to the http
tunnel in this case. If only a single tunnel is needed, call
* {@link CreateTunnel.Builder#withBindTls(BindTls)} with {@link BindTls#TRUE} and a reference to the
* https
tunnel will be returned.
*
ngrok
's Edge
* To use ngrok
's Edges with
* java-ngrok
, first configure an
* Edge on ngrok
's dashboard (with at least one Endpoint mapped to the Edge), and define a labeled
* tunnel in the ngrok
config file that points to the Edge.
*
*
* tunnels:
* some-edge-tunnel:
* labels:
* - edge=my_edge_id
* addr: http://localhost:80
*
* To start a labeled tunnel in java-ngrok
, set {@link CreateTunnel.Builder#withName(String)}.
*
*
* final NgrokClient ngrokClient = new NgrokClient.Builder().build();
*
* // Open a named tunnel from the config file
* final CreateTunnel createNamedTunnel = new CreateTunnel.Builder()
* .withName("some-edge-tunnel")
* .build();
* final Tunnel namedTunnel = ngrokClient.connect(createNamedTunnel);
*
* Once an Edge tunnel is started, it can be managed through
* ngrok
's dashboard.
* Get Active Tunnels
* It can be useful to ask the ngrok
client what tunnels are currently open. This can be accomplished with
* the {@link NgrokClient#getTunnels()} method, which returns a list of {@link Tunnel} objects.
*
*
* [<Tunnel: "https://<public_sub>.ngrok.io" -> "http://localhost:80">]
* final List<Tunnel> tunnels = ngrokClient.getTunnels();
*
* Close a Tunnel
* All open tunnels will automatically be closed when the Java process terminates, but we can also close them manually
* with {@link NgrokClient#disconnect(String)}.
*
*
* // The Tunnel returned from methods like connect(), getTunnels(), etc. contains the public URL
* ngrokClient.disconnect(publicUrl);
*
* Expose Other Services
* Using ngrok
we can expose any number of non-HTTP services, for instances databases, game servers, etc.
* This can be accomplished by using java-ngrok
to open a tcp
tunnel to the desired service.
*
*
* final NgrokClient ngrokClient = new NgrokClient.Builder().build();
*
* // Open a tunnel to MySQL with a Reserved TCP Address
* // <NgrokTunnel: "tcp://1.tcp.ngrok.io:12345" -> "localhost:3306">
* final CreateTunnel mysqlCreateTunnel = new CreateTunnel.Builder()
* .withProto(Proto.TCP)
* .withAddr(3306)
* .withRemoteAddr("1.tcp.ngrok.io:12345")
* .build();
* final Tunnel mysqlTunnel = ngrokClient.connect(mysqlCreateTunnel);
*
*
* We can also serve up local directories via
* ngrok’s built-in
* fileserver.
*
*
* final NgrokClient ngrokClient = new NgrokClient.Builder().build();
*
* // Open a tunnel to a local file server
* // <NgrokTunnel: "https://<public_sub>.ngrok.io" -> "file:///">
* final CreateTunnel fileserverCreateTunnel = new CreateTunnel.Builder()
* .withAddr("file:///)
* .build();
* final Tunnel fileserverTunnel = ngrokClient.connect(fileserverCreateTunnel);
*
* Integration Examples
* java-ngrok
is useful in any number of integrations, for instance to test locally without having to
* deploy or configure. Here are some common usage examples.
*
*
*/
public class NgrokClient {
private static final Logger LOGGER = Logger.getLogger(String.valueOf(NgrokClient.class));
private final Map currentTunnels = new HashMap<>();
private final String javaNgrokVersion;
private final JavaNgrokConfig javaNgrokConfig;
private final NgrokProcess ngrokProcess;
private final HttpClient httpClient;
private NgrokClient(final Builder builder) {
this.javaNgrokVersion = builder.javaNgrokVersion;
this.javaNgrokConfig = builder.javaNgrokConfig;
this.ngrokProcess = builder.ngrokProcess;
this.httpClient = builder.httpClient;
}
/**
* Establish a new ngrok
tunnel for the Tunnel creation request, returning an object representing the
* connected tunnel.
*
* If a tunnel definition in ngrok's config file matches the given
* {@link CreateTunnel.Builder#withName(String)}, it will be loaded and used to start the tunnel. When
* {@link CreateTunnel.Builder#withName(String)} is not set and a "java-ngrok-default" tunnel definition exists in
* ngrok
's config, it will be loaded and use. Any properties defined on {@link CreateTunnel} will
* override properties from the loaded tunnel definition.
*
*
If ngrok
is not installed at {@link JavaNgrokConfig}'s ngrokPath
, calling this
* method will first download and install ngrok
.
*
*
java-ngrok
is compatible with ngrok
v2 and v3, but by default it will install v2.
* To install v3 instead, set the version with {@link JavaNgrokConfig.Builder#withNgrokVersion(NgrokVersion)} and
* {@link CreateTunnel.Builder#withNgrokVersion(NgrokVersion)}.
*
*
If ngrok
is not running, calling this method will first start a process with
* {@link JavaNgrokConfig}.
*
*
Note: ngrok
v2's default behavior for http
when no additional
* properties are passed is to open two tunnels, one http
and one https
. This
* method will return a reference to the http
tunnel in this case. If only a single tunnel is needed,
* call {@link CreateTunnel.Builder#withBindTls(BindTls)} with {@link BindTls#TRUE} and a reference to the
* https
tunnel will be returned.
*
* @param createTunnel The tunnel definition.
* @return The created Tunnel.
* @throws JavaNgrokException The tunnel definition was invalid, or response was incompatible with
* java-ngrok
.
* @throws JavaNgrokHTTPException An HTTP error occurred communicating with the ngrok
API.
* @throws JavaNgrokSecurityException The URL was not supported.
*/
public Tunnel connect(final CreateTunnel createTunnel) {
ngrokProcess.start();
final CreateTunnel finalTunnel = interpolateTunnelDefinition(createTunnel);
LOGGER.info(String.format("Opening tunnel named: %s", finalTunnel.getName()));
final Response response;
try {
response = httpClient.post(String.format("%s/api/tunnels", ngrokProcess.getApiUrl()), finalTunnel,
Tunnel.class);
} catch (final HttpClientException e) {
throw new JavaNgrokHTTPException(String.format("An error occurred when POSTing to create the tunnel %s.",
finalTunnel.getName()), e, e.getUrl(), e.getStatusCode(), e.getBody());
}
final Tunnel tunnel;
if (javaNgrokConfig.getNgrokVersion() == NgrokVersion.V2
&& finalTunnel.getProto() == Proto.HTTP
&& finalTunnel.getBindTls() == BindTls.BOTH) {
try {
final Response getResponse = httpClient.get(ngrokProcess.getApiUrl()
+ response.getBody().getUri() + "%20%28http%29",
Tunnel.class);
tunnel = getResponse.getBody();
} catch (final HttpClientException e) {
throw new JavaNgrokHTTPException(String.format("An error occurred when GETing the HTTP tunnel %s.",
response.getBody().getName()), e, e.getUrl(), e.getStatusCode(), e.getBody());
}
} else {
tunnel = response.getBody();
}
applyEdgeToTunnel(tunnel);
currentTunnels.put(tunnel.getPublicUrl(), tunnel);
return tunnel;
}
/**
* See {@link #connect(CreateTunnel)}.
*/
public Tunnel connect() {
return connect(new CreateTunnel.Builder().withNgrokVersion(javaNgrokConfig.getNgrokVersion()).build());
}
/**
* Disconnect the ngrok
tunnel for the given URL, if open.
*
* @param publicUrl The public URL of the tunnel to disconnect.
* @throws JavaNgrokHTTPException An HTTP error occurred communicating with the ngrok
API.
* @throws JavaNgrokSecurityException The URL was not supported.
*/
public void disconnect(final String publicUrl) {
// If ngrok is not running, there are no tunnels to disconnect
if (!ngrokProcess.isRunning()) {
return;
}
if (!currentTunnels.containsKey(publicUrl)) {
getTunnels();
// One more check, if the given URL is still not in the list of tunnels, it is not active
if (!currentTunnels.containsKey(publicUrl)) {
return;
}
}
final Tunnel tunnel = currentTunnels.get(publicUrl);
ngrokProcess.start();
LOGGER.info(String.format("Disconnecting tunnel: %s", tunnel.getPublicUrl()));
try {
httpClient.delete(ngrokProcess.getApiUrl() + tunnel.getUri());
} catch (final HttpClientException e) {
throw new JavaNgrokHTTPException(String.format("An error occurred when DELETing the tunnel %s.",
publicUrl), e, e.getUrl(), e.getStatusCode(), e.getBody());
}
}
/**
* Get a list of active ngrok
tunnels.
*
* If ngrok
is not running, calling this method will first start a process with
* {@link JavaNgrokConfig}.
*
* @return The active ngrok
tunnels.
* @throws JavaNgrokException The response was invalid or not compatible with java-ngrok
.
* @throws JavaNgrokHTTPException An HTTP error occurred communicating with the ngrok
API.
* @throws JavaNgrokSecurityException The URL was not supported.
*/
public List getTunnels() {
ngrokProcess.start();
try {
final Response response = httpClient.get(String.format("%s/api/tunnels",
ngrokProcess.getApiUrl()), Tunnels.class);
currentTunnels.clear();
for (final Tunnel tunnel : response.getBody().getTunnels()) {
applyEdgeToTunnel(tunnel);
currentTunnels.put(tunnel.getPublicUrl(), tunnel);
}
final List sortedTunnels = new ArrayList<>(currentTunnels.values());
sortedTunnels.sort(Comparator.comparing(Tunnel::getProto));
return List.of(sortedTunnels.toArray(new Tunnel[]{}));
} catch (final HttpClientException e) {
throw new JavaNgrokHTTPException("An error occurred when GETing the tunnels.", e, e.getUrl(),
e.getStatusCode(), e.getBody());
}
}
/**
* Get the latest metrics for the given {@link Tunnel} and update its metrics
attribute.
*
* @param tunnel The Tunnel to update.
* @throws JavaNgrokException The API did not return metrics
.
* @throws JavaNgrokSecurityException The URL was not supported.
*/
public void refreshMetrics(final Tunnel tunnel) {
Response latestTunnel = httpClient.get(String.format("%s%s", ngrokProcess.getApiUrl(),
tunnel.getUri()), Tunnel.class);
if (isNull(latestTunnel.getBody().getMetrics()) || latestTunnel.getBody().getMetrics().isEmpty()) {
throw new JavaNgrokException("The ngrok API did not return \"metrics\" in the response");
}
tunnel.setMetrics(latestTunnel.getBody().getMetrics());
}
/**
* Terminate the ngrok
processes, if running. This method will not block, it will just issue a kill
* request.
*/
public void kill() {
ngrokProcess.stop();
currentTunnels.clear();
}
/**
* Set the ngrok
auth token in the config file, enabling authenticated features (for instance, more
* concurrent tunnels, custom subdomains, etc.).
*
* @param authToken The auth token.
*/
public void setAuthToken(final String authToken) {
ngrokProcess.setAuthToken(authToken);
}
/**
* Update ngrok
, if an update is available.
*/
public void update() {
ngrokProcess.update();
}
/**
* Get the ngrok
and java-ngrok
version.
*
* @return The versions.
*/
public Version getVersion() {
final String ngrokVersion = ngrokProcess.getVersion();
return new Version(ngrokVersion, javaNgrokVersion);
}
/**
* Get the java-ngrok
to use when interacting with the ngrok
binary.
*/
public JavaNgrokConfig getJavaNgrokConfig() {
return javaNgrokConfig;
}
/**
* Get the class used to manage the ngrok
binary.
*/
public NgrokProcess getNgrokProcess() {
return ngrokProcess;
}
/**
* Get the class used to make HTTP requests to ngrok
's APIs.
*/
public HttpClient getHttpClient() {
return httpClient;
}
private void applyEdgeToTunnel(final Tunnel tunnel) {
if ((isNull(tunnel.getPublicUrl()) || tunnel.getPublicUrl().isEmpty())
&& nonNull(javaNgrokConfig.getApiKey()) && nonNull(tunnel.getId())) {
final Map ngrokApiHeaders = Map.of(
"Authorization", String.format("Bearer %s", javaNgrokConfig.getApiKey()),
"Ngrok-Version", "2");
final Response