fix: приватные сервисы, безопасные загрузки и повторная настройка (S1–S10) #19
17 changed files with 1887 additions and 205 deletions
15
.github/workflows/bash-syntax.yml
vendored
15
.github/workflows/bash-syntax.yml
vendored
|
|
@ -13,7 +13,8 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
# v4.2.2 uses Node 20, supported by the current Forgejo runner.
|
||||
uses: https://code.forgejo.org/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
|
||||
- name: Check every installer
|
||||
run: |
|
||||
|
|
@ -46,3 +47,15 @@ jobs:
|
|||
|
||||
- name: Test step 8 AI agent catalog
|
||||
run: bash tests/test-step8-agent-catalog.sh
|
||||
|
||||
- name: Test firewall and panel security
|
||||
run: bash tests/test-firewall-panel-security.sh
|
||||
|
||||
- name: Test pinned downloads
|
||||
run: bash tests/test-download-security.sh
|
||||
|
||||
- name: Test notifications and backup security
|
||||
run: bash tests/test-notification-backup-security.sh
|
||||
|
||||
- name: Test initial backup policy and upload retry
|
||||
run: bash tests/test-backup-initial-retry.sh
|
||||
|
|
|
|||
60
README.md
60
README.md
|
|
@ -8,30 +8,37 @@ A modular set of Bash installers for the initial setup, security hardening, admi
|
|||
|
||||
## Quick installation
|
||||
|
||||
Copy the complete block into the server terminal. It downloads the repository archive through `codeload.github.com`, stops on errors, and starts the unified installer:
|
||||
Obtain an approved full commit SHA and the archive SHA-256 through a trusted release/review channel. The digest is specific to the selected source: Forgejo and GitHub archives can differ. Do not use a branch name or obtain the expected digest from the downloaded archive itself. Until a reviewed release publishes these values, this procedure requires the operator to supply them.
|
||||
|
||||
The complete block verifies the archive before starting the wizard. Forgejo is the default; GitHub is optional:
|
||||
|
||||
```bash
|
||||
set -e
|
||||
cd "$HOME"
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y curl ca-certificates tar
|
||||
read -rp 'Reviewed full commit SHA (40 hex): ' revision
|
||||
read -rp 'Trusted archive SHA-256 (64 hex): ' expected
|
||||
[[ "$revision" =~ ^[0-9a-f]{40}$ && "$expected" =~ ^[0-9a-f]{64}$ ]]
|
||||
read -rp 'Source: forgejo or github [forgejo]: ' source
|
||||
case "${source:-forgejo}" in
|
||||
forgejo) url="https://git.ochenstarik.ru/ochenstarik/lightweight-server/archive/${revision}.tar.gz" ;;
|
||||
github) url="https://codeload.github.com/ochenstarik-ui/lightweight-server/tar.gz/${revision}" ;;
|
||||
*) exit 1 ;;
|
||||
esac
|
||||
archive="$(mktemp)"
|
||||
trap 'rm -f "$archive"' EXIT
|
||||
curl -4 -fL \
|
||||
--retry 5 \
|
||||
--retry-delay 10 \
|
||||
--connect-timeout 30 \
|
||||
https://codeload.github.com/ochenstarik-ui/lightweight-server/tar.gz/refs/heads/main \
|
||||
-o "$archive"
|
||||
install -d -m 700 lightweight-server
|
||||
curl -fLsS --proto '=https' --proto-redir '=https' --tlsv1.2 \
|
||||
--retry 3 --connect-timeout 30 --max-time 300 "$url" -o "$archive"
|
||||
printf '%s %s\n' "$expected" "$archive" | sha256sum -c -
|
||||
# Refuse to mix a reviewed archive with files from an earlier installation.
|
||||
[[ ! -e lightweight-server ]]
|
||||
mkdir -m 700 lightweight-server
|
||||
tar -xzf "$archive" -C lightweight-server --strip-components=1
|
||||
rm -f "$archive"
|
||||
trap - EXIT
|
||||
cd lightweight-server
|
||||
chmod 700 ./*.sh
|
||||
sudo ./ochenstarik-server-install.sh
|
||||
```
|
||||
|
||||
The first wizard prompt selects the dialog language. English is the default; Russian, Spanish, German, French, Portuguese, Simplified Chinese, Japanese, Arabic, and Hindi are also available. The wizard then opens the main menu with steps 1–8. Select any step, run it, and the wizard returns to the main menu.
|
||||
|
||||
Keep the current SSH session open while changing the SSH port. Test the new login in a second terminal before disconnecting.
|
||||
|
|
@ -48,7 +55,7 @@ The wizard intentionally requires all module scripts to be present locally. Use
|
|||
| `ochenstarik-server-user-3.sh` | Administrative user, SSH key permissions, SSH migration, sudo, and fail2ban | Run after step 2 |
|
||||
| `ochenstarik-server-tg-4.sh` | Telegram notifications for successful SSH logins | Optional |
|
||||
| `ochenstarik-server-vpn-5.sh` | System VPN through Xray; repeated runs can connect, disconnect, reconfigure, or show status | Optional |
|
||||
| `ochenstarik-server-panel-warp-6.sh` | 3x-ui panel and local Cloudflare WARP proxy | Optional |
|
||||
| `ochenstarik-server-panel-warp-6.sh` | 3x-ui panel and optional local Cloudflare WARP proxy | Optional |
|
||||
| `ochenstarik-server-backup-7.sh` | Protected initial snapshot and selected daily, weekly, or monthly schedules | Optional |
|
||||
| `ochenstarik-server-ai-agents-8.sh` | Installs selected AI agents: Hermes, OpenClaw, OpenHands, OpenCode, Aider, AutoGPT, or Pi Coding Agent | Optional |
|
||||
| `ochenstarik-server-uninstall.sh` | Removes project-managed settings so installation can start again | Use carefully |
|
||||
|
|
@ -71,7 +78,7 @@ Replace `SCRIPT_NAME.sh` with the required filename from the table.
|
|||
4. Create or update the administrative user, install the public SSH key, repair ownership and permissions, and move SSH to the port selected in step 2.
|
||||
5. Optionally configure Telegram SSH-login notifications.
|
||||
6. Optionally configure the system VPN and subscription port.
|
||||
7. Optionally deploy 3x-ui and Cloudflare WARP with selected panel and subscription ports.
|
||||
7. Optionally deploy 3x-ui with separate panel/subscription access choices; WARP is a separate opt-in.
|
||||
8. Optionally create the permanent initial backup and configure rotating schedules.
|
||||
9. Optionally install one or more AI agents for the administrative user.
|
||||
|
||||
|
|
@ -88,8 +95,16 @@ sudo ./ochenstarik-server-vpn-5.sh --status
|
|||
|
||||
Disconnecting removes the system routing rules but keeps Xray and its configuration so the VPN can be reconnected without entering the subscription again.
|
||||
|
||||
## Pinned VPN and panel installers
|
||||
|
||||
Steps 5 and 6 require the bundled `ochenstarik-server-supply-chain.sh`. They verify the installers and executable payloads against committed SHA-256 values: 3x-ui `v3.10.2`, Xray installer commit `05aa47266c94e6fb5ca48cad96afdb4becb5891f`, Xray-core `v26.10.21` (amd64/arm64). Select GitHub or an operator-prepared HTTPS Forgejo mirror. The mirror must contain the files listed below; `ochenstarik-server-supply-chain-pins.txt` summarizes the hashes enforced by the helper; no mirror release is assumed to exist. Unknown installer downloads fail closed. Ordinary OS packages still require access to configured APT repositories.
|
||||
|
||||
The mirror layout is flat: `xui-install.sh`, `x-ui-linux-amd64.tar.gz` or `x-ui-linux-arm64.tar.gz`, `x-ui.sh`, `x-ui.service.debian`, `xray-install.sh`, and `Xray-linux-64.zip` or `Xray-linux-arm64-v8a.zip`. Digests are the committed values; the helper creates digest sidecars locally. Step 5 only needs the two Xray files.
|
||||
|
||||
## Optional AI agents
|
||||
|
||||
Do not install step 8 on VPN servers or Telegram client nodes. Use a separate machine for AI agents.
|
||||
|
||||
Step 8 offers a multiple selection of seven popular agents: Hermes Agent, OpenClaw, OpenHands, OpenCode, Aider, AutoGPT, and Pi Coding Agent. Installers are downloaded only from each project's official HTTPS endpoint, checked with `bash -n`, and launched as the selected regular user rather than directly as `root`.
|
||||
|
||||
AutoGPT is a larger Docker-based platform. Its official installer can request `sudo`, install Docker-related dependencies, and start local services. Pi is a lightweight terminal coding agent; after installation, start it with `pi` and use `/login` to connect a supported provider.
|
||||
|
|
@ -102,7 +117,10 @@ The script does not request or store model-provider API keys. After installation
|
|||
- A repeated step 2 run can change the IP-family mode or add ports without reinstalling everything.
|
||||
- Step 3 reads the SSH port chosen in step 2 and creates an administrator with sudo access.
|
||||
- The home directory, `.ssh`, and `authorized_keys` ownership and permissions are checked on every run.
|
||||
- Do not remove temporary port 22 access until a new SSH session succeeds on the selected port.
|
||||
- Defaults expose only the SSH ports needed during migration and TCP 443. Port 80, subscriptions (2096), WARP (40000), and 63636 are not opened automatically. Additional application rules require an explicit selection; existing managed application rules are listed for an explicit keep/remove decision.
|
||||
- After you successfully log in on the new SSH port and confirm this in step 3, it removes temporary TCP 22 access from UFW and managed state. Without confirmation it keeps 22; no cleanup runs when SSH remains on 22 or UFW management is disabled.
|
||||
- The panel defaults to `webListen=127.0.0.1`, checked against real TCP listener addresses. Reach it through an SSH tunnel. Subscriptions independently default to loopback, can be disabled, or can be explicitly published with a random secret path. Existing sufficiently random paths are preserved by default; rotation requires confirmation because it invalidates client URLs. Preserve subscription URLs privately. Only explicitly public services receive UFW rules. WARP defaults to off and remains loopback-only when selected.
|
||||
- Telegram login notifications run only for PAM `open_session`; detached delivery does not wait for Telegram during login or send a second login notification at session close.
|
||||
|
||||
## Backups
|
||||
|
||||
|
|
@ -113,7 +131,9 @@ Step 7 can create:
|
|||
- weekly backups retained for 4 weeks;
|
||||
- monthly backups retained for 12 months.
|
||||
|
||||
Schedules are selected independently, so any combination can be enabled. Keep an additional copy on another server or storage device; a local backup does not protect against loss of the entire host.
|
||||
Schedules are selected independently. Backups contain server secrets. Local plaintext `.tar.zst` remains the default; opt into age encryption by providing the recipient public key. Encrypted output is streamed into `.tar.zst.age`, with no temporary plaintext archive. Keep the private recovery key separately and test decryption before relying on a backup.
|
||||
|
||||
Optional SCP upload sends the completed archive and checksum with batch authentication and strict host-key checking against the operator-supplied known_hosts file. Configure a dedicated destination account and existing SSH key; no passwords or automatic host-key trust. Upload errors are failures. An existing plaintext initial snapshot is preserved: selecting age then stops with an explicit error rather than claiming it is encrypted. Move it to safe storage and resolve the initial-snapshot policy before continuing. Re-running `initial` verifies the existing checksum and retries offsite upload, including after an earlier upload failure. Local retention does not provide remote retention or protect against loss of the host. To restore an encrypted archive, decrypt with `age --decrypt -i RECOVERY_KEY` before passing the stream through `zstd -dc | tar`; never commit recovery keys.
|
||||
|
||||
## Multi-server monitoring and management
|
||||
|
||||
|
|
@ -131,18 +151,12 @@ The manager is under active early development. Review its security and developme
|
|||
- Ubuntu Server with systemd and APT;
|
||||
- root access through `sudo`;
|
||||
- an active SSH session;
|
||||
- outbound HTTPS access to GitHub or GitHub codeload;
|
||||
- outbound HTTPS access to the selected archive/vendor source and configured APT repositories;
|
||||
- a valid public SSH key for the administrator setup.
|
||||
|
||||
## Update
|
||||
|
||||
For a Git clone:
|
||||
|
||||
```bash
|
||||
cd ~/lightweight-server
|
||||
git pull
|
||||
sudo ./ochenstarik-server-install.sh
|
||||
```
|
||||
Use another reviewed full commit and its source-specific archive checksum with the verified download procedure above, into a fresh directory. Review the changes before running modules again; do not update production installers by pulling a mutable branch.
|
||||
|
||||
## Reset project-managed configuration
|
||||
|
||||
|
|
|
|||
107
agents/codex/done/2026-10-23-lightweight-security.md
Normal file
107
agents/codex/done/2026-10-23-lightweight-security.md
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
# Lightweight-server: S1–S10, 23.10.2026
|
||||
|
||||
Технический результат: **fixed**, S8 — **no_change** (защита уже была в исходном main). Релизная приёмка и merge остаются отдельным gate; автор ветку не вливает. База: `ef83559f7de19fd8649c78b22b05df397650cf46`. Ветка: `codex/lightweight-security-20261023`.
|
||||
|
||||
Задание: `agents/codex/inbox/2026-10-23-lightweight-security.md`. Изолированный worktree существующего репозитория, без clone. На хосте и реальных RU/DE/FI/DK серверах установщики и root-команды не запускались. Создана собственная одноразовая KVM Ubuntu 24.04; после проверок остановлена, её диски, seed и временные SSH-ключи удалены.
|
||||
|
||||
## Граница исправления
|
||||
|
||||
Установщики сами создавали публичные правила и wildcard-слушатели. Исправлена вся цепочка этапов 2/3/6, включая сохранённые управляемые правила и первый запуск upstream-установщика. Частичная правка одного списка портов оставляла бы прежние разрешения и другие этапы, открывающие их снова.
|
||||
|
||||
| Пункт | Изменение и подтверждение |
|
||||
| --- | --- |
|
||||
| S1 | По умолчанию только текущий/будущий SSH и TCP 443. Нет автоматического 80/2096/40000/63636. Старые управляемые правила требуют явного сохранения; при отказе удаляются реальные UFW-правила IPv4/IPv6 и лишь затем обновляется файл состояния. Проверены реальные правила в VM. |
|
||||
| S2 | `webListen=127.0.0.1`, адрес проверяется через `ss`. Перед первым upstream start/restart временная обёртка systemctl применяет loopback к панели и отключает подписки; ошибка не допускает старт. Итоговая конфигурация проверяется до снятия защиты и после запуска. |
|
||||
| S3 | Режимы панели и подписок независимы. Подписки: loopback / выключены / явно публичные со случайным путём. Прежний валидный приватный путь сохраняется; достаточно длинный секретный публичный путь сохраняется по умолчанию. Ротация запрашивает подтверждение и предупреждает о поломке старых URL. Путь в журнал не выводится. |
|
||||
| S4 | Закреплены 3x-ui v3.10.2, Xray-install `05aa47266c94e6fb5ca48cad96afdb4becb5891f`, Xray-core v26.10.21 и SHA-256 всех используемых исполняемых payload amd64/arm64. Локальный helper проверяет их перед запуском; неизвестные транзитивные загрузки 3x-ui блокируются. GitHub либо HTTPS Forgejo mirror, без fallback. В README загрузка самого проекта по полному проверенному SHA и доверенной сумме, без mutable main. |
|
||||
| S5 | После подтверждённого оператором нового входа удаляются правила 22/tcp и запись в управляемом списке. Если UFW всё ещё разрешает IPv4/IPv6 22 либо статус не проверен, скрипт не объявляет успех и сохраняет запись. Без подтверждения, при SSH=22 или MANAGE_UFW=no очистка не выполняется. |
|
||||
| S6 | Только PAM open_session. Отправка через фиксированный `/usr/bin/setsid`, отдельный процесс, stdin/stdout/stderr закрыты. Производственные пути конфигурации и журнала фиксированы, без тестовых environment overrides. Тест: close не отправляет, open запускает одну отправку, возврат <500ms при curl, задержанном на 1s. |
|
||||
| S7 | WARP — отдельный opt-in, по умолчанию выключен. |
|
||||
| S8 | Уже присутствовали `--proto '=https' --tlsv1.2`; исходный файл не изменён, регрессия закреплена новым тестом. |
|
||||
| S9 | Кавычки и обратные слэши экранируются в curl config, управляющие символы отклоняются до вызова curl. Проверены newline-инъекция и большой URL с vertical-tab, без pipefail/grep-q обхода. Секретная ссылка не передаётся в argv curl. |
|
||||
| S10 | Опциональный age encrypt streaming без временного plaintext-архива; SCP с BatchMode и строгим заранее проверенным known_hosts. Ошибка выгрузки — ошибка операции. Повтор initial проверяет checksum и повторяет выгрузку; несовместимый старый plaintext при выбранном age явно отклоняется без удаления снимка. В VM настоящий age/zstd/tar round-trip совпал с исходным fixture. |
|
||||
|
||||
Файлы: этапы `2`, `user-3`, `tg-4`, `vpn-5`, `panel-warp-6`, `backup-7`; новый `ochenstarik-server-supply-chain.sh` и справочный `...pins.txt`; README EN/RU, CI workflow и четыре новых теста. Исходные восемь тестов не изменены.
|
||||
|
||||
## Проверки по порядку
|
||||
|
||||
1. `find . -type f -name '*.sh' -print0 | xargs -0 -n1 bash -n` — PASS.
|
||||
2. Новые поведенческие проверки:
|
||||
- `tests/test-firewall-panel-security.sh`: defaults, миграция старых UFW-правил, IPv6 residual error, sudo без SSH_CONNECTION, независимые режимы/сохранение путей, startup guard, ожидание слушателя, подтверждение удаления 22;
|
||||
- `tests/test-download-security.sh`: escaping/контрольные символы, cache shim lifecycle и запрет неизвестного installer URL, реальные вызовы helper из этапов 5/6, отсутствие upstream main, отсутствие fallback при ошибке mirror;
|
||||
- `tests/test-notification-backup-security.sh`: PAM open/close, detached delivery, строгие SSH options и ошибка offsite;
|
||||
- `tests/test-backup-initial-retry.sh`: реальный early-return путь initial, ошибка SCP → повтор, неизменность snapshot/checksum, age policy с прежним plaintext.
|
||||
3. `for test in tests/*.sh; do bash "$test"; done` — **12/12 PASS**; `git diff --check` — PASS; diff исходных `tests/` пуст.
|
||||
4. Одноразовая Ubuntu 24.04 amd64, настоящий systemd/UFW/3x-ui: этап 2 (полная установка), миграция старых правил, этап 6 fresh install и повтор с публичными подписками — exit 0.
|
||||
5. Тот же VM: helper `prepare-xray` → `install-xray` — exit 0, реальный Xray **26.10.21**; age-backup round-trip — PASS.
|
||||
|
||||
## Настоящие UFW и TCP-слушатели
|
||||
|
||||
Ubuntu image SHA-256: `04e8f2461a16a44e78a370284471f29616cdd9baf1893df25807b71f985b6c94`, проверен по официальному SHA256SUMS cloud-images. SSH в тесте оставлен на 22; WARP выключен.
|
||||
|
||||
После полного этапа 2 и после удаления прежних 80/2096/40000/63636:
|
||||
|
||||
```text
|
||||
ufw status verbose
|
||||
Status: active
|
||||
Logging: on (low)
|
||||
Default: deny (incoming), allow (outgoing), disabled (routed)
|
||||
New profiles: skip
|
||||
22/tcp ALLOW IN Anywhere
|
||||
443/tcp ALLOW IN Anywhere
|
||||
22/tcp (v6) ALLOW IN Anywhere (v6)
|
||||
443/tcp (v6) ALLOW IN Anywhere (v6)
|
||||
```
|
||||
|
||||
После fresh этапа 6 с приватной панелью и приватными подписками UFW остаётся тем же. Полный `ss -ltn` (сокращены только столбцы очередей):
|
||||
|
||||
```text
|
||||
Local Address:Port Peer Address:Port
|
||||
127.0.0.1:2096 0.0.0.0:*
|
||||
127.0.0.1:2053 0.0.0.0:*
|
||||
0.0.0.0:22 0.0.0.0:*
|
||||
127.0.0.54:53 0.0.0.0:*
|
||||
127.0.0.1:62789 0.0.0.0:*
|
||||
127.0.0.53%lo:53 0.0.0.0:*
|
||||
127.0.0.1:11111 0.0.0.0:*
|
||||
[::]:22 [::]:*
|
||||
```
|
||||
|
||||
DNS/внутренние Xray-порты остаются локальными. Разрешение UFW 443 не означает, что скрипт создаёт на 443 отдельный HTTPS-сервис. Во время sampled fresh bootstrap наблюдались только `127.0.0.1:49587` (временный upstream порт), `127.0.0.1:2053`, `127.0.0.1:2096`; sampling дополняет pre-start enforcement, не доказывает отсутствие любого мгновенного слушателя сам по себе.
|
||||
|
||||
При явном публичном доступе к подпискам UFW дополнительно содержит:
|
||||
|
||||
```text
|
||||
2096/tcp ALLOW IN Anywhere
|
||||
2096/tcp (v6) ALLOW IN Anywhere (v6)
|
||||
```
|
||||
|
||||
`ss -ltn`: панель **127.0.0.1:2053**, подписки **\*:2096**. DB: `webListen=127.0.0.1`, `subListen=''`, `subEnable=true`. Секретный путь не выгружался в журнал.
|
||||
|
||||
## Два аудитора и собственная проверка
|
||||
|
||||
Дешёвые исполнители: `lightweight_firewall_panel`, `lightweight_downloads_notifications_backup`. До патча независимый boundary investigator; после кандидата два свежих read-only Codex auditor: `lightweight_security_audit_one`, `lightweight_security_audit_two`. Один цикл аудита, без самооценки авторского кода как релизного approval.
|
||||
|
||||
Подтверждены и исправлены найденные аудиторами: сохранённые UFW entries, игнорирование ошибки удаления 22, IPv6 статус, потеря/ротация прежнего subscription path, initial/offsite retry и несовместимая age policy, неточное описание pin manifest. Root дополнительно воспроизвёл реальные ошибки: sudo очищает SSH_CONNECTION, повторный mkdir shim и readiness race после systemctl start. После исправления повторил focused, все repository checks и настоящий VM-прогон. Неподтверждённое замечание об обязательной root escalation через PAM аудитор отозвал; тестовые env overrides всё равно удалены из производства.
|
||||
|
||||
## Evidence и пределы
|
||||
|
||||
Сырые обезличенные результаты сохранены в managed Codex Security artifact storage, target `lightweight-server`, bundle `artifacts-827d7430ed6521a319c17103be0c53cc1d742842b3e097b3a5f63693cead15d9`, каталог `artifacts/lightweight-20261023/`:
|
||||
|
||||
| Файл | SHA-256 |
|
||||
| --- | --- |
|
||||
| final-checks.log | 57be6a409fbce5488611bbc853eaecaacecdde8fa7c40656b87fb2ec925970f2 |
|
||||
| ubuntu24-step2.log | 6e37718bf82ef1bf061449dee8782d79855946dca2ba5776e6bc9bada3dbec37 |
|
||||
| ubuntu24-migration.log | 0257a0c97a8dd592c3492d886ad6fb42af2fedff7139eb70ea16387a8b2e2bab |
|
||||
| ubuntu24-private.log | 4de0099004bcb79822b5a915d1e5914ea3b447e8b41e67471c730f75f39553c8 |
|
||||
| ubuntu24-public.log | f0d75dd045e89ca9f8066542e4806b20c10724165e07f56f659b35c83728274f |
|
||||
| ubuntu24-age.log | 737740b39ceba68214737f21283fea1d37a5f448c5bb3addcb6eef1d39fbcc09 |
|
||||
| ubuntu24-xray.log | 270af1adae421035c3d97d4b0779c2d2f922e72208988036a3e534cff27c2a14 |
|
||||
|
||||
Полная VPN-маршрутизация реального сервера, WARP opt-in, arm64 execution и реальный SCP на чужой хост не запускались; вне требуемого Ubuntu amd64 прогона и разрешений. Mirror проверен на fail-closed отказ; готовый публичный mirror релиз не опубликован, нужны файлы с указанными суммами. Блок README требует реально одобренные SHA+checksum и не выдумывает готовый release.
|
||||
|
||||
По последнему правилу владельца новый PR ждёт Grok на точном SHA либо Claude по прямому запросу владельца. Два технических Codex-аудита этот release gate не заменяют. CI удалённой ветки проверяется отдельно после публикации; этот отчёт фиксирует локальные/VM результаты.
|
||||
|
||||
## Поправка CI после публикации
|
||||
|
||||
Первоначальный SHA `9f06756cefb56be41fb815edcf526f2eef924ef4`, автоматические push/PR runs 1/2 (tasks 7364/7365): FAIL **до checkout**, не отказ тестов. Forgejo 9.0.3, runner v6.4.0 отклонил `runs.using=node24` существующего checkout@v6. Workflow закреплён на совместимом Node20 checkout v4.2.2, точный upstream/mirror SHA `11bd71901bbe5b1630ceea73d27597364c9af683`; `action.yml` проверен из Forgejo mirror. Все 12 проверок сохранены. Результат автоматического CI нового head фиксируется в PR и AI-Memory; повторный dispatch не выполнялся.
|
||||
36
agents/codex/inbox/2026-10-23-lightweight-security.md
Normal file
36
agents/codex/inbox/2026-10-23-lightweight-security.md
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# Codex — lightweight-server: исправления по аудиту безопасности (23.10)
|
||||
|
||||
От: Claude, передаёт владелец. Репозиторий `git.ochenstarik.ru/ochenstarik/lightweight-server`, main `ef83559`.
|
||||
Если у тебя уже есть задание владельца по этому репо — это дополнение к нему: не дублируй, сведи в одну ветку или согласуй порядок.
|
||||
|
||||
## Правила
|
||||
- worktree от `/srv/projects/Agent projects/lightweight-server`, без клонов; одна задача — одна ветка — один PR; в main сам не вливаешь;
|
||||
- существующие тесты в `tests/` не менять, проверки не ослаблять; новые тесты — на поведение из таблицы;
|
||||
- **ничего не запускать на реальных серверах** (RU/DE/FI/DK) и на этой машине от root: проверки — `bash -n`, тесты, при необходимости
|
||||
одноразовый контейнер/VM Ubuntu 24.04 с удалением после прогона;
|
||||
- секретов в Git и логах нет; README.md и `docs/readme/README.ru.md` обновить под изменённое поведение (остальные переводы — по возможности);
|
||||
- режим: дешёвые модели → два аудитора → сам проверяешь. Отчёт — `agents/codex/done/` в этом репо.
|
||||
|
||||
## Зачем
|
||||
Серверы: RU — вход семейного VPN (3x-ui, панель только через SSH-туннель, подписки — семье), DE/FI/DK — выходы VPN и одновременно
|
||||
узлы Telegram-клиента. План: `kagent/agents/claude/done/2026-10-23-vps-plan.md`. Владелец ставит серверы этими скриптами —
|
||||
исправления нужны до российского сервера, в первую очередь S1–S3.
|
||||
|
||||
| # | Находка | Где | Серьёзность | Исправление |
|
||||
|---|---|---|---|---|
|
||||
| S1 | Шаг 2 по умолчанию открывает всему миру 22, 80, **2096**, 443, 40000, 63636. 2096 — порт подписок 3x-ui по умолчанию, поэтому «приватный режим» шага 6 не закрывает подписки; 40000/63636 открыты без сервиса | `server-2.sh:138`, `user-3.sh:491` | высокая | по умолчанию только SSH-порт и 443; остальное — явным выбором с объяснением |
|
||||
| S2 | Приватный режим панели — только правило UFW, панель слушает `0.0.0.0`. Docker и чужие nftables-таблицы (узел Telegram) обходят/перекрывают UFW | `panel-warp-6.sh` `configure_xui_ports` | высокая | в приватном режиме `webListen=127.0.0.1`; проверка `ss -ltn`, что панель не на внешнем адресе |
|
||||
| S3 | Панель и подписки — один переключатель | `panel-warp-6.sh:115` | средняя | раздельно: панель (по умолчанию закрыта, 127.0.0.1) и подписки (по выбору публично, случайный путь) |
|
||||
| S4 | 3x-ui и Xray ставятся установщиками с `main` апстрима, без версии и контрольной суммы; `bash -n` — только синтаксис. Быстрая установка самого проекта — с GitHub `main` | `panel-warp-6.sh:4`, `vpn-5.sh:404`, README | средняя | фиксированный тег релиза + sha256; источник по выбору — Forgejo `git.ochenstarik.ru` (с RU-серверов GitHub бывает недоступен) |
|
||||
| S5 | Порт 22 остаётся открытым после переноса SSH — только напечатанная подсказка | `user-3.sh:537` | средняя | после успешного входа на новый порт (подтверждение оператора) удалить правило 22 автоматически |
|
||||
| S6 | Уведомление о входе: `pam_exec` в `session` срабатывает и при открытии, и при закрытии сессии (двойное «успешный вход»); отправка синхронная, до 10 с задержки входа, если api.telegram.org недоступен (RU) | `tg-4.sh` hook | низкая | выходить, если `PAM_TYPE != open_session`; отправлять в фоне (`setsid … &`) |
|
||||
| S7 | WARP в шаге 6 обязателен; на RU не нужен и может не подняться — скрипт падает после установки 3x-ui | `panel-warp-6.sh` | низкая | WARP — отдельный вопрос/флаг |
|
||||
| S8 | Загрузка установщиков ИИ-агентов без `--proto '=https'` (редирект может уйти на http) | `ai-agents-8.sh:158` | низкая | добавить `--proto '=https' --tlsv1.2` |
|
||||
| S9 | URL подписки вставляется в конфиг curl через `printf 'url = "%s"'`: кавычка в ссылке ломает конфиг (ввод оператора) | `vpn-5.sh` | низкая | экранировать `\` и `"` или передавать URL через `--url` из файла |
|
||||
| S10 | Бэкап — полный снимок ФС с секретами (БД 3x-ui, ключи хоста, токен бота), без шифрования, только локально | `backup-7.sh` | инфо | опция шифрования (age/gpg) и выгрузки на другой хост |
|
||||
|
||||
Рекомендация к эксплуатации (не код): на серверах VPN/узлов Telegram **не ставить шаг 8 (ИИ-агенты)** — это лишний код с правами пользователя-админа.
|
||||
**Приёмка:** новые тесты в `tests/` на S1–S3, S5, S6 (рендер правил UFW/настроек 3x-ui, проверка PAM_TYPE); существующие тесты не менять; `bash -n` и все тесты PASS.
|
||||
|
||||
Дополнительно к приёмке: прогон шагов 2 и 6 в одноразовом Ubuntu 24.04 (контейнер с systemd или VM) — вывод `ufw status verbose`
|
||||
и `ss -ltn` в отчёте: открыты только SSH-порт и 443 (+ подписки, если выбраны), панель слушает только `127.0.0.1`.
|
||||
|
|
@ -8,30 +8,37 @@
|
|||
|
||||
## Быстрая установка
|
||||
|
||||
Скопируйте весь блок в терминал сервера. Он загружает архив через `codeload.github.com`, проверяет ошибки на каждом шаге и запускает единый пошаговый мастер:
|
||||
Получите полный SHA одобренного коммита и SHA-256 архива из доверенного канала релиза или ревью. Контрольная сумма зависит от источника: архивы Forgejo и GitHub могут различаться. Не используйте имя ветки и не вычисляйте «ожидаемую» сумму из самого скачанного архива. Пока проверенный релиз не опубликовал эти значения, оператор должен указать их сам.
|
||||
|
||||
Блок проверяет архив до запуска мастера. По умолчанию выбран Forgejo; GitHub можно выбрать отдельно:
|
||||
|
||||
```bash
|
||||
set -e
|
||||
cd "$HOME"
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y curl ca-certificates tar
|
||||
read -rp 'Reviewed full commit SHA (40 hex): ' revision
|
||||
read -rp 'Trusted archive SHA-256 (64 hex): ' expected
|
||||
[[ "$revision" =~ ^[0-9a-f]{40}$ && "$expected" =~ ^[0-9a-f]{64}$ ]]
|
||||
read -rp 'Source: forgejo or github [forgejo]: ' source
|
||||
case "${source:-forgejo}" in
|
||||
forgejo) url="https://git.ochenstarik.ru/ochenstarik/lightweight-server/archive/${revision}.tar.gz" ;;
|
||||
github) url="https://codeload.github.com/ochenstarik-ui/lightweight-server/tar.gz/${revision}" ;;
|
||||
*) exit 1 ;;
|
||||
esac
|
||||
archive="$(mktemp)"
|
||||
trap 'rm -f "$archive"' EXIT
|
||||
curl -4 -fL \
|
||||
--retry 5 \
|
||||
--retry-delay 10 \
|
||||
--connect-timeout 30 \
|
||||
https://codeload.github.com/ochenstarik-ui/lightweight-server/tar.gz/refs/heads/main \
|
||||
-o "$archive"
|
||||
install -d -m 700 lightweight-server
|
||||
curl -fLsS --proto '=https' --proto-redir '=https' --tlsv1.2 \
|
||||
--retry 3 --connect-timeout 30 --max-time 300 "$url" -o "$archive"
|
||||
printf '%s %s\n' "$expected" "$archive" | sha256sum -c -
|
||||
# Refuse to mix a reviewed archive with files from an earlier installation.
|
||||
[[ ! -e lightweight-server ]]
|
||||
mkdir -m 700 lightweight-server
|
||||
tar -xzf "$archive" -C lightweight-server --strip-components=1
|
||||
rm -f "$archive"
|
||||
trap - EXIT
|
||||
cd lightweight-server
|
||||
chmod 700 ./*.sh
|
||||
sudo ./ochenstarik-server-install.sh
|
||||
```
|
||||
|
||||
Первым запросом мастер предлагает выбрать язык диалога и только затем открывает главное меню со всеми этапами 1–8. Выберите нужный этап, установите его, и после завершения мастер вернётся обратно в главное меню. В первом этапе пустой ввод устанавливает все рекомендуемые наборы программ. Во время изменения SSH-порта не закрывайте текущую сессию — сначала проверьте новый вход во втором терминале.
|
||||
|
||||
Мастер намеренно требует, чтобы все скрипты этапов уже лежали рядом локально. Используйте полный архив или `git clone`; одиночный файл мастера больше не подкачивает недостающие этапы из изменяемой ветки `main`.
|
||||
|
|
@ -62,39 +69,7 @@ sudo ./ochenstarik-server-install.sh
|
|||
|
||||
## Получение проекта
|
||||
|
||||
Рекомендуемый способ — загрузка архива через `codeload.github.com`. Он подходит в том числе для серверов, на которых соединение с основным доменом `github.com` завершается ошибкой `SSL connection timeout`:
|
||||
|
||||
```bash
|
||||
set -e
|
||||
cd "$HOME"
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y curl ca-certificates tar
|
||||
curl -4 -fL \
|
||||
--retry 5 \
|
||||
--retry-delay 10 \
|
||||
--connect-timeout 30 \
|
||||
https://codeload.github.com/ochenstarik-ui/lightweight-server/tar.gz/refs/heads/main \
|
||||
-o lightweight-server.tar.gz
|
||||
mkdir lightweight-server
|
||||
tar -xzf lightweight-server.tar.gz -C lightweight-server --strip-components=1
|
||||
rm lightweight-server.tar.gz
|
||||
cd lightweight-server
|
||||
chmod 700 ./*.sh
|
||||
```
|
||||
|
||||
Если `github.com` доступен с сервера, проект также можно получить через Git:
|
||||
|
||||
```bash
|
||||
set -e
|
||||
cd "$HOME"
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y git ca-certificates
|
||||
git -c http.version=HTTP/1.1 clone --depth 1 https://github.com/ochenstarik-ui/lightweight-server.git
|
||||
cd lightweight-server
|
||||
chmod 700 ./*.sh
|
||||
```
|
||||
|
||||
Команда `set -e` останавливает последовательность при ошибке загрузки, поэтому команды `cd` и `chmod` не будут выполняться для отсутствующей папки.
|
||||
Используйте проверенную загрузку полного архива из раздела «Быстрая установка». Все модули и `ochenstarik-server-supply-chain.sh` должны оставаться рядом. Для обновления нужен новый проверенный полный SHA и соответствующая источнику сумма; распаковывайте в новый каталог. Не обновляйте установщики на рабочих серверах через изменяемую ветку.
|
||||
|
||||
Перед запуском любого выбранного файла можно проверить его синтаксис:
|
||||
|
||||
|
|
@ -129,7 +104,6 @@ bash -n ./ИМЯ-СКРИПТА.sh
|
|||
Затем скрипт создаёт `/swapfile` размером 2 ГБ, добавляет его в `/etc/fstab` и устанавливает `vm.swappiness=20`. Повторный запуск предусмотрен.
|
||||
|
||||
```bash
|
||||
curl -fLO https://raw.githubusercontent.com/ochenstarik-ui/lightweight-server/main/ochenstarik-server-1.sh
|
||||
chmod 700 ochenstarik-server-1.sh
|
||||
bash -n ochenstarik-server-1.sh
|
||||
sudo ./ochenstarik-server-1.sh
|
||||
|
|
@ -158,15 +132,9 @@ sysctl vm.swappiness
|
|||
|
||||
В режиме «только IPv6» системный IPv4 остаётся доступен для исходящих соединений и работы менеджера пакетов, но входящие управляемые порты по IPv4 закрываются UFW. Полное переносимое отключение IPv4 в Linux без привязки к конкретной сетевой конфигурации не выполняется.
|
||||
|
||||
Открываются TCP-порты:
|
||||
|
||||
- `22` — временный SSH-порт;
|
||||
- выбранный SSH-порт — будущий порт SSH;
|
||||
- `80`, `443` — HTTP и HTTPS;
|
||||
- `2096`, `40000`, `63636` — порты приложений.
|
||||
По умолчанию открываются только необходимые SSH-порты на время переноса и `443/tcp`. Порты `80`, `2096`, `40000` и `63636` автоматически не публикуются. Дополнительные порты требуют явного выбора с учётом назначения сервиса. При повторном запуске старые управляемые порты приложений выводятся отдельно: оператор явно выбирает, сохранить их или убрать.
|
||||
|
||||
```bash
|
||||
curl -fLO https://raw.githubusercontent.com/ochenstarik-ui/lightweight-server/main/ochenstarik-server-2.sh
|
||||
chmod 700 ochenstarik-server-2.sh
|
||||
bash -n ochenstarik-server-2.sh
|
||||
sudo ./ochenstarik-server-2.sh
|
||||
|
|
@ -208,7 +176,6 @@ timedatectl
|
|||
```bash
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y sudo ufw fail2ban openssl openssh-server
|
||||
curl -fLO https://raw.githubusercontent.com/ochenstarik-ui/lightweight-server/main/ochenstarik-server-user-3.sh
|
||||
chmod 700 ochenstarik-server-user-3.sh
|
||||
bash -n ochenstarik-server-user-3.sh
|
||||
sudo ./ochenstarik-server-user-3.sh
|
||||
|
|
@ -220,11 +187,7 @@ sudo ./ochenstarik-server-user-3.sh
|
|||
ssh -p ВЫБРАННЫЙ_ПОРТ ИМЯ_ПОЛЬЗОВАТЕЛЯ@IP_СЕРВЕРА
|
||||
```
|
||||
|
||||
Только после успешной проверки можно удалить временное правило порта 22:
|
||||
|
||||
```bash
|
||||
sudo ufw delete allow 22/tcp
|
||||
```
|
||||
После успешного входа на новый порт подтвердите это в запросе этапа 3: скрипт сам удалит временное правило `22/tcp` для IPv4/IPv6 и запись в управляемом списке. Без подтверждения порт сохраняется. Если SSH остаётся на 22 или управление UFW отключено, очистка не выполняется.
|
||||
|
||||
Чтобы позднее открыть дополнительные порты, повторно запустите скрипт, выберите пункт `2` и введите правила через пробел или запятую:
|
||||
|
||||
|
|
@ -238,14 +201,13 @@ sudo ufw delete allow 22/tcp
|
|||
|
||||
Файл: `ochenstarik-server-tg-4.sh`.
|
||||
|
||||
Создайте бота через `@BotFather`, отправьте ему сообщение и узнайте ID чата. Скрипт запросит токен и ID интерактивно, отправит тестовое сообщение и подключит уведомления через PAM.
|
||||
Создайте бота через `@BotFather`, отправьте ему сообщение и узнайте ID чата. Скрипт запросит токен и ID интерактивно, отправит тестовое сообщение и подключит уведомления через PAM. Hook обрабатывает только `PAM_TYPE=open_session`; закрытие сессии не отправляет второе «успешный вход». Отправка запускается через отсоединённый процесс с закрытым stdin/stdout/stderr и не задерживает SSH-вход при недоступности Telegram.
|
||||
|
||||
Для отдельного запуска:
|
||||
|
||||
```bash
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y curl openssh-server logrotate
|
||||
curl -fLO https://raw.githubusercontent.com/ochenstarik-ui/lightweight-server/main/ochenstarik-server-tg-4.sh
|
||||
chmod 700 ochenstarik-server-tg-4.sh
|
||||
bash -n ochenstarik-server-tg-4.sh
|
||||
sudo ./ochenstarik-server-tg-4.sh
|
||||
|
|
@ -268,7 +230,6 @@ sudo tail -f /var/log/ochenstarik-ssh-login-telegram.log
|
|||
При повторном запуске появляется меню: подключить VPN с сохранёнными настройками, отключить его без удаления конфигурации, заменить подписку либо показать состояние.
|
||||
|
||||
```bash
|
||||
curl -fLO https://raw.githubusercontent.com/ochenstarik-ui/lightweight-server/main/ochenstarik-server-vpn-5.sh
|
||||
chmod 700 ochenstarik-server-vpn-5.sh
|
||||
bash -n ochenstarik-server-vpn-5.sh
|
||||
sudo ./ochenstarik-server-vpn-5.sh
|
||||
|
|
@ -298,26 +259,22 @@ sudo ./ochenstarik-server-vpn-5.sh --status
|
|||
|
||||
Файл: `ochenstarik-server-panel-warp-6.sh`.
|
||||
|
||||
Скрипт предлагает выбрать три разных порта:
|
||||
Панель по умолчанию приватная: `webListen=127.0.0.1`; адрес реального слушателя проверяется через `ss -ltn`. Доступ — через SSH-туннель. Порт панели по умолчанию `2053`.
|
||||
|
||||
- порт веб-панели 3x-ui, по умолчанию `2053`;
|
||||
- порт подписок 3x-ui, по умолчанию `2096`;
|
||||
- локальный SOCKS5/HTTP proxy-порт WARP, по умолчанию `40000`.
|
||||
Подписки на отдельном порту (по умолчанию `2096`) выбираются независимо: приватные на loopback, полностью выключенные либо явно публичные с новым случайным секретным путём. При повторном запуске прежний достаточно случайный путь сохраняется по умолчанию; ротация требует подтверждения, поскольку старые URL перестанут работать. Храните URL подписок как секрет. Публичная панель — отдельный явный выбор. В UFW публикуются только выбранные публичные сервисы и `443/tcp`; порт 80 автоматически не открывается.
|
||||
|
||||
3x-ui устанавливается официальным интерактивным установщиком. После установки выбранные порты записываются в стандартную SQLite-базу с предварительной резервной копией. Скрипт включает сервис подписок, запускает `x-ui` и проверяет оба TCP-слушателя.
|
||||
3x-ui `v3.10.2`, установщик Xray на коммите `05aa47266c94e6fb5ca48cad96afdb4becb5891f` и Xray-core `v26.10.21` закреплены вместе с SHA-256 установщиков и исполняемых payload для amd64/arm64. Этапы 5 и 6 требуют комплектный `ochenstarik-server-supply-chain.sh`. Источник выбирается отдельно: GitHub либо подготовленное оператором HTTPS-зеркало Forgejo. Зеркало должно содержать перечисленные ниже файлы; `ochenstarik-server-supply-chain-pins.txt` — справочный список сумм, проверяемых helper; существование готового зеркального релиза не предполагается. Неизвестные загрузки установщика блокируются. Системные пакеты по-прежнему требуют доступных APT-репозиториев.
|
||||
|
||||
Cloudflare WARP устанавливается из официального APT-репозитория, переводится в Local proxy mode через MASQUE и проверяется запросом к Cloudflare: ответ должен содержать `warp=on`.
|
||||
Файлы зеркала лежат в одном каталоге: `xui-install.sh`, `x-ui-linux-amd64.tar.gz` либо `x-ui-linux-arm64.tar.gz`, `x-ui.sh`, `x-ui.service.debian`, `xray-install.sh`, `Xray-linux-64.zip` либо `Xray-linux-arm64-v8a.zip`. Суммы закреплены в коде; файлы контрольных сумм helper создаёт локально. Для этапа 5 нужны только два файла Xray.
|
||||
|
||||
WARP — отдельный необязательный выбор, по умолчанию выключен. Если выбран, устанавливается из официального APT-репозитория, работает в Local proxy mode через MASQUE и проверяется по `warp=on`. Его proxy слушает только `127.0.0.1:40000` (либо выбранный локальный порт) и не публикуется через UFW.
|
||||
|
||||
```bash
|
||||
curl -fLO https://raw.githubusercontent.com/ochenstarik-ui/lightweight-server/main/ochenstarik-server-panel-warp-6.sh
|
||||
chmod 700 ochenstarik-server-panel-warp-6.sh
|
||||
bash -n ochenstarik-server-panel-warp-6.sh
|
||||
sudo ./ochenstarik-server-panel-warp-6.sh
|
||||
```
|
||||
|
||||
В UFW открываются и проверяются `80/tcp`, `443/tcp`, выбранный порт панели и выбранный порт подписок. WARP слушает только локально на `127.0.0.1:40000` либо другом выбранном порту. Этот порт намеренно не публикуется через UFW, поскольку локальный proxy не требует аутентификации.
|
||||
|
||||
Публичные порты 3x-ui открываются только для семейств адресов, выбранных на этапе 2. В режиме `ipv4` скрипт не создаёт правил IPv6.
|
||||
Публичные порты открываются только для семейств адресов, выбранных на этапе 2.
|
||||
|
||||
Поддерживаются официально доступные пакеты WARP для Ubuntu `jammy`, `noble`, `resolute` и Debian `bookworm`, `trixie` на `amd64` и `arm64`.
|
||||
|
||||
|
|
@ -337,7 +294,6 @@ sudo ./ochenstarik-server-panel-warp-6.sh
|
|||
- только первичный снимок без автоматического расписания.
|
||||
|
||||
```bash
|
||||
curl -fLO https://raw.githubusercontent.com/ochenstarik-ui/lightweight-server/main/ochenstarik-server-backup-7.sh
|
||||
chmod 700 ochenstarik-server-backup-7.sh
|
||||
bash -n ochenstarik-server-backup-7.sh
|
||||
sudo ./ochenstarik-server-backup-7.sh
|
||||
|
|
@ -367,7 +323,9 @@ mkdir -p /restore
|
|||
zstd -dc /ПУТЬ/К/АРХИВУ.tar.zst | tar --acls --xattrs --numeric-owner -xpf - -C /restore
|
||||
```
|
||||
|
||||
Это локальная резервная копия, а не защита от отказа диска или потери сервера. Регулярно переносите готовые архивы и `.sha256` на отдельный сервер или накопитель. Снимок создаётся с работающей системы; для активно изменяющихся баз данных дополнительно используйте штатный экспорт или согласованный снимок самой базы.
|
||||
Архив содержит секреты сервера. По умолчанию сохраняется локальный незашифрованный `.tar.zst`. Шифрование age включается отдельно по публичному ключу получателя: поток сразу записывается в `.tar.zst.age`, временного незашифрованного архива нет. Приватный ключ восстановления храните отдельно; заранее проверьте расшифровку через `age --decrypt -i RECOVERY_KEY` перед `zstd -dc | tar`.
|
||||
|
||||
Можно отдельно включить SCP-выгрузку готового архива и `.sha256` на другой хост. Нужны выделенная учётная запись, существующий SSH-ключ и заранее проверенный known_hosts. Используются BatchMode и строгая проверка ключа хоста; ошибки передачи считаются ошибкой бэкапа. Существующий незашифрованный первичный снимок сохраняется: при выборе age скрипт явно остановится, а не объявит его зашифрованным. Перенесите прежний снимок в безопасное хранилище и определите политику первичного снимка перед продолжением. Повторный запуск `initial` проверяет контрольную сумму и повторяет выгрузку, в том числе после прежней ошибки SCP. Локальная ротация не настраивает удалённую ротацию. Локальная копия не защищает от потери сервера. Снимок создаётся с работающей системы; для активно изменяющихся баз данных дополнительно используйте штатный экспорт или согласованный снимок самой базы.
|
||||
|
||||
## Полная установка
|
||||
|
||||
|
|
@ -399,6 +357,8 @@ sudo ./ochenstarik-server-ai-agents-8.sh
|
|||
|
||||
Файл: `ochenstarik-server-ai-agents-8.sh`.
|
||||
|
||||
На серверах VPN и узлах Telegram-клиента не устанавливайте этап 8. ИИ-агенты должны работать на отдельной машине.
|
||||
|
||||
Скрипт предлагает множественный выбор из Hermes Agent, OpenClaw, OpenHands, OpenCode, Aider, AutoGPT и Pi Coding Agent. Установщики запускаются от выбранного обычного пользователя, а не напрямую от `root`. Каждый официальный установщик сначала загружается по HTTPS во временный файл и проходит проверку `bash -n`.
|
||||
|
||||
AutoGPT — более тяжёлая Docker-платформа: её официальный установщик может запросить `sudo`, установить зависимости Docker и запустить локальные службы. Pi — лёгкий терминальный агент; после установки его можно запустить командой `pi`, а затем выполнить `/login` для подключения провайдера.
|
||||
|
|
@ -406,7 +366,6 @@ AutoGPT — более тяжёлая Docker-платформа: её офици
|
|||
API-ключи скрипт не запрашивает и не сохраняет. После установки нужно войти под выбранным пользователем и выполнить показанную команду первоначальной настройки. AI-агенты могут запускать команды, изменять файлы, использовать сеть и устанавливать плагины, поэтому перед подключением рабочих данных необходимо проверить разрешения, песочницу и доступ к секретам.
|
||||
|
||||
```bash
|
||||
curl -fLO https://raw.githubusercontent.com/ochenstarik-ui/lightweight-server/main/ochenstarik-server-ai-agents-8.sh
|
||||
chmod 700 ochenstarik-server-ai-agents-8.sh
|
||||
bash -n ochenstarik-server-ai-agents-8.sh
|
||||
sudo ./ochenstarik-server-ai-agents-8.sh
|
||||
|
|
@ -423,7 +382,6 @@ VPN, 3x-ui, WARP, Telegram-уведомления, автоматические
|
|||
Удаление администратора и самих архивов резервных копий требует отдельных подтверждений. Обязательные системные пакеты второго этапа и обновления Ubuntu сохраняются, чтобы не потерять доступ к серверу. Языковые пакеты и дополнительные программы удаляются только если первый скрипт зафиксировал, что до его запуска их не было.
|
||||
|
||||
```bash
|
||||
curl -fLO https://raw.githubusercontent.com/ochenstarik-ui/lightweight-server/main/ochenstarik-server-uninstall.sh
|
||||
chmod 700 ochenstarik-server-uninstall.sh
|
||||
bash -n ochenstarik-server-uninstall.sh
|
||||
sudo ./ochenstarik-server-uninstall.sh
|
||||
|
|
|
|||
|
|
@ -109,11 +109,44 @@ add_managed_rule() {
|
|||
RULE_SEEN["$rule"]=1
|
||||
}
|
||||
|
||||
discover_current_ssh_ports() {
|
||||
local managed_file="${1:-$MANAGED_PORTS_CONFIG}"
|
||||
local client_ip client_port server_ip server_port port
|
||||
local -A seen_ports=()
|
||||
|
||||
if [[ -n "${SSH_CONNECTION:-}" ]]; then
|
||||
read -r client_ip client_port server_ip server_port <<< "$SSH_CONNECTION"
|
||||
if is_valid_port "${server_port:-}"; then
|
||||
printf '%s\n' "$((10#$server_port))"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
while IFS= read -r port; do
|
||||
is_valid_port "$port" || continue
|
||||
port="$((10#$port))"
|
||||
[[ -z "${seen_ports[$port]:-}" ]] || continue
|
||||
printf '%s\n' "$port"
|
||||
seen_ports["$port"]=1
|
||||
done < <(ss -H -ltnp 2>/dev/null | awk '$NF ~ /sshd/ { address=$4; sub(/^.*:/, "", address); if (address ~ /^[0-9]+$/) print address }')
|
||||
fi
|
||||
|
||||
if ((${#seen_ports[@]} == 0)) && [[ ! -e "$managed_file" ]]; then
|
||||
# First run from sudo or a local console: preserve the conventional SSH
|
||||
# port when no session metadata or listening socket is discoverable.
|
||||
printf '22\n'
|
||||
fi
|
||||
}
|
||||
|
||||
load_managed_rules() {
|
||||
local rule port protocol
|
||||
MANAGED_RULES=()
|
||||
RULE_SEEN=()
|
||||
[[ -e "$MANAGED_PORTS_CONFIG" ]] || return 0
|
||||
PREVIOUS_MANAGED_RULES=()
|
||||
if [[ ! -e "$MANAGED_PORTS_CONFIG" ]]; then
|
||||
return 0
|
||||
fi
|
||||
[[ -f "$MANAGED_PORTS_CONFIG" && ! -L "$MANAGED_PORTS_CONFIG" ]] \
|
||||
|| die "$MANAGED_PORTS_CONFIG должен быть обычным файлом"
|
||||
|
||||
|
|
@ -127,20 +160,58 @@ load_managed_rules() {
|
|||
is_valid_port "$port" || die "Некорректный порт в $MANAGED_PORTS_CONFIG: $port"
|
||||
add_managed_rule "$((10#$port))/${protocol}"
|
||||
done < "$MANAGED_PORTS_CONFIG"
|
||||
PREVIOUS_MANAGED_RULES=("${MANAGED_RULES[@]}")
|
||||
}
|
||||
|
||||
add_default_rules() {
|
||||
local saved_ssh_port
|
||||
local saved_ssh_port current_ssh_port
|
||||
local -a current_ssh_ports=()
|
||||
|
||||
saved_ssh_port="${SSH_PORT:-$(read_saved_value "$SSH_PORT_CONFIG" SSH_PORT)}"
|
||||
is_valid_port "$saved_ssh_port" || saved_ssh_port="$DEFAULT_SSH_PORT"
|
||||
for rule in \
|
||||
22/tcp "$((10#$saved_ssh_port))/tcp" 80/tcp 2096/tcp 443/tcp \
|
||||
40000/tcp 63636/tcp; do
|
||||
add_managed_rule "$rule"
|
||||
add_managed_rule "$((10#$saved_ssh_port))/tcp"
|
||||
add_managed_rule 443/tcp
|
||||
|
||||
# Keep the port carrying this setup session open during an SSH migration.
|
||||
mapfile -t current_ssh_ports < <(discover_current_ssh_ports)
|
||||
for current_ssh_port in "${current_ssh_ports[@]}"; do
|
||||
add_managed_rule "$current_ssh_port/tcp"
|
||||
done
|
||||
}
|
||||
|
||||
confirm_legacy_rules() {
|
||||
local rule answer current_ssh_port
|
||||
local -a legacy_rules=() retained_rules=()
|
||||
local -a current_ssh_ports=()
|
||||
local -A defaults=() seen=()
|
||||
defaults["$((10#$SSH_PORT))/tcp"]=1
|
||||
defaults[443/tcp]=1
|
||||
mapfile -t current_ssh_ports < <(discover_current_ssh_ports)
|
||||
for current_ssh_port in "${current_ssh_ports[@]}"; do defaults["$current_ssh_port/tcp"]=1; done
|
||||
|
||||
for rule in "${MANAGED_RULES[@]}"; do
|
||||
if [[ -n "${defaults[$rule]:-}" ]]; then
|
||||
retained_rules+=("$rule")
|
||||
seen["$rule"]=1
|
||||
else
|
||||
legacy_rules+=("$rule")
|
||||
fi
|
||||
done
|
||||
((${#legacy_rules[@]})) || return 0
|
||||
|
||||
printf '\nНайдены дополнительные правила UFW из прежней конфигурации; их назначение неизвестно:\n'
|
||||
printf ' %s\n' "${legacy_rules[@]}"
|
||||
read -rp 'Сохранить весь этот список? [y/N]: ' answer \
|
||||
|| die "Ввод прерван; правила UFW не изменены"
|
||||
if [[ "$answer" == y || "$answer" == Y ]]; then
|
||||
retained_rules+=("${legacy_rules[@]}")
|
||||
for rule in "${legacy_rules[@]}"; do seen["$rule"]=1; done
|
||||
fi
|
||||
MANAGED_RULES=("${retained_rules[@]}")
|
||||
RULE_SEEN=()
|
||||
for rule in "${!seen[@]}"; do RULE_SEEN["$rule"]=1; done
|
||||
}
|
||||
|
||||
collect_additional_ports() {
|
||||
local input normalized token port protocol rule
|
||||
local -a tokens additions=()
|
||||
|
|
@ -243,9 +314,9 @@ verify_ipv6_connectivity() {
|
|||
}
|
||||
|
||||
delete_managed_ufw_rules() {
|
||||
local rule port protocol attempt
|
||||
local rule port protocol attempt status
|
||||
|
||||
for rule in "${MANAGED_RULES[@]}"; do
|
||||
for rule in "${PREVIOUS_MANAGED_RULES[@]}"; do
|
||||
port="${rule%/*}"
|
||||
protocol="${rule#*/}"
|
||||
for attempt in {1..5}; do
|
||||
|
|
@ -256,6 +327,15 @@ delete_managed_ufw_rules() {
|
|||
ufw --force delete allow from ::/0 to any port "$port" proto "$protocol" \
|
||||
>/dev/null 2>&1 || true
|
||||
done
|
||||
|
||||
status="$(LANG=C ufw status)" || die "Не удалось проверить состояние UFW после удаления старых правил"
|
||||
for rule in "${PREVIOUS_MANAGED_RULES[@]}"; do
|
||||
if awk -v rule="$rule" \
|
||||
'$1 == rule && ($2 == "ALLOW" || ($2 == "(v6)" && $3 == "ALLOW")) { found=1 } END { exit !found }' \
|
||||
<<< "$status"; then
|
||||
die "Старое правило UFW ${rule} всё ещё разрешено; новая конфигурация не сохранена"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
allow_rule_for_selected_families() {
|
||||
|
|
@ -303,6 +383,7 @@ apply_ip_mode_and_firewall() {
|
|||
|
||||
[[ "$EUID" -eq 0 ]] || die "Запустите этот скрипт от имени root"
|
||||
declare -a MANAGED_RULES=()
|
||||
declare -a PREVIOUS_MANAGED_RULES=()
|
||||
declare -A RULE_SEEN=()
|
||||
ACTION=""
|
||||
SSH_PORT=""
|
||||
|
|
@ -327,9 +408,11 @@ if [[ "$ACTION" == install ]]; then
|
|||
done
|
||||
install -d -m 700 -o root -g root "$CONFIG_DIR"
|
||||
load_managed_rules
|
||||
PREVIOUS_MANAGED_RULES=("${MANAGED_RULES[@]}")
|
||||
confirm_legacy_rules
|
||||
add_default_rules
|
||||
save_configuration
|
||||
apply_ip_mode_and_firewall
|
||||
save_configuration
|
||||
log "SSH-порт ${SSH_PORT} сохранён для этапа 3"
|
||||
else
|
||||
for command_name in ip sed sysctl ufw; do
|
||||
|
|
@ -339,6 +422,8 @@ else
|
|||
SSH_PORT="$(read_saved_value "$SSH_PORT_CONFIG" SSH_PORT)"
|
||||
is_valid_port "$SSH_PORT" || SSH_PORT="$DEFAULT_SSH_PORT"
|
||||
load_managed_rules
|
||||
PREVIOUS_MANAGED_RULES=("${MANAGED_RULES[@]}")
|
||||
confirm_legacy_rules
|
||||
add_default_rules
|
||||
|
||||
if [[ "$ACTION" == ip-mode ]]; then
|
||||
|
|
@ -348,8 +433,8 @@ else
|
|||
case "$IP_MODE" in ipv4|ipv6|both) ;; *) choose_ip_mode ;; esac
|
||||
collect_additional_ports
|
||||
fi
|
||||
save_configuration
|
||||
apply_ip_mode_and_firewall
|
||||
save_configuration
|
||||
fi
|
||||
|
||||
log "Текущая конфигурация"
|
||||
|
|
|
|||
|
|
@ -77,6 +77,54 @@ choose_backup_root() {
|
|||
esac
|
||||
}
|
||||
|
||||
choose_protection() {
|
||||
local answer known_hosts
|
||||
|
||||
read -rp 'Шифровать архивы через age? [y/N]: ' answer || die "Ввод был прерван"
|
||||
case "${answer,,}" in
|
||||
y|yes)
|
||||
BACKUP_ENCRYPTION=age
|
||||
read -rp 'Публичный age recipient (age1...): ' AGE_RECIPIENT \
|
||||
|| die "Ввод был прерван"
|
||||
[[ "$AGE_RECIPIENT" =~ ^age1[0-9a-z]{20,}$ ]] \
|
||||
|| die "Укажите корректный публичный age recipient"
|
||||
;;
|
||||
''|n|no) BACKUP_ENCRYPTION=none ;;
|
||||
*) die "Введите y или n" ;;
|
||||
esac
|
||||
|
||||
read -rp 'Выгружать архивы на другой SSH-хост? [y/N]: ' answer \
|
||||
|| die "Ввод был прерван"
|
||||
case "${answer,,}" in
|
||||
y|yes)
|
||||
read -rp 'SSH-цель в формате user@host: ' OFFSITE_TARGET \
|
||||
|| die "Ввод был прерван"
|
||||
[[ "$OFFSITE_TARGET" =~ ^[A-Za-z0-9._-]+@[A-Za-z0-9.-]+$ ]] \
|
||||
|| die "Некорректная SSH-цель"
|
||||
read -rp 'Каталог на целевом хосте (абсолютный путь): ' OFFSITE_PATH \
|
||||
|| die "Ввод был прерван"
|
||||
[[ "$OFFSITE_PATH" =~ ^/[A-Za-z0-9._/-]+$ && "$OFFSITE_PATH" != / \
|
||||
&& "$OFFSITE_PATH" != *..* ]] \
|
||||
|| die "Некорректный удалённый каталог"
|
||||
read -rp 'Локальный файл known_hosts с заранее проверенным ключом: ' known_hosts \
|
||||
|| die "Ввод был прерван"
|
||||
[[ -f "$known_hosts" && ! -L "$known_hosts" && -s "$known_hosts" ]] \
|
||||
|| die "Нужен существующий непустой обычный known_hosts файл"
|
||||
[[ "$(stat -c %u "$known_hosts")" == 0 ]] \
|
||||
|| die "known_hosts должен принадлежать root"
|
||||
[[ "$(stat -c %a "$known_hosts")" =~ ^[0-7]*[0-5][0-5]$ ]] \
|
||||
|| die "known_hosts не должен быть доступен для записи группе или всем"
|
||||
OFFSITE_KNOWN_HOSTS_SOURCE="$(readlink -f -- "$known_hosts")"
|
||||
OFFSITE_KNOWN_HOSTS="${CONFIG_DIR}/backup-known_hosts"
|
||||
;;
|
||||
''|n|no)
|
||||
OFFSITE_TARGET=""; OFFSITE_PATH=""; OFFSITE_KNOWN_HOSTS=""
|
||||
OFFSITE_KNOWN_HOSTS_SOURCE=""
|
||||
;;
|
||||
*) die "Введите y или n" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
write_backup_runner() {
|
||||
cat > "$BACKUP_RUNNER" <<'BACKUP_RUNNER_EOF'
|
||||
#!/usr/bin/env bash
|
||||
|
|
@ -99,13 +147,30 @@ protect_initial_files() {
|
|||
local file
|
||||
command -v chattr >/dev/null 2>&1 || return 0
|
||||
for file in "$BACKUP_ROOT"/initial/initial-*.tar.zst \
|
||||
"$BACKUP_ROOT"/initial/initial-*.tar.zst.sha256; do
|
||||
[[ -e "$file" ]] || continue
|
||||
"$BACKUP_ROOT"/initial/initial-*.tar.zst.sha256 \
|
||||
"$BACKUP_ROOT"/initial/initial-*.tar.zst.age \
|
||||
"$BACKUP_ROOT"/initial/initial-*.tar.zst.age.sha256; do
|
||||
[[ -f "$file" && ! -L "$file" ]] || continue
|
||||
chattr +i -- "$file" 2>/dev/null \
|
||||
|| warn "Файловая система не поддерживает immutable для $file"
|
||||
done
|
||||
}
|
||||
|
||||
upload_offsite() {
|
||||
local archive="$1" checksum="$2"
|
||||
[[ -z "$OFFSITE_TARGET" ]] && return 0
|
||||
scp -B \
|
||||
-o BatchMode=yes \
|
||||
-o ConnectTimeout=15 \
|
||||
-o StrictHostKeyChecking=yes \
|
||||
-o "UserKnownHostsFile=$OFFSITE_KNOWN_HOSTS" \
|
||||
-o ForwardAgent=no \
|
||||
-o ClearAllForwardings=yes \
|
||||
"$archive" "$checksum" "${OFFSITE_TARGET}:${OFFSITE_PATH}/" \
|
||||
|| die "Выгрузка на заданный SSH-хост не удалась; резервная копия не считается завершённой"
|
||||
log "Архив и checksum выгружены на настроенный SSH-хост"
|
||||
}
|
||||
|
||||
check_free_space() {
|
||||
local root_device backup_device root_used backup_used source_used
|
||||
local available reserve estimated_required backup_total
|
||||
|
|
@ -136,7 +201,8 @@ rotate_backups() {
|
|||
local -a archives
|
||||
|
||||
mapfile -t archives < <(
|
||||
find "$target_dir" -maxdepth 1 -type f -name "${backup_kind}-*.tar.zst" \
|
||||
find "$target_dir" -maxdepth 1 -type f \
|
||||
\( -name "${backup_kind}-*.tar.zst" -o -name "${backup_kind}-*.tar.zst.age" \) \
|
||||
-printf '%f\n' | sort -r
|
||||
)
|
||||
for ((index = keep_count; index < ${#archives[@]}; index++)); do
|
||||
|
|
@ -149,6 +215,12 @@ rotate_backups() {
|
|||
read_backup_config() {
|
||||
local line key value
|
||||
BACKUP_ROOT=""
|
||||
BACKUP_ENCRYPTION=none
|
||||
AGE_RECIPIENT=""
|
||||
OFFSITE_TARGET=""
|
||||
OFFSITE_PATH=""
|
||||
OFFSITE_KNOWN_HOSTS=""
|
||||
OFFSITE_KNOWN_HOSTS_SOURCE=""
|
||||
[[ -f "$CONFIG_FILE" && ! -L "$CONFIG_FILE" ]] \
|
||||
|| die "Не найден безопасный файл настроек: $CONFIG_FILE"
|
||||
[[ "$(stat -c %u "$CONFIG_FILE")" == 0 ]] \
|
||||
|
|
@ -168,30 +240,92 @@ read_backup_config() {
|
|||
|| die "Некорректный BACKUP_ROOT в $CONFIG_FILE"
|
||||
BACKUP_ROOT="$value"
|
||||
;;
|
||||
BACKUP_ENCRYPTION)
|
||||
[[ "$value" == none || "$value" == age ]] \
|
||||
|| die "Некорректный BACKUP_ENCRYPTION в $CONFIG_FILE"
|
||||
BACKUP_ENCRYPTION="$value"
|
||||
;;
|
||||
AGE_RECIPIENT)
|
||||
[[ "$value" =~ ^age1[0-9a-z]{20,}$ ]] \
|
||||
|| die "Некорректный AGE_RECIPIENT в $CONFIG_FILE"
|
||||
AGE_RECIPIENT="$value"
|
||||
;;
|
||||
OFFSITE_TARGET)
|
||||
[[ "$value" =~ ^[A-Za-z0-9._-]+@[A-Za-z0-9.-]+$ ]] \
|
||||
|| die "Некорректный OFFSITE_TARGET в $CONFIG_FILE"
|
||||
OFFSITE_TARGET="$value"
|
||||
;;
|
||||
OFFSITE_PATH)
|
||||
[[ "$value" =~ ^/[A-Za-z0-9._/-]+$ && "$value" != / \
|
||||
&& "$value" != *..* ]] \
|
||||
|| die "Некорректный OFFSITE_PATH в $CONFIG_FILE"
|
||||
OFFSITE_PATH="$value"
|
||||
;;
|
||||
OFFSITE_KNOWN_HOSTS)
|
||||
[[ "$value" =~ ^/[A-Za-z0-9._/-]+$ ]] \
|
||||
|| die "Некорректный OFFSITE_KNOWN_HOSTS в $CONFIG_FILE"
|
||||
OFFSITE_KNOWN_HOSTS="$value"
|
||||
;;
|
||||
*)
|
||||
die "Неизвестный ключ в $CONFIG_FILE: $key"
|
||||
;;
|
||||
esac
|
||||
done < "$CONFIG_FILE"
|
||||
if [[ "$BACKUP_ENCRYPTION" == age ]]; then
|
||||
[[ -n "$AGE_RECIPIENT" ]] || die "Для шифрования нужен AGE_RECIPIENT"
|
||||
command -v age >/dev/null 2>&1 || die "Для шифрования не найдена команда age"
|
||||
elif [[ -n "$AGE_RECIPIENT" ]]; then
|
||||
die "AGE_RECIPIENT задан, но шифрование отключено"
|
||||
fi
|
||||
if [[ -n "$OFFSITE_TARGET" || -n "$OFFSITE_PATH" || -n "$OFFSITE_KNOWN_HOSTS" ]]; then
|
||||
[[ -n "$OFFSITE_TARGET" && -n "$OFFSITE_PATH" && -n "$OFFSITE_KNOWN_HOSTS" ]] \
|
||||
|| die "Настройки выгрузки должны быть заполнены полностью"
|
||||
[[ -f "$OFFSITE_KNOWN_HOSTS" && ! -L "$OFFSITE_KNOWN_HOSTS" \
|
||||
&& -s "$OFFSITE_KNOWN_HOSTS" && "$(stat -c %u "$OFFSITE_KNOWN_HOSTS")" == 0 \
|
||||
&& "$(stat -c %a "$OFFSITE_KNOWN_HOSTS")" =~ ^[0-7]*[0-5][0-5]$ ]] \
|
||||
|| die "known_hosts отсутствует или имеет небезопасные права"
|
||||
command -v scp >/dev/null 2>&1 || die "Для выгрузки не найдена команда scp"
|
||||
fi
|
||||
}
|
||||
|
||||
create_backup() {
|
||||
local backup_kind="$1" keep_count="$2" timestamp target_dir
|
||||
local archive temporary checksum checksum_temporary exclude_backup_root
|
||||
local archive temporary checksum checksum_temporary exclude_backup_root existing
|
||||
local -a initial_archives
|
||||
|
||||
target_dir="${BACKUP_ROOT}/${backup_kind}"
|
||||
mkdir -p -m 700 -- "$target_dir"
|
||||
|
||||
if [[ "$backup_kind" == initial ]] &&
|
||||
find "$target_dir" -maxdepth 1 -type f -name 'initial-*.tar.zst' -print -quit | grep -q .; then
|
||||
log "Первичный снимок уже существует и не будет перезаписан"
|
||||
protect_initial_files
|
||||
return 0
|
||||
if [[ "$backup_kind" == initial ]]; then
|
||||
mapfile -t initial_archives < <(
|
||||
find "$target_dir" -maxdepth 1 -type f \
|
||||
\( -name 'initial-*.tar.zst' -o -name 'initial-*.tar.zst.age' \) \
|
||||
-printf '%f\n' | sort -r
|
||||
)
|
||||
if ((${#initial_archives[@]} > 0)); then
|
||||
existing="${target_dir}/${initial_archives[0]}"
|
||||
checksum="${existing}.sha256"
|
||||
[[ -f "$existing" && ! -L "$existing" \
|
||||
&& -f "$checksum" && ! -L "$checksum" ]] \
|
||||
|| die "Первичный снимок или его контрольная сумма отсутствует либо небезопасна"
|
||||
(
|
||||
cd -- "$target_dir"
|
||||
sha256sum --check --status "$(basename "$checksum")"
|
||||
) || die "Контрольная сумма первичного снимка не совпадает"
|
||||
if [[ "$BACKUP_ENCRYPTION" == age && "$existing" != *.tar.zst.age ]]; then
|
||||
die "Первичный снимок уже создан без шифрования; он сохранён, но не соответствует выбранной политике age. Укажите прежнюю политику или архивируйте систему отдельно."
|
||||
fi
|
||||
protect_initial_files
|
||||
upload_offsite "$existing" "$checksum"
|
||||
log "Первичный снимок уже существует и не будет перезаписан; checksum проверен"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
check_free_space
|
||||
timestamp="$(date +%Y%m%d-%H%M%S)"
|
||||
archive="${target_dir}/${backup_kind}-${timestamp}.tar.zst"
|
||||
[[ "$BACKUP_ENCRYPTION" != age ]] || archive="${archive}.age"
|
||||
temporary="${archive}.partial"
|
||||
checksum="${archive}.sha256"
|
||||
checksum_temporary="${checksum}.partial"
|
||||
|
|
@ -233,7 +367,13 @@ create_backup() {
|
|||
--exclude='./swapfile' \
|
||||
--exclude="$exclude_backup_root" \
|
||||
--exclude="${exclude_backup_root}/*" \
|
||||
. | zstd -T0 -3 --quiet -o "$temporary"
|
||||
. | zstd -T0 -3 --quiet | {
|
||||
if [[ "$BACKUP_ENCRYPTION" == age ]]; then
|
||||
age --encrypt --recipient "$AGE_RECIPIENT" --output "$temporary"
|
||||
else
|
||||
cat > "$temporary"
|
||||
fi
|
||||
}
|
||||
|
||||
[[ -s "$temporary" ]] || die "Получился пустой архив"
|
||||
chmod 600 "$temporary"
|
||||
|
|
@ -251,6 +391,7 @@ create_backup() {
|
|||
else
|
||||
rotate_backups "$backup_kind" "$keep_count"
|
||||
fi
|
||||
upload_offsite "$archive" "$checksum"
|
||||
log "Готово: $archive ($(human_size "$(stat -c %s "$archive")"))"
|
||||
}
|
||||
|
||||
|
|
@ -376,18 +517,35 @@ done
|
|||
|
||||
choose_schedules
|
||||
choose_backup_root
|
||||
choose_protection
|
||||
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
log "Устанавливаю зависимости"
|
||||
apt-get update
|
||||
apt-get install -y coreutils e2fsprogs findutils tar util-linux zstd
|
||||
[[ "$BACKUP_ENCRYPTION" != age ]] || apt-get install -y age
|
||||
[[ -z "$OFFSITE_TARGET" ]] || apt-get install -y openssh-client
|
||||
for command_name in awk basename chattr df du find flock grep numfmt sha256sum sort stat tar zstd; do
|
||||
require_command "$command_name"
|
||||
done
|
||||
|
||||
install -d -o root -g root -m 700 "$CONFIG_DIR" "$BACKUP_ROOT"
|
||||
if [[ -n "$OFFSITE_TARGET" ]]; then
|
||||
[[ ! -L "$OFFSITE_KNOWN_HOSTS" ]] \
|
||||
|| die "Отказ от записи через символическую ссылку: $OFFSITE_KNOWN_HOSTS"
|
||||
install -o root -g root -m 600 -- "$OFFSITE_KNOWN_HOSTS_SOURCE" "$OFFSITE_KNOWN_HOSTS"
|
||||
fi
|
||||
[[ ! -L "$CONFIG_FILE" ]] || die "Отказ от записи через символическую ссылку: $CONFIG_FILE"
|
||||
printf 'BACKUP_ROOT=%s\n' "$BACKUP_ROOT" > "$CONFIG_FILE"
|
||||
{
|
||||
printf 'BACKUP_ROOT=%s\n' "$BACKUP_ROOT"
|
||||
printf 'BACKUP_ENCRYPTION=%s\n' "$BACKUP_ENCRYPTION"
|
||||
[[ "$BACKUP_ENCRYPTION" != age ]] || printf 'AGE_RECIPIENT=%s\n' "$AGE_RECIPIENT"
|
||||
if [[ -n "$OFFSITE_TARGET" ]]; then
|
||||
printf 'OFFSITE_TARGET=%s\n' "$OFFSITE_TARGET"
|
||||
printf 'OFFSITE_PATH=%s\n' "$OFFSITE_PATH"
|
||||
printf 'OFFSITE_KNOWN_HOSTS=%s\n' "$OFFSITE_KNOWN_HOSTS"
|
||||
fi
|
||||
} > "$CONFIG_FILE"
|
||||
chown root:root "$CONFIG_FILE"
|
||||
chmod 600 "$CONFIG_FILE"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
readonly XUI_INSTALL_URL="https://raw.githubusercontent.com/MHSanaei/3x-ui/main/install.sh"
|
||||
readonly XUI_BINARY="/usr/local/x-ui/x-ui"
|
||||
readonly XUI_DB="/etc/x-ui/x-ui.db"
|
||||
readonly CLOUDFLARE_KEY_URL="https://pkg.cloudflareclient.com/pubkey.gpg"
|
||||
|
|
@ -13,8 +12,12 @@ readonly DEFAULT_WARP_PORT="40000"
|
|||
readonly SSH_PORT_CONFIG="/etc/ochenstarik-server/ssh-port.conf"
|
||||
readonly IP_FAMILY_CONFIG="/etc/ochenstarik-server/ip-family.conf"
|
||||
readonly MANAGED_PORTS_CONFIG="/etc/ochenstarik-server/ufw-managed-ports.conf"
|
||||
readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
readonly SUPPLY_CHAIN_HELPER="${SCRIPT_DIR}/ochenstarik-server-supply-chain.sh"
|
||||
|
||||
TMP_DIR=""
|
||||
XUI_GUARD_DIR=""
|
||||
XUI_BOOTSTRAP_RELEASE_MARKER=""
|
||||
|
||||
log() { printf '[+] %s\n' "$*"; }
|
||||
warn() { printf '[!] %s\n' "$*" >&2; }
|
||||
|
|
@ -112,19 +115,139 @@ wait_for_tcp_listener() {
|
|||
die "${description} не открыл TCP-порт ${port}"
|
||||
}
|
||||
|
||||
verify_private_listener() {
|
||||
local port="$1" description="$2" listeners address found=no
|
||||
listeners="$(ss -H -ltn "sport = :${port}")"
|
||||
[[ -n "$listeners" ]] || die "${description} не слушает TCP-порт ${port}"
|
||||
while read -r address; do
|
||||
[[ -n "$address" ]] || continue
|
||||
[[ "$address" == "127.0.0.1:${port}" ]] \
|
||||
|| die "${description} слушает внешний или IPv6-адрес ${address}; ожидался только 127.0.0.1:${port}"
|
||||
found=yes
|
||||
done < <(awk '{print $4}' <<< "$listeners")
|
||||
[[ "$found" == yes ]] || die "Не удалось проверить loopback bind ${port}"
|
||||
}
|
||||
|
||||
valid_subscription_path() {
|
||||
local path="$1"
|
||||
[[ -n "$path" && ${#path} -le 1024 && "$path" =~ ^/?[A-Za-z0-9._~-]+(/[A-Za-z0-9._~-]+)*/?$ ]] \
|
||||
|| return 1
|
||||
[[ "$path" != *'..'* && "$path" != *'//'* ]]
|
||||
}
|
||||
|
||||
secure_public_subscription_path() {
|
||||
local path="$1" component
|
||||
component="${path#/}"
|
||||
component="${component%/}"
|
||||
[[ "$component" =~ ^[A-Za-z0-9_-]{32,}$ ]]
|
||||
}
|
||||
|
||||
generate_subscription_path() {
|
||||
local secret
|
||||
secret="$(openssl rand -hex 24)" || die "Не удалось создать случайный путь подписки"
|
||||
[[ "$secret" =~ ^[0-9a-f]{48}$ ]] || die "Генератор создал путь подписки неожиданного формата"
|
||||
XUI_SUB_PATH="/${secret}/"
|
||||
}
|
||||
|
||||
load_existing_subscription_path() {
|
||||
local db="${1:-$XUI_DB}"
|
||||
XUI_EXISTING_SUB_PATH=""
|
||||
[[ -f "$db" && ! -L "$db" ]] || return 0
|
||||
XUI_EXISTING_SUB_PATH="$(sqlite3 "$db" "SELECT value FROM settings WHERE key='subPath' LIMIT 1;" 2>/dev/null || true)"
|
||||
valid_subscription_path "$XUI_EXISTING_SUB_PATH" || XUI_EXISTING_SUB_PATH=""
|
||||
}
|
||||
|
||||
choose_public_access() {
|
||||
local answer
|
||||
printf '\nДоступ к панели 3x-ui и подпискам:\n'
|
||||
printf ' 1) Приватный режим: не открывать порты панели и подписок в UFW (рекомендуется)\n'
|
||||
printf ' 2) Публичный режим: открыть порты панели и подписок всему Интернету\n'
|
||||
printf '\nДоступ к панели 3x-ui:\n'
|
||||
printf ' 1) Приватный режим: слушать только 127.0.0.1, доступ через SSH tunnel (рекомендуется)\n'
|
||||
printf ' 2) Публичный режим: слушать все интерфейсы и открыть порт панели в UFW\n'
|
||||
while :; do
|
||||
read -rp 'Режим доступа [1]: ' answer
|
||||
read -rp 'Доступ к панели [1]: ' answer || answer=1
|
||||
answer="${answer:-1}"
|
||||
case "$answer" in
|
||||
1) XUI_PUBLIC_ACCESS=no; return 0 ;;
|
||||
1) XUI_PUBLIC_ACCESS=no; XUI_WEB_LISTEN=127.0.0.1; return 0 ;;
|
||||
2)
|
||||
warn "Публичный доступ к панели опасен. Используйте сложный путь, новые учётные данные, TLS и 2FA"
|
||||
warn "Публичная панель доступна из Интернета. Используйте TLS, новые учётные данные и 2FA"
|
||||
XUI_PUBLIC_ACCESS=yes
|
||||
XUI_WEB_LISTEN=""
|
||||
return 0
|
||||
;;
|
||||
*) warn "Введите 1 или 2" ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
choose_subscription_access() {
|
||||
local answer rotate_answer existing_path="${XUI_EXISTING_SUB_PATH:-}"
|
||||
printf '\nДоступ к подпискам 3x-ui (выбор не зависит от доступа к панели):\n'
|
||||
printf ' 1) Приватный режим: включены только на 127.0.0.1 (рекомендуется)\n'
|
||||
printf ' 2) Выключить сервис подписок\n'
|
||||
printf ' 3) Публичный доступ: отдельный случайный секретный путь и открытый порт\n'
|
||||
while :; do
|
||||
read -rp 'Доступ к подпискам [1]: ' answer || answer=1
|
||||
answer="${answer:-1}"
|
||||
case "$answer" in
|
||||
1)
|
||||
XUI_SUBSCRIPTION_ACCESS=private
|
||||
XUI_SUB_LISTEN=127.0.0.1
|
||||
XUI_SUB_PATH="${existing_path:-/sub/}"
|
||||
return 0
|
||||
;;
|
||||
2)
|
||||
XUI_SUBSCRIPTION_ACCESS=disabled
|
||||
XUI_SUB_LISTEN=127.0.0.1
|
||||
XUI_SUB_PATH="${existing_path:-/sub/}"
|
||||
return 0
|
||||
;;
|
||||
3)
|
||||
command -v openssl >/dev/null 2>&1 || die "Для случайного пути подписки нужна команда openssl"
|
||||
if [[ -n "$existing_path" ]] && secure_public_subscription_path "$existing_path"; then
|
||||
warn "Найден действующий секретный путь подписки; его сохранение оставит существующие URL рабочими"
|
||||
read -rp 'Создать новый путь и отключить старые URL? [y/N]: ' rotate_answer || rotate_answer=no
|
||||
if [[ "$rotate_answer" == y || "$rotate_answer" == Y ]]; then
|
||||
generate_subscription_path
|
||||
else
|
||||
XUI_SUB_PATH="$existing_path"
|
||||
fi
|
||||
elif [[ -n "$existing_path" ]]; then
|
||||
warn "Текущий путь подписки не выглядит достаточно случайным. Ротация отключит существующие URL."
|
||||
read -rp 'Создать новый криптографически случайный путь для публичного доступа? [y/N]: ' rotate_answer \
|
||||
|| rotate_answer=no
|
||||
if [[ "$rotate_answer" == y || "$rotate_answer" == Y ]]; then
|
||||
generate_subscription_path
|
||||
else
|
||||
warn "Публичный доступ к подпискам не выбран; выберите приватный режим или отключение"
|
||||
continue
|
||||
fi
|
||||
else
|
||||
generate_subscription_path
|
||||
fi
|
||||
XUI_SUBSCRIPTION_ACCESS=public
|
||||
XUI_SUB_LISTEN=""
|
||||
return 0
|
||||
;;
|
||||
*) warn "Введите 1, 2 или 3" ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
choose_xui_source() {
|
||||
local answer mirror_base
|
||||
printf '\nИсточник проверенных установочных файлов 3x-ui и Xray:\n'
|
||||
printf ' 1) GitHub (по умолчанию)\n'
|
||||
printf ' 2) Forgejo: указать HTTPS base URL без учётных данных\n'
|
||||
while :; do
|
||||
read -rp 'Источник [1]: ' answer || answer=1
|
||||
answer="${answer:-1}"
|
||||
case "$answer" in
|
||||
1) XUI_SUPPLY_SOURCE=github; XUI_SUPPLY_MIRROR=""; return 0 ;;
|
||||
2)
|
||||
read -rp 'Forgejo HTTPS base URL: ' mirror_base \
|
||||
|| die "Forgejo URL не получен"
|
||||
[[ -n "$mirror_base" ]] || die "Forgejo URL не может быть пустым"
|
||||
XUI_SUPPLY_SOURCE=forgejo
|
||||
XUI_SUPPLY_MIRROR="$mirror_base"
|
||||
return 0
|
||||
;;
|
||||
*) warn "Введите 1 или 2" ;;
|
||||
|
|
@ -171,8 +294,127 @@ COMMIT;
|
|||
SQL
|
||||
}
|
||||
|
||||
set_xui_desired_listeners() {
|
||||
if [[ "$XUI_PUBLIC_ACCESS" == yes ]]; then
|
||||
XUI_DESIRED_WEB_LISTEN=""
|
||||
else
|
||||
XUI_DESIRED_WEB_LISTEN=127.0.0.1
|
||||
fi
|
||||
|
||||
if [[ "$XUI_SUBSCRIPTION_ACCESS" == public ]]; then
|
||||
XUI_DESIRED_SUB_LISTEN=""
|
||||
else
|
||||
XUI_DESIRED_SUB_LISTEN=127.0.0.1
|
||||
fi
|
||||
|
||||
if [[ "$XUI_SUBSCRIPTION_ACCESS" == disabled ]]; then
|
||||
XUI_DESIRED_SUB_ENABLED=false
|
||||
else
|
||||
XUI_DESIRED_SUB_ENABLED=true
|
||||
fi
|
||||
}
|
||||
|
||||
create_xui_start_guard() {
|
||||
local directory="$1" marker="$2" binary="$3" db="$4"
|
||||
local real_systemctl="${5:-$(command -v systemctl)}"
|
||||
local real_rc_service="${6:-$(command -v rc-service || true)}"
|
||||
local real_sqlite3="${7:-$(command -v sqlite3)}"
|
||||
local systemctl_q marker_q binary_q db_q rc_service_q sqlite3_q
|
||||
[[ -x "$real_systemctl" ]] || die "systemctl недоступен для безопасной установки 3x-ui"
|
||||
[[ -x "$real_sqlite3" ]] || die "sqlite3 недоступен для безопасной установки 3x-ui"
|
||||
mkdir -m 700 -p -- "$directory"
|
||||
printf -v systemctl_q '%q' "$real_systemctl"
|
||||
printf -v marker_q '%q' "$marker"
|
||||
printf -v binary_q '%q' "$binary"
|
||||
printf -v db_q '%q' "$db"
|
||||
printf -v rc_service_q '%q' "$real_rc_service"
|
||||
printf -v sqlite3_q '%q' "$real_sqlite3"
|
||||
|
||||
cat > "$directory/systemctl" <<EOF
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
REAL_SYSTEMCTL=${systemctl_q}
|
||||
RELEASE_MARKER=${marker_q}
|
||||
XUI_BINARY=${binary_q}
|
||||
XUI_DB=${db_q}
|
||||
SQLITE3_BIN=${sqlite3_q}
|
||||
service=""
|
||||
case "\${1:-}" in
|
||||
start|restart) service="\${2:-}" ;;
|
||||
enable)
|
||||
for argument in "\$@"; do
|
||||
[[ "\$argument" != x-ui && "\$argument" != x-ui.service ]] || service=x-ui
|
||||
done
|
||||
;;
|
||||
esac
|
||||
if [[ ( "\$service" == x-ui || "\$service" == x-ui.service ) && ! -e "\$RELEASE_MARKER" ]]; then
|
||||
[[ -x "\$XUI_BINARY" ]] || { printf '[x] x-ui binary missing before guarded service start\\n' >&2; exit 1; }
|
||||
"\$XUI_BINARY" setting -listenIP 127.0.0.1 \\
|
||||
|| { printf '[x] Refusing x-ui start: loopback bind could not be applied\\n' >&2; exit 1; }
|
||||
[[ -f "\$XUI_DB" && ! -L "\$XUI_DB" ]] \\
|
||||
|| { printf '[x] Refusing x-ui start: settings database is unavailable\\n' >&2; exit 1; }
|
||||
"\$SQLITE3_BIN" "\$XUI_DB" <<'SQL' || { printf '[x] Refusing x-ui start: safe subscription bind could not be applied\\n' >&2; exit 1; }
|
||||
BEGIN IMMEDIATE;
|
||||
UPDATE settings SET value='127.0.0.1' WHERE key='subListen';
|
||||
INSERT INTO settings (key,value) SELECT 'subListen','127.0.0.1'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM settings WHERE key='subListen');
|
||||
UPDATE settings SET value='false' WHERE key='subEnable';
|
||||
INSERT INTO settings (key,value) SELECT 'subEnable','false'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM settings WHERE key='subEnable');
|
||||
COMMIT;
|
||||
SQL
|
||||
safe_sub_settings="\$("\$SQLITE3_BIN" "\$XUI_DB" "SELECT (SELECT value FROM settings WHERE key='subListen' LIMIT 1) || '|' || (SELECT value FROM settings WHERE key='subEnable' LIMIT 1);")"
|
||||
[[ "\$safe_sub_settings" == '127.0.0.1|false' ]] \\
|
||||
|| { printf '[x] Refusing x-ui start: safe subscription settings did not verify\\n' >&2; exit 1; }
|
||||
fi
|
||||
exec "\$REAL_SYSTEMCTL" "\$@"
|
||||
EOF
|
||||
chmod 700 "$directory/systemctl"
|
||||
|
||||
cat > "$directory/rc-service" <<EOF
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
REAL_RC_SERVICE=${rc_service_q}
|
||||
RELEASE_MARKER=${marker_q}
|
||||
XUI_BINARY=${binary_q}
|
||||
XUI_DB=${db_q}
|
||||
SQLITE3_BIN=${sqlite3_q}
|
||||
if [[ "\${1:-}" == x-ui && ( "\${2:-}" == start || "\${2:-}" == restart ) \\
|
||||
&& ! -e "\$RELEASE_MARKER" ]]; then
|
||||
[[ -x "\$XUI_BINARY" ]] || { printf '[x] x-ui binary missing before guarded service start\\n' >&2; exit 1; }
|
||||
"\$XUI_BINARY" setting -listenIP 127.0.0.1 \\
|
||||
|| { printf '[x] Refusing x-ui start: loopback bind could not be applied\\n' >&2; exit 1; }
|
||||
[[ -f "\$XUI_DB" && ! -L "\$XUI_DB" ]] \\
|
||||
|| { printf '[x] Refusing x-ui start: settings database is unavailable\\n' >&2; exit 1; }
|
||||
"\$SQLITE3_BIN" "\$XUI_DB" <<'SQL' || { printf '[x] Refusing x-ui start: safe subscription bind could not be applied\\n' >&2; exit 1; }
|
||||
BEGIN IMMEDIATE;
|
||||
UPDATE settings SET value='127.0.0.1' WHERE key='subListen';
|
||||
INSERT INTO settings (key,value) SELECT 'subListen','127.0.0.1'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM settings WHERE key='subListen');
|
||||
UPDATE settings SET value='false' WHERE key='subEnable';
|
||||
INSERT INTO settings (key,value) SELECT 'subEnable','false'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM settings WHERE key='subEnable');
|
||||
COMMIT;
|
||||
SQL
|
||||
safe_sub_settings="\$("\$SQLITE3_BIN" "\$XUI_DB" "SELECT (SELECT value FROM settings WHERE key='subListen' LIMIT 1) || '|' || (SELECT value FROM settings WHERE key='subEnable' LIMIT 1);")"
|
||||
[[ "\$safe_sub_settings" == '127.0.0.1|false' ]] \\
|
||||
|| { printf '[x] Refusing x-ui start: safe subscription settings did not verify\\n' >&2; exit 1; }
|
||||
fi
|
||||
[[ -x "\$REAL_RC_SERVICE" ]] || { printf '[x] rc-service is unavailable\\n' >&2; exit 127; }
|
||||
exec "\$REAL_RC_SERVICE" "\$@"
|
||||
EOF
|
||||
chmod 700 "$directory/rc-service"
|
||||
XUI_GUARD_DIR="$directory"
|
||||
XUI_BOOTSTRAP_RELEASE_MARKER="$marker"
|
||||
}
|
||||
|
||||
configure_xui_ports() {
|
||||
local backup_file panel_value subscription_value
|
||||
local backup_file panel_value subscription_value subscription_listeners
|
||||
|
||||
# XUI_WEB_LISTEN and XUI_SUB_LISTEN are changed to safe loopback values
|
||||
# during bootstrap. Recompute the requested final values from the explicit
|
||||
# access choices before entering that protected phase.
|
||||
set_xui_desired_listeners
|
||||
|
||||
[[ -x "$XUI_BINARY" ]] || die "После установки не найден $XUI_BINARY"
|
||||
[[ -f "$XUI_DB" && ! -L "$XUI_DB" ]] \
|
||||
|
|
@ -189,16 +431,21 @@ configure_xui_ports() {
|
|||
die "Не удалось изменить порт панели 3x-ui"
|
||||
fi
|
||||
|
||||
if ! set_xui_setting subPort "$SUBSCRIPTION_PORT" \
|
||||
|| ! set_xui_setting subEnable true; then
|
||||
# The guarded first start always uses loopback. Public panel access is
|
||||
# enabled only after this protected listener has been observed.
|
||||
if ! set_xui_setting webListen 127.0.0.1 \
|
||||
|| ! set_xui_setting subPort "$SUBSCRIPTION_PORT" \
|
||||
|| ! set_xui_setting subListen 127.0.0.1 \
|
||||
|| ! set_xui_setting subPath "$XUI_SUB_PATH" \
|
||||
|| ! set_xui_setting subEnable false; then
|
||||
cp -a -- "$backup_file" "$XUI_DB"
|
||||
systemctl start x-ui.service >/dev/null 2>&1 || true
|
||||
die "Не удалось изменить порт подписок 3x-ui; база восстановлена"
|
||||
fi
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now x-ui.service
|
||||
systemctl restart x-ui.service
|
||||
systemctl enable x-ui.service
|
||||
systemctl start x-ui.service
|
||||
systemctl is-active --quiet x-ui.service || die "Служба x-ui не запущена"
|
||||
|
||||
panel_value="$(sqlite3 "$XUI_DB" "SELECT value FROM settings WHERE key='webPort' LIMIT 1;")"
|
||||
|
|
@ -207,10 +454,41 @@ configure_xui_ports() {
|
|||
|| die "В базе 3x-ui указан неожиданный порт панели: ${panel_value:-пусто}"
|
||||
[[ "$subscription_value" == "$SUBSCRIPTION_PORT" ]] \
|
||||
|| die "В базе 3x-ui указан неожиданный порт подписок: ${subscription_value:-пусто}"
|
||||
XUI_WEB_LISTEN="$(sqlite3 "$XUI_DB" "SELECT value FROM settings WHERE key='webListen' LIMIT 1;")"
|
||||
XUI_SUB_LISTEN="$(sqlite3 "$XUI_DB" "SELECT value FROM settings WHERE key='subListen' LIMIT 1;")"
|
||||
[[ "$XUI_WEB_LISTEN" == 127.0.0.1 ]] \
|
||||
|| die "Первый запуск 3x-ui остановлен: webListen не равен 127.0.0.1"
|
||||
[[ "$XUI_SUB_LISTEN" == 127.0.0.1 ]] \
|
||||
|| die "Первый запуск 3x-ui остановлен: subListen не равен 127.0.0.1"
|
||||
XUI_WEB_PATH="$(sqlite3 "$XUI_DB" "SELECT value FROM settings WHERE key='webBasePath' LIMIT 1;")"
|
||||
XUI_SUB_PATH="$(sqlite3 "$XUI_DB" "SELECT value FROM settings WHERE key='subPath' LIMIT 1;")"
|
||||
[[ "$XUI_WEB_PATH" == /* ]] || XUI_WEB_PATH="/${XUI_WEB_PATH}"
|
||||
[[ "$XUI_SUB_PATH" == /* ]] || XUI_SUB_PATH="/${XUI_SUB_PATH}"
|
||||
|
||||
wait_for_tcp_listener "$PANEL_PORT" "Панель 3x-ui"
|
||||
verify_private_listener "$PANEL_PORT" "Панель 3x-ui"
|
||||
subscription_listeners="$(ss -H -ltn "sport = :${SUBSCRIPTION_PORT}")"
|
||||
if [[ -n "$subscription_listeners" ]]; then
|
||||
verify_private_listener "$SUBSCRIPTION_PORT" "Сервис подписок 3x-ui"
|
||||
fi
|
||||
|
||||
systemctl stop x-ui.service
|
||||
if ! set_xui_setting webListen "$XUI_DESIRED_WEB_LISTEN" \
|
||||
|| ! set_xui_setting subListen "$XUI_DESIRED_SUB_LISTEN" \
|
||||
|| ! set_xui_setting subEnable "$XUI_DESIRED_SUB_ENABLED"; then
|
||||
die "Не удалось применить выбранный режим доступа 3x-ui"
|
||||
fi
|
||||
XUI_WEB_LISTEN="$(sqlite3 "$XUI_DB" "SELECT value FROM settings WHERE key='webListen' LIMIT 1;")"
|
||||
XUI_SUB_LISTEN="$(sqlite3 "$XUI_DB" "SELECT value FROM settings WHERE key='subListen' LIMIT 1;")"
|
||||
XUI_SUB_ENABLED="$(sqlite3 "$XUI_DB" "SELECT value FROM settings WHERE key='subEnable' LIMIT 1;")"
|
||||
[[ "$XUI_WEB_LISTEN" == "$XUI_DESIRED_WEB_LISTEN" ]] \
|
||||
|| die "Неожиданный конечный webListen в базе 3x-ui"
|
||||
[[ "$XUI_SUB_LISTEN" == "$XUI_DESIRED_SUB_LISTEN" ]] \
|
||||
|| die "Неожиданный конечный subListen в базе 3x-ui"
|
||||
[[ "$XUI_SUB_ENABLED" == "$XUI_DESIRED_SUB_ENABLED" ]] \
|
||||
|| die "Неожиданное конечное состояние сервиса подписок в базе 3x-ui"
|
||||
: > "$XUI_BOOTSTRAP_RELEASE_MARKER"
|
||||
systemctl start x-ui.service
|
||||
systemctl is-active --quiet x-ui.service || die "Служба x-ui не запустилась с выбранными настройками"
|
||||
}
|
||||
|
||||
install_cloudflare_warp() {
|
||||
|
|
@ -286,80 +564,118 @@ PANEL_PORT=""
|
|||
SUBSCRIPTION_PORT=""
|
||||
WARP_PORT=""
|
||||
XUI_PUBLIC_ACCESS=no
|
||||
XUI_SUBSCRIPTION_ACCESS=private
|
||||
XUI_WEB_LISTEN=127.0.0.1
|
||||
XUI_SUB_LISTEN=127.0.0.1
|
||||
XUI_WEB_PATH="/"
|
||||
XUI_SUB_PATH="/sub/"
|
||||
XUI_SUB_PATH=""
|
||||
XUI_EXISTING_SUB_PATH=""
|
||||
choose_port PANEL_PORT "Порт панели 3x-ui" "$DEFAULT_PANEL_PORT" "${forbidden_common[@]}"
|
||||
choose_port SUBSCRIPTION_PORT "Порт подписок 3x-ui" "$DEFAULT_SUBSCRIPTION_PORT" \
|
||||
"${forbidden_common[@]}" "$PANEL_PORT"
|
||||
choose_port WARP_PORT "Локальный proxy-порт WARP" "$DEFAULT_WARP_PORT" \
|
||||
"${forbidden_common[@]}" "$PANEL_PORT" "$SUBSCRIPTION_PORT"
|
||||
choose_public_access
|
||||
|
||||
printf '\nБудут использованы порты:\n'
|
||||
printf ' 3x-ui panel: %s/tcp\n' "$PANEL_PORT"
|
||||
printf ' 3x-ui subscriptions: %s/tcp\n' "$SUBSCRIPTION_PORT"
|
||||
printf ' WARP local proxy: 127.0.0.1:%s/tcp\n\n' "$WARP_PORT"
|
||||
if [[ "$XUI_PUBLIC_ACCESS" == yes ]]; then
|
||||
printf ' UFW: порты панели и подписок будут открыты публично\n\n'
|
||||
choose_xui_source
|
||||
read -rp 'Установить Cloudflare WARP local proxy? [y/N]: ' WARP_ANSWER || WARP_ANSWER=no
|
||||
if [[ "$WARP_ANSWER" == y || "$WARP_ANSWER" == Y ]]; then
|
||||
choose_port WARP_PORT "Локальный proxy-порт WARP" "$DEFAULT_WARP_PORT" \
|
||||
"${forbidden_common[@]}" "$PANEL_PORT" "$SUBSCRIPTION_PORT"
|
||||
else
|
||||
printf ' UFW: порты панели и подписок не будут открыты публично\n\n'
|
||||
WARP_PORT=""
|
||||
fi
|
||||
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
log "Установка системных зависимостей"
|
||||
apt-get update
|
||||
apt-get install -y ca-certificates curl gnupg iproute2 sqlite3 ufw
|
||||
for command_name in curl gpg sqlite3 ss ufw; do
|
||||
apt-get install -y ca-certificates curl gnupg iproute2 openssl sqlite3 ufw
|
||||
for command_name in curl gpg openssl sqlite3 ss ufw; do
|
||||
require_command "$command_name"
|
||||
done
|
||||
|
||||
load_existing_subscription_path
|
||||
choose_subscription_access
|
||||
|
||||
printf '\nБудут использованы порты:\n'
|
||||
printf ' 3x-ui panel: %s/tcp\n' "$PANEL_PORT"
|
||||
printf ' 3x-ui subscriptions: %s/tcp\n' "$SUBSCRIPTION_PORT"
|
||||
if [[ -n "$WARP_PORT" ]]; then printf ' WARP local proxy: 127.0.0.1:%s/tcp\n' "$WARP_PORT"; else printf ' WARP: выключен\n'; fi
|
||||
printf ' UFW: HTTPS 443 открыт; панель %s; подписки %s\n\n' \
|
||||
"$([[ "$XUI_PUBLIC_ACCESS" == yes ]] && printf 'публично' || printf 'закрыта')" \
|
||||
"$([[ "$XUI_SUBSCRIPTION_ACCESS" == public ]] && printf 'публично' || printf '%s' "$XUI_SUBSCRIPTION_ACCESS")"
|
||||
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
chmod 700 "$TMP_DIR"
|
||||
[[ -f "$SUPPLY_CHAIN_HELPER" && ! -L "$SUPPLY_CHAIN_HELPER" ]] \
|
||||
|| die "Не найден безопасный supply-chain helper: $SUPPLY_CHAIN_HELPER"
|
||||
if [[ "$XUI_SUPPLY_SOURCE" == github ]]; then
|
||||
bash "$SUPPLY_CHAIN_HELPER" prepare github "${TMP_DIR}/vendor"
|
||||
else
|
||||
bash "$SUPPLY_CHAIN_HELPER" prepare forgejo "${TMP_DIR}/vendor" "$XUI_SUPPLY_MIRROR"
|
||||
fi
|
||||
create_xui_start_guard "${TMP_DIR}/xui-start-guard" \
|
||||
"${TMP_DIR}/release-xui-listener" "$XUI_BINARY" "$XUI_DB"
|
||||
export PATH="${XUI_GUARD_DIR}:${PATH}"
|
||||
|
||||
log "Загрузка официального установщика 3x-ui"
|
||||
XUI_INSTALLER="${TMP_DIR}/3x-ui-install.sh"
|
||||
curl --fail --silent --show-error --location \
|
||||
--proto '=https' --tlsv1.2 "$XUI_INSTALL_URL" -o "$XUI_INSTALLER"
|
||||
chmod 700 "$XUI_INSTALLER"
|
||||
bash -n "$XUI_INSTALLER"
|
||||
bash "$XUI_INSTALLER"
|
||||
log "Установка 3x-ui из проверенного релиза v3.10.2"
|
||||
bash "$SUPPLY_CHAIN_HELPER" install-xui "${TMP_DIR}/vendor"
|
||||
|
||||
log "Настройка портов 3x-ui"
|
||||
configure_xui_ports
|
||||
|
||||
log "Установка и настройка Cloudflare WARP"
|
||||
install_cloudflare_warp
|
||||
if [[ -n "$WARP_PORT" ]]; then
|
||||
log "Установка и настройка Cloudflare WARP"
|
||||
install_cloudflare_warp
|
||||
fi
|
||||
|
||||
log "Настройка UFW"
|
||||
allow_ufw_port 80 "HTTP"
|
||||
allow_ufw_port 443 "HTTPS"
|
||||
if [[ "$XUI_PUBLIC_ACCESS" == yes ]]; then
|
||||
allow_ufw_port "$PANEL_PORT" "панель 3x-ui"
|
||||
allow_ufw_port "$PANEL_PORT" "публичная панель 3x-ui"
|
||||
fi
|
||||
if [[ "$XUI_SUBSCRIPTION_ACCESS" == public ]]; then
|
||||
allow_ufw_port "$SUBSCRIPTION_PORT" "подписки 3x-ui"
|
||||
else
|
||||
log "UFW: порты панели и подписок оставлены закрытыми для публичного Интернета"
|
||||
log "UFW: порт подписок оставлен закрытым для публичного Интернета"
|
||||
fi
|
||||
ufw --force enable
|
||||
|
||||
for port in 80 443; do
|
||||
verify_ufw_port "$port"
|
||||
done
|
||||
verify_ufw_port 443
|
||||
if [[ "$XUI_PUBLIC_ACCESS" == yes ]]; then
|
||||
for port in "$PANEL_PORT" "$SUBSCRIPTION_PORT"; do
|
||||
verify_ufw_port "$port"
|
||||
done
|
||||
verify_ufw_port "$PANEL_PORT"
|
||||
fi
|
||||
if [[ "$XUI_SUBSCRIPTION_ACCESS" == public ]]; then
|
||||
verify_ufw_port "$SUBSCRIPTION_PORT"
|
||||
fi
|
||||
|
||||
wait_for_tcp_listener "$PANEL_PORT" "Панель 3x-ui"
|
||||
wait_for_tcp_listener "$SUBSCRIPTION_PORT" "Сервис подписок 3x-ui"
|
||||
verify_warp_proxy
|
||||
if [[ "$XUI_PUBLIC_ACCESS" != yes ]]; then
|
||||
verify_private_listener "$PANEL_PORT" "Панель 3x-ui"
|
||||
fi
|
||||
if [[ "$XUI_SUBSCRIPTION_ACCESS" != disabled ]]; then
|
||||
wait_for_tcp_listener "$SUBSCRIPTION_PORT" "Сервис подписок 3x-ui"
|
||||
if [[ "$XUI_SUBSCRIPTION_ACCESS" != public ]]; then
|
||||
verify_private_listener "$SUBSCRIPTION_PORT" "Сервис подписок 3x-ui"
|
||||
fi
|
||||
fi
|
||||
if [[ -n "$WARP_PORT" ]]; then verify_warp_proxy; fi
|
||||
|
||||
printf '\nУстановка завершена.\n'
|
||||
printf 'Панель 3x-ui: http(s)://<server-ip>:%s%s\n' "$PANEL_PORT" "$XUI_WEB_PATH"
|
||||
printf 'Подписки 3x-ui: http(s)://<server-ip>:%s%s\n' "$SUBSCRIPTION_PORT" "$XUI_SUB_PATH"
|
||||
printf 'Локальный WARP proxy: socks5://127.0.0.1:%s\n' "$WARP_PORT"
|
||||
printf '\nПорт WARP не открыт публично в UFW: локальный proxy не требует аутентификации.\n'
|
||||
if [[ "$XUI_PUBLIC_ACCESS" != yes ]]; then
|
||||
printf 'Порты панели и подписок не открыты публично. Используйте SSH tunnel, management VPN или повторный запуск с явным публичным режимом.\n'
|
||||
if [[ "$XUI_PUBLIC_ACCESS" == yes ]]; then
|
||||
printf 'Панель 3x-ui: http(s)://<server-ip>:%s%s\n' "$PANEL_PORT" "$XUI_WEB_PATH"
|
||||
else
|
||||
printf 'Панель 3x-ui: http://127.0.0.1:%s%s (только локально)\n' "$PANEL_PORT" "$XUI_WEB_PATH"
|
||||
printf 'SSH tunnel: ssh -L %s:127.0.0.1:%s <user>@<server-ip>\n' "$PANEL_PORT" "$PANEL_PORT"
|
||||
fi
|
||||
if [[ "$XUI_SUBSCRIPTION_ACCESS" == disabled ]]; then
|
||||
printf 'Подписки 3x-ui: выключены\n'
|
||||
elif [[ "$XUI_SUBSCRIPTION_ACCESS" == public ]]; then
|
||||
printf 'Подписки 3x-ui: порт %s/tcp открыт; случайный путь сохранён в базе 3x-ui и не выведен в журнал\n' "$SUBSCRIPTION_PORT"
|
||||
else
|
||||
printf 'Подписки 3x-ui: http(s)://127.0.0.1:%s%s (только локально)\n' "$SUBSCRIPTION_PORT" "$XUI_SUB_PATH"
|
||||
fi
|
||||
if [[ -n "$WARP_PORT" ]]; then
|
||||
printf 'Локальный WARP proxy: socks5://127.0.0.1:%s\n' "$WARP_PORT"
|
||||
printf '\nПорт WARP не открыт публично в UFW: локальный proxy не требует аутентификации.\n'
|
||||
else
|
||||
printf 'Cloudflare WARP не устанавливался.\n'
|
||||
fi
|
||||
printf 'Настройте TLS, сложный путь панели, новые учётные данные и двухфакторную аутентификацию в 3x-ui.\n'
|
||||
|
|
|
|||
13
ochenstarik-server-supply-chain-pins.txt
Normal file
13
ochenstarik-server-supply-chain-pins.txt
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# Reviewed upstream release and SHA-256 pins. Supported architectures: amd64, arm64.
|
||||
# Upstream installer repositories carry GPL-3.0 licenses; these pins fix exact bytes.
|
||||
3x-ui-tag=v3.10.2
|
||||
3x-ui-installer-sha256=9684bae4f769b2127fcceceb4f200045657381311867529e6ba76c6b58e2825d
|
||||
3x-ui-amd64-sha256=bfda7921984e7a72555c38d4e861c7a8b67b3686b658608937de99682f1452d0
|
||||
3x-ui-arm64-sha256=a867c65ce52ad49f6504dd4c9c395337dc92ba57ae478da4f1b7ddb624014ed5
|
||||
3x-ui-script-sha256=3c7dabe7ea9edf0d950eff1caffbf203a1f8b52e98fc75e203372757f47998c0
|
||||
3x-ui-debian-service-sha256=6e120b037ed0a1305331e31de21471d3e6485ea28133ccf85a45d1270258ff9e
|
||||
xray-install-commit=05aa47266c94e6fb5ca48cad96afdb4becb5891f
|
||||
xray-installer-sha256=65e9e2accfc0b3b5dd24e7f2c552f35af3e581068155163d66d40f1d04fb6c95
|
||||
xray-tag=v26.10.21
|
||||
xray-amd64-sha256=0eec3ed4163c273d8c8bf821f7d219e257921cf584aa3fb970715e458e631978
|
||||
xray-arm64-sha256=d1304946c486d98e6b886e9b931eaaaebb23835b30e217638037b7f2721d4e9c
|
||||
271
ochenstarik-server-supply-chain.sh
Executable file
271
ochenstarik-server-supply-chain.sh
Executable file
|
|
@ -0,0 +1,271 @@
|
|||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
readonly XUI_TAG="v3.10.2"
|
||||
readonly XUI_INSTALLER_SHA="9684bae4f769b2127fcceceb4f200045657381311867529e6ba76c6b58e2825d"
|
||||
readonly XUI_SCRIPT_SHA="3c7dabe7ea9edf0d950eff1caffbf203a1f8b52e98fc75e203372757f47998c0"
|
||||
readonly XUI_SERVICE_SHA="6e120b037ed0a1305331e31de21471d3e6485ea28133ccf85a45d1270258ff9e"
|
||||
readonly XRAY_COMMIT="05aa47266c94e6fb5ca48cad96afdb4becb5891f"
|
||||
readonly XRAY_INSTALLER_SHA="65e9e2accfc0b3b5dd24e7f2c552f35af3e581068155163d66d40f1d04fb6c95"
|
||||
readonly XRAY_TAG="v26.10.21"
|
||||
|
||||
die() { printf '[x] %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
architecture() {
|
||||
case "$(uname -m)" in
|
||||
x86_64|x64|amd64) printf 'amd64\n' ;;
|
||||
armv8*|armv8|arm64|aarch64) printf 'arm64\n' ;;
|
||||
*) die "Unsupported architecture for pinned installer payloads: $(uname -m)" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
sha_for_arch() {
|
||||
case "$1:$2" in
|
||||
xui:amd64) printf '%s\n' bfda7921984e7a72555c38d4e861c7a8b67b3686b658608937de99682f1452d0 ;;
|
||||
xui:arm64) printf '%s\n' a867c65ce52ad49f6504dd4c9c395337dc92ba57ae478da4f1b7ddb624014ed5 ;;
|
||||
xray:amd64) printf '%s\n' 0eec3ed4163c273d8c8bf821f7d219e257921cf584aa3fb970715e458e631978 ;;
|
||||
xray:arm64) printf '%s\n' d1304946c486d98e6b886e9b931eaaaebb23835b30e217638037b7f2721d4e9c ;;
|
||||
*) die "Missing pinned digest for $1/$2" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
fetch_pinned() {
|
||||
local url="$1" dest="$2" expected_sha="$3" tmp
|
||||
tmp="${dest}.partial.$$"
|
||||
curl -fLsS --location --retry 2 --connect-timeout 15 --max-time 180 \
|
||||
--proto '=https' --tlsv1.2 -o "$tmp" -- "$url" \
|
||||
|| die "Pinned download failed: $(basename "$dest")"
|
||||
[[ -s "$tmp" && "$(sha256sum "$tmp" | awk '{print $1}')" == "$expected_sha" ]] \
|
||||
|| die "SHA-256 verification failed: $(basename "$dest")"
|
||||
chmod 600 "$tmp"
|
||||
mv -- "$tmp" "$dest"
|
||||
}
|
||||
|
||||
validate_prepared_dir() {
|
||||
local directory="$1"
|
||||
[[ -d "$directory" && ! -L "$directory" \
|
||||
&& "$(stat -c %u "$directory")" == 0 \
|
||||
&& "$(stat -c %a "$directory")" =~ ^[0-7]*[0-5][0-5]$ ]] \
|
||||
|| die "Prepared directory must be a root-owned directory not writable by group or others"
|
||||
}
|
||||
|
||||
validate_prepared_file() {
|
||||
local file="$1"
|
||||
[[ -f "$file" && ! -L "$file" && "$(stat -c %u "$file")" == 0 \
|
||||
&& "$(stat -c %a "$file")" =~ ^[0-7]*[0-5][0-5]$ ]] \
|
||||
|| die "Prepared file is missing or writable by group/others: $(basename "$file")"
|
||||
}
|
||||
|
||||
prepare() {
|
||||
local source="$1" destination="$2" mirror_base="${3:-}" arch xui_arch xray_arch
|
||||
local xui_sha xray_sha name expected
|
||||
arch="$(architecture)"
|
||||
case "$arch" in
|
||||
amd64) xui_arch=amd64; xray_arch=64 ;;
|
||||
arm64) xui_arch=arm64; xray_arch=arm64-v8a ;;
|
||||
esac
|
||||
|
||||
case "$source" in
|
||||
github)
|
||||
[[ -z "$mirror_base" ]] || die "Do not pass a mirror base with github source"
|
||||
;;
|
||||
forgejo)
|
||||
[[ "$mirror_base" =~ ^https://[A-Za-z0-9.-]+(:[0-9]+)?(/[A-Za-z0-9._/-]+)?$ \
|
||||
&& "$mirror_base" != *..* ]] \
|
||||
|| die "Forgejo mirror base must be a plain HTTPS URL without credentials, query, or traversal"
|
||||
mirror_base="${mirror_base%/}"
|
||||
;;
|
||||
*) die "Usage: $0 prepare {github|forgejo} NEW_DEST_DIR [FORGEJO_HTTPS_BASE]" ;;
|
||||
esac
|
||||
[[ "$source" != forgejo || -n "$mirror_base" ]] || die "Forgejo HTTPS base is required"
|
||||
[[ ! -e "$destination" && ! -L "$destination" ]] \
|
||||
|| die "Destination must not already exist: $destination"
|
||||
mkdir -m 700 -- "$destination"
|
||||
destination="$(readlink -f -- "$destination")"
|
||||
xui_sha="$(sha_for_arch xui "$arch")"
|
||||
xray_sha="$(sha_for_arch xray "$arch")"
|
||||
|
||||
if [[ "$source" == github ]]; then
|
||||
fetch_pinned "https://raw.githubusercontent.com/MHSanaei/3x-ui/${XUI_TAG}/install.sh" \
|
||||
"$destination/xui-install.sh" "$XUI_INSTALLER_SHA"
|
||||
fetch_pinned "https://github.com/MHSanaei/3x-ui/releases/download/${XUI_TAG}/x-ui-linux-${xui_arch}.tar.gz" \
|
||||
"$destination/x-ui-linux-${xui_arch}.tar.gz" "$xui_sha"
|
||||
fetch_pinned "https://raw.githubusercontent.com/MHSanaei/3x-ui/${XUI_TAG}/x-ui.sh" \
|
||||
"$destination/x-ui.sh" "$XUI_SCRIPT_SHA"
|
||||
fetch_pinned "https://raw.githubusercontent.com/MHSanaei/3x-ui/${XUI_TAG}/x-ui.service.debian" \
|
||||
"$destination/x-ui.service.debian" "$XUI_SERVICE_SHA"
|
||||
fetch_pinned "https://raw.githubusercontent.com/XTLS/Xray-install/${XRAY_COMMIT}/install-release.sh" \
|
||||
"$destination/xray-install.sh" "$XRAY_INSTALLER_SHA"
|
||||
fetch_pinned "https://github.com/XTLS/Xray-core/releases/download/${XRAY_TAG}/Xray-linux-${xray_arch}.zip" \
|
||||
"$destination/Xray-linux-${xray_arch}.zip" "$xray_sha"
|
||||
else
|
||||
for name in xui-install.sh "x-ui-linux-${xui_arch}.tar.gz" x-ui.sh \
|
||||
x-ui.service.debian xray-install.sh "Xray-linux-${xray_arch}.zip"; do
|
||||
case "$name" in
|
||||
xui-install.sh) expected="$XUI_INSTALLER_SHA" ;;
|
||||
x-ui-linux-*.tar.gz) expected="$xui_sha" ;;
|
||||
x-ui.sh) expected="$XUI_SCRIPT_SHA" ;;
|
||||
x-ui.service.debian) expected="$XUI_SERVICE_SHA" ;;
|
||||
xray-install.sh) expected="$XRAY_INSTALLER_SHA" ;;
|
||||
Xray-linux-*.zip) expected="$xray_sha" ;;
|
||||
esac
|
||||
fetch_pinned "${mirror_base}/${name}" "$destination/$name" "$expected"
|
||||
done
|
||||
fi
|
||||
|
||||
printf '%s %s\n' "$xui_sha" "x-ui-linux-${xui_arch}.tar.gz" \
|
||||
> "$destination/x-ui-linux-${xui_arch}.tar.gz.sha256"
|
||||
printf 'SHA2-256= %s\n' "$xray_sha" \
|
||||
> "$destination/Xray-linux-${xray_arch}.zip.dgst"
|
||||
chmod 600 "$destination"/*
|
||||
printf '[+] Verified pinned installers and release assets in %s\n' "$destination"
|
||||
}
|
||||
|
||||
prepare_xray() {
|
||||
local source="$1" destination="$2" mirror_base="${3:-}" arch xray_arch xray_sha
|
||||
[[ ! -e "$destination" && ! -L "$destination" ]] \
|
||||
|| die "Destination must not already exist: $destination"
|
||||
arch="$(architecture)"
|
||||
case "$arch" in
|
||||
amd64) xray_arch=64 ;;
|
||||
arm64) xray_arch=arm64-v8a ;;
|
||||
esac
|
||||
xray_sha="$(sha_for_arch xray "$arch")"
|
||||
case "$source" in
|
||||
github)
|
||||
[[ -z "$mirror_base" ]] || die "Do not pass a mirror base with github source"
|
||||
;;
|
||||
forgejo)
|
||||
[[ "$mirror_base" =~ ^https://[A-Za-z0-9.-]+(:[0-9]+)?(/[A-Za-z0-9._/-]+)?$ \
|
||||
&& "$mirror_base" != *..* ]] \
|
||||
|| die "Forgejo mirror base must be a plain HTTPS URL without credentials, query, or traversal"
|
||||
mirror_base="${mirror_base%/}"
|
||||
;;
|
||||
*) die "Usage: $0 prepare-xray {github|forgejo} NEW_DEST_DIR [FORGEJO_HTTPS_BASE]" ;;
|
||||
esac
|
||||
[[ "$source" != forgejo || -n "$mirror_base" ]] || die "Forgejo HTTPS base is required"
|
||||
mkdir -m 700 -- "$destination"
|
||||
destination="$(readlink -f -- "$destination")"
|
||||
if [[ "$source" == github ]]; then
|
||||
fetch_pinned "https://raw.githubusercontent.com/XTLS/Xray-install/${XRAY_COMMIT}/install-release.sh" \
|
||||
"$destination/xray-install.sh" "$XRAY_INSTALLER_SHA"
|
||||
fetch_pinned "https://github.com/XTLS/Xray-core/releases/download/${XRAY_TAG}/Xray-linux-${xray_arch}.zip" \
|
||||
"$destination/Xray-linux-${xray_arch}.zip" "$xray_sha"
|
||||
else
|
||||
fetch_pinned "${mirror_base}/xray-install.sh" "$destination/xray-install.sh" "$XRAY_INSTALLER_SHA"
|
||||
fetch_pinned "${mirror_base}/Xray-linux-${xray_arch}.zip" \
|
||||
"$destination/Xray-linux-${xray_arch}.zip" "$xray_sha"
|
||||
fi
|
||||
printf 'SHA2-256= %s\n' "$xray_sha" \
|
||||
> "$destination/Xray-linux-${xray_arch}.zip.dgst"
|
||||
chmod 600 "$destination"/*
|
||||
printf '[+] Verified pinned Xray %s installer and payload in %s\n' "$XRAY_TAG" "$destination"
|
||||
}
|
||||
|
||||
write_xui_curl_shim() {
|
||||
local directory="$1" shim_dir="$2"
|
||||
mkdir -m 700 -p -- "$shim_dir"
|
||||
cat > "$shim_dir/curl" <<'CURL_SHIM'
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
url=""
|
||||
out=""
|
||||
has_writeout=no
|
||||
previous=""
|
||||
for argument in "$@"; do
|
||||
if [[ "$previous" == -o || "$previous" == --output \
|
||||
|| "$previous" =~ ^-[A-Za-z]*o$ ]]; then out="$argument"; fi
|
||||
[[ "$argument" != -w && "$argument" != --write-out ]] || has_writeout=yes
|
||||
[[ "$argument" != http://* && "$argument" != https://* ]] || url="$argument"
|
||||
previous="$argument"
|
||||
done
|
||||
answer_file=""
|
||||
case "$url" in
|
||||
https://github.com/MHSanaei/3x-ui/releases/download/v3.10.2/x-ui-linux-*.tar.gz)
|
||||
answer_file="${SUPPLY_DIR}/$(basename "$url")" ;;
|
||||
https://github.com/MHSanaei/3x-ui/releases/download/v3.10.2/x-ui-linux-*.tar.gz.sha256)
|
||||
answer_file="${SUPPLY_DIR}/$(basename "$url")" ;;
|
||||
https://raw.githubusercontent.com/MHSanaei/3x-ui/v3.10.2/x-ui.sh)
|
||||
answer_file="${SUPPLY_DIR}/x-ui.sh" ;;
|
||||
https://raw.githubusercontent.com/MHSanaei/3x-ui/v3.10.2/x-ui.service.debian)
|
||||
answer_file="${SUPPLY_DIR}/x-ui.service.debian" ;;
|
||||
https://raw.githubusercontent.com/MHSanaei/3x-ui/v3.10.2/x-ui.rc)
|
||||
printf 'Unsupported platform data request\n' >&2; exit 1 ;;
|
||||
https://check-host.net/ip)
|
||||
[[ -x "${REAL_CURL_BIN:-}" ]] || { printf 'curl is unavailable for pinned data lookup\n' >&2; exit 1; }
|
||||
exec "$REAL_CURL_BIN" "$@" --proto '=https' --tlsv1.2 --max-time 5 ;;
|
||||
*) printf 'Blocked unpinned 3x-ui installer request: %s\n' "$url" >&2; exit 1 ;;
|
||||
esac
|
||||
[[ -r "$answer_file" ]] || { printf 'Pinned data file is missing: %s\n' "$answer_file" >&2; exit 1; }
|
||||
if [[ -n "$out" && "$out" != /dev/null ]]; then
|
||||
cp -- "$answer_file" "$out"
|
||||
fi
|
||||
if [[ "$has_writeout" == yes ]]; then printf '200'; fi
|
||||
CURL_SHIM
|
||||
chmod 700 "$shim_dir/curl"
|
||||
: "$directory"
|
||||
}
|
||||
|
||||
install_xui() {
|
||||
local directory="$1" arch xui_arch shim_dir
|
||||
directory="$(readlink -f -- "$directory")"
|
||||
validate_prepared_dir "$directory"
|
||||
arch="$(architecture)"
|
||||
case "$arch" in amd64) xui_arch=amd64 ;; arm64) xui_arch=arm64 ;; esac
|
||||
for file in xui-install.sh "x-ui-linux-${xui_arch}.tar.gz" \
|
||||
"x-ui-linux-${xui_arch}.tar.gz.sha256" x-ui.sh x-ui.service.debian; do
|
||||
validate_prepared_file "$directory/$file"
|
||||
done
|
||||
[[ "$(sha256sum "$directory/xui-install.sh" | awk '{print $1}')" == "$XUI_INSTALLER_SHA" \
|
||||
&& "$(sha256sum "$directory/x-ui.sh" | awk '{print $1}')" == "$XUI_SCRIPT_SHA" \
|
||||
&& "$(sha256sum "$directory/x-ui.service.debian" | awk '{print $1}')" == "$XUI_SERVICE_SHA" \
|
||||
&& "$(sha256sum "$directory/x-ui-linux-${xui_arch}.tar.gz" | awk '{print $1}')" == "$(sha_for_arch xui "$arch")" ]] \
|
||||
|| die "Prepared 3x-ui payload no longer matches the pins"
|
||||
shim_dir="$(mktemp -d /tmp/ochenstarik-xui-curl.XXXXXX)"
|
||||
trap 'rm -rf -- "$shim_dir"' EXIT
|
||||
local real_curl
|
||||
real_curl="$(command -v curl)"
|
||||
[[ -x "$real_curl" ]] || die "curl is required for the pinned data lookup"
|
||||
write_xui_curl_shim "$directory" "$shim_dir"
|
||||
(cd -- "$directory" && SUPPLY_DIR="$directory" REAL_CURL_BIN="$real_curl" PATH="$shim_dir:$PATH" \
|
||||
XUI_NONINTERACTIVE=1 bash ./xui-install.sh "$XUI_TAG")
|
||||
rm -rf -- "$shim_dir"
|
||||
trap - EXIT
|
||||
}
|
||||
|
||||
install_xray() {
|
||||
local directory="$1" arch xray_arch expected
|
||||
directory="$(readlink -f -- "$directory")"
|
||||
validate_prepared_dir "$directory"
|
||||
arch="$(architecture)"
|
||||
case "$arch" in amd64) xray_arch=64 ;; arm64) xray_arch=arm64-v8a ;; esac
|
||||
expected="$(sha_for_arch xray "$arch")"
|
||||
validate_prepared_file "$directory/xray-install.sh"
|
||||
validate_prepared_file "$directory/Xray-linux-${xray_arch}.zip"
|
||||
validate_prepared_file "$directory/Xray-linux-${xray_arch}.zip.dgst"
|
||||
[[ "$(sha256sum "$directory/xray-install.sh" | awk '{print $1}')" == "$XRAY_INSTALLER_SHA" \
|
||||
&& "$(sha256sum "$directory/Xray-linux-${xray_arch}.zip" | awk '{print $1}')" == "$expected" \
|
||||
&& "$(awk -F '= ' '/^SHA2-256=/ {print $2}' "$directory/Xray-linux-${xray_arch}.zip.dgst")" == "$expected" ]] \
|
||||
|| die "Prepared Xray payload no longer matches the pins"
|
||||
(cd -- "$directory" && bash ./xray-install.sh install --local "Xray-linux-${xray_arch}.zip")
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
prepare)
|
||||
[[ $# -ge 3 && $# -le 4 ]] || die "Usage: $0 prepare {github|forgejo} NEW_DEST_DIR [FORGEJO_HTTPS_BASE]"
|
||||
prepare "$2" "$3" "${4:-}"
|
||||
;;
|
||||
prepare-xray)
|
||||
[[ $# -ge 3 && $# -le 4 ]] || die "Usage: $0 prepare-xray {github|forgejo} NEW_DEST_DIR [FORGEJO_HTTPS_BASE]"
|
||||
prepare_xray "$2" "$3" "${4:-}"
|
||||
;;
|
||||
install-xui)
|
||||
[[ $# == 2 ]] || die "Usage: $0 install-xui PREPARED_DIR"
|
||||
install_xui "$2"
|
||||
;;
|
||||
install-xray)
|
||||
[[ $# == 2 ]] || die "Usage: $0 install-xray PREPARED_DIR"
|
||||
install_xray "$2"
|
||||
;;
|
||||
*) die "Usage: $0 {prepare|prepare-xray|install-xui|install-xray} ..." ;;
|
||||
esac
|
||||
|
|
@ -56,7 +56,7 @@ send_message() {
|
|||
}
|
||||
|
||||
[[ "$EUID" -eq 0 ]] || die "Запустите этот скрипт от имени root"
|
||||
for command_name in curl sshd; do
|
||||
for command_name in curl sshd setsid; do
|
||||
command -v "$command_name" >/dev/null 2>&1 || die "Не найдена команда: $command_name; сначала запустите второй скрипт"
|
||||
done
|
||||
[[ -f "$PAM_SSHD" && ! -L "$PAM_SSHD" ]] || die "Не найден файл конфигурации PAM для SSH: $PAM_SSHD"
|
||||
|
|
@ -108,6 +108,12 @@ set -Euo pipefail
|
|||
|
||||
readonly CONFIG_FILE="/etc/ochenstarik-server/telegram.conf"
|
||||
readonly LOG_FILE="/var/log/ochenstarik-ssh-login-telegram.log"
|
||||
[[ "${PAM_TYPE:-}" == open_session ]] || exit 0
|
||||
if [[ "${1:-}" != --worker ]]; then
|
||||
/usr/bin/setsid --fork "$0" --worker \
|
||||
</dev/null >/dev/null 2>&1 &
|
||||
exit 0
|
||||
fi
|
||||
[[ -r "$CONFIG_FILE" ]] || exit 0
|
||||
umask 077
|
||||
|
||||
|
|
|
|||
|
|
@ -107,6 +107,52 @@ allow_ufw_rule() {
|
|||
fi
|
||||
}
|
||||
|
||||
delete_ufw_rule_matches() {
|
||||
local attempt
|
||||
for attempt in {1..5}; do
|
||||
ufw --force delete allow "$@" >/dev/null 2>&1 || break
|
||||
done
|
||||
}
|
||||
|
||||
retire_temporary_ssh_port_22() {
|
||||
local managed_file="${1:-$MANAGED_PORTS_CONFIG}" answer temp_file ufw_status
|
||||
|
||||
[[ "$MANAGE_UFW" == yes && "$SSH_PORT" != 22 ]] || return 0
|
||||
read -rp 'Успешно проверили вход по новому SSH-порту в отдельном терминале? Удалить временный порт 22? [y/N]: ' answer \
|
||||
|| answer=""
|
||||
[[ "$answer" == y || "$answer" == Y ]] || {
|
||||
log "Правило порта 22 оставлено для безопасного доступа"
|
||||
return 0
|
||||
}
|
||||
|
||||
require_command ufw
|
||||
delete_ufw_rule_matches 22/tcp
|
||||
delete_ufw_rule_matches from 0.0.0.0/0 to any port 22 proto tcp
|
||||
delete_ufw_rule_matches from ::/0 to any port 22 proto tcp
|
||||
|
||||
ufw_status="$(LANG=C ufw status)" \
|
||||
|| die "Could not verify UFW after deleting temporary SSH port 22; keeping its managed record"
|
||||
if grep -Eq '^22(/tcp)?([[:space:]]+\(v6\))?[[:space:]]+ALLOW([[:space:]]|$)' <<< "$ufw_status"; then
|
||||
die "UFW still allows temporary SSH port 22; keeping its managed record"
|
||||
fi
|
||||
|
||||
if [[ -e "$managed_file" ]]; then
|
||||
[[ -f "$managed_file" && ! -L "$managed_file" ]] \
|
||||
|| die "$managed_file must be a regular non-symlink file"
|
||||
temp_file="$(mktemp "${managed_file}.XXXXXX")"
|
||||
if grep -Fvx '22/tcp' "$managed_file" > "$temp_file"; then
|
||||
:
|
||||
elif [[ "$?" -ne 1 ]]; then
|
||||
rm -f -- "$temp_file"
|
||||
die "Could not update $managed_file"
|
||||
fi
|
||||
chown root:root "$temp_file"
|
||||
chmod 600 "$temp_file"
|
||||
mv -f -- "$temp_file" "$managed_file"
|
||||
fi
|
||||
log "Временный порт 22 удалён из UFW и списка управляемых правил"
|
||||
}
|
||||
|
||||
select_action() {
|
||||
local choice
|
||||
|
||||
|
|
@ -488,7 +534,6 @@ if [[ "$MANAGE_UFW" == yes ]]; then
|
|||
log "Allowing SSH, HTTPS, and application ports in UFW without resetting existing rules"
|
||||
allow_ufw_rule "${SSH_PORT}/tcp"
|
||||
allow_ufw_rule "443/tcp"
|
||||
allow_ufw_rule "63636/tcp"
|
||||
ufw --force enable
|
||||
else
|
||||
warn "MANAGE_UFW=no: firewall was not enabled or modified"
|
||||
|
|
@ -531,19 +576,5 @@ printf 'Password SSH authentication: %s\n' "$PASSWORD_AUTH"
|
|||
printf 'Passwordless sudo: %s\n' "$PASSWORDLESS_SUDO"
|
||||
printf '\nOpen a NEW terminal and test before closing the current root session:\n'
|
||||
printf 'ssh -p %s %s@<server-ip>\n' "$SSH_PORT" "$NEW_USERNAME"
|
||||
case "$(read_ip_mode)" in
|
||||
ipv4)
|
||||
printf 'After that login succeeds, remove temporary port 22 with:\n'
|
||||
printf 'sudo ufw delete allow from 0.0.0.0/0 to any port 22 proto tcp\n'
|
||||
;;
|
||||
ipv6)
|
||||
printf 'After that login succeeds, remove temporary port 22 with:\n'
|
||||
printf 'sudo ufw delete allow from ::/0 to any port 22 proto tcp\n'
|
||||
;;
|
||||
both)
|
||||
printf 'After that login succeeds, remove both temporary port 22 rules:\n'
|
||||
printf 'sudo ufw delete allow from 0.0.0.0/0 to any port 22 proto tcp\n'
|
||||
printf 'sudo ufw delete allow from ::/0 to any port 22 proto tcp\n'
|
||||
;;
|
||||
esac
|
||||
retire_temporary_ssh_port_22
|
||||
printf 'To enable Telegram login notifications, run ochenstarik-server-tg-4.sh.\n'
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
|
||||
readonly XRAY_CONFIG="/usr/local/etc/xray/config.json"
|
||||
readonly XRAY_DIR="/usr/local/etc/xray"
|
||||
readonly STATE_DIR="/etc/ochenstarik-xray"
|
||||
|
|
@ -14,6 +15,45 @@ log() { printf '[+] %s\n' "$*"; }
|
|||
warn() { printf '[!] %s\n' "$*" >&2; }
|
||||
die() { printf '[x] %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
choose_xray_source() {
|
||||
local choice mirror_base
|
||||
printf '\nИсточник установщика Xray и релиза v26.10.21:\n'
|
||||
printf ' 1) GitHub (по умолчанию)\n'
|
||||
printf ' 2) HTTPS-зеркало Forgejo\n'
|
||||
while :; do
|
||||
read -rp 'Выберите источник [1]: ' choice || die "Ввод прерван"
|
||||
choice="${choice:-1}"
|
||||
case "$choice" in
|
||||
1) XRAY_SOURCE=github; XRAY_MIRROR_BASE=""; return 0 ;;
|
||||
2)
|
||||
read -rp 'Базовый HTTPS URL зеркала с проверенными файлами: ' mirror_base \
|
||||
|| die "Ввод прерван"
|
||||
XRAY_SOURCE=forgejo
|
||||
XRAY_MIRROR_BASE="$mirror_base"
|
||||
return 0
|
||||
;;
|
||||
*) warn "Введите 1 или 2" ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
fetch_subscription() {
|
||||
local url="$1" output_file="$2" escaped_url LC_ALL=C
|
||||
# curl's config parser accepts C-style escaping for backslash and quote.
|
||||
# Reject control characters so the operator cannot add a second directive.
|
||||
if [[ "$url" == *[[:cntrl:]]* ]]; then
|
||||
die "Ссылка подписки содержит управляющие символы"
|
||||
fi
|
||||
escaped_url="${url//\\/\\\\}"
|
||||
escaped_url="${escaped_url//\"/\\\"}"
|
||||
{
|
||||
printf 'url = "%s"\n' "$escaped_url"
|
||||
printf 'fail\nsilent\nshow-error\nlocation\n'
|
||||
printf 'proto = "=https"\ntlsv1.2\nmax-time = 30\n'
|
||||
} | curl --config - -o "$output_file" \
|
||||
|| die "Не удалось загрузить подписку"
|
||||
}
|
||||
|
||||
TMP_DIR=""
|
||||
cleanup() {
|
||||
[[ -z "$TMP_DIR" ]] || rm -rf -- "$TMP_DIR"
|
||||
|
|
@ -175,17 +215,11 @@ LINKS_FILE="${TMP_DIR}/vless-links.txt"
|
|||
LINK_FILE="${TMP_DIR}/vless-link.txt"
|
||||
PORTS_FILE="${TMP_DIR}/vless-ports.txt"
|
||||
GENERATED_CONFIG="${TMP_DIR}/config.json"
|
||||
INSTALLER_FILE="${TMP_DIR}/install-release.sh"
|
||||
|
||||
if [[ "$SUBSCRIPTION_INPUT" == vless://* ]]; then
|
||||
printf '%s' "$SUBSCRIPTION_INPUT" > "$RAW_FILE"
|
||||
else
|
||||
{
|
||||
printf 'url = "%s"\n' "$SUBSCRIPTION_INPUT"
|
||||
printf 'fail\nsilent\nshow-error\nlocation\n'
|
||||
printf 'proto = "=https"\ntlsv1.2\nmax-time = 30\n'
|
||||
} | curl --config - > "$RAW_FILE" \
|
||||
|| die "Не удалось загрузить подписку"
|
||||
fetch_subscription "$SUBSCRIPTION_INPUT" "$RAW_FILE"
|
||||
fi
|
||||
unset SUBSCRIPTION_INPUT
|
||||
chmod 600 "$RAW_FILE"
|
||||
|
|
@ -398,13 +432,21 @@ valid_ipv4 "$VPN_IP" || die "Получен некорректный IPv4 VPN-с
|
|||
chmod 600 "$GENERATED_CONFIG"
|
||||
log "Подписка разобрана: VLESS + TCP/RAW + REALITY"
|
||||
|
||||
log "Установка или обновление Xray из официального репозитория XTLS"
|
||||
curl --fail --silent --show-error --location \
|
||||
--proto '=https' --tlsv1.2 \
|
||||
https://raw.githubusercontent.com/XTLS/Xray-install/main/install-release.sh \
|
||||
-o "$INSTALLER_FILE"
|
||||
chmod 700 "$INSTALLER_FILE"
|
||||
bash "$INSTALLER_FILE" install
|
||||
log "Установка Xray v26.10.21 с проверкой закреплённых SHA-256"
|
||||
choose_xray_source
|
||||
SUPPLY_CHAIN_HELPER="${SCRIPT_DIR}/ochenstarik-server-supply-chain.sh"
|
||||
[[ -f "$SUPPLY_CHAIN_HELPER" && ! -L "$SUPPLY_CHAIN_HELPER" ]] \
|
||||
|| die "Не найден локальный проверяющий загрузки: $SUPPLY_CHAIN_HELPER"
|
||||
XRAY_SUPPLY_DIR="${TMP_DIR}/xray-supply-chain"
|
||||
if [[ "$XRAY_SOURCE" == github ]]; then
|
||||
bash "$SUPPLY_CHAIN_HELPER" prepare-xray github "$XRAY_SUPPLY_DIR" \
|
||||
|| die "Не удалось проверить файлы Xray из GitHub"
|
||||
else
|
||||
bash "$SUPPLY_CHAIN_HELPER" prepare-xray forgejo "$XRAY_SUPPLY_DIR" "$XRAY_MIRROR_BASE" \
|
||||
|| die "Не удалось проверить файлы Xray из Forgejo-зеркала"
|
||||
fi
|
||||
bash "$SUPPLY_CHAIN_HELPER" install-xray "$XRAY_SUPPLY_DIR" \
|
||||
|| die "Установка закреплённого Xray завершилась ошибкой"
|
||||
|
||||
command -v xray >/dev/null 2>&1 || die "Xray не установлен"
|
||||
install -d -m 755 "$XRAY_DIR" /var/log/xray
|
||||
|
|
|
|||
86
tests/test-backup-initial-retry.sh
Executable file
86
tests/test-backup-initial-retry.sh
Executable file
|
|
@ -0,0 +1,86 @@
|
|||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.."
|
||||
tmp="$(mktemp -d)"
|
||||
trap 'rm -rf -- "$tmp"' EXIT
|
||||
|
||||
awk '/^ cat > "\$BACKUP_RUNNER" <<.BACKUP_RUNNER_EOF/ { copying=1; next }
|
||||
copying && /^BACKUP_RUNNER_EOF$/ { exit }
|
||||
copying { print }' ochenstarik-server-backup-7.sh > "$tmp/runner"
|
||||
awk '/^\[\[ "\$EUID"/ { exit } { print }' "$tmp/runner" > "$tmp/runner-functions"
|
||||
bash -n "$tmp/runner-functions"
|
||||
source "$tmp/runner-functions"
|
||||
|
||||
# Exercise the real create_backup early-return path with only the SSH transport
|
||||
# replaced. This never connects to a host or reads the real filesystem.
|
||||
protect_initial_files() { :; }
|
||||
mkdir -p "$tmp/bin"
|
||||
cat > "$tmp/bin/scp" <<'SCP_STUB'
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
printf '%s\n' "$*" >> "$SCP_CALLS"
|
||||
exit "${SCP_EXIT_CODE:-0}"
|
||||
SCP_STUB
|
||||
chmod +x "$tmp/bin/scp"
|
||||
export PATH="$tmp/bin:$PATH" SCP_CALLS="$tmp/scp-calls"
|
||||
|
||||
BACKUP_ROOT="$tmp/encrypted-root"
|
||||
BACKUP_ENCRYPTION=age
|
||||
AGE_RECIPIENT='age1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq'
|
||||
OFFSITE_TARGET='backup@example.test'
|
||||
OFFSITE_PATH='/srv/backups'
|
||||
OFFSITE_KNOWN_HOSTS="$tmp/known_hosts"
|
||||
mkdir -p "$BACKUP_ROOT/initial"
|
||||
touch "$OFFSITE_KNOWN_HOSTS"
|
||||
archive="$BACKUP_ROOT/initial/initial-20261023-120000.tar.zst.age"
|
||||
printf 'ciphertext fixture\n' > "$archive"
|
||||
(
|
||||
cd "$BACKUP_ROOT/initial"
|
||||
sha256sum "$(basename "$archive")" > "$(basename "$archive").sha256"
|
||||
)
|
||||
checksum="${archive}.sha256"
|
||||
|
||||
# A failed first offsite upload must leave both immutable local files intact,
|
||||
# return failure, and retry those exact files on the next invocation.
|
||||
if (SCP_EXIT_CODE=42 create_backup initial 0) > "$tmp/failed.out" 2>&1; then
|
||||
printf 'Initial offsite failure was accepted as success.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -Fq 'Первичный снимок уже существует' "$tmp/failed.out"; then
|
||||
printf 'Failed upload was reported as a successful existing backup.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
[[ -f "$archive" && -f "$checksum" ]]
|
||||
(
|
||||
cd "$BACKUP_ROOT/initial"
|
||||
sha256sum --check --status "$(basename "$checksum")"
|
||||
)
|
||||
[[ "$(wc -l < "$SCP_CALLS")" == 1 ]]
|
||||
grep -Fq -- "$archive" "$SCP_CALLS"
|
||||
grep -Fq -- "$checksum" "$SCP_CALLS"
|
||||
|
||||
create_backup initial 0 > "$tmp/retry.out"
|
||||
grep -Fq 'Первичный снимок уже существует' "$tmp/retry.out"
|
||||
[[ "$(wc -l < "$SCP_CALLS")" == 2 ]]
|
||||
[[ "$(sha256sum "$archive" | awk '{print $1}')" == \
|
||||
"$(cd "$BACKUP_ROOT/initial" && sha256sum --check --status "$(basename "$checksum")" && sha256sum "$(basename "$archive")" | awk '{print $1}')" ]]
|
||||
|
||||
# Selecting age cannot silently accept an existing plaintext initial snapshot.
|
||||
plain_root="$tmp/plain-root"
|
||||
mkdir -p "$plain_root/initial"
|
||||
plain_archive="$plain_root/initial/initial-20261022-120000.tar.zst"
|
||||
printf 'plaintext fixture\n' > "$plain_archive"
|
||||
(
|
||||
cd "$plain_root/initial"
|
||||
sha256sum "$(basename "$plain_archive")" > "$(basename "$plain_archive").sha256"
|
||||
)
|
||||
BACKUP_ROOT="$plain_root"
|
||||
if (create_backup initial 0) > "$tmp/plain.out" 2>&1; then
|
||||
printf 'Age policy silently accepted an existing plaintext snapshot.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -Fq 'уже создан без шифрования' "$tmp/plain.out"
|
||||
[[ -f "$plain_archive" && "$(wc -l < "$SCP_CALLS")" == 2 ]]
|
||||
|
||||
printf 'Initial backup retry and encryption-policy tests passed.\n'
|
||||
132
tests/test-download-security.sh
Executable file
132
tests/test-download-security.sh
Executable file
|
|
@ -0,0 +1,132 @@
|
|||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.."
|
||||
tmp="$(mktemp -d)"
|
||||
trap 'rm -rf -- "$tmp"' EXIT
|
||||
mkdir -p "$tmp/bin"
|
||||
|
||||
# install-xui allocates the shim with mktemp -d before populating it. Exercise
|
||||
# that real lifecycle, then both the cached and rejected download paths.
|
||||
source <(awk '/^write_xui_curl_shim\(\)/ { copying=1 } copying { print } copying && /^}/ { exit }' \
|
||||
ochenstarik-server-supply-chain.sh)
|
||||
mkdir -m 700 "$tmp/shim" "$tmp/vendor"
|
||||
printf 'verified-fixture\n' > "$tmp/vendor/x-ui.sh"
|
||||
write_xui_curl_shim "$tmp/vendor" "$tmp/shim"
|
||||
SUPPLY_DIR="$tmp/vendor" "$tmp/shim/curl" -fLRo "$tmp/copied" \
|
||||
https://raw.githubusercontent.com/MHSanaei/3x-ui/v3.10.2/x-ui.sh
|
||||
cmp "$tmp/vendor/x-ui.sh" "$tmp/copied"
|
||||
if SUPPLY_DIR="$tmp/vendor" "$tmp/shim/curl" -o "$tmp/unknown" \
|
||||
https://untrusted.example.test/installer.sh >/dev/null 2>&1; then
|
||||
printf 'Shim accepted an unpinned installer request.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
[[ ! -e "$tmp/unknown" ]]
|
||||
|
||||
# Load only the production URL fetcher so this test never calls the network or
|
||||
# runs the VPN installer.
|
||||
source <(awk '/^fetch_subscription\(\)/ { copying=1 } copying { print } copying && /^}/ { exit }' \
|
||||
ochenstarik-server-vpn-5.sh)
|
||||
die() { printf '%s\n' "$*" >&2; exit 1; }
|
||||
source <(awk '/^choose_xray_source\(\)/ { copying=1 } copying { print } copying && /^}/ { exit }' \
|
||||
ochenstarik-server-vpn-5.sh)
|
||||
choose_xray_source <<< $'2\nhttps://git.example.test/project/releases/download/vendor-20261023'
|
||||
[[ "$XRAY_SOURCE" == forgejo \
|
||||
&& "$XRAY_MIRROR_BASE" == 'https://git.example.test/project/releases/download/vendor-20261023' ]]
|
||||
|
||||
cat > "$tmp/bin/curl" <<'CURL_STUB'
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
printf '%s\n' "$*" > "$CURL_ARGS_LOG"
|
||||
cat > "$CURL_CONFIG_LOG"
|
||||
while (($#)); do
|
||||
if [[ "$1" == -o ]]; then printf 'test payload\n' > "$2"; exit 0; fi
|
||||
shift
|
||||
done
|
||||
exit 1
|
||||
CURL_STUB
|
||||
chmod +x "$tmp/bin/curl"
|
||||
export PATH="$tmp/bin:$PATH"
|
||||
export CURL_ARGS_LOG="$tmp/curl-args" CURL_CONFIG_LOG="$tmp/curl-config"
|
||||
|
||||
literal_url='https://subscriptions.example.test/path/a"b\c?key=literal-marker'
|
||||
fetch_subscription "$literal_url" "$tmp/subscription"
|
||||
[[ "$(cat "$tmp/subscription")" == 'test payload' ]]
|
||||
grep -Fqx -- 'proto = "=https"' "$CURL_CONFIG_LOG"
|
||||
grep -Fqx -- 'tlsv1.2' "$CURL_CONFIG_LOG"
|
||||
grep -Fqx -- 'location' "$CURL_CONFIG_LOG"
|
||||
expected_url="${literal_url//\\/\\\\}"
|
||||
expected_url="${expected_url//\"/\\\"}"
|
||||
grep -Fqx -- "url = \"$expected_url\"" "$CURL_CONFIG_LOG"
|
||||
if grep -Fq 'literal-marker' "$CURL_ARGS_LOG"; then
|
||||
printf 'Subscription URL leaked into curl arguments.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if (fetch_subscription $'https://subscriptions.example.test/good\nurl = "http://bad.example"' "$tmp/blocked") \
|
||||
>/dev/null 2>&1; then
|
||||
printf 'Control-character URL was accepted.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# A large value must not bypass rejection through grep -q / SIGPIPE under
|
||||
# pipefail. Bash validates the entire value before curl sees any config.
|
||||
printf -v padding '%131072s' ''
|
||||
if (fetch_subscription $'https://subscriptions.example.test/\v'"${padding// /x}" "$tmp/blocked-large") \
|
||||
>/dev/null 2>&1; then
|
||||
printf 'Large control-character URL was accepted.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# S8 already carries these flags; lock the redirect policy in as a regression.
|
||||
grep -Fq -- "--proto '=https' --tlsv1.2" ochenstarik-server-ai-agents-8.sh
|
||||
|
||||
# A failed Forgejo mirror request must stop without trying GitHub.
|
||||
cat > "$tmp/bin/curl" <<'CURL_FAIL_STUB'
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
printf '%s\n' "$*" >> "$CURL_ATTEMPTS"
|
||||
exit 22
|
||||
CURL_FAIL_STUB
|
||||
chmod +x "$tmp/bin/curl"
|
||||
export CURL_ATTEMPTS="$tmp/attempts"
|
||||
if bash ./ochenstarik-server-supply-chain.sh prepare forgejo "$tmp/prepared" \
|
||||
'https://git.example.test/project/releases/download/vendor-20261023' \
|
||||
>/dev/null 2>&1; then
|
||||
printf 'Failed Forgejo fetch unexpectedly succeeded.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
[[ "$(wc -l < "$CURL_ATTEMPTS")" == 1 ]]
|
||||
grep -Fq 'https://git.example.test/project/releases/download/vendor-20261023/xui-install.sh' "$CURL_ATTEMPTS"
|
||||
if grep -Eq 'github\.com|raw\.githubusercontent\.com' "$CURL_ATTEMPTS"; then
|
||||
printf 'Forgejo failure fell back to GitHub.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
if bash ./ochenstarik-server-supply-chain.sh prepare-xray forgejo "$tmp/xray-prepared" \
|
||||
'https://git.example.test/project/releases/download/vendor-20261023' \
|
||||
>/dev/null 2>&1; then
|
||||
printf 'Failed Forgejo Xray fetch unexpectedly succeeded.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
[[ "$(wc -l < "$CURL_ATTEMPTS")" == 2 ]]
|
||||
grep -Fq 'https://git.example.test/project/releases/download/vendor-20261023/xray-install.sh' "$CURL_ATTEMPTS"
|
||||
if grep -Eq 'github\.com|raw\.githubusercontent\.com' "$CURL_ATTEMPTS"; then
|
||||
printf 'Xray Forgejo failure fell back to GitHub.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Both installer steps must reach their pinned helper paths and avoid mutable
|
||||
# upstream main installer URLs.
|
||||
grep -Fq 'prepare-xray github' ochenstarik-server-vpn-5.sh
|
||||
grep -Fq 'prepare-xray forgejo' ochenstarik-server-vpn-5.sh
|
||||
grep -Fq 'install-xray "$XRAY_SUPPLY_DIR"' ochenstarik-server-vpn-5.sh
|
||||
grep -Fq 'prepare github' ochenstarik-server-panel-warp-6.sh
|
||||
grep -Fq 'prepare forgejo' ochenstarik-server-panel-warp-6.sh
|
||||
grep -Fq 'install-xui "${TMP_DIR}/vendor"' ochenstarik-server-panel-warp-6.sh
|
||||
if grep -Eq 'XTLS/Xray-install/main|MHSanaei/3x-ui/main' \
|
||||
ochenstarik-server-vpn-5.sh ochenstarik-server-panel-warp-6.sh; then
|
||||
printf 'An installer step still references a mutable upstream main URL.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'Download security tests passed.\n'
|
||||
295
tests/test-firewall-panel-security.sh
Normal file
295
tests/test-firewall-panel-security.sh
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.."
|
||||
|
||||
bash <<'STEP2_TEST'
|
||||
set -Eeuo pipefail
|
||||
source <(awk '/^\[\[ "\$EUID"/{exit} {print}' ochenstarik-server-2.sh)
|
||||
SSH_PORT=20202
|
||||
SSH_CONNECTION='198.51.100.9 41000 192.0.2.1 22'
|
||||
MANAGED_RULES=()
|
||||
declare -A RULE_SEEN=()
|
||||
add_default_rules
|
||||
[[ " ${MANAGED_RULES[*]} " == *' 20202/tcp '* ]]
|
||||
[[ " ${MANAGED_RULES[*]} " == *' 22/tcp '* ]]
|
||||
[[ " ${MANAGED_RULES[*]} " == *' 443/tcp '* ]]
|
||||
[[ " ${MANAGED_RULES[*]} " != *' 80/tcp '* ]]
|
||||
[[ " ${MANAGED_RULES[*]} " != *' 2096/tcp '* ]]
|
||||
[[ " ${MANAGED_RULES[*]} " != *' 40000/tcp '* ]]
|
||||
[[ " ${MANAGED_RULES[*]} " != *' 63636/tcp '* ]]
|
||||
|
||||
MANAGED_RULES=(22/tcp 20202/tcp 80/tcp 2096/tcp 443/tcp 40000/tcp 63636/tcp)
|
||||
PREVIOUS_MANAGED_RULES=("${MANAGED_RULES[@]}")
|
||||
RULE_SEEN=()
|
||||
confirm_legacy_rules <<< 'n'
|
||||
[[ " ${MANAGED_RULES[*]} " == *' 22/tcp '* ]]
|
||||
[[ " ${MANAGED_RULES[*]} " == *' 443/tcp '* ]]
|
||||
[[ " ${MANAGED_RULES[*]} " != *' 80/tcp '* ]]
|
||||
[[ " ${MANAGED_RULES[*]} " != *' 2096/tcp '* ]]
|
||||
[[ " ${MANAGED_RULES[*]} " != *' 40000/tcp '* ]]
|
||||
[[ " ${MANAGED_RULES[*]} " != *' 63636/tcp '* ]]
|
||||
legacy_delete_log="$(mktemp)"
|
||||
ufw() {
|
||||
if [[ "${1:-}" == status ]]; then
|
||||
printf '%s\n' "${UFW_TEST_STATUS:-$'Status: active\nTo Action From'}"
|
||||
return 0
|
||||
fi
|
||||
printf '%s\n' "$*" >> "$legacy_delete_log"
|
||||
return 1
|
||||
}
|
||||
UFW_TEST_STATUS=$'Status: active\nTo Action From'
|
||||
delete_managed_ufw_rules
|
||||
for legacy_rule in 80/tcp 2096/tcp 40000/tcp 63636/tcp; do
|
||||
grep -Fq -- "--force delete allow ${legacy_rule}" "$legacy_delete_log"
|
||||
done
|
||||
UFW_TEST_STATUS=$'Status: active\nTo Action From\n80/tcp (v6) ALLOW IN Anywhere (v6)'
|
||||
if (delete_managed_ufw_rules) >/dev/null 2>&1; then
|
||||
printf 'Step 2 accepted a remaining IPv6 legacy UFW allow rule\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
rm -f -- "$legacy_delete_log"
|
||||
MANAGED_RULES=(80/tcp 443/tcp)
|
||||
PREVIOUS_MANAGED_RULES=("${MANAGED_RULES[@]}")
|
||||
RULE_SEEN=()
|
||||
confirm_legacy_rules <<< 'y'
|
||||
[[ " ${MANAGED_RULES[*]} " == *' 80/tcp '* ]]
|
||||
[[ " ${MANAGED_RULES[*]} " == *' 443/tcp '* ]]
|
||||
SSH_CONNECTION='198.51.100.9 41000 192.0.2.1 20202'
|
||||
MANAGED_RULES=(20202/tcp 443/tcp)
|
||||
RULE_SEEN=()
|
||||
confirm_legacy_rules <<< 'n'
|
||||
add_default_rules
|
||||
[[ " ${MANAGED_RULES[*]} " != *' 22/tcp '* ]]
|
||||
|
||||
# sudo commonly clears SSH_CONNECTION. A first run should still retain the
|
||||
# conventional SSH port if no sshd socket can be discovered, while a later
|
||||
# run must not resurrect 22 after the managed list records its retirement.
|
||||
unset SSH_CONNECTION
|
||||
ss() { :; }
|
||||
ssh_probe_dir="$(mktemp -d)"
|
||||
trap 'rm -rf -- "$ssh_probe_dir"' EXIT
|
||||
mapfile -t current_ssh_ports < <(discover_current_ssh_ports "$ssh_probe_dir/missing-managed-file")
|
||||
[[ " ${current_ssh_ports[*]} " == *' 22 '* ]]
|
||||
touch "$ssh_probe_dir/retired-managed-file"
|
||||
mapfile -t current_ssh_ports < <(discover_current_ssh_ports "$ssh_probe_dir/retired-managed-file")
|
||||
[[ " ${current_ssh_ports[*]} " != *' 22 '* ]]
|
||||
STEP2_TEST
|
||||
|
||||
bash <<'PANEL_TEST'
|
||||
set -Eeuo pipefail
|
||||
source <(awk '/^\[\[ "\$EUID"/{exit} {print}' ochenstarik-server-panel-warp-6.sh)
|
||||
OPENSSL_FAKE_VALUE=0123456789abcdef0123456789abcdef0123456789abcdef
|
||||
openssl() { printf '%s' "$OPENSSL_FAKE_VALUE"; }
|
||||
XUI_EXISTING_SUB_PATH=''
|
||||
choose_public_access <<< ''
|
||||
[[ "$XUI_PUBLIC_ACCESS" == no && "$XUI_WEB_LISTEN" == 127.0.0.1 ]]
|
||||
choose_subscription_access <<< ''
|
||||
[[ "$XUI_SUBSCRIPTION_ACCESS" == private && "$XUI_SUB_LISTEN" == 127.0.0.1 ]]
|
||||
choose_subscription_access <<< '2'
|
||||
[[ "$XUI_SUBSCRIPTION_ACCESS" == disabled ]]
|
||||
choose_subscription_access <<< '3'
|
||||
[[ "$XUI_SUBSCRIPTION_ACCESS" == public ]]
|
||||
[[ "$XUI_SUB_LISTEN" == '' ]]
|
||||
[[ "$XUI_SUB_PATH" =~ ^/[0-9a-f]{48}/$ ]]
|
||||
|
||||
subscription_db_probe="$(mktemp)"
|
||||
SQLITE_TEST_SUB_PATH='/operator-custom/path/'
|
||||
sqlite3() { printf '%s' "$SQLITE_TEST_SUB_PATH"; }
|
||||
load_existing_subscription_path "$subscription_db_probe"
|
||||
[[ "$XUI_EXISTING_SUB_PATH" == '/operator-custom/path/' ]]
|
||||
choose_subscription_access <<< '1'
|
||||
[[ "$XUI_SUB_PATH" == "$XUI_EXISTING_SUB_PATH" ]]
|
||||
choose_subscription_access <<< '2'
|
||||
[[ "$XUI_SUB_PATH" == "$XUI_EXISTING_SUB_PATH" ]]
|
||||
|
||||
XUI_EXISTING_SUB_PATH='/0123456789abcdef0123456789abcdef0123456789abcdef/'
|
||||
choose_subscription_access <<< $'3\n'
|
||||
[[ "$XUI_SUBSCRIPTION_ACCESS" == public && "$XUI_SUB_PATH" == "$XUI_EXISTING_SUB_PATH" ]]
|
||||
OPENSSL_FAKE_VALUE=abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdef
|
||||
choose_subscription_access <<< $'3\ny'
|
||||
[[ "$XUI_SUBSCRIPTION_ACCESS" == public && "$XUI_SUB_PATH" == '/abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdef/' ]]
|
||||
|
||||
XUI_EXISTING_SUB_PATH='/operator-custom/path/'
|
||||
choose_subscription_access <<< $'3\n1'
|
||||
[[ "$XUI_SUBSCRIPTION_ACCESS" == private && "$XUI_SUB_PATH" == "$XUI_EXISTING_SUB_PATH" ]]
|
||||
OPENSSL_FAKE_VALUE=abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdef
|
||||
choose_subscription_access <<< $'3\ny'
|
||||
[[ "$XUI_SUBSCRIPTION_ACCESS" == public && "$XUI_SUB_PATH" == '/abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdef/' ]]
|
||||
rm -f -- "$subscription_db_probe"
|
||||
XUI_EXISTING_SUB_PATH=''
|
||||
|
||||
choose_public_access <<< '1'
|
||||
choose_subscription_access <<< '1'
|
||||
set_xui_desired_listeners
|
||||
[[ "$XUI_DESIRED_WEB_LISTEN" == 127.0.0.1 ]]
|
||||
[[ "$XUI_DESIRED_SUB_LISTEN" == 127.0.0.1 ]]
|
||||
[[ "$XUI_DESIRED_SUB_ENABLED" == true ]]
|
||||
choose_public_access <<< '2'
|
||||
choose_subscription_access <<< '3'
|
||||
set_xui_desired_listeners
|
||||
[[ "$XUI_DESIRED_WEB_LISTEN" == '' ]]
|
||||
[[ "$XUI_DESIRED_SUB_LISTEN" == '' ]]
|
||||
[[ "$XUI_DESIRED_SUB_ENABLED" == true ]]
|
||||
choose_subscription_access <<< '2'
|
||||
set_xui_desired_listeners
|
||||
[[ "$XUI_DESIRED_SUB_LISTEN" == 127.0.0.1 ]]
|
||||
[[ "$XUI_DESIRED_SUB_ENABLED" == false ]]
|
||||
|
||||
ss() {
|
||||
case "${SS_MODE:-private}" in
|
||||
private) printf 'LISTEN 0 4096 127.0.0.1:%s 0.0.0.0:*\n' "$SS_PORT" ;;
|
||||
delayed)
|
||||
local calls
|
||||
calls="$(cat "$SS_CALLS_FILE")"
|
||||
calls=$((calls + 1))
|
||||
printf '%s\n' "$calls" > "$SS_CALLS_FILE"
|
||||
((calls > 2)) && printf 'LISTEN 0 4096 127.0.0.1:%s 0.0.0.0:*\n' "$SS_PORT"
|
||||
;;
|
||||
wildcard) printf 'LISTEN 0 4096 0.0.0.0:%s 0.0.0.0:*\n' "$SS_PORT" ;;
|
||||
ipv6) printf 'LISTEN 0 4096 [::]:%s [::]:*\n' "$SS_PORT" ;;
|
||||
esac
|
||||
}
|
||||
SS_PORT=2053
|
||||
SS_MODE=delayed
|
||||
SS_CALLS_FILE="$(mktemp)"
|
||||
printf '0\n' > "$SS_CALLS_FILE"
|
||||
sleep() { :; }
|
||||
wait_for_tcp_listener 2053 panel
|
||||
[[ "$(cat "$SS_CALLS_FILE")" -eq 3 ]]
|
||||
verify_private_listener 2053 panel
|
||||
rm -f -- "$SS_CALLS_FILE"
|
||||
SS_MODE=private
|
||||
for SS_MODE in wildcard ipv6; do
|
||||
if (verify_private_listener 2053 panel) >/dev/null 2>&1; then
|
||||
printf 'Private listener check accepted %s bind\n' "$SS_MODE" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
guard_test_dir="$(mktemp -d)"
|
||||
trap 'rm -rf -- "$guard_test_dir"' EXIT
|
||||
cat > "$guard_test_dir/fake-x-ui" <<'FAKE_XUI'
|
||||
#!/usr/bin/env bash
|
||||
printf 'bind:%s\n' "$*" >> "$GUARD_LOG"
|
||||
[[ "${XUI_FAKE_FAIL:-no}" != yes ]]
|
||||
FAKE_XUI
|
||||
cat > "$guard_test_dir/real-systemctl" <<'REAL_SYSTEMCTL'
|
||||
#!/usr/bin/env bash
|
||||
printf 'systemctl:%s\n' "$*" >> "$GUARD_LOG"
|
||||
REAL_SYSTEMCTL
|
||||
cat > "$guard_test_dir/real-rc-service" <<'REAL_RC_SERVICE'
|
||||
#!/usr/bin/env bash
|
||||
printf 'rc-service:%s\n' "$*" >> "$GUARD_LOG"
|
||||
REAL_RC_SERVICE
|
||||
cat > "$guard_test_dir/fake-sqlite3" <<'FAKE_SQLITE3'
|
||||
#!/usr/bin/env bash
|
||||
if [[ "$#" == 1 ]]; then
|
||||
cat >/dev/null
|
||||
printf 'sqlite-write\n' >> "$GUARD_LOG"
|
||||
[[ "${SQLITE_FAKE_FAIL:-no}" != yes ]]
|
||||
else
|
||||
printf 'sqlite-query\n' >> "$GUARD_LOG"
|
||||
printf '127.0.0.1|false\n'
|
||||
fi
|
||||
FAKE_SQLITE3
|
||||
touch "$guard_test_dir/fake.db"
|
||||
chmod 700 "$guard_test_dir/fake-x-ui" "$guard_test_dir/real-systemctl" \
|
||||
"$guard_test_dir/real-rc-service" "$guard_test_dir/fake-sqlite3"
|
||||
export GUARD_LOG="$guard_test_dir/calls.log"
|
||||
create_xui_start_guard "$guard_test_dir/guard" "$guard_test_dir/released" \
|
||||
"$guard_test_dir/fake-x-ui" "$guard_test_dir/fake.db" \
|
||||
"$guard_test_dir/real-systemctl" "$guard_test_dir/real-rc-service" "$guard_test_dir/fake-sqlite3"
|
||||
export PATH="${XUI_GUARD_DIR}:${PATH}"
|
||||
systemctl start x-ui.service
|
||||
[[ "$(sed -n '1p' "$GUARD_LOG")" == 'bind:setting -listenIP 127.0.0.1' ]]
|
||||
[[ "$(sed -n '2p' "$GUARD_LOG")" == 'sqlite-write' ]]
|
||||
[[ "$(tail -n1 "$GUARD_LOG")" == 'systemctl:start x-ui.service' ]]
|
||||
: > "$GUARD_LOG"
|
||||
export XUI_FAKE_FAIL=yes
|
||||
if systemctl start x-ui; then
|
||||
printf 'Guard allowed an x-ui start after bind failure\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
! grep -q '^systemctl:' "$GUARD_LOG"
|
||||
unset XUI_FAKE_FAIL
|
||||
: > "$GUARD_LOG"
|
||||
export SQLITE_FAKE_FAIL=yes
|
||||
if systemctl start x-ui; then
|
||||
printf 'Guard allowed an x-ui start after safe subscription settings failed\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
! grep -q '^systemctl:' "$GUARD_LOG"
|
||||
: > "$GUARD_LOG"
|
||||
unset SQLITE_FAKE_FAIL
|
||||
: > "$GUARD_LOG"
|
||||
touch "$guard_test_dir/released"
|
||||
systemctl start x-ui
|
||||
[[ "$(wc -l < "$GUARD_LOG")" -eq 1 ]]
|
||||
[[ "$(sed -n '1p' "$GUARD_LOG")" == 'systemctl:start x-ui' ]]
|
||||
PANEL_TEST
|
||||
|
||||
bash <<'STEP3_TEST'
|
||||
set -Eeuo pipefail
|
||||
source <(awk '/^\[\[ "\$EUID"/{exit} {print}' ochenstarik-server-user-3.sh)
|
||||
tmp_dir="$(mktemp -d)"
|
||||
trap 'rm -rf -- "$tmp_dir"' EXIT
|
||||
MANAGE_UFW=yes
|
||||
SSH_PORT=20202
|
||||
UFW_TEST_STATUS='Status: active'
|
||||
ufw() {
|
||||
if [[ "${1:-}" == status ]]; then
|
||||
printf '%s\n' "$UFW_TEST_STATUS"
|
||||
return 0
|
||||
fi
|
||||
printf '%s\n' "$*" >> "$tmp_dir/ufw-calls"
|
||||
return 0
|
||||
}
|
||||
require_command() { :; }
|
||||
chown() { :; }
|
||||
printf '22/tcp\n20202/tcp\n443/tcp\n' > "$tmp_dir/managed"
|
||||
retire_temporary_ssh_port_22 "$tmp_dir/managed" <<< 'n'
|
||||
grep -Fqx '22/tcp' "$tmp_dir/managed"
|
||||
retire_temporary_ssh_port_22 "$tmp_dir/managed" </dev/null
|
||||
grep -Fqx '22/tcp' "$tmp_dir/managed"
|
||||
retire_temporary_ssh_port_22 "$tmp_dir/managed" <<< 'y'
|
||||
! grep -Fqx '22/tcp' "$tmp_dir/managed"
|
||||
grep -Fqx '20202/tcp' "$tmp_dir/managed"
|
||||
grep -Fq 'from 0.0.0.0/0 to any port 22 proto tcp' "$tmp_dir/ufw-calls"
|
||||
grep -Fq 'from ::/0 to any port 22 proto tcp' "$tmp_dir/ufw-calls"
|
||||
printf '22/tcp\n' > "$tmp_dir/managed"
|
||||
MANAGE_UFW=no
|
||||
retire_temporary_ssh_port_22 "$tmp_dir/managed" <<< 'y'
|
||||
grep -Fqx '22/tcp' "$tmp_dir/managed"
|
||||
MANAGE_UFW=yes
|
||||
SSH_PORT=22
|
||||
retire_temporary_ssh_port_22 "$tmp_dir/managed" <<< 'y'
|
||||
grep -Fqx '22/tcp' "$tmp_dir/managed"
|
||||
UFW_TEST_STATUS=$'Status: active\nTo Action From\n22/tcp ALLOW IN Anywhere'
|
||||
SSH_PORT=20202
|
||||
if (retire_temporary_ssh_port_22 "$tmp_dir/managed" <<< 'y') >/dev/null 2>&1; then
|
||||
printf 'Step 3 reported success while UFW still allows port 22\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -Fqx '22/tcp' "$tmp_dir/managed"
|
||||
UFW_TEST_STATUS=$'Status: active\nTo Action From\n22/tcp (v6) ALLOW IN Anywhere (v6)'
|
||||
if (retire_temporary_ssh_port_22 "$tmp_dir/managed" <<< 'y') >/dev/null 2>&1; then
|
||||
printf 'Step 3 reported success while UFW still allows IPv6 port 22\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -Fqx '22/tcp' "$tmp_dir/managed"
|
||||
STEP3_TEST
|
||||
|
||||
if grep -q 'allow_ufw_rule "63636/tcp"' ochenstarik-server-user-3.sh; then
|
||||
printf 'Step 3 must not open an unsolicited 63636/tcp rule\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -q 'allow_ufw_port 80 ' ochenstarik-server-panel-warp-6.sh; then
|
||||
printf 'Step 6 must not open an unsolicited 80/tcp rule\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -q 'XUI_PUBLIC_ACCESS=no' ochenstarik-server-panel-warp-6.sh
|
||||
grep -q 'Приватный режим' ochenstarik-server-panel-warp-6.sh
|
||||
grep -q 'WARP_ANSWER=no' ochenstarik-server-panel-warp-6.sh
|
||||
|
||||
printf '[+] Firewall and panel security behavior passed\n'
|
||||
119
tests/test-notification-backup-security.sh
Executable file
119
tests/test-notification-backup-security.sh
Executable file
|
|
@ -0,0 +1,119 @@
|
|||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.."
|
||||
tmp="$(mktemp -d)"
|
||||
trap 'rm -rf -- "$tmp"' EXIT
|
||||
mkdir -p "$tmp/bin"
|
||||
|
||||
# Extract and run the exact hook template without writing to PAM or contacting
|
||||
# Telegram. The stubs record invocation and deliberately make curl slow.
|
||||
awk '/^cat > "\$NOTIFY_SCRIPT" <<.HOOK/ { copying=1; next }
|
||||
copying && /^HOOK$/ { exit }
|
||||
copying { print }' ochenstarik-server-tg-4.sh > "$tmp/notify-hook"
|
||||
[[ -s "$tmp/notify-hook" ]]
|
||||
# Relocate only the extracted fixture; production PAM paths are fixed and do
|
||||
# not trust session-provided test overrides.
|
||||
sed -i "s|/etc/ochenstarik-server/telegram.conf|$tmp/config|g; s|/var/log/ochenstarik-ssh-login-telegram.log|$tmp/notify.log|g; s|/usr/bin/setsid|$tmp/bin/setsid|g" "$tmp/notify-hook"
|
||||
bash -n "$tmp/notify-hook"
|
||||
chmod 700 "$tmp/notify-hook"
|
||||
cat > "$tmp/config" <<'CONFIG'
|
||||
TG_BOT_TOKEN=123456:secret-token-marker
|
||||
TG_CHAT_ID=12345
|
||||
SERVER_IP=192.0.2.1
|
||||
CONFIG
|
||||
chmod 600 "$tmp/config"
|
||||
|
||||
cat > "$tmp/bin/setsid" <<'SETSID_STUB'
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
printf 'called\n' >> "$SETSID_LOG"
|
||||
exec /usr/bin/setsid "$@"
|
||||
SETSID_STUB
|
||||
cat > "$tmp/bin/curl" <<'CURL_STUB'
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
printf '%s\n' "$*" >> "$CURL_ARGS_LOG"
|
||||
cat > "$CURL_CONFIG_LOG"
|
||||
touch "$CURL_STARTED"
|
||||
sleep 1
|
||||
touch "$CURL_DONE"
|
||||
printf '200\n' >&2
|
||||
exit 0
|
||||
CURL_STUB
|
||||
chmod +x "$tmp/bin/setsid" "$tmp/bin/curl"
|
||||
|
||||
export PATH="$tmp/bin:$PATH"
|
||||
export SETSID_LOG="$tmp/setsid.log" CURL_ARGS_LOG="$tmp/curl-args"
|
||||
export CURL_CONFIG_LOG="$tmp/curl-config" CURL_STARTED="$tmp/curl-started"
|
||||
export CURL_DONE="$tmp/curl-done"
|
||||
export PAM_USER=test-user PAM_RHOST=192.0.2.7 PAM_SERVICE=sshd PAM_TTY=pts/1
|
||||
|
||||
PAM_TYPE=close_session "$tmp/notify-hook"
|
||||
[[ ! -e "$SETSID_LOG" && ! -e "$CURL_STARTED" ]]
|
||||
|
||||
started_ns="$(date +%s%N)"
|
||||
PAM_TYPE=open_session "$tmp/notify-hook"
|
||||
elapsed_ms=$(( ($(date +%s%N) - started_ns) / 1000000 ))
|
||||
((elapsed_ms < 500)) || { printf 'PAM hook waited %s ms for background work.\n' "$elapsed_ms" >&2; exit 1; }
|
||||
|
||||
for _ in {1..60}; do
|
||||
[[ -e "$CURL_STARTED" ]] && break
|
||||
sleep 0.05
|
||||
done
|
||||
[[ -e "$CURL_STARTED" ]]
|
||||
[[ "$(wc -l < "$SETSID_LOG")" == 1 ]]
|
||||
! grep -Fq 'secret-token-marker' "$CURL_ARGS_LOG"
|
||||
grep -Fq 'url = "https://api.telegram.org/bot123456:secret-token-marker/sendMessage"' "$CURL_CONFIG_LOG"
|
||||
|
||||
# Verify the generated backup runner's opt-in encryption and strict offsite
|
||||
# transport contract without invoking it against the host filesystem.
|
||||
awk '/^ cat > "\$BACKUP_RUNNER" <<.BACKUP_RUNNER_EOF/ { copying=1; next }
|
||||
copying && /^BACKUP_RUNNER_EOF$/ { exit }
|
||||
copying { print }' ochenstarik-server-backup-7.sh > "$tmp/backup-runner"
|
||||
bash -n "$tmp/backup-runner"
|
||||
grep -Fq 'BACKUP_ENCRYPTION=none' "$tmp/backup-runner"
|
||||
grep -Fq 'age --encrypt --recipient "$AGE_RECIPIENT" --output "$temporary"' "$tmp/backup-runner"
|
||||
grep -Fq 'StrictHostKeyChecking=yes' "$tmp/backup-runner"
|
||||
grep -Fq 'BatchMode=yes' "$tmp/backup-runner"
|
||||
grep -Fq 'UserKnownHostsFile=$OFFSITE_KNOWN_HOSTS' "$tmp/backup-runner"
|
||||
grep -Fq 'Выгрузка на заданный SSH-хост не удалась' "$tmp/backup-runner"
|
||||
if grep -Fq 'ssh-keyscan' ochenstarik-server-backup-7.sh; then
|
||||
printf 'Backup setup must not trust an unverified ssh-keyscan result.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
awk '/^upload_offsite\(\)/ { copying=1 }
|
||||
copying { print }
|
||||
copying && /^}/ { exit }' "$tmp/backup-runner" > "$tmp/upload-function"
|
||||
source "$tmp/upload-function"
|
||||
log() { :; }
|
||||
die() { printf '%s\n' "$*" >&2; exit 1; }
|
||||
cat > "$tmp/bin/scp" <<'SCP_STUB'
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
printf '%s\n' "$*" >> "$SCP_ARGS_LOG"
|
||||
exit "${SCP_EXIT_CODE:-0}"
|
||||
SCP_STUB
|
||||
chmod +x "$tmp/bin/scp"
|
||||
export SCP_ARGS_LOG="$tmp/scp-args"
|
||||
OFFSITE_TARGET='backup@example.test'
|
||||
OFFSITE_PATH='/srv/backups'
|
||||
OFFSITE_KNOWN_HOSTS="$tmp/known_hosts"
|
||||
touch "$OFFSITE_KNOWN_HOSTS" "$tmp/archive.tar.zst.age" "$tmp/archive.sha256"
|
||||
SCP_EXIT_CODE=0 upload_offsite "$tmp/archive.tar.zst.age" "$tmp/archive.sha256"
|
||||
grep -Fq -- '-o BatchMode=yes' "$SCP_ARGS_LOG"
|
||||
grep -Fq -- '-o StrictHostKeyChecking=yes' "$SCP_ARGS_LOG"
|
||||
grep -Fq -- "-o UserKnownHostsFile=$OFFSITE_KNOWN_HOSTS" "$SCP_ARGS_LOG"
|
||||
if (SCP_EXIT_CODE=42 upload_offsite "$tmp/archive.tar.zst.age" "$tmp/archive.sha256") \
|
||||
>/dev/null 2>&1; then
|
||||
printf 'Offsite upload failure was accepted as success.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
for _ in {1..60}; do
|
||||
[[ -e "$CURL_DONE" ]] && break
|
||||
sleep 0.05
|
||||
done
|
||||
[[ -e "$CURL_DONE" ]]
|
||||
|
||||
printf 'Notification and backup security tests passed.\n'
|
||||
Loading…
Reference in a new issue