Commit 6b599b84 authored by Christian Dupuis's avatar Christian Dupuis

Add remote shell implementation based on crsh

This commit adds a new starter named spring-boot-starter-shell-crsh and auto configuration support to embed a system shell within Spring Boot applications.

The embedded shell allows clients to connect via ssh or telnet to the Boot app and execute commands. Commands can be implemented and embedded with app.

For sample usage see spring-boot-samples-actuator.
parent 90a2bf38
......@@ -72,6 +72,11 @@
<artifactId>tomcat-embed-core</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.crashub</groupId>
<artifactId>crash.embed.spring</artifactId>
<optional>true</optional>
</dependency>
<!-- Test -->
<dependency>
<groupId>org.springframework</groupId>
......
/*
* Copyright 2013 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.actuate.properties;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Properties;
import java.util.UUID;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
/**
* Configuration properties for the shell subsystem.
*
* @author Christian Dupuis
*/
@ConfigurationProperties(name = "shell", ignoreUnknownFields = true)
public class CrshProperties {
protected static final String CRASH_AUTH = "crash.auth";
protected static final String CRASH_AUTH_JAAS_DOMAIN = "crash.auth.jaas.domain";
protected static final String CRASH_AUTH_KEY_PATH = "crash.auth.key.path";
protected static final String CRASH_AUTH_SIMPLE_PASSWORD = "crash.auth.simple.password";
protected static final String CRASH_AUTH_SIMPLE_USERNAME = "crash.auth.simple.username";
protected static final String CRASH_AUTH_SPRING_ROLES = "crash.auth.spring.roles";
protected static final String CRASH_SSH_KEYPATH = "crash.ssh.keypath";
protected static final String CRASH_SSH_PORT = "crash.ssh.port";
protected static final String CRASH_TELNET_PORT = "crash.telnet.port";
protected static final String CRASH_VFS_REFRESH_PERIOD = "crash.vfs.refresh_period";
private String auth = "simple";
@Autowired(required = false)
private AuthenticationProperties authenticationProperties;
private int commandRefreshInterval = -1;
private String[] commandPathPatterns = new String[] { "classpath*:/commands/**",
"classpath*:/crash/commands/**" };
private String[] configPathPatterns = new String[] { "classpath*:/crash/*" };
private String[] disabledPlugins = new String[0];
private Ssh ssh = new Ssh();
private Telnet telnet = new Telnet();
public String getAuth() {
return this.auth;
}
public AuthenticationProperties getAuthenticationProperties() {
return this.authenticationProperties;
}
public int getCommandRefreshInterval() {
return this.commandRefreshInterval;
}
public String[] getCommandPathPatterns() {
return this.commandPathPatterns;
}
public String[] getConfigPathPatterns() {
return this.configPathPatterns;
}
public String[] getDisabledPlugins() {
return this.disabledPlugins;
}
public Ssh getSsh() {
return this.ssh;
}
public Telnet getTelnet() {
return this.telnet;
}
public Properties mergeProperties(Properties properties) {
properties = ssh.mergeProperties(properties);
properties = telnet.mergeProperties(properties);
properties.put(CRASH_AUTH, auth);
if (authenticationProperties != null) {
properties = authenticationProperties.mergeProperties(properties);
}
if (this.commandRefreshInterval > 0) {
properties.put(CRASH_VFS_REFRESH_PERIOD, String.valueOf(this.commandRefreshInterval));
}
// special handling for disabling Ssh and Telnet support
List<String> dp = new ArrayList<String>(Arrays.asList(this.disabledPlugins));
if (!ssh.isEnabled()) {
dp.add("org.crsh.ssh.SSHPlugin");
}
if (!telnet.isEnabled()) {
dp.add("org.crsh.telnet.TelnetPlugin");
}
this.disabledPlugins = dp.toArray(new String[dp.size()]);
return properties;
}
public void setAuth(String auth) {
Assert.hasLength(auth);
this.auth = auth;
}
public void setAuthenticationProperties(AuthenticationProperties authenticationProperties) {
Assert.notNull(authenticationProperties);
this.authenticationProperties = authenticationProperties;
}
public void setCommandRefreshInterval(int commandRefreshInterval) {
this.commandRefreshInterval = commandRefreshInterval;
}
public void setCommandPathPatterns(String[] commandPathPatterns) {
Assert.notEmpty(commandPathPatterns);
this.commandPathPatterns = commandPathPatterns;
}
public void setConfigPathPatterns(String[] configPathPatterns) {
Assert.notEmpty(configPathPatterns);
this.configPathPatterns = configPathPatterns;
}
public void setDisabledPlugins(String[] disabledPlugins) {
Assert.notEmpty(disabledPlugins);
this.disabledPlugins = disabledPlugins;
}
public void setSsh(Ssh ssh) {
Assert.notNull(ssh);
this.ssh = ssh;
}
public void setTelnet(Telnet telnet) {
Assert.notNull(telnet);
this.telnet = telnet;
}
public interface AuthenticationProperties extends PropertiesProvider {
}
@ConfigurationProperties(name = "shell.auth.jaas", ignoreUnknownFields = false)
public static class JaasAuthenticationProperties implements AuthenticationProperties {
private String domain = "my-domain";
@Override
public Properties mergeProperties(Properties properties) {
properties.put(CRASH_AUTH_JAAS_DOMAIN, this.domain);
return properties;
}
public void setDomain(String domain) {
Assert.hasText(domain);
this.domain = domain;
}
}
@ConfigurationProperties(name = "shell.auth.key", ignoreUnknownFields = false)
public static class KeyAuthenticationProperties implements AuthenticationProperties {
private String path;
@Override
public Properties mergeProperties(Properties properties) {
if (this.path != null) {
properties.put(CRASH_AUTH_KEY_PATH, this.path);
}
return properties;
}
public void setPath(String path) {
Assert.hasText(path);
this.path = path;
}
}
public interface PropertiesProvider {
Properties mergeProperties(Properties properties);
}
@ConfigurationProperties(name = "shell.auth.simple", ignoreUnknownFields = false)
public static class SimpleAuthenticationProperties implements AuthenticationProperties {
private static Log logger = LogFactory.getLog(SimpleAuthenticationProperties.class);
private String username = "user";
private String password = UUID.randomUUID().toString();
private boolean defaultPassword = true;
public boolean isDefaultPassword() {
return this.defaultPassword;
}
@Override
public Properties mergeProperties(Properties properties) {
properties.put(CRASH_AUTH_SIMPLE_USERNAME, this.username);
properties.put(CRASH_AUTH_SIMPLE_PASSWORD, this.password);
if (this.defaultPassword) {
logger.info("Using default password for shell access: " + this.password);
}
return properties;
}
public void setPassword(String password) {
if (password.startsWith("${") && password.endsWith("}") || !StringUtils.hasLength(password)) {
return;
}
this.password = password;
this.defaultPassword = false;
}
public void setUsername(String username) {
Assert.hasLength(username);
this.username = username;
}
}
@ConfigurationProperties(name = "shell.auth.spring", ignoreUnknownFields = false)
public static class SpringAuthenticationProperties implements AuthenticationProperties {
private String[] roles = new String[] { "ROLE_ADMIN" };
@Override
public Properties mergeProperties(Properties properties) {
if (this.roles != null) {
properties.put(CRASH_AUTH_SPRING_ROLES, StringUtils.arrayToCommaDelimitedString(this.roles));
}
return properties;
}
public void setRoles(String[] roles) {
Assert.notNull(roles);
this.roles = roles;
}
}
public static class Ssh implements PropertiesProvider {
private boolean enabled = true;
private String keyPath = null;
private String port = "2000";
public boolean isEnabled() {
return this.enabled;
}
@Override
public Properties mergeProperties(Properties properties) {
if (this.enabled) {
properties.put(CRASH_SSH_PORT, this.port);
if (this.keyPath != null) {
properties.put(CRASH_SSH_KEYPATH, this.keyPath);
}
}
return properties;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public void setKeyPath(String keyPath) {
Assert.hasText(keyPath);
this.keyPath = keyPath;
}
public void setPort(Integer port) {
Assert.notNull(port);
this.port = port.toString();
}
}
public static class Telnet implements PropertiesProvider {
private boolean enabled = false;
private String port = "5000";
public boolean isEnabled() {
return this.enabled;
}
@Override
public Properties mergeProperties(Properties properties) {
if (this.enabled) {
properties.put(CRASH_TELNET_PORT, this.port);
}
return properties;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public void setPort(Integer port) {
Assert.notNull(port);
this.port = port.toString();
}
}
}
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
org.springframework.boot.actuate.autoconfigure.AuditAutoConfiguration,\
org.springframework.boot.actuate.autoconfigure.CrshAutoConfiguration,\
org.springframework.boot.actuate.autoconfigure.EndpointAutoConfiguration,\
org.springframework.boot.actuate.autoconfigure.EndpointWebMvcAutoConfiguration,\
org.springframework.boot.actuate.autoconfigure.ErrorMvcAutoConfiguration,\
......
......@@ -47,6 +47,7 @@
<thymeleaf-extras-springsecurity3.version>2.0.1</thymeleaf-extras-springsecurity3.version>
<thymeleaf-layout-dialect.version>1.1.3</thymeleaf-layout-dialect.version>
<tomcat.version>7.0.42</tomcat.version>
<crashub.version>1.3.0-beta8</crashub.version>
</properties>
<dependencyManagement>
<dependencies>
......@@ -481,6 +482,31 @@
<artifactId>geronimo-jms_1.1_spec</artifactId>
<version>1.1</version>
</dependency>
<dependency>
<groupId>org.crashub</groupId>
<artifactId>crash.cli</artifactId>
<version>${crashub.version}</version>
</dependency>
<dependency>
<groupId>org.crashub</groupId>
<artifactId>crash.connectors.ssh</artifactId>
<version>${crashub.version}</version>
</dependency>
<dependency>
<groupId>org.crashub</groupId>
<artifactId>crash.connectors.telnet</artifactId>
<version>${crashub.version}</version>
</dependency>
<dependency>
<groupId>org.crashub</groupId>
<artifactId>crash.embed.spring</artifactId>
<version>${crashub.version}</version>
</dependency>
<dependency>
<groupId>org.crashub</groupId>
<artifactId>crash.shell</artifactId>
<version>${crashub.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
<build>
......
......@@ -32,6 +32,10 @@
<groupId>${project.groupId}</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>spring-boot-starter-shell-crsh</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
......
......@@ -6,4 +6,12 @@ server.port: 8080
server.tomcat.basedir: target/tomcat
server.tomcat.access_log_pattern: %h %t "%r" %s %b
security.require_ssl: false
service.name: Phil
\ No newline at end of file
service.name: Phil
shell.ssh.enabled: true
shell.ssh.port: 2222
shell.telnet.enabled: false
#shell.telnet.port: 1111
shell.auth: spring
#shell.auth: key
#shell.auth.key.path: ${user.home}/test/id_rsa.pub.pem
#shell.auth: simple
\ No newline at end of file
......@@ -26,6 +26,7 @@
<module>spring-boot-starter-actuator</module>
<module>spring-boot-starter-parent</module>
<module>spring-boot-starter-security</module>
<module>spring-boot-starter-shell-crsh</module>
<module>spring-boot-starter-test</module>
<module>spring-boot-starter-tomcat</module>
<module>spring-boot-starter-web</module>
......
......@@ -98,6 +98,11 @@
<artifactId>spring-boot-starter-security</artifactId>
<version>0.5.0.BUILD-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-shell-crsh</artifactId>
<version>0.5.0.BUILD-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
......
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starters</artifactId>
<version>0.5.0.BUILD-SNAPSHOT</version>
</parent>
<artifactId>spring-boot-starter-shell-crsh</artifactId>
<packaging>jar</packaging>
<properties>
<main.basedir>${basedir}/../..</main.basedir>
</properties>
<dependencies>
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>spring-boot-starter</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.crashub</groupId>
<artifactId>crash.cli</artifactId>
</dependency>
<dependency>
<groupId>org.crashub</groupId>
<artifactId>crash.connectors.ssh</artifactId>
</dependency>
<dependency>
<groupId>org.crashub</groupId>
<artifactId>crash.connectors.telnet</artifactId>
<exclusions>
<exclusion>
<groupId>javax.servlet</groupId>
<artifactId>servlet-api</artifactId>
</exclusion>
<exclusion>
<groupId>log4j</groupId>
<artifactId>log4j</artifactId>
</exclusion>
<exclusion>
<groupId>commons-logging</groupId>
<artifactId>commons-logging</artifactId>
</exclusion>
</exclusions>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.crashub</groupId>
<artifactId>crash.embed.spring</artifactId>
</dependency>
<dependency>
<groupId>org.crashub</groupId>
<artifactId>crash.shell</artifactId>
<exclusions>
<exclusion>
<groupId>org.codehaus.groovy</groupId>
<artifactId>groovy-all</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.codehaus.groovy</groupId>
<artifactId>groovy</artifactId>
</dependency>
</dependencies>
</project>
package crash.commands.base;
import org.crsh.cli.Command;
import org.crsh.cli.Usage;
import org.crsh.command.BaseCommand;
import org.crsh.command.DescriptionFormat;
import org.crsh.command.InvocationContext;
import org.crsh.command.ShellCommand;
import org.crsh.shell.impl.command.CRaSH;
import org.crsh.text.Color;
import org.crsh.text.Decoration;
import org.crsh.text.Style;
import org.crsh.text.ui.LabelElement;
import org.crsh.text.ui.RowElement;
import org.crsh.text.ui.TableElement;
import java.io.IOException;
/** @author Julien Viet */
public class help extends BaseCommand {
@Usage("provides basic help")
@Command
public void main(InvocationContext<Object> context) throws IOException {
//
TableElement table = new TableElement().rightCellPadding(1);
table.add(
new RowElement().
add(new LabelElement("NAME").style(Style.style(Decoration.bold))).
add(new LabelElement("DESCRIPTION")));
//
CRaSH crash = (CRaSH)context.getSession().get("crash");
Iterable<String> names = crash.getCommandNames();
for (String name : names) {
try {
ShellCommand cmd = crash.getCommand(name);
if (cmd != null) {
String desc = cmd.describe(name, DescriptionFormat.DESCRIBE);
if (desc == null) {
desc = "";
}
table.add(
new RowElement().
add(new LabelElement(name).style(Style.style(Color.red))).
add(new LabelElement(desc)));
}
} catch (Exception ignore) {
//
}
}
//
context.provide(new LabelElement("Try one of these commands with the -h or --help switch:\n"));
context.provide(table);
}
}
package crash.commands.base;
import org.crsh.cli.Argument;
import org.crsh.cli.Command;
import org.crsh.cli.Option;
import org.crsh.cli.Usage;
import org.crsh.command.BaseCommand;
import org.crsh.command.InvocationContext;
import org.crsh.command.PipeCommand;
import org.crsh.command.ScriptException;
import javax.management.JMException;
import javax.management.MBeanAttributeInfo;
import javax.management.MBeanInfo;
import javax.management.MBeanServer;
import javax.management.ObjectInstance;
import javax.management.ObjectName;
import java.io.IOException;
import java.lang.management.ManagementFactory;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/** @author Julien Viet */
@Usage("Java Management Extensions")
public class jmx extends BaseCommand {
@Usage("find mbeans")
@Command
public void find(
InvocationContext<ObjectName> context,
@Usage("The object name pattern")
@Option(names = {"p", "pattern"})
String pattern) throws Exception {
//
ObjectName patternName = pattern != null ? ObjectName.getInstance(pattern) : null;
MBeanServer server = ManagementFactory.getPlatformMBeanServer();
Set<ObjectInstance> instances = server.queryMBeans(patternName, null);
for (ObjectInstance instance : instances) {
context.provide(instance.getObjectName());
}
/*
if (context.piped) {
} else {
UIBuilder ui = new UIBuilder()
ui.table(columns: [1,3]) {
row(bold: true, fg: black, bg: white) {
label("CLASS NAME"); label("OBJECT NAME")
}
instances.each { instance ->
row() {
label(foreground: red, instance.getClassName()); label(instance.objectName)
}
}
}
out << ui;
}
*/
}
@Command
@Usage("return the attributes info of an MBean")
public void attributes(InvocationContext<Map> context, @Argument ObjectName name) throws IOException {
MBeanServer server = ManagementFactory.getPlatformMBeanServer();
try {
MBeanInfo info = server.getMBeanInfo(name);
for (MBeanAttributeInfo attributeInfo : info.getAttributes()) {
HashMap<String, Object> tuple = new HashMap<String, Object>();
tuple.put("name", attributeInfo.getName());
tuple.put("type", attributeInfo.getType());
tuple.put("description", attributeInfo.getDescription());
context.provide(tuple);
}
}
catch (JMException e) {
throw new ScriptException("Could not access MBean meta data", e);
}
}
@Usage("get attributes of an MBean")
@Command
public PipeCommand<ObjectName, Map> get(@Argument final List<String> attributes) {
// Determine common attributes from all names
if (attributes == null || attributes.isEmpty()) {
throw new ScriptException("Must provide JMX attributes");
}
//
return new PipeCommand<ObjectName, Map>() {
/** . */
private MBeanServer server;
@Override
public void open() throws ScriptException {
server = ManagementFactory.getPlatformMBeanServer();
}
@Override
public void provide(ObjectName name) throws IOException {
try {
HashMap<String, Object> tuple = new HashMap<String, Object>();
for (String attribute : attributes) {
String prop = name.getKeyProperty(attribute);
if (prop != null) {
tuple.put(attribute, prop);
}
else {
tuple.put(attribute, server.getAttribute(name, attribute));
}
}
context.provide(tuple);
}
catch (JMException ignore) {
//
}
}
};
}
}
welcome = { ->
def hostName;
try {
hostName = java.net.InetAddress.getLocalHost().getHostName();
} catch (java.net.UnknownHostException ignore) {
hostName = "localhost";
}
def version = crash.context.attributes.get("spring.boot.version")
return """\
. ____ _ __ _ _
/\\\\ / ___'_ __ _ _(_)_ __ __ _ \\ \\ \\ \\
( ( )\\___ | '_ | '_| | '_ \\/ _` | \\ \\ \\ \\
\\\\/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |_\\__, | / / / /
=========|_|==============|___/=/_/_/_/
:: Spring Boot :: (v$version) on $hostName
""";
}
prompt = { ->
return "> ";
}
package commands
import org.crsh.text.ui.UIBuilder
import org.springframework.boot.actuate.endpoint.MetricsEndpoint
class metrics {
@Usage("Display metrics provided by Spring Boot")
@Command
public void main(InvocationContext context) {
context.takeAlternateBuffer();
try {
while (!Thread.interrupted()) {
out.cls()
out.show(new UIBuilder().table(columns:[1]) {
header {
table(columns:[1], separator: dashed) {
header(bold: true, fg: black, bg: white) { label("metrics"); }
}
}
row {
table(columns:[1, 1]) {
header(bold: true, fg: black, bg: white) {
label("NAME")
label("VALUE")
}
context.attributes['spring.beanfactory'].getBeansOfType(MetricsEndpoint.class).each { name, metrics ->
metrics.invoke().each { k, v ->
row {
label(k)
label(v)
}
}
}
}
}
}
);
out.flush();
Thread.sleep(1000);
}
}
finally {
context.releaseAlternateBuffer();
}
}
}
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment