Как стать автором
Обновить

Точечная маршрутизация на роутере с OpenWrt. WireGuard и DNSCrypt

Уровень сложностиСредний
Время на прочтение13 мин
Количество просмотров152K
Данный материал не является призывом к действию и публикуется исключительно в образовательных целях.

UPD 14.10.2023
Эта статья немного устарела. Здесь можно узнать, как использовать готовые списки IP-адресов, что не совсем актуально. Актуальная статья с маршрутизацией по доменам: https://habr.com/ru/articles/767464/


UPD 16.10.2022


  • Исправлены конфиги для Openwrt 22
  • Добавлен community список
  • В скрипт добавлена проверка загрузки файлов. Которая решает проблему, если при старте устройства не удалось сразу загрузить списки
  • DNSCrypt изменён на DNSCrypt v2

UPD 15.03.2023


  • Добавлена логика для работы с доменами, используются список доменов из community
  • Изменена проверка загрузки файлов в скрипте
  • В Ansible playbook теперь можно выбрать определённые списки

UPD 20.04.2023
Если у вас роутер получает IPv6 адрес, то роутинг будет работать криво. Пока нет инструкции для IPv6, поэтому нужно будет его выключить на роутере.


Часть 2: Поиск и исправление ошибок


Чем отличается от подобных материалов?


  • Реализация на чистом OpenWrt
  • Использование WireGuard
  • Конфигурация роутера организуется с помощью конфигов OpenWrt, а не кучей в одном скрипте
  • Предусмотрены ситуации при рестарте сети и перезагрузке
  • Потребляет мало ресурсов роутера: подсети содержатся в ipset, а не в таблицах маршрутизации. Что позволяет развернуть это дело даже на слабых устройствах
  • Автоматизация конфигурации с помощью Ansible (не требуется python на роутере)

Видеоверсия



Почему OpenWrt и WireGuard?


OpenWrt ставится на очень много моделей soho роутеров, конфигурируется и расширяется как душа пожелает. Сейчас многие прошивки роутеров — это надстройки над OpenWrt.


Wireguard используется из-за его быстрой и простой настройки, а так же из-за высокой скорости передачи через туннель.


Немного о WireGuard


В нашем случае сервер — это VPS, клиент — OpenWrt роутер дома. Когда вы захотите зайти на определенный ресурс в созданых списках, ваш роутер направит трафик через сервер с WireGuard.
WireGuard поднимает site-to-site соединение, т.е. и у сервера и у клиента имеется серверная и клиентская часть конфигурации. Если не понятно — станет понятно когда увидите конфигурацию.


У сервера и у клиента есть свои собственные приватный и публичный ключи.


Настройка WireGuard на сервере


Я проделываю всё на Ubuntu 18.04, но в официальной документации есть инструкции по установке для всех известных и не очень ОС.


Так же установку можно воспроизвести с помощью скрипта, например этого. Им удобно добавлять и удалять пользователей.


Установка


sudo add-apt-repository ppa:wireguard/wireguard

При возникновении ошибки
sudo: add-apt-repository: command not found


Установите software-properties-common — пакет предоставляет возможность добавления и удаления PPA
sudo apt install software-properties-common


sudo apt update
sudo apt install wireguard-dkms wireguard-tools

Генерируем ключи для сервера. Ключи сохраним в директории WireGuard для удобства


cd /etc/wireguard/
wg genkey | tee privatekey-server | wg pubkey > publickey-server

Соответственно в файле privatekey-server будет приватный ключ, а в publickey-server — публичный.
Так же сгенерируем сразу ключ для клиента:


wg genkey | tee privatekey-client | wg pubkey > publickey-client


Конфигурация


Конфиг хранится в /etc/wireguard/wg0.conf. Серверная часть выглядит так:


[Interface]
Address = 192.168.100.1
PrivateKey = privatekey-server
ListenPort = 51820
PostUp   = iptables -A FORWARD -i %i -j ACCEPT; iptables -A FORWARD -o %i -j ACCEPT; iptables -t nat -A POSTROUTING -o ens3 -j MASQUERADE
PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -D FORWARD -o %i -j ACCEPT; iptables -t nat -D POSTROUTING -o ens3 -j MASQUERADE

