OpenVPN в Ubuntu — один клик до счастья

Давным-давно, кажется в прошлую пятницу, у меня завелась отличная такая VPN. Жила она себе в Амстердаме и пускала меня во всякие там торре… простите, проверенные Правительством сайты. И все было хорошо, пока я не принял волевое решение — отказаться от Windows в пользу свободной и любимой всеми Ubuntu. Ну, как принял… заставили! Но оказалось, что графического клиента OpenVPN в Линуксах — нет. Ну, или есть, но пользоваться невозможно. А от консолек на постоянной основе я как-то отвык. Будем побеждать?

А что, собственно, надо?

Если коротко — тык. Если чуть более пространно, хочу нажимать кнопку. Причем, два раза: первый раз она будет коннектить меня в VPN, второй раз — разрывать соединение и возвращать трафик в нормальное русло. При этом, между первым и вторым тыком может пройти аж неделя: ноут я не выключаю, только мониторы разные цепляю.

Очевидно, что кнопочка это должна как-то запускать скрипт, который проверит состояние, подключит или отключит меня от VPN, обновит свое состояние и весело помрет. Хм, выглядит реалистично, правда?

И что у нас для этого есть?

Ну, по крайней мере, у нас есть консольный клиент openvpn, которому можно скормить файлик с конфигурацией, нажать enter и откинуться на спинку кресла — все остальное он сделает за нас. Вот его-то мы и будем или запускать или убивать. А еще, у нас есть машина на Ubuntu 16.04, немножечно Джавы в ней, приличная IDEшка и куча рабочего свободного времени. Мне нравится!

Стой, почему Java?

Я ждал этого вопроса. Задачка, на самом деле, тянет на один-единственный bash-скрипт, который будет ранниться по нажатию на иконку, согласен. Но мы с вами напишем чуть более общее решение: мы сделаем джарничек, который будет перебирать команды, лежащие в файлике рядом, причем — любое количество, а не только две. А еще — оно будет писать в соседний файлик лог того, что было запущено и того, что оно нам вернуло — очень удобно, если это что-то сложнее коннекта к VPN. Это уже на баш-скриптик как-то не тянет, правда? Так что расчехляем JVM и начинаем писать

Исполняем команды

Это первое, что нам надо научиться сделать, если мы хотим куда-то ехать. Поскольку, ради того, чтобы исполнить что-нибудь эдакое в консольке, нам таки придется вылезать из теплой уютной джава-машины наружу, в жестокий мир, не лишним будет обернуть это дело в отдельный классик, единственный статический метод которого мы будем дергать, передавая ему то, что нужно выполнить, как строчку

public class CommandsExecutor {
    private final static Logger logger = Logger.getLogger(CommandsProcessor.class);

    public static String executeCommand(String command) {

        StringBuilder result = new StringBuilder();

        try {
            Runtime r = Runtime.getRuntime();
            Process p = r.exec(command);
            p.waitFor();
            BufferedReader b = new BufferedReader(new InputStreamReader(p.getInputStream()));

            String line = "";
            while ((line = b.readLine()) != null) {
                result.append(line);
            }

            b.close();
        } catch (IOException | InterruptedException ex) {
            logger.error("can't execute command completely.");
        }

        return result.toString();
    }
}

Примерно вот так: прицепились, выполнили, подождали, собрали ответ, вернули Рантайм на место, пока никто не заметил. Теперь мы всегда сможем написать что-то вроде

String commandResponse = CommandsExecutor.executeCommand("uname -s");

и получить «Linux» в респонсе. Ну, или не Linux, что у вас там стоит. Ладно, командовать умеем. Теперь давайте озаботимся файликом, откуда мы будем запихивать эти команды в консольку

Command as a servise configuration

Первое, что нам нужно делать — уметь читать сам файлик на диске и возвращать ссылку на него. Ну, это просто:

private File getFile() {
        File result = new File(COMMANDS_FILE_NAME);

        if (!result.exists()) {
            try {
                result.createNewFile();
                logger.warn("commands file is recreated.");
            } catch (IOException e) {
                logger.error("can't create commands file.");
            }
        }

        return result;
    }

Да, немного не в духе Джавы: если файлика не нашлось, идеально было бы выкинуть эксцепшн и забить. Но я сегодня либеральный: У вас нет файлика — вот, нате. Теперь, будем читать количество строк в нем. Исходя из концепции «одна строчка — один батч для консоли», количество строк — это количество команд для перебора.

public int getCommandsNumber() {
        LineNumberReader reader = null;
        Integer result = 0;
        try {
            reader = new LineNumberReader(new FileReader(getFile()));
            if (Strings.isNullOrEmpty(reader.readLine())) {
                result = 0;
            } else {
                reader.skip(Long.MAX_VALUE);
                result = reader.getLineNumber() + 1;
            }

        } catch (IOException e) {
            logger.error("can't read commands file.");
        }
        return result;
    }

Если бы я выдал такой код в своей любимой бекэнд-разработке — спасибо бы мне за него не сказали и, того хуже, пулл-реквест бы задеклайнили под презрительный взгляд. В самом деле — открываем файл, читаем его весь вникуда и — считаем количество «\n» — символов. Если в файлике хотя бы мегабайт данных — затянется это надолго. Но мы-то с вами помним, что больше двух строк у нас не будет, правда? Так что оставим это как есть и никому не скажем

Осталось последнее: возвращать текст на определенной строчке. Совсем просто, такое мы уже делали. Правда, есть одна тонкость

public String getCommand(Integer commandNumber) {
        LineNumberReader reader;

        String result = null;
        Integer totalCommands = getCommandsNumber() - 1;

        try {
            reader = new LineNumberReader(new FileReader(getFile()));

            for (int i = 0; i < commandNumber % totalCommands; i++) {
                reader.readLine();
            }

            result = reader.readLine();
        } catch (IOException e) {
            logger.error("can't read commands file.");
        }

        return result;
    }

Всегда любил проценты: с ними все становится лучше… Ну, кроме кредитов, когда их берешь ты.

А по существу, теперь мы можем для файла из трех строк попросить 12-ю по счету строчку: ничего, прожует. Это, кстати, нам сильно сэкономит количество кода, когда будем писать персистентную часть. Кстати, начнем прям сейчас

Сохраняем состояние

Если бы у нас был большой серверный проект, прицепили бы мы сейчас базу данных, накатили миграции, заперсистили версию и горя бы не знали. Но, поскольку у нас никакой не сервер, а маленький, но гордый джава-скрипт, в хорошем смысле этого слова, состояние будем хранить прям тут же, в файле.

Причем, сохранять мы в этом файлике будем одну-единственную циферку, которую будем инкрементировать при каждом запуске джарника. Тут-то нам и пригодится автоматическое приведение номера строчки, которое мы писали выше.

public void incrementCommandNumber() {
        Integer nextCommandNumber = getCurrentCommandNumber() + 1;

        try (BufferedWriter writer = Files.newBufferedWriter(getFile().toPath(), StandardOpenOption.TRUNCATE_EXISTING);) {
            writer.write(String.valueOf(nextCommandNumber));
        } catch (IOException e) {
            logger.error("can't write persistence file.");
        }

    }

Получение ссылки на файлик у нас где-то выше уже пробегало, остальное — не сложнее: открыли файлик, заявили, что мы будем его перезаписывать с нуля, записали строчку и закрыли. Теперь — прочитаем. Это еще проще

public Integer getCurrentCommandNumber() {
        LineNumberReader reader;
        Integer result = 0;

        try {
            reader = new LineNumberReader(new FileReader(getFile()));
            result = Integer.valueOf(reader.readLine());
        } catch (IOException | NumberFormatException | NullPointerException ex) {
            logger.error("can't read persistence file.");
        }

        return result;
    }

Так, теперь у нас есть абсолютно все маленькие компоненты, которые осталось связать в единое целое

Один Main(), чтобы править ими всеми

Поскольку, у нас тут не крутое Spring-приложение, контекста и прочиих магических аннотаций нам не видать, как и Java 9, по всей видимости. Поэтому, обойдемся старым-добрым main — методом, который будет и, точкой входа в приложение и, слоем бизнес-логики. Собственно, все, что нам осталось сделать — это повызывать методы в правильном порядке

public static void main(String[] args) {
        PersistenceManager manager = new PersistenceManager();
        CommandsProcessor processor = new CommandsProcessor();

        if (processor.getCommandsNumber() == 0){
            logger.error("commands file is empty.");
            return;
        }

        String currentCommand = processor.getCommand(manager.getCurrentCommandNumber());
        String commandResponse = CommandsExecutor.executeCommand(currentCommand);

        manager.incrementCommandNumber();

        logger.info(currentCommand + " : " + commandResponse);
    }
}

Наконец-то то, о чем я и говорил! Запустились, прочитали состояние, выполнили команду из файлика, вывели что-то в лог, щелкнули свежим состоянием по файлику, умерли. Готово!

Не готово!

Ну, на самом деле, код у нас написан. Осталось немного — интегрировать его с нашей системой. Для начала, научим наш мавен собирать джарник так, чтобы внутри оказывались все-все зависимости и для его запуска ничего, кроме JRE не требовалось. Для этого добавляем плагин в pom-файл

            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-assembly-plugin</artifactId>
                <configuration>
                    <archive>
                        <manifest>
                            <addClasspath>true</addClasspath>
                            <mainClass>org.grakovne.commands.switcher.Switcher</mainClass>
                        </manifest>
                    </archive>
                    <descriptorRefs>
                        <descriptorRef>jar-with-dependencies</descriptorRef>
                    </descriptorRefs>

                </configuration>
                <executions>
                    <execution>
                        <id>assemble-all</id>
                        <phase>package</phase>
                        <goals>
                            <goal>single</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>

Теперь у нас есть исполняемый файлик, отлично.

Дальше — дело техники: кладем его в нужную нам папочку и создаем рядом файл commands.dat (ну, или запускаем джарник — сам создаст), в котором пишем что-то вроде:

sudo openvpn --config /home/grakovne/Documents/grakovne.ovpn --daemon
sudo killall openvpn

Теперь — пробуем это запустить:

cd /home/grakovne/commands-switcher/
java -jar /home/grakovne/commands-switcher/command-switcher-1.0-SNAPSHOT-jar-with-dependencies.jar

иии… повисли! Вообще, логично: любая команда от root — пользователя требует ввода пароля. А мы никакого пароля никуда не вводим и ждем, что нам ответят. Получается дедлок на ровном месте.

Наверное, есть какой-то хитрый способ запускать openvpn не от root-пользователя, согласен. Но проще всего — проделать маленькое отверстие в безопасности вашей системы, разрешив двум несчастным бинарникам запускаться от рута, но без пароля.

Открываем еще одно окно терминала и пишем:

sudo visudo

Теперь — в самый низ файлика дописываем две новых строчки, заменив мое имя пользователя на свое, не менее замечательное:

grakovne ALL= NOPASSWD: /usr/sbin/openvpn
grakovne ALL= NOPASSWD: /usr/bin/killall

Сохраняем файлик и пробуем запустить джарник еще раз. И еще раз… работает!

А как же кнопочка?

А это, как вам угодно. Я, например, пользуюсь Mate, в качестве графической оболочки, поэтому — могу повесить эту кнопку прям на одну из панелек

Видите во-о-он тот ключик сверху? Ну, вот это оно и есть: нажал — подключились, еще раз нажал — никаких тебе VPN. Впрочем, если у вас Ubuntu с Unity или еще какой дистрибутив, всегда можно подвесить этот ярлычок в dash — не намного дольше получится

Так, мне понравилось, как собрать?

Ага, вникать и кодить не хотите, а кнопочку — надо, отлично. Идем и клонируем себе мой репозиторий

git clone https://GrakovNe@bitbucket.org/GrakovNe/commands-switcher.git

Прекрасно. Теперь ставим Maven

sudo apt install maven

переходим в папку с проектом и собираем себе jar-файл

mvn clean install

Джарник у вас лежит в папочке target и с ним уже можно делать что угодно. Например, пользоваться.

Искренне Ваш, читающий комментарии к этой статье через VPN, GrakovNe