Feature/fleet control #1

Merged
cyber-zhaba merged 10 commits from feature/fleet-control into master 2026-10-03 18:57:23 +03:00
18 changed files with 392 additions and 115 deletions
+1
View File
@@ -1,2 +1,3 @@
keys/
vars/wifi.yml
*.retry
+77 -31
View File
@@ -1,47 +1,93 @@
# fleet
Идемпотентный провижнинг парка приёмников пеленгации (Raspberry Pi + HackRF).
Один прогон приводит новую или старую малинку в рабочее состояние: обновление
системы, зависимости сборки, общий deploy-ключ до Gitea, клон и сборка
`hackrf-scanner`.
Гоняется дома, пока узлы в сети с интернетом. После переноса в изолированную
сеть (коммутатор, статические IP) на карте уже есть всё; правки кода дальше
идут `rsync` + сборка на месте. Статические IP playbook не трогает.
Плейбуки ansible, которые приводят любую малинку парка (Raspberry Pi +
HackRF) в рабочее состояние и управляют ею дальше: домашний Wi-Fi ради
интернета, пакеты, код `hackrf-scanner` со сборкой на месте, паспорт узла,
запуск и остановка сканера, перезагрузка и выключение.
Управление идёт по проводной сети 11.0.0.0/24 (`ansible_host` в инвентаре),
Wi-Fi нужен только на время установки пакетов и перед работой выключается,
потому что адаптер сидит в 2,4 ГГц рядом с приёмником.
## Требования
- на управляющей машине: `ansible` (Arch: `pacman -S ansible`), доступ по ssh
к узлам под `pi` по ключу;
- на узлах: Raspberry Pi OS (Debian 13), `pi` с sudo без пароля, интернет.
- управляющая машина: `ansible` с коллекциями `community.general` и
`ansible.posix`, `rsync`, `sshpass` (только для `bootstrap.yml`),
рядом лежит каталог `../hackrf-scanner` (переменная `scanner_src`);
Alt Linux p11 и Sisyphus: `apt-get install ansible
python3-module-ansible-collections-ansible-posix sshpass rsync openssh-clients`;
на p10 пакет `ansible` 2.9 не подходит, нужны `ansible-core
python3-module-packaging glibc-locales` и `ansible-galaxy collection install
community.general ansible.posix`;
- узлы: Raspberry Pi OS Lite (Debian 13, NetworkManager), пользователь `pi`
с sudo без пароля, статический адрес 11.0.0.x на eth0; на полном образе
с рабочим столом первый `full-upgrade` по Wi-Fi идёт больше часа;
- `vars/wifi.yml` (в `.gitignore`) по образцу `vars/wifi.yml.example`.
## Первый запуск
## Новый узел
scripts/gen-key.sh # пара keys/gitea_deploy (в .gitignore)
# добавить keys/gitea_deploy.pub как deploy key (read-only)
# в Gitea: IU9/hackrf-scanner
ansible-playbook playbook.yml # весь парк
```bash
nmap -sn 11.0.0.0/24 # какие узлы в сети
ansible-playbook bootstrap.yml -l node107 # ключ по паролю pi/raspberry
ansible-playbook wifi-on.yml # Wi-Fi и проверка интернета
ansible-playbook playbook.yml # пакеты, права, имя
ansible-playbook wifi-off.yml # Wi-Fi выключен до следующего раза
ansible-playbook deploy.yml # код с этой машины, сборка на узле
ansible-playbook env.yml # паспорт ~/node.env из инвентаря
```
## Теги
## Работа
base apt upgrade, зависимости, pi в plugdev
hostname имя узла из инвентаря
key deploy-ключ, ssh config, known_hosts
repo clone/pull ветки scanner_branch (stash локальных правок)
build cmake configure + build
```bash
# 1. расстановка: x, y узлов и server_ip в inventory/hosts.yml
ansible-playbook env.yml --diff # 2. паспорта на узлы
# 3. частота и усиления: inventory/group_vars/all/scanner.yml
ansible-playbook scanner-start.yml # 4. peleng на всех узлах
ansible-playbook scanner-start.yml -e scanner_args="--freq 2437" # разово поверх
ansible-playbook scanner-stop.yml # HackRF свободен (scan, калибровка)
```
ansible-playbook playbook.yml -l node106 --tags repo,build
`scanner-start.yml` на каждом узле зовёт `node.py start <профиль> <флаги>`:
останавливает прежний peleng и любой процесс, держащий HackRF, переинициализирует
USB и запускает peleng в фоне с логом `/tmp/peleng.log`. Профиль и флаги лежат в
`inventory/group_vars/all/scanner.yml` (`scanner_profile`, `scanner_args`); узлу
можно задать своё значение в `inventory/hosts.yml`, разовый запуск поверх всего
через `-e`. peleng читает паспорт
только при старте, поэтому после `env.yml` нужен повторный `scanner-start.yml`.
Новый узел сначала вписывается в `inventory/hosts.yml`. Смена роутера: правка
`vars/wifi.yml` и `wifi-on.yml`, профиль на узлах обновится.
## Плейбуки
| Плейбук | Что делает |
|---|---|
| `bootstrap.yml` | ключ управляющей машины в `authorized_keys`, вход по паролю |
| `wifi-on.yml` | радио включено, профиль `fleet-wifi` с кредами из `vars/wifi.yml`, подключение, проверка DNS и HTTPS |
| `playbook.yml` | теги `base` (apt, зависимости, plugdev, udev для HackRF, fish), `hostname`, `key` (deploy-ключ Gitea) |
| `wifi-off.yml` | радио выключено, NetworkManager помнит это через перезагрузку |
| `deploy.yml` | `rsync` исходников без `.git` и `build`, ревизия в `~/hackrf-scanner/DEPLOYED`, `cmake` сборка, проверка бинарников |
| `env.yml` | `~/node.env` (`SDR_ID`, `X`, `Y`, `SERVER`) из инвентаря, перезаписывается целиком |
| `scanner-start.yml` | `node.py start`: освободить HackRF, переинициализировать USB, peleng в фоне, `status` |
| `scanner-stop.yml` | `node.py stop`: peleng и любой процесс с HackRF остановлены, проверка `status` |
| `reboot.yml` | перезагрузка с ожиданием возврата, печатает ядро |
| `poweroff.yml` | штатное выключение и ожидание, пока узел пропадёт из сети |
Ограничение по узлам и тегам как обычно: `-l node106`, `--tags base`.
## Инвентарь
`inventory/hosts.yml`: адреса узлов в домашней сети (транзитные), а также
справочно -- статический IP для поля, серийник HackRF и координаты x, y (м).
Ветка репозитория -- переменная `scanner_branch`.
`inventory/hosts.yml`: адрес узла в проводной сети, `sdr_id`, серийник HackRF,
координаты x, y в метрах. Общие переменные: `server_ip` (куда узлы шлют замеры),
`scanner_dir` (каталог сканера на узле). Параметры запуска сканера - в
`inventory/group_vars/all/scanner.yml`. Инвентарь главный: `env.yml` перезаписывает паспорт узла,
правки руками на узле до следующего прогона.
## Приёмка
1. Прогон без `failed`.
2. Второй прогон подряд -- `changed=0` на всех узлах.
3. На узле: `hackrf_info` видит устройство, `~/hackrf-scanner/build/` собран,
`git rev-parse --abbrev-ref HEAD` = `scanner_branch`, `hostname` = имя узла.
4. `ssh -T git@monitor.yss.su` с узла отвечает Gitea.
1. Все плейбуки без `failed`, второй прогон подряд `changed=0`.
2. После `wifi-on.yml` каждый узел печатает адрес в домашней сети и
"интернет есть", после `wifi-off.yml` у `wlan0` нет адреса.
3. На узле `~/hackrf-scanner/build/peleng` и `scan` собраны, `DEPLOYED`
совпадает с `git describe` на управляющей машине, `hostname` = имя узла.
4. После `scanner-start.yml` каждый узел печатает "peleng запущен" и строку
замера, после `scanner-stop.yml` статус "не запущен".
+2
View File
@@ -3,6 +3,8 @@ inventory = inventory/hosts.yml
remote_user = pi
host_key_checking = False
interpreter_python = auto_silent
# Все узлы парка одновременно: старт сканера ждёт USB и первый замер ~15 с на узел
forks = 16
[ssh_connection]
pipelining = True
+13
View File
@@ -0,0 +1,13 @@
# Голый узел: вход по паролю pi/raspberry, кладём ключ управляющей машины.
# После этого остальные плейбуки ходят по ключу.
# ansible-playbook bootstrap.yml -l node107
- name: Ключ управляющей машины на узел
hosts: all
gather_facts: false
vars:
ansible_password: raspberry
tasks:
- name: Публичный ключ в authorized_keys пользователя pi
ansible.posix.authorized_key:
user: pi
key: "{{ lookup('file', control_pubkey) }}"
+56
View File
@@ -0,0 +1,56 @@
# Исходники hackrf-scanner с управляющей машины (scanner_src) на узлы и сборка
# на месте. Интернет не нужен, зависимости ставит playbook.yml (тег base).
# Что уехало, видно в ~/hackrf-scanner/DEPLOYED (git describe).
- name: Код hackrf-scanner на узлы и сборка
hosts: all
gather_facts: false
tasks:
- name: Ревизия исходников на управляющей машине
delegate_to: localhost
run_once: true
ansible.builtin.command: git -C {{ scanner_src }} describe --always --dirty
register: rev
changed_when: false
- name: Исходники на узле (без .git и build), лишние файлы удаляются
ansible.posix.synchronize:
src: "{{ scanner_src }}/"
dest: "{{ scanner_dir }}/"
delete: true
rsync_opts:
- "--exclude=.git"
- "--exclude=build*"
- "--exclude=.cache"
- "--exclude=__pycache__"
- "--exclude=DEPLOYED"
- name: След клона прежнего провижнинга не нужен
ansible.builtin.file:
path: "{{ scanner_dir }}/.git"
state: absent
- name: Метка ревизии
ansible.builtin.copy:
content: "{{ rev.stdout }}\n"
dest: "{{ scanner_dir }}/DEPLOYED"
mode: "0644"
- name: Сборка
ansible.builtin.shell: |
cmake -B build -DCMAKE_BUILD_TYPE=RelWithDebInfo >/dev/null
cmake --build build -j"$(nproc)"
args:
chdir: "{{ scanner_dir }}"
register: build
changed_when: "'Building CXX' in build.stdout or 'Linking' in build.stdout"
- name: Бинарники на месте
ansible.builtin.stat:
path: "{{ scanner_dir }}/build/{{ item }}"
register: bins
failed_when: not bins.stat.exists
loop: [peleng, scan]
- name: Итог по узлу
ansible.builtin.debug:
msg: "[+] {{ inventory_hostname }}: {{ rev.stdout }} собран"
+22
View File
@@ -0,0 +1,22 @@
# Паспорт узла ~/node.env из инвентаря: SDR_ID, X, Y, SERVER. Инвентарь главный,
# файл на узле перезаписывается целиком. peleng читает паспорт только при
# старте, поэтому после смены расстановки нужен scanner-start.yml.
# ansible-playbook env.yml --diff
- name: Паспорт узла из инвентаря
hosts: all
gather_facts: false
tasks:
- name: ~/node.env
ansible.builtin.copy:
dest: ~/node.env
mode: "0644"
content: |
SDR_ID={{ sdr_id }}
X={{ x }}
Y={{ y }}
SERVER={{ server_ip }}
register: env
- name: Итог по узлу
ansible.builtin.debug:
msg: "[{{ '+' if env.changed else 'i' }}] {{ inventory_hostname }}: SDR_ID={{ sdr_id }} X={{ x }} Y={{ y }} SERVER={{ server_ip }}{{ ' (обновлён)' if env.changed else '' }}"
+3
View File
@@ -0,0 +1,3 @@
apt-get install ansible-core python3-module-packaging glibc-locales sshpass rsync openssh-clients
export LANG=en_US.UTF-8
ansible-galaxy collection install community.general ansible.posix
+12
View File
@@ -0,0 +1,12 @@
# Как запускать сканер: scanner-start.yml берёт эти значения на всех узлах.
# Правка тут, затем ansible-playbook scanner-start.yml.
#
# Узлу можно задать своё значение в inventory/hosts.yml (например, другой
# --lna у PortaPack node104), оно перекроет этот файл. Разовый запуск поверх
# всего: ansible-playbook scanner-start.yml -e scanner_args="--freq 2437".
#
# Профили node.py: dji24, vtx58, lte, pp24, pn24. Флаги peleng переопределяют
# профиль; калибровка сервера должна быть снята с теми же --lna и --vga.
scanner_profile: dji24
scanner_args: "--freq 2422 --lna 8"
+34 -18
View File
@@ -1,37 +1,53 @@
# Координаты x, y в метрах, замер 27.09.2026 (комната).
all:
vars:
scanner_repo: ssh://git@monitor.yss.su:2222/IU9/hackrf-scanner.git
scanner_branch: feature/scanner-v2
scanner_src: "{{ playbook_dir }}/../hackrf-scanner"
control_pubkey: ~/.ssh/id_ed25519.pub
gitea_host: monitor.yss.su
gitea_port: 2222
server_ip: 11.0.0.100
scanner_dir: /home/pi/hackrf-scanner
hosts:
node102:
ansible_host: 192.168.1.45
ansible_host: 11.0.0.102
sdr_id: 1
static_ip: 11.0.0.102
sdr_serial: a32868dc35944247
x: -0.68
y: -1.28
x: 0.34
y: 0.47
node103:
ansible_host: 11.0.0.103
sdr_id: 6
sdr_serial: d63861dc2d9b4353
x: 0.77
y: 0.60
node104:
ansible_host: 192.168.1.50
ansible_host: 11.0.0.104
sdr_id: 2
static_ip: 11.0.0.104
sdr_serial: 42a068dc377ab507
note: PortaPack
x: 2.28
y: 0.45
node105:
ansible_host: 192.168.1.87
ansible_host: 11.0.0.105
sdr_id: 3
static_ip: 11.0.0.105
sdr_serial: a32868dc33894f47
x: 3.77
y: -0.74
node106:
ansible_host: 192.168.1.42
sdr_id: 4
static_ip: 11.0.0.106
sdr_serial: a32868dc346d2847
x: 0.00
y: 0.50
y: -1.02
node106:
ansible_host: 11.0.0.106
sdr_id: 4
sdr_serial: a32868dc346d2847
x: 3.77
y: -0.78
node107:
ansible_host: 11.0.0.107
sdr_id: 5
sdr_serial: b25062dc2242970b
x: 3.77
y: 0.00
node108:
ansible_host: 11.0.0.108
sdr_id: 7
sdr_serial: a32868dc33324c47
x: -0.74
y: 0.47
+5 -57
View File
@@ -1,3 +1,5 @@
# Базовый провижнинг: пакеты, права на HackRF, имя узла, deploy-ключ.
# Нужен интернет на узлах (wifi-on.yml). Код на узлы кладёт deploy.yml.
- name: Провижнинг приёмников пеленгации
hosts: all
tasks:
@@ -16,10 +18,10 @@
upgrade: full
tags: [base]
- name: Зависимости сборки и утилиты HackRF
- name: Зависимости сборки, утилиты HackRF, python для node.py
become: true
ansible.builtin.apt:
name: [cmake, g++, pkg-config, git, rsync, libhackrf-dev, libusb-1.0-0-dev, libfftw3-dev, hackrf, fish]
name: [cmake, g++, pkg-config, git, rsync, libhackrf-dev, libusb-1.0-0-dev, libfftw3-dev, hackrf, python3, fish]
state: present
tags: [base]
@@ -76,7 +78,7 @@
- name: Ключа нет
run_once: true
ansible.builtin.fail:
msg: "[!] нет keys/gitea_deploy -- запустите scripts/gen-key.sh"
msg: "[!] нет keys/gitea_deploy, запустите scripts/gen-key.sh"
when: not deploy_key.stat.exists
tags: [key]
@@ -108,57 +110,3 @@
IdentitiesOnly yes
StrictHostKeyChecking accept-new
tags: [key]
- name: ~/node.env для обёртки node.sh (только засев, полевые правки не трогаем)
ansible.builtin.copy:
dest: ~/node.env
mode: "0644"
force: false
content: |
SDR_ID={{ sdr_id }}
X={{ x }}
Y={{ y }}
SERVER={{ server_ip }}
tags: [env]
- name: Репозиторий уже клонирован?
ansible.builtin.stat:
path: ~/hackrf-scanner/.git
register: repo_dir
tags: [repo]
- name: Локальные правки в репо (rsync-разработка)?
ansible.builtin.command: git status --porcelain
args:
chdir: ~/hackrf-scanner
register: dirty
changed_when: false
when: repo_dir.stat.exists
tags: [repo]
- name: Stash локальных правок перед обновлением
ansible.builtin.command: git stash push -m fleet-provision
args:
chdir: ~/hackrf-scanner
when: repo_dir.stat.exists and dirty.stdout != ""
tags: [repo]
- name: Репозиторий hackrf-scanner
ansible.builtin.git:
repo: "{{ scanner_repo }}"
dest: ~/hackrf-scanner
version: "{{ scanner_branch }}"
update: true
force: true
register: repo
tags: [repo]
- name: Сборка
ansible.builtin.shell: |
cmake -B build -DCMAKE_BUILD_TYPE=RelWithDebInfo >/dev/null
cmake --build build -j4
args:
chdir: ~/hackrf-scanner
register: build
changed_when: "'Building CXX' in build.stdout or 'Linking' in build.stdout"
tags: [build]
+18
View File
@@ -0,0 +1,18 @@
# Штатное выключение всех узлов перед снятием питания.
# Плейбук ждёт, пока ssh каждого узла перестанет отвечать.
- name: Выключение узлов
hosts: all
gather_facts: false
tasks:
- name: poweroff
become: true
community.general.shutdown:
delay: 0
- name: Узел ушёл из сети
delegate_to: localhost
ansible.builtin.wait_for:
host: "{{ ansible_host }}"
port: 22
state: stopped
timeout: 90
+19
View File
@@ -0,0 +1,19 @@
# Перезагрузка узлов с ожиданием возврата по ssh (например, после обновления
# ядра). В конце каждый узел печатает ядро, на котором поднялся.
- name: Перезагрузка узлов
hosts: all
gather_facts: false
tasks:
- name: reboot и ожидание возврата
become: true
ansible.builtin.reboot:
reboot_timeout: 300
- name: Ядро после перезагрузки
ansible.builtin.command: uname -r
register: kernel
changed_when: false
- name: Итог по узлу
ansible.builtin.debug:
msg: "[+] {{ inventory_hostname }}: поднялся, ядро {{ kernel.stdout }}"
-9
View File
@@ -1,9 +0,0 @@
#!/bin/sh
# run.sh [аргументы ansible-playbook] примеры:
# ./run.sh весь парк, все теги
# ./run.sh --tags base -l node106
# ./run.sh --check --diff сухой прогон
set -e
cd "$(dirname "$0")"
[ -f keys/gitea_deploy ] || { echo "[!] нет keys/gitea_deploy -- запустите scripts/gen-key.sh"; exit 1; }
exec ansible-playbook playbook.yml "$@"
+23
View File
@@ -0,0 +1,23 @@
# Запуск peleng на всех узлах одной командой node.py start: остановить прежний
# peleng и любой процесс, держащий HackRF, переинициализировать USB, запустить
# в фоне (лог /tmp/peleng.log). Профиль и флаги в inventory/group_vars/all/scanner.yml,
# разово поверх: ansible-playbook scanner-start.yml -e scanner_args="--freq 2437"
- name: Запуск сканера на узлах
hosts: all
gather_facts: false
tasks:
- name: node.py start {{ scanner_profile }} {{ scanner_args }}
ansible.builtin.command:
argv: "{{ ['python3', scanner_dir + '/scripts/node.py', 'start', scanner_profile] + scanner_args.split() }}"
register: start
changed_when: true
- name: node.py status
ansible.builtin.command:
argv: ["python3", "{{ scanner_dir }}/scripts/node.py", "status"]
register: status
changed_when: false
- name: Итог по узлу
ansible.builtin.debug:
msg: "{{ start.stdout_lines + status.stdout_lines }}"
+22
View File
@@ -0,0 +1,22 @@
# Остановка peleng на всех узлах: node.py stop останавливает свой peleng и любой
# процесс, держащий HackRF. После этого HackRF свободен для scan или калибровки.
- name: Остановка сканера на узлах
hosts: all
gather_facts: false
tasks:
- name: node.py stop
ansible.builtin.command:
argv: ["python3", "{{ scanner_dir }}/scripts/node.py", "stop"]
register: stop
changed_when: stop.stdout | length > 0
- name: node.py status
ansible.builtin.command:
argv: ["python3", "{{ scanner_dir }}/scripts/node.py", "status"]
register: status
changed_when: false
failed_when: "'работает' in status.stdout"
- name: Итог по узлу
ansible.builtin.debug:
msg: "{{ stop.stdout_lines + status.stdout_lines }}"
+4
View File
@@ -0,0 +1,4 @@
# Домашняя сеть для провижнинга (apt, обновления). Скопировать в vars/wifi.yml,
# он в .gitignore. Смена роутера - смена этих двух строк и прогон wifi-on.yml.
wifi_ssid: Keenetic-6852
wifi_psk: пароль
+19
View File
@@ -0,0 +1,19 @@
# Перед работой Wi-Fi узлов выключается: адаптер сидит в 2,4 ГГц рядом
# с приёмником. Состояние радио NetworkManager помнит через перезагрузку,
# обратно включает только wifi-on.yml.
- name: Wi-Fi выключить
hosts: all
gather_facts: false
tasks:
- name: Радио Wi-Fi выключено
become: true
ansible.builtin.shell: |
if [ "$(nmcli radio wifi)" = disabled ]; then echo OK; else nmcli radio wifi off && echo CHANGED; fi
register: radio
changed_when: "'CHANGED' in radio.stdout"
- name: wlan0 без адреса
ansible.builtin.command: ip -4 -o addr show wlan0
register: wlan
changed_when: false
failed_when: wlan.stdout != ""
+62
View File
@@ -0,0 +1,62 @@
# Узлы подключаются к домашней сети (vars/wifi.yml) ради интернета для apt.
# Проводная сеть 11.0.0.0/24 без шлюза, поэтому маршрут по умолчанию
# и DNS приходят от Wi-Fi. В конце каждый узел обязан достучаться до интернета.
- name: Wi-Fi включить и подключить к домашней сети
hosts: all
gather_facts: false
vars_files:
- vars/wifi.yml
vars:
wifi_con: fleet-wifi
tasks:
- name: Радио Wi-Fi включено
become: true
ansible.builtin.shell: |
if [ "$(nmcli radio wifi)" = enabled ]; then echo OK; else nmcli radio wifi on && echo CHANGED; fi
register: radio
changed_when: "'CHANGED' in radio.stdout"
- name: Профиль {{ wifi_con }} с актуальными SSID и паролем
become: true
community.general.nmcli:
conn_name: "{{ wifi_con }}"
ifname: wlan0
type: wifi
ssid: "{{ wifi_ssid }}"
wifi_sec:
key-mgmt: wpa-psk
psk: "{{ wifi_psk }}"
autoconnect: true
state: present
register: profile
- name: Переподключение после смены SSID или пароля
become: true
ansible.builtin.command: nmcli con down {{ wifi_con }}
when: profile.changed
failed_when: false
- name: Подключение активно
become: true
ansible.builtin.shell: |
state=$(nmcli -t -f GENERAL.STATE con show {{ wifi_con }} | cut -d: -f2)
if [ "$state" = activated ]; then echo OK; else nmcli con up {{ wifi_con }} >/dev/null && echo CHANGED; fi
register: up
changed_when: "'CHANGED' in up.stdout"
- name: Интернет доступен (DNS и HTTPS)
ansible.builtin.uri:
url: https://deb.debian.org/
register: inet
retries: 6
delay: 5
until: inet.status == 200
- name: Адрес в домашней сети
ansible.builtin.command: nmcli -t -f IP4.ADDRESS con show {{ wifi_con }}
register: addr
changed_when: false
- name: Итог по узлу
ansible.builtin.debug:
msg: "[+] {{ inventory_hostname }}: {{ addr.stdout | regex_replace('^IP4.ADDRESS\\[1\\]:', '') }}, интернет есть"