Enable aliases for SSH commands

Site administrators can alias SSH commands from a plugin into the
gerrit namespace using the ssh-alias section in gerrit.config:

  [ssh-alias]
    ls = gerrit ls-projects
    replicate = replication start

The aliases are configured statically at server startup, but are
resolved dynamically at invocation time to the currently loaded
version of the plugin. If the plugin is not loaded, or does not
define the command, "not found" is returned to the user.

Change-Id: Ie98edbef6c8f2fcb3960e7f06329f5dee670ac1e
This commit is contained in:
Shawn O. Pearce
2012-05-11 14:57:56 -07:00
parent ba99c03981
commit 521380a28d
10 changed files with 243 additions and 25 deletions

View File

@@ -1920,6 +1920,17 @@ If true the deprecated `/query` URL is available to return JSON
and text results for changes. If false, the URL is disabled and
returns 404 to clients. Default is true, enabling `/query`.
[[ssh-alias]] Section ssh-alias
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Variables in section ssh-alias permit the site administrator to alias
another command from Gerrit or a plugin into the `gerrit` command
namespace. To alias `replication start` to `gerrit replicate`:
----
[ssh-alias]
replicate = replication start
----
[[sshd]] Section sshd
~~~~~~~~~~~~~~~~~~~~~

View File

