Line data Source code
1 : // Copyright (C) 2009 The Android Open Source Project
2 : //
3 : // Licensed under the Apache License, Version 2.0 (the "License");
4 : // you may not use this file except in compliance with the License.
5 : // You may obtain a copy of the License at
6 : //
7 : // http://www.apache.org/licenses/LICENSE-2.0
8 : //
9 : // Unless required by applicable law or agreed to in writing, software
10 : // distributed under the License is distributed on an "AS IS" BASIS,
11 : // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 : // See the License for the specific language governing permissions and
13 : // limitations under the License.
14 :
15 : package com.google.gerrit.sshd;
16 :
17 : import static java.nio.charset.StandardCharsets.UTF_8;
18 :
19 : import com.google.common.base.Joiner;
20 : import com.google.common.flogger.FluentLogger;
21 : import com.google.common.util.concurrent.Atomics;
22 : import com.google.gerrit.common.Nullable;
23 : import com.google.gerrit.entities.Project;
24 : import com.google.gerrit.extensions.annotations.PluginName;
25 : import com.google.gerrit.extensions.registration.DynamicMap;
26 : import com.google.gerrit.extensions.restapi.AuthException;
27 : import com.google.gerrit.server.AccessPath;
28 : import com.google.gerrit.server.CurrentUser;
29 : import com.google.gerrit.server.DynamicOptions;
30 : import com.google.gerrit.server.IdentifiedUser;
31 : import com.google.gerrit.server.RequestCleanup;
32 : import com.google.gerrit.server.git.ProjectRunnable;
33 : import com.google.gerrit.server.git.WorkQueue.CancelableRunnable;
34 : import com.google.gerrit.server.permissions.GlobalPermission;
35 : import com.google.gerrit.server.permissions.PermissionBackend;
36 : import com.google.gerrit.server.permissions.PermissionBackendException;
37 : import com.google.gerrit.server.project.NoSuchChangeException;
38 : import com.google.gerrit.server.project.NoSuchProjectException;
39 : import com.google.gerrit.sshd.SshScope.Context;
40 : import com.google.gerrit.util.cli.CmdLineParser;
41 : import com.google.gerrit.util.cli.EndOfOptionsHandler;
42 : import com.google.inject.Inject;
43 : import com.google.inject.Injector;
44 : import java.io.BufferedWriter;
45 : import java.io.IOException;
46 : import java.io.InputStream;
47 : import java.io.InterruptedIOException;
48 : import java.io.OutputStream;
49 : import java.io.OutputStreamWriter;
50 : import java.io.PrintWriter;
51 : import java.io.StringWriter;
52 : import java.nio.charset.Charset;
53 : import java.util.Arrays;
54 : import java.util.concurrent.Future;
55 : import java.util.concurrent.ScheduledThreadPoolExecutor;
56 : import java.util.concurrent.atomic.AtomicReference;
57 : import org.apache.sshd.common.SshException;
58 : import org.apache.sshd.server.Environment;
59 : import org.apache.sshd.server.ExitCallback;
60 : import org.apache.sshd.server.channel.ChannelSession;
61 : import org.apache.sshd.server.command.Command;
62 : import org.kohsuke.args4j.Argument;
63 : import org.kohsuke.args4j.CmdLineException;
64 : import org.kohsuke.args4j.Option;
65 :
66 : public abstract class BaseCommand implements Command {
67 18 : private static final FluentLogger logger = FluentLogger.forEnclosingClass();
68 :
69 18 : public static final Charset ENC = UTF_8;
70 :
71 : private static final int PRIVATE_STATUS = 1 << 30;
72 : static final int STATUS_CANCEL = PRIVATE_STATUS | 1;
73 : static final int STATUS_NOT_FOUND = PRIVATE_STATUS | 2;
74 : public static final int STATUS_NOT_ADMIN = PRIVATE_STATUS | 3;
75 :
76 : @SuppressWarnings("unused") // unused here, but triggers logic in EndOfOptionsHandler
77 : @Option(name = "--", usage = "end of options", handler = EndOfOptionsHandler.class)
78 : private boolean endOfOptions;
79 :
80 : protected InputStream in;
81 : protected OutputStream out;
82 : protected OutputStream err;
83 :
84 : protected ExitCallback exit;
85 :
86 : @Inject protected CurrentUser user;
87 :
88 : @Inject private SshScope sshScope;
89 :
90 : @Inject private CmdLineParser.Factory cmdLineParserFactory;
91 :
92 : @Inject protected RequestCleanup cleanup;
93 :
94 : @Inject @CommandExecutor private ScheduledThreadPoolExecutor executor;
95 :
96 : @Inject private PermissionBackend permissionBackend;
97 :
98 : @Inject private SshScope.Context context;
99 :
100 : /** Commands declared by a plugin can be scoped by the plugin name. */
101 : @Inject(optional = true)
102 : @PluginName
103 : private String pluginName;
104 :
105 : @Inject protected Injector injector;
106 :
107 : @Inject protected DynamicMap<DynamicOptions.DynamicBean> dynamicBeans;
108 :
109 : /** The task, as scheduled on a worker thread. */
110 : private final AtomicReference<Future<?>> task;
111 :
112 : /** Text of the command line which lead up to invoking this instance. */
113 10 : private String commandName = "";
114 :
115 : /** Unparsed command line options. */
116 : private String[] argv;
117 :
118 : /** trimmed command line arguments. */
119 : private String[] trimmedArgv;
120 :
121 10 : public BaseCommand() {
122 10 : task = Atomics.newReference();
123 10 : }
124 :
125 : @Override
126 : public void setInputStream(InputStream in) {
127 9 : this.in = in;
128 9 : }
129 :
130 : @Override
131 : public void setOutputStream(OutputStream out) {
132 9 : this.out = out;
133 9 : }
134 :
135 : @Override
136 : public void setErrorStream(OutputStream err) {
137 9 : this.err = err;
138 9 : }
139 :
140 : @Override
141 : public void setExitCallback(ExitCallback callback) {
142 9 : this.exit = callback;
143 9 : }
144 :
145 : @Nullable
146 : protected String getPluginName() {
147 9 : return pluginName;
148 : }
149 :
150 : protected String getName() {
151 9 : return commandName;
152 : }
153 :
154 : void setName(String prefix) {
155 9 : this.commandName = prefix;
156 9 : }
157 :
158 : public String[] getArguments() {
159 9 : return argv;
160 : }
161 :
162 : public void setArguments(String[] argv) {
163 9 : this.argv = argv;
164 9 : }
165 :
166 : /**
167 : * Trim the argument if it is spanning multiple lines.
168 : *
169 : * @return the arguments where all the multiple-line fields are trimmed.
170 : */
171 : protected String[] getTrimmedArguments() {
172 9 : if (trimmedArgv == null && argv != null) {
173 9 : trimmedArgv = new String[argv.length];
174 9 : for (int i = 0; i < argv.length; i++) {
175 8 : String arg = argv[i];
176 8 : int indexOfMultiLine = arg.indexOf("\n");
177 8 : if (indexOfMultiLine > -1) {
178 0 : arg = arg.substring(0, indexOfMultiLine) + " [trimmed]";
179 : }
180 8 : trimmedArgv[i] = arg;
181 : }
182 : }
183 9 : return trimmedArgv;
184 : }
185 :
186 : @Override
187 : public void destroy(ChannelSession channel) {
188 9 : Future<?> future = task.getAndSet(null);
189 9 : if (future != null && !future.isDone()) {
190 7 : future.cancel(true);
191 : }
192 9 : }
193 :
194 : /**
195 : * Pass all state into the command, then run its start method.
196 : *
197 : * <p>This method copies all critical state, like the input and output streams, into the supplied
198 : * command. The caller must still invoke {@code cmd.start()} if wants to pass control to the
199 : * command.
200 : *
201 : * @param cmd the command that will receive the current state.
202 : */
203 : protected void provideStateTo(Command cmd) {
204 9 : cmd.setInputStream(in);
205 9 : cmd.setOutputStream(out);
206 9 : cmd.setErrorStream(err);
207 9 : cmd.setExitCallback(exit);
208 9 : }
209 :
210 : /**
211 : * Parses the command line argument, injecting parsed values into fields.
212 : *
213 : * <p>This method must be explicitly invoked to cause a parse.
214 : *
215 : * @param pluginOptions which helps to define and parse options provided from plugins
216 : * @throws UnloggedFailure if the command line arguments were invalid.
217 : * @see Option
218 : * @see Argument
219 : */
220 : protected void parseCommandLine(DynamicOptions pluginOptions) throws UnloggedFailure {
221 9 : parseCommandLine(this, pluginOptions);
222 9 : }
223 :
224 : /**
225 : * Parses the command line argument, injecting parsed values into fields.
226 : *
227 : * <p>This method must be explicitly invoked to cause a parse.
228 : *
229 : * @param options object whose fields declare Option and Argument annotations to describe the
230 : * parameters of the command. Usually {@code this}.
231 : * @param pluginOptions which helps to define and parse options provided from plugins
232 : * @throws UnloggedFailure if the command line arguments were invalid.
233 : * @see Option
234 : * @see Argument
235 : */
236 : protected void parseCommandLine(Object options, DynamicOptions pluginOptions)
237 : throws UnloggedFailure {
238 9 : final CmdLineParser clp = newCmdLineParser(options);
239 9 : pluginOptions.setBean(options);
240 9 : pluginOptions.startLifecycleListeners();
241 9 : pluginOptions.parseDynamicBeans(clp);
242 9 : pluginOptions.setDynamicBeans();
243 9 : pluginOptions.onBeanParseStart();
244 : try {
245 9 : clp.parseArgument(argv);
246 1 : } catch (IllegalArgumentException | CmdLineException err) {
247 1 : if (!clp.wasHelpRequestedByOption()) {
248 1 : throw new UnloggedFailure(1, "fatal: " + err.getMessage());
249 : }
250 9 : }
251 :
252 9 : if (clp.wasHelpRequestedByOption()) {
253 1 : StringWriter msg = new StringWriter();
254 1 : clp.printDetailedUsage(commandName, msg);
255 1 : msg.write(usage());
256 1 : throw new UnloggedFailure(1, msg.toString());
257 : }
258 9 : pluginOptions.onBeanParseEnd();
259 9 : }
260 :
261 : protected String usage() {
262 1 : return "";
263 : }
264 :
265 : /** Construct a new parser for this command's received command line. */
266 : protected CmdLineParser newCmdLineParser(Object options) {
267 9 : return cmdLineParserFactory.create(options);
268 : }
269 :
270 : /**
271 : * Spawn a function into its own thread.
272 : *
273 : * <p>Typically this should be invoked within {@link Command#start(ChannelSession, Environment)},
274 : * such as:
275 : *
276 : * <pre>
277 : * startThread(new CommandRunnable() {
278 : * public void run() throws Exception {
279 : * runImp();
280 : * }
281 : * },
282 : * accessPath);
283 : * </pre>
284 : *
285 : * <p>If the function throws an exception, it is translated to a simple message for the client, a
286 : * non-zero exit code, and the stack trace is logged.
287 : *
288 : * @param thunk the runnable to execute on the thread, performing the command's logic.
289 : * @param accessPath the path used by the end user for running the SSH command
290 : */
291 : protected void startThread(final CommandRunnable thunk, AccessPath accessPath) {
292 9 : final TaskThunk tt = new TaskThunk(thunk, accessPath);
293 :
294 9 : if (isAdminHighPriorityCommand()) {
295 : // Admin commands should not block the main work threads (there
296 : // might be an interactive shell there), nor should they wait
297 : // for the main work threads.
298 : //
299 1 : new Thread(tt, tt.toString()).start();
300 : } else {
301 9 : task.set(executor.submit(tt));
302 : }
303 9 : }
304 :
305 : private boolean isAdminHighPriorityCommand() {
306 9 : if (getClass().getAnnotation(AdminHighPriorityCommand.class) != null) {
307 : try {
308 1 : permissionBackend.user(user).check(GlobalPermission.ADMINISTRATE_SERVER);
309 1 : return true;
310 0 : } catch (AuthException | PermissionBackendException e) {
311 0 : return false;
312 : }
313 : }
314 9 : return false;
315 : }
316 :
317 : /**
318 : * Terminate this command and return a result code to the remote client.
319 : *
320 : * <p>Commands should invoke this at most once. Once invoked, the command may lose access to
321 : * request based resources as any callbacks previously registered with {@link RequestCleanup} will
322 : * fire.
323 : *
324 : * @param rc exit code for the remote client.
325 : */
326 : protected void onExit(int rc) {
327 8 : exit.onExit(rc);
328 8 : if (cleanup != null) {
329 8 : cleanup.run();
330 : }
331 8 : }
332 :
333 : /** Wrap the supplied output stream in a UTF-8 encoded PrintWriter. */
334 : protected static PrintWriter toPrintWriter(OutputStream o) {
335 5 : return new PrintWriter(new BufferedWriter(new OutputStreamWriter(o, ENC)));
336 : }
337 :
338 : private int handleError(Throwable e) {
339 3 : if ((e.getClass() == IOException.class && "Pipe closed".equals(e.getMessage()))
340 : || //
341 3 : (e.getClass() == SshException.class && "Already closed".equals(e.getMessage()))
342 : || //
343 3 : e.getClass() == InterruptedIOException.class) {
344 : // This is sshd telling us the client just dropped off while
345 : // we were waiting for a read or a write to complete. Either
346 : // way its not really a fatal error. Don't log it.
347 : //
348 0 : return 127;
349 : }
350 :
351 3 : if (!(e instanceof UnloggedFailure)) {
352 3 : final StringBuilder m = new StringBuilder();
353 3 : m.append("Internal server error");
354 3 : if (user.isIdentifiedUser()) {
355 3 : final IdentifiedUser u = user.asIdentifiedUser();
356 3 : m.append(" (user ");
357 3 : m.append(u.getUserName().orElse(null));
358 3 : m.append(" account ");
359 3 : m.append(u.getAccountId());
360 3 : m.append(")");
361 : }
362 3 : m.append(" during ");
363 3 : m.append(context.getCommandLine());
364 3 : logCauseIfRelevant(e, m);
365 : }
366 :
367 3 : if (e instanceof Failure) {
368 2 : final Failure f = (Failure) e;
369 : try {
370 2 : err.write((f.getMessage() + "\n").getBytes(ENC));
371 2 : err.flush();
372 0 : } catch (IOException e2) {
373 : // Ignored
374 0 : } catch (RuntimeException e2) {
375 0 : logger.atWarning().withCause(e2).log("Cannot send failure message to client");
376 2 : }
377 2 : return f.exitCode;
378 : }
379 :
380 : try {
381 0 : err.write("fatal: internal server error\n".getBytes(ENC));
382 0 : err.flush();
383 2 : } catch (IOException e2) {
384 : // Ignored
385 0 : } catch (RuntimeException e2) {
386 0 : logger.atWarning().withCause(e2).log("Cannot send internal server error message to client");
387 2 : }
388 2 : return 128;
389 : }
390 :
391 : private void logCauseIfRelevant(Throwable e, StringBuilder message) {
392 3 : String zeroLength = "length=0";
393 3 : String streamAlreadyClosed = "stream is already closed";
394 3 : boolean isZeroLength = false;
395 :
396 3 : if (streamAlreadyClosed.equals(e.getMessage())) {
397 0 : StackTraceElement[] stackTrace = e.getStackTrace();
398 0 : isZeroLength = Arrays.stream(stackTrace).anyMatch(s -> s.toString().contains(zeroLength));
399 : }
400 3 : if (!isZeroLength) {
401 3 : logger.atSevere().withCause(e).log("%s", message);
402 : }
403 3 : }
404 :
405 : protected UnloggedFailure die(String msg) {
406 1 : return new UnloggedFailure(1, "fatal: " + msg);
407 : }
408 :
409 : protected UnloggedFailure die(String msg, Throwable why) {
410 0 : return new UnloggedFailure(1, "fatal: " + msg, why);
411 : }
412 :
413 : protected UnloggedFailure die(Throwable why) {
414 0 : return new UnloggedFailure(1, "fatal: " + why.getMessage(), why);
415 : }
416 :
417 : protected void writeError(String type, String msg) {
418 : try {
419 0 : err.write((type + ": " + msg + "\n").getBytes(ENC));
420 0 : } catch (IOException e) {
421 : // Ignored
422 0 : }
423 0 : }
424 :
425 : protected void enableGracefulStop() {
426 9 : context.getSession().setGracefulStop(true);
427 9 : }
428 :
429 : protected String getTaskDescription() {
430 9 : String[] ta = getTrimmedArguments();
431 9 : if (ta != null) {
432 9 : return commandName + " " + Joiner.on(" ").join(ta);
433 : }
434 0 : return commandName;
435 : }
436 :
437 : private String getTaskName() {
438 9 : StringBuilder m = new StringBuilder();
439 9 : m.append(getTaskDescription());
440 9 : if (user.isIdentifiedUser()) {
441 9 : IdentifiedUser u = user.asIdentifiedUser();
442 9 : if (u.getUserName().isPresent()) {
443 9 : m.append(" (").append(u.getUserName().get()).append(")");
444 : }
445 : }
446 9 : return m.toString();
447 : }
448 :
449 : private final class TaskThunk implements CancelableRunnable, ProjectRunnable {
450 : private final CommandRunnable thunk;
451 : private final String taskName;
452 : private final AccessPath accessPath;
453 :
454 : private Project.NameKey projectName;
455 :
456 9 : private TaskThunk(final CommandRunnable thunk, AccessPath accessPath) {
457 9 : this.thunk = thunk;
458 9 : this.taskName = getTaskName();
459 9 : this.accessPath = accessPath;
460 9 : }
461 :
462 : @Override
463 : public void cancel() {
464 0 : synchronized (this) {
465 0 : final Context old = sshScope.set(context);
466 : try {
467 0 : onExit(STATUS_CANCEL);
468 : } finally {
469 0 : sshScope.set(old);
470 : }
471 0 : }
472 0 : }
473 :
474 : @Override
475 : public void run() {
476 9 : synchronized (this) {
477 9 : final Thread thisThread = Thread.currentThread();
478 9 : final String thisName = thisThread.getName();
479 9 : int rc = 0;
480 9 : context.getSession().setAccessPath(accessPath);
481 9 : final Context old = sshScope.set(context);
482 : try {
483 9 : context.start();
484 9 : thisThread.setName("SSH " + taskName);
485 :
486 : try {
487 9 : if (thunk instanceof ProjectCommandRunnable) {
488 5 : try (DynamicOptions pluginOptions = new DynamicOptions(injector, dynamicBeans)) {
489 4 : ((ProjectCommandRunnable) thunk).executeParseCommand(pluginOptions);
490 4 : projectName = ((ProjectCommandRunnable) thunk).getProjectName();
491 4 : thunk.run();
492 : }
493 : } else {
494 5 : thunk.run();
495 : }
496 0 : } catch (NoSuchProjectException e) {
497 0 : throw new UnloggedFailure(1, e.getMessage());
498 0 : } catch (NoSuchChangeException e) {
499 0 : throw new UnloggedFailure(1, e.getMessage() + " no such change");
500 9 : }
501 :
502 9 : out.flush();
503 9 : err.flush();
504 3 : } catch (Exception e) {
505 : try {
506 2 : out.flush();
507 2 : } catch (Exception e2) {
508 : // Ignored
509 2 : }
510 : try {
511 2 : err.flush();
512 2 : } catch (Exception e2) {
513 : // Ignored
514 2 : }
515 3 : rc = handleError(e);
516 : } finally {
517 : try {
518 9 : onExit(rc);
519 : } finally {
520 9 : sshScope.set(old);
521 9 : thisThread.setName(thisName);
522 : }
523 : }
524 9 : }
525 9 : }
526 :
527 : @Override
528 : public String toString() {
529 1 : return taskName;
530 : }
531 :
532 : @Override
533 : public Project.NameKey getProjectNameKey() {
534 0 : return projectName;
535 : }
536 :
537 : @Override
538 : public String getRemoteName() {
539 0 : return null;
540 : }
541 :
542 : @Override
543 : public boolean hasCustomizedPrint() {
544 0 : return false;
545 : }
546 : }
547 :
548 : /** Runnable function which can throw an exception. */
549 : @FunctionalInterface
550 : public interface CommandRunnable {
551 : void run() throws Exception;
552 : }
553 :
554 : /** Runnable function which can retrieve a project name related to the task */
555 : public interface ProjectCommandRunnable extends CommandRunnable {
556 : // execute parser command before running, in order to be able to retrieve
557 : // project name
558 : void executeParseCommand(DynamicOptions pluginOptions) throws Exception;
559 :
560 : Project.NameKey getProjectName();
561 : }
562 :
563 : /** Thrown from {@link CommandRunnable#run()} with client message and code. */
564 : public static class Failure extends Exception {
565 : private static final long serialVersionUID = 1L;
566 :
567 : final int exitCode;
568 :
569 : /**
570 : * Create a new failure.
571 : *
572 : * @param exitCode exit code to return the client, which indicates the failure status of this
573 : * command. Should be between 1 and 255, inclusive.
574 : * @param msg message to also send to the client's stderr.
575 : */
576 : public Failure(int exitCode, String msg) {
577 1 : this(exitCode, msg, null);
578 1 : }
579 :
580 : /**
581 : * Create a new failure.
582 : *
583 : * @param exitCode exit code to return the client, which indicates the failure status of this
584 : * command. Should be between 1 and 255, inclusive.
585 : * @param msg message to also send to the client's stderr.
586 : * @param why stack trace to include in the server's log, but is not sent to the client's
587 : * stderr.
588 : */
589 : public Failure(int exitCode, String msg, Throwable why) {
590 2 : super(msg, why);
591 2 : this.exitCode = exitCode;
592 2 : }
593 : }
594 :
595 : /** Thrown from {@link CommandRunnable#run()} with client message and code. */
596 : public static class UnloggedFailure extends Failure {
597 : private static final long serialVersionUID = 1L;
598 :
599 : /**
600 : * Create a new failure.
601 : *
602 : * @param msg message to also send to the client's stderr.
603 : */
604 : public UnloggedFailure(String msg) {
605 0 : this(1, msg);
606 0 : }
607 :
608 : /**
609 : * Create a new failure.
610 : *
611 : * @param exitCode exit code to return the client, which indicates the failure status of this
612 : * command. Should be between 1 and 255, inclusive.
613 : * @param msg message to also send to the client's stderr.
614 : */
615 : public UnloggedFailure(int exitCode, String msg) {
616 1 : this(exitCode, msg, null);
617 1 : }
618 :
619 : /**
620 : * Create a new failure.
621 : *
622 : * @param exitCode exit code to return the client, which indicates the failure status of this
623 : * command. Should be between 1 and 255, inclusive.
624 : * @param msg message to also send to the client's stderr.
625 : * @param why stack trace to include in the server's log, but is not sent to the client's
626 : * stderr.
627 : */
628 : public UnloggedFailure(int exitCode, String msg, Throwable why) {
629 1 : super(exitCode, msg, why);
630 1 : }
631 : }
632 : }
|