Skip to content

Commit

Permalink
Fix hanging terminal when unexpected runtime exception occurs
Browse files Browse the repository at this point in the history
  • Loading branch information
François Onimus committed Oct 18, 2019
1 parent 09e1ab0 commit b3bb41d
Show file tree
Hide file tree
Showing 7 changed files with 343 additions and 218 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,7 @@ public class ApplicationTest {}
* Bump to spring boot 2.2.0.RELEASE
* Audit and Http Trace actuator commands will be disabled by default, because endpoint will be by spring boot by default
(check [spring boot migration 2.2](https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-2.2-Release-Notes#actuator-http-trace-and-auditing-are-disabled-by-default) for more info)
* Fix hanging terminal when unexpected runtime exception occurs

### 1.1.6

Expand Down
52 changes: 28 additions & 24 deletions starter/src/main/java/com/github/fonimus/ssh/shell/SshContext.java
Original file line number Diff line number Diff line change
@@ -1,44 +1,48 @@
package com.github.fonimus.ssh.shell;

import com.github.fonimus.ssh.shell.auth.SshAuthentication;
import com.github.fonimus.ssh.shell.postprocess.PostProcessorObject;
import lombok.Getter;
import lombok.Setter;

import java.util.List;

import org.jline.reader.LineReader;
import org.jline.terminal.Terminal;

import java.util.List;
import com.github.fonimus.ssh.shell.auth.SshAuthentication;
import com.github.fonimus.ssh.shell.postprocess.PostProcessorObject;

/**
* Ssh context to hold terminal, exit callback and thread per thread
*/
@Getter
public class SshContext {

private Thread thread;
private SshShellRunnable sshShellRunnable;

private Terminal terminal;

private Terminal terminal;
private LineReader lineReader;

private LineReader lineReader;
private SshAuthentication authentication;

private SshAuthentication authentication;
@Setter
private List<PostProcessorObject> postProcessorsList;

@Setter
private List<PostProcessorObject> postProcessorsList;
public SshContext() {
}

/**
* Constructor
*
* @param thread ssh thread session
* @param terminal ssh terminal
* @param lineReader ssh line reader
* @param authentication (optional) spring authentication objects
*/
public SshContext(Thread thread, Terminal terminal, LineReader lineReader,
SshAuthentication authentication) {
this.thread = thread;
this.terminal = terminal;
this.lineReader = lineReader;
this.authentication = authentication;
}
/**
* Constructor
*
* @param sshShellRunnable
* @param terminal ssh terminal
* @param lineReader ssh line reader
* @param authentication (optional) spring authentication objects
*/
public SshContext(SshShellRunnable sshShellRunnable, Terminal terminal, LineReader lineReader, SshAuthentication authentication) {
this.sshShellRunnable = sshShellRunnable;
this.terminal = terminal;
this.lineReader = lineReader;
this.authentication = authentication;
}
}
27 changes: 27 additions & 0 deletions starter/src/main/java/com/github/fonimus/ssh/shell/SshIO.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/*
* Copyright (c) Worldline 2019.
*/

package com.github.fonimus.ssh.shell;

import lombok.Getter;
import lombok.Setter;

import java.io.InputStream;
import java.io.OutputStream;

import org.apache.sshd.server.ExitCallback;

/**
* Ssh io
*/
@Getter
@Setter
public class SshIO {

private InputStream is;

private OutputStream os;

private ExitCallback ec;
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,70 +2,40 @@

import lombok.extern.slf4j.Slf4j;

import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.PrintStream;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

import org.apache.sshd.common.Factory;
import org.apache.sshd.server.ChannelSessionAware;
import org.apache.sshd.server.ExitCallback;
import org.apache.sshd.server.Signal;
import org.apache.sshd.server.channel.ChannelSession;
import org.apache.sshd.server.command.Command;
import org.jline.reader.EndOfFileException;
import org.jline.reader.LineReader;
import org.jline.reader.LineReaderBuilder;
import org.jline.reader.Parser;
import org.jline.terminal.Attributes;
import org.jline.terminal.Size;
import org.jline.terminal.Terminal;
import org.jline.terminal.TerminalBuilder;
import org.jline.utils.AttributedString;
import org.jline.utils.AttributedStringBuilder;
import org.jline.utils.AttributedStyle;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.Banner;
import org.springframework.context.annotation.Lazy;
import org.springframework.core.env.Environment;
import org.springframework.shell.ExitRequest;
import org.springframework.shell.Input;
import org.springframework.shell.Shell;
import org.springframework.shell.jline.InteractiveShellApplicationRunner;
import org.springframework.shell.jline.JLineShellAutoConfiguration;
import org.springframework.shell.jline.PromptProvider;
import org.springframework.shell.result.DefaultResultHandler;
import org.springframework.stereotype.Component;

import com.github.fonimus.ssh.shell.auth.SshAuthentication;
import com.github.fonimus.ssh.shell.auth.SshShellSecurityAuthenticationProvider;

import static com.github.fonimus.ssh.shell.SshShellHistoryAutoConfiguration.HISTORY_FILE;

/**
* Ssh shell command factory implementation
* Ssh shell command implementation, which starts threads of SshShellRunnable
*
* @see SshShellRunnable
*/
@Slf4j
@Component
public class SshShellCommandFactory
implements Command, Factory<Command>, ChannelSessionAware, Runnable {
implements Command {

public static final ThreadLocal<SshContext> SSH_THREAD_CONTEXT = ThreadLocal.withInitial(() -> null);

private InputStream is;

private OutputStream os;

private ExitCallback ec;

private Thread sshThread;

private ChannelSession session;

private Banner shellBanner;

private PromptProvider promptProvider;
Expand All @@ -80,10 +50,12 @@ public class SshShellCommandFactory

private File historyFile;

private org.apache.sshd.server.Environment sshEnv;

private boolean displayBanner;

public static final ThreadLocal<SshIO> SSH_IO_CONTEXT = ThreadLocal.withInitial(SshIO::new);

private Map<ChannelSession, Thread> threads = new ConcurrentHashMap<>();

/**
* Constructor
*
Expand Down Expand Up @@ -116,115 +88,24 @@ public SshShellCommandFactory(@Autowired(required = false) Banner banner, @Lazy
* @param env ssh environment
*/
@Override
public void start(ChannelSession channelSession, org.apache.sshd.server.Environment env) throws IOException {
LOGGER.debug("{}: start", session.toString());
sshEnv = env;
sshThread = new Thread(this, "ssh-session-" + System.nanoTime());
public void start(ChannelSession channelSession, org.apache.sshd.server.Environment env) {
SshIO sshIO = SSH_IO_CONTEXT.get();
Thread sshThread = new Thread(new ThreadGroup("ssh-shell"),
new SshShellRunnable(channelSession, shellBanner, promptProvider, shell, completerAdapter, parser, environment, historyFile, env,
displayBanner, this, sshIO.getIs(), sshIO.getOs(), sshIO.getEc()),
"ssh-session-" + System.nanoTime());
sshThread.start();
threads.put(channelSession, sshThread);
LOGGER.debug("{}: started [{} session(s) currently active]", channelSession, threads.size());
}

/**
* Run ssh session
*/
@Override
public void run() {
LOGGER.debug("{}: run", session.toString());
Size size = new Size(Integer.parseInt(sshEnv.getEnv().get("COLUMNS")), Integer.parseInt(sshEnv.getEnv().get("LINES")));
try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
PrintStream ps = new PrintStream(baos, true, StandardCharsets.UTF_8.name());
Terminal terminal = TerminalBuilder.builder().system(false).size(size).type(sshEnv.getEnv().get("TERM")).streams(is, os).build()) {

DefaultResultHandler resultHandler = new DefaultResultHandler();
resultHandler.setTerminal(terminal);

Attributes attr = terminal.getAttributes();
SshShellUtils.fill(attr, sshEnv.getPtyModes());
terminal.setAttributes(attr);

sshEnv.addSignalListener((channel, signal) -> {
terminal.setSize(new Size(
Integer.parseInt(sshEnv.getEnv().get("COLUMNS")),
Integer.parseInt(sshEnv.getEnv().get("LINES"))));
terminal.raise(Terminal.Signal.WINCH);
}, Signal.WINCH);

if (displayBanner && shellBanner != null) {
shellBanner.printBanner(environment, this.getClass(), ps);
}
resultHandler.handleResult(new String(baos.toByteArray(), StandardCharsets.UTF_8));
resultHandler.handleResult("Please type `help` to see available commands");

LineReader reader = LineReaderBuilder.builder()
.terminal(terminal)
.appName("Spring Ssh Shell")
.completer(completerAdapter)
.highlighter((reader1, buffer) -> {
int l = 0;
String best = null;
for (String command : shell.listCommands().keySet()) {
if (buffer.startsWith(command) && command.length() > l) {
l = command.length();
best = command;
}
}
if (best != null) {
return new AttributedStringBuilder(buffer.length()).append(best, AttributedStyle.BOLD).append(buffer.substring(l)).toAttributedString();
} else {
return new AttributedString(buffer, AttributedStyle.DEFAULT.foreground(AttributedStyle.RED));
}
})
.parser(parser)
.build();
reader.setVariable(LineReader.HISTORY_FILE, historyFile.toPath());

Object authenticationObject = session.getSession().getIoSession().getAttribute(
SshShellSecurityAuthenticationProvider.AUTHENTICATION_ATTRIBUTE);
SshAuthentication authentication = null;
if (authenticationObject != null) {
if (!(authenticationObject instanceof SshAuthentication)) {
throw new IllegalStateException("Unknown authentication object class: " + authenticationObject.getClass().getName());
}
authentication = (SshAuthentication) authenticationObject;
}

SSH_THREAD_CONTEXT.set(new SshContext(sshThread, terminal, reader, authentication));
shell.run(new SshShellInputProvider(reader, promptProvider));
LOGGER.debug("{}: end", session.toString());
quit(0);
} catch (Throwable e) {
LOGGER.error("{}: unexpected exception", session.toString(), e);
quit(1);
}
}

private void quit(int exitCode) {
ec.onExit(exitCode);
}

@Override
public void destroy(ChannelSession channelSession) throws Exception {
// nothing to do
}

static class SshShellInputProvider
extends InteractiveShellApplicationRunner.JLineInputProvider {

public SshShellInputProvider(LineReader lineReader, PromptProvider promptProvider) {
super(lineReader, promptProvider);
}

@Override
public Input readInput() {
SshContext ctx = SSH_THREAD_CONTEXT.get();
if (ctx != null) {
ctx.setPostProcessorsList(null);
}
try {
return super.readInput();
} catch (EndOfFileException e) {
throw new ExitRequest(1);
}
public void destroy(ChannelSession channelSession) {
Thread sshThread = threads.remove(channelSession);
if (sshThread != null) {
sshThread.interrupt();
}
LOGGER.debug("{}: destroyed [{} session(s) currently active]", channelSession, threads.size());
}

@Override
Expand All @@ -234,26 +115,16 @@ public void setErrorStream(OutputStream errOS) {

@Override
public void setExitCallback(ExitCallback ec) {
this.ec = ec;
SSH_IO_CONTEXT.get().setEc(ec);
}

@Override
public void setInputStream(InputStream is) {
this.is = is;
SSH_IO_CONTEXT.get().setIs(is);
}

@Override
public void setOutputStream(OutputStream os) {
this.os = os;
}

@Override
public void setChannelSession(ChannelSession session) {
this.session = session;
}

@Override
public Command create() {
return this;
SSH_IO_CONTEXT.get().setOs(os);
}
}
Loading

0 comments on commit b3bb41d

Please sign in to comment.