@@ -245,7 +245,7 @@ public class Daemon extends SiteProgram {
private Injector createSshInjector() {
final List<Module> modules = new ArrayList<Module>();
if (sshd) {
modules.add(new SshModule());
modules.add(sysInjector.getInstance(SshModule.class));
if (slave) {
modules.add(new SlaveCommandModule());
} else {

View File

@@ -0,0 +1,124 @@
// Copyright (C) 2012 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.google.gerrit.sshd;
import com.google.common.collect.Lists;
import com.google.common.util.concurrent.Atomics;
import com.google.gerrit.server.CurrentUser;
import com.google.gerrit.server.account.CapabilityControl;
import com.google.inject.Provider;
import org.apache.sshd.server.Command;
import org.apache.sshd.server.Environment;
import java.io.IOException;
import java.util.LinkedList;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
/** Command that executes some other command. */
public class AliasCommand extends BaseCommand {
private final DispatchCommandProvider root;
private final Provider<CurrentUser> currentUser;
private final CommandName command;
private final AtomicReference<Command> atomicCmd;
AliasCommand(@CommandName(Commands.ROOT) DispatchCommandProvider root,
Provider<CurrentUser> currentUser, CommandName command) {
this.root = root;
this.currentUser = currentUser;
this.command = command;
this.atomicCmd = Atomics.newReference();
}
@Override
public void start(Environment env) throws IOException {
try {
begin(env);
} catch (UnloggedFailure e) {
String msg = e.getMessage();
if (!msg.endsWith("\n")) {
msg += "\n";
}
err.write(msg.getBytes(ENC));
err.flush();
onExit(e.exitCode);
}
}
private void begin(Environment env) throws UnloggedFailure, IOException {
Map<String, Provider<Command>> map = root.getMap();
for (String name : chain(command)) {
Provider<? extends Command> p = map.get(name);
if (p == null) {
throw new UnloggedFailure(1, getName() + ": not found");
}
Command cmd = p.get();
if (!(cmd instanceof DispatchCommand)) {
throw new UnloggedFailure(1, getName() + ": not found");
}
map = ((DispatchCommand) cmd).getMap();
}
Provider<? extends Command> p = map.get(command.value());
if (p == null) {
throw new UnloggedFailure(1, getName() + ": not found");
}
Command cmd = p.get();
checkRequiresCapability(cmd);
if (cmd instanceof BaseCommand) {
BaseCommand bc = (BaseCommand)cmd;
bc.setName(getName());
bc.setArguments(getArguments());
}
provideStateTo(cmd);
atomicCmd.set(cmd);
cmd.start(env);
}
@Override
public void destroy() {
Command cmd = atomicCmd.getAndSet(null);
if (cmd != null) {
cmd.destroy();
}
}
private void checkRequiresCapability(Command cmd) throws UnloggedFailure {
RequiresCapability rc = cmd.getClass().getAnnotation(RequiresCapability.class);
if (rc != null) {
CurrentUser user = currentUser.get();
CapabilityControl ctl = user.getCapabilities();
if (!ctl.canPerform(rc.value()) && !ctl.canAdministrateServer()) {
String msg = String.format(
"fatal: %s does not have \"%s\" capability.",
user.getUserName(), rc.value());
throw new UnloggedFailure(BaseCommand.STATUS_NOT_ADMIN, msg);
}
}
}
private static LinkedList<String> chain(CommandName command) {
LinkedList<String> chain = Lists.newLinkedList();
while (command != null) {
chain.addFirst(command.value());
command = Commands.parentOf(command);
}
chain.removeLast();
return chain;
}
}

View File

@@ -0,0 +1,42 @@
// Copyright (C) 2012 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.google.gerrit.sshd;
import com.google.gerrit.server.CurrentUser;
import com.google.inject.Inject;
import com.google.inject.Provider;
import org.apache.sshd.server.Command;
/** Resolves an alias to another command. */
public class AliasCommandProvider implements Provider<Command> {
private final CommandName command;
@Inject
@CommandName(Commands.ROOT)
private DispatchCommandProvider root;
@Inject
private Provider<CurrentUser> currentUser;
public AliasCommandProvider(CommandName command) {
this.command = command;
}
@Override
public Command get() {
return new AliasCommand(root, currentUser, command);
}
}

View File

@@ -117,10 +117,18 @@ public abstract class BaseCommand implements Command {
this.exit = callback;
}
String getName() {
return commandName;
}
void setName(final String prefix) {
this.commandName = prefix;
}
String[] getArguments() {
return argv;
}
public void setArguments(final String[] argv) {
this.argv = argv;
}
@@ -280,7 +288,9 @@ public abstract class BaseCommand implements Command {
*/
protected void onExit(final int rc) {
exit.onExit(rc);
cleanup.run();
if (cleanup != null) {
cleanup.run();
}
}
/** Wrap the supplied output stream in a UTF-8 encoded PrintWriter. */

View File

@@ -97,6 +97,13 @@ public class Commands {
return false;
}
static CommandName parentOf(CommandName name) {
if (name instanceof NestedCommandNameImpl) {
return ((NestedCommandNameImpl) name).parent;
}
return null;
}
private static final class NestedCommandNameImpl implements CommandName {
private final CommandName parent;
private final String name;

View File

@@ -38,11 +38,10 @@ import java.util.concurrent.atomic.AtomicReference;
*/
final class DispatchCommand extends BaseCommand {
interface Factory {
DispatchCommand create(String prefix, Map<String, Provider<Command>> map);
DispatchCommand create(Map<String, Provider<Command>> map);
}
private final Provider<CurrentUser> currentUser;
private final String prefix;
private final Map<String, Provider<Command>> commands;
private final AtomicReference<Command> atomicCmd;
@@ -53,14 +52,17 @@ final class DispatchCommand extends BaseCommand {
private List<String> args = new ArrayList<String>();
@Inject
DispatchCommand(final Provider<CurrentUser> cu, @Assisted final String pfx,
DispatchCommand(final Provider<CurrentUser> cu,
@Assisted final Map<String, Provider<Command>> all) {
currentUser = cu;
prefix = pfx;
commands = all;
atomicCmd = Atomics.newReference();
}
Map<String, Provider<Command>> getMap() {
return commands;
}
@Override
public void start(final Environment env) throws IOException {
try {
@@ -69,7 +71,7 @@ final class DispatchCommand extends BaseCommand {
final Provider<Command> p = commands.get(commandName);
if (p == null) {
String msg =
(prefix.isEmpty() ? "Gerrit Code Review" : prefix) + ": "
(getName().isEmpty() ? "Gerrit Code Review" : getName()) + ": "
+ commandName + ": not found";
throw new UnloggedFailure(1, msg);
}
@@ -78,10 +80,10 @@ final class DispatchCommand extends BaseCommand {
checkRequiresCapability(cmd);
if (cmd instanceof BaseCommand) {
final BaseCommand bc = (BaseCommand) cmd;
if (prefix.isEmpty())
if (getName().isEmpty())
bc.setName(commandName);
else
bc.setName(prefix + " " + commandName);
bc.setName(getName() + " " + commandName);
bc.setArguments(args.toArray(new String[args.size()]));
} else if (!args.isEmpty()) {
@@ -129,9 +131,9 @@ final class DispatchCommand extends BaseCommand {
protected String usage() {
final StringBuilder usage = new StringBuilder();
usage.append("Available commands");
if (!prefix.isEmpty()) {
if (!getName().isEmpty()) {
usage.append(" of ");
usage.append(prefix);
usage.append(getName());
}
usage.append(" are:\n");
usage.append("\n");
@@ -143,8 +145,8 @@ final class DispatchCommand extends BaseCommand {
usage.append("\n");
usage.append("See '");
if (prefix.indexOf(' ') < 0) {
usage.append(prefix);
if (getName().indexOf(' ') < 0) {
usage.append(getName());
usage.append(' ');
}
usage.append("COMMAND --help' for more information.\n");

View File

@@ -38,24 +38,16 @@ public class DispatchCommandProvider implements Provider<DispatchCommand> {
@Inject
private DispatchCommand.Factory factory;
private final String dispatcherName;
private final CommandName parent;
private volatile ConcurrentMap<String, Provider<Command>> map;
public DispatchCommandProvider(final CommandName cn) {
this(Commands.nameOf(cn), cn);
}
public DispatchCommandProvider(final String dispatcherName,
final CommandName cn) {
this.dispatcherName = dispatcherName;
this.parent = cn;
}
@Override
public DispatchCommand get() {
return factory.create(dispatcherName, getMap());
return factory.create(getMap());
}
public RegistrationHandle register(final CommandName name,
@@ -84,7 +76,7 @@ public class DispatchCommandProvider implements Provider<DispatchCommand> {
};
}
private ConcurrentMap<String, Provider<Command>> getMap() {
ConcurrentMap<String, Provider<Command>> getMap() {
if (map == null) {
synchronized (this) {
if (map == null) {

View File

@@ -16,6 +16,7 @@ package com.google.gerrit.sshd;
import static com.google.inject.Scopes.SINGLETON;
import com.google.common.collect.Maps;
import com.google.gerrit.lifecycle.LifecycleModule;
import com.google.gerrit.reviewdb.client.Account;
import com.google.gerrit.reviewdb.client.AccountGroup;
@@ -29,6 +30,7 @@ import com.google.gerrit.server.account.AccountManager;
import com.google.gerrit.server.account.ChangeUserName;
import com.google.gerrit.server.config.FactoryModule;
import com.google.gerrit.server.config.GerritRequestModule;
import com.google.gerrit.server.config.GerritServerConfig;
import com.google.gerrit.server.git.QueueProvider;
import com.google.gerrit.server.git.WorkQueue;
import com.google.gerrit.server.plugins.ModuleGenerator;
@@ -49,19 +51,32 @@ import com.google.gerrit.sshd.commands.DefaultCommandModule;
import com.google.gerrit.sshd.commands.QueryShell;
import com.google.gerrit.util.cli.CmdLineParser;
import com.google.gerrit.util.cli.OptionHandlerUtil;
import com.google.inject.Inject;
import com.google.inject.internal.UniqueAnnotations;
import com.google.inject.servlet.RequestScoped;
import org.apache.sshd.common.KeyPairProvider;
import org.apache.sshd.server.CommandFactory;
import org.apache.sshd.server.PublickeyAuthenticator;
import org.eclipse.jgit.lib.Config;
import org.eclipse.jgit.lib.ObjectId;
import org.kohsuke.args4j.spi.OptionHandler;
import java.net.SocketAddress;
import java.util.Map;
/** Configures standard dependencies for {@link SshDaemon}. */
public class SshModule extends FactoryModule {
private final Map<String, String> aliases;
@Inject
SshModule(@GerritServerConfig Config cfg) {
aliases = Maps.newHashMap();
for (String name : cfg.getNames("ssh-alias")) {
aliases.put(name, cfg.getString("ssh-alias", null, name));
}
}
@Override
protected void configure() {
bindScope(RequestScoped.class, SshScope.REQUEST);
@@ -69,6 +84,7 @@ public class SshModule extends FactoryModule {
configureRequestScope();
configureCmdLineParser();
configureAliases();
install(SshKeyCacheImpl.module());
bind(SshLog.class);
@@ -78,7 +94,7 @@ public class SshModule extends FactoryModule {
factory(PeerDaemonUser.Factory.class);
bind(DispatchCommandProvider.class).annotatedWith(Commands.CMD_ROOT)
.toInstance(new DispatchCommandProvider("", Commands.CMD_ROOT));
.toInstance(new DispatchCommandProvider(Commands.CMD_ROOT));
bind(CommandFactoryProvider.class);
bind(CommandFactory.class).toProvider(CommandFactoryProvider.class);
bind(WorkQueue.Executor.class).annotatedWith(StreamCommandExecutor.class)
@@ -111,6 +127,20 @@ public class SshModule extends FactoryModule {
});
}
private void configureAliases() {
CommandName gerrit = Commands.named("gerrit");
for (Map.Entry<String, String> e : aliases.entrySet()) {
String name = e.getKey();
String[] dest = e.getValue().split("[ \\t]+");
CommandName cmd = Commands.named(dest[0]);
for (int i = 1; i < dest.length; i++) {
cmd = Commands.named(cmd, dest[i]);
}
bind(Commands.key(gerrit, name))
.toProvider(new AliasCommandProvider(cmd));
}
}
private void configureRequestScope() {
bind(SshScope.Context.class).toProvider(SshScope.ContextProvider.class);

View File

@@ -218,7 +218,7 @@ public class WebAppInitializer extends GuiceServletContextListener {
private Injector createSshInjector() {
final List<Module> modules = new ArrayList<Module>();
modules.add(new SshModule());
modules.add(sysInjector.getInstance(SshModule.class));
modules.add(new MasterCommandModule());
return sysInjector.createChildInjector(modules);
}