1. Introduction
Micronaut Console is an extension to Micronaut applications and functions that allows executing arbitrary code. The project was inspired by the Grails Console Plugin. The console does not provide a web UI, but it lets you leverage the power of your IDE. Micronaut Console provides out-of-the-box support for Groovy and for any JSR223 compatible scripting engine, such as the built-in Ecmascript Nashorn engine or Kotlin script JSR223 wrapper.
2. Installation
Add the following dependency into your build file.
repositories {
mavenCentral()
}
dependencies {
implementation 'com.agorapulse:micronaut-console:3.0.1'
// for Groovy integration
implementation 'org.apache.groovy:groovy'
// for Kotlin integration
implementation "org.jetbrains.kotlin:kotlin-scripting-jsr223:${kotlinVersion}"
// for Micronaut Security integration
implementation "io.micronaut.security:micronaut-security"
implementation "io.micronaut.security:micronaut-security-jwt"
}
The console must be explicitly enabled. Add at least the following configuration to your application configuration.
console:
enabled: true
3. Usage
3.1. Script Variables
The following variables are available by default:
Name | Type | Description |
---|---|---|
|
|
The application context |
|
|
The user executing the script |
|
|
The request used to execute the script (only for HTTP calls) |
You can create additional beans that implement com.agorapulse.micronaut.console.BindingProvider
to provide additional binding variables.
You can see what bindings are available in your application by issuing a GET
request to /console/dsl/text
:
curl http://localhost:8080/console/dsl/text
You can download an up-to-date DSL file from your server by altering the text
part of the path. These are the available descriptors:
-
gdsl
for Groovy and IntelliJ -
dsld
for Groovy and Eclipse
curl http://localhost:8080/console/dsl/gdsl > console.gdsl
3.2. Server Applications
The typical use case is to execute a script in your HTTP server application.There are two endpoints,
/console/execute
and /console/execute/result
. Both
accept the script as the body of a POST
request, but the first returns a JSON response
and the second returns a plain text response.
The base path /console can be altered with the console.path configuration property.
|
Here is an example of a simple Groovy script:
import io.netty.util.concurrent.FastThreadLocalThread
if (Thread.currentThread() instanceof FastThreadLocalThread) {
throw new IllegalStateException('Running on FastThreadLocalThread will fail execution of HTTP client requests')
}
println "This is a debug message"
"Hello Developer!"
You can use tools such as cURL to execute the script, or you can use IDE integrations such as the IntelliJ HTTP client.
The Content-Type
must match the mime type of language e.g. text/groovy
, application/javascript
, or text/x-kotlin
.
curl -X POST -H "Content-Type: text/groovy" --data-binary @printer.groovy "http://localhost:8080/console/execute/result"
POST http://localhost:8080/console/execute/result
Content-Type: text/groovy
Accept: text/plain
// language=groovy (1)
import io.netty.util.concurrent.FastThreadLocalThread
if (Thread.currentThread() instanceof FastThreadLocalThread) {
throw new IllegalStateException('Running on FastThreadLocalThread will fail execution of HTTP client requests')
}
println "This is a debug message"
"Hello Developer!"
1 | IntelliJ hint to treat the body of the request as Groovy code and provide content assistance |
POST http://localhost:8080/console/execute/result
Content-Type: text/groovy
Accept: text/plain
< printer.groovy (1)
1 | Path to the script file which will get all the content assist available |
After execution, you will get the following results depending on an endpoint used:
# Out #
This is a debug message
# Result #
Hello Developer!
{
"result": "Hello Developer!",
"out": "This is a debug message\n"
}
JSON responses are useful to create a client for console endpoints. Plain text responses are useful for shell and IDE executions.
The result property returns a JSON representation of the last evaluated line, so it can also be a JavaScript
object or array depending on the situation.
|
3.3. Security
Security is very important as you can run arbitrary code using the console. By default, the console is disabled.
You must either set console.enabled
to true
, or you can set console.until
to any ISO date in the future such as 2020-11-12T10:00:00Z
. The second option will enable the console only for the specified period.
3.3.1. Address Filter
You can specify from which address the users can access the console
console:
addresses:
- /127.0.0.1
- /0:0:0:0:0:0:0:1
The addresses must start with a forward slash / .
|
If you use a reverse proxy (e.g., Nginx on AWS Beanstalk), you must configure hostname resolution correctly. |
3.3.2. User Filter
You can specify which users can access the console
console:
users:
- sherlock
You must use Micronaut Security or define your own implementation of TypedRequestArgumentBinder<User>
to
get the username of the user.
import com.agorapulse.micronaut.console.User;
import io.micronaut.context.annotation.Replaces;
import io.micronaut.core.convert.ArgumentConversionContext;
import io.micronaut.core.type.Argument;
import io.micronaut.http.HttpRequest;
import io.micronaut.http.bind.binders.TypedRequestArgumentBinder;
import jakarta.inject.Singleton;
@Singleton
@Replaces(AnonymousUserArgumentBinder.class)
public class SimpleUserBinder implements TypedRequestArgumentBinder<User> {
private final AnonymousUserArgumentBinder delegate = new AnonymousUserArgumentBinder();
@Override
public Argument<User> argumentType() {
return delegate.argumentType();
}
@Override
public BindingResult<User> bind(ArgumentConversionContext<User> context, HttpRequest<?> source) {
return () -> delegate.bind(context, source).getValue().map(u -> new User(
source.getHeaders().get("X-Console-User"),
source.getHeaders().get("X-Console-Name"),
u.getAddress()
));
}
}
3.3.3. Micronaut Security Integration
Micronaut Console integrates well with the Micronaut Security library.
micronaut:
security:
enabled: true (1)
endpoints:
login:
enabled: true (2)
token:
jwt:
enabled: true (3)
signatures:
secret:
generator:
secret: pleaseChangeThisSecretForANewOne (4)
jws-algorithm: HS256
intercept-url-map:
- pattern: /console/** (5)
http-method: GET
access:
- isAnonymous()
- pattern: /console/** (6)
http-method: POST
access:
- isAuthenticated()
authentication: bearer (7)
1 | Enable Micronaut Security |
2 | Enable login endpoint |
3 | Enable JWT token integration |
4 | JWT token generator’s secret |
5 | Allow anonymous access for DSL files generation |
6 | Allow only authenticated users execute scripts |
Using Shell with Micronaut Security
When you are running the scripts from shell then you need to do few extra steps to authenticate with the Micronaut Security
First, create credentials.json
file and add it to .gitignore
.
{
"username": "sherlock",
"password": "password"
}
Then you can create a shell script file which simplifies the console script execution:
execute.sh
#!/bin/sh
if [ "$#" -ne 1 ]
then
echo "Usage: ./execute.sh script_file [host] [mimetype]"
exit 1
fi
script_file=$1
default_host="http://localhost:8080"
host=${2:-$default_host}
extension="${1##*.}"
default_mimetype="text/$extension"
mimetype=${3:-$default_mimetype}
curl -sS -X POST -H 'Content-Type: application/json' -d @credentials.json -o token.json "$host/login"
curl -X POST -H "Content-Type: $mimetype" -H "Authorization: Bearer $(jq -r .access_token token.json)" --data-binary @"$script_file" "$host/console/execute/result"
rm token.json
echo
You can use this script to execute the console scripts:
./execute.sh external.groovy text/groovy http://localhost:8080
The mime type text/groovy
is optional if it is the same as text/<extension>
.
The host is optional if it is http://localhost:8080
Using IntelliJ HTTP Files with Micronaut Security
When you run the scripts from IntelliJ, you must do a few extra steps to authenticate with Micronaut Security.
First, create http-client.private.env.json
file and add it to .gitignore
.
{
"username": "sherlock",
"password": "password"
}
You can have as many entries for different environments (e.g., test
) as you wish.
Then update the HTTP client file to include authentication:
POST {{host}}/login
Content-Type: application/json
Accept: application/json
{
"username": "{{username}}",
"password": "{{password}}"
}
> {%
client.global.set("auth_token", response.body.access_token);
%}
###
POST {{host}}/console/execute/result
Content-Type: text/groovy
Accept: text/plain
Authorization: Bearer {{auth_token}}
< external.groovy
3.3.4. Auditing
You can audit who used the Micronaut Console by implementing a bean that implements com.agorapulse.micronaut.console.AuditService
.
There is a default implementation which logs using SLF4J.
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import jakarta.inject.Singleton;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.util.Map;
@Singleton
public class DefaultAuditService implements AuditService {
private static final Logger LOGGER = LoggerFactory.getLogger(DefaultAuditService.class);
@Override
public void beforeExecute(Script script, Map<String, Object> bindings) {
LOGGER.debug("Before execution:\n{}\n\nBindings: {}", script, bindings);
}
@Override
public void afterExecute(Script script, ExecutionResult result) {
LOGGER.debug("After execution:\n{}\n\nResult:\n{}", script, result);
}
@Override
public void onError(Script script, Throwable throwable) {
if (LOGGER.isInfoEnabled()) {
StringWriter sw = new StringWriter();
throwable.printStackTrace(new PrintWriter(sw));
LOGGER.debug("Execution error:\n" + script + "\n\n" + sw);
}
}
}
3.3.5. Security Advisors
You can add more customization such as role based access by implementing your own security advisor com.agorapulse.micronaut.console.SecurityAdvisor
.
Here is how the builtin advisors look like:
import com.agorapulse.micronaut.console.ConsoleConfiguration;
import com.agorapulse.micronaut.console.Script;
import com.agorapulse.micronaut.console.SecurityAdvisor;
import io.micronaut.context.annotation.Requires;
import jakarta.inject.Singleton;
@Singleton
@Requires(property = "console.addresses")
public class AddressAdvisor implements SecurityAdvisor {
private final ConsoleConfiguration configuration;
public AddressAdvisor(ConsoleConfiguration configuration) {
this.configuration = configuration;
}
@Override
public boolean isExecutionAllowed(Script script) {
if (script.getUser() == null || script.getUser().getAddress() == null) {
// address must be known
return false;
}
return configuration.getAddresses().contains(script.getUser().getAddress());
}
@Override
public String toString() {
return "Address advisor for addresses " + String.join(", ", configuration.getAddresses());
}
}
import com.agorapulse.micronaut.console.ConsoleConfiguration;
import com.agorapulse.micronaut.console.Script;
import com.agorapulse.micronaut.console.SecurityAdvisor;
import io.micronaut.context.ApplicationContext;
import io.micronaut.context.env.Environment;
import jakarta.inject.Singleton;
import java.time.Instant;
@Singleton
public class EnabledAdvisor implements SecurityAdvisor {
private final ConsoleConfiguration configuration;
private final ApplicationContext context;
public EnabledAdvisor(ConsoleConfiguration configuration, ApplicationContext context) {
this.configuration = configuration;
this.context = context;
}
@Override
public boolean isExecutionAllowed(Script script) {
//CHECKSTYLE:OFF
if (configuration.isEnabled() || (configuration.convertUntil() != null && configuration.convertUntil().isAfter(Instant.now()))) {
return true;
}
//CHECKSTYLE:ON
// functions have their own security checks
// otherwise return false
return context.getEnvironment().getActiveNames().contains(Environment.FUNCTION);
// disable by default
}
@Override
public String toString() {
return "Enabled advisor while enabled = " + configuration.isEnabled() + " and until is " + configuration.getUntil();
}
}
import com.agorapulse.micronaut.console.ConsoleConfiguration;
import com.agorapulse.micronaut.console.Script;
import com.agorapulse.micronaut.console.SecurityAdvisor;
import io.micronaut.context.annotation.Requires;
import jakarta.inject.Singleton;
import java.time.Instant;
@Singleton
@Requires(property = "console.until")
public class UntilAdvisor implements SecurityAdvisor {
private final ConsoleConfiguration configuration;
public UntilAdvisor(ConsoleConfiguration configuration) {
this.configuration = configuration;
}
@Override
public boolean isExecutionAllowed(Script script) {
return Instant.now().isBefore(configuration.convertUntil());
}
@Override
public String toString() {
return "Until advisor for date before " + configuration.convertUntil();
}
}
import com.agorapulse.micronaut.console.ConsoleConfiguration;
import com.agorapulse.micronaut.console.Script;
import com.agorapulse.micronaut.console.SecurityAdvisor;
import io.micronaut.context.annotation.Requires;
import jakarta.inject.Singleton;
@Singleton
@Requires(property = "console.users")
public class UsersAdvisor implements SecurityAdvisor {
private final ConsoleConfiguration configuration;
public UsersAdvisor(ConsoleConfiguration configuration) {
this.configuration = configuration;
}
@Override
public boolean isExecutionAllowed(Script script) {
if (script.getUser() == null || script.getUser().getId() == null) {
// id must be known
return false;
}
return configuration.getUsers().contains(script.getUser().getId());
}
@Override
public String toString() {
return "Users advisor for user IDs " + String.join(", ", configuration.getUsers());
}
}
If any of the security advisors denies the execution then the script is not executed.
3.3.6. Compilation Customizers
Groovy programming language is the first-class citizen in Micronaut Console. You can customize the way how the Groovy script by implementing bean of type
com.agorapulse.micronaut.console.groovy.CompilerConfigurationCustomizer
.
3.3.7. Server-side Request Forgery Protection
You may want to open your console for access only from the localhost and connect via SSH tunnel to execute the scripts. In this case, you need to be aware of Server-side Request Forgery (SSRF). If your application issues HTTP requests for user-defined URLs then you should protect your console with additional required header which will be only present in authorized calls but won’t be present in any accidental ones.
console:
# enable the console
enabled: true
# but only for localhost
addresses:
- /127.0.0.1
- /0:0:0:0:0:0:0:1
# the name of the header which needs to be present in the POST requests
header-name: X-Console-Verify
# the expected value of the header
header-value: S3cr3T
If you configure the required header then every POST
request must also pass the header X-Console-Verify: S3cr3t
otherwise 403: FORBIDDEN
response will be returned from the console’s endpoints.