Address — адрес для интерфейса wg (адрес внутри туннеля)
PrivateKey — Приватный ключ (privatekey-server)
ListenPort — Порт на котором служба ожидает подключения


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


Клиентская часть


[Peer]
PublicKey = publickey-client
AllowedIPs = 192.168.100.3/24

PublicKey — публичный ключ нашего роутера (publickey-client)
AllowedIPs — подсети, которые будут доступны через этот туннель. Серверу требуется доступ только до адреса клиента.


Обе части хранятся в одном конфиге.


Включаем автозапуск при перезагрузке:


systemctl enable wg-quick@wg0

Делаем сервер маршрутизатором:


sysctl -w net.ipv4.ip_forward=1

Для постоянного действия в /etc/sysctl.conf добавляем или раскоментируем строку


net.ipv4.ip_forward=1

Настроим фаервол. Предположим, что у нас на сервере только WireGuard и ssh:


sudo iptables -A INPUT -i lo -j ACCEPT
sudo iptables -A INPUT -p udp -m udp --dport 51820 -j ACCEPT
sudo iptables -A INPUT -m state --state RELATED,ESTABLISHED -j ACCEPT
sudo iptables -A INPUT -p icmp -j ACCEPT
sudo iptables -A INPUT -p tcp -m tcp --dport 22 -j ACCEPT
sudo iptables -A INPUT -j DROP

Сохраним конфигурацию iptables:


sudo apt-get install iptables-persistent
sudo netfilter-persistent save

Поднимаем wg интерфейс первый раз вручную:


wg-quick up wg0


WireGuard сервер готов.


UPD 27.06.19 Если ваш провайдер до сих пор использует PPoE, то нужно добавить правило. Спасибо denix123


iptables -t mangle -I POSTROUTING -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu

Можно использовать и другие туннели:



Настройка роутера


Я использую OpenWrt версии 18.06.1 на Xiaomi mi 3G и Asus RT-N16.
UPD 16.10.2022: Так же это работает и на других версиях, лично тестировал на 20, 21 и 22. Для 22 версии конфиги ipset немного отличаются.


Логика работы роутера


Загружаем списки, помещаем их в ipset\nftables sets, все адреса из этих списков iptables помечает маркером 0x1. Далее все пакеты помеченные 0x1 идут в отдельную таблицу маршрутизации, все пакеты попавшие в эту таблицу маршрутизации идут через wg интерфейс.



Установка пакетов


Насчет занимаемого места на флеше, на всё понадобится примерно 0.9МБ. Если у вас совсем плохо с местом, замените curl wget'ом и можете не ставить dnscrypt-proxy.


Ставим пакеты. В OpenWrt это просто сделать через менеджер пакетов opkg:


opkg update
opkg install ipset wireguard-tools curl

UPD 16.10.2022: Для OpenWrt 22 не нужно устанавливать ipset.


Загрузка списков


Всё, что можно сделать через стандартные возможности OpenWrt, сделано через них. Всё остальное (кроме hotplug) я поместил в небольшой скрипт:
UPD 16.10.2022: Добавлены проверка загрузки, community список и версия с wget
UPD 16.03.2023: Проверки переделаны. Добавлена логика для конвертации списка доменов в конфиг dnsmasq.
Для domains нужен пакет dnsmasq-full. А для OpenWrt 22.03 версия dnsmasq-full должна быть => 2.87, её нет в официальном репозитории, но можно установить из dev репозитория. Инструкция по установке есть в моём тг канале.
Подробнее как работает роутинг по списку доменов можно прочитать тоже в моём тг канале.
Если времени для проверки не хватает, увеличивайте количество попыток и время ожидания в функции download.
Здесь включена загрузка всех списков.


UPD 02.04.2023: Добавлены start, stop, restart, reload. И отчистка ruleset при ошибке


netlink: Error: Could not process rule: No buffer space available
The rendered ruleset contains errors, not doing firewall restart.

Версия c curl


#!/bin/sh /etc/rc.common

START=99

