Linux-десктоп своими руками: WiFi-manager

419becca6b982f222d3a82f7b69b85f0

По просьбе некоторых комментаторов «что-то написать самому и выложить на обозрение» — ну вот, написал и выкладываю:

Суть задачи: как, не имея установленного современного Desktop Environment, с Network Manager и systemd, управлять подключением к Wi-Fi сетям без особых проблем?
Усложнение: допустим, у нас к тому же несколько Wi-Fi адаптеров, для одновременного подключения к нескольким сетям.

Легко!
Но для начала — немного о том, «как это работает под капотом» (кому неинтересно — проскакиваем)

Восход Солнца вручную

Сразу, чтобы не пытаться охватить все возможные ситуации, обозначу рамки:
на сегодняшний день почти везде используется WPA/WPA2, даже в каких-нибудь ESP8266, поэтому речь идет о подключении именно через WPA.

За это дело в Linux отвечает уже давно известная прекрасная программа wpa_supplicant. Прекрасна она еще и тем, что кроме работы с вполне читаемыми и понятными конфигами ей можно управлять и через командную строку, через wpa_cli.

Как правило, ее настройки хранятся где-то в /etc/wpa_supplicant, при запуске она читает конфигурационные файлы, содержащие записи о подключенных сетях, висит демоном, отслеживая появление-пропадание сетей и подключается к ним, когда надо.

При этом её работой можно управлять через «control interface socket», по умолчанию в /var/run/wpa_supplicant, чем и занимается wpa_cli.

В простейшем случае нужен хотя бы один конфигурационнный файл примерно такого содержания:
/etc/wpa_supplicant.conf

ctrl_interface=/run/wpa_supplicant
update_config=1
country=US

network={
  ssid = "MyNet"
  psk = "MyPassword"
}

(country тут определяет разрешенные диапазоны работы WiFi)

После чего достаточно запустить её:

wpa_supplicant -i wlan0 -c /etc/wpa_supplicant.conf

Теперь интерфейс wlan0 должен быть подключен к сети MyNet.
Останется только получить IP-адрес по DHCP…

Но это хорошо только для стационарного компьютера, когда сеть известна заранее, Wi-Fi адаптер известен, достаточно один раз прописать и потом запускать из-под рута.

В реальности бывает немного сложнее.
Во-первых, сейчас принято давать Wi-Fi адаптерам «предсказуемые имена» (предсказуемые в том смысле, что они соответствуют конкретному адаптеру, и при перезагрузке не перескочат случайно wlan0 <-> wlan1).
Но это означает, что вместо wlan0 там может быть что-то типа wl557hwej24, и вот это-то вы не предскажете заранее, подключая USB-донгл.

Если адаптеров несколько, и у них вот такие имена — надо делать для них разные конфигурационные файлы, и вызывать wpa_supplicant для каждого отдельно.

И если у вас ноутбук — вы не сможете заранее знать все нужные вам сети, а править всё это вручную, убивая и перезапуская процессы — удовольствие то еще.
Как раз эту проблему решает wpa_cli: с его помощью можно подключиться к запущенному wpa_supplicant, создать новую сеть или удалить старую:

scan — запускает сканирование
scan_results — покажет, что найдено
list_networks — покажет, что сохранено

add_network — создаст новую сеть (сохранение) с номером N
set_network N ssid «MyNet»
set_network N psk «MyPass»
save_config

select_network N — выбрать ее текущей

remove_network N — удалить ее

И это только малая часть команд. Уже лучше, но всё равно «вручную».
К тому же, по умолчанию wpa_cli нужно запускать от рута.

Ну и наконец еще одна проблема — у адаптера может быть просто отключено питание (включен режим Power Save) — тогда надо его сначала включить.

Итого, алгоритм получается такой:
1 — найти адаптер (ы)


2 — включить питание
3 — включить как сетевой интерфейс (это немного другое)
4 — запустить для него wpa_supplicant
5 — если сеть новая — прописать ее и сохранить настройку
6 — после подключения к AP — получить IP-адрес.

Вот это и автоматизируем

Пусть работает компьютер, он железный

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

#!/bin/sh -x

# /etc/wpa_supplicant/start_wpa_supplicant.sh

PATH=/sbin:$PATH; export PATH

CONFIG_DIR=/etc/wpa_supplicant
CTL_DIR=/run/wpa_supplicant

exec >> /var/log/wpa_auto.log
exec 2>&1

check_config_file(){
  if [ -n "${CONFIG_FILE}" ] && [ ! -f "${CONFIG_FILE}" ] ; then
    (
      echo "ctrl_interface=${CTL_DIR}"
      echo "update_config=1"
      echo "country=US"
    ) > "${CONFIG_FILE}"
  fi
}

which iw
NO_IW=$?

if [ ${NO_IW} ] ; then
  IFACES=$(iwconfig 2>&1 | grep IEEE | awk '{ print $1 }')
else
  IFACES=$(iw dev | grep Interface | awk '{ print $2 }')
fi

for i in ${IFACES} ; do
  echo ${i}

  CONFIG_FILE=${CONFIG_DIR}/iface_${i}.conf

  x=$(ps ax | grep -v grep | grep "${CONFIG_FILE}" | wc -l)
  if [ "$x" -eq "0" ] ; then
    echo "No wpa_supplicant for ${i} found"

    # Включим питание на всякий случай
    if [ ${NO_IW} ] ; then
      iwconfig ${i} power on
    else
      iw dev ${i} set power_save off
    fi

    check_config_file

    # Поднимаем интерфейс
    ip link set "${i}" up

    # Запускаем wpa_supplicant
    wpa_supplicant -i "${i}" -c "${CONFIG_FILE}" -B -C ${CTL_DIR}
    chown -R root:netdev ${CTL_DIR}

    # Запускаем wpa_cli, чтобы следить за событиями и запускать DHCP при подключении
    wpa_cli -a /etc/wpa_supplicant/wpa_dhcp.sh -i ${i} -B

  fi

done

exit

Да, это самый обыкновенный shell-скрипт. Он проверяет какие адаптеры есть в системе, используя iw или iwconfig, создает для них конфигурационный файл, если его еще нет, и запускает демона.
Дополнительно запускает в фоне процесс, контролирующий момент подключения, чтобы автоматически запустить dhclient:

#!/bin/sh

# /etc/wpa_supplicant/wpa_dhcp.sh

PATH=/sbin:$PATH; export PATH

exec >> /var/log/wpa_dhcp.log
exec 2>&1

INTERFACE="$1"
EVENT="$2"

echo "Event received: $EVENT on interface $INTERFACE" 

if [ "$EVENT" = "CONNECTED" ]; then
  echo "Wi-Fi connected on $INTERFACE, requesting DHCP..."
  dhclient -r "$INTERFACE"
  dhclient "$INTERFACE"
fi

Запускать этот скрипт удобно автоматически, через udevd:

/etc/udev/rules.d/99-wifi-autostart.rules

ACTION=="add", SUBSYSTEM=="net", KERNEL=="wl*", RUN+="/etc/wpa_supplicant/start_wpa_supplicant.sh %k"

При появлении в системе устройства типа wlan скрипт атоматически запустится.
Почему нельзя просто использовать передаваемый ему параметр — имя интерфейса?
Потому что тут он как раз еще не переименован, тут будет wlan0, но после запуска это будет уже что-то «предсказуемое».

В общем, уже значительная часть работы автоматизирована: если в соответствующем конфиг-файле сохранена какая-то сеть — wpa_supplicant подключится к ней, wpa_cli, ждущий соединения, запустит dhclient и интерфейс получит адрес.
Остается автоматизировать внесение сетей.

Для этого — несложный скрипт, с графическими окошками, традиционно — perl.

#!/usr/bin/perl
use strict;
use warnings;
use Gtk3 '-init';
use IPC::Open2;

use Data::Dumper;

my $wpa_cli = "/sbin/wpa_cli";

my %networks;

# Создание основного окна
my $window = Gtk3::Window->new('toplevel');
$window->set_title("Wi-Fi Manager");
$window->set_default_size(400, 400);
$window->signal_connect(delete_event => sub { Gtk3->main_quit; });

# Создание списка интерфейсов (A)
my $store_A = Gtk3::ListStore->new('Glib::String');
my $list_A = Gtk3::TreeView->new($store_A);
my $colA1 = Gtk3::TreeViewColumn->new_with_attributes('Interface', Gtk3::CellRendererText->new, text => 0);
$list_A->append_column($colA1);

# Создание списка сетей (B)
my $store_B = Gtk3::ListStore->new('Glib::String','Glib::String','Glib::String');
my $list_B = Gtk3::TreeView->new($store_B);
my $colB1 = Gtk3::TreeViewColumn->new_with_attributes('#', Gtk3::CellRendererText->new, text => 0);
my $colB2 = Gtk3::TreeViewColumn->new_with_attributes('SSID', Gtk3::CellRendererText->new, text => 1);
my $colB3 = Gtk3::TreeViewColumn->new_with_attributes('Status', Gtk3::CellRendererText->new, text => 2);
$list_B->append_column($colB1);
$list_B->append_column($colB2);
$list_B->append_column($colB3);

# Кнопки управления
my $scan_button = Gtk3::Button->new_with_label("Add network");
my $select_button = Gtk3::Button->new_with_label("Select");
my $delete_button = Gtk3::Button->new_with_label("Remove");
my $quit_button = Gtk3::Button->new_with_label("Quit");
$_->set_sensitive(0) for ($scan_button, $select_button, $delete_button);

# Формирование окна
{
  my $vbox = Gtk3::Box->new('vertical', 5);
  $window->add($vbox);

  $vbox->pack_start($list_A, 1, 1, 5);
  $vbox->pack_start($list_B, 1, 1, 5);

  my $button_box = Gtk3::ButtonBox->new('horizontal');
  $button_box->pack_start($_, 1, 1, 5) for ($scan_button, $select_button, $delete_button, $quit_button);
  $vbox->pack_start($button_box, 1, 1, 20);
}

# =========================================
# Функция для выполнения команд wpa_cli
sub run_wpa_cli {
  my ($cmd) = @_;
  print STDERR "cmd: ==$cmd==\n";
  open2(my $out, my $in, "$wpa_cli $cmd");
  my @result = <$out>;
  print STDERR Dumper(@result);
  return @result;
}

# Заполнение списка интерфейсов (A)
sub load_interfaces {
  $list_A->get_model->clear;
  my @interfaces = run_wpa_cli('interface');
  foreach my $iface (@interfaces) {
    next if($iface =~ /\w+\s+\w/);
    $iface =~ s/\s+$//;
    my $iter = $list_A->get_model->append();
    $list_A->get_model->set($iter, 0 => $iface);
  }
}

# Получение выбранного интерфейса
sub get_selected_iface {
  my $selection = $list_A->get_selection;
  my ($model, $iter) = $selection->get_selected;
  return $model->get($iter, 0);
}

# Получение выбранной сети
sub get_selected_network {
  my $selection = $list_B->get_selection;
  my ($model, $iter) = $selection->get_selected;
  return $model->get($iter, 0);
}

# Заполнение списка сетей (B) для выбранного интерфейса
sub load_networks {
  my ($iface) = @_;
  $list_B->get_model->clear;
  my @networks = run_wpa_cli("-i $iface list_network");
  shift @networks;
  foreach my $net (@networks) {
    my ($id, $ssid, $bssid, $flags) = split(/\s/, $net);
    my $iter = $list_B->get_model->append();
    $list_B->get_model->set($iter, 0 => $id, 1 => $ssid, 2 => $flags);
    $networks{$ssid} = $id;
  }
  $select_button->set_sensitive(0);
  $delete_button->set_sensitive(0);
}

# Выбор сети
sub select_network {
  my $iface = get_selected_iface();
  my $id = get_selected_network();
  run_wpa_cli("-i $iface select_network $id");
  run_wpa_cli("-i $iface save_config");
  load_networks($iface);
}

# Удаление сети
sub delete_network {
  my $iface = get_selected_iface();
  my $id = get_selected_network();
  run_wpa_cli("-i $iface remove_network $id");
  run_wpa_cli("-i $iface save_config");
  %networks=();
  load_networks($iface);
}

# Ввод пароля
sub show_password_dialog {
  my ($iface, $ssid, $parent_window) = @_;

  my $dialog = Gtk3::Dialog->new("Password", $parent_window,
    [ 'modal' ], 'gtk-ok', 'accept', 'gtk-cancel', 'cancel');
  $dialog->set_default_size(300, 100);

  my $entry = Gtk3::Entry->new();
  $entry->set_visibility(0);
  $dialog->get_content_area()->pack_start($entry, 1, 1, 5);

  $dialog->signal_connect(response => sub {
    my ($dialog, $response) = @_;
    if ($response eq 'accept') {
      my $pass= $entry->get_text();
      if ($pass) {
        my $id = $networks{ $ssid };
        if(!defined $id){
          my @output = run_wpa_cli("-i $iface add_network");
          if(defined $output[0] && $output[0] =~ /(\d+)/){
            $id = $1;
          }
          else{
            $dialog->destroy;
            return;
          }
        }
        run_wpa_cli("-i $iface set_network $id ssid '\"$ssid\"'");
        run_wpa_cli("-i $iface set_network $id psk '\"$pass\"'");
        run_wpa_cli("-i $iface enable_network $id");
        run_wpa_cli("-i $iface save_config");
        sleep(1);
      }
    }
    load_networks($iface);
    $dialog->destroy;
    $parent_window->destroy;
  });

  $dialog->show_all;
}

# Выбор сетей из отсканированных
sub scan_networks {
  my $iface = get_selected_iface();
  return unless $iface;

  # Создаем новое окно сканирования
  my $scan_window = Gtk3::Dialog->new(
    "Scan Wi-Fi",
    $window,
    [ 'modal' ]
  );
  $scan_window->set_default_size(400, 300);

  my $vbox_scan = Gtk3::Box->new('vertical', 5);
  my $content_area = $scan_window->get_content_area();
  $content_area->add($vbox_scan);

  my $scrolled_window = Gtk3::ScrolledWindow->new();
  $scrolled_window->set_policy('automatic', 'automatic');
  $scrolled_window->set_min_content_height(400);

  # Список доступных сетей (C)
 my $store_C = Gtk3::ListStore->new('Glib::String','Glib::String');
  my $list_C = Gtk3::TreeView->new($store_C);
  $scrolled_window->add($list_C);
  $vbox_scan->pack_start($scrolled_window, 1, 1, 5);

  my $colC1 = Gtk3::TreeViewColumn->new_with_attributes('SSID', Gtk3::CellRendererText->new, text => 0);
  my $colC2 = Gtk3::TreeViewColumn->new_with_attributes(' ', Gtk3::CellRendererText->new, text => 1);
  $list_C->append_column($colC1);
  $list_C->append_column($colC2);

  # Кнопки
  my $start_button = Gtk3::Button->new_with_label("Scan");
  my $close_button = Gtk3::Button->new_with_label("Close");

  my $button_box = Gtk3::ButtonBox->new('horizontal');
  $button_box->pack_start($_, 1, 1, 5) for ($start_button, $close_button);
  $vbox_scan->pack_start($button_box, 1, 1, 5);

  $scan_window->show_all;

  run_wpa_cli("-i $iface scan");
  sleep(1);

  # -------------------------------------------
  # Обработчик выбора сети в списке C (делает кнопку "Добавить" активной)
  my $selection_C = $list_C->get_selection;
  $selection_C->signal_connect(changed => sub {
    my ($model, $iter) = $selection_C->get_selected;
    my $sel_ssid = $list_C->get_model->get($iter, 0);
    my $iface = get_selected_iface();

    show_password_dialog($iface, $sel_ssid, $scan_window);

  });
  
  # -------------------------------------------
  $start_button->signal_connect(clicked => sub {
    my %scan_results;
    $list_C->get_model->clear;

    my @results = run_wpa_cli("-i $iface scan_results");
    shift @results;

    my $list = {};

    foreach my $line (@results) {
      $line =~ s/[\n\r]+//gm;
      print STDERR "$line\n";
      my ($bssid, $freq, $signal, $flags, $ssid) = split(/\s+/, $line, 5);
      next unless $ssid;

      $list->{$ssid} = 1;
    }
    
    foreach my $ssid (keys(%$list)){
      my $iter = $list_C->get_model->append();
      my $fl = (defined $networks{ $ssid }) ? '*':'';
      $list_C->get_model->set($iter, 0 => $ssid, 1 => $fl);
    }
  });

  # -------------------------------------------
  # Закрытие окна
  $close_button->signal_connect(clicked => sub {
    $scan_window->destroy;
  });

}

# =========================================
my $selection_A = $list_A->get_selection;
$selection_A->signal_connect(changed => sub {
  my $iface = get_selected_iface();
  if ($iface) {
    %networks=();
    load_networks($iface);
    $scan_button->set_sensitive(1);
  }
});

my $selection_B = $list_B->get_selection;
$selection_B->signal_connect(changed => sub {
  $select_button->set_sensitive(1);
  $delete_button->set_sensitive(1);
});

$scan_button->signal_connect('clicked',sub {
  scan_networks();
});

$select_button->signal_connect('clicked',sub {
  select_network();
});

$delete_button->signal_connect('clicked',sub {
  delete_network();
});

$quit_button->signal_connect(clicked => sub {
  Gtk3->main_quit;
});

#########################################################
# Загрузка интерфейсов при старте
load_interfaces();

$window->show_all;
Gtk3->main;

Скрипт просто показывает в окошках найденные сети, позволяет что-то выбрать и установить пароль.
А для того, чтобы не требовалось запускать его от рута — во-первых, в скрипте start_wpa_supplicant.sh переназначаются права на control interface, добавляются права для группы netdev, а во-вторых, достаточно внести пользователя в группу netdev.

Таким образом, вся установка сводится к тому, чтобы установить необходимые пакеты:

sudo apt install wpasupplicant libgtk3-perl isc-dhcp-client iw

любым удобным способом прописать пользователя в группу netdev, и разложить файлы по своим местам

/etc/udev/rules.d/99-wifi-autostart.rules
/etc/wpa_supplicant/start_wpa_supplicant.sh
/etc/wpa_supplicant/start_wpa_dhcp.sh
/usr/local/bin/wifi_ctl.pl

Чем это лучше NetworkManager и компании? Тем, что не требует установки кучи всего, только самое необходимое.
Чем это лучше iwd, например? Не знаю. Может быть тем, что тут всё прозрачно, кто что делает, и всегда можно посмотреть, что и где пошло не так.
В конце концов, я для себя делал, меня вроде пока устраивает.

© Habrahabr.ru