diff --git a/solr/bin/solr b/solr/bin/solr index db487c04bfd2..c8866113762c 100755 --- a/solr/bin/solr +++ b/solr/bin/solr @@ -419,6 +419,7 @@ function print_usage() { echo "" echo " -e/--example Name of the example to run; available examples:" echo " cloud: SolrCloud example" + echo " secure_solr: Single-node SolrCloud with Basic Authentication and techproducts data" echo " techproducts: Comprehensive example illustrating many of Solr's core capabilities" echo " schemaless: Schema-less example (schema is inferred from data during indexing)" echo " films: Example of starting with _default configset and adding explicit fields dynamically" @@ -598,6 +599,17 @@ else exit fi +if [[ "$SCRIPT_CMD" == "ssl" ]]; then + SSL_INCLUDE_ARGS=(--solr-include-file "${SOLR_INCLUDE:-$SOLR_TIP/bin/solr.in.sh}") + for arg in "$@"; do + case "$arg" in + --solr-include-file|--solr-include-file=*) SSL_INCLUDE_ARGS=(); break ;; + esac + done + run_tool ssl "${SSL_INCLUDE_ARGS[@]}" "$@" + exit $? +fi + # configure authentication if [[ "$SCRIPT_CMD" == "auth" ]]; then : "${SOLR_SERVER_DIR:=$DEFAULT_SERVER_DIR}" diff --git a/solr/bin/solr.cmd b/solr/bin/solr.cmd index 3867aa1049b7..3fd5beefe530 100755 --- a/solr/bin/solr.cmd +++ b/solr/bin/solr.cmd @@ -337,6 +337,7 @@ goto err @echo. @echo -e/--example name Name of the example to run; available examples: @echo cloud: SolrCloud example +@echo secure_solr: Single-node SolrCloud with Basic Authentication and techproducts data @echo techproducts: Comprehensive example illustrating many of Solr's core capabilities @echo schemaless: Schema-less example (schema is inferred from data during indexing) @echo films: Example of starting with _default configset and defining explicit fields dynamically diff --git a/solr/core/src/java/org/apache/solr/cli/AuthTool.java b/solr/core/src/java/org/apache/solr/cli/AuthTool.java index b63a0d545ddf..85f3d5856646 100644 --- a/solr/core/src/java/org/apache/solr/cli/AuthTool.java +++ b/solr/core/src/java/org/apache/solr/cli/AuthTool.java @@ -27,13 +27,11 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; -import java.util.Arrays; import java.util.LinkedHashMap; -import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Set; import java.util.concurrent.TimeUnit; -import java.util.stream.Collectors; import org.apache.commons.cli.CommandLine; import org.apache.commons.cli.Option; import org.apache.commons.cli.Options; @@ -230,9 +228,8 @@ public String getHeader() { List of options:"""; } - List authenticationVariables = - Arrays.asList( - "SOLR_AUTHENTICATION_CLIENT_BUILDER", "SOLR_AUTH_TYPE", "SOLR_AUTHENTICATION_OPTS"); + private static final Set AUTHENTICATION_VARIABLES = + Set.of("SOLR_AUTHENTICATION_CLIENT_BUILDER", "SOLR_AUTH_TYPE", "SOLR_AUTHENTICATION_OPTS"); @Override public Options getOptions() { @@ -361,10 +358,13 @@ private void enableBasicAuth(AuthParams params) throws Exception { runtime.exit(0); } - Files.writeString( + if (Files.exists(basicAuthConfFile)) { + echo("Backup: " + IncludeFileEditor.backup(basicAuthConfFile)); + } + IncludeFileEditor.writePrivate( basicAuthConfFile, - "httpBasicAuthUser=" + username + "\nhttpBasicAuthPassword=" + password, - StandardCharsets.UTF_8); + ("httpBasicAuthUser=" + username + "\nhttpBasicAuthPassword=" + password) + .getBytes(StandardCharsets.UTF_8)); // update the solr.in.sh file to contain the necessary authentication lines updateIncludeFileEnableAuth(includeFile, basicAuthConfFile); @@ -474,73 +474,32 @@ private void printAuthEnablingInstructions(String username, String password) { } } - /** - * This will update the include file (e.g. solr.in.sh / solr.in.cmd) with the authentication - * parameters. - * - * @param includeFile The include file - * @param basicAuthConfFile If basicAuth, the path of the file containing credentials. If not, - * null. - */ private void updateIncludeFileEnableAuth(Path includeFile, Path basicAuthConfFile) throws IOException { - List includeFileLines = Files.readAllLines(includeFile, StandardCharsets.UTF_8); - for (int i = 0; i < includeFileLines.size(); i++) { - String line = includeFileLines.get(i); - if (authenticationVariables.contains(line.trim().split("=")[0].trim())) { // Non-Windows - includeFileLines.set(i, "# " + line); - } - if (line.trim().split("=")[0].trim().startsWith("set ") - && authenticationVariables.contains( - line.trim().split("=")[0].trim().substring(4))) { // Windows - includeFileLines.set(i, "REM " + line); - } - } - includeFileLines.add(""); // blank line - - if (basicAuthConfFile != null) { // for basicAuth - if (Constants.WINDOWS) { - includeFileLines.add("REM The following lines added by solr.cmd for enabling BasicAuth"); - includeFileLines.add("set SOLR_AUTH_TYPE=basic"); - includeFileLines.add( - "set SOLR_AUTHENTICATION_OPTS=\"-Dsolr.httpclient.config=" + basicAuthConfFile + "\""); - } else { - includeFileLines.add("# The following lines added by ./solr for enabling BasicAuth"); - includeFileLines.add("SOLR_AUTH_TYPE=\"basic\""); - includeFileLines.add( - "SOLR_AUTHENTICATION_OPTS=\"-Dsolr.httpclient.config=" + basicAuthConfFile + "\""); - } - } - - String lines = includeFileLines.stream().collect(Collectors.joining(System.lineSeparator())); - Files.writeString(includeFile, lines, StandardCharsets.UTF_8); - - if (basicAuthConfFile != null) { - echoIfVerbose("Written out credentials file: " + basicAuthConfFile); - } + var format = + Constants.WINDOWS ? IncludeFileEditor.Format.WINDOWS : IncludeFileEditor.Format.SHELL; + Map settings = new LinkedHashMap<>(); + settings.put("SOLR_AUTH_TYPE", "basic"); + String authenticationOptions = "-Dsolr.httpclient.config=" + basicAuthConfFile; + settings.put( + "SOLR_AUTHENTICATION_OPTS", + Constants.WINDOWS ? "\"" + authenticationOptions + "\"" : authenticationOptions); + String updated = + IncludeFileEditor.update( + Files.readString(includeFile), settings, AUTHENTICATION_VARIABLES, format); + echo("Backup: " + IncludeFileEditor.save(includeFile, updated)); + echoIfVerbose("Written out credentials file: " + basicAuthConfFile); echoIfVerbose("Updated Solr include file: " + includeFile.toAbsolutePath()); } private void updateIncludeFileDisableAuth(Path includeFile) throws IOException { - List includeFileLines = Files.readAllLines(includeFile, StandardCharsets.UTF_8); - boolean hasChanged = false; - for (int i = 0; i < includeFileLines.size(); i++) { - String line = includeFileLines.get(i); - if (authenticationVariables.contains(line.trim().split("=")[0].trim())) { // Non-Windows - includeFileLines.set(i, "# " + line); - hasChanged = true; - } - if (line.trim().split("=")[0].trim().startsWith("set ") - && authenticationVariables.contains( - line.trim().split("=")[0].trim().substring(4))) { // Windows - includeFileLines.set(i, "REM " + line); - hasChanged = true; - } - } - if (hasChanged) { - String lines = includeFileLines.stream().collect(Collectors.joining(System.lineSeparator())); - Files.writeString(includeFile, lines, StandardCharsets.UTF_8); - echoIfVerbose("Commented out necessary lines from " + includeFile.toAbsolutePath()); + var format = + Constants.WINDOWS ? IncludeFileEditor.Format.WINDOWS : IncludeFileEditor.Format.SHELL; + String original = Files.readString(includeFile); + String updated = IncludeFileEditor.disable(original, AUTHENTICATION_VARIABLES, format); + if (!original.equals(updated)) { + echo("Backup: " + IncludeFileEditor.save(includeFile, updated)); + echoIfVerbose("Commented out authentication settings in " + includeFile.toAbsolutePath()); } } diff --git a/solr/core/src/java/org/apache/solr/cli/IncludeFileEditor.java b/solr/core/src/java/org/apache/solr/cli/IncludeFileEditor.java new file mode 100644 index 000000000000..01172b645c74 --- /dev/null +++ b/solr/core/src/java/org/apache/solr/cli/IncludeFileEditor.java @@ -0,0 +1,146 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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.apache.solr.cli; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.attribute.AclFileAttributeView; +import java.nio.file.attribute.PosixFilePermissions; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; + +/** Edits Solr shell and Windows include files without changing unrelated settings. */ +final class IncludeFileEditor { + enum Format { + SHELL(Pattern.compile("\\s*(?:export\\s+)?([A-Za-z_][A-Za-z0-9_]*)\\s*=.*"), "# "), + WINDOWS(Pattern.compile("\\s*(?i:set)\\s+\"?([A-Za-z_][A-Za-z0-9_]*)\\s*=.*"), "REM "); + + private final Pattern assignment; + private final String comment; + + Format(Pattern assignment, String comment) { + this.assignment = assignment; + this.comment = comment; + } + + private String variable(String line) { + var matcher = assignment.matcher(line); + if (!matcher.matches()) return null; + return this == WINDOWS ? matcher.group(1).toUpperCase(Locale.ROOT) : matcher.group(1); + } + } + + private IncludeFileEditor() {} + + static String update( + String original, Map settings, Set replaced, Format format) { + String newline = newline(original, format); + StringBuilder output = new StringBuilder(); + for (String line : original.split("\\R")) { + String variable = format.variable(line); + if (variable == null || !replaced.contains(variable)) output.append(line).append(newline); + } + settings.forEach( + (key, value) -> { + if (!key.matches("[A-Za-z_][A-Za-z0-9_]*") + || value.indexOf('\n') >= 0 + || value.indexOf('\r') >= 0 + || value.indexOf('\0') >= 0) { + throw new IllegalArgumentException( + "Include-file settings must be single-line variable assignments."); + } + if (format == Format.WINDOWS) { + output.append("set ").append(key).append('=').append(value); + } else { + output.append(key).append("='").append(value.replace("'", "'\"'\"'")).append("'"); + } + output.append(newline); + }); + return output.toString(); + } + + static String disable(String original, Set settings, Format format) { + String newline = newline(original, format); + StringBuilder output = new StringBuilder(); + boolean changed = false; + for (String line : original.split("\\R")) { + String variable = format.variable(line); + if (variable != null && settings.contains(variable)) { + output.append(format.comment); + changed = true; + } + output.append(line).append(newline); + } + return changed ? output.toString() : original; + } + + private static String newline(String original, Format format) { + return original.contains("\r\n") || (original.isEmpty() && format == Format.WINDOWS) + ? "\r\n" + : "\n"; + } + + static Path backup(Path path) throws IOException { + Path target = path.toRealPath(); + Path backup = Files.createTempFile(target.getParent(), target.getFileName() + ".", ".bak"); + try { + Files.copy( + target, backup, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.COPY_ATTRIBUTES); + restrictPermissions(backup); + return backup; + } catch (IOException failure) { + Files.deleteIfExists(backup); + throw failure; + } + } + + static Path save(Path path, String content) throws IOException { + Path backup = backup(path); + writePrivate(path, content.getBytes(StandardCharsets.UTF_8)); + return backup; + } + + static void writePrivate(Path path, byte[] data) throws IOException { + Path target = Files.exists(path) ? path.toRealPath() : path.toAbsolutePath().normalize(); + Path staged = Files.createTempFile(target.getParent(), ".solr-config-", ".tmp"); + try { + restrictPermissions(staged); + if (Files.exists(target)) { + var sourceAcl = Files.getFileAttributeView(target, AclFileAttributeView.class); + var destinationAcl = Files.getFileAttributeView(staged, AclFileAttributeView.class); + if (sourceAcl != null && destinationAcl != null) destinationAcl.setAcl(sourceAcl.getAcl()); + } + Files.write(staged, data); + Files.move( + staged, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } finally { + Files.deleteIfExists(staged); + } + } + + private static void restrictPermissions(Path path) throws IOException { + if (path.getFileSystem().supportedFileAttributeViews().contains("posix")) { + Files.setPosixFilePermissions(path, PosixFilePermissions.fromString("rw-------")); + } + } +} diff --git a/solr/core/src/java/org/apache/solr/cli/RunExampleTool.java b/solr/core/src/java/org/apache/solr/cli/RunExampleTool.java index 66828271c060..ba7c3a3c2bde 100644 --- a/solr/core/src/java/org/apache/solr/cli/RunExampleTool.java +++ b/solr/core/src/java/org/apache/solr/cli/RunExampleTool.java @@ -45,10 +45,18 @@ import org.apache.commons.exec.Executor; import org.apache.commons.exec.environment.EnvironmentUtils; import org.apache.commons.io.file.PathUtils; +import org.apache.solr.client.solrj.RemoteSolrException; import org.apache.solr.client.solrj.SolrClient; +import org.apache.solr.client.solrj.SolrRequest; import org.apache.solr.client.solrj.impl.CloudSolrClient; +import org.apache.solr.client.solrj.request.GenericSolrRequest; import org.apache.solr.common.SolrException; +import org.apache.solr.common.cloud.SolrZkClient; +import org.apache.solr.common.params.ModifiableSolrParams; import org.apache.solr.common.util.EnvUtils; +import org.apache.solr.common.util.Utils; +import org.apache.solr.core.SolrCore; +import org.apache.solr.security.Sha256AuthenticationProvider; import org.noggit.CharArr; import org.noggit.JSONWriter; @@ -121,7 +129,8 @@ public class RunExampleTool extends ToolBase { .hasArg() .argName("NAME") .required() - .desc("Name of the example to launch, one of: cloud, techproducts, schemaless, films.") + .desc( + "Name of the example to launch, one of: cloud, techproducts, secure_solr, schemaless, films.") .get(); /** @@ -332,7 +341,7 @@ record CloudExampleParams( required = true, paramLabel = "NAME", description = - "Name of the example to launch, one of: cloud, techproducts, schemaless, films.") + "Name of the example to launch, one of: cloud, techproducts, secure_solr, schemaless, films.") private String exampleOpt; @picocli.CommandLine.Option( @@ -500,12 +509,13 @@ public void runImpl(CommandLine cli) throws Exception { if (!"cloud".equals(exampleType) && !"techproducts".equals(exampleType) + && !"secure_solr".equals(exampleType) && !"schemaless".equals(exampleType) && !"films".equals(exampleType)) { throw new IllegalArgumentException( "Unsupported example " + exampleType - + "! Please choose one of: cloud, schemaless, techproducts, or films"); + + "! Please choose one of: cloud, schemaless, techproducts, secure_solr, or films"); } StartSolrParams startParams = @@ -532,6 +542,16 @@ public void runImpl(CommandLine cli) throws Exception { zkHost, port, startParams)); + } else if ("secure_solr".equals(exampleType)) { + if (cli.hasOption(USER_MANAGED_OPTION) || zkHost != null) { + throw new IllegalArgumentException( + "secure_solr requires embedded ZooKeeper in SolrCloud mode"); + } + runSecureExample( + startParams, + port, + cli.hasOption(NO_PROMPT_OPTION), + cli.getOptionValue(SCRIPT_INPUTS_OPTION)); } else { runExample( new RunExampleParams(!cli.hasOption(USER_MANAGED_OPTION), zkHost, port, startParams)); @@ -588,8 +608,8 @@ void initDirs( String solrHomeProp = EnvUtils.getProperty("solr.home"); if (solrHomeProp != null && !solrHomeProp.isEmpty()) { solrHomeDir = Path.of(solrHomeProp); - } else if ("cloud".equals(exampleType)) { - solrHomeDir = exampleDir.resolve("cloud"); + } else if ("cloud".equals(exampleType) || "secure_solr".equals(exampleType)) { + solrHomeDir = exampleDir.resolve(exampleType); if (!Files.isDirectory(solrHomeDir)) Files.createDirectory(solrHomeDir); } else { solrHomeDir = serverDir.resolve("solr"); @@ -602,11 +622,132 @@ void initDirs( + " is not a directory!"); } + private static String[] withCredentials(String[] args, String credentials) { + if (credentials == null) return args; + List result = new ArrayList<>(Arrays.asList(args)); + result.add("--credentials"); + result.add(credentials); + return result.toArray(String[]::new); + } + + @SuppressWarnings("unchecked") + void runSecureExample(StartSolrParams start, int port, boolean noPrompt, String scriptInputs) + throws Exception { + for (String file : List.of("solr.xml", "zoo.cfg")) { + Path target = solrHomeDir.resolve(file); + if (!Files.exists(target)) Files.copy(serverDir.resolve("solr").resolve(file), target); + } + echo("Welcome to the secure SolrCloud example!"); + echo( + "This example uses demonstration credentials. Change them before using it outside your local environment."); + usingScriptInputs = scriptInputs != null; + Scanner input = + new Scanner( + usingScriptInputs + ? new ByteArrayInputStream(scriptInputs.getBytes(StandardCharsets.UTF_8)) + : userInput, + StandardCharsets.UTF_8); + if (usingScriptInputs) input.useDelimiter(","); + String user = noPrompt ? "solr" : prompt(input, "Administrator username [solr]: ", "solr"); + String password = + noPrompt ? "SolrRocks" : promptPassword(input, "Administrator password [SolrRocks]: "); + if (user.contains(":")) throw new IllegalArgumentException("Username must not contain ':'"); + if (user.equals(password)) + throw new IllegalArgumentException("Username and password must differ"); + Map config; + try (InputStream template = + SolrCore.class.getClassLoader().getResourceAsStream("security.json")) { + config = (Map) Utils.fromJSON(template.readAllBytes()); + } + Map authentication = (Map) config.get("authentication"); + authentication.put("blockUnknown", true); + Map credentials = new HashMap<>(); + credentials.put(user, Sha256AuthenticationProvider.getSaltedHashedValue(password)); + authentication.put("credentials", credentials); + Map authorization = (Map) config.get("authorization"); + Map userRoles = new HashMap<>(); + userRoles.put(user, List.of("superadmin", "admin", "index", "search")); + authorization.put("user-role", userRoles); + for (String role : List.of("index", "search")) { + if (!noPrompt + && !"y".equalsIgnoreCase(prompt(input, "Create a " + role + " user? [Y]: ", "Y"))) + continue; + String name = noPrompt ? role : prompt(input, "Username [" + role + "]: ", role); + if (name.contains(":") || credentials.containsKey(name)) + throw new IllegalArgumentException("Usernames must be unique and must not contain ':'"); + String pass = noPrompt ? "SolrRocks" : promptPassword(input, "Password [SolrRocks]: "); + if (name.equals(pass)) + throw new IllegalArgumentException("Username and password must differ"); + credentials.put(name, Sha256AuthenticationProvider.getSaltedHashedValue(pass)); + userRoles.put(name, "index".equals(role) ? List.of("index", "search") : List.of("search")); + } + StartSolrParams secured = + new StartSolrParams( + start.example(), + start.host(), + start.memory(), + start.jvmOpts(), + start.force(), + user + ":" + password, + start.extraArgs()); + runExample(new RunExampleParams(true, null, port, secured), config); + echo( + "Secure Solr example running at " + + urlScheme + + "://localhost:" + + port + + "/solr/. Sign in as " + + user + + "."); + } + + private String promptPassword(Scanner input, String message) { + echo(message); + String value; + if (!usingScriptInputs && System.console() != null && userInput == System.in) { + value = new String(System.console().readPassword()); + } else { + value = + usingScriptInputs + ? (input.hasNext() ? input.next() : "") + : (input.hasNextLine() ? input.nextLine() : ""); + } + return value.isEmpty() ? "SolrRocks" : value; + } + + private void waitForSecurity(String solrUrl, String credentials) throws Exception { + // ZooKeeper watchers reload security asynchronously. + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(30); + while (System.nanoTime() < deadline) { + try (SolrClient anonymous = CLIUtils.getSolrClient(solrUrl, null)) { + anonymous.request( + new GenericSolrRequest( + SolrRequest.METHOD.GET, "/admin/info/system", new ModifiableSolrParams())); + } catch (RemoteSolrException e) { + if (e.code() == 401) { + getNodeStatus(solrUrl, credentials, 30); + return; + } + throw e; + } + TimeUnit.MILLISECONDS.sleep(100); + } + throw new IllegalStateException("Timed out waiting for Basic Authentication to become active"); + } + void runExample(RunExampleParams params) throws Exception { + runExample(params, null); + } + + private void runExample(RunExampleParams params, Map securityConfig) + throws Exception { String exampleName = params.start().example(); - String collectionName = "schemaless".equals(exampleName) ? "gettingstarted" : exampleName; - String configSet = - "techproducts".equals(exampleName) ? "sample_techproducts_configs" : "_default"; + boolean techproducts = "techproducts".equals(exampleName) || "secure_solr".equals(exampleName); + String collectionName = + techproducts + ? "techproducts" + : "schemaless".equals(exampleName) ? "gettingstarted" : exampleName; + String configSet = techproducts ? "sample_techproducts_configs" : "_default"; String zkHost = params.zkHost(); int port = params.port(); @@ -615,6 +756,19 @@ void runExample(RunExampleParams params) throws Exception { String solrUrl = CLIUtils.normalizeSolrUrl((String) nodeStatus.get("baseUrl"), false); + if (securityConfig != null) { + String embeddedZk = (String) ((Map) nodeStatus.get("cloud")).get("ZooKeeper"); + try (SolrZkClient zk = new SolrZkClient.Builder().withUrl(embeddedZk).build()) { + if (!zk.exists("/security.json") + || Utils.fromJSON(zk.getData("/security.json", null, null)).equals(Map.of())) { + zk.makePath("/security.json", Utils.toJSON(securityConfig), false); + } else { + echo("Using the existing security configuration."); + } + } + waitForSecurity(solrUrl, params.start().credentials()); + } + // If the example already exists then let the user know they should delete it, or // they may get unusual behaviors. boolean alreadyExists = false; @@ -657,14 +811,13 @@ void runExample(RunExampleParams params) throws Exception { "--conf-dir", configSet, "--solr-url", solrUrl }; + createArgs = withCredentials(createArgs, params.start().credentials()); CreateTool createTool = new CreateTool(runtime); int createCode = createTool.runTool(SolrCLI.processCommandLineArgs(createTool, createArgs)); - if (createCode != 0) - throw new Exception( - "Failed to create " + collectionName + " using command: " + Arrays.asList(createArgs)); + if (createCode != 0) throw new Exception("Failed to create " + collectionName); } - if ("techproducts".equals(exampleName) && !alreadyExists) { + if (techproducts && !alreadyExists) { Path exampledocsDir = this.exampleDir.resolve("exampledocs"); if (!Files.isDirectory(exampledocsDir)) { @@ -691,7 +844,8 @@ void runExample(RunExampleParams params) throws Exception { exampledocsDir.toAbsolutePath().toString() }; PostTool postTool = new PostTool(runtime); - CommandLine postToolCli = SolrCLI.parseCmdLine(postTool, args); + CommandLine postToolCli = + SolrCLI.parseCmdLine(postTool, withCredentials(args, params.start().credentials())); postTool.runTool(postToolCli); } else { @@ -1025,7 +1179,7 @@ Map startSolr( solrHome = solrHome.substring(cwdPath.length() + 1); final var syspropArg = - ("techproducts".equals(params.example())) + ("techproducts".equals(params.example()) || "secure_solr".equals(params.example())) ? "-Dsolr.modules=clustering,extraction,langid,ltr,scripting,sql -Dsolr.ltr.enabled=true -Dsolr.clustering.enabled=true" : ""; @@ -1054,7 +1208,7 @@ Map startSolr( String.format( Locale.ROOT, "%s://%s:%d/solr", urlScheme, (host != null ? host : "localhost"), port); - String credentials = null; // for now, we don't need it for example tool. But we should. + String credentials = params.credentials(); Map nodeStatus = checkPortConflict(solrUrl, credentials, solrHomeDir, port); if (nodeStatus != null) @@ -1401,12 +1555,13 @@ public int callTool() throws Exception { if (!"cloud".equals(exampleType) && !"techproducts".equals(exampleType) + && !"secure_solr".equals(exampleType) && !"schemaless".equals(exampleType) && !"films".equals(exampleType)) { throw new IllegalArgumentException( "Unsupported example " + exampleType - + "! Please choose one of: cloud, schemaless, techproducts, or films"); + + "! Please choose one of: cloud, schemaless, techproducts, secure_solr, or films"); } StartSolrParams startParams = @@ -1416,6 +1571,12 @@ public int callTool() throws Exception { if ("cloud".equals(exampleType)) { runCloudExample( new CloudExampleParams(noPromptOpt, scriptInputsOpt, zkHost, port, startParams)); + } else if ("secure_solr".equals(exampleType)) { + if (userManagedOpt || zkHost != null) { + throw new IllegalArgumentException( + "secure_solr requires embedded ZooKeeper in SolrCloud mode"); + } + runSecureExample(startParams, port, noPromptOpt, scriptInputsOpt); } else { runExample(new RunExampleParams(!userManagedOpt, zkHost, port, startParams)); } diff --git a/solr/core/src/java/org/apache/solr/cli/SSLTool.java b/solr/core/src/java/org/apache/solr/cli/SSLTool.java new file mode 100644 index 000000000000..352ac4cd0da9 --- /dev/null +++ b/solr/core/src/java/org/apache/solr/cli/SSLTool.java @@ -0,0 +1,379 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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.apache.solr.cli; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyStore; +import java.security.cert.X509Certificate; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import org.apache.commons.cli.CommandLine; +import org.apache.commons.cli.Option; +import org.apache.commons.cli.Options; +import org.apache.solr.common.cloud.ClusterProperties; +import org.apache.solr.common.cloud.SolrZkClient; +import org.apache.solr.common.util.EnvUtils; + +/** Installs PEM certificates and configures HTTPS for a Solr node. */ +@SuppressWarnings("UnnecessarilyFullyQualified") +@picocli.CommandLine.Command( + name = "ssl", + description = "Convert PEM files to a Java keystore and configure HTTPS in solr.in.sh.", + footerHeading = "%nExamples:%n", + footer = { + " bin/solr ssl --cert fullchain.pem --key privkey.pem --password-file password.txt", + " bin/solr ssl --cert fullchain.pem --key privkey.pem --password-file password.txt --zk-host localhost:9983" + }) +public class SSLTool extends ToolBase { + @picocli.CommandLine.Option( + names = "--cert", + required = true, + description = "PEM certificate chain, with the server certificate first.") + private Path certificate; + + @picocli.CommandLine.Option( + names = "--key", + required = true, + description = "PEM private key. Requires OpenSSL on PATH.") + private Path privateKey; + + @picocli.CommandLine.Option( + names = "--password-file", + required = true, + description = "File containing the keystore password on one line.") + private Path passwordFile; + + @picocli.CommandLine.Option( + names = "--key-password-file", + description = "Password file for an encrypted PEM private key.") + private Path keyPasswordFile; + + @picocli.CommandLine.Option( + names = "--keystore", + description = "Destination keystore. Default directory: server/etc in the Solr installation.") + private Path keystore; + + @picocli.CommandLine.Option( + names = "--store-type", + defaultValue = "JKS", + description = "Keystore format: JKS or PKCS12. Default: JKS.") + private String storeType = "JKS"; + + @picocli.CommandLine.Option( + names = "--solr-include-file", + description = + "Existing solr.in.sh to update. Defaults to bin/solr.in.sh in the Solr installation.") + private Path includeFile; + + @picocli.CommandLine.Option( + names = "--zk-host", + description = + "ZooKeeper connection string. If supplied, set urlScheme=https for the entire cluster.") + private String zkHost; + + record SSLParams( + Path certificate, + Path privateKey, + Path passwordFile, + Path keyPasswordFile, + Path keystore, + String storeType, + Path includeFile, + String zkHost) {} + + public SSLTool() { + this(new DefaultToolRuntime()); + } + + public SSLTool(ToolRuntime runtime) { + super(runtime); + } + + @Override + public String getName() { + return "ssl"; + } + + @Override + public Options getOptions() { + Options options = super.getOptions(); + for (var field : SSLTool.class.getDeclaredFields()) { + var option = field.getAnnotation(picocli.CommandLine.Option.class); + if (option != null) { + options.addOption( + Option.builder() + .longOpt(option.names()[0].substring(2)) + .hasArg() + .required(option.required()) + .desc(String.join(" ", option.description())) + .get()); + } + } + return options; + } + + private static Path path(CommandLine cli, Option option) { + String value = cli.getOptionValue(option); + return value == null ? null : Path.of(value); + } + + @Override + public void runImpl(CommandLine cli) throws Exception { + Options options = getOptions(); + configure( + new SSLParams( + path(cli, options.getOption("cert")), + path(cli, options.getOption("key")), + path(cli, options.getOption("password-file")), + path(cli, options.getOption("key-password-file")), + path(cli, options.getOption("keystore")), + cli.getOptionValue(options.getOption("store-type"), "JKS"), + path(cli, options.getOption("solr-include-file")), + cli.getOptionValue(options.getOption("zk-host")))); + } + + @Override + public int callTool() throws Exception { + configure( + new SSLParams( + certificate, + privateKey, + passwordFile, + keyPasswordFile, + keystore, + storeType, + includeFile, + zkHost)); + return 0; + } + + void configure(SSLParams params) throws Exception { + if (EnvUtils.getProperty("os.name", "").startsWith("Windows")) { + throw new UnsupportedOperationException( + "bin/solr ssl is not supported on Windows. Configure HTTPS manually using the Enabling SSL section of the Solr Reference Guide."); + } + String type = params.storeType().toUpperCase(Locale.ROOT); + if (!type.equals("JKS") && !type.equals("PKCS12")) { + throw new IllegalArgumentException("--store-type must be JKS or PKCS12."); + } + Path install = Path.of(EnvUtils.getProperty("solr.install.dir", ".")).toAbsolutePath(); + Path include = + (params.includeFile() == null ? install.resolve("bin/solr.in.sh") : params.includeFile()) + .toRealPath(); + if (!include.getFileName().toString().endsWith(".sh") || !Files.isWritable(include)) { + throw new IllegalArgumentException( + "--solr-include-file must be a writable shell include file (.sh)."); + } + Path target = + (params.keystore() == null + ? install.resolve( + "server/etc/solr-ssl.keystore." + (type.equals("JKS") ? "jks" : "p12")) + : params.keystore()) + .toAbsolutePath() + .normalize(); + Path trustTarget = target.resolveSibling(target.getFileName() + ".truststore"); + if (target.toString().chars().anyMatch(Character::isWhitespace)) { + throw new IllegalArgumentException("The keystore path must not contain whitespace."); + } + for (Path output : new Path[] {target, trustTarget}) { + if (Files.isSymbolicLink(output) + || !Files.isDirectory(output.getParent()) + || !Files.isWritable(output.getParent())) { + throw new IllegalArgumentException( + "Keystore destinations must have writable existing parent directories and must not be symbolic links."); + } + for (Path input : + new Path[] {params.certificate(), params.privateKey(), params.passwordFile(), include}) { + if (output.equals(input.toAbsolutePath().normalize()) + || (Files.exists(output) && Files.isSameFile(output, input))) { + throw new IllegalArgumentException("Keystore destinations must differ from input files."); + } + } + if (params.keyPasswordFile() != null + && output.equals(params.keyPasswordFile().toAbsolutePath().normalize())) { + throw new IllegalArgumentException("Keystore destinations must differ from input files."); + } + } + String password = readPassword(params.passwordFile()); + char[] secret = password.toCharArray(); + Path temporary = Files.createTempDirectory("solr-ssl-"); + try { + Path converted = temporary.resolve("converted.p12"); + ProcessBuilder builder = + new ProcessBuilder( + "openssl", + "pkcs12", + "-export", + "-in", + params.certificate().toAbsolutePath().toString(), + "-inkey", + params.privateKey().toAbsolutePath().toString(), + "-name", + "solr-ssl", + "-out", + converted.toString(), + "-passout", + "env:SOLR_SSL_CONVERSION_PASSWORD", + "-passin", + "env:SOLR_SSL_PEM_PASSWORD"); + builder.environment().put("SOLR_SSL_CONVERSION_PASSWORD", password); + builder + .environment() + .put( + "SOLR_SSL_PEM_PASSWORD", + params.keyPasswordFile() == null ? "" : readPassword(params.keyPasswordFile())); + builder.redirectErrorStream(true).redirectOutput(temporary.resolve("openssl.log").toFile()); + Process process = builder.start(); + process.getOutputStream().close(); + if (!process.waitFor(30, TimeUnit.SECONDS)) { + process.destroyForcibly().waitFor(); + throw new IllegalStateException("OpenSSL conversion timed out."); + } + if (process.exitValue() != 0) { + throw new IllegalArgumentException( + "OpenSSL could not convert the PEM files. Check the certificate, matching private key, and key password."); + } + KeyStore source = KeyStore.getInstance("PKCS12"); + try (var input = Files.newInputStream(converted)) { + source.load(input, secret); + } + var chain = source.getCertificateChain("solr-ssl"); + for (var cert : chain) { + ((X509Certificate) cert).checkValidity(); + } + KeyStore store = KeyStore.getInstance(type); + store.load(null, secret); + store.setKeyEntry("solr-ssl", source.getKey("solr-ssl", secret), secret, chain); + KeyStore trust = KeyStore.getInstance(type); + trust.load(null, secret); + Path defaultTrust = Path.of(EnvUtils.getProperty("java.home"), "lib/security/cacerts"); + KeyStore defaults = KeyStore.getInstance(defaultTrust.toFile(), (char[]) null); + var aliases = defaults.aliases(); + while (aliases.hasMoreElements()) { + String alias = aliases.nextElement(); + trust.setCertificateEntry("jdk-" + alias, defaults.getCertificate(alias)); + } + for (int i = 0; i < chain.length; i++) { + trust.setCertificateEntry("solr-" + i, chain[i]); + } + String original = Files.readString(include); + Map settings = new LinkedHashMap<>(); + settings.put("SOLR_SSL_ENABLED", "true"); + settings.put("SOLR_SSL_KEY_STORE", target.toString()); + settings.put("SOLR_SSL_KEY_STORE_PASSWORD", password); + settings.put("SOLR_SSL_KEY_STORE_TYPE", type); + settings.put("SOLR_SSL_TRUST_STORE", trustTarget.toString()); + settings.put("SOLR_SSL_TRUST_STORE_PASSWORD", password); + settings.put("SOLR_SSL_TRUST_STORE_TYPE", type); + settings.put("SOLR_SSL_NEED_CLIENT_AUTH", "false"); + settings.put("SOLR_SSL_WANT_CLIENT_AUTH", "false"); + settings.put("SOLR_SSL_CHECK_PEER_NAME", "true"); + Map files = new LinkedHashMap<>(); + files.put(target, serialize(store, secret)); + files.put(trustTarget, serialize(trust, secret)); + files.put( + include, + IncludeFileEditor.update( + original, settings, settings.keySet(), IncludeFileEditor.Format.SHELL) + .getBytes(StandardCharsets.UTF_8)); + Map previous = new LinkedHashMap<>(); + for (Path file : files.keySet()) { + previous.put(file, Files.exists(file) ? Files.readAllBytes(file) : null); + if (Files.exists(file)) { + echo("Backup: " + IncludeFileEditor.backup(file)); + } + } + try { + for (var file : files.entrySet()) { + IncludeFileEditor.writePrivate(file.getKey(), file.getValue()); + } + if (params.zkHost() != null) { + int slash = params.zkHost().indexOf('/'); + if (slash >= 0) { + try (SolrZkClient rootClient = + new SolrZkClient.Builder() + .withUrl(params.zkHost().substring(0, slash)) + .withConnTimeOut(3, TimeUnit.SECONDS) + .build()) { + if (!rootClient.exists(params.zkHost().substring(slash))) { + throw new IllegalArgumentException("The ZooKeeper chroot does not exist."); + } + } + } + try (SolrZkClient client = + new SolrZkClient.Builder() + .withUrl(params.zkHost()) + .withConnTimeOut(3, TimeUnit.SECONDS) + .build()) { + if (!client.exists("/")) { + throw new IllegalArgumentException("The ZooKeeper chroot does not exist."); + } + new ClusterProperties(client).setClusterProperty("urlScheme", "https"); + } + echo("Set cluster property urlScheme=https."); + } + } catch (Exception failure) { + for (var file : previous.entrySet()) { + try { + if (file.getValue() == null) Files.deleteIfExists(file.getKey()); + else IncludeFileEditor.writePrivate(file.getKey(), file.getValue()); + } catch (Exception rollback) { + failure.addSuppressed(rollback); + } + } + throw failure; + } + echo("Installed " + type + " keystore: " + target); + echo("Updated " + include + ". Restart Solr to enable HTTPS."); + if (params.zkHost() == null) { + echo( + "No cluster property changed. For SolrCloud, set urlScheme=https with bin/solr cluster before restarting nodes."); + } + } finally { + Arrays.fill(secret, '\0'); + try (var files = Files.list(temporary)) { + for (Path file : files.toList()) Files.deleteIfExists(file); + } + Files.deleteIfExists(temporary); + } + } + + private static String readPassword(Path path) throws Exception { + String value = Files.readString(path).replaceFirst("\\r?\\n$", ""); + if (value.length() < 6 + || value.indexOf('\n') >= 0 + || value.indexOf('\r') >= 0 + || value.indexOf('\0') >= 0) { + throw new IllegalArgumentException( + "Password files must contain one line of at least six characters."); + } + return value; + } + + private static byte[] serialize(KeyStore store, char[] password) throws Exception { + var output = new ByteArrayOutputStream(); + store.store(output, password); + return output.toByteArray(); + } +} diff --git a/solr/core/src/java/org/apache/solr/cli/SolrCLI.java b/solr/core/src/java/org/apache/solr/cli/SolrCLI.java index 555b30bf7c8d..cfa7b95372ea 100755 --- a/solr/core/src/java/org/apache/solr/cli/SolrCLI.java +++ b/solr/core/src/java/org/apache/solr/cli/SolrCLI.java @@ -79,6 +79,7 @@ VersionTool.class, ZkTool.class, AuthTool.class, + SSLTool.class, CreateTool.class, DeleteTool.class, AssertTool.class, @@ -371,6 +372,7 @@ private static Tool newTool(String toolType, ToolRuntime runtime) throws Excepti else if ("mkroot".equals(toolType)) return new ZkMkrootTool(runtime); else if ("assert".equals(toolType)) return new AssertTool(runtime); else if ("auth".equals(toolType)) return new AuthTool(runtime); + else if ("ssl".equals(toolType)) return new SSLTool(runtime); else if ("export".equals(toolType)) return new ExportTool(runtime); else if ("package".equals(toolType)) return new PackageTool(runtime); else if ("post".equals(toolType)) return new PostTool(runtime); diff --git a/solr/core/src/java/org/apache/solr/cli/StartCommand.java b/solr/core/src/java/org/apache/solr/cli/StartCommand.java index 5af9b497aa81..c73e5031e27b 100644 --- a/solr/core/src/java/org/apache/solr/cli/StartCommand.java +++ b/solr/core/src/java/org/apache/solr/cli/StartCommand.java @@ -93,7 +93,7 @@ public class StartCommand implements Callable { @CommandLine.Option( names = {"-e", "--example"}, - description = "Run an example: cloud, techproducts, schemaless, films") + description = "Run an example: cloud, techproducts, secure_solr, schemaless, films") String example; @CommandLine.Option( diff --git a/solr/core/src/test/org/apache/solr/cli/AuthToolTest.java b/solr/core/src/test/org/apache/solr/cli/AuthToolTest.java index b0e94f2ef8da..080382f59c16 100644 --- a/solr/core/src/test/org/apache/solr/cli/AuthToolTest.java +++ b/solr/core/src/test/org/apache/solr/cli/AuthToolTest.java @@ -21,6 +21,7 @@ import java.nio.file.Files; import java.nio.file.Path; import org.apache.commons.io.file.PathUtils; +import org.apache.lucene.util.Constants; import org.apache.solr.cloud.SolrCloudTestCase; import org.apache.solr.security.Sha256AuthenticationProvider; import org.junit.After; @@ -67,6 +68,8 @@ protected int runTool(String[] args, Class clazz) throws Exc @Test public void testEnableAuth() throws Exception { Path solrIncludeFile = Files.createFile(dir.resolve("solrIncludeFile.txt")); + String sslSetting = Constants.WINDOWS ? "set SOLR_SSL_ENABLED=true" : "SOLR_SSL_ENABLED=true"; + Files.writeString(solrIncludeFile, sslSetting + "\n"); String[] args = { "auth", "enable", @@ -82,6 +85,35 @@ public void testEnableAuth() throws Exception { "true" }; assertEquals(0, runTool(args, AuthTool.class)); + String enabled = Files.readString(solrIncludeFile); + assertTrue(enabled.contains(sslSetting)); + assertTrue( + enabled.contains( + Constants.WINDOWS ? "set SOLR_AUTH_TYPE=basic" : "SOLR_AUTH_TYPE='basic'")); + assertTrue(enabled.contains("-Dsolr.httpclient.config=" + dir.resolve("basicAuth.conf"))); + assertEquals( + "httpBasicAuthUser=solr\nhttpBasicAuthPassword=solrRocks", + Files.readString(dir.resolve("basicAuth.conf"))); + String[] disable = { + "auth", + "disable", + "-z", + cluster.getZkClient().getZkServerAddress(), + "--auth-conf-dir", + dir.toString(), + "--solr-include-file", + solrIncludeFile.toString() + }; + assertEquals(0, runTool(disable, AuthTool.class)); + String disabled = Files.readString(solrIncludeFile); + assertTrue(disabled.contains(sslSetting)); + assertTrue( + disabled.contains( + Constants.WINDOWS ? "REM set SOLR_AUTH_TYPE=basic" : "# SOLR_AUTH_TYPE='basic'")); + assertEquals( + "{}", + new String( + cluster.getZkClient().getData("/security.json", null, null), StandardCharsets.UTF_8)); } @Test diff --git a/solr/core/src/test/org/apache/solr/cli/IncludeFileEditorTest.java b/solr/core/src/test/org/apache/solr/cli/IncludeFileEditorTest.java new file mode 100644 index 000000000000..80e0c1c78b4d --- /dev/null +++ b/solr/core/src/test/org/apache/solr/cli/IncludeFileEditorTest.java @@ -0,0 +1,154 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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.apache.solr.cli; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermissions; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import org.apache.solr.SolrTestCase; +import org.apache.solr.common.util.EnvUtils; +import org.junit.Test; + +/** Tests the include-file editing shared by authentication and SSL tools. */ +public class IncludeFileEditorTest extends SolrTestCase { + @Test + public void testShellUpdatesAreIdempotent() { + String original = + "# Keep this comment\r\nexport SOLR_AUTH_TYPE=old\r\nSOLR_AUTH_TYPE=duplicate\r\nSOLR_SSL_ENABLED=true\r\n"; + var settings = Map.of("SOLR_AUTH_TYPE", "basic"); + String updated = + IncludeFileEditor.update( + original, settings, settings.keySet(), IncludeFileEditor.Format.SHELL); + assertEquals( + "# Keep this comment\r\nSOLR_SSL_ENABLED=true\r\nSOLR_AUTH_TYPE='basic'\r\n", updated); + assertEquals( + updated, + IncludeFileEditor.update( + updated, settings, settings.keySet(), IncludeFileEditor.Format.SHELL)); + } + + @Test + public void testWindowsAssignments() { + String original = + "REM Keep this comment\r\nset SOLR_AUTH_TYPE=old\r\nSET \"solr_auth_type=duplicate\"\r\nset SOLR_AUTHENTICATION_CLIENT_BUILDER=old\r\nset SOLR_SSL_ENABLED=true\r\n"; + var settings = new LinkedHashMap(); + settings.put("SOLR_AUTH_TYPE", "basic"); + settings.put( + "SOLR_AUTHENTICATION_OPTS", + "\"-Dsolr.httpclient.config=C:\\Program Files\\Solr\\basicAuth.conf\""); + var replaced = + Set.of("SOLR_AUTH_TYPE", "SOLR_AUTHENTICATION_OPTS", "SOLR_AUTHENTICATION_CLIENT_BUILDER"); + String updated = + IncludeFileEditor.update(original, settings, replaced, IncludeFileEditor.Format.WINDOWS); + assertEquals( + "REM Keep this comment\r\nset SOLR_SSL_ENABLED=true\r\nset SOLR_AUTH_TYPE=basic\r\nset SOLR_AUTHENTICATION_OPTS=\"-Dsolr.httpclient.config=C:\\Program Files\\Solr\\basicAuth.conf\"\r\n", + updated); + assertEquals( + updated, + IncludeFileEditor.update(updated, settings, replaced, IncludeFileEditor.Format.WINDOWS)); + } + + @Test + public void testDisablingSettings() { + for (var format : IncludeFileEditor.Format.values()) { + String assignment = + format == IncludeFileEditor.Format.SHELL + ? "export SOLR_AUTH_TYPE='basic'" + : "set \"SOLR_AUTH_TYPE=basic\""; + String original = assignment + "\r\n# unchanged\r\n"; + String updated = IncludeFileEditor.disable(original, Set.of("SOLR_AUTH_TYPE"), format); + String comment = format == IncludeFileEditor.Format.SHELL ? "# " : "REM "; + assertEquals(comment + assignment + "\r\n# unchanged\r\n", updated); + assertEquals(updated, IncludeFileEditor.disable(updated, Set.of("SOLR_AUTH_TYPE"), format)); + assertEquals( + "# untouched", + IncludeFileEditor.disable("# untouched", Set.of("SOLR_AUTH_TYPE"), format)); + } + } + + @Test + public void testShellQuoting() throws Exception { + assumeTrue( + "External programs require -Ptests.useSecurityManager=false", + EnvUtils.getProperty("java.security.manager") == null); + assumeFalse("Requires bash", EnvUtils.getProperty("os.name").startsWith("Windows")); + Path dir = createTempDir(); + Path include = dir.resolve("solr.in.sh"); + String value = "value' $HOME `false` $(false)"; + var settings = Map.of("SOLR_AUTHENTICATION_OPTS", value); + Files.writeString( + include, + IncludeFileEditor.update("", settings, settings.keySet(), IncludeFileEditor.Format.SHELL)); + ProcessBuilder builder = + new ProcessBuilder( + "bash", + "-c", + "source \"$1\"; test \"$SOLR_AUTHENTICATION_OPTS\" = \"$EXPECTED_SHARED_VALUE\"", + "test", + include.toString()); + builder.environment().put("EXPECTED_SHARED_VALUE", value); + Process shell = builder.start(); + try { + assertTrue(shell.waitFor(10, TimeUnit.SECONDS)); + assertEquals(0, shell.exitValue()); + } finally { + if (shell.isAlive()) shell.destroyForcibly().waitFor(); + } + } + + @Test + public void testBackupsAndPermissions() throws Exception { + Path dir = createTempDir(); + Path include = dir.resolve("solr.in.sh"); + Files.writeString(include, "original"); + Path backup = IncludeFileEditor.save(include, "updated"); + assertEquals("original", Files.readString(backup)); + assertEquals("updated", Files.readString(include)); + Path secondBackup = IncludeFileEditor.save(include, "newer"); + assertNotEquals(backup, secondBackup); + assertEquals("updated", Files.readString(secondBackup)); + if (include.getFileSystem().supportedFileAttributeViews().contains("posix")) { + assertEquals( + PosixFilePermissions.fromString("rw-------"), Files.getPosixFilePermissions(include)); + assertEquals( + PosixFilePermissions.fromString("rw-------"), Files.getPosixFilePermissions(backup)); + } + } + + @Test + public void testSymlinkIsPreserved() throws Exception { + assumeTrue( + "Symbolic links require -Ptests.useSecurityManager=false", + EnvUtils.getProperty("java.security.manager") == null); + Path dir = createTempDir(); + assumeTrue( + "Requires POSIX filesystem", + dir.getFileSystem().supportedFileAttributeViews().contains("posix")); + Path target = dir.resolve("actual.in.sh"); + Files.writeString(target, "original"); + Path link = Files.createSymbolicLink(dir.resolve("solr.in.sh"), target); + Path backup = IncludeFileEditor.save(link, "updated"); + assertTrue(Files.isSymbolicLink(link)); + assertEquals("updated", Files.readString(target)); + assertEquals("original", Files.readString(backup)); + } +} diff --git a/solr/core/src/test/org/apache/solr/cli/SSLToolTest.java b/solr/core/src/test/org/apache/solr/cli/SSLToolTest.java new file mode 100644 index 000000000000..fd5e03f90f0b --- /dev/null +++ b/solr/core/src/test/org/apache/solr/cli/SSLToolTest.java @@ -0,0 +1,184 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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.apache.solr.cli; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyStore; +import java.util.concurrent.TimeUnit; +import org.apache.solr.SolrTestCase; +import org.apache.solr.cloud.ZkTestServer; +import org.apache.solr.common.cloud.ClusterProperties; +import org.apache.solr.common.cloud.SolrZkClient; +import org.apache.solr.common.util.EnvUtils; +import org.junit.Test; + +/** Tests certificate conversion and local HTTPS configuration. */ +public class SSLToolTest extends SolrTestCase { + private Path prepare() throws Exception { + assumeTrue( + "External programs require -Ptests.useSecurityManager=false", + EnvUtils.getProperty("java.security.manager") == null); + assumeFalse( + "solr.in.sh requires POSIX file permissions", + EnvUtils.getProperty("os.name").startsWith("Windows")); + Path dir = createTempDir(); + Files.writeString( + dir.resolve("openssl.cnf"), + "[req]\ndistinguished_name=dn\n[dn]\n[v3]\nsubjectAltName=DNS:localhost,IP:127.0.0.1\n"); + try { + Process process = + new ProcessBuilder( + "openssl", + "req", + "-x509", + "-newkey", + "rsa:2048", + "-nodes", + "-keyout", + dir.resolve("key.pem").toString(), + "-out", + dir.resolve("cert.pem").toString(), + "-days", + "2", + "-subj", + "/CN=localhost", + "-config", + dir.resolve("openssl.cnf").toString(), + "-extensions", + "v3") + .redirectErrorStream(true) + .redirectOutput(dir.resolve("openssl.log").toFile()) + .start(); + if (!process.waitFor(30, TimeUnit.SECONDS)) { + process.destroyForcibly().waitFor(); + fail("Test certificate generation timed out"); + } + assertEquals(Files.readString(dir.resolve("openssl.log")), 0, process.exitValue()); + } catch (IOException missingOpenSSL) { + assumeNoException("OpenSSL must be installed", missingOpenSSL); + } + Files.writeString(dir.resolve("password.txt"), "Test'$(false)Password\n"); + Files.writeString( + dir.resolve("solr.in.sh"), + "# Keep this comment\nSOLR_HEAP=512m\nexport SOLR_SSL_ENABLED=false\nSOLR_SSL_KEY_STORE=old\n"); + return dir; + } + + private SSLTool.SSLParams params(Path dir, String type, String zkHost) { + return new SSLTool.SSLParams( + dir.resolve("cert.pem"), + dir.resolve("key.pem"), + dir.resolve("password.txt"), + null, + dir.resolve("store"), + type, + dir.resolve("solr.in.sh"), + zkHost); + } + + @Test + public void testBothParsersAndFormats() throws Exception { + for (String type : new String[] {"JKS", "PKCS12"}) { + Path dir = prepare(); + var runtime = new CLITestHelper.TestingRuntime(true); + String[] args = { + "--cert", + dir.resolve("cert.pem").toString(), + "--key", + dir.resolve("key.pem").toString(), + "--password-file", + dir.resolve("password.txt").toString(), + "--keystore", + dir.resolve("store").toString(), + "--solr-include-file", + dir.resolve("solr.in.sh").toString(), + "--store-type", + type + }; + SSLTool tool = new SSLTool(runtime); + if (type.equals("JKS")) { + assertEquals(0, tool.runTool(SolrCLI.processCommandLineArgs(tool, args))); + } else { + assertEquals(0, new picocli.CommandLine(tool).execute(args)); + } + char[] password = "Test'$(false)Password".toCharArray(); + KeyStore store = KeyStore.getInstance(type); + try (var input = Files.newInputStream(dir.resolve("store"))) { + store.load(input, password); + } + assertNotNull(store.getKey("solr-ssl", password)); + assertEquals("RSA", store.getKey("solr-ssl", password).getAlgorithm()); + KeyStore trust = KeyStore.getInstance(type); + try (var input = Files.newInputStream(dir.resolve("store.truststore"))) { + trust.load(input, password); + } + assertTrue(trust.size() > 1); + assertEquals(store.getCertificate("solr-ssl"), trust.getCertificate("solr-0")); + assertFalse(runtime.getOutput().contains(new String(password))); + String include = Files.readString(dir.resolve("solr.in.sh")); + assertTrue(include.contains("SOLR_SSL_ENABLED='true'")); + assertTrue(include.contains("SOLR_SSL_KEY_STORE_TYPE='" + type + "'")); + } + } + + @Test + public void testInvalidKeyLeavesConfigurationUntouched() throws Exception { + Path dir = prepare(); + String original = Files.readString(dir.resolve("solr.in.sh")); + Files.writeString(dir.resolve("key.pem"), "not a private key"); + expectThrows( + IllegalArgumentException.class, () -> new SSLTool().configure(params(dir, "JKS", null))); + assertEquals(original, Files.readString(dir.resolve("solr.in.sh"))); + assertFalse(Files.exists(dir.resolve("store"))); + } + + @Test + public void testClusterPropertyAndRollback() throws Exception { + Path dir = prepare(); + ZkTestServer server = new ZkTestServer(createTempDir()); + server.run(); + try { + new SSLTool().configure(params(dir, "JKS", server.getZkAddress())); + try (SolrZkClient client = + new SolrZkClient.Builder().withUrl(server.getZkAddress()).build()) { + var properties = new ClusterProperties(client).getClusterProperties(); + assertEquals("https", properties.get("urlScheme")); + } + byte[] originalStore = Files.readAllBytes(dir.resolve("store")); + String originalInclude = Files.readString(dir.resolve("solr.in.sh")); + expectThrows( + IllegalArgumentException.class, + () -> new SSLTool().configure(params(dir, "JKS", server.getZkAddress() + "/missing"))); + assertArrayEquals(originalStore, Files.readAllBytes(dir.resolve("store"))); + assertEquals(originalInclude, Files.readString(dir.resolve("solr.in.sh"))); + } finally { + server.shutdown(); + } + } + + @Test + public void testWindowsUnsupportedMessage() { + System.setProperty("os.name", "Windows 11"); + var error = + expectThrows(UnsupportedOperationException.class, () -> new SSLTool().configure(null)); + assertTrue(error.getMessage().contains("not supported on Windows")); + assertTrue(error.getMessage().contains("Configure HTTPS manually")); + } +} diff --git a/solr/core/src/test/org/apache/solr/cli/TestSolrCLIRunExample.java b/solr/core/src/test/org/apache/solr/cli/TestSolrCLIRunExample.java index b9957e993cc1..5fc5efa5a236 100644 --- a/solr/core/src/test/org/apache/solr/cli/TestSolrCLIRunExample.java +++ b/solr/core/src/test/org/apache/solr/cli/TestSolrCLIRunExample.java @@ -37,6 +37,7 @@ import org.apache.commons.exec.ExecuteResultHandler; import org.apache.lucene.tests.util.LuceneTestCase; import org.apache.solr.SolrTestCaseJ4; +import org.apache.solr.client.solrj.RemoteSolrException; import org.apache.solr.client.solrj.SolrClient; import org.apache.solr.client.solrj.impl.CloudSolrClient; import org.apache.solr.client.solrj.jetty.HttpJettySolrClient; @@ -304,6 +305,11 @@ public void testTechproductsExample() throws Exception { testExample("techproducts"); } + @Test + public void testSecureSolrExample() throws Exception { + testExample("secure_solr"); + } + @Test public void testSchemalessExample() throws Exception { testExample("schemaless"); @@ -316,7 +322,8 @@ public void testFilmsExample() throws Exception { protected void testExample(String exampleName) throws Exception { // Occasionally we want to test in User Managed mode, not the default SolrCloud mode. - String testStandaloneMode = LuceneTestCase.rarely() ? "--user-managed" : ""; + String testStandaloneMode = + !"secure_solr".equals(exampleName) && LuceneTestCase.rarely() ? "--user-managed" : ""; Path defaultSolrHomeDir = ExternalPaths.SERVER_HOME; if (!Files.isDirectory(defaultSolrHomeDir)) { fail(defaultSolrHomeDir + " not found and is required to run this test!"); @@ -344,6 +351,7 @@ protected void testExample(String exampleName) throws Exception { testSolrHomeDir.toString(), "-p", String.valueOf(bindPort), + "secure_solr".equals(exampleName) && pass == 1 ? "" : "--no-prompt", testStandaloneMode }; @@ -353,7 +361,13 @@ protected void testExample(String exampleName) throws Exception { RunExampleExecutor executor = new RunExampleExecutor(); closeables.add(executor); - RunExampleTool tool = new RunExampleTool(executor, System.in, runtime); + RunExampleTool tool = + new RunExampleTool( + executor, + "secure_solr".equals(exampleName) + ? new ByteArrayInputStream("\n\n\n\n\n\n\n\n".getBytes(StandardCharsets.UTF_8)) + : System.in, + runtime); try { int status = runTool(tool, toolArgs); @@ -380,10 +394,12 @@ protected void testExample(String exampleName) throws Exception { // dump all the output written by the SolrCLI commands to stdout // System.out.println("\n\n"+toolOutput+"\n\n"); - if ("techproducts".equals(exampleName)) { + if ("techproducts".equals(exampleName) || "secure_solr".equals(exampleName)) { try (SolrClient solrClient = new HttpJettySolrClient.Builder("http://localhost:" + bindPort + "/solr") - .withDefaultCollection(exampleName) + .withDefaultCollection("techproducts") + .withOptionalBasicAuthCredentials( + "secure_solr".equals(exampleName) ? "solr:SolrRocks" : null) .build()) { SolrQuery query = new SolrQuery("*:*"); QueryResponse qr = solrClient.query(query); @@ -407,6 +423,26 @@ protected void testExample(String exampleName) throws Exception { } } + if ("secure_solr".equals(exampleName)) { + String url = "http://localhost:" + bindPort + "/solr"; + try (SolrClient anonymous = CLIUtils.getSolrClient(url, null); + SolrClient search = CLIUtils.getSolrClient(url, "search:SolrRocks")) { + assertEquals( + 401, + expectThrows( + RemoteSolrException.class, + () -> anonymous.query("techproducts", new SolrQuery("*:*"))) + .code()); + assertEquals( + 31, search.query("techproducts", new SolrQuery("*:*")).getResults().getNumFound()); + assertEquals( + 403, + expectThrows( + RemoteSolrException.class, () -> search.deleteByQuery("techproducts", "*:*")) + .code()); + } + } + // stop the test instance executor.execute(CommandLine.parse("bin/solr stop -p " + bindPort)); } diff --git a/solr/packaging/test/test_ssl.bats b/solr/packaging/test/test_ssl.bats index cf92d144d4f9..7d4a6cc58cf0 100644 --- a/solr/packaging/test/test_ssl.bats +++ b/solr/packaging/test/test_ssl.bats @@ -30,29 +30,40 @@ teardown() { } -@test "start solr with ssl" { - # Create a keystore +configure_ssl() { + run solr zk ls / --zk-host "localhost:${ZK_PORT}" + assert_output --partial 'live_nodes' export ssl_dir="${BATS_TEST_TMPDIR}/ssl" mkdir -p "$ssl_dir" - ( - cd "$ssl_dir" - rm -f solr-ssl.keystore.p12 solr-ssl.pem - keytool -genkeypair -alias solr-ssl -keyalg RSA -keysize 2048 -keypass secret -storepass secret -validity 9999 -keystore solr-ssl.keystore.p12 -storetype PKCS12 -ext SAN=DNS:localhost,IP:127.0.0.1 -dname "CN=localhost, OU=Organizational Unit, O=Organization, L=Location, ST=State, C=Country" - openssl pkcs12 -in solr-ssl.keystore.p12 -out solr-ssl.pem -passin pass:secret -passout pass:secret - ) + printf '%s\n' '[req]' 'distinguished_name=dn' '[dn]' '[v3]' \ + 'subjectAltName=DNS:localhost,IP:127.0.0.1' > "$ssl_dir/openssl.cnf" + run openssl req -x509 -newkey rsa:2048 -nodes \ + -keyout "$ssl_dir/key.pem" -out "$ssl_dir/cert.pem" \ + -days 2 -subj '/CN=localhost' -config "$ssl_dir/openssl.cnf" -extensions v3 + refute_output --partial 'Error' + assert_success + printf '%s\n' secret > "$ssl_dir/password.txt" + chmod 600 "$ssl_dir/password.txt" + + run solr ssl --cert "$ssl_dir/cert.pem" --key "$ssl_dir/key.pem" \ + --password-file "$ssl_dir/password.txt" --keystore "$ssl_dir/solr.jks" \ + --zk-host "localhost:${ZK_PORT}" + assert_output --partial 'Installed JKS keystore' + assert_output --partial 'Set cluster property urlScheme=https' + assert_success +} - # Set ENV_VARs so that Solr uses this keystore - export SOLR_SSL_ENABLED=true - export SOLR_SSL_KEY_STORE=$ssl_dir/solr-ssl.keystore.p12 - export SOLR_SSL_KEY_STORE_PASSWORD=secret - export SOLR_SSL_TRUST_STORE=$ssl_dir/solr-ssl.keystore.p12 - export SOLR_SSL_TRUST_STORE_PASSWORD=secret - export SOLR_SSL_NEED_CLIENT_AUTH=false - export SOLR_SSL_WANT_CLIENT_AUTH=false +@test "start solr with ssl" { + export SOLR_INCLUDE="${BATS_TEST_TMPDIR}/solr.in.sh" + touch "$SOLR_INCLUDE" export SOLR_HOST_ADVERTISE=localhost - - solr start - solr assert --started https://localhost:${SOLR_PORT} --timeout 5000 + run solr start + assert_output --partial 'Started Solr server' + configure_ssl + run solr stop --all + assert_output --partial 'Sending stop command' + run solr start + assert_output --partial 'Started Solr server' run solr create -c test --shards 2 assert_output --partial "Created collection 'test'" @@ -60,10 +71,11 @@ teardown() { run solr api --solr-url "https://localhost:${SOLR_PORT}/solr/test/select?q=*:*" assert_output --partial '"numFound":0' - run curl --cacert "$ssl_dir/solr-ssl.pem" "https://localhost:${SOLR_PORT}/solr/test/select?q=*:*" + run curl --cacert "$ssl_dir/cert.pem" "https://localhost:${SOLR_PORT}/solr/test/select?q=*:*" assert_output --partial '"numFound":0' - run ! curl "https://localhost:${SOLR_PORT}/solr/test/select?q=*:*" + run ! curl --silent --show-error --fail "https://localhost:${SOLR_PORT}/solr/test/select?q=*:*" + assert_output --regexp 'SSL certificate problem|certificate verify failed|self-signed certificate' } @test "use different hostname when not checking peer-name" { @@ -130,40 +142,27 @@ teardown() { } @test "start solr with ssl and auth" { - # Create a keystore - export ssl_dir="${BATS_TEST_TMPDIR}/ssl" - mkdir -p "$ssl_dir" - ( - cd "$ssl_dir" - rm -f solr-ssl.keystore.p12 solr-ssl.pem - keytool -genkeypair -alias solr-ssl -keyalg RSA -keysize 2048 -keypass secret -storepass secret -validity 9999 -keystore solr-ssl.keystore.p12 -storetype PKCS12 -ext SAN=DNS:localhost,IP:127.0.0.1 -dname "CN=localhost, OU=Organizational Unit, O=Organization, L=Location, ST=State, C=Country" - openssl pkcs12 -in solr-ssl.keystore.p12 -out solr-ssl.pem -passin pass:secret -passout pass:secret - ) - - # Set ENV_VARs so that Solr uses this keystore - export SOLR_SSL_ENABLED=true - export SOLR_SSL_KEY_STORE=$ssl_dir/solr-ssl.keystore.p12 - export SOLR_SSL_KEY_STORE_PASSWORD=secret - export SOLR_SSL_TRUST_STORE=$ssl_dir/solr-ssl.keystore.p12 - export SOLR_SSL_TRUST_STORE_PASSWORD=secret - export SOLR_SSL_NEED_CLIENT_AUTH=false - export SOLR_SSL_WANT_CLIENT_AUTH=false - export SOLR_SSL_CHECK_PEER_NAME=true + export SOLR_INCLUDE="${BATS_TEST_TMPDIR}/solr.in.sh" + touch "$SOLR_INCLUDE" export SOLR_HOST_ADVERTISE=localhost + run solr start + assert_output --partial 'Started Solr server' + run solr auth enable --type basicAuth --credentials name:password + assert_output --partial 'Successfully enabled basic auth' + configure_ssl + run solr stop --all + assert_output --partial 'Sending stop command' + run solr start + assert_output --partial 'Started Solr server' - solr start - solr assert --started https://localhost:${SOLR_PORT} --timeout 5000 - solr auth enable --type basicAuth --credentials name:password - - run curl -u name:password --basic --cacert "$ssl_dir/solr-ssl.pem" "https://localhost:${SOLR_PORT}/solr/admin/collections?action=CREATE&collection.configName=_default&name=test&numShards=2&replicationFactor=1&router.name=compositeId&wt=json" + run curl -u name:password --basic --cacert "$ssl_dir/cert.pem" "https://localhost:${SOLR_PORT}/solr/admin/collections?action=CREATE&collection.configName=_default&name=test&numShards=2&replicationFactor=1&router.name=compositeId&wt=json" assert_output --partial '"status":0' - run curl -u name:password --basic --http2 --cacert "$ssl_dir/solr-ssl.pem" "https://localhost:${SOLR_PORT}/solr/test/select?q=*:*" + run curl -u name:password --basic --http2 --cacert "$ssl_dir/cert.pem" "https://localhost:${SOLR_PORT}/solr/test/select?q=*:*" assert_output --partial '"numFound":0' - # When the Jenkins box "curl" supports --fail-with-body, add "--fail-with-body" and change "run" to "run !", to expect a failure - run curl --http2 --cacert "$ssl_dir/solr-ssl.pem" "https://localhost:${SOLR_PORT}/solr/test/select?q=*:*" - assert_output --partial 'Error 401 Authentication' + run ! curl --silent --show-error --fail --cacert "$ssl_dir/cert.pem" "https://localhost:${SOLR_PORT}/solr/test/select?q=*:*" + assert_output --partial '401' } @test "start solr with client truststore and security manager" { diff --git a/solr/solr-ref-guide/modules/deployment-guide/deployment-nav.adoc b/solr/solr-ref-guide/modules/deployment-guide/deployment-nav.adoc index b001d404ba33..b2dece8ab0ff 100644 --- a/solr/solr-ref-guide/modules/deployment-guide/deployment-nav.adoc +++ b/solr/solr-ref-guide/modules/deployment-guide/deployment-nav.adoc @@ -47,6 +47,7 @@ **** xref:cli/solr-snapshot-describe.adoc[describe] **** xref:cli/solr-snapshot-export.adoc[export] **** xref:cli/solr-snapshot-list.adoc[list] +*** xref:cli/solr-ssl.adoc[ssl] *** xref:cli/solr-start.adoc[start] *** xref:cli/solr-status.adoc[status] *** xref:cli/solr-stop.adoc[stop] diff --git a/solr/solr-ref-guide/modules/deployment-guide/pages/cli/index.adoc b/solr/solr-ref-guide/modules/deployment-guide/pages/cli/index.adoc index 318010391d56..18270f9a6a14 100644 --- a/solr/solr-ref-guide/modules/deployment-guide/pages/cli/index.adoc +++ b/solr/solr-ref-guide/modules/deployment-guide/pages/cli/index.adoc @@ -86,6 +86,7 @@ Any command not listed here is unavailable when the picocli implementation is en |xref:cli/solr-snapshot-export.adoc[bin/solr snapshot export] |xref:cli/solr-snapshot-list.adoc[bin/solr snapshot list] |xref:cli/solr-snapshot.adoc[bin/solr snapshot] +|xref:cli/solr-ssl.adoc[bin/solr ssl] |xref:cli/solr-start.adoc[bin/solr start] |xref:cli/solr-status.adoc[bin/solr status] |xref:cli/solr-stop.adoc[bin/solr stop] diff --git a/solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-ssl.adoc b/solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-ssl.adoc new file mode 100644 index 000000000000..9d6fc76b3c0d --- /dev/null +++ b/solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-ssl.adoc @@ -0,0 +1,92 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you 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. +// +// DO NOT EDIT -- this page is auto-generated from picocli annotations. +// To update: modify the @Command/@Option annotations in the Java source, then run: +// ./gradlew :solr:solr-ref-guide:generateCliDocs + += bin/solr ssl +:page-toclevels: 2 + +[WARNING] +==== +This page documents the *experimental* picocli implementation of `bin/solr`, which is not enabled by default. +For what `bin/solr` does by default, see xref:solr-control-script-reference.adoc[]. +See xref:cli/index.adoc[] to enable this implementation and for its known limitations. +==== + +// tag::picocli-generated-man-section-synopsis[] +== Synopsis + +.... +bin/solr ssl [-hv] --cert= --key= + [--key-password-file=] [--keystore=] + --password-file= [--solr-include-file=] + [--store-type=] [--zk-host=] +.... + +// end::picocli-generated-man-section-synopsis[] + +// tag::picocli-generated-man-section-description[] +== Description + +Convert PEM files to a Java keystore and configure HTTPS in solr.in.sh. + +// end::picocli-generated-man-section-description[] + +// tag::picocli-generated-man-section-options[] +== Options + +*--cert*=__:: + *(required)* PEM certificate chain, with the server certificate first. + +*-h*, *--help*:: + Print this help message and exit. + +*--key*=__:: + *(required)* PEM private key. Requires OpenSSL on PATH. + +*--key-password-file*=__:: + Password file for an encrypted PEM private key. + +*--keystore*=__:: + Destination keystore. Default directory: server/etc in the Solr installation. + +*--password-file*=__:: + *(required)* File containing the keystore password on one line. + +*--solr-include-file*=__:: + Existing solr.in.sh to update. Defaults to bin/solr.in.sh in the Solr installation. + +*--store-type*=__:: + Keystore format: JKS or PKCS12. Default: JKS. + +*-v*, *--verbose*:: + Enable verbose mode. + +*--zk-host*=__:: + ZooKeeper connection string. If supplied, set urlScheme=https for the entire cluster. + +// end::picocli-generated-man-section-options[] + +// tag::picocli-generated-man-section-footer[] +== Examples +[%hardbreaks] + bin/solr ssl --cert fullchain.pem --key privkey.pem --password-file password.txt + bin/solr ssl --cert fullchain.pem --key privkey.pem --password-file password.txt --zk-host localhost:9983 + +// end::picocli-generated-man-section-footer[] diff --git a/solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-start.adoc b/solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-start.adoc index a6ba0f61c2bb..ffd5f5e603d8 100644 --- a/solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-start.adoc +++ b/solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-start.adoc @@ -56,7 +56,7 @@ Starts Solr Set solr.data.home system property for index data storage; default is solr.solr.home *-e*, *--example*=__:: - Run an example: cloud, techproducts, schemaless, films + Run an example: cloud, techproducts, secure_solr, schemaless, films *--example-dir*=__:: Override the directory containing example configurations used when running examples with --example diff --git a/solr/solr-ref-guide/modules/deployment-guide/pages/enabling-ssl.adoc b/solr/solr-ref-guide/modules/deployment-guide/pages/enabling-ssl.adoc index 27b02b71415e..746a16b24c96 100644 --- a/solr/solr-ref-guide/modules/deployment-guide/pages/enabling-ssl.adoc +++ b/solr/solr-ref-guide/modules/deployment-guide/pages/enabling-ssl.adoc @@ -19,12 +19,64 @@ Solr can encrypt communications to and from clients and between nodes in SolrCloud mode with Secure Sockets Layer encryption (SSL). -This section describes enabling SSL using a self-signed certificate. +This section describes installing an existing PEM certificate with `bin/solr ssl` and configuring SSL manually with a self-signed certificate. +The command is available on Unix and macOS; use the manual steps on Windows or when you already have a Java keystore. For background on SSL certificates and keys, see http://www.tldp.org/HOWTO/SSL-Certificates-HOWTO/. == Configuring Solr for SSL +=== Install an Existing PEM Certificate + +On Unix and macOS, `bin/solr ssl` converts a PEM certificate and private key to a Java keystore, installs it, and updates `solr.in.sh`. +It requires OpenSSL on your `PATH`. +The certificate must cover the hostnames used by clients and other Solr nodes. +For a certificate issued by Let's Encrypt, use `fullchain.pem` and `privkey.pem` from your ACME client. +Obtaining and renewing certificates is a separate step. + +Save a keystore password of at least six characters in a file readable only by the user running Solr. +The file must contain a single line; a final newline is optional. + +[source,bash] +---- +chmod 600 /path/to/keystore-password.txt +bin/solr ssl --cert /path/to/fullchain.pem \ + --key /path/to/privkey.pem \ + --password-file /path/to/keystore-password.txt \ + --zk-host localhost:9983 +---- + +This installs `server/etc/solr-ssl.keystore.jks` and a separate truststore, relative to the Solr installation. +The truststore contains the running JDK's trusted certificates and the supplied certificate chain. +The tool configures HTTPS with hostname verification enabled and client certificate authentication disabled. +It sets `urlScheme=https` in ZooKeeper when `--zk-host` is supplied. +Omit that option for a user-managed node. +If you omit it for SolrCloud, set the property separately with `bin/solr cluster --property urlScheme --value https --zk-host ` before restarting Solr. + +The shell script supplies the include file selected by `SOLR_INCLUDE`. +Use `--solr-include-file /path/to/solr.in.sh` to select another file, such as `/var/solr/solr.in.sh` for a service installation. +Use `--keystore /path/to/solr-ssl.jks` to select the destination; its parent directory must exist and its path must not contain whitespace. +For PKCS12, supply `--store-type PKCS12`; the default destination then ends in `.p12`. +For an encrypted PEM key, supply `--key-password-file /path/to/key-password.txt`. + +Existing destination files and the include file are backed up to unique `.bak` files alongside the originals. +The tool prints the backup paths and restricts the keystore, truststore, include file, and backups to their owner's access (`0600`). +The include file contains the keystore password, so run the tool as the Solr service user and protect the password file and backups. +A failure to update ZooKeeper restores the local files; inspect the cluster property before retrying after a connection failure. + +Restart Solr to use the new configuration. +For SolrCloud, changing `urlScheme` affects every node: prepare certificates and configuration on every node and coordinate the switch to HTTPS across the cluster. +The tool does not restart nodes or update advertised hostnames. +Run it again after certificate renewal to install the renewed files. +Rebuilding the truststore also refreshes its copy of the JDK's trusted certificates. + +For all options, see xref:cli/solr-ssl.adoc[]. + +== Configure SSL Manually + +If you used `bin/solr ssl`, skip these manual keystore and include-file steps and continue with <>. +The following example creates a PKCS12 keystore directly, so it does not need conversion with `bin/solr ssl`. + === Generate a Self-Signed Certificate and a Key To generate a self-signed certificate and a single key that will be used to authenticate both the server and the client, we'll use the JDK https://docs.oracle.com/javase/8/docs/technotes/tools/unix/keytool.html[`keytool`] command and create a separate keystore. @@ -48,20 +100,24 @@ The above command will create a keystore file named `solr-ssl.keystore.p12` in t === Convert the Certificate and Key to PEM Format for Use with curl -Convert the PKCS12 format keystore, including both the certificate and the key, into PEM format using the http://www.openssl.org[`openssl`] command: +For curl to trust the self-signed server certificate, export a certificate-only PEM file: [source,bash] ---- -openssl pkcs12 -in solr-ssl.keystore.p12 -out solr-ssl.pem +openssl pkcs12 -nokeys -in solr-ssl.keystore.p12 -out solr-ssl.cacert.pem ---- -If you want to use curl on OS X Yosemite (10.10), you'll need to create a certificate-only version of the PEM format, as follows: +If you also enable client certificate authentication, export the certificate and private key to a separate PEM file for curl's `-E` option: [source,bash] ---- -openssl pkcs12 -nokeys -in solr-ssl.keystore.p12 -out solr-ssl.cacert.pem +openssl pkcs12 -in solr-ssl.keystore.p12 -out solr-ssl.pem ---- +If you used `bin/solr ssl`, your original PEM certificate files remain available for clients. +For publicly trusted certificates, curl normally uses the operating system's trusted certificates without `--cacert`. +For a private CA or a self-signed certificate, supply its certificate with `--cacert`. + === Set Common SSL-Related Environment Variables The Solr Control Script is already setup to pass SSL-related environment variables. @@ -132,6 +188,20 @@ They are mutually exclusive and Jetty will select one of them which may not be w When you start Solr, the `bin/solr` script includes these settings and will pass them as system properties to the JVM. +=== Set the SolrCloud URL Scheme + +For SolrCloud, set the cluster property before starting nodes with HTTPS: + +[source,bash] +---- +bin/solr cluster --property urlScheme --value https --zk-host server1:2181,server2:2181,server3:2181 +---- + +On Windows, use `bin\solr.cmd cluster` with the same options. +ZooKeeper must be running, and its connection string must include any chroot used by the cluster. +This property affects every node; prepare each node's SSL configuration and coordinate the switch across the cluster. +Skip this step if `bin/solr ssl --zk-host ...` already set the property, or if you are using a user-managed node. + == Starting Solr After Enabling SSL === Start SolrCloud @@ -159,7 +229,7 @@ Windows:: ==== [source,powershell] ---- -bin\solr.cmd --solr-home cloud\node1 -z server1:2181,server2:2181,server3:2181 +bin\solr.cmd start --solr-home cloud\node1 -z server1:2181,server2:2181,server3:2181 -p 8984 ---- ==== @@ -177,7 +247,7 @@ Customize the values for the parameters shown as needed and add any used in your ==== [source,bash] ---- -bin/solr start -p 8984 +bin/solr start --user-managed -p 8984 ---- ==== @@ -186,7 +256,7 @@ Windows:: ==== [source,powershell] ---- -bin\solr.cmd -p 8984 +bin\solr.cmd start --user-managed -p 8984 ---- ==== ====== @@ -255,11 +325,12 @@ The `create` action will pass the `SOLR_SSL_*` properties set in your include fi === Retrieve SolrCloud Cluster Status using curl -To get the resulting cluster status (again, if you have not enabled client authentication, remove the `-E solr-ssl.pem:secret` option): +To get the resulting cluster status with the self-signed certificate from the manual example: +If you enabled client certificate authentication, also supply `-E solr-ssl.pem:secret`. [source,bash] ---- -curl -E solr-ssl.pem:secret --cacert solr-ssl.pem "https://localhost:8984/solr/admin/collections?action=CLUSTERSTATUS&indent=on" +curl --cacert solr-ssl.cacert.pem "https://localhost:8984/solr/admin/collections?action=CLUSTERSTATUS&indent=on" ---- You should get a response that looks like this: @@ -308,12 +379,14 @@ bin/solr post --solr-url https://localhost:8984 --name mycollection example/exam === Query Using curl -Use curl to query the SolrCloud collection created above, from a directory containing the PEM formatted certificate and key created above (e.g., `example/etc/`). -If you have not enabled client authentication (system property `-Djetty.ssl.clientAuth=true)`, then you can remove the `-E solr-ssl.pem:secret` option: +Use curl to query the collection, from the directory containing the certificate-only PEM file created in the manual example. +For a publicly trusted certificate installed with `bin/solr ssl`, use the hostname in that certificate and omit `--cacert`. +If Basic Authentication is enabled, also supply `--user username:password`. +The `-E` option is needed only when client certificate authentication is enabled. [source,bash] ---- -curl -E solr-ssl.pem:secret --cacert solr-ssl.pem "https://localhost:8984/solr/mycollection/select?q=*:*" +curl --cacert solr-ssl.cacert.pem "https://localhost:8984/solr/mycollection/select?q=*:*" ---- === Index a Document using CloudSolrClient diff --git a/solr/solr-ref-guide/modules/deployment-guide/pages/solr-control-script-reference.adoc b/solr/solr-ref-guide/modules/deployment-guide/pages/solr-control-script-reference.adoc index de678a1c1207..530637468520 100644 --- a/solr/solr-ref-guide/modules/deployment-guide/pages/solr-control-script-reference.adoc +++ b/solr/solr-ref-guide/modules/deployment-guide/pages/solr-control-script-reference.adoc @@ -265,6 +265,7 @@ The available options are: * `cloud`: SolrCloud example * `techproducts`: Comprehensive example illustrating many of Solr's core capabilities +* `secure_solr`: Single-node SolrCloud with Basic Authentication, Rule-Based Authorization, and techproducts data * `schemaless`: Schema-less example (schema is inferred from data during indexing) * `films`: Example of starting with _default configset and adding explicit fields dynamically + @@ -394,6 +395,28 @@ The film data used can be found in `$SOLR_HOME/example/films/films.json`. The run in-foreground option (`-f`) is not compatible with the `-e` option since the script needs to perform additional tasks after starting the Solr server. ==== +===== Secure Solr Example + +[source,bash] +---- +bin/solr start -e secure_solr +---- + +This example starts one SolrCloud node on port 8983 with embedded ZooKeeper. +It uses Basic Authentication and Rule-Based Authorization, creates a `techproducts` collection with one shard and one replica, and loads the sample data using `sample_techproducts_configs`. +The Solr home is `example/secure_solr` unless overridden. + +Press Enter at the prompts to create administrator `solr`, indexing user `index`, and search-only user `search`, each with password `SolrRocks`. +Passwords typed at an interactive terminal are hidden. +Use `--no-prompt` to accept all defaults. +These are demonstration credentials; change them before using the example outside your local environment. + +Security is installed in ZooKeeper after startup and before collection creation or indexing. +Existing security settings are preserved on subsequent starts; enter credentials for an existing administrator. +Open `http://localhost:8983/solr/` and sign in as the administrator. +The indexing user can search and update documents; the search-only user can search but cannot update documents. + + === Stop The `stop` command sends a STOP request to a running Solr node, which allows it to shutdown gracefully. @@ -1075,6 +1098,11 @@ Defines the location of `solr.solr.home`, which by default is `server/solr`. If you have multiple instances of Solr on the same host, or if you have customized the `$SOLR_HOME` directory path, you likely need to define this. This is where any credentials or authentication configuration files (e.g. basicAuth.conf) would be placed. +The `auth` and `ssl` commands back up existing files before replacing them and print the backup paths. +Both update the include file; `auth` also writes `basicAuth.conf`, while `ssl` installs a keystore and truststore. +On Unix and macOS, these files and their backups are restricted to owner access (`0600`). +Run these commands as the Solr service user, and protect the backups because they may contain credentials. + === Disabling Basic Authentication You can disable Basic Authentication with `bin/solr auth disable`. diff --git a/solr/solr-ref-guide/modules/getting-started/getting-started-nav.adoc b/solr/solr-ref-guide/modules/getting-started/getting-started-nav.adoc index 562ba636558e..8c5fbe292d65 100644 --- a/solr/solr-ref-guide/modules/getting-started/getting-started-nav.adoc +++ b/solr/solr-ref-guide/modules/getting-started/getting-started-nav.adoc @@ -34,6 +34,7 @@ ** xref:tutorial-paramsets.adoc[] ** xref:tutorial-vectors.adoc[] ** xref:tutorial-solrcloud.adoc[] +** xref:tutorial-securing-solr.adoc[] ** xref:tutorial-opennlp.adoc[] ** xref:tutorial-aws.adoc[] diff --git a/solr/solr-ref-guide/modules/getting-started/pages/tutorial-securing-solr.adoc b/solr/solr-ref-guide/modules/getting-started/pages/tutorial-securing-solr.adoc new file mode 100644 index 000000000000..0b5d3cf3f396 --- /dev/null +++ b/solr/solr-ref-guide/modules/getting-started/pages/tutorial-securing-solr.adoc @@ -0,0 +1,302 @@ += Exercise 8: Securing Solr +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you 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. + +This tutorial introduces authentication and authorization using a single-node SolrCloud example. +You will start Solr with Basic Authentication and Rule-Based Authorization, sign in to the Admin UI, try requests with different permissions, and enable HTTPS with `bin/solr ssl`. +The example loads the same techproducts data used in xref:tutorial-techproducts.adoc[]. + +Authentication identifies the user making a request. +Authorization determines what that user may do. +A correct password does not grant access to every API: the user's roles must also allow the requested operation. + +== Before You Begin + +Run the commands from your Solr installation directory. +This tutorial assumes you are familiar with the basics in xref:solr-tutorial.adoc[]. +The examples use `curl` and OpenSSL from a Unix or macOS terminal. + +[WARNING] +==== +This is a local learning environment with demonstration passwords. +It starts with HTTP and adds HTTPS later in the exercise. +Basic Authentication does not encrypt credentials; use TLS for connections outside this local example. +A production deployment also needs network restrictions and a secured ZooKeeper ensemble. +See xref:deployment-guide:securing-solr.adoc[] for deployment guidance. +==== + +== Start the Secure Example + +[source,bash] +---- +bin/solr start -e secure_solr +---- + +The example starts one SolrCloud node on port 8983 with embedded ZooKeeper. +It creates a `techproducts` collection with one shard and one replica, using `sample_techproducts_configs`, and indexes 31 sample documents. + +=== Choose Users and Passwords + +The interactive session asks for an administrator, then offers an indexing user and a search-only user: + +[source,text] +---- +Administrator username [solr]: +Administrator password [SolrRocks]: +Create a index user? [Y]: +Username [index]: +Password [SolrRocks]: +Create a search user? [Y]: +Username [search]: +Password [SolrRocks]: +---- + +Press Enter at each prompt to accept the defaults used throughout this tutorial. +You can enter your own usernames and passwords instead; substitute those credentials in later commands. +Passwords typed at an interactive terminal are hidden. +Enter `N` at either optional-user prompt to skip that account. + +[%autowidth,options="header"] +|=== +|Username |Default password |Access +|`solr` |`SolrRocks` |Security, collection, and configuration administration, indexing, and searching +|`index` |`SolrRocks` |Indexing and searching +|`search` |`SolrRocks` |Searching +|=== + +For a non-interactive start with all three default users: + +[source,bash] +---- +bin/solr start -e secure_solr --no-prompt +---- + +=== Where Security Is Stored + +The example uses `example/secure_solr` as its Solr home. +It starts the node, writes the security configuration to `/security.json` in ZooKeeper, and waits for authentication to become active before creating the collection or indexing data. + +The configuration uses these plugins: + +[source,json] +---- +{ + "authentication": { + "class": "solr.BasicAuthPlugin", + "blockUnknown": true + }, + "authorization": { + "class": "solr.RuleBasedAuthorizationPlugin" + } +} +---- + +This excerpt only shows the plugin settings. +The complete configuration also contains salted password hashes, user-to-role mappings, and permission rules. +The administrator has the `superadmin`, `admin`, `index`, and `search` roles. +The indexing user has `index` and `search`; the search-only user has `search`. + +In SolrCloud, security settings are shared through ZooKeeper. +Editing a local `security.json` file in the Solr home does not change the cluster configuration. + +== Sign In to the Admin UI + +Open http://localhost:8983/solr/[the Solr Admin UI] in your browser. +Sign in with username `solr` and password `SolrRocks`, unless you chose different administrator credentials. + +Select the `techproducts` collection and open its Query screen. +Run the default `\*:*` query to see the sample documents. +The Security screen lets the administrator inspect and manage users, roles, and permissions. + +== Try Authentication and Authorization + +=== Query Without Credentials + +[source,bash] +---- +curl -i 'http://localhost:8983/solr/techproducts/select?q=*:*&rows=0' +---- + +The request returns HTTP `401 Unauthorized` because it has no credentials. +The `blockUnknown` setting requires authentication. +An incorrect password also produces a 401 response. + +=== Query as the Search User + +[source,bash] +---- +curl --user search:SolrRocks 'http://localhost:8983/solr/techproducts/select?q=*:*&rows=0' +---- + +This request succeeds. +The response's `numFound` value is `31` on a fresh example. +The search user's role grants the `read` permission. + +=== Try an Update as the Search User + +[source,bash] +---- +curl -i --user search:SolrRocks \ + -H 'Content-Type: application/json' \ + --data-binary '[{"id":"secure-example-product","name":"Secure example product"}]' \ + 'http://localhost:8983/solr/techproducts/update?commit=true' +---- + +This request returns HTTP `403 Forbidden`. +The password is valid, but the user's roles do not grant the `update` permission. +The document is not added. + +=== Index as the Indexing User + +Send the same update using the indexing account: + +[source,bash] +---- +curl --user index:SolrRocks \ + -H 'Content-Type: application/json' \ + --data-binary '[{"id":"secure-example-product","name":"Secure example product"}]' \ + 'http://localhost:8983/solr/techproducts/update?commit=true' +---- + +This request succeeds because the indexing user's `index` role grants `update`. +Query the new document with the search account: + +[source,bash] +---- +curl --user search:SolrRocks \ + 'http://localhost:8983/solr/techproducts/select?q=id:secure-example-product' +---- + +== Create Another Search User + +Creating an account and assigning its permissions are separate steps. +As the administrator, create a user named `reader`: + +[source,bash] +---- +curl --user solr:SolrRocks \ + -H 'Content-Type: application/json' \ + --data-binary '{"set-user":{"reader":"ReaderRocks"}}' \ + 'http://localhost:8983/solr/admin/authentication' +---- + +Assign the existing `search` role to that user: + +[source,bash] +---- +curl --user solr:SolrRocks \ + -H 'Content-Type: application/json' \ + --data-binary '{"set-user-role":{"reader":["search"]}}' \ + 'http://localhost:8983/solr/admin/authorization' +---- + +The node reloads security changes asynchronously. +Once the new configuration has loaded, query with the new credentials: + +[source,bash] +---- +curl --user reader:ReaderRocks 'http://localhost:8983/solr/techproducts/select?q=*:*&rows=0' +---- + +For details on changing passwords and permission rules, see xref:deployment-guide:basic-authentication-plugin.adoc[] and xref:deployment-guide:rule-based-authorization-plugin.adoc[]. + +== Stop and Restart + +[source,bash] +---- +bin/solr stop -p 8983 +---- + +Run `bin/solr start -e secure_solr` again to restart the example. +Existing security settings are preserved, and an existing collection is not reindexed. +Enter the credentials of an existing administrator; the prompts do not reset passwords or replace the saved configuration. +If you changed the administrator password, `--no-prompt` will still try the original demonstration credentials and will not authenticate. + +== Enable HTTPS with bin/solr ssl + +Keep the example running for this step so its embedded ZooKeeper is available on port 9983. +We will create a self-signed certificate for `localhost` and install it with `bin/solr ssl`. + +=== Create a Local Certificate + +[source,bash] +---- +mkdir -p example/secure_solr/tls +openssl req -x509 -newkey rsa:2048 -nodes \ + -keyout example/secure_solr/tls/key.pem \ + -out example/secure_solr/tls/cert.pem \ + -days 30 -subj '/CN=localhost' \ + -addext 'subjectAltName=DNS:localhost,IP:127.0.0.1' +chmod 600 example/secure_solr/tls/key.pem +---- + +This certificate is for the local exercise. +For a certificate issued by your CA or Let's Encrypt, use its certificate chain and private key instead. +The certificate must cover the hostname used by the Admin UI, CLI, and other clients. + +Save a password to protect the keystore: + +[source,bash] +---- +(umask 077; printf '%s\n' 'SolrRocks' > example/secure_solr/tls/keystore-password.txt) +---- + +This is a keystore password, separate from the passwords of Solr users. +Use a different password outside this local exercise. + +=== Install the Certificate and Restart + +[source,bash] +---- +bin/solr ssl --cert example/secure_solr/tls/cert.pem \ + --key example/secure_solr/tls/key.pem \ + --password-file example/secure_solr/tls/keystore-password.txt \ + --zk-host localhost:9983 +bin/solr stop -p 8983 +bin/solr start -e secure_solr +---- + +The tool installs a JKS keystore and truststore, backs up and updates `solr.in.sh`, and sets `urlScheme=https` in ZooKeeper. +It leaves your Basic Authentication users and authorization rules in place. +Accept the same administrator credentials when restarting the example. + +=== Verify HTTPS + +[source,bash] +---- +curl --cacert example/secure_solr/tls/cert.pem --user search:SolrRocks \ + 'https://localhost:8983/solr/techproducts/select?q=*:*&rows=0' +---- + +The request succeeds with certificate verification enabled. +The `--cacert` option tells curl to trust the self-signed certificate. +Repeat the earlier requests using `https://` and this option to verify that authentication and authorization still apply. + +Open https://localhost:8983/solr/[the HTTPS Admin UI]. +Your browser will warn about the self-signed certificate; it is not trusted automatically. +A certificate from a publicly trusted CA avoids this warning when you use the hostname in that certificate. + +HTTPS settings apply to subsequent Solr starts that use this include file. +To return the local example to HTTP, keep embedded ZooKeeper running and first set `urlScheme=http` using `bin/solr cluster --property urlScheme --value http --zk-host localhost:9983`. +Stop Solr while the HTTPS settings are still active, then restore the original include-file backup printed by `bin/solr ssl` before starting Solr again. + +See xref:deployment-guide:enabling-ssl.adoc#install-an-existing-pem-certificate[Install an Existing PEM Certificate] for service installations, certificate renewal, and other keystore formats. + +== Next Steps + +You have used separate accounts to demonstrate the difference between authentication and authorization. +For a deployed system, continue with xref:deployment-guide:securing-solr.adoc[], xref:deployment-guide:enabling-ssl.adoc[], and xref:deployment-guide:zookeeper-access-control.adoc[].