script () {
    dir=/tmp/lst
    SUBNET=https://antifilter.download/list/subnet.lst
    IP=https://antifilter.download/list/ip.lst
    COMMUNITY=https://community.antifilter.download/list/community.lst
    DOMAINS=https://community.antifilter.download/list/domains.lst

    download () {
        count=0
        while [ ! -f $dir/$1 ]; do
        if [ $count -gt 10 ]; then
            echo Exit
            exit 1
        else
            echo "Try $count"
            curl -f -z $dir/$1 $2 --output $dir/$1
            count=$((count+1))
            sleep 5
        fi
        done
    }

    mkdir -p $dir

    # Для 22.03 версии, при ошибке `No buffer space available`
    #echo "Flush sets"
    #nft flush ruleset

    echo "Run download lists"
    download subnet.lst $SUBNET

    download ip.lst $IP

    download community.lst $COMMUNITY

    download domains.lst $DOMAINS

    sed "s/.*/nftset=\/&\/4#inet#fw4#vpn_domains/" $dir/domains.lst > /tmp/dnsmasq.d/domains
    # Для openwrt 21:
    # sed "s/.*/\/&/" $dir/domains.lst | sed -e "s/.*/ipset=&\/vpn_domains/" > /tmp/dnsmasq.d/domains

    echo "Dnsmasq restart"
    /etc/init.d/dnsmasq restart

    echo "Firewall restart"
    /etc/init.d/firewall restart
}

start () {
    script
}

restart () {
    script
}

reload () {
    script
}

Версия с wget


#!/bin/sh /etc/rc.common

START=99

script () {
    dir=/tmp/lst
    SUBNET=https://antifilter.download/list/subnet.lst
    IP=https://antifilter.download/list/ip.lst
    COMMUNITY=https://community.antifilter.download/list/community.lst
    DOMAINS=https://community.antifilter.download/list/domains.lst

    download () {
        count=0
        while [ ! -f $dir/$1 ]; do
        if [ $count -gt 10 ]; then
            echo Exit
            exit 1
        else
            echo "Try $count"
            wget -P $dir $2
            count=$((count+1))
            sleep 5
        fi
        done
    }

    mkdir -p $dir

    # Для 22.03 версии, при ошибке `No buffer space available`
    #echo "Flush sets"
    #nft flush ruleset

    echo "Run download lists"
    rm -f /$dir/subnet.lst && wget -P $dir $SUBNET
    download subnet.lst $SUBNET

    rm -f /$dir/ip.lst && wget -P $dir $IP
    download ip.lst $IP

    rm -f /$dir/community.lst && wget -P $dir $COMMUNITY
    download community.lst $COMMUNITY

    rm -f /$dir/domains.lst && wget -P $dir $DOMAINS
    download domains.lst $DOMAINS

    sed "s/.*/nftset=\/&\/4#inet#fw4#vpn_domains/" $dir/domains.lst > /tmp/dnsmasq.d/domains
    # Для openwrt 21:
    # sed "s/.*/\/&/" $dir/domains.lst | sed -e "s/.*/ipset=&\/vpn_domains/" > /tmp/dnsmasq.d/domains

    echo "Dnsmasq restart"
    /etc/init.d/dnsmasq restart

    echo "Firewall restart"
    /etc/init.d/firewall restart
}

start () {
    script
}

restart () {
    script
}

reload () {
    script
}

Списки запрещенных подсетей и адресов получаем файлами. Для них создаём директорию в /tmp. В /tmp — потому что это RAM, такая особенность OpenWrt, довольно удобная. На ROM роутера что-то писать лишний раз не стоит.


Выкачиваем списки с antifilter.download curl'ом, флаг z означает, что curl будет скачивать файл, только если удаленный файл отличается от локального или если его нет, как например в случае при загрузке роутера.
Используйте wget, если у вас есть проблемы с ssl или нет места для curl.


subnet.lst — список заблокированных подсетей, изменяется не часто.
ip.lst — список заблокированных адресов, прям из списка РКН
community.lst — список заблокированных адресов, который составляется комьюнити antifilter.download


Списки предоставлены для образовательных целей, можете составить свои.


Вы можете использовать комбинацию любых списков. Например заместо ip.lst можно использовать ipsum который суммаризирован по маске. Вместо 150 тысяч записей получаем 15 тысяч. Но при этом некоторые ресурсы будут тоже идти через туннель.


После того как файлы у нас — рестартуем firewall, это нужно для того что бы ipset отработал и добавил списки в iptables, ipset у нас будет сконфигурен в /etc/config/firewall.


