feat: geosite/geoip для Discord со сборкой из апстримов

Домены и сети Discord в geosite.dat и geoip.dat для Xray/3x-ui, плюс
sing-box, Clash/mihomo, AdGuard, текст и готовый фрагмент routing.

Рукописная база живёт в data/discord-base.txt и data/ip/discord-base.txt,
остальное tools/sync.py тянет из пяти публичных апстримов: JSON-исходники
парсятся напрямую, скомпилированные .srs распаковываются через sing-box.
Каждый источник кладётся в свою категорию, недоступный источник не
обнуляет данные, файлы без шапки GENERATED скрипт не трогает.

Ограничения по протоколу и портам (директивы !network / !port) доезжают
до sing-box rule-set и до xray/routing.json. Такие категории намеренно
не входят в сводную discord: там сети вроде 172.64.0.0/13, и без привязки
к UDP-портам правило утащило бы в туннель чужой трафик.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-08-10 09:24:08 +03:00
co-authored by Claude Opus 5
commit 61c0ea3558
23 changed files with 7278 additions and 0 deletions
+187
View File
@@ -0,0 +1,187 @@
name: build
on:
push:
branches: [main]
paths:
- "data/**"
- "tools/**"
- ".gitea/workflows/build.yml"
pull_request:
paths:
- "data/**"
- "tools/**"
- ".gitea/workflows/build.yml"
schedule:
- cron: "17 3 * * *"
workflow_dispatch:
env:
GITEA_URL: ${{ github.server_url }}
GITEA_REPO: ${{ github.repository }}
# Автоматического токена обычно хватает. Если у раннера нет прав на push
# или на релизы — заведите секрет RELEASE_TOKEN с правами repo:write.
AUTO_TOKEN: ${{ github.token }}
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Окружение
run: |
set -eu
missing=""
for cmd in python3 curl unzip tar git; do
command -v "$cmd" >/dev/null 2>&1 || missing="$missing $cmd"
done
if [ -n "$missing" ]; then
echo "не хватает:$missing — доустанавливаем"
sudo=""; [ "$(id -u)" -eq 0 ] || sudo=sudo
$sudo apt-get update -qq
$sudo apt-get install -y -qq python3 curl unzip tar git ca-certificates
fi
python3 --version
# Раньше сборки: sing-box нужен не только для .srs, но и синхронизации —
# три из пяти апстримов отдают уже скомпилированный rule-set.
- name: Внешние инструменты
run: |
sh tools/fetch-tools.sh "$GITHUB_WORKSPACE/bin"
echo "$GITHUB_WORKSPACE/bin" >> "$GITHUB_PATH"
- name: Синхронизация апстримов
run: python3 tools/sync.py
- name: Проверка исходников
run: python3 tools/build.py --check
- name: Тесты
run: python3 -m unittest discover -s tools -t tools
- name: Сборка
run: |
SOURCE_DATE_EPOCH=$(git log -1 --format=%ct) python3 tools/build.py
- name: Проверка .dat настоящим Xray
run: |
# Роутинг-правило заставляет Xray реально распарсить оба файла:
# битый .dat или отсутствующий тег дают ненулевой код возврата.
cat > /tmp/xray-test.json <<'EOF'
{
"inbounds": [{"port": 10800, "protocol": "socks"}],
"outbounds": [{"protocol": "freedom", "tag": "direct"}],
"routing": {
"rules": [
{"type": "field", "domain": ["ext:discord-geosite.dat:discord"], "outboundTag": "direct"},
{"type": "field", "ip": ["ext:discord-geoip.dat:discord"], "outboundTag": "direct"},
{"type": "field", "ip": ["ext:geoip.dat:discord-base"], "outboundTag": "direct"}
]
}
}
EOF
XRAY_LOCATION_ASSET="$GITHUB_WORKSPACE/dist" xray run -test -c /tmp/xray-test.json
- name: Проверка rule-set для sing-box
run: |
set -eu
sing-box rule-set decompile --output /tmp/site.json dist/sing-box/site/discord.srs
grep -q '"discord.com"' /tmp/site.json
sing-box rule-set decompile --output /tmp/ip.json dist/sing-box/ip/discord.srs
grep -q 'ip_cidr' /tmp/ip.json
# Ограниченные категории обязаны донести протокол и порты до .srs.
for src in dist/sing-box/ip/*-udp*.json; do
[ -e "$src" ] || continue
sing-box rule-set decompile --output /tmp/udp.json "${src%.json}.srs"
grep -q '"udp"' /tmp/udp.json
grep -q 'port_range' /tmp/udp.json
done
- name: Обновление data/ в main
if: github.event_name != 'pull_request'
run: |
set -eu
if git diff --quiet -- data; then
echo "апстримы не изменились"
exit 0
fi
token="${RELEASE_TOKEN:-$AUTO_TOKEN}"
[ -n "$token" ] || { echo "нет токена для push" >&2; exit 1; }
host="${GITEA_URL#*://}"
scheme="${GITEA_URL%%://*}"
git config user.name "gitea-actions"
git config user.email "gitea-actions@noreply.local"
git add data
git commit -q -m "sync: обновление списков из апстримов"
git push -q "$scheme://gitea-actions:$token@$host/$GITEA_REPO.git" HEAD:main
echo "data/ обновлён в main"
- name: Описание релиза
id: notes
run: |
set -eu
tag=$(date -u '+%Y.%m.%d-%H%M')
echo "tag=$tag" >> "$GITHUB_OUTPUT"
{
echo "Собрано из \`$(git rev-parse --short HEAD)\`."
echo
echo '| Категория | Доменов |'
echo '| --- | ---: |'
python3 -c "import json; [print(f'| {k} | {v} |') for k, v in json.load(open('dist/metadata.json'))['categories'].items()]"
echo
echo '| IP-категория | Сетей | Ограничения |'
echo '| --- | ---: | --- |'
python3 -c "
import json
for k, v in json.load(open('dist/metadata.json'))['ip_categories'].items():
limits = ' '.join(filter(None, ['/'.join(v.get('network', [])), v.get('ports', '')])) or '—'
print(f\"| {k} | {v['count']} | {limits} |\")
"
echo
echo "sha256(discord-geosite.dat) = \`$(cut -d' ' -f1 dist/discord-geosite.dat.sha256sum)\`"
echo "sha256(discord-geoip.dat) = \`$(cut -d' ' -f1 dist/discord-geoip.dat.sha256sum)\`"
} > /tmp/notes.md
cat /tmp/notes.md
- name: Публикация в ветку release
if: github.event_name != 'pull_request'
run: |
set -eu
token="${RELEASE_TOKEN:-$AUTO_TOKEN}"
[ -n "$token" ] || { echo "нет токена для push" >&2; exit 1; }
host="${GITEA_URL#*://}"
scheme="${GITEA_URL%%://*}"
cp README.md LICENSE dist/
cd dist
git init -q -b release
git config user.name "gitea-actions"
git config user.email "gitea-actions@noreply.local"
git add .
git commit -q -m "build $(date -u '+%Y-%m-%d %H:%M UTC') ($GITHUB_SHA)"
git push -qf "$scheme://gitea-actions:$token@$host/$GITEA_REPO.git" release
cd ..
rm -f dist/README.md dist/LICENSE
echo "ветка release обновлена"
- name: Релиз в Gitea
if: github.event_name != 'pull_request'
run: |
set -eu
export GITEA_TOKEN="${RELEASE_TOKEN:-$AUTO_TOKEN}"
# Собираем архив вне dist/, иначе tar пакует сам себя на лету.
tar -czf /tmp/discord-geodata-all.tar.gz -C dist .
mv /tmp/discord-geodata-all.tar.gz dist/
sh tools/gitea-release.sh "${{ steps.notes.outputs.tag }}" /tmp/notes.md \
dist/discord-geosite.dat \
dist/discord-geosite.dat.sha256sum \
dist/discord-geoip.dat \
dist/discord-geoip.dat.sha256sum \
dist/xray/routing.json \
dist/sing-box/site/discord.srs \
dist/sing-box/ip/discord.srs \
dist/metadata.json \
dist/SHA256SUMS \
dist/discord-geodata-all.tar.gz
+4
View File
@@ -0,0 +1,4 @@
dist/
bin/
__pycache__/
*.pyc
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Leonid Pershin
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+300
View File
@@ -0,0 +1,300 @@
# discord-geodata
Домены и сети Discord, собранные в `geosite.dat` и `geoip.dat` для Xray/3x-ui —
плюс те же списки в форматах sing-box, Clash/mihomo, AdGuard и обычным текстом.
Списки не выдумываются: рукописная база лежит в `data/discord-base.txt` и
`data/ip/discord-base.txt`, всё остальное `tools/sync.py` тянет из публичных
апстримов (см. [Апстримы](#апстримы)) и раскладывает по отдельным категориям.
## Категории
Домены (`geosite.dat`):
| Категория | Что внутри |
| --- | --- |
| `discord` | всё вместе — то, что обычно и нужно в роутинге |
| `discord-base` | рукописный список: домены Discord и его проектов |
| `discord-metacubex` | из MetaCubeX meta-rules-dat |
| `discord-itdoginfo` | из itdoginfo/allow-domains |
| `discord-youoops` | из you-oops-dev/ipranges-singbox, включая имена голосовых узлов |
Сети (`geoip.dat`):
| Категория | Что внутри | Ограничения |
| --- | --- | --- |
| `discord` | все безусловные списки вместе | — |
| `discord-base` | адреса, снятые резолвом доменов | — |
| `discord-legiz` | адреса голосовых узлов (legiz-ru/sb-rule-sets) | — |
| `discord-youoops` | диапазоны сетей (you-oops-dev/ipranges-singbox) | — |
| `discord-vernette-udp` | голосовой трафик (vernette/rulesets) | `udp`, порты 50000-50099 |
| `discord-itdoginfo-udp-*` | голосовой трафик (itdoginfo) | `udp`, свои диапазоны портов |
Категории с ограничениями по протоколу и портам **не входят** в сводную
`discord`: там широкие сети вроде `172.64.0.0/13` и `162.158.0.0/15`, то есть
пол-Cloudflare. Без ограничения по UDP-портам такое правило утащило бы в туннель
кучу чужого трафика. Точные счётчики по каждой категории — в `metadata.json`.
## Скачать
Ветка `release` — распакованный `dist/`, релизы — те же файлы архивом:
- `https://gitea.hsrv.site/mrleo1nid/discord-geodata/raw/branch/release/<файл>`
| Файл | Для чего |
| --- | --- |
| `discord-geosite.dat` | Xray / v2ray / 3x-ui — домены, кладётся рядом со штатным `geosite.dat` |
| `discord-geoip.dat` | то же для сетей, рядом со штатным `geoip.dat` |
| `geosite.dat`, `geoip.dat` | те же данные под именами по умолчанию — если хотите заменить штатные файлы |
| `*.dat.sha256sum` | контрольные суммы, по ним работает автообновление |
| `xray/routing.json` | готовый фрагмент `routing` со всеми правилами |
| `update-geodata.sh` | скрипт обновления для cron (см. ниже) |
| `sing-box/site/<категория>.json`, `.srs` | sing-box, домены: исходный и скомпилированный rule-set |
| `sing-box/ip/<категория>.json`, `.srs` | sing-box, сети — здесь же живут `network` и `port_range` |
| `mihomo/site/<категория>.mrs`, `mihomo/ip/<категория>.mrs` | mihomo, бинарные rule-provider |
| `clash/site/<категория>.yaml`, `.list` | Clash/mihomo, `behavior: domain` и `classical` |
| `clash/ip/<категория>.yaml`, `.list` | Clash/mihomo, `behavior: ipcidr` и `classical` |
| `txt/site/<категория>.txt`, `txt/ip/<категория>.txt` | просто домены и сети, по одной на строку |
| `json/…` | структурированные списки с типами правил и ограничениями |
| `adguard/<категория>.txt` | AdGuard Home / uBlock — если нужно блокировать, а не роутить |
| `metadata.json`, `SHA256SUMS` | время сборки, счётчики, суммы по всем файлам |
## 3x-ui
Положите оба файла рядом с бинарём (обычно `/usr/local/x-ui/bin`):
```sh
cd /usr/local/x-ui/bin
base=https://gitea.hsrv.site/mrleo1nid/discord-geodata/raw/branch/release
curl -fsSLO "$base/discord-geosite.dat"
curl -fsSLO "$base/discord-geoip.dat"
x-ui restart
```
Дальше в панели: **Настройки → Xray → Маршруты** (или прямо в JSON-конфиге).
Готовые правила лежат в `xray/routing.json`, менять там нужно только
`outboundTag`:
```json
{
"type": "field",
"domain": ["ext:discord-geosite.dat:discord"],
"outboundTag": "proxy"
},
{
"type": "field",
"ip": ["ext:discord-geoip.dat:discord"],
"outboundTag": "proxy"
},
{
"type": "field",
"network": "udp",
"port": "50000-50099",
"ip": ["ext:discord-geoip.dat:discord-vernette-udp"],
"outboundTag": "proxy"
}
```
`ext:<файл>:<тег>` работает и для `domain`, и для `ip` — файл ищется в
`XRAY_LOCATION_ASSET` (по умолчанию каталог с бинарём). Тег — имя категории,
регистр не важен.
Порядок правил — это приоритет: Xray берёт первое подошедшее, поэтому
ограниченные по портам правила идут последними, чтобы более общие не
перехватывали их раньше.
## Автообновление на сервере
`update-geodata.sh` качает оба `.dat`, только если их sha256 разошлись с
удалёнными, проверяет сумму до подмены и перезапускает сервис один раз —
и только если что-то реально изменилось.
```sh
base=https://gitea.hsrv.site/mrleo1nid/discord-geodata/raw/branch/release
curl -fsSLo /usr/local/bin/update-geodata.sh "$base/update-geodata.sh"
chmod +x /usr/local/bin/update-geodata.sh
RESTART_CMD='x-ui restart' /usr/local/bin/update-geodata.sh
```
cron раз в сутки:
```cron
23 4 * * * RESTART_CMD='x-ui restart' /usr/local/bin/update-geodata.sh >> /var/log/discord-geodata.log 2>&1
```
| Переменная | По умолчанию | Значение |
| --- | --- | --- |
| `BASE_URL` | `https://gitea.hsrv.site/mrleo1nid/discord-geodata/raw/branch/release` | база, откуда качать |
| `DEST_DIR` | `/usr/local/x-ui/bin` | куда класть файлы |
| `NAMES` | `discord-geosite.dat discord-geoip.dat` | что качать |
| `RESTART_CMD` | пусто | что выполнить после успешного обновления |
| `TIMEOUT` | `30` | таймаут curl, секунды |
Если 3x-ui в Docker — укажите `DEST_DIR` на смонтированный каталог и
`RESTART_CMD='docker restart 3x-ui'`.
## sing-box
```json
{
"route": {
"rule_set": [
{
"type": "remote",
"tag": "discord",
"format": "binary",
"url": "https://gitea.hsrv.site/mrleo1nid/discord-geodata/raw/branch/release/sing-box/site/discord.srs"
},
{
"type": "remote",
"tag": "discord-ip",
"format": "binary",
"url": "https://gitea.hsrv.site/mrleo1nid/discord-geodata/raw/branch/release/sing-box/ip/discord.srs"
},
{
"type": "remote",
"tag": "discord-voice",
"format": "binary",
"url": "https://gitea.hsrv.site/mrleo1nid/discord-geodata/raw/branch/release/sing-box/ip/discord-vernette-udp.srs"
}
],
"rules": [{"rule_set": ["discord", "discord-ip", "discord-voice"], "outbound": "proxy"}]
}
}
```
`network` и `port_range` зашиты внутрь самого rule-set — отдельно в правиле их
указывать не нужно.
## Clash / mihomo
```yaml
rule-providers:
discord:
type: http
behavior: domain
format: yaml
url: https://gitea.hsrv.site/mrleo1nid/discord-geodata/raw/branch/release/clash/site/discord.yaml
path: ./providers/discord.yaml
interval: 86400
discord-ip:
type: http
behavior: ipcidr
format: yaml
url: https://gitea.hsrv.site/mrleo1nid/discord-geodata/raw/branch/release/clash/ip/discord.yaml
path: ./providers/discord-ip.yaml
interval: 86400
rules:
- RULE-SET,discord,PROXY
- RULE-SET,discord-ip,PROXY
```
Ограничения по протоколу и портам в форматы Clash не переносятся — в шапке таких
файлов об этом написано прямо. Нужен голосовой трафик с портами — берите
sing-box или Xray.
## Апстримы
`tools/sources.json` — список источников, `tools/sync.py` их синхронизирует:
| Источник | Что берём |
| --- | --- |
| [MetaCubeX/meta-rules-dat](https://github.com/MetaCubeX/meta-rules-dat) | домены |
| [itdoginfo/allow-domains](https://github.com/itdoginfo/allow-domains) | домены и голосовые сети с портами |
| [you-oops-dev/ipranges-singbox](https://github.com/you-oops-dev/ipranges-singbox) | сети и имена голосовых узлов |
| [legiz-ru/sb-rule-sets](https://github.com/legiz-ru/sb-rule-sets) | адреса голосовых узлов |
| [vernette/rulesets](https://github.com/vernette/rulesets) | голосовой трафик UDP 50000-50099 |
```sh
python3 tools/sync.py # синхронизировать всё
python3 tools/sync.py --only legiz # только один источник
python3 tools/sync.py --strict # падать, если хоть один источник недоступен
```
Что происходит внутри:
- каждый источник кладётся в **свой** файл `data/discord-<источник>.txt` — так
видно, кто что принёс, и можно выключить конкретный список;
- правила с `network`/`port_range` уезжают в отдельную категорию с директивами
`!network` и `!port` в шапке; если у источника таких наборов несколько, в имя
добавляются порты (`discord-itdoginfo-udp-19000-20000`);
- сводные `data/discord.txt` и `data/ip/discord.txt` пересобираются как список
`include:` — руками их править бесполезно;
- недоступный источник **не** обнуляет свой файл: остаются прежние данные и
печатается предупреждение. Устаревший список лучше пустого;
- источники в формате `.srs` скомпилированы, для них нужен бинарь `sing-box`
(`tools/fetch-tools.sh` его ставит). Без него они просто пропускаются.
Файлы, которые пишет `sync.py`, помечены шапкой `# GENERATED` — файл без такой
шапки скрипт не тронет. Поэтому рукописный `discord-base.txt` в безопасности.
## Как добавить домен или сеть руками
Правьте `data/discord-base.txt` (домены) и `data/ip/discord-base.txt` (сети) —
они попадут в сводные категории автоматически.
Формат доменного файла: `example.com` — суффикс (сам домен и все поддомены),
`full:example.com` — точное совпадение, `keyword:`, `regexp:`, `include:другая-категория`.
Атрибуты — через `@`: `example.com @ads`.
Формат IP-файла: `1.2.3.4` или `1.2.3.0/24` (IPv6 тоже), `include:`, и директивы
в шапке:
```
!network: udp
!port: 50000-50099
```
Ограничения действуют на весь файл. `include:` разрешён только между
категориями с **одинаковыми** ограничениями, иначе сборка упадёт: так не выйдет
случайно снять «только UDP на голосовых портах» с половины Cloudflare.
Дальше:
```sh
python3 tools/build.py --check
```
`--check` ругается на дубликаты, на домены, уже покрытые суффиксом, на сети,
вложенные в другие сети того же файла, на циклы в `include` и на конфликт
ограничений.
## Локальная сборка
```sh
python3 tools/build.py --check # проверить исходники
python3 tools/build.py # собрать dist/
python3 -m unittest discover -s tools -t tools
```
Зависимостей нет — только стандартная библиотека Python 3.10+. Для `.srs` и
`.mrs` нужны бинарники sing-box и mihomo; без них сборка проходит, просто эти
файлы не появляются:
```sh
sh tools/fetch-tools.sh ./bin && export PATH="$PWD/bin:$PATH"
```
Полезные флаги: `--out <каталог>`, `--sing-box <путь>`, `--mihomo <путь>`,
`--outbound <тег>` (какой `outboundTag` подставить в `xray/routing.json`).
Сборка детерминирована: одни и те же данные дают побайтово одинаковый `.dat`.
Оба файла после записи читаются обратно и сверяются с исходными правилами.
## CI
`.gitea/workflows/build.yml` на каждый push в `main`, на PR и ежедневно по cron:
ставит внешние инструменты, синхронизирует апстримы, проверяет исходники,
гоняет тесты, собирает `dist/`, скармливает оба `.dat` настоящему Xray и
проверяет, что в скомпилированных `.srs` доехали протокол и порты. После этого
коммитит обновлённый `data/` обратно в `main`, обновляет ветку `release` и
публикует релиз.
Если автокоммит в `main` не нужен — удалите шаг «Обновление data/ в main»:
сборка от этого не сломается, просто списки в репозитории останутся прежними.
## Лицензия
MIT — см. [LICENSE](LICENSE). Списки собраны из публичных источников,
перечисленных выше; лицензии апстримов смотрите в их репозиториях.
+36
View File
@@ -0,0 +1,36 @@
# discord-base — рукописный список доменов Discord.
# Это единственный доменный файл, который правят руками: остальные
# data/discord-*.txt пересобирает tools/sync.py из апстримов.
dis.gd
discord.co
discord.com
discord.design
discord.dev
discord.gg
discord.gift
discord.gifts
discord.media
discord.new
discord.store
discord.tools
discord-activities.com
discordactivities.com
discordapp.com
discordapp.io
discordapp.net
discordcdn.com
discordmerch.com
discordpartygames.com
discordsays.com
discordstatus.com
# Проекты Discord под собственными доменами.
airhorn.solutions
airhornbot.com
bigbeans.solutions
watchanimeattheoffice.com
# Точные имена: суффикс здесь захватил бы чужие сервисы (Google Storage, Zendesk).
full:discord-attachments-uploads-prd.storage.googleapis.com
full:hammerandchisel.ssl.zendesk.com
+25
View File
@@ -0,0 +1,25 @@
# GENERATED tools/sync.py — не редактируйте вручную.
# Источник: itdoginfo/allow-domains, последний релиз
# URL: https://github.com/itdoginfo/allow-domains/releases/latest/download/discord.srs
# Правил: 20
dis.gd
discord-activities.com
discord-attachments-uploads-prd.storage.googleapis.com
discord.co
discord.com
discord.design
discord.dev
discord.gg
discord.gift
discord.gifts
discord.media
discord.new
discord.store
discord.tools
discordactivities.com
discordapp.com
discordapp.net
discordmerch.com
discordpartygames.com
discordsays.com
+33
View File
@@ -0,0 +1,33 @@
# GENERATED tools/sync.py — не редактируйте вручную.
# Источник: MetaCubeX meta-rules-dat, ветка sing (домены)
# URL: https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/refs/heads/sing/geo/geosite/discord.json
# Правил: 28
airhorn.solutions
airhornbot.com
bigbeans.solutions
dis.gd
discord-activities.com
discord.co
discord.com
discord.design
discord.dev
discord.gg
discord.gift
discord.gifts
discord.media
discord.new
discord.store
discord.tools
discordactivities.com
discordapp.com
discordapp.io
discordapp.net
discordcdn.com
discordmerch.com
discordpartygames.com
discordsays.com
discordstatus.com
watchanimeattheoffice.com
full:discord-attachments-uploads-prd.storage.googleapis.com
full:hammerandchisel.ssl.zendesk.com
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
# GENERATED tools/sync.py — не редактируйте вручную.
# Сводная категория: рукописный список плюс всё, что притянул sync.
include:discord-base
include:discord-itdoginfo
include:discord-metacubex
include:discord-youoops
+106
View File
@@ -0,0 +1,106 @@
# discord-base — рукописный список адресов, снятых резолвом доменов Discord.
#
# Внимание: это A-записи, а не сети Discord. Часть адресов принадлежит
# Cloudflare (104.x, 162.159.x, 172.6x.x) и Google (74.125.x, 142.25x.x),
# где они разделяются с чужими сайтами, а часть — Shopify (23.227.38.74,
# магазин) и Vercel (216.198.5x.11). Список стареет: когда апстримы из
# tools/sources.json покрывают нужное, лучше опираться на них.
23.227.38.74
34.126.226.51
64.233.179.207
64.233.181.207
74.125.69.207
74.125.126.207
74.125.132.207
74.125.201.207
74.125.202.207
104.17.51.93
104.17.117.93
104.18.32.174
104.18.34.249
104.18.37.76
104.18.38.75
104.21.25.51
104.21.72.221
104.21.80.173
104.21.82.160
104.21.89.187
108.177.121.207
142.250.125.207
142.250.152.207
142.250.177.91
142.250.177.251
142.250.188.187
142.250.189.123
142.250.189.155
142.250.217.123
142.251.32.27
142.251.34.251
142.251.41.91
142.251.41.155
142.251.184.207
142.251.189.207
142.251.210.123
142.251.210.219
142.251.210.251
142.251.211.27
142.251.211.33
142.251.211.59
142.251.214.91
142.251.215.155
162.159.128.232
162.159.128.233
162.159.128.234
162.159.128.235
162.159.129.232
162.159.129.233
162.159.129.234
162.159.129.235
162.159.130.232
162.159.130.233
162.159.130.234
162.159.130.235
162.159.133.232
162.159.133.233
162.159.133.234
162.159.134.232
162.159.134.233
162.159.134.234
162.159.135.232
162.159.135.233
162.159.135.234
162.159.136.232
162.159.136.233
162.159.136.234
162.159.137.232
162.159.137.233
162.159.137.234
162.159.138.232
162.159.138.233
162.159.138.234
172.64.149.181
172.64.150.180
172.64.153.7
172.64.155.82
172.65.202.19
172.67.152.113
172.67.155.163
172.67.159.89
172.67.191.5
172.67.222.182
172.217.214.207
172.253.155.207
173.194.42.207
173.194.64.207
173.194.193.207
173.194.194.207
173.194.195.207
173.194.206.207
192.178.129.207
192.178.210.207
192.178.212.207
209.85.145.207
209.85.200.207
216.198.53.11
216.198.54.11
@@ -0,0 +1,10 @@
# GENERATED tools/sync.py — не редактируйте вручную.
# Источник: itdoginfo/allow-domains, последний релиз
# URL: https://github.com/itdoginfo/allow-domains/releases/latest/download/discord.srs
# Сетей: 1
# Ограничения: udp 19000-20000
!network: udp
!port: 19000-20000
104.16.0.0/12
@@ -0,0 +1,17 @@
# GENERATED tools/sync.py — не редактируйте вручную.
# Источник: itdoginfo/allow-domains, последний релиз
# URL: https://github.com/itdoginfo/allow-domains/releases/latest/download/discord.srs
# Сетей: 8
# Ограничения: udp 50000-65535
!network: udp
!port: 50000-65535
5.200.14.128/25
34.0.0.0/14
35.192.0.0/11
66.22.192.0/18
104.16.0.0/12
138.128.136.0/21
162.158.0.0/15
172.64.0.0/13
+49
View File
@@ -0,0 +1,49 @@
# GENERATED tools/sync.py — не редактируйте вручную.
# Источник: legiz-ru/sb-rule-sets, адреса голосовых узлов
# URL: https://raw.githubusercontent.com/legiz-ru/sb-rule-sets/refs/heads/main/discord-voice-ip-list.json
# Сетей: 43
# Ограничения: без ограничений
35.213.129.2/32
35.213.130.27/32
35.213.130.216/31
35.213.131.78/32
35.213.133.191/32
35.213.135.56/32
35.213.136.41/32
35.213.136.207/32
35.213.139.79/32
35.213.142.228/32
35.213.142.249/32
35.213.143.38/32
35.213.145.124/32
35.213.145.149/32
35.213.146.200/32
35.213.149.32/32
35.213.149.99/32
35.213.149.115/32
35.213.150.41/32
35.213.150.163/32
35.213.152.13/32
35.213.153.185/32
35.213.160.90/32
35.213.162.61/32
35.213.163.235/32
35.213.164.163/32
35.213.165.33/32
35.213.165.102/32
35.213.167.165/32
35.213.168.191/32
35.213.169.179/32
35.213.170.10/32
35.213.172.159/32
35.213.173.1/32
35.213.176.117/32
35.213.177.116/32
35.213.177.119/32
35.213.181.44/32
35.213.181.71/32
35.213.182.222/32
35.213.183.188/32
35.213.185.36/32
35.213.188.188/32
+22
View File
@@ -0,0 +1,22 @@
# GENERATED tools/sync.py — не редактируйте вручную.
# Источник: vernette/rulesets, голосовой трафик UDP 50000-50099
# URL: https://raw.githubusercontent.com/vernette/rulesets/refs/heads/master/json/discord-voice-chats.json
# Сетей: 13
# Ограничения: udp 50000-50099
!network: udp
!port: 50000-50099
5.200.14.128/25
34.0.0.0/15
34.2.0.0/16
34.3.0.0/23
34.3.2.0/24
35.192.0.0/12
35.208.0.0/12
35.224.0.0/12
35.240.0.0/13
66.22.192.0/18
138.128.136.0/21
162.158.0.0/15
172.64.0.0/13
+60
View File
@@ -0,0 +1,60 @@
# GENERATED tools/sync.py — не редактируйте вручную.
# Источник: you-oops-dev/ipranges-singbox (диапазоны сетей)
# URL: https://raw.githubusercontent.com/you-oops-dev/ipranges-singbox/refs/heads/main/discord/discord.srs
# Сетей: 54
# Ограничения: без ограничений
23.227.38.74/32
34.126.226.51/32
104.17.51.93/32
104.17.117.93/32
104.18.32.174/32
104.18.34.249/32
104.18.37.76/32
104.18.38.75/32
104.21.25.51/32
104.21.72.221/32
104.21.80.173/32
104.21.82.160/32
104.21.89.187/32
142.251.32.187/32
142.251.34.91/32
142.251.35.91/32
142.251.40.123/32
142.251.41.27/32
142.251.45.1/32
142.251.45.27/32
142.251.210.155/32
142.251.214.123/32
142.251.215.91/32
142.251.215.187/32
142.251.215.219/32
142.251.215.251/32
142.251.218.27/32
162.159.128.232/30
162.159.129.232/30
162.159.130.232/30
162.159.133.232/31
162.159.133.234/32
162.159.134.232/31
162.159.134.234/32
162.159.135.232/31
162.159.135.234/32
162.159.136.232/31
162.159.136.234/32
162.159.137.232/31
162.159.137.234/32
162.159.138.232/31
162.159.138.234/32
172.64.149.181/32
172.64.150.180/32
172.64.153.7/32
172.64.155.82/32
172.65.202.19/32
172.67.152.113/32
172.67.155.163/32
172.67.159.89/32
172.67.191.5/32
172.67.222.182/32
216.198.53.11/32
216.198.54.11/32
+6
View File
@@ -0,0 +1,6 @@
# GENERATED tools/sync.py — не редактируйте вручную.
# Сводная категория: рукописный список плюс всё, что притянул sync.
include:discord-base
include:discord-legiz
include:discord-youoops
+1028
View File
File diff suppressed because it is too large Load Diff
+48
View File
@@ -0,0 +1,48 @@
#!/bin/sh
# Кладёт sing-box, mihomo и xray в каталог (по умолчанию ./bin).
# Нужен только для сборки .srs/.mrs и для проверки .dat настоящим Xray.
#
# ./tools/fetch-tools.sh [каталог]
set -eu
BIN="${1:-./bin}"
mkdir -p "$BIN"
BIN=$(cd "$BIN" && pwd)
case "$(uname -m)" in
x86_64 | amd64) sb_arch=amd64; mh_arch=amd64; xr_arch=64 ;;
aarch64 | arm64) sb_arch=arm64; mh_arch=arm64; xr_arch=arm64-v8a ;;
*) echo "неподдерживаемая архитектура: $(uname -m)" >&2; exit 1 ;;
esac
# Печатает ссылку на ассет последнего релиза, имя которого совпало с regex.
asset_url() {
curl -fsSL "https://api.github.com/repos/$1/releases/latest" | python3 -c '
import json, re, sys
assets = json.load(sys.stdin)["assets"]
match = next((a for a in assets if re.search(sys.argv[1], a["name"])), None)
if match is None:
sys.exit(f"в релизе нет ассета по шаблону {sys.argv[1]}")
print(match["browser_download_url"])
' "$2"
}
echo "sing-box…"
curl -fsSL "$(asset_url SagerNet/sing-box "-linux-$sb_arch\\.tar\\.gz\$")" |
tar -xz --strip-components=1 -C "$BIN" --wildcards '*/sing-box'
echo "mihomo…"
curl -fsSL "$(asset_url MetaCubeX/mihomo "^mihomo-linux-$mh_arch-v[0-9.]+\\.gz\$")" |
gunzip > "$BIN/mihomo"
echo "xray…"
curl -fsSL -o "$BIN/xray.zip" "$(asset_url XTLS/Xray-core "^Xray-linux-$xr_arch\\.zip\$")"
unzip -oqj "$BIN/xray.zip" xray -d "$BIN"
rm -f "$BIN/xray.zip"
chmod +x "$BIN/sing-box" "$BIN/mihomo" "$BIN/xray"
echo "готово: $BIN"
"$BIN/sing-box" version | head -1
"$BIN/mihomo" -v | head -1
"$BIN/xray" version | head -1
+50
View File
@@ -0,0 +1,50 @@
#!/bin/sh
# Создаёт (или обновляет) релиз в Gitea и заливает в него файлы.
#
# GITEA_URL=https://gitea.hsrv.site GITEA_REPO=mrleo1nid/discord-geodata GITEA_TOKEN=… \
# ./tools/gitea-release.sh <тег> <файл-с-описанием> <файл> [файл …]
#
# Имя ассета — путь относительно dist/ с заменой "/" на "-", чтобы
# sing-box/site/discord.json и json/site/discord.json не конфликтовали.
set -eu
[ $# -ge 3 ] || { echo "использование: $0 <тег> <notes.md> <файл...>" >&2; exit 1; }
TAG="$1"; NOTES="$2"; shift 2
: "${GITEA_URL:?не задан GITEA_URL}"
: "${GITEA_REPO:?не задан GITEA_REPO}"
: "${GITEA_TOKEN:?не задан GITEA_TOKEN}"
API="$GITEA_URL/api/v1/repos/$GITEA_REPO/releases"
auth="Authorization: token $GITEA_TOKEN"
json_field() { python3 -c 'import json,sys; print(json.load(sys.stdin).get(sys.argv[1],""))' "$1"; }
payload=$(NOTES_FILE="$NOTES" TAG="$TAG" python3 -c '
import json, os
print(json.dumps({
"tag_name": os.environ["TAG"],
"name": os.environ["TAG"],
"body": open(os.environ["NOTES_FILE"], encoding="utf-8").read(),
"draft": False,
"prerelease": False,
}))')
# Релиз с таким тегом мог остаться от прошлого прогона — тогда переиспользуем его.
id=$(curl -fsS -X POST "$API" -H "$auth" -H 'Content-Type: application/json' \
-d "$payload" 2>/dev/null | json_field id || true)
if [ -z "$id" ]; then
id=$(curl -fsS "$API/tags/$TAG" -H "$auth" | json_field id) ||
{ echo "не удалось ни создать, ни найти релиз $TAG" >&2; exit 1; }
echo "релиз $TAG уже существует (id $id), дозаливаем ассеты"
fi
echo "релиз $TAG: id $id"
for file in "$@"; do
[ -f "$file" ] || { echo "нет файла: $file" >&2; exit 1; }
name=$(printf '%s' "${file#dist/}" | tr '/' '-')
curl -fsS -X POST "$API/$id/assets?name=$name" -H "$auth" \
-F "attachment=@$file" >/dev/null
echo " + $name"
done
+43
View File
@@ -0,0 +1,43 @@
{
"_note": [
"Апстримы, из которых tools/sync.py собирает data/*-<source>.txt.",
"kind определяется по расширению: .json — исходник sing-box rule-set,",
".srs — скомпилированный, для него нужен бинарь sing-box (rule-set decompile).",
"json_url — необязательный текстовый близнец того же списка: если он есть,",
"sync обходится без sing-box."
],
"aggregate": "discord",
"sources": [
{
"name": "metacubex",
"note": "MetaCubeX meta-rules-dat, ветка sing (домены)",
"url": "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/refs/heads/sing/geo/geosite/discord.srs",
"json_url": "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/refs/heads/sing/geo/geosite/discord.json",
"target": "discord-metacubex"
},
{
"name": "itdoginfo",
"note": "itdoginfo/allow-domains, последний релиз",
"url": "https://github.com/itdoginfo/allow-domains/releases/latest/download/discord.srs",
"target": "discord-itdoginfo"
},
{
"name": "you-oops",
"note": "you-oops-dev/ipranges-singbox (диапазоны сетей)",
"url": "https://raw.githubusercontent.com/you-oops-dev/ipranges-singbox/refs/heads/main/discord/discord.srs",
"target": "discord-youoops"
},
{
"name": "legiz",
"note": "legiz-ru/sb-rule-sets, адреса голосовых узлов",
"url": "https://raw.githubusercontent.com/legiz-ru/sb-rule-sets/refs/heads/main/discord-voice-ip-list.json",
"target": "discord-legiz"
},
{
"name": "vernette",
"note": "vernette/rulesets, голосовой трафик UDP 50000-50099",
"url": "https://raw.githubusercontent.com/vernette/rulesets/refs/heads/master/json/discord-voice-chats.json",
"target": "discord-vernette"
}
]
}
+387
View File
@@ -0,0 +1,387 @@
#!/usr/bin/env python3
"""Синхронизирует data/ с апстримными rule-set из tools/sources.json.
Каждый источник кладётся в свой файл — data/<target>.txt для доменов и
data/ip/<target>.txt для сетей, — а сводные категории (data/discord.txt,
data/ip/discord.txt) пересобираются как список include-ов. Рукописные
списки (data/discord-base.txt) остаются нетронутыми: sync перезаписывает
только файлы со своей шапкой GENERATED.
python3 tools/sync.py # синхронизировать всё
python3 tools/sync.py --only legiz # только один источник
python3 tools/sync.py --strict # падать, если хоть один источник недоступен
Источники в формате .srs скомпилированы: для них нужен бинарь sing-box
(`rule-set decompile`). Если его нет — источник пропускается, а ранее
синхронизированный файл остаётся на месте: устаревшие данные лучше пустых.
"""
from __future__ import annotations
import argparse
import ipaddress
import json
import shutil
import subprocess
import sys
import tempfile
import urllib.error
import urllib.request
from dataclasses import dataclass, field
from pathlib import Path
import build
from build import BuildError, Net, Options, Rule
TOOLS = Path(__file__).resolve().parent
USER_AGENT = "discord-geodata-sync (+https://github.com/)"
TIMEOUT = 60
GENERATED_HEADER = f"# {build.GENERATED_MARK} tools/sync.py — не редактируйте вручную."
class SyncError(Exception):
"""Источник не удалось получить или разобрать."""
@dataclass
class Parsed:
domains: list[Rule] = field(default_factory=list)
groups: dict[Options, list[Net]] = field(default_factory=dict)
skipped: list[str] = field(default_factory=list)
# --------------------------------------------------------------------------- #
# Получение
# --------------------------------------------------------------------------- #
def fetch(url: str) -> bytes:
request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
try:
with urllib.request.urlopen(request, timeout=TIMEOUT) as response:
return response.read()
except (urllib.error.URLError, OSError, TimeoutError) as exc:
raise SyncError(f"не удалось скачать {url}: {exc}") from exc
def decompile_srs(blob: bytes, singbox: str | None) -> dict:
""".srs — это zlib-контейнер sing-box; читать его умеет только сам sing-box."""
if not singbox:
raise SyncError("формат .srs требует бинаря sing-box (--sing-box или в PATH)")
with tempfile.TemporaryDirectory() as tmp:
src = Path(tmp) / "rule-set.srs"
dst = Path(tmp) / "rule-set.json"
src.write_bytes(blob)
result = subprocess.run(
[singbox, "rule-set", "decompile", "--output", str(dst), str(src)],
capture_output=True,
text=True,
)
if result.returncode != 0:
raise SyncError(f"sing-box rule-set decompile: {result.stderr.strip() or result.stdout.strip()}")
return json.loads(dst.read_text(encoding="utf-8"))
def load_source(source: dict, singbox: str | None) -> dict:
url = source.get("json_url") or source["url"]
blob = fetch(url)
if url.endswith(".srs") or blob[:3] == b"SRS":
return decompile_srs(blob, singbox)
try:
return json.loads(blob.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise SyncError(f"{url}: не похоже на JSON rule-set: {exc}") from exc
# --------------------------------------------------------------------------- #
# Разбор sing-box rule-set
# --------------------------------------------------------------------------- #
def iter_default_rules(rules: list, where: str):
"""Разворачивает logical-правила: вложенные условия нас интересуют как список."""
for rule in rules:
if not isinstance(rule, dict):
raise SyncError(f"{where}: правило не является объектом")
if rule.get("type") == "logical":
yield from iter_default_rules(rule.get("rules", []), where)
else:
yield rule
def listify(value: object, where: str, key: str) -> list:
"""В sing-box почти любое поле правила — «listable»: строка или массив строк."""
if value is None:
return []
if isinstance(value, (str, int)):
return [value]
if isinstance(value, list):
return value
raise SyncError(f"{where}: поле '{key}' неожиданного типа {type(value).__name__}")
def rule_field(rule: dict, key: str, where: str) -> list:
return listify(rule.get(key), where, key)
def rule_options(rule: dict, where: str) -> Options:
networks = sorted({str(n).lower() for n in rule_field(rule, "network", where)})
for network in networks:
if network not in build.NETWORKS:
raise SyncError(f"{where}: неизвестный network '{network}'")
ports: list[tuple[int, int]] = []
for port in rule_field(rule, "port", where):
ports.append((int(port), int(port)))
for span in rule_field(rule, "port_range", where):
match = build.PORT_RE.match(str(span))
if not match:
raise SyncError(f"{where}: невалидный port_range '{span}'")
start = int(match.group(1))
ports.append((start, int(match.group(2) or start)))
return Options(tuple(networks), build.merge_ports(ports))
def parse_ruleset(payload: dict, where: str) -> Parsed:
if not isinstance(payload, dict) or "rules" not in payload:
raise SyncError(f"{where}: нет ключа 'rules'")
parsed = Parsed()
domains: dict[Rule, None] = {}
for index, rule in enumerate(iter_default_rules(payload["rules"], where)):
spot = f"{where}#{index}"
if rule.get("invert"):
parsed.skipped.append(f"{spot}: invert-правило не выразимо в geosite/geoip")
continue
for key, template in (
("domain", "full:{}"),
("domain_suffix", "{}"),
("domain_keyword", "keyword:{}"),
("domain_regex", "regexp:{}"),
):
for value in rule_field(rule, key, spot):
# Ведущая точка в sing-box значит «только поддомены», а в geosite
# суффикс всегда покрывает и сам домен — точку просто снимаем.
token = template.format(str(value).lstrip(".") if key == "domain_suffix" else value)
try:
domains.setdefault(build.parse_token(token, spot), None)
except BuildError as exc:
parsed.skipped.append(str(exc))
cidrs: list[Net] = []
for value in rule_field(rule, "ip_cidr", spot):
try:
network = ipaddress.ip_network(str(value), strict=False)
except ValueError as exc:
parsed.skipped.append(f"{spot}: невалидная сеть '{value}': {exc}")
continue
cidrs.append(Net(network.network_address.packed, network.prefixlen))
for key in ("source_ip_cidr", "source_port", "source_port_range"):
if rule.get(key):
parsed.skipped.append(f"{spot}: '{key}' игнорируется")
if cidrs:
parsed.groups.setdefault(rule_options(rule, spot), []).extend(cidrs)
parsed.domains = sorted(domains, key=lambda r: r.sort_key)
return parsed
# --------------------------------------------------------------------------- #
# Чистка
# --------------------------------------------------------------------------- #
# Чистка внутри файла — та же, что build применяет к развёрнутой категории:
# покрытые суффиксом домены и вложенные сети до .dat всё равно не доедут.
prune_domains = build.collapse_rules
prune_nets = build.collapse_nets
# --------------------------------------------------------------------------- #
# Запись
# --------------------------------------------------------------------------- #
def is_generated(path: Path) -> bool:
if not path.is_file():
return True
head = path.read_text(encoding="utf-8").splitlines()[:3]
return any(build.GENERATED_MARK in line for line in head)
def write_generated(path: Path, notes: list[str], body: list[str]) -> None:
if not is_generated(path):
raise SyncError(f"{path} создан вручную — sync его не трогает")
content = "\n".join([GENERATED_HEADER, *(f"# {n}" for n in notes), "", *body, ""])
build.write(path, content)
def short_suffix(options: Options) -> str:
return "-".join(options.networks) or "port"
def long_suffix(options: Options) -> str:
"""Различает наборы, у которых совпал протокол, но разошлись порты."""
ports = "-".join(f"{a}" if a == b else f"{a}-{b}" for a, b in options.ports)
return "-".join(part for part in (short_suffix(options), ports) if part)
def group_names(groups: list[Options], target: str) -> dict[Options, str]:
"""Имя категории на группу: короткое, а при совпадении — с портами."""
taken = [short_suffix(o) for o in groups]
names = {}
for options in groups:
suffix = short_suffix(options)
if taken.count(suffix) > 1:
suffix = long_suffix(options)
names[options] = f"{target}-{suffix}"
if len(set(names.values())) != len(names):
raise SyncError(f"{target}: не удалось развести имена категорий по ограничениям")
return names
def sync_source(source: dict, singbox: str | None) -> tuple[list[Path], list[str]]:
"""Возвращает записанные файлы и предупреждения."""
target = source["target"]
note = source.get("note", source["name"])
url = source.get("json_url") or source["url"]
parsed = parse_ruleset(load_source(source, singbox), source["name"])
written: list[Path] = []
domains = prune_domains(parsed.domains)
if domains:
write_generated(
build.DATA_DIR / f"{target}.txt",
[f"Источник: {note}", f"URL: {url}", f"Правил: {len(domains)}"],
[str(rule) for rule in domains],
)
written.append(build.DATA_DIR / f"{target}.txt")
ordered = sorted(parsed.groups, key=lambda o: (o.networks, o.ports))
names = group_names([o for o in ordered if o.restricted], target)
for options in ordered:
nets = prune_nets(parsed.groups[options])
if not nets:
continue
name = names[options] if options.restricted else target
directives = [f"!network: {n}" for n in options.networks]
if options.ports:
directives.append(f"!port: {options.xray_ports}")
path = build.DATA_DIR / build.IP_SUBDIR / f"{name}.txt"
write_generated(
path,
[f"Источник: {note}", f"URL: {url}", f"Сетей: {len(nets)}", f"Ограничения: {options}"],
[*directives, *([""] if directives else []), *(str(net) for net in nets)],
)
written.append(path)
return written, parsed.skipped
def drop_stale(target: str, written: set[Path]) -> list[Path]:
"""Убирает файлы источника, которые в этот раз не появились (апстрим сузился)."""
removed = []
for directory in (build.DATA_DIR, build.DATA_DIR / build.IP_SUBDIR):
for path in sorted(directory.glob(f"{target}*.txt")):
stem = path.stem
if stem != target and not stem.startswith(f"{target}-"):
continue
if path in written or not is_generated(path):
continue
path.unlink()
removed.append(path)
return removed
def write_aggregates(aggregate: str) -> None:
"""Сводные категории — просто список include-ов по тому, что есть на диске."""
for directory, restrict in ((build.DATA_DIR, False), (build.DATA_DIR / build.IP_SUBDIR, True)):
names = []
for path in sorted(directory.glob("*.txt")):
name = path.stem
if name == aggregate:
continue
if restrict:
# В сводную попадают только безусловные списки: подмешать к ним
# «udp + порты 50000-50099» значило бы снять это ограничение с
# чужих сетей (там половина Cloudflare) — такие категории живут
# отдельными тегами.
_, options = build.resolve_ip(name)
if options.restricted:
continue
names.append(name)
if not names:
continue
write_generated(
directory / f"{aggregate}.txt",
["Сводная категория: рукописный список плюс всё, что притянул sync."],
[f"include:{name}" for name in names],
)
# --------------------------------------------------------------------------- #
# Точка входа
# --------------------------------------------------------------------------- #
def main(argv: list[str] | None = None) -> int:
for stream in (sys.stdout, sys.stderr):
stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
parser = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument("--sources", type=Path, default=TOOLS / "sources.json")
parser.add_argument("--only", action="append", default=[], help="синхронизировать только эти источники")
parser.add_argument("--sing-box", dest="singbox", help="путь к бинарю sing-box для .srs")
parser.add_argument("--strict", action="store_true", help="ненулевой код возврата при любой ошибке")
args = parser.parse_args(argv)
config = json.loads(args.sources.read_text(encoding="utf-8"))
singbox = args.singbox or shutil.which("sing-box")
sources = [s for s in config["sources"] if not args.only or s["name"] in args.only]
if not sources:
print("error: подходящих источников нет", file=sys.stderr)
return 1
failed = []
for source in sources:
try:
written, skipped = sync_source(source, singbox)
removed = drop_stale(source["target"], set(written))
files = ", ".join(p.relative_to(build.ROOT).as_posix() for p in written) or "нет данных"
print(f"{source['name']}: {files}")
for path in removed:
print(f" удалён устаревший {path.relative_to(build.ROOT).as_posix()}")
for warning in skipped[:10]:
print(f" пропущено — {warning}")
if len(skipped) > 10:
print(f" … и ещё {len(skipped) - 10} пропущенных записей")
except (SyncError, BuildError) as exc:
failed.append(source["name"])
print(f"{source['name']}: ОШИБКА — {exc}", file=sys.stderr)
print(" оставлены прежние данные", file=sys.stderr)
try:
write_aggregates(config["aggregate"])
except (SyncError, BuildError) as exc:
print(f"error: не удалось пересобрать сводные категории: {exc}", file=sys.stderr)
return 1
if failed:
print(f"источников с ошибкой: {len(failed)} из {len(sources)} ({', '.join(failed)})", file=sys.stderr)
if args.strict or len(failed) == len(sources):
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
+414
View File
@@ -0,0 +1,414 @@
#!/usr/bin/env python3
"""Тесты сборщика и синхронизатора: python -m unittest discover -s tools -t tools"""
from __future__ import annotations
import contextlib
import ipaddress
import json
import tempfile
import unittest
from pathlib import Path
import build
import sync
from build import BuildError, Net, Options, Rule
def net(cidr: str) -> Net:
parsed = ipaddress.ip_network(cidr)
return Net(parsed.network_address.packed, parsed.prefixlen)
@contextlib.contextmanager
def data(ip: dict[str, str] | None = None, **files: str):
"""Подменяет data/ временным каталогом с заданными категориями."""
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
(root / build.IP_SUBDIR).mkdir()
for name, content in files.items():
(root / f"{name.replace('_', '-')}.txt").write_text(content, encoding="utf-8")
for name, content in (ip or {}).items():
path = root / build.IP_SUBDIR / f"{name.replace('_', '-')}.txt"
path.write_text(content, encoding="utf-8")
original = build.DATA_DIR
build.DATA_DIR = root
try:
yield root
finally:
build.DATA_DIR = original
class ParseTest(unittest.TestCase):
def test_default_type_is_domain(self):
with data(a="example.com\n"):
self.assertEqual(build.resolve("a"), [Rule("domain", "example.com")])
def test_all_prefixes(self):
source = "domain:a.com\nfull:b.com\nkeyword:cc\nregexp:^d[0-9]+\\.com$\n"
with data(a=source):
self.assertEqual(
build.resolve("a"),
[
Rule("domain", "a.com"),
Rule("full", "b.com"),
Rule("keyword", "cc"),
Rule("regexp", r"^d[0-9]+\.com$"),
],
)
def test_comments_blank_lines_and_attributes(self):
with data(a="# заголовок\n\na.com @ads @cn # хвост\n"):
self.assertEqual(build.resolve("a"), [Rule("domain", "a.com", ("ads", "cn"))])
def test_normalisation(self):
with data(a="Example.COM.\nDOMAIN:пример.рф\n"):
values = [r.value for r in build.resolve("a")]
self.assertIn("example.com", values)
self.assertIn("xn--e1afmkfd.xn--p1ai", values)
def test_include_dedupes_and_sorts(self):
with data(a="include:b\nz.com\na.com\n", b="a.com\nfull:m.com\n"):
self.assertEqual(
build.resolve("a"),
[Rule("domain", "a.com"), Rule("domain", "z.com"), Rule("full", "m.com")],
)
def test_rejects_bad_input(self):
cases = {
"битый домен": "not_a_domain\n",
"битый regexp": "regexp:[unclosed\n",
"атрибут без @": "a.com ads\n",
"неизвестный include": "include:missing\n",
}
for label, source in cases.items():
with self.subTest(label), data(a=source):
with self.assertRaises(BuildError):
build.resolve("a")
def test_detects_include_cycle(self):
with data(a="include:b\n", b="include:a\n"):
with self.assertRaisesRegex(BuildError, "цикл"):
build.resolve("a")
class IPParseTest(unittest.TestCase):
def test_bare_address_becomes_host_route(self):
with data(ip={"a": "1.2.3.4\n2001:db8::1\n"}):
nets, options = build.resolve_ip("a")
self.assertEqual([str(n) for n in nets], ["1.2.3.4/32", "2001:db8::1/128"])
self.assertFalse(options.restricted)
def test_directives(self):
with data(ip={"a": "!network: udp\n!port: 50000-50099, 443\n1.2.3.0/24\n"}):
_, options = build.resolve_ip("a")
self.assertEqual(options.networks, ("udp",))
self.assertEqual(options.ports, ((443, 443), (50000, 50099)))
self.assertEqual(options.xray_ports, "443,50000-50099")
def test_adjacent_port_ranges_merge(self):
self.assertEqual(build.merge_ports([(10, 20), (21, 30), (100, 100)]), ((10, 30), (100, 100)))
def test_rejects_bad_input(self):
cases = {
"не сеть": "1.2.3.999\n",
"заданы хостовые биты": "1.2.3.4/24\n",
"чужой протокол": "!network: icmp\n1.2.3.4\n",
"порт вне диапазона": "!port: 70000\n1.2.3.4\n",
"неизвестная директива": "!ttl: 5\n1.2.3.4\n",
}
for label, source in cases.items():
with self.subTest(label), data(ip={"a": source}):
with self.assertRaises(BuildError):
build.resolve_ip("a")
def test_include_requires_matching_options(self):
with data(ip={"a": "!network: udp\ninclude:b\n", "b": "1.2.3.4\n"}):
with self.assertRaisesRegex(BuildError, "ограничения не совпадают"):
build.resolve_ip("a")
def test_include_with_same_options_is_allowed(self):
source = "!network: udp\n!port: 100-200\n"
with data(ip={"a": source + "include:b\n1.1.1.1\n", "b": source + "2.2.2.2\n"}):
nets, options = build.resolve_ip("a")
self.assertEqual([str(n) for n in nets], ["1.1.1.1/32", "2.2.2.2/32"])
self.assertEqual(options, Options(("udp",), ((100, 200),)))
class CollapseTest(unittest.TestCase):
def test_subdomains_are_dropped_under_suffix(self):
rules = [Rule("domain", "a.com"), Rule("full", "x.a.com"), Rule("domain", "deep.sub.a.com")]
self.assertEqual(build.collapse_rules(rules), [Rule("domain", "a.com")])
def test_attributed_rules_survive(self):
rules = [Rule("domain", "a.com"), Rule("full", "x.a.com", ("ads",))]
self.assertEqual(len(build.collapse_rules(rules)), 2)
def test_nested_networks_are_dropped(self):
nets = [net("10.0.0.0/8"), net("10.1.2.3/32"), net("11.0.0.0/8"), net("2001:db8::/32")]
collapsed = [str(n) for n in build.collapse_nets(nets)]
self.assertEqual(collapsed, ["10.0.0.0/8", "11.0.0.0/8", "2001:db8::/32"])
def test_collapse_happens_across_includes(self):
with data(a="include:b\nfull:node1.a.com\n", b="a.com\n"):
self.assertEqual(build.resolve("a"), [Rule("domain", "a.com")])
class ProtobufTest(unittest.TestCase):
def test_known_geosite_byte_layout(self):
blob = build.encode_geosite_dat({"t": [Rule("domain", "a.com")]})
# 0a 0e GeoSiteList.entry, 14 байт
# 0a 01 54 country_code = "T" (в .dat теги в верхнем регистре)
# 12 09 GeoSite.domain, 9 байт
# 08 02 type = Domain(2)
# 12 05 612e636f6d value = "a.com"
self.assertEqual(blob.hex(), "0a0e" "0a0154" "1209" "0802" "1205" "612e636f6d")
def test_known_geoip_byte_layout(self):
blob = build.encode_geoip_dat({"t": [net("1.2.3.0/24")]})
# 0a 0d GeoIPList.entry, 13 байт
# 0a 01 54 country_code = "T"
# 12 08 GeoIP.cidr, 8 байт
# 0a 04 01020300 ip = 1.2.3.0
# 10 18 prefix = 24
self.assertEqual(blob.hex(), "0a0d" "0a0154" "1208" "0a04" "01020300" "1018")
def test_keyword_type_is_omitted_as_proto3_default(self):
blob = build.encode_geosite_dat({"t": [Rule("keyword", "ip")]})
self.assertNotIn(b"\x08\x00", blob)
self.assertEqual(build.decode_geosite_dat(blob)["T"], [Rule("keyword", "ip")])
def test_round_trip_with_attributes(self):
categories = {
"one": [Rule("domain", "a.com", ("ads",)), Rule("full", "b.com")],
"two": [Rule("keyword", "ip"), Rule("regexp", r"^x\d+$")],
}
decoded = build.decode_geosite_dat(build.encode_geosite_dat(categories))
self.assertEqual(decoded, {k.upper(): v for k, v in categories.items()})
def test_geoip_round_trip_mixed_families(self):
categories = {"one": [net("0.0.0.0/0"), net("1.2.3.4/32")], "two": [net("2001:db8::/32")]}
decoded = build.decode_geoip_dat(build.encode_geoip_dat(categories))
self.assertEqual(decoded, {k.upper(): v for k, v in categories.items()})
class RenderTest(unittest.TestCase):
def test_singbox_expands_suffix_to_apex_plus_dot(self):
rendered = json.loads(build.render_singbox([Rule("domain", "a.com"), Rule("full", "b.com")]))
self.assertEqual(rendered["rules"][0]["domain"], ["a.com", "b.com"])
self.assertEqual(rendered["rules"][0]["domain_suffix"], [".a.com"])
def test_clash_yaml_skips_unsupported_types(self):
rules = [Rule("domain", "a.com"), Rule("full", "b.com"), Rule("keyword", "ip")]
payload = build.render_clash_yaml("t", rules)
self.assertIn("- '+.a.com'", payload)
self.assertIn("- 'b.com'", payload)
self.assertNotIn("ip", payload.split("payload:")[1])
def test_clash_list_keeps_all_types(self):
rules = [Rule("domain", "a.com"), Rule("keyword", "ip"), Rule("regexp", "^x$")]
body = build.render_clash_list("t", rules)
self.assertIn("DOMAIN-SUFFIX,a.com", body)
self.assertIn("DOMAIN-KEYWORD,ip", body)
self.assertIn("DOMAIN-REGEX,^x$", body)
def test_ip_singbox_carries_network_and_ports(self):
options = Options(("udp",), ((443, 443), (50000, 50099)))
rendered = json.loads(build.render_ip_singbox([net("1.2.3.0/24")], options))
rule = rendered["rules"][0]
self.assertEqual(rule["ip_cidr"], ["1.2.3.0/24"])
self.assertEqual(rule["network"], ["udp"])
self.assertEqual(rule["port"], [443])
self.assertEqual(rule["port_range"], ["50000:50099"])
def test_ip_clash_list_marks_family_and_no_resolve(self):
body = build.render_ip_clash_list("t", [net("1.2.3.0/24"), net("2001:db8::/32")], Options())
self.assertIn("IP-CIDR,1.2.3.0/24,no-resolve", body)
self.assertIn("IP-CIDR6,2001:db8::/32,no-resolve", body)
def test_xray_routing_orders_general_rules_before_restricted(self):
payload = json.loads(
build.render_xray_routing(
{"site": [Rule("domain", "a.com")]},
{
"voice": ([net("1.2.3.0/24")], Options(("udp",), ((50000, 50099),))),
"plain": ([net("4.4.4.0/24")], Options()),
},
"site.dat",
"ip.dat",
"proxy",
)
)
rules = payload["routing"]["rules"]
self.assertEqual(rules[0]["domain"], ["ext:site.dat:site"])
self.assertEqual(rules[1]["ip"], ["ext:ip.dat:plain"])
self.assertEqual(rules[2]["network"], "udp")
self.assertEqual(rules[2]["port"], "50000-50099")
self.assertTrue(all(r["outboundTag"] == "proxy" for r in rules))
def test_xray_routing_skips_categories_covered_by_another(self):
payload = json.loads(
build.render_xray_routing(
{"all": [Rule("domain", "a.com")], "part": [Rule("full", "x.a.com")]},
{
"all": ([net("10.0.0.0/8")], Options()),
"part": ([net("10.1.2.3/32")], Options()),
# Те же сети, но под ограничением — это другое правило.
"voice": ([net("10.0.0.0/8")], Options(("udp",), ((50000, 50099),))),
},
"site.dat",
"ip.dat",
"proxy",
)
)
rules = payload["routing"]["rules"]
self.assertEqual(
[r.get("domain", r.get("ip"))[0] for r in rules],
["ext:site.dat:all", "ext:ip.dat:all", "ext:ip.dat:voice"],
)
def test_essential_keeps_one_of_two_identical_categories(self):
items = {"b": [Rule("domain", "a.com")], "a": [Rule("domain", "a.com")]}
self.assertEqual(build.essential(items, build.collapse_rules), ["a"])
class SyncTest(unittest.TestCase):
def test_listable_fields_are_accepted(self):
payload = {
"version": 3,
"rules": [{"network": "udp", "ip_cidr": "1.2.3.0/24", "port_range": "50000:50099"}],
}
parsed = sync.parse_ruleset(payload, "t")
(options, nets), = parsed.groups.items()
self.assertEqual(options, Options(("udp",), ((50000, 50099),)))
self.assertEqual([str(n) for n in nets], ["1.2.3.0/24"])
def test_domain_fields_map_to_geosite_types(self):
payload = {
"version": 1,
"rules": [
{
"domain": ["exact.com"],
"domain_suffix": [".suffix.com", "bare.com"],
"domain_keyword": ["kw"],
"domain_regex": ["^r$"],
}
],
}
parsed = sync.parse_ruleset(payload, "t")
self.assertEqual(
sorted(str(r) for r in parsed.domains),
["bare.com", "full:exact.com", "keyword:kw", "regexp:^r$", "suffix.com"],
)
def test_logical_rules_are_flattened_and_invert_skipped(self):
payload = {
"version": 2,
"rules": [
{"type": "logical", "mode": "or", "rules": [{"domain_suffix": "inner.com"}]},
{"ip_cidr": "9.9.9.9/32", "invert": True},
],
}
parsed = sync.parse_ruleset(payload, "t")
self.assertEqual([str(r) for r in parsed.domains], ["inner.com"])
self.assertEqual(parsed.groups, {})
self.assertTrue(any("invert" in s for s in parsed.skipped))
def test_group_names_disambiguate_by_ports(self):
first = Options(("udp",), ((50000, 50099),))
second = Options(("udp",), ((19000, 20000),))
names = sync.group_names([first, second], "src")
self.assertEqual(names[first], "src-udp-50000-50099")
self.assertEqual(names[second], "src-udp-19000-20000")
def test_single_group_keeps_short_name(self):
options = Options(("udp",), ((50000, 50099),))
self.assertEqual(sync.group_names([options], "src"), {options: "src-udp"})
def test_handwritten_files_are_never_overwritten(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "manual.txt"
path.write_text("# мой список\na.com\n", encoding="utf-8")
self.assertFalse(sync.is_generated(path))
with self.assertRaisesRegex(sync.SyncError, "вручную"):
sync.write_generated(path, [], ["b.com"])
sync.write_generated(Path(tmp) / "new.txt", ["nota"], ["b.com"])
self.assertTrue(sync.is_generated(Path(tmp) / "new.txt"))
class CheckTest(unittest.TestCase):
def test_clean_sources_pass(self):
with data(a="a.com\nb.com\n", ip={"a": "1.2.3.0/24\n"}):
self.assertEqual(build.check(), 0)
def test_duplicate_is_reported(self):
with data(a="a.com\na.com\n"):
self.assertEqual(build.check(), 1)
def test_subdomain_covered_by_parent_is_reported(self):
with data(a="a.com\ndeep.sub.a.com\n"):
self.assertEqual(build.check(), 1)
def test_duplicate_network_is_reported(self):
with data(a="a.com\n", ip={"a": "1.2.3.4\n1.2.3.4/32\n"}):
self.assertEqual(build.check(), 1)
def test_network_covered_by_wider_one_is_reported(self):
with data(a="a.com\n", ip={"a": "10.0.0.0/8\n10.1.2.3\n"}):
self.assertEqual(build.check(), 1)
def test_real_repository_data_is_clean(self):
self.assertEqual(build.check(), 0)
class BuildTest(unittest.TestCase):
def test_produces_every_format(self):
expected = [
"discord-geosite.dat",
"discord-geoip.dat",
"geosite.dat",
"geoip.dat",
"discord-geoip.dat.sha256sum",
"metadata.json",
"xray/routing.json",
"txt/site/b.txt",
"src/site/b.txt",
"json/site/b.json",
"sing-box/site/b.json",
"clash/site/b.yaml",
"clash/site/b.list",
"adguard/b.txt",
"txt/ip/v.txt",
"src/ip/v.txt",
"json/ip/v.json",
"sing-box/ip/v.json",
"clash/ip/v.yaml",
"clash/ip/v.list",
]
sources = {"a": "a.com\n", "b": "include:a\nfull:c.com\n"}
nets = {"n": "1.2.3.0/24\n", "v": "!network: udp\n!port: 50000-50099\n5.6.7.0/24\n"}
with data(ip=nets, **sources), tempfile.TemporaryDirectory() as out:
out_dir = Path(out) / "dist"
self.assertEqual(build.build(out_dir, None, None, "proxy"), 0)
for rel in expected:
self.assertTrue((out_dir / rel).is_file(), rel)
self.assertEqual(
(out_dir / "geoip.dat").read_bytes(), (out_dir / "discord-geoip.dat").read_bytes()
)
meta = json.loads((out_dir / "metadata.json").read_text(encoding="utf-8"))
self.assertEqual(meta["categories"], {"a": 1, "b": 2})
self.assertEqual(meta["ip_categories"]["n"], {"count": 1})
self.assertEqual(
meta["ip_categories"]["v"], {"count": 1, "network": ["udp"], "ports": "50000-50099"}
)
def test_source_dump_round_trips_through_parser(self):
"""src/ip/*.txt должен снова читаться сборщиком — вместе с директивами."""
options = Options(("udp",), ((50000, 50099),))
dumped = build.render_ip_source("v", [net("5.6.7.0/24")], options)
with data(a="a.com\n", ip={"v": dumped}):
self.assertEqual(build.resolve_ip("v"), ([net("5.6.7.0/24")], options))
if __name__ == "__main__":
unittest.main()
+86
View File
@@ -0,0 +1,86 @@
#!/bin/sh
# Обновляет локальные discord-geosite.dat и discord-geoip.dat из ветки release.
# Рассчитан на запуск по cron.
#
# Каждый файл скачивается, только если его sha256 отличается от локального,
# контрольная сумма проверяется до подмены, а сервис перезапускается один раз
# и только если что-то реально изменилось. Молчит, когда обновлять нечего, —
# cron не будет слать письма впустую.
#
# ./update-geodata.sh
# BASE_URL=https://gitea.hsrv.site/mrleo1nid/discord-geodata/raw/branch/release \
# DEST_DIR=/usr/local/x-ui/bin RESTART_CMD='x-ui restart' ./update-geodata.sh
#
# NAMES задаёт список файлов; по умолчанию — оба .dat.
# Коды возврата: 0 — обновлено или уже актуально, 1 — ошибка.
set -eu
BASE_URL="${BASE_URL:-https://gitea.hsrv.site/mrleo1nid/discord-geodata/raw/branch/release}"
DEST_DIR="${DEST_DIR:-/usr/local/x-ui/bin}"
NAMES="${NAMES:-discord-geosite.dat discord-geoip.dat}"
RESTART_CMD="${RESTART_CMD:-}"
TIMEOUT="${TIMEOUT:-30}"
log() { printf '%s update-geodata: %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$*"; }
die() { log "ОШИБКА: $*" >&2; exit 1; }
command -v curl >/dev/null 2>&1 || die "нужен curl"
if command -v sha256sum >/dev/null 2>&1; then
hash_of() { sha256sum "$1" | cut -d' ' -f1; }
elif command -v shasum >/dev/null 2>&1; then
hash_of() { shasum -a 256 "$1" | cut -d' ' -f1; }
else
die "нужен sha256sum или shasum"
fi
fetch() { curl -fsSL --retry 3 --retry-delay 2 --max-time "$TIMEOUT" "$1"; }
mkdir -p "$DEST_DIR"
changed=0
for name in $NAMES; do
dest="$DEST_DIR/$name"
# Без промежуточной переменной статус вернул бы cut, а не curl.
remote_line=$(fetch "$BASE_URL/$name.sha256sum") ||
die "не удалось скачать $BASE_URL/$name.sha256sum"
remote_sum=$(printf '%s' "$remote_line" | cut -d' ' -f1)
case "$remote_sum" in
[0-9a-f][0-9a-f]*) [ "${#remote_sum}" -eq 64 ] || die "битая контрольная сумма: $remote_sum" ;;
*) die "битая контрольная сумма: $remote_sum" ;;
esac
if [ -f "$dest" ] && [ "$(hash_of "$dest")" = "$remote_sum" ]; then
continue # уже актуально — тишина
fi
tmp=$(mktemp "${TMPDIR:-/tmp}/discord-geodata.XXXXXX") || die "не удалось создать временный файл"
trap 'rm -f "$tmp"' EXIT INT TERM
fetch "$BASE_URL/$name" > "$tmp" || die "не удалось скачать $BASE_URL/$name"
got=$(hash_of "$tmp")
[ "$got" = "$remote_sum" ] || die "$name: ожидалось $remote_sum, получено $got"
[ -s "$tmp" ] || die "$name: скачан пустой файл"
[ ! -f "$dest" ] || cp -p "$dest" "$dest.bak" || die "не удалось сохранить резервную копию"
# mv в пределах одного каталога атомарен: Xray никогда не увидит половину файла.
cat "$tmp" > "$dest.new" && mv -f "$dest.new" "$dest" || die "не удалось записать $dest"
rm -f "$tmp"
trap - EXIT INT TERM
log "обновлён $dest (sha256 $remote_sum)"
changed=1
done
[ "$changed" -eq 1 ] || exit 0
if [ -n "$RESTART_CMD" ]; then
if sh -c "$RESTART_CMD"; then
log "выполнено: $RESTART_CMD"
else
die "перезапуск не удался: $RESTART_CMD"
fi
fi