Allow triggering reload with pre-shared secret (#712)

This commit is contained in:
Torsten Walter 2019-05-15 22:25:46 +02:00 committed by Tim Jacomb
parent 15e78611e4
commit 791fccccee
5 changed files with 312 additions and 0 deletions

View File

@ -324,6 +324,22 @@ Kubernetes users:
Most plugins should be supported out-of-the-box, or maybe require some minimal changes. See this [dashboard](https://issues.jenkins.io/secure/Dashboard.jspa?selectPageId=18341) for known compatibility issues.
## Triggering Configuration Reload
You have the following option to trigger a configuration reload:
- via the user interface: `Manage Jenkins -> Configuration -> Reload existing configuration`
- via http POST to `JENKINS_URL/configuration-as-code/reload`
Note: this needs to include a valid CRUMB and authentication information e.g. username + token of a user with admin
permissions. Since Jenkins 2.96 CRUMB is not needed for API tokens.
- via Jenkins CLI
- via http POST to `JENKINS_URL/reload-configuration-as-code`
It's disabled by default and secured via a token configured as system property `casc.reload.token`.
Setting the system property enables this functionality and the requests need to include the token as
query parameter named `casc-reload-token`, i.e. `JENKINS_URL/reload-configuration-as-code/?casc-reload-token=32424324rdsadsa`.
`curl -X POST "JENKINS_URL:8080/reload-configuration-as-code/?casc-reload-token=32424324rdsadsa"`
## Jenkins Enhancement Proposal
As configuration as code is demonstrated to be a highly requested topic in Jenkins community, we have published

View File

@ -0,0 +1,73 @@
package io.jenkins.plugins.casc;
import com.google.common.base.Strings;
import hudson.Extension;
import hudson.model.UnprotectedRootAction;
import java.io.IOException;
import java.util.logging.Logger;
import javax.annotation.CheckForNull;
import javax.servlet.http.HttpServletRequest;
import org.apache.commons.httpclient.HttpStatus;
import org.kohsuke.stapler.StaplerRequest;
import org.kohsuke.stapler.StaplerResponse;
import org.kohsuke.stapler.interceptor.RequirePOST;
@Extension
public class TokenReloadAction implements UnprotectedRootAction {
public static final Logger LOGGER = Logger.getLogger(TokenReloadAction.class.getName());
public static final String URL_NAME = "reload-configuration-as-code";
public static final String RELOAD_TOKEN_PROPERTY = "casc.reload.token";
public static final String RELOAD_TOKEN_QUERY_PARAMETER = "casc-reload-token";
@CheckForNull
@Override
public String getIconFileName() {
return null;
}
@CheckForNull
@Override
public String getDisplayName() {
return "Reload Configuration as Code";
}
@CheckForNull
@Override
public String getUrlName() {
return URL_NAME;
}
@RequirePOST
public void doIndex(StaplerRequest request, StaplerResponse response) throws IOException {
String token = getReloadTokenProperty();
if (Strings.isNullOrEmpty(token)) {
response.sendError(HttpStatus.SC_NOT_FOUND);
LOGGER.warning("Configuration reload via token is not enabled");
} else {
String requestToken = getRequestToken(request);
if (token.equals(requestToken)) {
LOGGER.info("Configuration reload triggered via token");
ConfigurationAsCode.get().configure();
} else {
response.sendError(HttpStatus.SC_UNAUTHORIZED);
LOGGER.warning("Invalid token received, not reloading configuration");
}
}
}
private String getRequestToken(HttpServletRequest request) {
return request.getParameter(RELOAD_TOKEN_QUERY_PARAMETER);
}
private static String getReloadTokenProperty() {
return System.getProperty(RELOAD_TOKEN_PROPERTY);
}
public static boolean tokenReloadEnabled() {
return !Strings.isNullOrEmpty(getReloadTokenProperty());
}
}

View File

@ -0,0 +1,26 @@
package io.jenkins.plugins.casc;
import hudson.Extension;
import hudson.security.csrf.CrumbExclusion;
import java.io.IOException;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
@Extension
public class TokenReloadCrumbExclusion extends CrumbExclusion {
@Override
public boolean process(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
if (TokenReloadAction.tokenReloadEnabled()) {
String pathInfo = request.getPathInfo();
if (pathInfo != null && pathInfo.equals("/" + TokenReloadAction.URL_NAME + "/")) {
chain.doFilter(request, response);
return true;
}
}
return false;
}
}

View File

@ -0,0 +1,142 @@
package io.jenkins.plugins.casc;
import io.jenkins.plugins.casc.misc.JenkinsConfiguredWithCodeRule;
import java.io.IOException;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.logging.Level;
import java.util.logging.LogRecord;
import org.apache.commons.httpclient.HttpStatus;
import org.eclipse.jetty.server.Request;
import org.eclipse.jetty.server.Response;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.contrib.java.lang.system.RestoreSystemProperties;
import org.jvnet.hudson.test.LoggerRule;
import org.kohsuke.stapler.RequestImpl;
import org.kohsuke.stapler.ResponseImpl;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public class TokenReloadActionTest {
private Date lastTimeLoaded;
private TokenReloadAction tokenReloadAction;
@Rule
public final RestoreSystemProperties restoreSystemProperties = new RestoreSystemProperties();
@Rule
public final LoggerRule loggerRule = new LoggerRule();
@Rule
public JenkinsConfiguredWithCodeRule j = new JenkinsConfiguredWithCodeRule();
private ServletResponseSpy response;
private class ServletResponseSpy extends Response {
private int error = HttpStatus.SC_OK;
public ServletResponseSpy() {
super(null, null);
}
@Override
public void sendError(int sc) throws IOException {
error = sc;
}
@Override
public int getStatus() {
return error;
}
}
private class RequestStub extends Request {
private final String authorization;
public RequestStub(String authorization) {
super(null, null);
this.authorization = authorization;
}
@Override
public String getParameter(String name) {
if (TokenReloadAction.RELOAD_TOKEN_QUERY_PARAMETER.equals(name)) {
return authorization;
}
return super.getHeader(name); }
}
private RequestImpl newRequest(String authorization) {
return new RequestImpl(null, new RequestStub(authorization), Collections.emptyList(), null);
}
private boolean configWasReloaded() {
return !lastTimeLoaded.equals(ConfigurationAsCode.get().getLastTimeLoaded());
}
@Before
public void setUp() {
tokenReloadAction = new TokenReloadAction();
response = new ServletResponseSpy();
loggerRule.record(TokenReloadAction.class, Level.ALL);
loggerRule.capture(3);
lastTimeLoaded = ConfigurationAsCode.get().getLastTimeLoaded();
}
@Test
public void reloadIsDisabledByDefault() throws IOException {
System.clearProperty("casc.reload.token");
RequestImpl request = newRequest(null);
tokenReloadAction.doIndex(request, new ResponseImpl(null, response));
assertEquals(HttpStatus.SC_NOT_FOUND, response.getStatus());
List<LogRecord> messages = loggerRule.getRecords();
assertEquals(1, messages.size());
assertEquals("Configuration reload via token is not enabled", messages.get(0).getMessage());
assertEquals(Level.WARNING, messages.get(0).getLevel());
assertFalse(configWasReloaded());
}
@Test
public void reloadReturnsUnauthorizedIfTokenDoesNotMatch() throws IOException {
System.setProperty("casc.reload.token", "someSecretValue");
RequestImpl request = newRequest(null);
tokenReloadAction.doIndex(request, new ResponseImpl(null, response));
assertEquals(HttpStatus.SC_UNAUTHORIZED, response.getStatus());
assertFalse(configWasReloaded());
List<LogRecord> messages = loggerRule.getRecords();
assertEquals(1, messages.size());
assertEquals("Invalid token received, not reloading configuration", messages.get(0).getMessage());
assertEquals(Level.WARNING, messages.get(0).getLevel());
}
@Test
public void reloadReturnsOkWhenCalledWithValidToken() throws IOException {
System.setProperty("casc.reload.token", "someSecretValue");
tokenReloadAction.doIndex(newRequest("someSecretValue"), new ResponseImpl(null, response));
assertEquals(HttpStatus.SC_OK, response.getStatus());
assertTrue(configWasReloaded());
List<LogRecord> messages = loggerRule.getRecords();
assertEquals(1, messages.size());
assertEquals("Configuration reload triggered via token", messages.get(0).getMessage());
assertEquals(Level.INFO, messages.get(0).getLevel());
}
}

View File

@ -0,0 +1,55 @@
package io.jenkins.plugins.casc;
import java.util.concurrent.atomic.AtomicBoolean;
import org.eclipse.jetty.server.Request;
import org.junit.Rule;
import org.junit.Test;
import org.junit.contrib.java.lang.system.RestoreSystemProperties;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public class TokenReloadCrumbExclusionTest {
@Rule
public final RestoreSystemProperties restoreSystemProperties = new RestoreSystemProperties();
private Request newRequestWithPath(String hello) {
return new Request(null, null) {
@Override
public String getPathInfo() {
return hello;
}
};
}
@Test
public void crumbExclusionIsDisabledByDefault() throws Exception {
System.clearProperty("casc.reload.token");
TokenReloadCrumbExclusion crumbExclusion = new TokenReloadCrumbExclusion();
assertFalse(crumbExclusion.process(newRequestWithPath("/reload-configuration-as-code/"), null, null));
}
@Test
public void crumbExclusionChecksRequestPath() throws Exception {
System.setProperty("casc.reload.token", "someSecretValue");
TokenReloadCrumbExclusion crumbExclusion = new TokenReloadCrumbExclusion();
assertFalse(crumbExclusion.process(newRequestWithPath("/reload-configuration-as-code/2"), null, null));
}
@Test
public void crumbExclustionAllowsReloadIfEnabledAndRequestPathMatch() throws Exception {
System.setProperty("casc.reload.token", "someSecretValue");
TokenReloadCrumbExclusion crumbExclusion = new TokenReloadCrumbExclusion();
AtomicBoolean callProcessed = new AtomicBoolean(false);
assertTrue(crumbExclusion.process(newRequestWithPath("/reload-configuration-as-code/"), null, (request, response) -> callProcessed.set(true)));
assertTrue(callProcessed.get());
}
}