Скрипт этот мы добавляем в /etc/init.d/ назовём hivpn. Сделаем его исполняемым


chmod +x /etc/init.d/hivpn

Теперь у нас не просто скрипт, а целая служба. Для того, что бы он запускался при загрузке, делаем симлинк в /etc/rc.d. Нам нужно, что бы он запускался после всех остальных служб, поэтому делаем приставку S99


ln -s /etc/init.d/hivpn /etc/rc.d/S99hivpn

Списки нужно обновлять время от времени, добавляем запись в cron:


crontab -e

0 4 * * * /etc/init.d/hivpn start

Мне кажется вполне достаточным обновлять их раз в сутки. Имейте в виду, что при добавлении списков в ipset, отваливается сеть, в моём случае это 2 секунды.
UPD: Если не хотите разрывов, то sigo73 и Grayver подсказали в комментариях как это осуществить.


Так же включите крон, по дефолту он отключен:


/etc/init.d/cron enable
/etc/init.d/cron start

Конфигурация таблицы маршрутизации


Создаем таблицу маршрутизации для трафика через туннель, просто добавив строку:


99  vpn

в файл /etc/iproute2/rt_tables.


Создать дефолтный маршрут для таблицы "vpn" через wg интерфейс можно командой (но не нужно выполнять, это вызовет ошибку т.к. интерфейс wg0 ещё не создан):


ip route add table vpn default dev wg0

Но при рестарте сети маршрут пропадёт, поэтому создаём файл 30-vpnroute в директории /etc/hotplug.d/iface/ с простым содержимым:


#!/bin/sh

ip route add table vpn default dev wg0

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


Конфигурация сети


Нам необходимо сконфигурировать WireGuard и правило для пакетов с меткой 0x1.


Конфигурация WireGuard располагается в /etc/config/network


"Серверная" часть:


config interface 'wg0'
        option private_key 'privatekey-client'
        list addresses '192.168.100.3/24'
        option listen_port '51820'
        option proto 'wireguard'

private_key — это privatekey-client, который мы генерировали при настройке сервера
list addresses — адрес wg интерфейса
listen_port — порт на котором WireGuard принимает соединения. Но соединение будет происходить через порт на сервере, поэтому здесь мы не будем открывать для него порт на firewall
proto — указываем протокол, что бы openwrt понимало что это конфигурация WireGuard


"Клиентская" часть:


config wireguard_wg0
        option public_key 'publickey-server'
        option allowed_ips '0.0.0.0/0'
        option route_allowed_ips '0'
        option endpoint_host 'wg-server-ip'
        option persistent_keepalive '25'
        option endpoint_port '51820'

public_key — ключ publickey-server
allowed_ips — подсети, в которые может ходить трафик через тунель, в нашем случае никаких ограничей не требуется, поэтому 0.0.0.0/0
route_allowed_ips — флаг, который делает роут через wg интерфейс для перечисленных сетей из параметра allowed_ips. В нашем случае это не нужно, эту работу выполняет iptables
endpoint_host — ip/url нашего wg сервера
persistent_keepalive — интервал времени, через который отправляются пакеты для поддержки соединения
endpoint_port — порт wireguard на сервере
Если на сервере настроен preshared_key, то его нужно задать на клиенте как ещё одну option


        option preshared_key '$KEY'

Ещё в конфигурацию network добавим правило, которое будет отправлять весь трафик, помеченный 0x1, в таблицу маршрутизации "vpn":


config rule
        option priority '100'
        option lookup 'vpn'
        option mark '0x1'

Конфигурация firewall


Конфигурация фаервола находится в /etc/config/firewall


Добавляем зону для wireguard. В openwrt зоны — это кастомные цепочки в iptables. Таким образом создаётся зона с одним\несколькими интерфейсами и уже на неё вешаются правила. Зона для wg выглядит например вот так:


config zone
        option name 'wg'
        option family 'ipv4'
        option masq '1'
        option output 'ACCEPT'
        option forward 'REJECT'
        option input 'REJECT'
        option mtu_fix '1'
        option network 'wg0'

Мы разрешаем только выход трафика из интерфейса и включаем маскарадинг.


Теперь нужно разрешить переадресацию с lan зоны на wg зону:


config forwarding
        option src 'lan'
        option dest 'wg'

