diff --git a/pom.xml b/pom.xml
index 078edc0..6b6b208 100644
--- a/pom.xml
+++ b/pom.xml
@@ -4,9 +4,9 @@
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
4.0.0
- org.example
+ ru.kirillius
mikrotik-container-backup-utility
- 1.0-SNAPSHOT
+ 1.0.0.0
21
@@ -14,4 +14,98 @@
UTF-8
+
+
+ kirillius
+ kirillius
+ https://repo.kirillius.ru/maven
+
+ true
+ always
+ fail
+
+ default
+
+
+
+
+
+
+ ru.kirillius.utils
+ common-logging
+ 1.3.0.0
+
+
+
+
+ org.apache.commons
+ commons-compress
+ 1.20
+
+
+
+
+ org.slf4j
+ slf4j-api
+ 2.0.16
+
+
+
+
+ org.slf4j
+ slf4j-jdk14
+ 2.0.16
+
+
+
+
+ org.junit.jupiter
+ junit-jupiter-api
+ 5.11.4
+ test
+
+
+
+ org.junit.jupiter
+ junit-jupiter-engine
+ 5.11.4
+ test
+
+
+
+
+ me.legrange
+ mikrotik
+ 3.0.8
+
+
+
+ org.projectlombok
+ lombok
+ 1.18.36
+ provided
+
+
+
+
+ org.easytesting
+ fest-assert-core
+ 2.0M10
+ test
+
+
+
+
+
+ com.hierynomus
+ sshj
+ 0.39.0
+
+
+
+ ru.kirillius
+ json-convert
+ 2.1.0.0
+
+
\ No newline at end of file
diff --git a/src/main/java/ru/kirillius/mktbk/App.java b/src/main/java/ru/kirillius/mktbk/App.java
new file mode 100644
index 0000000..c0fe10e
--- /dev/null
+++ b/src/main/java/ru/kirillius/mktbk/App.java
@@ -0,0 +1,244 @@
+package ru.kirillius.mktbk;
+
+import me.legrange.mikrotik.MikrotikApiException;
+import net.schmizz.sshj.sftp.SFTPException;
+import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
+import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream;
+import org.apache.commons.compress.archivers.tar.TarConstants;
+import ru.kirillius.utils.logging.SystemLogger;
+
+import java.io.*;
+import java.rmi.RemoteException;
+import java.util.Collections;
+import java.util.List;
+import java.util.logging.Level;
+
+public class App {
+ private static final String LOG_CONTEXT = App.class.getSimpleName();
+ private List containers;
+
+
+ public static void main(String[] args) throws IOException, MikrotikApiException, InterruptedException {
+ SystemLogger.initializeLogging(Level.INFO, Collections.emptyList());
+
+ new App();
+ }
+
+ private String cin() {
+ try {
+ return reader.readLine();
+ } catch (IOException e) {
+ return "";
+ }
+ }
+
+ private void pause() {
+ System.out.println("Press any key to continue...");
+ cin();
+ }
+
+ private BufferedReader reader;
+
+ private ContainerMetadata selectContainer() {
+ System.out.println("Found containers:");
+ var i = 0;
+ for (var container : containers) {
+ System.out.println(String.valueOf(++i) + ' ' + container);
+ }
+ do {
+
+ var index = -1;
+
+ if (containers.size() > 1) {
+ do {
+ index = -1;
+ System.out.print("Select container you want to backup:");
+ try {
+ index = Integer.parseInt(cin()) - 1;
+ } catch (NumberFormatException e) {
+ SystemLogger.error("Invalid number", LOG_CONTEXT, e);
+ }
+ } while (index >= containers.size() || index < 0);
+ }
+
+ if (containers.isEmpty()) {
+ return null;
+ }
+
+ var container = containers.get(index);
+ System.out.print("Do you really want to backup container " + container + "? [y/n]");
+
+ if (cin().equals("y")) {
+ return container;
+ }
+ } while (true);
+ }
+
+ public App() throws IOException, MikrotikApiException, InterruptedException {
+ try {
+ reader = new BufferedReader(new InputStreamReader(System.in));
+ auth();
+ containers = deviceContext.getContainers();
+
+ ContainerMetadata container;
+ do {
+ container = selectContainer();
+ if (container == null) {
+ return;
+ }
+
+ if (!container.isRunning()) {
+ SystemLogger.warning("The selected container is not running. There is a limitation that requires the container to be running.", LOG_CONTEXT);
+ SystemLogger.message("Starting container " + container, LOG_CONTEXT);
+ deviceContext.getApiConnection().execute("/container/start .id=" + container.getId());
+ try {
+ Thread.sleep(10000L);
+ } catch (InterruptedException e) {
+ throw new RuntimeException(e);
+ }
+
+ containers = deviceContext.getContainers();
+ var id = container.getId();
+ container = containers.stream().filter(containerMetadata -> containerMetadata.getId().equals(id)).findFirst().orElse(null);
+ if (container == null || !container.isRunning()) {
+ SystemLogger.error("Something went wrong. Unable to start container.", LOG_CONTEXT);
+ pause();
+ }
+ }
+ } while (container == null);
+
+ SystemLogger.warning("/!\\ We are going to make a backup. You have to stop all services in this container to prevent data corruption.", LOG_CONTEXT);
+ System.out.print("Type yes to continue or Ctrl+C to abort: ");
+ while (!"yes".equals(cin())) ;
+ SystemLogger.message("Reading container filesystem metadata...", LOG_CONTEXT);
+ var filterContents = List.of("/sys/", "/proc/", "/dev/", "/mnt/", "/run/", "/tmp/");
+ var files = deviceContext.listFilesystem(container).stream().filter(f -> filterContents.contains(f.getPath()) || filterContents.stream().noneMatch(s -> f.getPath().startsWith(s))).toList();
+ SystemLogger.message("Found " + files.size() + " files", LOG_CONTEXT);
+
+ var sftp = deviceContext.getSftpClient();
+ final var localTmpFile = "./transfer.tmp";
+
+ try (var outputStream = new FileOutputStream(container.getGuid())) {
+ var count = 0;
+
+ try (var tar = new TarArchiveOutputStream(outputStream)) {
+ tar.setLongFileMode(TarArchiveOutputStream.LONGFILE_GNU);
+ var status = new DownloadStatus("Downloading", files.size());
+ for (var file : files) {
+ try {
+ if (file.isDirectory()) {
+ var entry = new TarArchiveEntry(file.getPath(), TarConstants.LF_DIR);
+ entry.setGroupId(file.getGroupId());
+ entry.setUserId(file.getUserId());
+ entry.setMode(file.getMode());
+ tar.putArchiveEntry(entry);
+ tar.closeArchiveEntry();
+ status.addCopiedFile(0);
+ } else if (file.isSymlink()) {
+ var entry = new TarArchiveEntry(file.getPath(), TarConstants.LF_SYMLINK);
+ entry.setLinkName(file.getTarget());
+ tar.putArchiveEntry(entry);
+ tar.closeArchiveEntry();
+ status.addCopiedFile(0);
+ } else {
+ var entry = new TarArchiveEntry(file.getPath());
+ entry.setGroupId(file.getGroupId());
+ entry.setUserId(file.getUserId());
+ entry.setMode(file.getMode());
+ var remotePath = "/" + container.getGuid() + file.getPath();
+ var size = sftp.size(remotePath);
+
+
+ entry.setSize(size);
+ tar.putArchiveEntry(entry);
+
+
+ try {
+ sftp.get(remotePath, new SFTPFileStream(size, tar));
+ }catch (AssertionError ae){
+ throw new RuntimeException(ae);
+ }
+
+ tar.closeArchiveEntry();
+ status.addCopiedFile(size);
+ }
+ } catch (IOException e) {
+ status.clear();
+ SystemLogger.error("Unable to copy file " + file.getPath(), LOG_CONTEXT, e);
+ pause();
+ }
+
+ }
+ tar.finish();
+ }
+ }
+
+ System.out.println(files.size());
+
+ } finally {
+ if (reader != null) {
+ reader.close();
+ }
+ if (deviceContext != null) {
+ deviceContext.close();
+ }
+ }
+
+// openSFTP();
+
+
+ }
+
+
+// private void openSSH() throws TransportException, ConnectionException {
+// session = client.startSession();
+// }
+
+
+// private void openSFTP() throws IOException {
+// sftpClient = client.newSFTPClient();
+// for (var info : sftpClient.ls("/")) {
+// System.out.println(info.getPath());
+// }
+//
+// }
+
+ private DeviceContext deviceContext;
+
+ private void auth() {
+ do {
+
+ try {
+
+ System.out.print("Enter remote host:");
+ var host = cin();
+ if (host.trim().isEmpty()) {
+ throw new RuntimeException("Remote host is empty");
+ }
+
+ System.out.print("Enter username:");
+ var username = cin();
+ if (username.trim().isEmpty()) {
+ throw new RuntimeException("Username is empty");
+ }
+ System.out.print("Enter password:");
+ var password = cin();
+ if (password.trim().isEmpty()) {
+ throw new RuntimeException("Password is empty");
+ }
+
+ deviceContext = new DeviceContext(host, username, password);
+ deviceContext.getApiConnection();
+ } catch (Exception e) {
+ SystemLogger.error("Unable to connect", LOG_CONTEXT, e);
+ if (deviceContext != null) {
+ deviceContext.close();
+ }
+ deviceContext = null;
+ }
+ } while (deviceContext == null);
+
+ SystemLogger.message("Connected", LOG_CONTEXT);
+ }
+
+}
diff --git a/src/main/java/ru/kirillius/mktbk/ContainerMetadata.java b/src/main/java/ru/kirillius/mktbk/ContainerMetadata.java
new file mode 100644
index 0000000..72728f1
--- /dev/null
+++ b/src/main/java/ru/kirillius/mktbk/ContainerMetadata.java
@@ -0,0 +1,104 @@
+package ru.kirillius.mktbk;
+
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import ru.kirillius.json.JSONSerializable;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+import java.lang.reflect.Field;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.StringJoiner;
+
+@Getter
+@JSONSerializable
+@NoArgsConstructor
+public class ContainerMetadata {
+ @Retention(RetentionPolicy.RUNTIME)
+ @Target(ElementType.FIELD)
+ public @interface FieldAlias {
+ String value();
+ }
+
+ public boolean isRunning() {
+ return "running".equals(status);
+ }
+
+ @Override
+ public String toString() {
+ var line = new StringJoiner(" ");
+
+ if (isRunning()) {
+ line.add("[R]");
+ }
+ if (comment != null) {
+ line.add(comment);
+ }
+ line.add(hostname);
+ line.add(tag);
+ line.add(os);
+ line.add(arch);
+ line.add(ifname);
+
+ return line.toString();
+ }
+
+ public ContainerMetadata(Map data) {
+ var fields = new HashMap();
+
+ for (var field : getClass().getDeclaredFields()) {
+ var key = field.getName();
+ if (field.isAnnotationPresent(FieldAlias.class)) {
+ key = field.getAnnotation(FieldAlias.class).value();
+ }
+ fields.put(key, field);
+ }
+
+
+ for (var key : data.keySet()) {
+ if (fields.containsKey(key)) {
+ Field field = fields.get(key);
+ field.setAccessible(true);
+ try {
+ field.set(this, data.get(key));
+ } catch (IllegalAccessException e) {
+ throw new RuntimeException(e);
+ }
+ }
+ }
+
+ }
+
+ @FieldAlias(".id")
+ private String id;
+ @FieldAlias("name")
+ private String guid;
+ private String tag;
+ private String comment;
+ private String os;
+ private String arch;
+ @FieldAlias("interface")
+ private String ifname;
+ private String entrypoint;
+ private String mounts;
+ private String dns;
+ private String hostname;
+ private String workdir;
+ @FieldAlias("start-on-boot")
+ private String autostart;
+ @FieldAlias("domain-name")
+ private String domainname;
+ private String envlist;
+ private String logging;
+ @FieldAlias("root-dir")
+ private String rootdir;
+ @FieldAlias("stop-signal")
+ private String stopsignal;
+ private String user;
+ private String status;
+
+
+}
diff --git a/src/main/java/ru/kirillius/mktbk/DeviceContext.java b/src/main/java/ru/kirillius/mktbk/DeviceContext.java
new file mode 100644
index 0000000..d609578
--- /dev/null
+++ b/src/main/java/ru/kirillius/mktbk/DeviceContext.java
@@ -0,0 +1,241 @@
+package ru.kirillius.mktbk;
+
+import me.legrange.mikrotik.ApiConnection;
+import me.legrange.mikrotik.ApiConnectionException;
+import me.legrange.mikrotik.MikrotikApiException;
+import net.schmizz.sshj.SSHClient;
+import net.schmizz.sshj.sftp.SFTPClient;
+import net.schmizz.sshj.transport.verification.PromiscuousVerifier;
+import ru.kirillius.utils.logging.SystemLogger;
+
+import java.io.*;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.StringJoiner;
+import java.util.function.Predicate;
+import java.util.regex.Pattern;
+
+public class DeviceContext implements Closeable {
+
+ private final static String LOG_CONTEXT = DeviceContext.class.getSimpleName();
+ private final String host;
+ private final String username;
+ private final String password;
+ private SFTPClient sftpClient;
+ private SSHClient sshClient;
+ private ApiConnection apiConnection;
+
+ public DeviceContext(String host, String username, String password) {
+ this.host = host;
+ this.username = username;
+ this.password = password;
+ }
+
+ public SFTPClient getSftpClient() throws IOException {
+ if (sftpClient == null) {
+ SystemLogger.message("Initializing sftp connection", LOG_CONTEXT);
+ sftpClient = getSshClient().newSFTPClient();
+ }
+ return sftpClient;
+ }
+
+ public List getContainers() throws MikrotikApiException {
+ return getApiConnection().execute("/container/print").stream().map(ContainerMetadata::new).toList();
+ }
+
+ private String stripBashSlashes(String path) {
+ var escaped = false;
+ var doubleQuotes = false;
+ if (path.startsWith("\"")) {
+ escaped = true;
+ doubleQuotes = true;
+ } else if (path.startsWith("'")) {
+ escaped = true;
+ }
+ if (!escaped) {
+ return path;
+ }
+
+ path = path.substring(1, path.length() - 1);
+
+ if (doubleQuotes) {
+ var special = List.of("$", "\\");
+
+ for (var symbol : special) {
+ path = path.replaceAll(Pattern.quote("\\" + symbol), symbol);
+ }
+ return path;
+ } else {
+ return path.replaceAll(Pattern.quote("'\\''"), "'");
+ }
+ }
+
+ private final static String LIST_COMMAND = "ls --color=never -lAnpR --full-time /";
+ private final static String BEGIN_PATTERN = ":::BEGIN-OF-STREAM:::";
+ private final static String END_PATTERN = ":::END-OF-STREAM:::";
+
+
+ public List listFilesystem(ContainerMetadata container) throws IOException, InterruptedException {
+ var files = new ArrayList();
+
+
+ try (var session = getSshClient().startSession()) {
+ session.allocateDefaultPTY();
+ try (var terminal = new VirtualTerminal(session)) {
+ SystemLogger.message("Waiting for terminal be ready", LOG_CONTEXT);
+ terminal.newLine().newLine().newLine().expect("[" + username + "@", 30000).expect("]", 5000);
+ SystemLogger.message("Opening container shell", LOG_CONTEXT);
+ terminal.skipEverything().write("/container/shell " + container.getId()).write("\r\n\r\n").expect("#", 20000);
+ terminal.skipEverything().write("echo " + BEGIN_PATTERN).newLine().expect(BEGIN_PATTERN, 5000).skipEverything();
+ terminal.write(LIST_COMMAND).newLine().write("echo " + END_PATTERN).newLine();
+
+
+ var reader = terminal.getReader();
+ String line;
+ String directory = null;
+ while ((line = reader.readLine()) != null) {
+ if (line.startsWith(LIST_COMMAND)) {
+ continue;
+ }
+ if (line.endsWith(END_PATTERN)) {
+ break;
+ }
+ if ((line.startsWith("/") || line.startsWith("'/")) && line.endsWith(":")) {
+ directory = line.substring(0, line.length() - 1);
+ if (directory.indexOf('\'') == 0) {
+ directory = directory.substring(1, directory.length() - 2);
+ }
+ if (!directory.endsWith("/")) {
+ directory = directory + "/";
+ }
+ continue;
+ }
+
+
+ if (line.trim().isEmpty()) {
+ continue;
+ }
+ if (line.startsWith("total")) {
+ continue;
+ }
+
+ line = stripSpaces(line);
+
+ var split = line.split(Pattern.quote(" "), 9);
+
+ if (split.length < 9) {
+ SystemLogger.warning("Unable to parse line " + line, LOG_CONTEXT);
+ continue;
+ }
+
+
+ var path = stripBashSlashes(split[8].trim());
+ String target = null;
+
+ if (path.contains("->")) {
+ var parts = path.split(Pattern.quote("->"));
+ path = stripBashSlashes(parts[0].trim());
+ target = stripBashSlashes(parts[1].trim());
+ }
+
+ if (directory == null) {
+ SystemLogger.warning("Current directory is not set! Skipping", LOG_CONTEXT);
+ continue;
+ }
+
+ path = directory.trim() + path;
+
+ var file = FileMetadata.builder().permissions(split[0]).owner(split[2]).group(split[3]).path(path).target(target).build();
+
+ files.add(file);
+ if (files.size() % 10000 == 0) {
+ SystemLogger.message("Processed " + files.size() + " files", LOG_CONTEXT);
+ }
+ }
+ }
+
+
+ }
+
+ return files;
+ }
+
+ private String stripSpaces(String line) {
+
+ String[] parts;
+ var singleQuote = line.indexOf("'");
+ var doubleQuote = line.indexOf("\"");
+
+ if (singleQuote == -1 && doubleQuote == -1) {
+ parts = new String[]{line};
+ } else {
+ var offset = -1;
+ if (singleQuote == -1) {
+ offset = doubleQuote;
+ } else if (doubleQuote == -1) {
+ offset = singleQuote;
+ } else {
+ offset = Math.min(singleQuote, doubleQuote);
+ }
+ parts = new String[]{line.substring(0, offset), line.substring(offset)};
+ }
+
+ while (parts[0].contains(" ")) {
+ parts[0] = parts[0].replaceAll(Pattern.quote(" "), " ");
+ }
+
+ var builder = new StringBuilder();
+ for (var part : parts) {
+ builder.append(part);
+ }
+
+ return builder.toString();
+ }
+
+ public SSHClient getSshClient() throws IOException {
+ if (sshClient == null) {
+ SystemLogger.message("Initializing ssh connection", LOG_CONTEXT);
+ sshClient = new SSHClient();
+ sshClient.addHostKeyVerifier(new PromiscuousVerifier());
+ sshClient.connect(host);
+ sshClient.useCompression();
+ sshClient.authPassword(username, password);
+ }
+ return sshClient;
+ }
+
+ public ApiConnection getApiConnection() throws MikrotikApiException {
+ if (apiConnection == null) {
+ SystemLogger.message("Initializing api connection", LOG_CONTEXT);
+ apiConnection = ApiConnection.connect(host);
+ apiConnection.login(username, password);
+ }
+ return apiConnection;
+ }
+
+ @Override
+ public void close() {
+
+ if (apiConnection != null) {
+ try {
+ apiConnection.close();
+ } catch (ApiConnectionException e) {
+ SystemLogger.error("Error closing api connection", LOG_CONTEXT, e);
+ }
+ }
+ if (sftpClient != null) {
+ try {
+ sftpClient.close();
+ } catch (IOException e) {
+ SystemLogger.error("Error closing sftp connection", LOG_CONTEXT, e);
+ }
+ }
+ if (sshClient != null) {
+ try {
+ sshClient.close();
+ } catch (IOException e) {
+ SystemLogger.error("Error closing ssh connection", LOG_CONTEXT, e);
+ }
+ }
+ }
+}
diff --git a/src/main/java/ru/kirillius/mktbk/DownloadStatus.java b/src/main/java/ru/kirillius/mktbk/DownloadStatus.java
new file mode 100644
index 0000000..9f1c7ad
--- /dev/null
+++ b/src/main/java/ru/kirillius/mktbk/DownloadStatus.java
@@ -0,0 +1,72 @@
+package ru.kirillius.mktbk;
+
+public class DownloadStatus {
+ private String buffer = "";
+ private long bytes;
+ private long copied;
+ private long total;
+ private long startTime;
+ private String label;
+ private long lastUpdate = 0;
+
+ public DownloadStatus(String label, long total) {
+ this.label = label;
+ this.total = total;
+ startTime = System.currentTimeMillis();
+ }
+
+ public void addCopiedFile(long size) {
+ bytes += size;
+ copied++;
+ update();
+ }
+
+ private int spin;
+
+ private String spinner() {
+ spin++;
+ return switch (spin) {
+ case 1 -> "\\";
+ case 2 -> "|";
+ case 3 -> "/";
+ case 4 -> {
+ spin = 0;
+ yield "-";
+ }
+ default -> "";
+ };
+ }
+
+ private String formatBytes(long bytes) {
+ if (bytes < 1024) {
+ return bytes + " B";
+ } else if (bytes < 1048576L) {
+ return Math.floor((float) bytes / 1024L) + " KB";
+ } else if (bytes < 1073741824L) {
+ return Math.floor((float) bytes / 1048576L) + " MB";
+ } else if (bytes < 1099511627776L) {
+ return Math.floor((float) bytes / 1073741824L) + " GB";
+ } else {
+ return Math.floor((float) bytes / 1099511627776L) + " TB";
+ }
+ }
+
+ public void update() {
+ if (System.currentTimeMillis() - lastUpdate < 1000) {
+ return;
+ }
+ clear();
+ var speed = bytes * 1000 / (System.currentTimeMillis() - startTime);
+ buffer = ("[" + spinner() + "] " + label + " " + Math.floor((float) copied / total * 1000f) / 10 + "% " + copied + "/" + total + " (" + formatBytes(bytes) + ") " + formatBytes(speed) + "/s");
+ System.out.print(buffer);
+ lastUpdate = System.currentTimeMillis();
+ }
+
+
+ public void clear() {
+ for (var i = 0; i < buffer.length(); i++) {
+ System.out.print('\b');
+ }
+ }
+
+}
diff --git a/src/main/java/ru/kirillius/mktbk/FileMetadata.java b/src/main/java/ru/kirillius/mktbk/FileMetadata.java
new file mode 100644
index 0000000..3599ed2
--- /dev/null
+++ b/src/main/java/ru/kirillius/mktbk/FileMetadata.java
@@ -0,0 +1,44 @@
+package ru.kirillius.mktbk;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+
+
+@AllArgsConstructor
+@Builder
+public class FileMetadata {
+ @Getter
+ private String path;
+ private String permissions;
+ private String owner;
+ private String group;
+ @Getter
+ private String target;
+
+ public boolean isSymlink() {
+ return permissions.startsWith("l");
+ }
+
+ public boolean isDirectory() {
+ return permissions.startsWith("d");
+ }
+
+ public int getGroupId() {
+ return Integer.parseInt(group);
+ }
+
+ public int getUserId() {
+ return Integer.parseInt(owner);
+ }
+
+ public int getMode() {
+ return (parseModeOctet(permissions.substring(1, 4)) << 6) +
+ (parseModeOctet(permissions.substring(4, 7)) << 3) +
+ parseModeOctet(permissions.substring(7, 10));
+ }
+
+ private static int parseModeOctet(String octet) {
+ return 0xFFFFFF & (octet.contains("r") ? 4 : 0) + (octet.contains("w") ? 2 : 0) + (octet.contains("x") ? 1 : 0);
+ }
+}
diff --git a/src/main/java/ru/kirillius/mktbk/SFTPFileStream.java b/src/main/java/ru/kirillius/mktbk/SFTPFileStream.java
new file mode 100644
index 0000000..795de9d
--- /dev/null
+++ b/src/main/java/ru/kirillius/mktbk/SFTPFileStream.java
@@ -0,0 +1,62 @@
+package ru.kirillius.mktbk;
+
+import net.schmizz.sshj.xfer.InMemoryDestFile;
+import net.schmizz.sshj.xfer.LocalDestFile;
+import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream;
+
+import java.io.IOException;
+import java.io.OutputStream;
+
+public class SFTPFileStream extends InMemoryDestFile {
+ private final OutputStream os;
+ private final long size;
+
+ @Override
+ public void setLastAccessedTime(long t)
+ throws IOException {
+
+ }
+
+ @Override
+ public void setLastModifiedTime(long t)
+ throws IOException {
+
+ }
+
+ @Override
+ public void setPermissions(int perms)
+ throws IOException {
+
+ }
+
+ @Override
+ public LocalDestFile getTargetDirectory(String dirname) throws IOException {
+ return super.getTargetDirectory(dirname);
+ }
+
+ public SFTPFileStream(long size, TarArchiveOutputStream tar) {
+ this.size = size;
+ os = new OutputStream() {
+ @Override
+ public void write(int i) throws IOException {
+ tar.write(i);
+ }
+ };
+ }
+
+ @Override
+ public long getLength() {
+ return size;
+ }
+
+ @Override
+ public OutputStream getOutputStream() throws IOException {
+ return os;
+ }
+
+ @Override
+ public OutputStream getOutputStream(boolean append) throws IOException {
+ return os;
+ }
+
+}
diff --git a/src/main/java/ru/kirillius/mktbk/VirtualTerminal.java b/src/main/java/ru/kirillius/mktbk/VirtualTerminal.java
new file mode 100644
index 0000000..6e3bd67
--- /dev/null
+++ b/src/main/java/ru/kirillius/mktbk/VirtualTerminal.java
@@ -0,0 +1,71 @@
+package ru.kirillius.mktbk;
+
+import lombok.Getter;
+import net.schmizz.sshj.connection.ConnectionException;
+import net.schmizz.sshj.connection.channel.direct.Session;
+import net.schmizz.sshj.transport.TransportException;
+
+import java.io.*;
+
+public class VirtualTerminal implements Closeable {
+ private final Session.Shell shell;
+ @Getter
+ private final BufferedReader reader;
+ private final BufferedWriter writer;
+
+
+ public VirtualTerminal(Session session) throws TransportException, ConnectionException {
+ shell = session.startShell();
+ reader = new BufferedReader(new InputStreamReader(shell.getInputStream()));
+ writer = new BufferedWriter(new OutputStreamWriter(shell.getOutputStream()));
+ }
+
+ public VirtualTerminal newLine() throws IOException {
+ writer.newLine();
+ writer.flush();
+ return this;
+ }
+
+ public VirtualTerminal write(String text) throws IOException {
+ writer.write(text);
+ writer.flush();
+ return this;
+ }
+
+
+ public VirtualTerminal skipEverything() throws IOException {
+ while (reader.ready()) {
+ reader.read();
+ }
+
+ return this;
+ }
+
+ public VirtualTerminal expect(String expected, long timeout) throws IOException, InterruptedException {
+ var buffer = new StringBuilder();
+
+
+ var startTime = System.currentTimeMillis();
+
+ while (buffer.indexOf(expected) == -1 && System.currentTimeMillis() - startTime < timeout) {
+ if (!reader.ready()) {
+ Thread.sleep(50);
+ continue;
+ }
+ buffer.append((char) reader.read());
+ }
+ var i = buffer.indexOf(expected);
+ if (buffer.indexOf(expected) == -1) {
+ throw new InterruptedException("Timeout exceeded while waiting for string \"" + expected + "\"");
+ }
+
+ return this;
+ }
+
+ @Override
+ public void close() throws IOException {
+ if (shell != null) {
+ shell.close();
+ }
+ }
+}
diff --git a/src/test/java/ru/kirillius/mktbk/FileMetadataTest.java b/src/test/java/ru/kirillius/mktbk/FileMetadataTest.java
new file mode 100644
index 0000000..53c6257
--- /dev/null
+++ b/src/test/java/ru/kirillius/mktbk/FileMetadataTest.java
@@ -0,0 +1,28 @@
+package ru.kirillius.mktbk;
+
+import org.junit.jupiter.api.Test;
+
+import static org.fest.assertions.api.Assertions.assertThat;
+
+class FileMetadataTest {
+
+ @Test
+ public void TestPermissions() {
+ comparePermissions("-rwxrwxrwx", 0777);
+ comparePermissions("-rwxrwx---", 0770);
+ comparePermissions("-rwx------", 0700);
+ comparePermissions("-r--------", 0400);
+ comparePermissions("-x--------", 0100);
+ comparePermissions("-w--------", 0200);
+ comparePermissions("-r---w---x", 0421);
+
+
+
+ }
+
+ private void comparePermissions(String s, int expected) {
+ var m = FileMetadata.builder().permissions(s).build();
+ assertThat(m.getMode()).isEqualTo(expected);
+ }
+
+}
\ No newline at end of file