From 6b599b8483e2fad1b679d937bf8de6d4eb269462 Mon Sep 17 00:00:00 2001 From: Christian Dupuis Date: Mon, 4 Nov 2013 17:16:17 +0100 Subject: [PATCH] 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. --- spring-boot-actuator/pom.xml | 5 + .../autoconfigure/CrshAutoConfiguration.java | 462 ++++++++++++++++++ .../actuate/properties/CrshProperties.java | 355 ++++++++++++++ .../main/resources/META-INF/spring.factories | 1 + .../CrshAutoConfigurationTests.java | 333 +++++++++++++ .../properties/CrshPropertiesTests.java | 282 +++++++++++ spring-boot-dependencies/pom.xml | 26 + .../spring-boot-sample-actuator/pom.xml | 4 + .../src/main/resources/application.properties | 10 +- spring-boot-starters/pom.xml | 1 + .../spring-boot-starter-parent/pom.xml | 5 + .../spring-boot-starter-shell-crsh/pom.xml | 67 +++ .../main/resources/commands/crash/help.groovy | 58 +++ .../main/resources/commands/crash/jmx.groovy | 123 +++++ .../resources/commands/crash/login.groovy | 22 + .../resources/commands/crash/metrics.groovy | 49 ++ 16 files changed, 1802 insertions(+), 1 deletion(-) create mode 100644 spring-boot-actuator/src/main/java/org/springframework/boot/actuate/autoconfigure/CrshAutoConfiguration.java create mode 100644 spring-boot-actuator/src/main/java/org/springframework/boot/actuate/properties/CrshProperties.java create mode 100644 spring-boot-actuator/src/test/java/org/springframework/boot/actuate/autoconfigure/CrshAutoConfigurationTests.java create mode 100644 spring-boot-actuator/src/test/java/org/springframework/boot/actuate/properties/CrshPropertiesTests.java create mode 100644 spring-boot-starters/spring-boot-starter-shell-crsh/pom.xml create mode 100644 spring-boot-starters/spring-boot-starter-shell-crsh/src/main/resources/commands/crash/help.groovy create mode 100644 spring-boot-starters/spring-boot-starter-shell-crsh/src/main/resources/commands/crash/jmx.groovy create mode 100644 spring-boot-starters/spring-boot-starter-shell-crsh/src/main/resources/commands/crash/login.groovy create mode 100644 spring-boot-starters/spring-boot-starter-shell-crsh/src/main/resources/commands/crash/metrics.groovy diff --git a/spring-boot-actuator/pom.xml b/spring-boot-actuator/pom.xml index 0838b2efdf9..0bc11243e5f 100644 --- a/spring-boot-actuator/pom.xml +++ b/spring-boot-actuator/pom.xml @@ -72,6 +72,11 @@ tomcat-embed-core true + + org.crashub + crash.embed.spring + true + org.springframework diff --git a/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/autoconfigure/CrshAutoConfiguration.java b/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/autoconfigure/CrshAutoConfiguration.java new file mode 100644 index 00000000000..61c24ce0d37 --- /dev/null +++ b/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/autoconfigure/CrshAutoConfiguration.java @@ -0,0 +1,462 @@ +/* + * 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.autoconfigure; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URISyntaxException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.Set; + +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; + +import org.crsh.auth.AuthenticationPlugin; +import org.crsh.plugin.CRaSHPlugin; +import org.crsh.plugin.PluginContext; +import org.crsh.plugin.PluginDiscovery; +import org.crsh.plugin.PluginLifeCycle; +import org.crsh.plugin.PropertyDescriptor; +import org.crsh.plugin.ServiceLoaderDiscovery; +import org.crsh.vfs.FS; +import org.crsh.vfs.spi.AbstractFSDriver; +import org.springframework.beans.factory.ListableBeanFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.actuate.properties.CrshProperties; +import org.springframework.boot.actuate.properties.CrshProperties.AuthenticationProperties; +import org.springframework.boot.actuate.properties.CrshProperties.JaasAuthenticationProperties; +import org.springframework.boot.actuate.properties.CrshProperties.KeyAuthenticationProperties; +import org.springframework.boot.actuate.properties.CrshProperties.SimpleAuthenticationProperties; +import org.springframework.boot.actuate.properties.CrshProperties.SpringAuthenticationProperties; +import org.springframework.boot.autoconfigure.AutoConfigureAfter; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.SpringVersion; +import org.springframework.core.io.Resource; +import org.springframework.core.io.support.ResourcePatternResolver; +import org.springframework.security.access.AccessDecisionManager; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.access.SecurityConfig; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; +import org.springframework.util.StringUtils; + +/** + * {@link EnableAutoConfiguration Auto-configuration} for embedding an extensible shell into a Spring + * Boot enabled application. By default a SSH daemon is started on port 2000 with a default username + * user and password (default password is logged during application startup). + * + *

+ * This configuration will auto detect the existence of a Spring Security {@link AuthenticationManager} + * and will delegate authentication requests for shell access to this detected instance. + * + *

+ * To add customizations to the shell simply define beans of type {@link CRaSHPlugin} in the + * application context. Those beans will get auto detected during startup and registered with the + * underlying shell infrastructure. + * + *

+ * Additional shell commands can be implemented using the guide and documentation at + * crashub.org. By default Boot will search for commands using + * the following classpath scanning pattern classpath*:/commands/**. To add different + * locations or override the default use shell.command_path_patterns in your application + * configuration. + * + * @author Christian Dupuis + */ +@Configuration +@ConditionalOnClass({ PluginLifeCycle.class }) +@EnableConfigurationProperties({ CrshProperties.class }) +@AutoConfigureAfter(SecurityAutoConfiguration.class) +public class CrshAutoConfiguration { + + @Autowired + private CrshProperties properties; + + + @Bean + @ConditionalOnExpression("#{environment['shell.auth'] == 'jaas'}") + @ConditionalOnMissingBean({ AuthenticationProperties.class }) + public AuthenticationProperties jaasAuthenticationProperties() { + return new JaasAuthenticationProperties(); + } + + @Bean + @ConditionalOnExpression("#{environment['shell.auth'] == 'key'}") + @ConditionalOnMissingBean({ AuthenticationProperties.class }) + public AuthenticationProperties keyAuthenticationProperties() { + return new KeyAuthenticationProperties(); + } + + @Bean + @ConditionalOnExpression("#{environment['shell.auth'] == 'simple'}") + @ConditionalOnMissingBean({ AuthenticationProperties.class }) + public AuthenticationProperties simpleAuthenticationProperties() { + return new SimpleAuthenticationProperties(); + } + + @Bean + @ConditionalOnExpression("#{environment['shell.auth'] == 'spring'}") + @ConditionalOnMissingBean({ AuthenticationProperties.class }) + public AuthenticationProperties SpringAuthenticationProperties() { + return new SpringAuthenticationProperties(); + } + + @Bean + @ConditionalOnBean({ AuthenticationManager.class }) + public CRaSHPlugin shellAuthenticationManager() { + return new AuthenticationManagerAdapter(); + } + + @Bean + @ConditionalOnMissingBean({ PluginLifeCycle.class }) + public PluginLifeCycle shellBootstrap() { + CrshBootstrap bs = new CrshBootstrap(); + bs.setConfig(properties.mergeProperties(new Properties())); + return bs; + } + + + public static class CrshBootstrap extends PluginLifeCycle { + + @Autowired + private ListableBeanFactory beanFactory; + + @Autowired + private CrshProperties properties; + + @Autowired + private ResourcePatternResolver resourceLoader; + + + @PreDestroy + public void destroy() { + stop(); + } + + @PostConstruct + public void init() throws Exception { + FS commandFileSystem = createFileSystem(properties.getCommandPathPatterns()); + FS confFileSystem = createFileSystem(properties.getConfigPathPatterns()); + + PluginDiscovery discovery = new BeanFactoryFilteringPluginDiscovery(resourceLoader.getClassLoader(), + beanFactory, properties.getDisabledPlugins()); + + PluginContext context = new PluginContext(discovery, createPluginContextAttributes(), + commandFileSystem, confFileSystem, resourceLoader.getClassLoader()); + + context.refresh(); + start(context); + } + + + protected FS createFileSystem(String[] pathPatterns) throws IOException, URISyntaxException { + Assert.notNull(pathPatterns); + FS cmdFS = new FS(); + for (String pathPattern : pathPatterns) { + cmdFS.mount(new SimpleFileSystemDriver(new DirectoryHandle(pathPattern, resourceLoader))); + } + return cmdFS; + } + + protected Map createPluginContextAttributes() { + Map attributes = new HashMap(); + String bootVersion = CrshAutoConfiguration.class.getPackage().getImplementationVersion(); + if (bootVersion != null) { + attributes.put("spring.boot.version", bootVersion); + } + attributes.put("spring.version", SpringVersion.getVersion()); + if (beanFactory != null) { + attributes.put("spring.beanfactory", beanFactory); + } + return attributes; + } + + } + + + @SuppressWarnings("rawtypes") + private static class AuthenticationManagerAdapter extends CRaSHPlugin implements + AuthenticationPlugin { + + private static final PropertyDescriptor ROLES = PropertyDescriptor.create( + "auth.spring.roles", "ADMIN", "Comma separated list of roles required to access the shell"); + + + @Autowired(required=false) + private AccessDecisionManager accessDecisionManager; + + @Autowired + private AuthenticationManager authenticationManager; + + private String[] roles = new String[] { "ROLE_ADMIN" }; + + + @Override + public boolean authenticate(String username, String password) throws Exception { + // Authenticate first to make credentials are valid + Authentication token = new UsernamePasswordAuthenticationToken(username, password); + try { + token = authenticationManager.authenticate(token); + } + catch (AuthenticationException ae) { + return false; + } + + // Test access rights if a Spring Security AccessDecisionManager is installed + if (accessDecisionManager != null && token.isAuthenticated() && roles != null) { + try { + accessDecisionManager.decide(token, this, SecurityConfig.createList(roles)); + } + catch (AccessDeniedException e) { + return false; + } + } + return token.isAuthenticated(); + } + + @Override + public Class getCredentialType() { + return String.class; + } + + @Override + public AuthenticationPlugin getImplementation() { + return this; + } + + @Override + public String getName() { + return "spring"; + } + + @Override + public void init() { + String rolesPropertyValue = getContext().getProperty(ROLES); + if (rolesPropertyValue != null) { + this.roles = StringUtils.commaDelimitedListToStringArray(rolesPropertyValue); + } + } + + + @Override + protected Iterable> createConfigurationCapabilities() { + return Arrays.>asList(ROLES); + } + + } + + + private static class BeanFactoryFilteringPluginDiscovery extends ServiceLoaderDiscovery { + + private ListableBeanFactory beanFactory; + + private String[] disabledPlugins; + + + public BeanFactoryFilteringPluginDiscovery(ClassLoader classLoader, ListableBeanFactory beanFactory, + String[] disabledPlugins) + throws NullPointerException { + super(classLoader); + this.beanFactory = beanFactory; + this.disabledPlugins = disabledPlugins; + } + + + @Override + @SuppressWarnings("rawtypes") + public Iterable> getPlugins() { + List> plugins = new ArrayList>(); + + for (CRaSHPlugin p : super.getPlugins()) { + if (!shouldFilter(p)) { + plugins.add(p); + } + } + + Collection springPlugins = beanFactory.getBeansOfType(CRaSHPlugin.class).values(); + for (CRaSHPlugin p : springPlugins) { + if (!shouldFilter(p)) { + plugins.add(p); + } + } + + return plugins; + } + + + @SuppressWarnings("rawtypes") + protected boolean shouldFilter(CRaSHPlugin plugin) { + Assert.notNull(plugin); + + Set classes = ClassUtils.getAllInterfacesAsSet(plugin); + classes.add(plugin.getClass()); + + for (Class clazz : classes) { + if (disabledPlugins != null && disabledPlugins.length > 0) { + for (String disabledPlugin : disabledPlugins) { + if (ClassUtils.getShortName(clazz).equalsIgnoreCase(disabledPlugin) + || ClassUtils.getQualifiedName(clazz).equalsIgnoreCase(disabledPlugin)) { + return true; + } + } + } + } + return false; + } + + } + + + private static class SimpleFileSystemDriver extends AbstractFSDriver { + + private ResourceHandle root; + + + public SimpleFileSystemDriver(ResourceHandle handle) { + this.root = handle; + } + + + @Override + public Iterable children(ResourceHandle handle) throws IOException { + if (handle instanceof DirectoryHandle) { + return ((DirectoryHandle) handle).members(); + } + return Collections.emptySet(); + } + + @Override + public long getLastModified(ResourceHandle handle) throws IOException { + if (handle instanceof FileHandle) { + return ((FileHandle) handle).getLastModified(); + } + return -1; + } + + @Override + public boolean isDir(ResourceHandle handle) throws IOException { + return handle instanceof DirectoryHandle; + } + + @Override + public String name(ResourceHandle handle) throws IOException { + return handle.getName(); + } + + @Override + public Iterator open(ResourceHandle handle) throws IOException { + if (handle instanceof FileHandle) { + return Collections.singletonList(((FileHandle) handle).openStream()).iterator(); + } + return Collections.emptyList().iterator(); + } + + @Override + public ResourceHandle root() throws IOException { + return root; + } + + } + + + private static class DirectoryHandle extends ResourceHandle { + + private ResourcePatternResolver resourceLoader; + + + public DirectoryHandle(String name, ResourcePatternResolver resourceLoader) { + super(name); + this.resourceLoader = resourceLoader; + } + + + public List members() throws IOException { + Resource[] resources = resourceLoader.getResources(getName()); + List files = new ArrayList(); + for (Resource resource : resources) { + if (!resource.getURL().getPath().endsWith("/")) { + files.add(new FileHandle(resource.getFilename(), resource)); + } + } + return files; + } + + } + + + private static class FileHandle extends ResourceHandle { + + private Resource resource; + + + public FileHandle(String name, Resource resource) { + super(name); + this.resource = resource; + } + + + public InputStream openStream() throws IOException { + return this.resource.getInputStream(); + } + + public long getLastModified() { + try { + return this.resource.lastModified(); + } + catch (IOException e) {} + return -1; + } + + } + + + private abstract static class ResourceHandle { + + private String name; + + + public ResourceHandle(String name) { + this.name = name; + } + + + public String getName() { + return name; + } + + } + +} diff --git a/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/properties/CrshProperties.java b/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/properties/CrshProperties.java new file mode 100644 index 00000000000..cc0c8f2e6ff --- /dev/null +++ b/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/properties/CrshProperties.java @@ -0,0 +1,355 @@ +/* + * 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 dp = new ArrayList(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(); + } + + } + +} diff --git a/spring-boot-actuator/src/main/resources/META-INF/spring.factories b/spring-boot-actuator/src/main/resources/META-INF/spring.factories index 55cabc4f408..89736607c7d 100644 --- a/spring-boot-actuator/src/main/resources/META-INF/spring.factories +++ b/spring-boot-actuator/src/main/resources/META-INF/spring.factories @@ -1,5 +1,6 @@ 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,\ diff --git a/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/autoconfigure/CrshAutoConfigurationTests.java b/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/autoconfigure/CrshAutoConfigurationTests.java new file mode 100644 index 00000000000..1843535e2f2 --- /dev/null +++ b/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/autoconfigure/CrshAutoConfigurationTests.java @@ -0,0 +1,333 @@ +/* + * 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.autoconfigure; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import org.crsh.auth.AuthenticationPlugin; +import org.crsh.auth.JaasAuthenticationPlugin; +import org.crsh.lang.groovy.GroovyREPL; +import org.crsh.plugin.PluginContext; +import org.crsh.plugin.PluginLifeCycle; +import org.crsh.plugin.ResourceKind; +import org.crsh.processor.term.ProcessorIOHandler; +import org.crsh.vfs.Resource; +import org.junit.After; +import org.junit.Test; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.mock.env.MockEnvironment; +import org.springframework.mock.web.MockServletContext; +import org.springframework.security.access.AccessDecisionManager; +import org.springframework.security.access.AccessDecisionVoter; +import org.springframework.security.access.vote.RoleVoter; +import org.springframework.security.access.vote.UnanimousBased; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.web.context.support.AnnotationConfigWebApplicationContext; + +/** + * Tests for {@link CrshAutoConfiguration}. + * + * @author Christian Dupuis + */ +@SuppressWarnings({ "rawtypes", "unchecked" }) +public class CrshAutoConfigurationTests { + + private AnnotationConfigWebApplicationContext context; + + @After + public void tearDown() { + if (this.context != null) { + this.context.close(); + this.context = null; + } + } + + @Test + public void testDisabledPlugins() throws Exception { + MockEnvironment env = new MockEnvironment(); + env.setProperty("shell.disabled_plugins", "GroovyREPL, termIOHandler, org.crsh.auth.AuthenticationPlugin"); + this.context = new AnnotationConfigWebApplicationContext(); + this.context.setEnvironment(env); + this.context.register(CrshAutoConfiguration.class); + this.context.refresh(); + + PluginLifeCycle lifeCycle = this.context.getBean(PluginLifeCycle.class); + assertNotNull(lifeCycle); + + assertNull(lifeCycle.getContext().getPlugin(GroovyREPL.class)); + assertNull(lifeCycle.getContext().getPlugin(ProcessorIOHandler.class)); + assertNull(lifeCycle.getContext().getPlugin(JaasAuthenticationPlugin.class)); + } + + @Test + public void testAttributes() throws Exception { + this.context = new AnnotationConfigWebApplicationContext(); + this.context.register(CrshAutoConfiguration.class); + this.context.refresh(); + + PluginLifeCycle lifeCycle = this.context.getBean(PluginLifeCycle.class); + + Map attributes = lifeCycle.getContext().getAttributes(); + assertTrue(attributes.containsKey("spring.version")); + assertTrue(attributes.containsKey("spring.beanfactory")); + assertEquals(this.context.getBeanFactory(), attributes.get("spring.beanfactory")); + } + + @Test + public void testSshConfiguration() { + MockEnvironment env = new MockEnvironment(); + env.setProperty("shell.ssh.enabled", "true"); + env.setProperty("shell.ssh.port", "3333"); + this.context = new AnnotationConfigWebApplicationContext(); + this.context.setEnvironment(env); + this.context.register(CrshAutoConfiguration.class); + this.context.refresh(); + + PluginLifeCycle lifeCycle = this.context.getBean(PluginLifeCycle.class); + + assertEquals(lifeCycle.getConfig().getProperty("crash.ssh.port"), "3333"); + } + + @Test + public void testSshConfigurationWithKeyPath() { + MockEnvironment env = new MockEnvironment(); + env.setProperty("shell.ssh.enabled", "true"); + env.setProperty("shell.ssh.key_path", "~/.ssh/id.pem"); + this.context = new AnnotationConfigWebApplicationContext(); + this.context.setEnvironment(env); + this.context.register(CrshAutoConfiguration.class); + this.context.refresh(); + + PluginLifeCycle lifeCycle = this.context.getBean(PluginLifeCycle.class); + + assertEquals(lifeCycle.getConfig().getProperty("crash.ssh.keypath"), "~/.ssh/id.pem"); + } + + @Test + public void testCommandResolution() { + this.context = new AnnotationConfigWebApplicationContext(); + this.context.register(CrshAutoConfiguration.class); + this.context.refresh(); + + PluginLifeCycle lifeCycle = this.context.getBean(PluginLifeCycle.class); + + int count = 0; + Iterator resources = lifeCycle.getContext().loadResources("login", ResourceKind.LIFECYCLE).iterator(); + while (resources.hasNext()) { + count++; + resources.next(); + } + assertEquals(1, count); + + count = 0; + resources = lifeCycle.getContext().loadResources("help.java", ResourceKind.COMMAND).iterator(); + while (resources.hasNext()) { + count++; + resources.next(); + } + assertEquals(1, count); + } + + @Test + public void testAuthenticationProvidersAreInstalled() { + this.context = new AnnotationConfigWebApplicationContext(); + this.context.setServletContext(new MockServletContext()); + this.context.register(SecurityConfiguration.class); + this.context.register(CrshAutoConfiguration.class); + this.context.refresh(); + + PluginLifeCycle lifeCycle = this.context.getBean(PluginLifeCycle.class); + PluginContext pluginContext = lifeCycle.getContext(); + + int count = 0; + Iterator plugins = pluginContext.getPlugins(AuthenticationPlugin.class).iterator(); + while (plugins.hasNext()) { + count++; + plugins.next(); + } + assertEquals(3, count); + } + + @Test + public void testJaasAuthenticationProvider() { + MockEnvironment env = new MockEnvironment(); + env.setProperty("shell.auth", "jaas"); + env.setProperty("shell.auth.jaas.domain", "my-test-domain"); + this.context = new AnnotationConfigWebApplicationContext(); + this.context.setEnvironment(env); + this.context.setServletContext(new MockServletContext()); + this.context.register(SecurityConfiguration.class); + this.context.register(CrshAutoConfiguration.class); + this.context.refresh(); + + PluginLifeCycle lifeCycle = this.context.getBean(PluginLifeCycle.class); + assertEquals(lifeCycle.getConfig().get("crash.auth"), "jaas"); + assertEquals(lifeCycle.getConfig().get("crash.auth.jaas.domain"), "my-test-domain"); + } + + @Test + public void testKeyAuthenticationProvider() { + MockEnvironment env = new MockEnvironment(); + env.setProperty("shell.auth", "key"); + env.setProperty("shell.auth.key.path", "~/test.pem"); + this.context = new AnnotationConfigWebApplicationContext(); + this.context.setEnvironment(env); + this.context.setServletContext(new MockServletContext()); + this.context.register(SecurityConfiguration.class); + this.context.register(CrshAutoConfiguration.class); + this.context.refresh(); + + PluginLifeCycle lifeCycle = this.context.getBean(PluginLifeCycle.class); + assertEquals(lifeCycle.getConfig().get("crash.auth"), "key"); + assertEquals(lifeCycle.getConfig().get("crash.auth.key.path"), "~/test.pem"); + } + + @Test + public void testSimpleAuthenticationProvider() { + MockEnvironment env = new MockEnvironment(); + env.setProperty("shell.auth", "simple"); + env.setProperty("shell.auth.simple.username", "user"); + env.setProperty("shell.auth.simple.password", "password"); + this.context = new AnnotationConfigWebApplicationContext(); + this.context.setEnvironment(env); + this.context.setServletContext(new MockServletContext()); + this.context.register(SecurityConfiguration.class); + this.context.register(CrshAutoConfiguration.class); + this.context.refresh(); + + PluginLifeCycle lifeCycle = this.context.getBean(PluginLifeCycle.class); + assertEquals(lifeCycle.getConfig().get("crash.auth"), "simple"); + + AuthenticationPlugin authenticationPlugin = null; + String authentication = lifeCycle.getConfig().getProperty("crash.auth"); + assertNotNull(authentication); + for (AuthenticationPlugin plugin : lifeCycle.getContext().getPlugins(AuthenticationPlugin.class)) { + if (authentication.equals(plugin.getName())) { + authenticationPlugin = plugin; + break; + } + } + assertNotNull(authenticationPlugin); + try { + assertTrue(authenticationPlugin.authenticate("user", "password")); + } + catch (Exception e) { + fail(); + } + + try { + assertFalse(authenticationPlugin.authenticate(UUID.randomUUID().toString(), + "password")); + } + catch (Exception e) { + fail(); + } + } + + @Test + public void testSpringAuthenticationProvider() { + MockEnvironment env = new MockEnvironment(); + env.setProperty("shell.auth", "spring"); + this.context = new AnnotationConfigWebApplicationContext(); + this.context.setEnvironment(env); + this.context.setServletContext(new MockServletContext()); + this.context.register(SecurityConfiguration.class); + this.context.register(CrshAutoConfiguration.class); + this.context.refresh(); + + PluginLifeCycle lifeCycle = this.context.getBean(PluginLifeCycle.class); + + AuthenticationPlugin authenticationPlugin = null; + String authentication = lifeCycle.getConfig().getProperty("crash.auth"); + assertNotNull(authentication); + for (AuthenticationPlugin plugin : lifeCycle.getContext().getPlugins(AuthenticationPlugin.class)) { + if (authentication.equals(plugin.getName())) { + authenticationPlugin = plugin; + break; + } + } + assertNotNull(authenticationPlugin); + try { + assertTrue(authenticationPlugin.authenticate(SecurityConfiguration.USERNAME, + SecurityConfiguration.PASSWORD)); + } + catch (Exception e) { + fail(); + } + + try { + assertFalse(authenticationPlugin.authenticate(UUID.randomUUID().toString(), + SecurityConfiguration.PASSWORD)); + } + catch (Exception e) { + fail(); + } + } + + @Configuration + public static class SecurityConfiguration { + + public static final String USERNAME = UUID.randomUUID().toString(); + + public static final String PASSWORD = UUID.randomUUID().toString(); + + @Bean + public AuthenticationManager authenticationManager() { + return new AuthenticationManager() { + + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + if (authentication.getName().equals(USERNAME) && authentication.getCredentials().equals(PASSWORD)) { + authentication = new UsernamePasswordAuthenticationToken(authentication.getPrincipal(), + authentication.getCredentials(), Collections.singleton(new SimpleGrantedAuthority("ROLE_ADMIN"))); + } + else { + throw new BadCredentialsException("Invalid username and password"); + } + return authentication; + } + }; + } + + @Bean + public AccessDecisionManager accessDecisionManager() { + List voters = new ArrayList(); + voters.add(new RoleVoter()); + AccessDecisionManager result = new UnanimousBased(voters); + return result; + } + } + +} diff --git a/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/properties/CrshPropertiesTests.java b/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/properties/CrshPropertiesTests.java new file mode 100644 index 00000000000..d00cabcf2f1 --- /dev/null +++ b/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/properties/CrshPropertiesTests.java @@ -0,0 +1,282 @@ +/* + * 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 static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Properties; + +import org.junit.Assert; +import org.junit.Test; +import org.springframework.beans.MutablePropertyValues; +import org.springframework.boot.actuate.properties.CrshProperties.JaasAuthenticationProperties; +import org.springframework.boot.actuate.properties.CrshProperties.KeyAuthenticationProperties; +import org.springframework.boot.actuate.properties.CrshProperties.SimpleAuthenticationProperties; +import org.springframework.boot.actuate.properties.CrshProperties.SpringAuthenticationProperties; +import org.springframework.boot.bind.RelaxedDataBinder; +import org.springframework.core.convert.support.DefaultConversionService; + +/** + * Tests for {@link CrshProperties}. + * + * @author Christian Dupuis + */ +public class CrshPropertiesTests { + + @Test + public void testBindingAuth() { + CrshProperties props = new CrshProperties(); + RelaxedDataBinder binder = new RelaxedDataBinder(props, "shell"); + binder.bind(new MutablePropertyValues(Collections.singletonMap("shell.auth", "spring"))); + assertFalse(binder.getBindingResult().hasErrors()); + assertEquals("spring", props.getAuth()); + } + + @Test + public void testBindingAuthIfEmpty() { + CrshProperties props = new CrshProperties(); + RelaxedDataBinder binder = new RelaxedDataBinder(props, "shell"); + binder.bind(new MutablePropertyValues(Collections.singletonMap("shell.auth", ""))); + assertTrue(binder.getBindingResult().hasErrors()); + assertEquals("simple", props.getAuth()); + } + + @Test + public void testBindingCommandRefreshInterval() { + CrshProperties props = new CrshProperties(); + RelaxedDataBinder binder = new RelaxedDataBinder(props, "shell"); + binder.setConversionService(new DefaultConversionService()); + binder.bind(new MutablePropertyValues(Collections.singletonMap("shell.command_refresh_interval", "1"))); + assertFalse(binder.getBindingResult().hasErrors()); + assertEquals(1, props.getCommandRefreshInterval()); + } + + @Test + public void testBindingCommandPathPatterns() { + CrshProperties props = new CrshProperties(); + RelaxedDataBinder binder = new RelaxedDataBinder(props, "shell"); + binder.setConversionService(new DefaultConversionService()); + binder.bind(new MutablePropertyValues(Collections.singletonMap("shell.command_path_patterns", + "pattern1, pattern2"))); + assertFalse(binder.getBindingResult().hasErrors()); + assertEquals(2, props.getCommandPathPatterns().length); + Assert.assertArrayEquals(new String[] { "pattern1", "pattern2" }, props.getCommandPathPatterns()); + } + + @Test + public void testBindingConfigPathPatterns() { + CrshProperties props = new CrshProperties(); + RelaxedDataBinder binder = new RelaxedDataBinder(props, "shell"); + binder.setConversionService(new DefaultConversionService()); + binder.bind(new MutablePropertyValues(Collections.singletonMap("shell.config_path_patterns", + "pattern1, pattern2"))); + assertFalse(binder.getBindingResult().hasErrors()); + assertEquals(2, props.getConfigPathPatterns().length, 2); + Assert.assertArrayEquals(new String[] { "pattern1", "pattern2" }, props.getConfigPathPatterns()); + } + + @Test + public void testBindingDisabledPlugins() { + CrshProperties props = new CrshProperties(); + RelaxedDataBinder binder = new RelaxedDataBinder(props, "shell"); + binder.setConversionService(new DefaultConversionService()); + binder.bind(new MutablePropertyValues(Collections.singletonMap("shell.disabled_plugins", + "pattern1, pattern2"))); + assertFalse(binder.getBindingResult().hasErrors()); + assertEquals(2, props.getDisabledPlugins().length, 2); + assertArrayEquals(new String[] { "pattern1", "pattern2" }, props.getDisabledPlugins()); + } + + @Test + public void testBindingSsh() { + CrshProperties props = new CrshProperties(); + RelaxedDataBinder binder = new RelaxedDataBinder(props, "shell"); + binder.setConversionService(new DefaultConversionService()); + Map map = new HashMap(); + map.put("shell.ssh.enabled", "true"); + map.put("shell.ssh.port", "2222"); + map.put("shell.ssh.key_path", "~/.ssh/test.pem"); + binder.bind(new MutablePropertyValues(map)); + assertFalse(binder.getBindingResult().hasErrors()); + + Properties p = new Properties(); + p = props.mergeProperties(p); + + assertEquals("2222", p.get(CrshProperties.CRASH_SSH_PORT)); + assertEquals("~/.ssh/test.pem", p.get(CrshProperties.CRASH_SSH_KEYPATH)); + } + + @Test + public void testBindingSshIgnored() { + CrshProperties props = new CrshProperties(); + RelaxedDataBinder binder = new RelaxedDataBinder(props, "shell"); + binder.setConversionService(new DefaultConversionService()); + Map map = new HashMap(); + map.put("shell.ssh.enabled", "false"); + map.put("shell.ssh.port", "2222"); + map.put("shell.ssh.key_path", "~/.ssh/test.pem"); + binder.bind(new MutablePropertyValues(map)); + assertFalse(binder.getBindingResult().hasErrors()); + + Properties p = new Properties(); + p = props.mergeProperties(p); + + assertNull(p.get(CrshProperties.CRASH_SSH_PORT)); + assertNull(p.get(CrshProperties.CRASH_SSH_KEYPATH)); + } + + @Test + public void testBindingTelnet() { + CrshProperties props = new CrshProperties(); + RelaxedDataBinder binder = new RelaxedDataBinder(props, "shell"); + binder.setConversionService(new DefaultConversionService()); + Map map = new HashMap(); + map.put("shell.telnet.enabled", "true"); + map.put("shell.telnet.port", "2222"); + binder.bind(new MutablePropertyValues(map)); + assertFalse(binder.getBindingResult().hasErrors()); + + Properties p = new Properties(); + p = props.mergeProperties(p); + + assertEquals("2222", p.get(CrshProperties.CRASH_TELNET_PORT)); + } + + @Test + public void testBindingTelnetIgnored() { + CrshProperties props = new CrshProperties(); + RelaxedDataBinder binder = new RelaxedDataBinder(props, "shell"); + binder.setConversionService(new DefaultConversionService()); + Map map = new HashMap(); + map.put("shell.telnet.enabled", "false"); + map.put("shell.telnet.port", "2222"); + binder.bind(new MutablePropertyValues(map)); + assertFalse(binder.getBindingResult().hasErrors()); + + Properties p = new Properties(); + p = props.mergeProperties(p); + + assertNull(p.get(CrshProperties.CRASH_TELNET_PORT)); + } + + @Test + public void testBindingJaas() { + JaasAuthenticationProperties props = new JaasAuthenticationProperties(); + RelaxedDataBinder binder = new RelaxedDataBinder(props, "shell.auth.jaas"); + binder.setConversionService(new DefaultConversionService()); + Map map = new HashMap(); + map.put("shell.auth.jaas.domain", "my-test-domain"); + binder.bind(new MutablePropertyValues(map)); + assertFalse(binder.getBindingResult().hasErrors()); + + Properties p = new Properties(); + p = props.mergeProperties(p); + + assertEquals("my-test-domain", p.get(CrshProperties.CRASH_AUTH_JAAS_DOMAIN)); + } + + @Test + public void testBindingKey() { + KeyAuthenticationProperties props = new KeyAuthenticationProperties(); + RelaxedDataBinder binder = new RelaxedDataBinder(props, "shell.auth.key"); + binder.setConversionService(new DefaultConversionService()); + Map map = new HashMap(); + map.put("shell.auth.key.path", "~/.ssh/test.pem"); + binder.bind(new MutablePropertyValues(map)); + assertFalse(binder.getBindingResult().hasErrors()); + + Properties p = new Properties(); + p = props.mergeProperties(p); + + assertEquals("~/.ssh/test.pem", p.get(CrshProperties.CRASH_AUTH_KEY_PATH)); + } + + @Test + public void testBindingKeyIgnored() { + KeyAuthenticationProperties props = new KeyAuthenticationProperties(); + RelaxedDataBinder binder = new RelaxedDataBinder(props, "shell.auth.key"); + binder.setConversionService(new DefaultConversionService()); + Map map = new HashMap(); + binder.bind(new MutablePropertyValues(map)); + assertFalse(binder.getBindingResult().hasErrors()); + + Properties p = new Properties(); + p = props.mergeProperties(p); + + assertNull(p.get(CrshProperties.CRASH_AUTH_KEY_PATH)); + } + + @Test + public void testBindingSimple() { + SimpleAuthenticationProperties props = new SimpleAuthenticationProperties(); + RelaxedDataBinder binder = new RelaxedDataBinder(props, "shell.auth.simple"); + binder.setConversionService(new DefaultConversionService()); + Map map = new HashMap(); + map.put("shell.auth.simple.username", "username123"); + map.put("shell.auth.simple.password", "password123"); + binder.bind(new MutablePropertyValues(map)); + assertFalse(binder.getBindingResult().hasErrors()); + + Properties p = new Properties(); + p = props.mergeProperties(p); + + assertEquals("username123", p.get(CrshProperties.CRASH_AUTH_SIMPLE_USERNAME)); + assertEquals("password123", p.get(CrshProperties.CRASH_AUTH_SIMPLE_PASSWORD)); + } + + @Test + public void testDefaultPasswordAutogeneratedIfUnresolovedPlaceholder() { + SimpleAuthenticationProperties security = new SimpleAuthenticationProperties(); + RelaxedDataBinder binder = new RelaxedDataBinder(security, "security"); + binder.bind(new MutablePropertyValues(Collections.singletonMap( + "shell.auth.simple.password", "${ADMIN_PASSWORD}"))); + assertFalse(binder.getBindingResult().hasErrors()); + assertTrue(security.isDefaultPassword()); + } + + @Test + public void testDefaultPasswordAutogeneratedIfEmpty() { + SimpleAuthenticationProperties security = new SimpleAuthenticationProperties(); + RelaxedDataBinder binder = new RelaxedDataBinder(security, "security"); + binder.bind(new MutablePropertyValues(Collections.singletonMap( + "shell.auth.simple.password", ""))); + assertFalse(binder.getBindingResult().hasErrors()); + assertTrue(security.isDefaultPassword()); + } + + @Test + public void testBindingSpring() { + SpringAuthenticationProperties props = new SpringAuthenticationProperties(); + RelaxedDataBinder binder = new RelaxedDataBinder(props, "shell.auth.spring"); + binder.bind(new MutablePropertyValues(Collections.singletonMap("shell.auth.spring.roles", "role1, role2"))); + assertFalse(binder.getBindingResult().hasErrors()); + + Properties p = new Properties(); + p = props.mergeProperties(p); + + assertEquals("role1, role2", p.get(CrshProperties.CRASH_AUTH_SPRING_ROLES)); + } + + +} diff --git a/spring-boot-dependencies/pom.xml b/spring-boot-dependencies/pom.xml index e14a5f02db2..14b3f94395a 100644 --- a/spring-boot-dependencies/pom.xml +++ b/spring-boot-dependencies/pom.xml @@ -47,6 +47,7 @@ 2.0.1 1.1.3 7.0.42 + 1.3.0-beta8 @@ -481,6 +482,31 @@ geronimo-jms_1.1_spec 1.1 + + org.crashub + crash.cli + ${crashub.version} + + + org.crashub + crash.connectors.ssh + ${crashub.version} + + + org.crashub + crash.connectors.telnet + ${crashub.version} + + + org.crashub + crash.embed.spring + ${crashub.version} + + + org.crashub + crash.shell + ${crashub.version} + diff --git a/spring-boot-samples/spring-boot-sample-actuator/pom.xml b/spring-boot-samples/spring-boot-sample-actuator/pom.xml index dc284647db7..a52e16a8a26 100644 --- a/spring-boot-samples/spring-boot-sample-actuator/pom.xml +++ b/spring-boot-samples/spring-boot-sample-actuator/pom.xml @@ -32,6 +32,10 @@ ${project.groupId} spring-boot-starter-security + + ${project.groupId} + spring-boot-starter-shell-crsh + diff --git a/spring-boot-samples/spring-boot-sample-actuator/src/main/resources/application.properties b/spring-boot-samples/spring-boot-sample-actuator/src/main/resources/application.properties index 8691d54080d..a21f25c56b8 100644 --- a/spring-boot-samples/spring-boot-sample-actuator/src/main/resources/application.properties +++ b/spring-boot-samples/spring-boot-sample-actuator/src/main/resources/application.properties @@ -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 diff --git a/spring-boot-starters/pom.xml b/spring-boot-starters/pom.xml index a72623787b1..23d801f9d72 100644 --- a/spring-boot-starters/pom.xml +++ b/spring-boot-starters/pom.xml @@ -26,6 +26,7 @@ spring-boot-starter-actuator spring-boot-starter-parent spring-boot-starter-security + spring-boot-starter-shell-crsh spring-boot-starter-test spring-boot-starter-tomcat spring-boot-starter-web diff --git a/spring-boot-starters/spring-boot-starter-parent/pom.xml b/spring-boot-starters/spring-boot-starter-parent/pom.xml index e579f380e99..e4b52badc9d 100644 --- a/spring-boot-starters/spring-boot-starter-parent/pom.xml +++ b/spring-boot-starters/spring-boot-starter-parent/pom.xml @@ -98,6 +98,11 @@ spring-boot-starter-security 0.5.0.BUILD-SNAPSHOT + + org.springframework.boot + spring-boot-starter-shell-crsh + 0.5.0.BUILD-SNAPSHOT + org.springframework.boot spring-boot-starter-websocket diff --git a/spring-boot-starters/spring-boot-starter-shell-crsh/pom.xml b/spring-boot-starters/spring-boot-starter-shell-crsh/pom.xml new file mode 100644 index 00000000000..2d159c569b2 --- /dev/null +++ b/spring-boot-starters/spring-boot-starter-shell-crsh/pom.xml @@ -0,0 +1,67 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starters + 0.5.0.BUILD-SNAPSHOT + + spring-boot-starter-shell-crsh + jar + + ${basedir}/../.. + + + + ${project.groupId} + spring-boot-starter + ${project.version} + + + org.crashub + crash.cli + + + org.crashub + crash.connectors.ssh + + + org.crashub + crash.connectors.telnet + + + javax.servlet + servlet-api + + + log4j + log4j + + + commons-logging + commons-logging + + + true + + + org.crashub + crash.embed.spring + + + org.crashub + crash.shell + + + org.codehaus.groovy + groovy-all + + + + + org.codehaus.groovy + groovy + + + diff --git a/spring-boot-starters/spring-boot-starter-shell-crsh/src/main/resources/commands/crash/help.groovy b/spring-boot-starters/spring-boot-starter-shell-crsh/src/main/resources/commands/crash/help.groovy new file mode 100644 index 00000000000..4ea73636cac --- /dev/null +++ b/spring-boot-starters/spring-boot-starter-shell-crsh/src/main/resources/commands/crash/help.groovy @@ -0,0 +1,58 @@ +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 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 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); + } +} diff --git a/spring-boot-starters/spring-boot-starter-shell-crsh/src/main/resources/commands/crash/jmx.groovy b/spring-boot-starters/spring-boot-starter-shell-crsh/src/main/resources/commands/crash/jmx.groovy new file mode 100644 index 00000000000..64bded8bc71 --- /dev/null +++ b/spring-boot-starters/spring-boot-starter-shell-crsh/src/main/resources/commands/crash/jmx.groovy @@ -0,0 +1,123 @@ +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 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 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 context, @Argument ObjectName name) throws IOException { + MBeanServer server = ManagementFactory.getPlatformMBeanServer(); + try { + MBeanInfo info = server.getMBeanInfo(name); + for (MBeanAttributeInfo attributeInfo : info.getAttributes()) { + HashMap tuple = new HashMap(); + 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 get(@Argument final List attributes) { + + // Determine common attributes from all names + if (attributes == null || attributes.isEmpty()) { + throw new ScriptException("Must provide JMX attributes"); + } + + // + return new PipeCommand() { + + /** . */ + private MBeanServer server; + + @Override + public void open() throws ScriptException { + server = ManagementFactory.getPlatformMBeanServer(); + } + + @Override + public void provide(ObjectName name) throws IOException { + try { + HashMap tuple = new HashMap(); + 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) { + // + } + } + }; + } +} diff --git a/spring-boot-starters/spring-boot-starter-shell-crsh/src/main/resources/commands/crash/login.groovy b/spring-boot-starters/spring-boot-starter-shell-crsh/src/main/resources/commands/crash/login.groovy new file mode 100644 index 00000000000..21d4dc5b65a --- /dev/null +++ b/spring-boot-starters/spring-boot-starter-shell-crsh/src/main/resources/commands/crash/login.groovy @@ -0,0 +1,22 @@ +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 "> "; +} diff --git a/spring-boot-starters/spring-boot-starter-shell-crsh/src/main/resources/commands/crash/metrics.groovy b/spring-boot-starters/spring-boot-starter-shell-crsh/src/main/resources/commands/crash/metrics.groovy new file mode 100644 index 00000000000..3fffed92f3b --- /dev/null +++ b/spring-boot-starters/spring-boot-starter-shell-crsh/src/main/resources/commands/crash/metrics.groovy @@ -0,0 +1,49 @@ +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