Настроим формирование списков в ipset или nftables sets начиная с 22 версии
UPD 16.10.2022: Для OpenWrt 22 не нужно указывать переменные storage, hashsize, maxelem. При их указании fw4 выдаст warning:


Section @ipset[0] (vpn_subnets) option 'storage' is not supported by fw4
Section @ipset[0] (vpn_subnets) option 'hashsize' is not supported by fw4

Таким образом, для OpenWrt 22 эта секция выглядит таким образом:


config ipset
        option name 'vpn_ip'
        option match 'dst_net'
        option loadfile '/tmp/lst/ip.lst'

config ipset
        option name 'vpn_subnets'
        option match 'dst_net'
        option loadfile '/tmp/lst/subnet.lst'

config ipset
        option name 'vpn_community'
        option match 'dst_net'
        option loadfile '/tmp/lst/community.lst'

config ipset
        option name 'vpn_domains'
        option match 'dst_net'

Для OpenWrt версий ниже 22


config ipset
        option name 'vpn_ip'
        option match 'dst_net'
        option loadfile '/tmp/lst/ip.lst'
        option storage 'hash'
        option hashsize '9900000'
        option maxelem '9900000'

config ipset
        option name 'vpn_subnets'
        option match 'dst_net'
        option loadfile '/tmp/lst/subnet.lst'
        option storage 'hash'

config ipset
        option name 'vpn_community'
        option match 'dst_net'
        option loadfile '/tmp/lst/community.lst'
        option storage 'hash'
        option hashsize '9900000'
        option maxelem '9900000'

config ipset
        option name 'vpn_domains'
        option match 'dst_net'
        option storage 'hash'

loadfile — файл из которого берем список
name — имя для нашего списка
storage, match — здесь указываем как хранить и какой тип данных. Будем хранить тип "подсеть"


Для версий OpenWrt ниже 22 необходимо указывать hashsize и maxelem, иначе будете получать ошибку


ipset v6.38: Hash is full, cannot add more elements

Добавим правила маркировки пакетов


config rule
        option name 'mark_subnet'
        option src 'lan'
        option dest '*'
        option proto 'all'
        option ipset 'vpn_subnets'
        option set_mark '0x1'
        option target 'MARK'
        option family 'ipv4'

config rule
        option name 'mark_ip'
        option src 'lan'
        option dest '*'
        option proto 'all'
        option ipset 'vpn_ip'
        option set_mark '0x1'
        option target 'MARK'
        option family 'ipv4'

config rule
        option name 'mark_community'
        option src 'lan'
        option dest '*'
        option proto 'all'
        option ipset 'vpn_community'
        option set_mark '0x1'
        option target 'MARK'
        option family 'ipv4'

config rule
        option name 'mark_domains'
        option src 'lan'
        option dest '*'
        option proto 'all'
        option ipset 'vpn_domains'
        option set_mark '0x1'
        option target 'MARK'
        option family 'ipv4'

Эти правила подразумевают под собой, что все пакеты идущие в подсети из списков vpn_subnets, vpn_ip и vpn_community необходимо помечать маркером 0x1.


После этого рестартуем сеть:


/etc/init.d/network restart


и запускаем скрипт:


/etc/init.d/hivpn start


После отработки скрипта у вас должно всё заработать. Проверьте маршрут на клиенте роутера:


mtr/traceroute graylog.org


Бонусом настроим DNSCrypt


UPD 16.10.2022: Изменено на DNSCrypt v2.


Что бы удалить первую версию


opkg remove dnscrypt-proxy

Зачем? Ваш провайдер может заботливо подменять ip адреса некоторых ресурсов, таким образом перенаправляя вас на свой ip с заглушкой, ну и роутинг по ip в данном случае не поможет. Для подмены не всегда даже нужно использовать dns сервер провайдера, ваши запросы могут перехватываться и ответы подменяться. Ну и к слову, это может делать не только провайдер.


opkg install dnscrypt-proxy2

Dnscrypt v2 работает сразу из коробки, его настраивать не обязательно. Из всего пула серверов он выбирает доступные и получает данные от них.


Если вы хотите задать только опредленные dns серверы, с которыми он должен работать в конфиге /etc/dnscrypt-proxy2/dnscrypt-proxy.toml раскоментируйте server_names и задайте нужные вам резолверы. Весь список можно посмотреть здесь https://dnscrypt.info/public-servers. Пример конфигурации с несколькими выбраными резолверами:


server_names = ['google', 'cloudflare', 'scaleway-fr', 'yandex']

Во второй версии DNSCrypt по дефолту работает тоже на 53 порту, но на другом адресе из диапазона локальных адресов


127.0.0.53:53

Как и для первой версии, нужно настроить dnsmasq на работу с ним. В /etc/config/dhcp добавляем к config dnsmasq


        list server '/pool.ntp.org/208.67.222.222'
        list server '127.0.0.53#53'
        option noresolv '1'

noresolv отключает сервера провайдера
Запись list server ‘domain/ip_dns’ указывает какой dns сервер использовать для резолва указанного домена. Таким образом мы не задействуем dnscrypt для синхронизации ntp — для работы службе dnscrypt важно иметь актуальное время.


Во второй версии нет проблемы с резолвингом адреса antifilter.download при старте и добавлять в конфиг больше не нужно.


Отключаем использование провайдерских DNS для интерфейса wan
В /etc/config/network добавляем строку


option peerdns '0'

к интерфейсу wan.
Получаем такую конфигурацию (ifname может отличаться)


config interface 'wan’
        option ifname 'eth0.2’
        option proto 'dhcp’
        option peerdns ‘0’

Рестартуем сеть


/etc/init.d/network restart

Добавляем в автозагрузку и стартуем dnscrypt:


/etc/init.d/dnscrypt-proxy enable
/etc/init.d/dnscrypt-proxy start

Рестартуем dnsmasq:


/etc/init.d/dnsmasq restart


Илюстрация работы без DNSCrypt и c DNSCrypt


Автоматически развертываем с помощью Ansible


Playbook и темплейты лежат на github


UPD 16.03.2023: Добавлен выбор списков, все подробности в README


По этой части все обновы и настройки лучше читать в README репозитория, там я обновляю всё чаще и первым делом изменения появляются там.


Используется модуль, в нём не нужен python на роутере и есть поддержка uci.
Я постарался сделать так, что бы ваша конфигурация OpenWrt осталась не тронутой, но всё равно будьте бдительны.


Устанавливаем модуль gekmihesg/ansible-openwrt:


ansible-galaxy install gekmihesg.openwrt

Копируем плейбук и темлпейты:


cd /etc/ansible
git clone https://github.com/itdoginfo/domain-routing-openwrt
mv domain-routing-openwrt/* .
rm -rf domain-routing-openwrt

Добавляйте ваш роутер в hosts:


[openwrt]
192.168.1.1

Подставляете свои переменные в hivpn.yml


  vars:
    ansible_template_dir: /etc/ansible/templates/
    wg_server_address: wg_server_ip/url
    wg_private_key: privatekey-client
    wg_public_key: publickey-server
    wg_listen_port: 51820
    wg_client_port: 51820
    wg_client_address: 192.168.100.3/24

Обязательно нужно задать:


wg_server_address — ip/url wireguard сервера
wg_private_key, wg_public_key — приватный ключ клиента и публичный сервера
Остальное можно не менять или менять, в зависимости от того как настроен WireGuard сервер


Запускаем playbook


ansible-playbook playbooks/hivpn.yml

После выполнения плейбука, роутер сразу начнёт выполнять роутинг через ваш wireguard сервер.


Почему не BGP?


Статья про BGP




В комментариях к подобным статьям я видел, что роутеры у людей "призадумывались" на некоторое время, когда те загоняют списки в таблицу маршрутизации. С реализацией через ipset мой Xiaomi mi 3G задумывается на 2 секунды (Asus rt-n16 на 5 секунд), когда скармливаешь ему список из 15ти тысяч подсетей. При дальнейшей работе нагрузки на процессор не замечал.


Все материалы не являются призывом к действию и представлены для ознакомления с функционалом ОС Linux.


Телеграм канал с обновлениями https://t.me/itdoginfo

Теги:
Хабы:
Всего голосов 62: ↑61 и ↓1+60
Комментарии107

Публикации

Истории

Работа

Ближайшие события

15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
22 – 24 ноября
Хакатон «AgroCode Hack Genetics'24»
Онлайн
28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань