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.

Gradle Installation
repositories {
    mavenCentral()
}

dependencies {
    implementation 'com.agorapulse:micronaut-console:3.0.0'

    // 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.

application.yml
console:
  enabled: true

3. Usage

3.1. Script Variables

The following variables are available by default:

Name Type Description

ctx

io.micronaut.context.ApplicationContext

The application context

user

com.agorapulse.micronaut.console.User

The user executing the script

request

io.micronaut.http.HttpRequest

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:

Example Groovy Script
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.

Bash Script
curl -X POST -H "Content-Type: text/groovy" --data-binary @printer.groovy "http://localhost:8080/console/execute/result"
IntelliJ HTTP Client with Inline Code
POST http://localhost:8080/console/execute/result
Content-Type: text/groovy
Accept: text/plain

// language=groovy                                                                      (1)
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
IntelliJ HTTP Client with External Script
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:

Plain Text
# Out #
This is a debug message

# Result #
Hello Developer!
JSON
{
    "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

Address Filter Configuration
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

User FilterConfiguration
console:
  users:
    - sherlock

You must use Micronaut Security or define your own implementation of TypedRequestArgumentBinder<User> to get the username of the user.

User Binder
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 Configuration
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.

Shell Credentials File
{
    "username": "sherlock",
    "password": "password"
}

Then you can create a shell script file which simplifies the console script execution:

Shell Script 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.

IntelliJ Credentials File
{
    "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:

HTTP Clint file with 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:

Address Advisor
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());
    }

}
Enabled Advisor
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();
    }

}
Until Advisor
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();
    }

}
Users Advisor
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.

4. Release Notes

4.1. 3.0.0

4.1.1. Breaking Changes

  • Migrated to Micronaut 4.x so the minimum JDK version is now 17 and the Groovy version used for scripting is now 4.x.

4.2. 2.0.0

4.2.1. Breaking Changes

  • The console is disabled by default. To enable it, set the console.enabled property to true in the application configuration file or set the CONSOLE_ENABLED environment variable to true.