feat: публикация портов не-HTTP сервисами, проверка портов до деплоя, пример RustDesk

serverus: app_ports.py проверяет опубликованные порты приложения до
изменений - по docker compose config (переменные из .env подставлены)
против слушающих сокетов сервера и портов контейнеров других приложений.
Занятый порт больше не роняет up после замены файлов. Конвенция: только
мост; не-HTTP сервисы публикуют свои порты, 80 и 443 - у caddy;
bind-mount только своих файлов из data/<сервис>/ с :ro. Описано, что
упавший up после синхронизации файлы не откатывает.

App: uploads.check предупреждает о 80/443 вместо любых портов и о
bind-mount из папки приложения вне data/ или без :ro.

docs/apps/rustdesk: приложение (hbbs, hbbr, ключ генерируем сами),
genkey.py и check.py - проверка сервера по протоколу RustDesk снаружи.
Проверено на стенде Debian 13: деплой, повторный деплой, отказ второго
приложения на тех же портах до изменений, перезагрузка сервера.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
protokeyandClaude Opus 5.5 committed 2026-09-30 10:31:38 +04:00
1 parent a8d1f286d1
commit fd4b05097a
16 files changed
+526 -13

No files matched your search

+8 -2
View File
@@ -1,9 +1,10 @@
# extravars: apps_dir, app, archive (tar.gz на управляющей машине: полный набор файлов приложения)
#
# 1. Файлы распаковываются во временную папку, compose-файл и caddy.conf (caddy validate на копии конфигурации
# с новым блоком) проверяются там: ошибка - приложение не тронуто.
# 1. Файлы распаковываются во временную папку, compose-файл, его порты (app_ports.py: не заняты ли на сервере)
# и caddy.conf (caddy validate на копии конфигурации с новым блоком) проверяются там: ошибка - приложение не тронуто.
# 2. Папка приложения приводится к присланной копии по месту (app_sync.py), лишние файлы удаляются.
# 3. docker compose up -d --remove-orphans: compose сам пересоздает сервисы с изменившейся конфигурацией.
# Упал (например, образ не скачался) - файлы уже новые, отката нет.
# 4. Сервисы, у которых изменилось что-то в data/<сервис>/, пересоздаются: конфиги они читают при старте.
# У нового приложения - нет: up только что создал все контейнеры.
# 5. caddy.conf приложения становится блоком ingress. Caddy все же не принял - прежний блок возвращается, ошибка.
@@ -42,6 +43,11 @@
chdir: "{{ stage }}"
changed_when: false
# переменные из .env подставлены compose: занятый порт иначе уронил бы up уже после замены файлов
- name: Check published ports
ansible.builtin.command: python3 {{ apps_dir }}/.serverus/app_ports.py {{ stage | quote }} {{ app | quote }}
changed_when: false
- name: Check new caddy.conf
ansible.builtin.stat:
path: "{{ stage }}/caddy.conf"
+1 -1
View File
@@ -1,7 +1,7 @@
# extravars: apps_dir, caddy_image, local_certs
#
# Docker из репозитория Docker, логи контейнеров с ротацией, автоматические обновления безопасности, папка приложений,
# сеть web и ingress: приложение caddy, единственное, которое публикует порты. Caddy подключает блоки приложений
# сеть web и ingress: приложение caddy, которое публикует 80 и 443. Caddy подключает блоки приложений
# из data/caddy/sites/ и сам выпускает сертификаты для их доменов. local_certs - сертификаты своего CA Caddy.
# Фаервол настраивается первым: nftables при старте стирает все правила, Docker ставит свои поверх.
- name: Install Docker platform
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env python3
"""Проверка портов приложения до изменений: app_ports.py <stage> <app>.
Порты берутся из docker compose config (переменные из .env подставлены, диапазоны раскрыты) и сверяются с занятыми
на сервере: слушающие сокеты (ss) и порты контейнеров других приложений. Порты, которые держит само приложение,
занятыми не считаются: при деплое их контейнеры пересоздаются.
Занят - печатает причины в stderr и выходит с кодом 1.
"""
import json
import re
import subprocess
import sys
# 0.0.0.0:9000-9001->9000-9001/udp, [::]:80->80/tcp
DOCKER_PORT_RE = re.compile(r':(\d+)(?:-(\d+))?->[^/,]+/(tcp|udp)')
def run(*args, cwd=None):
result = subprocess.run(args, cwd=cwd, capture_output=True, text=True, timeout=120)
if result.returncode != 0:
raise RuntimeError((result.stderr or result.stdout).strip()[-500:])
return result.stdout
def published(stage, app):
"""{(порт, протокол): [сервисы]} из отрендеренного compose-файла."""
config = json.loads(run('docker', 'compose', '-p', app, 'config', '--format', 'json', cwd=stage))
ports = {}
for name, service in (config.get('services') or {}).items():
for entry in service.get('ports') or []:
if entry.get('published'):
key = (int(entry['published']), entry.get('protocol', 'tcp'))
ports.setdefault(key, []).append(name)
return ports
def listening():
"""Все слушающие порты сервера, включая docker-proxy опубликованных портов."""
ports = set()
for line in run('ss', '-Hlntu').splitlines():
fields = line.split()
if len(fields) >= 5:
port = fields[4].rsplit(':', 1)[-1]
if port.isdigit():
ports.add((int(port), fields[0]))
return ports
def containers():
"""[(проект, контейнер, {(порт, протокол)})] всех контейнеров с опубликованными портами."""
result = []
for line in run('docker', 'ps', '--format', '{{json .}}').splitlines():
container = json.loads(line)
ports = set()
for first, last, protocol in DOCKER_PORT_RE.findall(container.get('Ports', '')):
ports.update((port, protocol) for port in range(int(first), int(last or first) + 1))
labels = dict(label.split('=', 1) for label in container.get('Labels', '').split(',') if '=' in label)
result.append((labels.get('com.docker.compose.project', ''), container.get('Names', ''), ports))
return result
def main(stage, app):
wanted = published(stage, app)
if not wanted:
return
own, owners = set(), {}
for project, name, ports in containers():
if project == app:
own |= ports
else:
owners.update(dict.fromkeys(ports, name))
busy = (listening() - own) | owners.keys()
errors = [
f'{port}/{protocol} ({", ".join(services)}): ' + (f'занят контейнером {owners[port, protocol]}'
if (port, protocol) in owners else 'занят на сервере')
for (port, protocol), services in sorted(wanted.items()) if (port, protocol) in busy
]
if errors:
print('порты заняты: ' + '; '.join(errors), file=sys.stderr)
sys.exit(1)
if __name__ == '__main__':
main(sys.argv[1], sys.argv[2])
+1 -1
View File
@@ -10,4 +10,4 @@
src: "files/{{ item }}"
dest: "{{ apps_dir }}/.serverus/{{ item }}"
mode: "0755"
loop: [ app_sync.py, app_status.py ]
loop: [ app_sync.py, app_status.py, app_ports.py ]