diff --git a/.github/workflows/luci.yml b/.github/workflows/luci.yml new file mode 100644 index 0000000..51e55b7 --- /dev/null +++ b/.github/workflows/luci.yml @@ -0,0 +1,42 @@ +name: LuCI Studio +on: + push: + paths: ['luci/**', '.github/workflows/luci.yml'] + pull_request: + paths: ['luci/**', '.github/workflows/luci.yml'] + workflow_dispatch: +permissions: + contents: read +jobs: + test-and-package: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Install browser test dependencies + run: | + python -m pip install playwright==1.55.0 + python -m playwright install --with-deps chromium + - name: Check syntax and package metadata + run: | + for file in $(find luci -name '*.js'); do node --check "$file"; done + for file in $(find luci -type f -path '*/root/*' ! -name '*.json' ! -path '*/etc/config/*'); do sh -n "$file"; done + python -c 'import json,pathlib; [json.loads(p.read_text()) for p in pathlib.Path("luci").rglob("*.json")]' + - name: Check backend, watchdog and browser contracts + run: | + python luci/tests/backend.py + python luci/tests/sentinel.py + python luci/tests/theme_restore.py + python luci/tests/browser.py + - name: Build candidate IPK and source archive + run: python luci/scripts/build.py + - uses: actions/upload-artifact@v4 + with: + name: OpenWrtStudio-LuCI-v1.0.0-rc1 + path: | + luci/dist/* + luci/artifacts/*.png + luci/README.md + luci/RELEASE_NOTES.md diff --git a/luci/.gitignore b/luci/.gitignore new file mode 100644 index 0000000..46c6b32 --- /dev/null +++ b/luci/.gitignore @@ -0,0 +1,3 @@ +dist/ +artifacts/ +__pycache__/ diff --git a/luci/README.md b/luci/README.md new file mode 100644 index 0000000..c4fd84d --- /dev/null +++ b/luci/README.md @@ -0,0 +1,125 @@ +# OpenWrt Studio для LuCI — 1.0.0-rc1 + +Тема оформления роутера и приложение-компаньон. Работают непосредственно на OpenWrt через сессию LuCI: Windows, .NET, SSH-профили и внешний сервер для этого не нужны. + +Это **кандидат для проверки**, а не подтверждённый на оборудовании стабильный выпуск и не полный перенос настольного приложения. Оформление использует Bootstrap из LuCI OpenWrt 24.10, commit `0e6537981036ea221a73e497920192179e9f74c3`, Apache-2.0. Footstrap изучался как пример; его код не копировался. Код приложения Studio — MIT. + +## Что работает + +| Возможность Studio | Реализация LuCI | +| --- | --- | +| Обзор, память, загрузка, overlay, uptime | Собственные карточки, RPC-опрос каждые 25 секунд, очистка при ошибке связи | +| Отчёт для поддержки | Экспорт модели, прошивки и ресурсов; без профилей, адресов клиентов, hostname, паролей и ключей | +| Клиенты | IPv4 DHCP-аренды; статические адреса и ограничения — через штатные формы DHCP/Firewall | +| Карта VPN и сервисов | Наличие init-скрипта и статус служб, запуск/остановка/перезапуск после подтверждения | +| Диагностика | WAN, ICMP, локальный/публичный DNS, интерфейсы и маршруты; журналы и nftables — через штатные страницы | +| Sentinel | Ручная диагностика и отдельный фоновый демон: классификация локального DNS, WAN, внешней сети и остановленного прокси; восстановление DNS/выбранного ядра после повторных сбоев с паузой | +| Mihomo | HTTPS-подписка, два пресета, редактирование JSON/YAML, проверка ядром, резервная копия, применение | +| Wi-Fi, WAN/LAN, DHCP, проброс портов, hostname, пароль, backup | Штатные конфигураторы LuCI в оформлении Studio | +| WireGuard / AmneziaWG | Импорт .conf с DNS, несколькими peer, IPv6 endpoint и параметрами обфускации; отложенные изменения UCI и штатная форма интерфейсов | +| Sing-box | Генератор VLESS Reality, Hysteria 2 и TUIC с TUN; проверка ядром перед заменой файла | +| OpenVPN | Импорт клиентского .ovpn со встроенными сертификатами и ключами, создание секции UCI и запуск | +| Tailscale / ZeroTier | Auth key, Exit Node, анонс подсетей и принятие маршрутов Tailscale; вступление в сеть ZeroTier | +| PassWall 2 и другие службы | Запуск/остановка/перезапуск; настройка через дополнительно установленные LuCI-модули | +| Планировщик | Системный редактор cron и примеры расписаний | +| Пакеты / ForkOP | Системный каталог, предложение адреса ForkOP по архитектуре, штатный редактор источников | +| USB | Штатная страница точек монтирования при наличии `luci-app-mount` | + +Не перенесены: расширенные экраны узлов/задержек ForkOP, точное воспроизведение всех правил настольного Mihomo, конфигуратор Argon и собственный редактор USB. Эти настройки можно выполнять через дополнительные LuCI-модули. Трей Windows и SSH-профили не имеют прямого аналога в интерфейсе на самом роутере. Ссылки на необязательные LuCI-модули могут вести на отсутствующую страницу, пока модуль не установлен. Новость должна называть этот выпуск кандидатом и перечислять его фактические возможности. + +## VPN и Sentinel + +В разделе «Конструкторы VPN» импорт `.conf` не перезаписывает существующий интерфейс. После импорта проверьте зону брандмауэра и маршрутизацию в штатной форме LuCI и примените изменения с откатом. Требуется установленный протокол WireGuard/AmneziaWG; неизвестные параметры файла отклоняются. + +Для Sing-box используется `/etc/sing-box/config.json`, проверка `sing-box check -c`, резервная копия `config.json.studio-backup` и служба `/etc/init.d/sing-box`. Генератор создаёт современный TUN-конфиг; его совместимость проверяет установленное ядро. Настройка DNS и дополнительных правил остаётся доступной в редакторе JSON. + +OpenVPN сохраняется в `/etc/openvpn/studio.conf` и секции `openvpn.studio`. Поддерживаются клиентские профили со встроенным ключевым материалом. Внешние файлы, сценарии, плагины и неизвестные директивы отклоняются. Эта проверка не подтверждает доступность VPN-сервера. Профили с логином/паролем и отдельными сертификатами настраивайте в `luci-app-openvpn`. + +Tailscale передаёт ключ через временный файл с правами 0600 и удаляет его после команды. Анонсы подсетей/Exit Node требуют разрешения администратора tailnet. Участник ZeroTier должен быть разрешён в контроллере сети. + +Sentinel **выключен по умолчанию**. Настройки хранятся в `/etc/config/studio`, журнал доступен через `logread -e studio-sentinel`. Он проверяет ICMP и DNS каждые 60 секунд, по умолчанию ждёт 3 последовательных сбоя и 300 секунд между восстановлениями. Сбой WAN/внешней сети сам по себе не вызывает перезапуск всей сети. Автовосстановление ограничено dnsmasq и явно выбранным ядром прокси. + +Горячие клавиши темы: Ctrl+1 — обзор, Ctrl+2 — клиенты, Ctrl+3 — диагностика, Ctrl+, — инструменты. Кнопка ◐ переключает светлую/тёмную палитру и запоминает выбор в браузере. + +## Установка на OpenWrt 24.10 с opkg + +Пакеты не устанавливались на реальный роутер в рамках этой работы. Сначала проверьте на тестовом устройстве с резервной копией и доступом по SSH. IPK рассчитаны на современные ucode-шаблоны LuCI OpenWrt 24.10. Для старого Lua LuCI и apk-прошивок эти IPK не предназначены. + +Скопируйте оба IPK и SHA256SUMS.txt из `dist/` на роутер, затем: + +```sh +cd /tmp/studio-release +sha256sum -c SHA256SUMS.txt --ignore-missing +opkg update +opkg install ./luci-theme-studio_1.0.0~rc1-1_all.ipk ./luci-app-studio_1.0.0~rc1-1_all.ipk +/etc/init.d/rpcd restart +``` + +Если BusyBox `sha256sum` не поддерживает `--ignore-missing`, сравните `sha256sum *.ipk` с соответствующими строками SHA256SUMS.txt. + +Откройте **Система → Система → Язык и стиль**, выберите **OpenWrtStudio**, примените изменения и обновите страницу браузера. Установка регистрирует тему, но не переключает её принудительно. Раздел **OpenWrt Studio** появляется в основном меню. При необходимости: + +```sh +uci set luci.main.mediaurlbase='/luci-static/studio' +uci commit luci +``` + +Тема поддерживает светлую и тёмную палитры по настройке браузера/ОС. Приложение работает и с другой темой, но карточки Studio оформляются при выборе темы Studio. + +## Mihomo + +Поддерживается служба `/etc/init.d/mihomo`, использующая `/etc/mihomo/config.yaml`. Для OpenClash и других схем запуска автоматическое применение не предназначено. Нужны установленное ядро Mihomo, поддержка TUN и геобазы для соответствующих правил. Подписка должна возвращать узлы в формате Mihomo, не base64-список URI. + +Перед заменой конфигурация проверяется `mihomo -t`; предыдущий файл копируется в `/etc/mihomo/config.yaml.studio-backup`. Файлы с подпиской создаются с правами 0600. Пресет «RU напрямую» отправляет остальной внешний трафик в прокси и не обещает точный список заблокированных сайтов. Генератор не публикует контроллер Mihomo в LAN. + +Восстановление прежнего файла по SSH: + +```sh +cp /etc/mihomo/config.yaml.studio-backup /etc/mihomo/config.yaml +/etc/init.d/mihomo restart +``` + +## Права + +Чтение ресурсов разрешено ACL `luci-app-studio`. Запись отделена от чтения: только перечисленные действия над перечисленными сервисами, сохранение VPN в фиксированные пути и подключение mesh VPN. Импорт WireGuard требует права чтения/записи UCI network; настройки Sentinel — UCI studio. RPC не принимает произвольные команды или пути. Стандартные формы настроек используют собственные ACL LuCI. Наличие init-скрипта показывает наличие службы, а не полную совместимость протокола с прошивкой. + +## Сборка и проверка + +```sh +python3 luci/scripts/build.py +python3 luci/tests/backend.py +python3 luci/tests/sentinel.py +python3 luci/tests/theme_restore.py +python3 luci/tests/browser.py +``` + +Сборка IPK использует Python 3 без сторонних библиотек. Тест обработчика требует `jq`; браузерный тест требует Python Playwright и Chromium в `/usr/bin/chromium`. Тесты используют фиктивные RPC-ответы и изолированные заглушки команд OpenWrt. Они не проверяют реальный rpcd/jshn, установку opkg, ucode-рендеринг и маршрутизацию на устройстве. + +Для сборки штатными средствами OpenWrt SDK с установленным feeds/luci скопируйте `luci-theme-studio` и `luci-app-studio` в каталог `package/`, затем: + +```sh +make defconfig +make package/luci-theme-studio/compile V=s +make package/luci-app-studio/compile V=s +``` + +## Проверка на роутере с возвратом прежней темы + +После копирования IPK на тестовый OpenWrt 24.10 можно выполнить: + +```sh +sh smoke-router.sh /tmp/luci-theme-studio_1.0.0~rc1-1_all.ipk /tmp/luci-app-studio_1.0.0~rc1-1_all.ipk +``` + +Сценарий `scripts/smoke-router.sh` проверяет отсутствие отложенных настроек LuCI, установку, регистрацию RPC и рендеринг страницы входа с темой Studio. Обработчик завершения восстанавливает и проверяет прежнее значение темы, включая случай ошибки. Пакеты остаются установленными. Сценарий не меняет VPN, WAN, Wi-Fi или firewall; он пока не выполнялся на реальном оборудовании. + +## Удаление и возврат темы + +```sh +uci set luci.main.mediaurlbase='/luci-static/bootstrap' +uci commit luci +opkg remove luci-app-studio luci-theme-studio +/etc/init.d/rpcd restart +``` + +Удаление темы также переключает выбранную Studio на Bootstrap. Сохранённые конфигурации роутера и Mihomo при удалении не очищаются. diff --git a/luci/RELEASE_NOTES.md b/luci/RELEASE_NOTES.md new file mode 100644 index 0000000..84ff3a0 --- /dev/null +++ b/luci/RELEASE_NOTES.md @@ -0,0 +1,20 @@ +# OpenWrt Studio LuCI v1.0.0-rc1 + +Первый кандидат темы и приложения OpenWrt Studio для веб-интерфейса роутера. Это отдельный комплект для LuCI, а не обновление Windows-приложения v2.5.19. + +- Светлая/тёмная тема с адаптивными карточками и горячими клавишами. +- Обзор загрузки, RAM, overlay, времени работы и DHCP-клиентов; отчёт для поддержки без адресов клиентов, паролей и ключей. +- Карта возможностей, управление установленными службами и диагностика WAN/DNS/маршрутов. +- Импорт WireGuard/AmneziaWG: несколько peer, DNS, IPv6 endpoint и параметры обфускации. Изменения проверяются и применяются через LuCI. +- Конструктор Mihomo с подпиской и двумя пресетами; конфигурация проверяется ядром и сохраняется с резервной копией. +- Генератор Sing-box для VLESS Reality, Hysteria 2 и TUIC с проверкой ядром. +- Импорт клиентского OpenVPN со встроенными сертификатами; сценарии и плагины из импортируемого профиля не выполняются. +- Подключение Tailscale (Auth key, Exit Node, подсети) и ZeroTier. +- Фоновый Sentinel с повторными проверками и паузой между восстановлениями. По умолчанию выключен. +- Доступ к штатным настройкам Wi-Fi, WAN/LAN, DHCP, брандмауэра, cron, пакетов, резервного копирования и USB; предложение источника ForkOP по архитектуре. + +IPK предназначены для проверки на OpenWrt 24.10 с современным LuCI и opkg. Для apk-прошивок используйте исходники и SDK своей версии. Пакеты протоколов и ядра VPN устанавливаются отдельно. + +Прошли проверки синтаксиса, браузерные сценарии на фиктивных RPC-ответах, изолированные проверки RPC-обработчика и Sentinel, сборка и проверка структуры IPK. На реальном роутере установка, работа ucode-шаблонов, протоколов и маршрутизации пока не проверены. Полная идентичность всех функций настольной программы не заявляется. Подробности установки и ограничения — в README комплекта. + +Файлы: `luci-theme-studio_1.0.0~rc1-1_all.ipk`, `luci-app-studio_1.0.0~rc1-1_all.ipk`, архив исходников и SHA256SUMS.txt. diff --git a/luci/announcements/4pda.bbcode b/luci/announcements/4pda.bbcode new file mode 100644 index 0000000..81d1c52 --- /dev/null +++ b/luci/announcements/4pda.bbcode @@ -0,0 +1,21 @@ +[b]OpenWrt Studio для LuCI — v1.0.0-rc1[/b] + +На предложение перенести интерфейс прямо в OpenWrt: вызов принят. Подготовлен комплект из темы и LuCI-приложения, работающего непосредственно на роутере без Windows/.NET. + +[spoiler="Возможности"] +— Светлая/тёмная тема, адаптивная раскладка и горячие клавиши. +— Обзор ресурсов и DHCP-клиентов, отчёт для поддержки без адресов клиентов и секретов. +— Карта возможностей и управление установленными службами. +— Импорт WireGuard/AmneziaWG (.conf, несколько peer, DNS, IPv6 endpoint, параметры обфускации). +— Конструктор Mihomo с подпиской и проверкой ядром перед применением. +— Генератор Sing-box: VLESS Reality / Hysteria 2 / TUIC. +— Импорт клиентского OpenVPN со встроенными сертификатами. +— Tailscale: Auth key, Exit Node, подсети; подключение ZeroTier. +— Диагностика WAN/DNS/маршрутов и фоновый Sentinel с повторными проверками и паузой между восстановлениями. +— Штатные настройки Wi-Fi, WAN/LAN, DHCP, firewall, cron, пакетов, резервного копирования и USB в оформлении Studio. +[/spoiler] + +[b]Статус:[/b] кандидат для тестирования на OpenWrt 24.10 с opkg. Браузерные проверки на фиктивных данных, изолированные проверки обработчика и Sentinel, сборка IPK прошли. На реальном роутере комплект пока не проверен. Ядра и модули протоколов устанавливаются отдельно. Sentinel выключен по умолчанию. Это не полный перенос всех экранов Windows-программы. + +[url=https://github.com/RichkovGit/OpenWrtStudio/tree/codex/luci-studio/luci]Исходники, установка и ограничения[/url] +Сборочные IPK доступны в артефактах workflow LuCI Studio после успешной сборки. Перед установкой сделайте резервную копию настроек. diff --git a/luci/announcements/telegram.txt b/luci/announcements/telegram.txt new file mode 100644 index 0000000..5a00e97 --- /dev/null +++ b/luci/announcements/telegram.txt @@ -0,0 +1,18 @@ +«А перенести OpenWrt Studio прямо в LuCI?» — вызов принят 🙂 + +Подготовлен OpenWrt Studio LuCI v1.0.0-rc1: тема оформления и приложение, которые работают прямо на роутере, без Windows и .NET. + +В комплекте: +• светлый/тёмный интерфейс и мобильная раскладка; +• обзор ресурсов, DHCP-клиенты и отчёт для поддержки; +• импорт WireGuard/AmneziaWG, конструкторы Mihomo и Sing-box, импорт OpenVPN; +• подключение Tailscale/ZeroTier; +• диагностика и фоновый Sentinel с восстановлением DNS/прокси; +• штатные настройки Wi-Fi, сети, DHCP, брандмауэра, cron и пакетов в оформлении Studio. + +Это кандидат для тестирования на OpenWrt 24.10 с opkg. Проверки на заглушках прошли; установка и маршрутизация на реальном роутере ещё требуют проверки. Ядра VPN устанавливаются отдельно, Sentinel по умолчанию выключен. Полное совпадение всех возможностей Windows-программы пока не обещаем. + +Исходники, инструкция и ограничения: +https://github.com/RichkovGit/OpenWrtStudio/tree/codex/luci-studio/luci + +Сборочные пакеты — в артефактах workflow LuCI Studio после успешного завершения сборки. Перед установкой сохраните резервную копию настроек. diff --git a/luci/luci-app-studio/LICENSE b/luci/luci-app-studio/LICENSE new file mode 100644 index 0000000..9d6b1d5 --- /dev/null +++ b/luci/luci-app-studio/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 OpenWrt Studio Team + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/luci/luci-app-studio/Makefile b/luci/luci-app-studio/Makefile new file mode 100644 index 0000000..1785748 --- /dev/null +++ b/luci/luci-app-studio/Makefile @@ -0,0 +1,11 @@ +include $(TOPDIR)/rules.mk +PKG_NAME:=luci-app-studio +PKG_VERSION:=1.0.0~rc1 +PKG_RELEASE:=1 +LUCI_TITLE:=OpenWrt Studio router management +LUCI_DEPENDS:=+luci-base +rpcd +rpcd-mod-file +rpcd-mod-iwinfo +luci-mod-admin-full +jshn +luci-app-firewall +luci-app-opkg +PKG_LICENSE:=MIT +define Package/luci-app-studio/conffiles +/etc/config/studio +endef +include $(TOPDIR)/feeds/luci/luci.mk diff --git a/luci/luci-app-studio/htdocs/luci-static/resources/view/studio/capabilities.js b/luci/luci-app-studio/htdocs/luci-static/resources/view/studio/capabilities.js new file mode 100644 index 0000000..807ec6d --- /dev/null +++ b/luci/luci-app-studio/htdocs/luci-static/resources/view/studio/capabilities.js @@ -0,0 +1,147 @@ +"use strict"; +"require view"; +"require rpc"; +"require ui"; +var status = rpc.declare({ object: "studio", method: "status", expect: {} }); +var action = rpc.declare({ + object: "studio", + method: "action", + params: ["name", "service"], + expect: {}, +}); +var routes = { + "studio-sentinel": "admin/studio/sentinel", + mihomo: "admin/studio/mihomo", + "sing-box": "admin/services/sing-box", + xray: "admin/services/passwall2", + tailscale: "admin/services/tailscale", + zerotier: "admin/services/zerotier", + openvpn: "admin/services/openvpn", + smartdns: "admin/services/smartdns", + mosdns: "admin/services/mosdns", + passwall2: "admin/services/passwall2", + dnsmasq: "admin/network/dhcp", +}; +return view.extend({ + load: function () { + return status(); + }, + render: function (data) { + function run(service, name, button) { + ui.showModal("Подтверждение", [ + E( + "p", + {}, + name + + " · " + + service + + ". Действие может прервать соединение с роутером.", + ), + E("div", { class: "right" }, [ + E("button", { class: "cbi-button", click: ui.hideModal }, "Отмена"), + E( + "button", + { + class: "cbi-button cbi-button-positive", + click: function () { + ui.hideModal(); + button.disabled = true; + action(name, service) + .then(function (r) { + if (!r.ok) throw new Error(r.error || r.output); + ui.addNotification( + null, + E("p", {}, "Команда выполнена: " + service), + "info", + ); + }) + .catch(function (e) { + ui.addNotification(null, E("p", {}, e.message), "error"); + }) + .finally(function () { + button.disabled = false; + }); + }, + }, + "Выполнить", + ), + ]), + ]); + } + return E("div", {}, [ + E("h2", {}, "Карта возможностей"), + E( + "p", + {}, + "Состояние служб определяется на роутере. Для настройки протокола используется его LuCI-модуль; доступность зависит от установленных пакетов.", + ), + E("div", { class: "studio-links" }, [ + E( + "a", + { href: L.url("admin/network/network") }, + "WireGuard / AmneziaWG · интерфейсы", + ), + E("a", { href: L.url("admin/system/opkg") }, "Установить пакеты"), + ]), + E( + "div", + { class: "studio-grid" }, + (data.services || []).map(function (s) { + return E("div", { class: "studio-card" }, [ + E("h3", {}, s.name), + E( + "span", + { class: "studio-status" + (s.running ? "" : " studio-warn") }, + s.running + ? "Работает" + : s.installed + ? "Остановлен" + : "Не установлен", + ), + E( + "div", + { class: "studio-actions" }, + s.installed + ? ["start", "stop", "restart"].map(function (n) { + var button = E( + "button", + { + class: "cbi-button", + disabled: !L.hasViewPermission(), + click: function () { + run(s.name, n, button); + }, + }, + n, + ); + return button; + }) + : [ + E( + "a", + { + href: + L.url("admin/system/opkg") + + "?query=" + + encodeURIComponent(s.name), + }, + "Каталог пакетов", + ), + ], + ), + s.installed + ? E( + "a", + { href: L.url(routes[s.name]) }, + "Открыть конфигуратор (если установлен)", + ) + : "", + ]); + }), + ), + ]); + }, + handleSaveApply: null, + handleSave: null, + handleReset: null, +}); diff --git a/luci/luci-app-studio/htdocs/luci-static/resources/view/studio/diagnostics.js b/luci/luci-app-studio/htdocs/luci-static/resources/view/studio/diagnostics.js new file mode 100644 index 0000000..d1775c3 --- /dev/null +++ b/luci/luci-app-studio/htdocs/luci-static/resources/view/studio/diagnostics.js @@ -0,0 +1,115 @@ +"use strict"; +"require view"; +"require rpc"; +"require ui"; +var probe = rpc.declare({ object: "studio", method: "probe", expect: {} }); +var action = rpc.declare({ + object: "studio", + method: "action", + params: ["name", "service"], + expect: {}, +}); +return view.extend({ + render: function () { + var output = E( + "pre", + {}, + "Нажмите «Проверить». Проверка обращается к openwrt.org и 1.1.1.1 с роутера. Неудачный ping сам по себе не доказывает сбой провайдера.", + ); + var button = E( + "button", + { + class: "cbi-button cbi-button-positive", + click: function () { + button.disabled = true; + output.textContent = "Проверка…"; + return probe() + .then(function (r) { + output.textContent = r.output || r.error || "Нет данных"; + }) + .catch(function (e) { + output.textContent = e.message; + }) + .finally(function () { + button.disabled = false; + }); + }, + }, + "Проверить сеть", + ); + function recover(service) { + ui.showModal("Sentinel · восстановление", [ + E( + "p", + {}, + "Перезапустить " + + service + + "? Возможен кратковременный обрыв доступа.", + ), + E("div", { class: "right" }, [ + E("button", { class: "cbi-button", click: ui.hideModal }, "Отмена"), + E( + "button", + { + class: "cbi-button cbi-button-positive", + click: function () { + ui.hideModal(); + return action("restart", service) + .then(function (r) { + output.textContent = + (r.ok + ? "Команда выполнена. Повторите диагностику.\n" + : "Ошибка: ") + (r.error || r.output || ""); + }) + .catch(function (e) { + output.textContent = e.message; + }); + }, + }, + "Перезапустить", + ), + ]), + ]); + } + return E("div", {}, [ + E("h2", {}, "Диагностика · Sentinel"), + E( + "p", + {}, + "Проверка WAN, DNS, маршрутов и интерфейсов. Восстановление запускается вручную; автоматические изменения сети отключены.", + ), + E( + "div", + { class: "studio-actions" }, + [button].concat( + ["dnsmasq", "mihomo", "sing-box"].map(function (s) { + return E( + "button", + { + class: "cbi-button", + disabled: !L.hasViewPermission(), + click: function () { + recover(s); + }, + }, + "Перезапустить " + s, + ); + }), + ), + ), + E("div", { class: "studio-card" }, [output]), + E("div", { class: "studio-links" }, [ + E( + "a", + { href: L.url("admin/network/diagnostics") }, + "Ping / traceroute / nslookup", + ), + E("a", { href: L.url("admin/status/nftables") }, "Правила nftables"), + E("a", { href: L.url("admin/status/syslog") }, "Системный журнал"), + ]), + ]); + }, + handleSaveApply: null, + handleSave: null, + handleReset: null, +}); diff --git a/luci/luci-app-studio/htdocs/luci-static/resources/view/studio/mesh.js b/luci/luci-app-studio/htdocs/luci-static/resources/view/studio/mesh.js new file mode 100644 index 0000000..01dc740 --- /dev/null +++ b/luci/luci-app-studio/htdocs/luci-static/resources/view/studio/mesh.js @@ -0,0 +1,116 @@ +"use strict"; +"require view"; +"require rpc"; +"require ui"; +var configure = rpc.declare({ + object: "studio", + method: "mesh", + params: ["kind", "key", "routes", "exitnode", "accept"], + expect: {}, +}); +return view.extend({ + render: function () { + var auth = E("input", { + type: "password", + autocomplete: "off", + placeholder: "tskey-auth-…", + }), + routes = E("input", { placeholder: "192.168.1.0/24,10.0.0.0/24" }), + exitnode = E("input", { type: "checkbox" }), + accept = E("input", { type: "checkbox" }), + network = E("input", { placeholder: "0123456789abcdef" }), + output = E("pre"); + accept.checked = true; + function apply(kind) { + var key = (kind === "tailscale" ? auth : network).value.trim(); + if ( + kind === "tailscale" + ? !/^tskey-auth-[A-Za-z0-9_-]+$/.test(key) + : !/^[0-9a-f]{16}$/i.test(key) + ) { + output.textContent = "Проверьте ключ / ID сети"; + return; + } + ui.showModal("Подключить " + kind + "?", [ + E( + "p", + {}, + "Роутер подключится к указанной VPN-сети. Для Tailscale анонс маршрутов и Exit Node требуют одобрения в панели tailnet. Для ZeroTier подтвердите участника в контроллере сети.", + ), + E("button", { class: "cbi-button", click: ui.hideModal }, "Отмена"), + E( + "button", + { + class: "cbi-button cbi-button-positive", + click: function () { + ui.hideModal(); + output.textContent = "Подключение…"; + return configure( + kind, + key, + routes.value.trim(), + exitnode.checked, + accept.checked, + ) + .then(function (r) { + output.textContent = + (r.ok ? "Готово: " : "Ошибка: ") + + (r.error || r.output || ""); + if (r.ok) auth.value = ""; + }) + .catch(function (e) { + output.textContent = e.message; + }); + }, + }, + "Подключить", + ), + ]); + } + return E("div", {}, [ + E("h2", {}, "Mesh VPN"), + E("div", { class: "studio-grid" }, [ + E("div", { class: "studio-card" }, [ + E("h3", {}, "Tailscale"), + E("label", {}, "Auth key"), + auth, + E("label", {}, "Подсети для анонса"), + routes, + E("p", {}, [exitnode, " Анонсировать Exit Node"]), + E("p", {}, [accept, " Принимать маршруты tailnet"]), + E( + "button", + { + class: "cbi-button cbi-button-positive", + disabled: !L.hasViewPermission(), + click: function () { + apply("tailscale"); + }, + }, + "Подключить Tailscale", + ), + ]), + E("div", { class: "studio-card" }, [ + E("h3", {}, "ZeroTier"), + E("label", {}, "Network ID"), + network, + E( + "button", + { + class: "cbi-button cbi-button-positive", + disabled: !L.hasViewPermission(), + click: function () { + apply("zerotier"); + }, + }, + "Подключить ZeroTier", + ), + ]), + ]), + output, + ]); + }, + handleSaveApply: null, + handleSave: null, + handleReset: null, +}); diff --git a/luci/luci-app-studio/htdocs/luci-static/resources/view/studio/mihomo.js b/luci/luci-app-studio/htdocs/luci-static/resources/view/studio/mihomo.js new file mode 100644 index 0000000..73a9ba6 --- /dev/null +++ b/luci/luci-app-studio/htdocs/luci-static/resources/view/studio/mihomo.js @@ -0,0 +1,190 @@ +"use strict"; +"require view"; +"require rpc"; +"require ui"; +var deploy = rpc.declare({ + object: "studio", + method: "mihomo", + params: ["content"], + expect: {}, +}); +function config(url, mode) { + if (!/^https:\/\/[^\s]+$/.test(url)) + throw new Error("Нужна HTTPS-ссылка на подписку в формате Mihomo."); + return { + "mixed-port": 7890, + "allow-lan": false, + mode: "rule", + "log-level": "info", + tun: { + enable: true, + stack: "mixed", + "auto-route": true, + "auto-detect-interface": true, + "dns-hijack": ["any:53"], + }, + dns: { + enable: true, + listen: "127.0.0.1:1053", + "enhanced-mode": "fake-ip", + nameserver: ["https://1.1.1.1/dns-query"], + "fake-ip-filter": ["*.lan", "*.local"], + }, + "proxy-providers": { + subscription: { + type: "http", + url: url, + interval: 86400, + path: "./proxy_providers/studio.yaml", + "health-check": { + enable: true, + url: "https://www.gstatic.com/generate_204", + interval: 300, + }, + }, + }, + "proxy-groups": [ + { name: "PROXY", type: "select", use: ["subscription"] }, + { + name: "AUTO", + type: "url-test", + use: ["subscription"], + url: "https://www.gstatic.com/generate_204", + interval: 300, + }, + ], + rules: [ + "IP-CIDR,127.0.0.0/8,DIRECT", + "IP-CIDR,192.168.0.0/16,DIRECT", + "IP-CIDR,10.0.0.0/8,DIRECT", + "IP-CIDR,172.16.0.0/12,DIRECT", + ].concat( + mode === "smart" + ? [ + "GEOIP,RU,DIRECT", + "GEOSITE,youtube,PROXY", + "GEOSITE,discord,PROXY", + "MATCH,PROXY", + ] + : ["MATCH,PROXY"], + ), + }; +} +return view.extend({ + render: function () { + var subscription = E("input", { + type: "password", + class: "cbi-input-text", + placeholder: "https://…", + autocomplete: "off", + }); + var preset = E("select", { class: "cbi-input-select" }, [ + E( + "option", + { value: "smart" }, + "RU напрямую, внешний трафик через прокси", + ), + E("option", { value: "full" }, "Полный туннель"), + ]); + var editor = E("textarea", { + class: "studio-editor", + spellcheck: "false", + placeholder: "Конфигурация JSON (допустимое подмножество YAML)", + }); + var result = E("pre"); + return E("div", {}, [ + E("h2", {}, "Конструктор Mihomo"), + E( + "p", + {}, + "Подписка должна возвращать YAML с узлами Mihomo. Генератор создаёт конфигурацию с TUN и обновлением подписки раз в сутки. Правила GEOSITE/GEOIP требуют геобаз Mihomo. Парольная ссылка остаётся только в форме и конфигурации роутера.", + ), + E("div", { class: "studio-card" }, [ + E("label", {}, "Подписка HTTPS"), + subscription, + E("label", {}, "Маршрутизация"), + preset, + E("div", { class: "studio-actions" }, [ + E( + "button", + { + class: "cbi-button", + click: function () { + try { + editor.value = JSON.stringify( + config(subscription.value.trim(), preset.value), + null, + 2, + ); + } catch (e) { + ui.addNotification(null, E("p", {}, e.message), "error"); + } + }, + }, + "Сформировать", + ), + ]), + editor, + E("div", { class: "studio-actions" }, [ + E( + "button", + { + class: "cbi-button cbi-button-positive", + disabled: !L.hasViewPermission(), + click: function () { + var content = editor.value; + try { + var parsed = JSON.parse(content); + if (!parsed["proxy-groups"] || !parsed.rules) + throw new Error("Нужны proxy-groups и rules"); + } catch (e) { + result.textContent = "Ошибка JSON: " + e.message; + return; + } + ui.showModal("Применить Mihomo?", [ + E( + "p", + {}, + "Будет заменён /etc/mihomo/config.yaml и перезапущена служба mihomo. Предыдущая конфигурация сохранится в config.yaml.studio-backup. TUN меняет маршрутизацию и может прервать доступ. Убедитесь, что служба использует этот путь.", + ), + E("div", { class: "right" }, [ + E( + "button", + { class: "cbi-button", click: ui.hideModal }, + "Отмена", + ), + E( + "button", + { + class: "cbi-button cbi-button-positive", + click: function () { + ui.hideModal(); + result.textContent = "Проверка и применение…"; + return deploy(content) + .then(function (r) { + result.textContent = + (r.ok ? "Готово: " : "Ошибка: ") + + (r.error || r.output || ""); + }) + .catch(function (e) { + result.textContent = e.message; + }); + }, + }, + "Проверить и применить", + ), + ]), + ]); + }, + }, + "Проверить ядром Mihomo и применить", + ), + ]), + result, + ]), + ]); + }, + handleSaveApply: null, + handleSave: null, + handleReset: null, +}); diff --git a/luci/luci-app-studio/htdocs/luci-static/resources/view/studio/overview.js b/luci/luci-app-studio/htdocs/luci-static/resources/view/studio/overview.js new file mode 100644 index 0000000..9670b86 --- /dev/null +++ b/luci/luci-app-studio/htdocs/luci-static/resources/view/studio/overview.js @@ -0,0 +1,193 @@ +"use strict"; +"require view"; +"require rpc"; +"require poll"; +"require ui"; +var board = rpc.declare({ object: "system", method: "board", expect: {} }); +var info = rpc.declare({ object: "system", method: "info", expect: {} }); +var status = rpc.declare({ object: "studio", method: "status", expect: {} }); +var leases = rpc.declare({ + object: "luci-rpc", + method: "getDHCPLeases", + expect: {}, +}); +function card(title, value, detail, percent) { + return E("div", { class: "studio-card" }, [ + E("h3", {}, title), + E("div", { class: "studio-value" }, String(value)), + E("p", { class: "studio-muted" }, detail || ""), + percent == null + ? "" + : E("div", { class: "studio-meter" }, [ + E("span", { + style: "width:" + Math.max(0, Math.min(100, percent)) + "%", + }), + ]), + ]); +} +return view.extend({ + load: function () { + return Promise.all([board(), info(), status(), leases()]); + }, + render: function (data) { + var root = E("div"), + latest = data; + function draw(d) { + latest = d; + var b = d[0], + i = d[1], + s = d[2], + m = i.memory || {}, + ram = m.total + ? (100 * + (m.total - + (m.available != null + ? m.available + : (m.free || 0) + (m.buffered || 0) + (m.cached || 0)))) / + m.total + : null; + var clients = d[3].dhcp_leases || [], + load = (i.load || []) + .map(function (n) { + return (n / 65536).toFixed(2); + }) + .join(" / "); + root.replaceChildren( + E("div", { class: "studio-hero" }, [ + E("div", {}, [ + E("h2", {}, "Ваша сеть под контролем"), + E( + "p", + {}, + (b.model || "OpenWrt") + + " · " + + ((b.release || {}).description || "") + + " · " + + new Date().toLocaleTimeString(), + ), + ]), + E("span", { class: "studio-status" }, "Подключено · LuCI RPC"), + ]), + E("div", { class: "studio-grid" }, [ + card("Загрузка системы", load, "Load average · 1 / 5 / 15 минут"), + card( + "Оперативная память", + ram == null ? "Нет данных" : ram.toFixed(1) + "%", + "С учётом MemAvailable", + ram, + ), + card( + "Хранилище overlay", + s.overlay || "Нет данных", + "Занято места", + parseFloat(s.overlay) || 0, + ), + card( + "Время работы", + Math.floor((i.uptime || 0) / 3600) + + " ч " + + Math.floor(((i.uptime || 0) % 3600) / 60) + + " мин", + s.architecture, + ), + ]), + E("div", { class: "studio-actions" }, [ + E( + "button", + { class: "cbi-button", click: report }, + "Сохранить отчёт", + ), + E( + "a", + { class: "cbi-button", href: L.url("admin/studio/diagnostics") }, + "Проверить сеть", + ), + ]), + E("h3", { id: "studio-clients" }, "Клиенты DHCP"), + E( + "p", + { class: "studio-muted" }, + "Активные IPv4-аренды. Статические адреса и ограничения доступа настраиваются в разделе «Настройки и инструменты».", + ), + E( + "table", + { class: "table" }, + [ + E( + "tr", + { class: "tr table-titles" }, + ["Имя", "IP-адрес", "MAC-адрес"].map(function (x) { + return E("th", { class: "th" }, x); + }), + ), + ].concat( + clients.map(function (c) { + return E( + "tr", + { class: "tr" }, + [c.hostname || "—", c.ipaddr || "—", c.macaddr || "—"].map( + function (x) { + return E("td", { class: "td" }, x); + }, + ), + ); + }), + ), + ), + ); + if (ram > 92 || parseFloat(s.overlay) > 92) + root.appendChild( + E( + "div", + { class: "alert-message warning" }, + "Ресурсы роутера близки к исчерпанию. Проверьте память и свободное место.", + ), + ); + } + function report() { + var b = latest[0], + i = latest[1], + s = latest[2]; + var text = JSON.stringify( + { + application: "OpenWrt Studio LuCI 1.0.0-rc1", + model: b.model, + release: (b.release || {}).description, + uptime: i.uptime, + load: i.load, + memory: i.memory, + overlay: s.overlay, + architecture: s.architecture, + }, + null, + 2, + ); + var url = URL.createObjectURL( + new Blob([text], { type: "text/plain;charset=utf-8" }), + ); + var a = E("a", { href: url, download: "openwrt-studio-report.txt" }); + a.click(); + setTimeout(function () { + URL.revokeObjectURL(url); + }, 1000); + } + draw(data); + poll.add(function () { + return Promise.all([board(), info(), status(), leases()]) + .then(draw) + .catch(function (e) { + root.replaceChildren( + E( + "div", + { class: "alert-message warning" }, + "Данные недоступны: " + e.message, + ), + ); + }); + }, 25); + return root; + }, + handleSaveApply: null, + handleSave: null, + handleReset: null, +}); diff --git a/luci/luci-app-studio/htdocs/luci-static/resources/view/studio/sentinel.js b/luci/luci-app-studio/htdocs/luci-static/resources/view/studio/sentinel.js new file mode 100644 index 0000000..6157278 --- /dev/null +++ b/luci/luci-app-studio/htdocs/luci-static/resources/view/studio/sentinel.js @@ -0,0 +1,52 @@ +"use strict"; +"require view"; +"require form"; +"require uci"; +return view.extend({ + load: function () { + return uci.load("studio"); + }, + render: function () { + var m = new form.Map( + "studio", + "Sentinel · фоновый мониторинг", + "Проверяет внешнюю связность и локальный DNS непосредственно на роутере. Работает после закрытия браузера. Включается по желанию. Повторные сбои WAN и внешней сети записываются в журнал; восстановление ограничено DNS и выбранной службой прокси.", + ); + var s = m.section(form.NamedSection, "sentinel", "sentinel"); + s.addremove = false; + var o = s.option(form.Flag, "enabled", "Включить Sentinel"); + o.rmempty = false; + o = s.option(form.Value, "interval", "Период проверки, секунд"); + o.datatype = "range(30,3600)"; + o.default = "60"; + o.rmempty = false; + o = s.option(form.Value, "failures", "Сбоев подряд до восстановления"); + o.datatype = "range(2,20)"; + o.default = "3"; + o.rmempty = false; + o = s.option( + form.Value, + "cooldown", + "Пауза между восстановлениями, секунд", + ); + o.datatype = "range(300,86400)"; + o.default = "300"; + o.rmempty = false; + o = s.option( + form.Flag, + "dns", + "Перезапускать dnsmasq при локальном сбое DNS", + ); + o.rmempty = false; + o = s.option( + form.ListValue, + "proxy", + "Перезапускать остановленное ядро при потере связи", + ); + o.value("none", "Не перезапускать"); + o.value("mihomo", "Mihomo"); + o.value("sing-box", "Sing-box"); + o.default = "none"; + return m.render(); + }, +}); diff --git a/luci/luci-app-studio/htdocs/luci-static/resources/view/studio/tools.js b/luci/luci-app-studio/htdocs/luci-static/resources/view/studio/tools.js new file mode 100644 index 0000000..0b874a0 --- /dev/null +++ b/luci/luci-app-studio/htdocs/luci-static/resources/view/studio/tools.js @@ -0,0 +1,101 @@ +"use strict"; +"require view"; +"require rpc"; +var status = rpc.declare({ object: "studio", method: "status", expect: {} }); +return view.extend({ + load: function () { + return status(); + }, + render: function (data) { + var groups = [ + [ + "Сеть и устройства", + [ + ["Wi-Fi: SSID, пароль, WPA2/WPA3", "admin/network/wireless"], + ["WAN / LAN / WireGuard / AmneziaWG", "admin/network/network"], + ["DHCP и статические аренды", "admin/network/dhcp"], + ["Брандмауэр, ограничения клиентов", "admin/network/firewall"], + ["Проброс портов", "admin/network/firewall/forwards"], + ["Статические маршруты", "admin/network/routing"], + ], + ], + [ + "Система", + [ + ["Имя роутера, время и тема", "admin/system/system"], + ["Пароль администратора и SSH", "admin/system/admin"], + ["Резервная копия и прошивка", "admin/system/flash"], + ["Планировщик cron", "admin/system/crontab"], + ["Пакеты и источники ForkOP", "admin/system/opkg"], + ["Службы и автозапуск", "admin/system/startup"], + ["Перезагрузка", "admin/system/reboot"], + ["USB и точки монтирования (luci-app-mount)", "admin/system/mounts"], + ], + ], + ]; + return E("div", {}, [ + E("h2", {}, "Настройки и инструменты"), + E( + "p", + {}, + "Штатные формы LuCI работают с UCI, проверкой полей и подтверждением применения. Для WireGuard нужен luci-proto-wireguard, для AmneziaWG — совместимый модуль вашей прошивки. Источники ForkOP добавляются для конкретной версии OpenWrt и архитектуры.", + ), + E( + "div", + { class: "studio-grid" }, + groups.map(function (g) { + return E("div", { class: "studio-card" }, [ + E("h3", {}, g[0]), + E( + "div", + { class: "studio-links" }, + g[1].map(function (link) { + return E("a", { href: L.url(link[1]) }, link[0]); + }), + ), + ]); + }), + ), + E("div", { class: "studio-card" }, [ + E("h3", {}, "ForkOP · источник для архитектуры"), + E( + "p", + {}, + "Архитектура: " + + (data.architecture || "не определена") + + ". Сверьте поддержку вашей версии OpenWrt и ключ подписи в документации источника перед добавлением.", + ), + E( + "pre", + {}, + /^[a-zA-Z0-9_]+$/.test(data.architecture || "") + ? "src/gz forkop https://raw.githubusercontent.com/forkop/openwrt-packages/master/" + + data.architecture + : "Невозможно предложить источник без архитектуры.", + ), + E( + "a", + { href: L.url("admin/system/opkg") }, + "Открыть каталог и настройки источников", + ), + ]), + E("div", { class: "studio-card" }, [ + E("h3", {}, "Шаблоны cron"), + E( + "p", + {}, + "Добавляйте задания в системный планировщик после проверки времени роутера. Выберите только нужные строки.", + ), + E( + "pre", + {}, + "0 4 * * * /sbin/reboot\n0 1 * * * /sbin/wifi down\n0 7 * * * /sbin/wifi up\n0 */6 * * * /etc/init.d/mihomo reload", + ), + E("a", { href: L.url("admin/system/crontab") }, "Открыть планировщик"), + ]), + ]); + }, + handleSaveApply: null, + handleSave: null, + handleReset: null, +}); diff --git a/luci/luci-app-studio/htdocs/luci-static/resources/view/studio/vpn.js b/luci/luci-app-studio/htdocs/luci-static/resources/view/studio/vpn.js new file mode 100644 index 0000000..dad840f --- /dev/null +++ b/luci/luci-app-studio/htdocs/luci-static/resources/view/studio/vpn.js @@ -0,0 +1,468 @@ +"use strict"; +"require view"; +"require uci"; +"require ui"; +"require rpc"; +"require validation"; +function parseWireGuard(text, name, protocol) { + if (!/^[a-zA-Z][a-zA-Z0-9_]{0,14}$/.test(name)) + throw new Error("Имя интерфейса: до 15 латинских букв, цифр и _."); + if (protocol !== "wireguard" && protocol !== "amneziawg") + throw new Error("Неизвестный протокол"); + var iface = {}, + peers = [], + section = null; + text.split(/\r?\n/).forEach(function (line) { + line = line.trim(); + if (!line || /^[#;]/.test(line)) return; + if (line === "[Interface]") { + if (section) throw new Error("Повторный Interface"); + section = iface; + return; + } + if (line === "[Peer]") { + section = {}; + peers.push(section); + return; + } + var m = line.match(/^([A-Za-z0-9]+)\s*=\s*(.*?)\s*$/); + if (!m || !section) throw new Error("Неизвестная строка конфигурации"); + if (Object.prototype.hasOwnProperty.call(section, m[1])) + throw new Error("Повторный параметр " + m[1]); + section[m[1]] = m[2]; + }); + function key(s) { + return /^[A-Za-z0-9+/]{43}=$/.test(s || ""); + } + function list(value, cidr) { + return (value || "") + .split(",") + .map(function (x) { + return x.trim(); + }) + .filter(Boolean) + .map(function (x) { + var a = x.split("/"), + v4 = validation.parseIPv4(a[0]), + v6 = validation.parseIPv6(a[0]); + if ( + (!v4 && !v6) || + (cidr && + (a.length !== 2 || + !/^\d+$/.test(a[1]) || + +a[1] > (v4 ? 32 : 128))) || + (!cidr && a.length !== 1) + ) + throw new Error("Неверный адрес: " + x); + return x; + }); + } + if (!key(iface.PrivateKey) || !peers.length) + throw new Error("Нужны PrivateKey и хотя бы один Peer"); + var values = { + proto: protocol, + private_key: iface.PrivateKey, + addresses: list(iface.Address, true), + }; + if (!values.addresses.length) throw new Error("Не задан Address"); + if (iface.DNS) values.dns = list(iface.DNS, false); + Object.keys(iface).forEach(function (k) { + if ( + [ + "PrivateKey", + "Address", + "DNS", + "ListenPort", + "MTU", + "Jc", + "Jmin", + "Jmax", + "S1", + "S2", + "H1", + "H2", + "H3", + "H4", + ].indexOf(k) < 0 + ) + throw new Error("Не поддерживается параметр Interface: " + k); + }); + [ + ["ListenPort", "listen_port", 1, 65535], + ["MTU", "mtu", 576, 9000], + ["Jc", "jc", 0, 65535], + ["Jmin", "jmin", 0, 65535], + ["Jmax", "jmax", 0, 65535], + ["S1", "s1", 0, 65535], + ["S2", "s2", 0, 65535], + ["H1", "h1", 0, 4294967295], + ["H2", "h2", 0, 4294967295], + ["H3", "h3", 0, 4294967295], + ["H4", "h4", 0, 4294967295], + ].forEach(function (f) { + if (iface[f[0]] != null) { + var n = iface[f[0]]; + if (!/^\d+$/.test(n) || +n < f[2] || +n > f[3]) + throw new Error("Неверный " + f[0]); + if (protocol === "wireguard" && /^[JSH]/.test(f[0])) + throw new Error("Для параметров обфускации выберите AmneziaWG"); + values[f[1]] = n; + } + }); + var peerValues = peers.map(function (p) { + if (!key(p.PublicKey) || (p.PresharedKey && !key(p.PresharedKey))) + throw new Error("Неверный ключ Peer"); + Object.keys(p).forEach(function (k) { + if ( + [ + "PublicKey", + "PresharedKey", + "AllowedIPs", + "Endpoint", + "PersistentKeepalive", + ].indexOf(k) < 0 + ) + throw new Error("Не поддерживается параметр Peer: " + k); + }); + var v = { + public_key: p.PublicKey, + allowed_ips: list(p.AllowedIPs, true), + route_allowed_ips: "1", + }; + if (!v.allowed_ips.length) throw new Error("Не задан AllowedIPs"); + if (p.PresharedKey) v.preshared_key = p.PresharedKey; + if (p.Endpoint) { + var m = p.Endpoint.match( + /^(?:\[([0-9a-fA-F:]+)\]|([A-Za-z0-9.-]+)):(\d+)$/, + ); + if ( + !m || + +m[3] < 1 || + +m[3] > 65535 || + (m[1] && !validation.parseIPv6(m[1])) + ) + throw new Error("Неверный Endpoint"); + v.endpoint_host = m[1] || m[2]; + v.endpoint_port = m[3]; + } + if (p.PersistentKeepalive != null) { + if ( + !/^\d+$/.test(p.PersistentKeepalive) || + +p.PersistentKeepalive > 65535 + ) + throw new Error("Неверный PersistentKeepalive"); + v.persistent_keepalive = p.PersistentKeepalive; + } + return v; + }); + return { name: name, values: values, peers: peerValues, protocol: protocol }; +} +var saveConfig = rpc.declare({ + object: "studio", + method: "vpnconfig", + params: ["kind", "content"], + expect: {}, +}); +function singbox(fields) { + var port = +fields.port; + if ( + !/^[A-Za-z0-9.:-]+$/.test(fields.server) || + port < 1 || + port > 65535 || + !Number.isInteger(port) + ) + throw new Error("Проверьте сервер и порт"); + var outbound = { + type: fields.protocol, + tag: "proxy", + server: fields.server, + server_port: port, + tls: { enabled: true, server_name: fields.sni }, + }; + if (fields.protocol === "vless") { + if ( + !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( + fields.uuid, + ) + ) + throw new Error("Нужен UUID"); + outbound.uuid = fields.uuid; + outbound.flow = "xtls-rprx-vision"; + outbound.tls.utls = { enabled: true, fingerprint: "chrome" }; + outbound.tls.reality = { + enabled: true, + public_key: fields.publickey, + short_id: fields.shortid, + }; + if ( + !/^[A-Za-z0-9_-]{43}$/.test(fields.publickey) || + !/^([0-9a-f]{2}){0,8}$/i.test(fields.shortid) + ) + throw new Error("Проверьте Reality public key / short ID"); + } else if (fields.protocol === "hysteria2") { + if (!fields.password) throw new Error("Нужен пароль"); + outbound.password = fields.password; + } else if (fields.protocol === "tuic") { + if ( + !fields.password || + !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( + fields.uuid, + ) + ) + throw new Error("Нужны UUID и пароль"); + outbound.uuid = fields.uuid; + outbound.password = fields.password; + outbound.congestion_control = "bbr"; + } else throw new Error("Неизвестный протокол"); + return { + log: { level: "info" }, + inbounds: [ + { + type: "tun", + tag: "tun-in", + interface_name: "studio-tun", + address: ["172.19.0.1/30"], + auto_route: true, + strict_route: true, + }, + ], + outbounds: [outbound, { type: "direct", tag: "direct" }], + route: { + auto_detect_interface: true, + rules: [{ ip_is_private: true, outbound: "direct" }], + final: "proxy", + }, + }; +} +return view.extend({ + parseWireGuard: parseWireGuard, + buildSingbox: singbox, + load: function () { + return uci.load("network"); + }, + render: function () { + var name = E("input", { value: "awg0" }), + protocol = E("select", {}, [ + E("option", { value: "amneziawg" }, "AmneziaWG"), + E("option", { value: "wireguard" }, "WireGuard"), + ]), + text = E("textarea", { + class: "studio-editor", + placeholder: + "[Interface]\nPrivateKey = …\nAddress = …\n[Peer]\nPublicKey = …\nAllowedIPs = …", + }), + output = E("pre"); + var file = E("input", { type: "file", accept: ".conf" }); + file.addEventListener("change", function () { + if (file.files[0]) + file.files[0].text().then(function (t) { + text.value = t; + }); + }); + function stage() { + var c; + try { + c = parseWireGuard(text.value, name.value, protocol.value); + if (uci.get("network", c.name)) + throw new Error("Интерфейс уже существует. Выберите другое имя."); + } catch (e) { + output.textContent = e.message; + return; + } + ui.showModal("Добавить интерфейс?", [ + E( + "p", + {}, + "Будет добавлен " + + c.name + + " и " + + c.peers.length + + " peer. Импорт создаёт отложенные изменения UCI. Проверьте протокол, зону брандмауэра и маршруты в форме интерфейсов, затем примените изменения с откатом LuCI.", + ), + E("button", { class: "cbi-button", click: ui.hideModal }, "Отмена"), + E( + "button", + { + class: "cbi-button cbi-button-positive", + click: function () { + ui.hideModal(); + uci.add("network", "interface", c.name); + Object.keys(c.values).forEach(function (k) { + uci.set("network", c.name, k, c.values[k]); + }); + c.peers.forEach(function (p) { + var sid = uci.add("network", c.protocol + "_" + c.name); + Object.keys(p).forEach(function (k) { + uci.set("network", sid, k, p[k]); + }); + }); + return uci + .save() + .then(function () { + location.href = L.url("admin/network/network"); + }) + .catch(function (e) { + output.textContent = e.message; + }); + }, + }, + "Добавить отложенные изменения", + ), + ]); + } + var inputs = {}; + function field(key, label, type, value) { + var input = E("input", { + type: type || "text", + value: value || "", + autocomplete: "off", + }); + inputs[key] = input; + return E("div", { class: "cbi-value" }, [ + E("label", { class: "cbi-value-title" }, label), + E("div", { class: "cbi-value-field" }, [input]), + ]); + } + var proxyProtocol = E( + "select", + {}, + ["vless", "hysteria2", "tuic"].map(function (x) { + return E("option", { value: x }, x); + }), + ); + var editor = E("textarea", { class: "studio-editor", spellcheck: "false" }), + result = E("pre"); + function apply(kind, content) { + ui.showModal("Применить " + kind + "?", [ + E( + "p", + {}, + "Sing-box проверяется ядром; профиль OpenVPN проверяется по списку допустимых клиентских директив. Файл сохраняется с резервной копией, затем служба перезапускается. Изменение маршрутов может прервать доступ к роутеру. Для OpenVPN допускается только клиентский профиль с встроенными сертификатами без сценариев и внешних файлов.", + ), + E("button", { class: "cbi-button", click: ui.hideModal }, "Отмена"), + E( + "button", + { + class: "cbi-button cbi-button-positive", + click: function () { + ui.hideModal(); + return saveConfig(kind, content) + .then(function (r) { + result.textContent = + (r.ok ? "Готово: " : "Ошибка: ") + + (r.error || r.output || ""); + }) + .catch(function (e) { + result.textContent = e.message; + }); + }, + }, + "Проверить и применить", + ), + ]); + } + var ovpn = E("textarea", { + class: "studio-editor", + placeholder: "client\nremote …\n…\n…", + }), + ovpnFile = E("input", { type: "file", accept: ".ovpn" }); + ovpnFile.addEventListener("change", function () { + if (ovpnFile.files[0]) + ovpnFile.files[0].text().then(function (t) { + ovpn.value = t; + }); + }); + return E("div", {}, [ + E("h2", {}, "Импорт и конструкторы VPN"), + E("div", { class: "studio-card" }, [ + E("h3", {}, "WireGuard / AmneziaWG · импорт .conf"), + E( + "p", + {}, + "Нужен установленный модуль протокола. Поддерживаются IPv4/IPv6 endpoint, несколько peer, DNS и параметры Jc/Jmin/Jmax/S1/S2/H1–H4. Неподдерживаемые параметры отклоняются.", + ), + name, + protocol, + file, + text, + E( + "button", + { + class: "cbi-button cbi-button-positive", + disabled: !L.hasViewPermission(), + click: stage, + }, + "Импортировать в UCI", + ), + output, + ]), + E("div", { class: "studio-card" }, [ + E("h3", {}, "Sing-box · VLESS Reality / Hysteria 2 / TUIC"), + proxyProtocol, + field("server", "Сервер"), + field("port", "Порт", "number", "443"), + field("sni", "TLS Server Name"), + field("uuid", "UUID"), + field("password", "Пароль", "password"), + field("publickey", "Reality public key"), + field("shortid", "Reality short ID"), + E( + "button", + { + class: "cbi-button", + click: function () { + try { + var values = { protocol: proxyProtocol.value }; + Object.keys(inputs).forEach(function (k) { + values[k] = + k === "password" ? inputs[k].value : inputs[k].value.trim(); + }); + editor.value = JSON.stringify(singbox(values), null, 2); + } catch (e) { + result.textContent = e.message; + } + }, + }, + "Сформировать Sing-box", + ), + editor, + E( + "button", + { + class: "cbi-button cbi-button-positive", + disabled: !L.hasViewPermission(), + click: function () { + try { + JSON.parse(editor.value); + apply("sing-box", editor.value); + } catch (e) { + result.textContent = e.message; + } + }, + }, + "Проверить и применить Sing-box", + ), + ]), + E("div", { class: "studio-card" }, [ + E("h3", {}, "OpenVPN · импорт .ovpn"), + ovpnFile, + ovpn, + E( + "button", + { + class: "cbi-button cbi-button-positive", + disabled: !L.hasViewPermission(), + click: function () { + apply("openvpn", ovpn.value); + }, + }, + "Импортировать и запустить OpenVPN", + ), + result, + ]), + ]); + }, + handleSaveApply: null, + handleSave: null, + handleReset: null, +}); diff --git a/luci/luci-app-studio/root/etc/config/studio b/luci/luci-app-studio/root/etc/config/studio new file mode 100644 index 0000000..81ea1df --- /dev/null +++ b/luci/luci-app-studio/root/etc/config/studio @@ -0,0 +1,7 @@ +config sentinel 'sentinel' + option enabled '0' + option interval '60' + option failures '3' + option cooldown '300' + option dns '1' + option proxy 'none' diff --git a/luci/luci-app-studio/root/etc/init.d/studio-sentinel b/luci/luci-app-studio/root/etc/init.d/studio-sentinel new file mode 100755 index 0000000..a4b656f --- /dev/null +++ b/luci/luci-app-studio/root/etc/init.d/studio-sentinel @@ -0,0 +1,17 @@ +#!/bin/sh /etc/rc.common +START=99 +STOP=10 +USE_PROCD=1 +start_service() { + . /lib/functions.sh + config_load studio + config_get_bool enabled sentinel enabled 0 + [ "$enabled" = 1 ] || return 0 + procd_open_instance + procd_set_param command /usr/libexec/studio-sentinel + procd_set_param respawn 3600 5 5 + procd_set_param stdout 1 + procd_set_param stderr 1 + procd_close_instance +} +service_triggers() { procd_add_reload_trigger studio; } diff --git a/luci/luci-app-studio/root/etc/uci-defaults/91-luci-app-studio b/luci/luci-app-studio/root/etc/uci-defaults/91-luci-app-studio new file mode 100755 index 0000000..ad02030 --- /dev/null +++ b/luci/luci-app-studio/root/etc/uci-defaults/91-luci-app-studio @@ -0,0 +1,4 @@ +#!/bin/sh +/etc/init.d/studio-sentinel enable +/etc/init.d/studio-sentinel start +exit 0 diff --git a/luci/luci-app-studio/root/usr/libexec/rpcd/studio b/luci/luci-app-studio/root/usr/libexec/rpcd/studio new file mode 100755 index 0000000..17f4be9 --- /dev/null +++ b/luci/luci-app-studio/root/usr/libexec/rpcd/studio @@ -0,0 +1,162 @@ +#!/bin/sh +# Fixed RPC methods; no client-supplied shell command or destination path. +. /usr/share/libubox/jshn.sh +emit_error() { json_init; json_add_boolean ok 0; json_add_string error "$1"; json_dump; } +result() { json_init; json_add_boolean ok "$1"; json_add_string output "$2"; json_dump; } +case "$1" in +list) + echo '{"status":{},"probe":{},"action":{"name":"","service":""},"mihomo":{"content":""},"vpnconfig":{"kind":"","content":""},"mesh":{"kind":"","key":"","routes":"","exitnode":false,"accept":true}}' + ;; +call) + read -r request + case "$2" in + status) + json_init + json_add_array services + for service in studio-sentinel mihomo sing-box xray dnsmasq tailscale zerotier openvpn smartdns mosdns passwall2; do + json_add_object '' + json_add_string name "$service" + installed=0; [ -x "/etc/init.d/$service" ] && installed=1 + json_add_boolean installed "$installed" + running=0 + if [ "$installed" = 1 ]; then /etc/init.d/"$service" running >/dev/null 2>&1 && running=1; fi + json_add_boolean running "$running" + json_close_object + done + json_close_array + json_add_string overlay "$(df -Pk /overlay 2>/dev/null | tail -n 1 | awk '{print $5}')" + json_add_string architecture "$(. /etc/openwrt_release; printf '%s' "$DISTRIB_ARCH")" + json_add_boolean ok 1 + json_dump + ;; + probe) + output="$( + printf 'WAN\n'; ubus call network.interface.wan status 2>&1 + printf '\nВнешняя связность (ICMP)\n' + ping -c 1 -W 2 1.1.1.1 2>&1 + printf '\nЛокальный DNS\n'; nslookup openwrt.org 127.0.0.1 2>&1 + printf '\nПубличный DNS\n'; nslookup openwrt.org 1.1.1.1 2>&1 + printf '\nИнтерфейсы\n'; ip -s link 2>&1 + printf '\nМаршруты\n'; ip route 2>&1 + )" + result 1 "$output" + ;; + action) + json_load "$request" || { emit_error 'Invalid JSON'; exit 0; } + json_get_var name name + json_get_var service service + case "$service" in studio-sentinel|mihomo|sing-box|xray|dnsmasq|tailscale|zerotier|openvpn|smartdns|mosdns|passwall2) ;; *) emit_error 'Unsupported service'; exit 0;; esac + case "$name" in start|stop|restart|reload) ;; *) emit_error 'Unsupported action'; exit 0;; esac + [ -x "/etc/init.d/$service" ] || { emit_error 'Service is not installed'; exit 0; } + output="$(/etc/init.d/"$service" "$name" 2>&1)"; code=$? + if [ "$code" = 0 ]; then result 1 "$output"; else result 0 "$output"; fi + ;; + mihomo) + json_load "$request" || { emit_error 'Invalid JSON'; exit 0; } + json_get_var content content + [ "${#content}" -le 262144 ] && [ -n "$content" ] || { emit_error 'Config must be 1–262144 bytes'; exit 0; } + command -v mihomo >/dev/null 2>&1 || { emit_error 'Mihomo is not installed'; exit 0; } + [ -x /etc/init.d/mihomo ] || { emit_error 'Expected /etc/init.d/mihomo'; exit 0; } + [ ! -L /etc/mihomo ] && [ ! -L /etc/mihomo/config.yaml ] && [ ! -L /etc/mihomo/config.yaml.studio-backup ] || { emit_error 'Symlink destination is not supported'; exit 0; } + umask 077 + mkdir -p /etc/mihomo || { emit_error 'Cannot create directory'; exit 0; } + mkdir /etc/mihomo/.studio-lock 2>/dev/null || { emit_error 'Another configuration operation is active'; exit 0; } + temporary='' + trap 'rm -f "$temporary"; rmdir /etc/mihomo/.studio-lock' EXIT + trap 'exit 1' HUP INT TERM + temporary="$(mktemp /etc/mihomo/.studio.XXXXXX)" || { emit_error 'Cannot create temporary file'; exit 0; } + printf '%s\n' "$content" > "$temporary" || { emit_error 'Cannot write config'; exit 0; } + output="$(mihomo -t -d /etc/mihomo -f "$temporary" 2>&1)"; code=$? + [ "$code" = 0 ] || { result 0 "$output"; exit 0; } + if [ -f /etc/mihomo/config.yaml ]; then + cp -p /etc/mihomo/config.yaml /etc/mihomo/config.yaml.studio-backup || { emit_error 'Backup failed'; exit 0; } + fi + mv "$temporary" /etc/mihomo/config.yaml || { emit_error 'Replace failed'; exit 0; } + output="$(/etc/init.d/mihomo restart 2>&1)"; code=$? + if [ "$code" = 0 ]; then result 1 'Configuration saved; restart command succeeded. Verify service status.'; else result 0 "Configuration saved, restart failed: $output"; fi + ;; + vpnconfig) + json_load "$request" || { emit_error 'Invalid JSON'; exit 0; } + json_get_var kind kind + json_get_var content content + [ -n "$content" ] && [ "${#content}" -le 262144 ] || { emit_error 'Invalid config size'; exit 0; } + case "$kind" in + sing-box) directory=/etc/sing-box; destination=/etc/sing-box/config.json;; + openvpn) directory=/etc/openvpn; destination=/etc/openvpn/studio.conf;; + *) emit_error 'Unsupported config type'; exit 0;; + esac + [ -x "/etc/init.d/$kind" ] && command -v "$kind" >/dev/null 2>&1 || { emit_error 'VPN service/core is not installed'; exit 0; } + [ ! -L "$directory" ] && [ ! -L "$destination" ] && [ ! -L "$destination.studio-backup" ] || { emit_error 'Symlink destination is not supported'; exit 0; } + umask 077 + mkdir -p "$directory" || { emit_error 'Cannot create directory'; exit 0; } + mkdir "$directory/.studio-lock" 2>/dev/null || { emit_error 'Another configuration operation is active'; exit 0; } + temporary='' + trap 'rm -f "$temporary"; rmdir "$directory/.studio-lock"' EXIT + trap 'exit 1' HUP INT TERM + temporary="$(mktemp "$directory/.studio.XXXXXX")" || { emit_error 'Cannot create temporary file'; exit 0; } + printf '%s\n' "$content" > "$temporary" || { emit_error 'Cannot write config'; exit 0; } + if [ "$kind" = sing-box ]; then + output="$(sing-box check -c "$temporary" 2>&1)"; code=$? + [ "$code" = 0 ] || { result 0 "$output"; exit 0; } + else + # Restrict imported OpenVPN to client directives and embedded key material. + # Never allow config/include, plugin, scripts, log paths, or external key files. + awk ' + BEGIN { inline=0; client=0; remote=0; failed=0 } + /^[[:space:]]*([#;]|$)/ { next } + /^<(ca|cert|key|tls-auth|tls-crypt|extra-certs)>[[:space:]]*$/ { if (inline) { failed=1; exit }; inline=1; tag=$0; gsub(/[<>[:space:]]/, "", tag); next } + /^<\// { closing=$0; gsub(/[<>\/[:space:]]/, "", closing); if (!inline || closing!=tag) { failed=1; exit }; inline=0; next } + inline { next } + { key=$1; if (key=="client") client=1; if (key=="remote") remote=1; + if (key !~ /^(client|dev|dev-type|proto|remote|remote-random|resolv-retry|nobind|persist-key|persist-tun|remote-cert-tls|verify-x509-name|cipher|data-ciphers|data-ciphers-fallback|auth|auth-nocache|verb|mute|mute-replay-warnings|key-direction|tls-version-min|tls-cipher|tls-ciphersuites|reneg-sec|sndbuf|rcvbuf|tun-mtu|mssfix|explicit-exit-notify|ping|ping-restart|redirect-gateway|route|route-ipv6|route-nopull|pull|pull-filter|dhcp-option|connect-retry|connect-timeout|fast-io|float)$/) { failed=1; exit } + if (key=="dev" && $2 !~ /^(tun|tap)[0-9]*$/) { failed=1; exit } + } + END { if (failed || inline || !client || !remote) exit 1 } + ' "$temporary" || { emit_error 'Unsupported or malformed OpenVPN client profile'; exit 0; } + fi + if [ -f "$destination" ]; then cp -p "$destination" "$destination.studio-backup" || { emit_error 'Backup failed'; exit 0; }; fi + mv "$temporary" "$destination" || { emit_error 'Replace failed'; exit 0; } + if [ "$kind" = openvpn ]; then + uci set openvpn.studio=openvpn && uci set openvpn.studio.config="$destination" && uci set openvpn.studio.enabled=1 && uci commit openvpn || { emit_error 'Config saved; UCI update failed'; exit 0; } + fi + output="$(/etc/init.d/"$kind" restart 2>&1)"; code=$? + if [ "$code" = 0 ]; then result 1 'Configuration saved; restart command succeeded. Verify service status.'; else result 0 "Configuration saved; restart failed: $output"; fi + ;; + mesh) + json_load "$request" || { emit_error 'Invalid JSON'; exit 0; } + json_get_var kind kind + json_get_var key key + json_get_var routes routes + json_get_var exitnode exitnode + json_get_var accept accept + case "$kind" in + tailscale) + command -v tailscale >/dev/null 2>&1 || { emit_error 'Tailscale is not installed'; exit 0; } + case "$key" in tskey-auth-*) ;; *) emit_error 'Invalid Tailscale auth key'; exit 0;; esac + case "$key" in *[!A-Za-z0-9_-]*) emit_error 'Invalid auth key characters'; exit 0;; esac + case "$routes" in *[!0-9a-fA-F:.,/]*) emit_error 'Invalid subnet characters'; exit 0;; esac + case "$exitnode" in 1|true) exitnode=true;; 0|false) exitnode=false;; *) emit_error 'Invalid exitnode flag'; exit 0;; esac + case "$accept" in 1|true) accept=true;; 0|false) accept=false;; *) emit_error 'Invalid accept flag'; exit 0;; esac + umask 077 + keyfile="$(mktemp /tmp/studio-tskey.XXXXXX)" || { emit_error 'Cannot create temporary file'; exit 0; } + trap 'rm -f "$keyfile"' EXIT + trap 'exit 1' HUP INT TERM + printf '%s' "$key" > "$keyfile" + /etc/init.d/tailscale start >/dev/null 2>&1 || { emit_error 'Cannot start Tailscale service'; exit 0; } + output="$(tailscale up --auth-key="file:$keyfile" --accept-routes="$accept" --advertise-exit-node="$exitnode" --advertise-routes="$routes" --timeout=15s 2>&1)"; code=$? + ;; + zerotier) + command -v zerotier-cli >/dev/null 2>&1 || { emit_error 'ZeroTier is not installed'; exit 0; } + [ "${#key}" = 16 ] || { emit_error 'ZeroTier network ID must contain 16 hex characters'; exit 0; } + case "$key" in *[!0-9a-fA-F]*) emit_error 'Invalid network ID'; exit 0;; esac + /etc/init.d/zerotier start >/dev/null 2>&1 || { emit_error 'Cannot start ZeroTier service'; exit 0; } + output="$(zerotier-cli join "$key" 2>&1)"; code=$? + ;; + *) emit_error 'Unsupported mesh VPN'; exit 0;; + esac + if [ "$code" = 0 ]; then result 1 "$output"; else result 0 "$output"; fi + ;; + *) emit_error 'Unknown method';; + esac + ;; +esac diff --git a/luci/luci-app-studio/root/usr/libexec/studio-sentinel b/luci/luci-app-studio/root/usr/libexec/studio-sentinel new file mode 100755 index 0000000..a4b4e99 --- /dev/null +++ b/luci/luci-app-studio/root/usr/libexec/studio-sentinel @@ -0,0 +1,38 @@ +#!/bin/sh +# Opt-in recovery. Never restarts the entire network or changes routes/DNS settings. +. /lib/functions.sh +config_load studio +config_get interval sentinel interval 60 +config_get failures sentinel failures 3 +config_get cooldown sentinel cooldown 300 +config_get_bool dns sentinel dns 1 +config_get proxy sentinel proxy none +case "$interval" in ''|*[!0-9]*) interval=60;; esac +case "$failures" in ''|*[!0-9]*) failures=3;; esac +case "$cooldown" in ''|*[!0-9]*) cooldown=300;; esac +[ "$interval" -ge 30 ] && [ "$interval" -le 3600 ] || interval=60 +[ "$failures" -ge 2 ] && [ "$failures" -le 20 ] || failures=3 +[ "$cooldown" -ge 300 ] && [ "$cooldown" -le 86400 ] || cooldown=300 +case "$proxy" in mihomo|sing-box|none) ;; *) proxy=none;; esac +count=0; previous=''; last=0 +while :; do + issue=healthy; recovery='' + if ping -c 1 -W 2 1.1.1.1 >/dev/null 2>&1 || ping -c 1 -W 2 77.88.8.8 >/dev/null 2>&1; then + if ! nslookup openwrt.org 127.0.0.1 >/dev/null 2>&1; then issue=local_dns; [ "$dns" = 1 ] && recovery=dnsmasq; fi + else + wan="$(ubus call network.interface.wan status 2>/dev/null | jsonfilter -e '@.up')" + if [ "$wan" != true ]; then issue=wan_down; else issue=upstream_or_tunnel; fi + if [ "$proxy" != none ] && [ -x "/etc/init.d/$proxy" ] && ! /etc/init.d/"$proxy" running >/dev/null 2>&1; then issue=proxy_stopped; recovery="$proxy"; fi + fi + if [ "$issue" = "$previous" ]; then count=$((count + 1)); else + logger -t studio-sentinel "Health state: $issue" + count=1; previous="$issue" + fi + now="$(date +%s)" + if [ -n "$recovery" ] && [ "$count" -ge "$failures" ] && [ $((now - last)) -ge "$cooldown" ]; then + logger -t studio-sentinel "Recovery: restarting $recovery after $count failed checks" + if /etc/init.d/"$recovery" restart; then logger -t studio-sentinel 'Restart command succeeded'; else logger -t studio-sentinel 'Restart command failed'; fi + last="$now"; count=0 + fi + sleep "$interval" +done diff --git a/luci/luci-app-studio/root/usr/share/luci/menu.d/luci-app-studio.json b/luci/luci-app-studio/root/usr/share/luci/menu.d/luci-app-studio.json new file mode 100644 index 0000000..879e0c5 --- /dev/null +++ b/luci/luci-app-studio/root/usr/share/luci/menu.d/luci-app-studio.json @@ -0,0 +1,78 @@ +{ + "admin/studio": { + "title": "OpenWrt Studio", + "order": 15, + "action": { + "type": "firstchild" + }, + "depends": { + "acl": [ + "luci-app-studio" + ] + } + }, + "admin/studio/overview": { + "title": "Обзор сети", + "order": 1, + "action": { + "type": "view", + "path": "studio/overview" + } + }, + "admin/studio/capabilities": { + "title": "VPN и возможности", + "order": 2, + "action": { + "type": "view", + "path": "studio/capabilities" + } + }, + "admin/studio/diagnostics": { + "title": "Диагностика · Sentinel", + "order": 3, + "action": { + "type": "view", + "path": "studio/diagnostics" + } + }, + "admin/studio/mihomo": { + "title": "Конструктор Mihomo", + "order": 4, + "action": { + "type": "view", + "path": "studio/mihomo" + } + }, + "admin/studio/tools": { + "title": "Настройки и инструменты", + "order": 5, + "action": { + "type": "view", + "path": "studio/tools" + } + }, + "admin/studio/vpn": { + "title": "Конструкторы VPN", + "order": 3, + "action": { + "type": "view", + "path": "studio/vpn" + } + }, + "admin/studio/mesh": { + "title": "Tailscale / ZeroTier", + "order": 4, + "action": { + "type": "view", + "path": "studio/mesh" + } + }, + "admin/studio/sentinel": { + "title": "Фоновый Sentinel", + "order": 6, + "action": { + "type": "view", + "path": "studio/sentinel" + } + } +} diff --git a/luci/luci-app-studio/root/usr/share/rpcd/acl.d/luci-app-studio.json b/luci/luci-app-studio/root/usr/share/rpcd/acl.d/luci-app-studio.json new file mode 100644 index 0000000..b67aef5 --- /dev/null +++ b/luci/luci-app-studio/root/usr/share/rpcd/acl.d/luci-app-studio.json @@ -0,0 +1,42 @@ +{ + "luci-app-studio": { + "description": "OpenWrt Studio: monitor and manage supported services", + "read": { + "ubus": { + "system": [ + "board", + "info" + ], + "network.interface": [ + "dump" + ], + "luci-rpc": [ + "getDHCPLeases", + "getHostHints" + ], + "studio": [ + "status", + "probe" + ] + }, + "uci": [ + "network", + "studio" + ] + }, + "write": { + "ubus": { + "studio": [ + "action", + "mihomo", + "vpnconfig", + "mesh" + ] + }, + "uci": [ + "network", + "studio" + ] + } + } +} diff --git a/luci/luci-theme-studio/LICENSE b/luci/luci-theme-studio/LICENSE new file mode 100644 index 0000000..f49a4e1 --- /dev/null +++ b/luci/luci-theme-studio/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/luci/luci-theme-studio/Makefile b/luci/luci-theme-studio/Makefile new file mode 100644 index 0000000..c77b2e5 --- /dev/null +++ b/luci/luci-theme-studio/Makefile @@ -0,0 +1,18 @@ +include $(TOPDIR)/rules.mk +PKG_NAME:=luci-theme-studio +PKG_VERSION:=1.0.0~rc1 +PKG_RELEASE:=1 +LUCI_TITLE:=OpenWrt Studio theme +LUCI_DEPENDS:=+luci-base +luci-theme-bootstrap +PKG_LICENSE:=Apache-2.0 +define Package/luci-theme-studio/postrm +#!/bin/sh +[ -n "$${IPKG_INSTROOT}" ] || { + if [ "$$(uci -q get luci.main.mediaurlbase)" = /luci-static/studio ]; then + uci set luci.main.mediaurlbase=/luci-static/bootstrap + fi + uci -q delete luci.themes.OpenWrtStudio + uci commit luci +} +endef +include $(TOPDIR)/feeds/luci/luci.mk diff --git a/luci/luci-theme-studio/NOTICE b/luci/luci-theme-studio/NOTICE new file mode 100644 index 0000000..2136dbb --- /dev/null +++ b/luci/luci-theme-studio/NOTICE @@ -0,0 +1,8 @@ +LuCI - Lua Configuration Interface +Copyright 2008 Steven Barth +Copyright 2008 Jo-Philipp Wich +Licensed under the Apache License, Version 2.0. + +Contains code from: +coxpcall - Copyright 2005 - Kepler Project (www.keplerproject.org) +ltn12/luasocket - Copyright 2004-2007 Diego Nehab diff --git a/luci/luci-theme-studio/htdocs/luci-static/studio/cascade.css b/luci/luci-theme-studio/htdocs/luci-static/studio/cascade.css new file mode 100644 index 0000000..f5d5fc0 --- /dev/null +++ b/luci/luci-theme-studio/htdocs/luci-static/studio/cascade.css @@ -0,0 +1,2629 @@ +/*! + * LuCI Bootstrap Theme + * Copyright 2012 Nut & Bolt + * By David Menting + * Based on Bootstrap v1.4.0 + * + * Copyright 2011 Twitter, Inc + * Licensed under the Apache License v2.0 + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Designed and built with all the love in the world @twitter by @mdo and @fat. + */ + +/* Variables + * --------- */ + +:root { + --background-color-delta-l-sign: -1; + --background-color-h: 0; + --background-color-s: 0%; + --background-color-l: 100%; + + --background-color-high-hsl: + var(--background-color-h), + var(--background-color-s), + var(--background-color-l); + --background-color-high: hsl(var(--background-color-high-hsl)); + + --background-color-medium-hsl: + var(--background-color-h), + var(--background-color-s), + calc(var(--background-color-l) + var(--background-color-delta-l-sign) * 2.35%); + --background-color-medium: hsl(var(--background-color-medium-hsl)); + + --background-color-low-hsl: + var(--background-color-h), + var(--background-color-s), + calc(var(--background-color-l) + var(--background-color-delta-l-sign) * 3.92%); + --background-color-low: hsl(var(--background-color-low-hsl)); + + --text-color-delta-l-sign: 1; + --text-color-h: 0; + --text-color-s: 0%; + --text-color-l: 0%; + + --text-color-highest-hsl: + var(--text-color-h), + var(--text-color-s), + var(--text-color-l); + --text-color-highest: hsl(var(--text-color-highest-hsl)); + + --text-color-high-hsl: + var(--text-color-h), + var(--text-color-s), + calc(var(--text-color-l) + var(--text-color-delta-l-sign) * 25.1%); + --text-color-high: hsl(var(--text-color-high-hsl)); + + --text-color-medium-hsl: + var(--text-color-h), + var(--text-color-s), + calc(var(--text-color-l) + var(--text-color-delta-l-sign) * 50.2%); + --text-color-medium: hsl(var(--text-color-medium-hsl)); + + --text-color-low-hsl: + var(--text-color-h), + var(--text-color-s), + calc(var(--text-color-l) + var(--text-color-delta-l-sign) * 74.9%); + --text-color-low: hsl(var(--text-color-low-hsl)); + + --border-color-delta-l-sign: -1; + --border-color-h: var(--background-color-h); + --border-color-s: var(--background-color-s); + --border-color-l: var(--background-color-l); + + --border-color-high-hsl: + var(--border-color-h), + var(--border-color-s), + calc(var(--border-color-l) + var(--border-color-delta-l-sign) * 20%); + --border-color-high: hsl(var(--border-color-high-hsl)); + + --border-color-medium-hsl: + var(--border-color-h), + var(--border-color-s), + calc(var(--border-color-l) + var(--border-color-delta-l-sign) * 13.33%); + --border-color-medium: hsl(var(--border-color-medium-hsl)); + + --border-color-low-hsl: + var(--border-color-h), + var(--border-color-s), + calc(var(--border-color-l) + var(--border-color-delta-l-sign) * 6.67%); + --border-color-low: hsl(var(--border-color-low-hsl)); + + --primary-color-high: #1976d2; + --primary-color-medium: #1564c0; + --primary-color-low: #0d46a1; + --on-primary-color: var(--background-color-high); + + --error-color-high-rgb: 246, 43, 18; + --error-color-high: rgb(var(--error-color-high-rgb)); + --error-color-medium: #e8210d; + --error-color-low: #d00000; + --on-error-color: var(--background-color-high); + + --success-color-high-rgb: 0, 172, 89; + --success-color-high: rgb(var(--success-color-high-rgb)); + --success-color-medium: #009a4c; + --success-color-low: #007936; + --on-success-color: var(--background-color-high); + + --warn-color-high: #efbd0b; + --warn-color-medium: #f0c629; + --warn-color-low: #f2d24f; + --on-warn-color: var(--text-color-highest); + + --disabled-opacity: .7; + + color-scheme: light; +} + +:root[data-darkmode="true"] { + --background-color-delta-l-sign: 1; + --background-color-h: 0; + --background-color-s: 0%; + --background-color-l: 13.33%; + --text-color-delta-l-sign: -1; + --text-color-h: 0; + --text-color-s: 0%; + --text-color-l: 100%; + --border-color-delta-l-sign: 1; + + --primary-color-high: #4da1c0; + --primary-color-medium: #448da6; + --primary-color-low: #3c7a8d; + + --error-color-high-rgb: 209, 86, 83; + --error-color-medium: #bf4e4c; + --error-color-low: #b14946; + + --success-color-high-rgb: 0, 166, 108; + --success-color-medium: #00945e; + --success-color-low: #008252; + + --warn-color-high: #a69461; + --warn-color-medium: #a68d45; + --warn-color-low: #a68732; + --on-warn-color: var(--background-color-high); + + --disabled-opacity: .4; + + color-scheme: dark; +} + +/* Reset.less + * Props to Eric Meyer (meyerweb.com) for his CSS reset file. We're using an adapted version here that cuts out some of the reset HTML elements we will never need here (i.e., dfn, samp, etc). + * ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- */ + +* { + scroll-margin-top: 40px; +} + +*, *::before, *::after { + margin: 0; + padding: 0; + border: 0; + box-sizing: border-box; +} + +abbr[title], acronym[title] { + border-bottom: 1px dotted; + font-weight: inherit; + cursor: help; +} + +table { + border-collapse: collapse; + border-spacing: 0; +} + +ol, ul { + list-style: none; +} + +html { + font-size: 100%; + height: 100%; +} + +a:focus { + outline: thin dotted; +} + +a:hover, a:active { + outline: 0; +} + +sub, sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +sup { + top: -0.5em; +} + +sub { + bottom: -0.25em; +} + +img { + -ms-interpolation-mode: bicubic; +} + +button, +input, +select, +option, +textarea { + font-size: 100%; + margin: 0; + box-sizing: border-box; + vertical-align: baseline; + line-height: 2em; +} + +button::-moz-focus-inner, input::-moz-focus-inner { + border: 0; + padding: 0; +} + +button, +input[type="button"], +input[type="reset"], +input[type="submit"] { + cursor: pointer; + -webkit-appearance: button; + word-break: break-all; +} + +button[disabled], +input[type="button"][disabled], +input[type="reset"][disabled], +input[type="submit"][disabled] { + opacity: var(--disabled-opacity); +} + +input[type="search"] { + -webkit-appearance: textfield; + box-sizing: content-box; +} + +input[type="search"]::-webkit-search-decoration { + -webkit-appearance: none; +} + +textarea { + overflow: auto; + vertical-align: top; + font-family: monospace; +} + +.control-group { + display: inline-flex; + gap: .2em; +} + +.control-group > input + :not(input):not(select), +.control-group > select + :not(input):not(select) { + margin-left: calc(-.2em + -2px); + border-radius: 0 3px 3px 0; + padding: 0 6px; +} + +/* + * Scaffolding + * Basic and global styles for generating a grid system, structural layout, and page templates + * ------------------------------------------------------------------------------------------- */ +body { + background-color: var(--background-color-high); + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + font-size: 13px; + font-weight: normal; + line-height: 18px; + color: var(--text-color-high); + padding: 5px; + min-height: 100%; + display: flex; + flex-direction: column; + overflow-y: scroll; +} + +.container { + width: 100%; + max-width: 940px; + margin-left: auto; + margin-right: auto; + zoom: 1; +} + +a { + color: var(--primary-color-high); + text-decoration: none; + line-height: inherit; + font-weight: inherit; +} + +a:hover { + color: var(--primary-color-low); + text-decoration: underline; +} + +.pull-right { + float: right; +} + +.pull-left { + float: left; +} + +.nowrap { + white-space: nowrap; +} + +/* Typography.less + * Headings, body text, lists, code, and more for a versatile and durable typography system + * ---------------------------------------------------------------------------------------- */ +p, +.cbi-map-descr, +.cbi-section-descr, +.table .tr.cbi-section-table-descr .th { + font-size: 13px; + font-weight: normal; + line-height: 18px; + margin-bottom: 9px; +} + +p small { + font-size: 11px; + color: var(--text-color-low); +} + +h1, +h2, +h3, legend, +h4, +h5, +h6 { + font-weight: bold; + color: var(--text-color-high); +} + +h1 small, +h2 small, +h3 small, +h4 small, +h5 small, +h6 small { + color: var(--text-color-low); +} + +h1 { + margin-bottom: 18px; + font-size: 30px; + line-height: 36px; +} + +h1 small { + font-size: 18px; +} + +h2 { + font-size: 24px; + line-height: 36px; +} + +h2 small { + font-size: 14px; +} + +h3, legend, +h4, +h5, +h6 { + line-height: 36px; +} + +h3, legend { + font-size: 18px; +} + +h3 small { + font-size: 14px; +} + +h4 { + font-size: 16px; +} + +h4 small { + font-size: 12px; +} + +h5 { + font-size: 14px; +} + +h6 { + font-size: 13px; + color: var(--text-color-low); + text-transform: uppercase; +} + +ul, ol { + margin: 0 0 18px 25px; +} + +ul ul, +ul ol, +ol ol, +ol ul { + margin-bottom: 0; +} + +ul { + list-style: disc; +} + +ol { + list-style: decimal; +} + +li { + line-height: 18px; + color: var(--text-color-medium); +} + +ul.unstyled { + list-style: none; + margin-left: 0; +} + +dl { + margin-bottom: 18px; +} + +dl dt, dl dd { + line-height: 18px; +} + +dl dt { + font-weight: bold; +} + +dl dd { + margin-left: 9px; +} + +hr { + margin: 20px 0 19px; + border: 0; + border-bottom: 1px solid var(--border-color-low); +} + +strong { + font-style: inherit; + font-weight: bold; +} + +em { + font-style: italic; + font-weight: inherit; + line-height: inherit; +} + +small { font-size: 0.9em } + +address { + display: block; + line-height: 18px; + margin-bottom: 18px; +} + +code, pre { + padding: 0 3px 2px; + font-family: Monaco, Andale Mono, Courier New, monospace; + font-size: 12px; + border-radius: 3px; +} + +code { + background-color: var(--border-color-low); + color: var(--text-color-high); + padding: 1px 3px; +} + +pre { + --border-color-h: var(--background-color-h); + --border-color-s: var(--background-color-s); + --border-color-delta-l: 100%; + --border-color-l: calc(var(--background-color-l) + var(--background-color-delta-l-sign) * var(--border-color-delta-l)); + --border-color-a: 0.15; + --border-color: hsla(var(--border-color-hsl), var(--border-color-a)); + + background-color: var(--background-color-low); + display: block; + padding: 8.5px; + margin: 0 0 18px; + line-height: 18px; + font-size: 12px; + border: 1px solid var(--border-color); + border-radius: 3px; + white-space: pre; + white-space: pre-wrap; + word-wrap: break-word; +} + +/* Forms.less + * Base styles for various input types, form layouts, and states + * ------------------------------------------------------------- */ +fieldset { + margin-bottom: 9px; + padding-top: 9px; +} + +fieldset legend { + display: block; + font-size: 19.5px; + line-height: 1; + color: var(--text-color-high); + padding-top: 20px; +} + +label, +input, +button, +select { + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + font-size: 13px; + font-weight: normal; + line-height: normal; +} + +.cbi-value { + display: flex; + flex-wrap: wrap; + margin-bottom: 18px; + align-items: baseline; +} + +.cbi-value-field { + margin-left: 20px; + flex: 1; +} + +.cbi-value label.cbi-value-title { + padding-top: 6px; + font-size: 13px; + line-height: 18px; + flex: 0 0 180px; + text-align: right; + color: var(--text-color-high); +} + +.cbi-value > .cbi-section, +.cbi-value > .cbi-tblsection { + width: 100%; +} + +label > input[type="checkbox"], +label > input[type="radio"] { + vertical-align: text-top; + margin: 0; +} + +label[for] { + cursor: pointer; +} + +input, +textarea, +select, +.cbi-select, +.cbi-dropdown:not(.btn):not(.cbi-button) { + display: inline-block; + width: 210px; + padding: 4px; + background: var(--background-color-high); + color: var(--text-color-high); + font-size: 13px; + line-height: 18px; + border: 1px solid var(--border-color-high); + border-radius: 3px; +} + +input, +select, +.cbi-select, +.cbi-dropdown:not(.btn):not(.cbi-button) { + height: 30px; +} + +.cbi-dropdown:not(.btn):not(.cbi-button), +.cbi-dynlist { + min-width: 210px; + max-width: 400px; + width: auto; +} + +.cbi-dynlist { + height: auto; + min-height: 30px; + display: inline-flex; + flex-direction: column; +} + +.cbi-dynlist > .item { + margin-bottom: 4px; + box-shadow: 0 0 2px var(--border-color-high); + background: var(--background-color-high); + padding: 6px 2em 6px 4px; + border: 1px solid var(--border-color-high); + border-radius: 3px; + color: var(--text-color-high); + position: relative; + pointer-events: auto; /* needed for drag-and-drop in UIDynamicList */ + overflow: hidden; + word-break: break-all; + cursor: move; /* drag-and-drop */ + user-select: text; /* text selection in drag-and-drop */ +} + +.cbi-dynlist > .item::after { + content: "×"; + position: absolute; + display: inline-flex; + align-items: center; + top: -1px; + right: -1px; + bottom: -1px; + padding: 0 6px; + border: 1px solid var(--border-color-high); + border-radius: 0 3px 3px 0; + font-weight: bold; + color: #c44; + pointer-events: auto; +} + +/* indication line for drag-and-drop in UIDynamicList*/ +.cbi-dynlist > .item.drag-over { + border-top: 1px solid var(--text-color-highest); +} + +/* Make item being dragged in UIDynamicList partially transparent*/ +.cbi-dynlist > .item.dragging { + opacity: 0.5; +} + +/* prevent pointer changing when over the span element in UIDynamicList */ +.cbi-dynlist > .item > span { + pointer-events: none; +} + +.cbi-dynlist > .add-item { + display: flex; +} + +/* indication line for drag-and-drop in UIDynamicList*/ +.cbi-dynlist > .add-item > .cbi-input-text.drag-over { + border-top: 1px solid var(--text-color-highest); +} + +/* indication line for drag-and-drop in UIDynamicList*/ +.cbi-dynlist > .add-item > .cbi-button-add.drag-over { + border-top: 1px solid var(--text-color-highest); +} + +.cbi-dynlist > .add-item > input, +.cbi-dynlist > .add-item > button { + flex: 1 1 auto; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.cbi-value-field > .cbi-checkbox, +.cbi-value-field > div > .cbi-radio { + height: 30px; + display: inline-flex; + align-items: center; +} + +.cbi-radio { + cursor: pointer; + gap: .125em; +} + +.cbi-select { + padding: 0; + position: relative; + background: linear-gradient(var(--background-color-high), var(--border-color-low)); +} + +.cbi-select select, +.cbi-select select:focus { + -webkit-appearance: none; + appearance: none; + outline: none; + border: none; + background: transparent; + height: 100%; + width: 100%; + padding: 0 .3em; + cursor: pointer; + margin-right: .6em; +} + +.cbi-select::before { + position: absolute; + top: 0; + right: 0; + bottom: 0; + content: "▾"; + padding: 0 .3em; + background: linear-gradient(var(--background-color-high), var(--border-color-low)); + pointer-events: none; + border-radius: 3px; + display: flex; + flex-direction: column; + justify-content: center; +} + +.cbi-select select option { + background: var(--background-color-low); + color: var(--text-color-high); +} + +.cbi-select select option:hover { + background: var(--primary-color-low); + color: var(--on-primary-color); +} + +.cbi-select select option:checked { + background: var(--primary-color-medium); + color: var(--on-primary-color); +} + +input[type=file] { + padding: initial; + border: initial; + line-height: initial; + box-shadow: none; + width: auto !important; +} + +input[type=button], input[type=reset], input[type=submit] { + width: auto; + height: auto; +} + +select[multiple] { + height: inherit; + background-color: #fff; +} + +input[type="checkbox"], +input[type="radio"] { + --bd-color: var(--border-color-high); + --fg-color: var(--text-color-high); + + appearance: none; + -webkit-appearance: none; + width: 14px; + height: 14px; + color: var(--fg-color); + position: relative; + display: inline-block; + cursor: pointer; + background: none; + border: none; +} + +input[type="checkbox"]::before, +input[type="checkbox"]::after, +input[type="radio"]::before, +input[type="radio"]::after { + position: absolute; + content: ""; +} + +input[type="checkbox"]::before, +input[type="radio"]::before { + top: 0; + left: 0; + width: 14px; + height: 14px; + background: linear-gradient(var(--background-color-high), var(--background-color-low)); + border: 1px solid var(--bd-color); + border-radius: 2px; +} + +input[type="radio"], +input[type="radio"]::before { + border-radius: 50%; +} + +input[type="checkbox"]::after, +input[type="radio"]::after { + top: 2px; + left: 2px; + width: 10px; + height: 10px; +} + +input[type="checkbox"]:checked::after, +input[type="radio"]:checked::after { + --checkmark-icon: url("data:image/svg+xml,"); + -webkit-mask: var(--checkmark-icon) center/cover no-repeat; + mask: var(--checkmark-icon) center/cover no-repeat; + background: var(--fg-color); +} + +input[type="radio"]:checked:after { + --checkmark-icon: url("data:image/svg+xml,"); +} + +input[type="checkbox"].cbi-input-invalid, +input[type="radio"].cbi-input-invalid { + --bd-color: var(--error-color-medium); + --fg-color: var(--error-color-high); +} + +.td > input[type=text], +.td > input[type=password], +.td > select, +.td > .cbi-dropdown:not(.btn):not(.cbi-button), +.cbi-dynlist > .add-item > .cbi-dropdown { + width: 100%; +} + +::placeholder { + color: var(--text-color-medium); +} + +.item::after, +.btn, +.cbi-button, +.cbi-select, +.cbi-dropdown, +.cbi-dynlist > .item, +input[type="checkbox"]::before, +input[type="radio"]::before, +input, button, textarea, select { + transition: border linear 0.2s, box-shadow linear 0.2s; + box-shadow: inset 0 1px 3px hsla(var(--border-color-low-hsl), .01); +} + +.item:hover::after, +.btn:hover, .btn:focus, +.cbi-button:hover, .cbi-button:focus, +.cbi-select.focus, +.cbi-dropdown:focus, .cbi-dropdown[open], +.cbi-dynlist > .item:focus, +input[type="checkbox"]:focus::before, +input[type="radio"]:focus::before, +input:focus, button:hover, textarea:focus, select:focus { + --focus-color-rgb: 82, 168, 236; + + outline: 0; + border-color: rgba(var(--focus-color-rgb), 0.8) !important; + box-shadow: inset 0 1px 3px hsla(var(--border-color-low-hsl), .01), 0 0 8px rgba(var(--focus-color-rgb), 0.6); + text-decoration: none; +} + +.cbi-input-invalid:focus, +.cbi-select.cbi-input-invalid, +input[type="checkbox"].cbi-input-invalid:focus::before, +input[type="radio"].cbi-input-invalid:focus::before { + --focus-color-rgb: var(--error-color-high-rgb); +} + +input[disabled], +button[disabled], +select[disabled], +textarea[disabled], +.cbi-select[disabled]::before, +.cbi-dropdown[disabled]:not(.btn):not(.cbi-button), +input[type="checkbox"][disabled]::before, +input[type="checkbox"][disabled]::after, +input[type="radio"][disabled]::before, +input[type="radio"][disabled]::after { + opacity: var(--disabled-opacity); + pointer-events: none; + cursor: default; +} + +input[readonly], +select[readonly], +textarea[readonly] { + border-color: hsla(var(--border-color-high-hsl), var(--disabled-opacity)); + pointer-events: auto; + cursor: auto; +} + +.cbi-optionals, +.cbi-section-create { + padding: 0 0 10px 10px; +} + +.cbi-section-create { + margin: -3px; + display: inline-flex; + align-items: center; +} + +.cbi-section-create > * { + margin: 3px; + flex: 1 1 auto; +} + +.cbi-section-create > * > input { + width: 100%; +} + +.actions, +.cbi-page-actions { + background: var(--background-color-low); + margin-bottom: 18px; + padding: 17px 20px 18px 17px; + border-top: 1px solid var(--border-color-medium); + border-radius: 0 0 3px 3px; + text-align: right; +} + +.actions .secondary-action, +.cbi-page-actions .secondary-action{ + float: right; +} + +.actions .secondary-action a, +.cbi-page-actions .secondary-action a { + line-height: 30px; +} + +.actions .secondary-action a:hover, +.cbi-page-actions .secondary-action a:hover { + text-decoration: underline; +} + +.cbi-page-actions > form { + display: inline; + margin: 0; +} + +/* + * Tables.less + * Tables for, you guessed it, tabular data + * ---------------------------------------- */ +.tr { display: table-row; } +.table[width="33%"], .th[width="33%"], .td[width="33%"] { width: 33%; } +.table[width="100%"], .th[width="100%"], .td[width="100%"] { width: 100%; } + +.table { + display: table; + width: 100%; + margin-bottom: 18px; + padding: 0; + font-size: 13px; + border-collapse: collapse; + position: relative; +} + +.table .th, .table .td { + display: table-cell; + vertical-align: middle; /* Fixme */ + padding: 10px 10px 9px; + line-height: 18px; + text-align: left; +} + +.table .tr:first-child .th { + padding-top: 9px; + font-weight: bold; + vertical-align: top; +} + +.table .td, .table .th { + border-top: 1px solid var(--border-color-medium); +} + +.tr.placeholder { + height: calc(3em + 20px); +} + +.tr.placeholder > .td { + position: absolute; + left: 0; + right: 0; + bottom: 0; + text-align: center; + line-height: 3em; +} + +.tr.drag-over-above, +.tr.drag-over-below { + border: 2px solid #0069d6; + border-width: 2px 0 0 0; +} + +.tr.drag-over-below { + border-width: 0 0 2px 0; +} + +/* Patterns.less + * Repeatable UI elements outside the base styles provided from the scaffolding + * ---------------------------------------------------------------------------- */ +header { + position: sticky; + top: 0; + z-index: 800; + overflow: visible; + color: #BFBFBF; + margin: -5px -5px 15px -5px; + display: flex; +} + +header a { + color: #bfbfbf; + text-shadow: 0 -1px 0 hsla(var(--border-color-low-hsl), .25); +} + +header .brand:hover, header ul .active > a { + background-color: #333; + background-color: rgba(255, 255, 255, 0.05); + color: #fff; + text-decoration: none; +} + +header .brand { + float: left; + display: block; + padding: 8px 20px 12px; + margin-left: -15px; + color: #fff; + font-size: 20px; + font-weight: 200; + line-height: 1; +} + +header .pull-right { + margin-left: auto; + margin-right: 5px; + align-self: center; +} + +header p { + margin: 0; + line-height: 40px; +} + +header { + background: linear-gradient(#333333, #222222); + box-shadow: 0 1px 3px hsla(var(--border-color-low-hsl), .25), inset 0 -1px 0 hsla(var(--border-color-low-hsl), .01); + padding: 0; + /* the redundant properties below work around a csstidy bug */ + padding-left: calc((100% - 940px) / 2); + padding-right: calc((100% - 940px) / 2); +} + +.nav { + display: block; + float: left; + margin: 0 10px 0 0; + position: relative; + left: 0; +} + +.nav > li { + display: block; + float: left; +} + +.nav a { + display: block; + float: none; + padding: 10px 10px 11px; + line-height: 19px; + text-decoration: none; +} + +.nav a:hover { + color: #fff; + text-decoration: none; +} + +.nav .active > a { + background-color: #222; + background-color: rgba(0, 0, 0, 0.5); +} + +.nav a.menu:hover { + background: #444; + background: rgba(255, 255, 255, 0.05); +} + +.nav .dropdown-menu { + background-color: #333; +} + +.nav .dropdown-menu a.menu { + color: #fff; +} + +.nav .dropdown-menu li a { + color: #999; + text-shadow: 0 1px 0 rgba(0, 0, 0, 0.5); +} + +.nav .dropdown-menu li a:hover { + background-color: #191919; + background-repeat: repeat-x; + background-image: linear-gradient(to bottom, #292929, #191919); + color: #fff; +} + +.nav .dropdown-menu .active a { + color: #fff; +} + +.nav .dropdown-menu li a { + padding: 4px 15px; +} + +li.menu, .dropdown { + position: relative; +} + +a.menu:after { + width: 0; + height: 0; + display: inline-block; + content: "↓"; + text-indent: -99999px; + vertical-align: top; + margin-top: 8px; + margin-left: 4px; + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-top: 4px solid #fff; + opacity: 0.5; +} + +.dropdown-menu { + background-color: #fff; + float: left; + position: absolute; + top: 40px; + left: -9999px; + z-index: 900; + min-width: max-content; + margin-left: 0; + margin-right: 0; + padding: 6px 0; + zoom: 1; + border-color: #999; + border-color: rgba(0, 0, 0, 0.2); + border-style: solid; + border-width: 0 1px 1px; + border-radius: 0 0 6px 6px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + background-clip: padding-box; +} + +.dropdown-menu li { + float: none; + display: block; + background-color: transparent; +} + +.dropdown-menu a { + display: block; + padding: 4px 15px; + clear: both; + font-weight: normal; + line-height: 18px; + color: #808080; + text-shadow: 0 1px 0 #fff; +} + +.dropdown-menu a:hover { + background-color: #ddd; + background-repeat: repeat-x; + background-image: linear-gradient(to bottom, #eee, #ddd); + color: #404040; + text-decoration: none; + box-shadow: inset 0 1px 0 rgba(0, 0, 0, 0.025), inset 0 -1px rgba(0, 0, 0, 0.025); +} + +.dropdown:hover ul.dropdown-menu { + left: 0; +} + +.dropdown-menu .dropdown-menu { + position: absolute; + left: 159px; +} + +.dropdown-menu li { + position: relative; +} + +.tabs, .cbi-tabmenu { + --tab-bar-background-color: var(--background-color-high); + --tab-inactive-background-color-h: var(--border-color-low-h); + --tab-inactive-background-color-s: var(--border-color-low-s); + --tab-inactive-background-color-l: var(--border-color-low-l); + --tab-inactive-background-color: var(--border-color-low); + --tab-inactive-border-color: var(--border-color-medium); + --tab-inactive-text-color-delta-l: 33.33%; + --tab-inactive-text-color-l: calc(var(--tab-inactive-background-color-l) + var(--background-color-delta-l-sign) * var(--tab-inactive-text-color-delta-l)); + --tab-inactive-text-color: hsl(var(--tab-inactive-background-color-hsl)); + --tab-inactive-hover-background-color: var(--background-color-high); + --tab-active-background-color: var(--background-color-high); + --tab-active-text-color: #0069d6; + --tab-active-border-color: var(--border-color-medium); + + margin: 0 -5px 18px; + padding: 0 2px; + list-style: none; + display: flex; + flex-wrap: wrap; + background: linear-gradient(var(--tab-bar-background-color) 28px, var(--tab-inactive-border-color) 28px); + background-size: 1px 29px; + background-position: left bottom; +} + +.tabs > li, .cbi-tabmenu > li { + flex: 0 1 auto; + display: flex; + align-items: center; + height: 25px; + max-width: 48%; + margin: 4px 2px 0 2px; + background: var(--tab-active-background-color); + border: 1px solid var(--tab-active-border-color); + border-bottom: none; + border-radius: 4px 4px 0 0; + color: var(--primary-color-high); +} + +.tabs > li > a, .cbi-tabmenu > li > a { + padding: 4px 6px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: inherit; + text-decoration: none; + border-radius: 4px 4px 0 0; + line-height: 25px; + outline: none; +} + +.tabs > li > a:focus-visible, .cbi-tabmenu > li > a:focus-visible { + text-decoration: underline; +} + +.tabs > li:not(.active):hover, .cbi-tabmenu > .cbi-tab-disabled:hover { + background: linear-gradient(var(--tab-inactive-hover-background-color) 90%, var(--tab-inactive-border-color) 100%); +} + +.tabs > li:not(.active), .cbi-tabmenu > .cbi-tab-disabled { + color: var(--tab-inactive-text-color); + background: linear-gradient(var(--tab-inactive-background-color) 90%, var(--tab-inactive-border-color) 100%); +} + +.cbi-tab-disabled[data-errors]::after { + content: attr(data-errors); + background: #c43c35; + color: #fff; + height: 16px; + min-width: 16px; + border-radius: 8px; + text-align: center; + margin: 0 5px 0 0; + padding: 3px 2px 1px 2px; + display: inline-flex; + flex-direction: column; + justify-content: center; + font-size: 12px; +} + +.cbi-tabmenu.map { + margin: 0; +} + +.cbi-tabmenu.map > li { + font-size: 16.5px; + font-weight: bold; +} + +.cbi-tab-descr { + margin: -9px 0 18px 0; +} + +.tabs .menu-dropdown, .tabs .dropdown-menu { + top: 35px; + border-width: 1px; + border-radius: 0 6px 6px 6px; +} + +.tabs a.menu:after, .tabs .dropdown-toggle:after { + border-top-color: #999; + margin-top: 15px; + margin-left: 5px; +} + +.tabs li.open.menu .menu, .tabs .open.dropdown .dropdown-toggle { + border-color: #999; +} + +.tabs li.open a.menu:after, .tabs .dropdown.open .dropdown-toggle:after { + border-top-color: #555; +} + +.tab-content > .tab-pane, +.tab-content > div { + display: none; +} + +.tab-content > .active { + display: block; +} + +.breadcrumb { + padding: 7px 14px; + margin: 0 0 18px; + background: linear-gradient(to bottom, var(--background-color-high), var(--background-color-low)); + border: 1px solid var(--border-color-medium); + border-radius: 3px; + display: flex; + flex: 0; +} + +.breadcrumb li { + list-style: none; +} + +.breadcrumb li:not(:last-child)::after { + content: "|"; + padding: 0 5px; +} + +.breadcrumb .active a { + color: var(--text-color-medium); +} + +footer { + padding-top: 17px; + margin-top: auto; + border-top: 1px solid var(--border-color-low); + display: flex; + flex-wrap: wrap; + align-items: baseline; + justify-content: space-between; + font-size: 12px; + color: var(--text-color-medium); + /* the redundant properties below work around a csstidy bug */ + padding-left: calc((100% - 940px) / 2); + padding-right: calc((100% - 940px) / 2); +} + +footer a { + color: var(--primary-color-low); +} + +footer span { + margin-bottom: 1em; +} + +footer ul.breadcrumb { + margin-left: auto; +} + +#modal_overlay { + position: fixed; + top: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + z-index: 900; + overflow: auto; + transition: opacity .125s ease-in; + opacity: 0; + visibility: hidden; +} + +.modal { + width: 90%; + margin: 5em auto; + min-height: 32px; + max-width: 600px; + border-radius: 3px; + background: var(--background-color-high); + border: 1px solid var(--border-color-low); + box-shadow: 0 0 3px var(--background-color-low); + padding: 1em 1em .5em 1em; + min-width: 270px; +} + +.modal > * { + line-height: normal; + margin-bottom: .5em; + max-width: 100%; +} + +.modal > pre, +.modal > textarea { + white-space: pre-wrap; + overflow: auto; + width: 100%; +} + +.modal > .button-row { + display: flex; + justify-content: space-between; +} + +.modal > .button-row > button:not(:last-of-type) { + margin-right: .5em; +} + +.modal > .button-row > button:first-of-type { + margin-right: auto; +} + +body.modal-overlay-active { + overflow: hidden; + height: 100vh; +} + +body.modal-overlay-active #modal_overlay { + left: 0; + right: 0; + opacity: 1; + visibility: visible; +} + +.btn .close, .alert-message .close { + font-family: Arial, sans-serif; + line-height: 18px; +} + +.btn.danger, +.alert-message.danger, +.btn.error, +.alert-message.error, +.cbi-tooltip.error { + background: linear-gradient(var(--error-color-low), var(--error-color-medium)); + color: var(--on-error-color); + border-color: var(--error-color-high) var(--error-color-high) var(--error-color-low); +} + +.btn.success, .alert-message.success, .cbi-tooltip.success { + background: linear-gradient(var(--success-color-low), var(--success-color-medium)); + color: var(--on-error-color); + border-color: var(--success-color-high) var(--success-color-high) var(--success-color-low); +} + +.btn.info, .alert-message.info, .cbi-tooltip.info { + background: linear-gradient(var(--primary-color-low), var(--primary-color-medium)); + color: var(--on-primary-color); + border-color: var(--primary-color-high) var(--primary-color-high) var(--primary-color-low); +} + +.alert-message.notice, .cbi-tooltip.notice { + background: linear-gradient(var(--background-color-low), var(--background-color-medium)); + border-color: var(--background-color-high) var(--background-color-high) var(--background-color-low); + color: var(--text-color-high); +} + +.alert-message.warning { + background: linear-gradient(var(--warn-color-low), var(--warn-color-medium)); + border-color: var(--warn-color-high) var(--warn-color-high) var(--warn-color-low); + color: var(--on-warn-color); +} + +.modal.alert-message { + color: var(--text-color-high); +} + +.item::after, +.btn, +.cbi-button { + --default-btn-background: linear-gradient(var(--background-color-high), var(--background-color-high) 25%, var(--background-color-low)); + --on-color: var(--text-color-high); + + cursor: pointer; + display: inline-block; + background: var(--default-btn-background); + padding: 0 14px; + color: var(--on-color); + font-size: 13px; + line-height: 2em; + border: 1px solid var(--border-color-high); + border-radius: 4px; + white-space: pre; +} + +.cbi-input-invalid, +.cbi-input-invalid.cbi-dropdown:not(.btn):not(.cbi-button), +.cbi-input-invalid.cbi-dropdown:not([open]) > ul > li, +.cbi-value-error input { + color: var(--error-color-high); + border-color: var(--error-color-medium); +} + +.cbi-button-positive, +.cbi-button-fieldadd, +.cbi-button-add, +.cbi-button-save { + --on-color: var(--success-color-high); + border-color: var(--on-color); +} + +.cbi-button-neutral, +.cbi-button-download, +.cbi-button-find, +.cbi-button-link, +.cbi-button-up, +.cbi-button-down { + color: var(--text-color-high); +} + +.btn.primary, +.cbi-button-action, +.cbi-button-apply, +.cbi-button-reload, +.cbi-button-edit { + --on-color: var(--primary-color-high); + border-color: var(--on-color); +} + +.cbi-button-negative, +.cbi-section-remove .cbi-button, +.cbi-button-reset, +.cbi-button-remove { + --on-color: var(--error-color-high); + border-color: var(--on-color); +} + +.cbi-page-actions::after { + display: table; + content: ""; + clear: both; +} + +.cbi-page-actions > * { + vertical-align: middle; +} + +.cbi-page-actions > :not([method="post"]):not(.cbi-button-apply):not(.cbi-button-negative):not(.cbi-button-save):not(.cbi-button-reset) { + float: left; + margin-right: .4em; +} + +.btn.primary, +.cbi-button-action.important, +.cbi-page-actions .cbi-button-apply, +.cbi-section-actions .cbi-button-edit { + --on-color: var(--on-primary-color); + background: linear-gradient(var(--primary-color-medium), var(--primary-color-low)); + border-color: var(--primary-color-high); +} + +.cbi-button-positive.important, +.cbi-page-actions .cbi-button-save { + --on-color: var(--on-success-color); + background: linear-gradient(var(--success-color-medium), var(--success-color-low)); + border-color: var(--success-color-high); +} + +.cbi-button-negative.important { + --on-color: var(--on-error-color); + background: linear-gradient(var(--error-color-medium), var(--error-color-low)); + border-color: var(--error-color-high); +} + +.cbi-page-actions .cbi-button-apply + .cbi-button-save, +.cbi-page-actions .cbi-button-negative + .cbi-button-save { + --on-color: var(--success-color-high); + border-color: var(--on-color); + background: var(--default-btn-background); +} + +.cbi-dropdown { + display: inline-flex !important; + cursor: pointer; + height: auto; + position: relative; + padding: 0 !important; +} + +.cbi-dropdown:not(.btn):not(.cbi-button) { + background: linear-gradient(var(--background-color-high) 0%, var(--border-color-low) 100%); + border: 1px solid var(--border-color-high); + border-radius: 3px; + color: var(--text-color-high); +} + +.cbi-dropdown img { + /* the following are necessary to constrain (vector) images of any size */ + max-height: 1.6em; /* Adjusts image height to fit slightly larger than text line height */ + width: auto; /* Preserves aspect ratio */ +} + +.cbi-dropdown > ul { + margin: 0 !important; + padding: 0; + list-style: none; + overflow-x: hidden; + overflow-y: hidden; + display: flex; + width: 100%; +} + +.cbi-dropdown.btn > ul:not(.dropdown), +.cbi-dropdown.cbi-button > ul:not(.dropdown) { + margin: 0 0 0 13px !important; +} + +.cbi-dropdown.btn.spinning > ul:not(.dropdown), +.cbi-dropdown.cbi-button.spinning > ul:not(.dropdown) { + margin: 0 !important; +} + +.cbi-dropdown > ul.preview { + display: none; +} + +.cbi-dropdown > .open, +.cbi-dropdown > .more { + flex-grow: 0; + flex-shrink: 0; + display: flex; + flex-direction: column; + justify-content: center; + text-align: center; + line-height: 2em; + padding: 0 .3em; +} + +.cbi-dropdown.btn > .open, +.cbi-dropdown.cbi-button > .open { + padding: 0 .5em; + margin-left: .5em; + border-left: 1px solid; +} + +.cbi-dropdown > .more, +.cbi-dropdown:not(.btn):not(.cbi-button) > ul > li[placeholder] { + color: var(--text-color-medium); + display: none; +} + +.cbi-dropdown > ul > li { + display: none; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex-shrink: 1; + flex-grow: 1; + align-items: center; + align-self: center; + color: inherit; +} + +.cbi-dropdown > ul.dropdown > li, +.cbi-dropdown:not(.btn):not(.cbi-button) > ul > li { + min-height: 20px; + padding: .25em; + color: var(--text-color-high); +} + +.cbi-dropdown > ul > li .hide-open { display: block; display: initial; } +.cbi-dropdown > ul > li .hide-close { display: none; } + +.cbi-dropdown > ul > li[display]:not([display="0"]) { + border-left: 1px solid var(--border-color-high); +} + +.cbi-dropdown[empty] > ul { + max-width: 1px; +} + +.cbi-dropdown > ul > li > form { + display: none; + margin: 0; + padding: 0; + pointer-events: none; +} + +.cbi-dropdown > ul > li img { + vertical-align: middle; + margin-right: .25em; +} + +.cbi-dropdown > ul > li > form > input[type="checkbox"] { + margin: 0 .25em 0 0; +} + +.cbi-dropdown > ul > li input[type="text"] { + height: 20px; +} + +.cbi-dropdown[open] { + position: relative; +} + +.cbi-dropdown[open] > ul.dropdown { + display: block; + background: var(--background-color-low); + border: 1px solid #918e8c; + box-shadow: 0 0 4px #918e8c; + position: absolute; + z-index: 1100; + max-width: none; + min-width: 100%; + width: auto; + transition: max-height .125s ease-in; + overflow-y: auto; +} + +.cbi-dropdown > ul > li[display], +.cbi-dropdown[open] > ul.preview, +.cbi-dropdown[open] > ul.dropdown > li, +.cbi-dropdown[multiple] > ul > li > label, +.cbi-dropdown[multiple][open] > ul.dropdown > li, +.cbi-dropdown[multiple][more] > .more, +.cbi-dropdown[multiple][empty] > .more { + flex-grow: 1; + display: flex !important; +} + +.cbi-dropdown[empty] > ul > li, +.cbi-dropdown[optional][open] > ul.dropdown > li[placeholder] { + display: block !important; +} + +.cbi-dropdown[multiple][open] > ul.dropdown > li > form { + display: flex !important; +} + +.cbi-dropdown[open] > ul.dropdown > li .hide-open { display: none; } +.cbi-dropdown[open] > ul.dropdown > li .hide-close { display: block; display: initial; } + +.cbi-dropdown[open] > ul.dropdown > li { + border-bottom: 1px solid var(--border-color-high); +} + +.cbi-dropdown[open] > ul.dropdown > li[selected] { + background: var(--primary-color-medium); + color: var(--on-primary-color); +} + +.cbi-dropdown[open] > ul.dropdown > li.focus, +.cbi-dropdown[open] > ul.dropdown > li:hover { + background: var(--primary-color-low); + color: var(--on-primary-color); +} + +.cbi-dropdown[open] > ul.dropdown > li[selected].focus, +.cbi-dropdown[open] > ul.dropdown > li[selected]:hover { + background: linear-gradient(90deg, var(--primary-color-medium) 50%, var(--primary-color-low)); +} + +.cbi-dropdown[open] > ul.dropdown > li:last-child { + margin-bottom: 0; + border-bottom: none; +} + +.cbi-dropdown[open] > ul.dropdown > li[unselectable] { + opacity: 0.7; +} + +.cbi-dropdown[open] > ul.dropdown > li > input.create-item-input:first-child:last-child { + width: 100%; +} + +.cbi-title-ref { + color: #37c; +} + +.cbi-title-ref::after { + content: "➙"; +} + +.cbi-tooltip-container { + cursor: help; +} + +.cbi-tooltip { + position: absolute; + z-index: 1000; + left: -10000px; + box-shadow: 0 0 2px var(--border-color-high); + border-radius: 3px; + background: var(--background-color-high); + white-space: pre; + padding: 2px 5px; + opacity: 0; + transition: opacity .25s ease-in; +} + +.cbi-tooltip-container:hover .cbi-tooltip:not(:empty) { + left: auto; + opacity: 1; + transition: opacity .25s ease-in; +} + +.cbi-progressbar { + border: 1px solid var(--border-color-high); + border-radius: 3px; + position: relative; + min-width: 170px; + height: 8px; + margin: 1.4em 0 4px 0; + background: var(--background-color-medium); +} + +.cbi-progressbar > div { + background: var(--primary-color-medium); + height: 100%; + transition: width .25s ease-in; + width: 0%; + border-radius: 2px; +} + +.cbi-progressbar::before { + position: absolute; + top: -1.4em; + left: 0; + content: attr(title); + white-space: pre; + overflow: hidden; + text-overflow: ellipsis; +} + +.zonebadge .cbi-tooltip { + padding: 1px; + background: inherit; + margin: -1.6em 0 0 -5px; + border-radius: 3px; + pointer-events: none; + box-shadow: 0 0 3px #444; +} + +.zonebadge .cbi-tooltip > * { + margin: 1px; + z-index: 2; + position: relative; +} + +.zone-forwards { + display: flex; + flex-wrap: nowrap; +} + +.zone-forwards > * { + flex: 1 1 40%; + padding: 1px; + text-wrap: auto; +} + +.zone-forwards > span { + flex-basis: 10%; + text-align: center; +} + +.zone-forwards .zone-src, +.zone-forwards .zone-dest { + display: flex; + flex-direction: column; +} + +.btn.active, .btn:active { + box-shadow: inset 0 2px 4px hsla(var(--border-color-low-hsl), .25), 0 1px 2px rgba(0, 0, 0, 0.05); +} + +.btn.disabled, +.btn[disabled] { + cursor: default; + opacity: var(--disabled-opacity); + box-shadow: none; +} + +.btn.large { + font-size: 15px; + line-height: normal; + padding: 9px 14px 9px; + border-radius: 6px; +} + +.btn.small { + padding: 7px 9px 7px; + font-size: 11px; +} + +button.btn::-moz-focus-inner, input[type=submit].btn::-moz-focus-inner { + padding: 0; + border: 0; +} + +.close { + float: right; + color: #000; + font-size: 20px; + font-weight: bold; + line-height: 13.5px; + text-shadow: 0 1px 0 #fff; + opacity: 0.25; +} + +.close:hover { + color: #000; + text-decoration: none; + opacity: 0.4; +} + +.alert-message { + position: relative; + padding: .5em .5em .25em .5em; + margin-bottom: .5em; + color: var(--on-warn-color); + background: linear-gradient(#fceec1, #eedc94); + border: 1px solid var(--border-color-low); + border-color: var(--border-color-high) var(--border-color-high) var(--border-color-low); + border-radius: 4px; + box-shadow: 1px 1px 1px var(--border-color-low); + white-space: unset; +} + +.alert-message .close { + margin-top: 1px; +} + +.alert-message h4, +.alert-message h5, +.alert-message pre, +.alert-message ul, +.alert-message li, +.alert-message p { + color: inherit; + border: none; + line-height: inherit; + background: transparent; + padding: 0; + margin: .25em 0; +} + +.alert-message button { + margin: .25em 0; +} + +.label, +header [data-indicator] { + padding: 3px 3px 2px; + font-size: 9.75px; + font-weight: bold; + color: var(--text-color-high); + text-transform: uppercase; + white-space: nowrap; + background-color: var(--background-color-low); + border-radius: 3px; + text-shadow: none; + margin: .125em 0 .125em .4em; +} + +header [data-indicator][data-clickable] { + cursor: pointer; +} + +a.label:link, +a.label:visited { + color: #fff; +} + +a.label:hover { + text-decoration: none; +} + +.label.important { + background-color: var(--primary-color-high); + color: var(--on-primary-color); +} + +.label.warning { + background-color: var(--warn-color-high); + color: var(--on-warn-color); +} + +.label.success { + background-color: var(--success-color-high); + color: var(--on-success-color); +} + +.label.notice, +header [data-indicator][data-style="active"] { + color: var(--on-primary-color); + background-color: var(--primary-color-high); +} + +/* LuCI specific items */ +.hidden { display: none } + +form.inline { display: inline; margin-bottom: 0; } + +#modemenu li:last-child span.divider { display: none } + +#syslog { + width: 100%; + color: var(--text-color-highest); + margin-bottom: 18px; + font-family: monospace; +} + +.cbi-section-table .tr:hover .td, +.cbi-section-table .tr:hover .th, +.cbi-section-table .tr:hover::before { + background-color: var(--background-color-low); +} + +.cbi-section-table .tr.cbi-section-table-descr .th { + font-weight: normal; +} + +.cbi-section-table-titles.named::before, +.cbi-section-table-descr.named::before, +.cbi-section-table-row[data-title]::before { + content: attr(data-title) " "; + display: table-cell; + padding: 10px 10px 9px; + line-height: 18px; + font-weight: bold; + vertical-align: middle; +} + +.cbi-section-table-titles.named::before, +.cbi-section-table-descr.named::before, +.cbi-section-table-row[data-title]::before { + border-top: 1px solid var(--border-color-medium); +} + +.left { text-align: left !important; } +.right { text-align: right !important; } +.center { text-align: center !important; } +.top { vertical-align: top !important; } +.middle { vertical-align: middle !important; } +.bottom { vertical-align: bottom !important; } + +table table td, +.cbi-value-field table td { + border: none; +} + +.table.cbi-section-table input[type="password"], +.table.cbi-section-table input[type="text"], +.table.cbi-section-table textarea, +.table.cbi-section-table select, +.table.cbi-section-table .cbi-select { + width: 100%; + min-width: auto; +} + +.table.cbi-section-table .td.cbi-section-table-cell { + white-space: nowrap; + text-align: right; +} + +.table.cbi-section-table .td.cbi-section-table-cell select, +.table.cbi-section-table .td.cbi-section-table-cell .cbi-select { + width: inherit; +} + +.td.cbi-section-actions { + text-align: right; + vertical-align: middle; + width: 15%; +} + +.td.cbi-section-actions > * { + display: flex; +} + +.td.cbi-section-actions > :not(.cbi-dropdown) > *, +.td.cbi-section-actions > * > form > * { + flex: 1 1 4em; + margin: 0 1px; +} + +.td.cbi-section-actions > * > form { + display: inline-flex; + margin: 0; +} + +.table.valign-middle .td { + vertical-align: middle; +} + +.cbi-rowstyle-2, +.tr.table-titles, +.tr.cbi-section-table-titles { + background: var(--background-color-medium); +} + +th[data-sortable-row] { cursor: pointer; } +th[data-sort-direction="asc"]::after { content: "\a0\25b2"; } +th[data-sort-direction="desc"]::after { content: "\a0\25bc"; } + +.cbi-value-description { + margin: .25em 0 0 1.25em; + position: relative; +} + +.cbi-value-description:not(:empty)::before { + --help-icon: url("data:image/svg+xml,"); + + position: absolute; + left: -1.25em; + content: "\a0"; + display: inline-block; + width: 1em; + height: 1em; + margin-right: .25em; + background: var(--primary-color-high); + mask-image: var(--help-icon); + mask-size: cover; + -webkit-mask-image: var(--help-icon); + -webkit-mask-size: cover; +} + +.cbi-section-error { + border: 1px solid #f00; + border-radius: 3px; + background-color: #fce6e6; + padding: 5px; + margin-bottom: 18px; +} + +.cbi-section-error ul { margin: 0 0 0 20px; } + +.cbi-section-error ul li { + color: #f00; + font-weight: bold; +} + +.ifacebox { + border: 1px solid var(--border-color-high); + margin: 0 10px; + text-align: center; + white-space: nowrap; + background: linear-gradient(var(--background-color-high), var(--background-color-medium)); + border-radius: 4px; + box-shadow: inset 0 1px 0 hsla(var(--text-color-low-hsl), 0.05); + display: inline-flex; + flex-direction: column; + line-height: 1.2em; + min-width: 100px; +} + +.ifacebox .ifacebox-head { + border-bottom: 1px solid var(--border-color-high); + border-radius: 3px 3px 0 0; + padding: 2px; + background: #eee; + color: var(--text-color-high); +} + +.ifacebox .ifacebox-head[style] { + text-shadow: 0 1px 1px hsla(var(--background-color-high-hsl), 0.75); +} + +.ifacebox .ifacebox-head.active { + background: var(--primary-color-high); + color: var(--on-primary-color); +} + +[data-darkmode="true"] .zonebadge[style], +[data-darkmode="true"] .ifacebox-head[style] { + background: linear-gradient(rgba(var(--zone-color-rgb), .4), rgba(var(--zone-color-rgb), .3)) !important; +} + +.ifacebox .ifacebox-body { + padding: .25em; +} + +#cbi-network-interface .ifacebox-body { + white-space: normal; +} + +.ifacebadge { + display: inline-block; + flex-direction: row; + white-space: nowrap; + border: 1px solid var(--border-color-high); + padding: 2px; + background: linear-gradient(var(--background-color-high), var(--background-color-high) 25%, var(--background-color-medium)); + color: var(--text-color-high); + text-shadow: 0 1px 1px hsla(var(--background-color-high-hsl), 0.75); + border-radius: 4px; + box-shadow: inset 0 1px 0 hsla(var(--background-color-high-hsl), 0.05); + cursor: default; + line-height: 1.2em; +} + +.ifacebadge img { + width: 32px; + height: 32px; + vertical-align: middle; +} + +.ifacebadge-active { + border-color: #000; + font-weight: bold; +} + +.network-status-table { + display: flex; + flex-wrap: wrap; +} + +.network-status-table .ifacebox { + margin: .5em; + flex-grow: 1; +} + +.network-status-table .ifacebox-body { + display: flex; + flex-direction: column; + height: 100%; + text-align: left; +} + +.network-status-table .ifacebox-body > * { + margin: .25em; +} + +.network-status-table .ifacebox-body > span { + flex: 10 10 auto; + height: 100%; +} + +.network-status-table .ifacebox-body > div { + margin: -.125em; + display: flex; + flex-wrap: wrap; +} + +#dsl_status_table .ifacebox-body span > strong { + display: inline-block; + min-width: 35%; +} + +.ifacebadge.large, +.network-status-table .ifacebox-body .ifacebadge { + display: flex; + flex: 1; + padding: .25em; + min-width: 220px; + margin: .125em; +} + +.ifacebadge.large { + display: inline-flex; +} + +.network-status-table .ifacebox-body .ifacebadge > span { + overflow: hidden; + text-overflow: ellipsis; +} + +.ifacebadge > *, +.ifacebadge.large > * { + margin: 0 .125em; +} + +.zonebadge { + padding: 2px; + border-radius: 4px; + display: inline-block; + white-space: nowrap; + color: var(--text-color-high); + text-shadow: 0 1px 1px hsla(var(--background-color-high-hsl), 0.75); +} + +.zonebadge > em, +.zonebadge > strong { + margin: 0 2px; + display: inline-block; +} + +.zonebadge input { + width: 6em; +} + +.zonebadge > .ifacebadge { + margin-left: 2px; +} + +.zonebadge-empty { + border: 1px dashed #aaa; + color: #aaa; + font-style: italic; + font-size: smaller; +} + +div.cbi-value var, +.td.cbi-value-field var { + font-style: italic; + color: #0069d6; +} + +div.cbi-value var[data-tooltip], +.td.cbi-value-field var[data-tooltip], +div.cbi-value var.cbi-tooltip-container, +.td.cbi-value-field var.cbi-tooltip-container { + cursor: help; + border-bottom: 1px dotted #0069d6; +} + +div.cbi-value var.cbi-tooltip-container, +.td.cbi-value-field var.cbi-tooltip-container .cbi-tooltip { + font-style: normal; + white-space: normal; + color: var(--text-color-high); +} + +#modal_overlay > .modal.uci-dialog, +#modal_overlay > .modal.cbi-modal { + max-width: 900px; +} + +#modal_overlay > .modal.login > button.important { + font-size: 120% !important; + margin-top: .5em; + width: 100%; +} + +.uci-change-list { + line-height: 170%; + white-space: pre; +} + +.uci-dialog div > del, +.uci-dialog div > ins, +.uci-dialog div > var { + margin-bottom: 2px; + border: 1px solid var(--border-color-high); + line-height: 15px; + overflow: hidden; + text-overflow: ellipsis; + padding: 2px; + position: relative; + background-color: var(--background-color-low); +} + +.uci-dialog div > ins { + background-color: rgba(var(--success-color-high-rgb), .3); + border-color: rgba(var(--success-color-high-rgb), .6); +} + +.uci-dialog div > del { + background-color: rgba(var(--error-color-high-rgb), .3); + border-color: rgba(var(--error-color-high-rgb), .6); +} + +.uci-dialog var > ins { + background-color: rgba(var(--success-color-high-rgb), .3); +} + +.uci-dialog var > del { + background-color: rgba(var(--error-color-high-rgb), .3); +} + +.uci-change-list > var > * { + overflow: hidden; + text-overflow: ellipsis; +} + +.uci-dialog del, +.uci-dialog ins, +.uci-dialog var { + text-decoration: none; + font-family: monospace; + font-style: normal; + color: var(--text-color-high); + display: block; +} + +.uci-change-legend { + display: flex; + flex-wrap: wrap; +} + +.uci-change-legend-label { + flex-basis: 150px; + margin: 2px; + display: flex; + align-items: center; +} + +.uci-change-legend-label > ins, +.uci-change-legend-label > del, +.uci-change-legend-label > var { + margin-right: 4px; + width: 16px; + height: 16px; + display: flex; +} + +.uci-change-legend-label > * > * { + flex-basis: 100%; + padding: 1px; +} + +#applyreboot-section { + line-height: 300%; +} + +@keyframes flash { + 0% { opacity: 1; } + 50% { opacity: .5; } + 100% { opacity: 1; } +} + +.flash { + animation: flash .35s; +} + +#view > div.spinning:first-child { + display: table; + margin: 15vh auto; +} + +.spinning { + position: relative; + padding-left: 32px !important; +} + +.spinning::before { + --spinner-icon: url("data:image/svg+xml,"); + + position: absolute; + top: calc(50% - 10px); + left: 6px; + width: 20px; + height: 20px; + content: " "; + background: var(--on-color, #000); + mask: var(--spinner-icon) center/cover no-repeat; + -webkit-mask: var(--spinner-icon) center/cover no-repeat; +} + +[data-darkmode="true"] .spinning::before { + background: var(--on-color, #fff); +} + +[data-tab-title] { + height: 0; + opacity: 0; + overflow: hidden; +} + +[data-tab-active="true"] { + opacity: 1; + height: auto; + overflow: visible; + transition: opacity .25s ease-in; +} + +.cbi-filebrowser { + min-width: 210px; + max-width: 100%; + border: 1px solid #ccc; + border-radius: 3px; + display: flex; + flex-direction: column; + opacity: 0; + height: 0; + overflow: hidden; +} + +.cbi-filebrowser.open { + opacity: 1; + height: auto; + overflow: visible; + transition: opacity .25s ease-in; +} + +.cbi-filebrowser > * { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + padding: 0 0 .25em 0; + margin: .25em .25em 0px .25em; + white-space: nowrap; + border-bottom: 1px solid #ccc; +} + +.cbi-filebrowser .cbi-button-positive { + margin-right: .25em; +} + +.cbi-filebrowser > div { + border-bottom: none; +} + +.cbi-filebrowser > ul > li { + display: flex; + flex-direction: row; +} + +.cbi-filebrowser > ul > li:hover { + background: #f5f5f5; +} + +.cbi-filebrowser > ul > li > div:first-child { + flex: 10; + overflow: hidden; + text-overflow: ellipsis; +} + +.cbi-filebrowser > ul > li > div:last-child { + flex: 3; + text-align: right; +} + +.cbi-filebrowser > ul > li > div:last-child > button { + padding: .125em .25em; + margin: 1px 0 1px .25em; +} + +.cbi-filebrowser .upload { + display: flex; + flex-direction: row; + flex-wrap: wrap; + margin: 0 -.125em .25em -.125em; + padding: 0 0 .125em 0px; + border-bottom: 1px solid #ccc; +} + +.cbi-filebrowser .upload > * { + margin: .125em; + flex: 1; +} + +.cbi-filebrowser .upload > .btn { + flex-basis: 60px; +} + +.cbi-filebrowser .upload > div { + flex: 10; + min-width: 150px; +} + +.cbi-filebrowser .upload > div > input { + width: 100%; +} + +@keyframes fade-in { + 0% { opacity: 0; } + 100% { opacity: 1; } +} + +@keyframes fade-out { + 0% { opacity: 1; } + 100% { opacity: 0; } +} + +.fade-in { + animation: fade-in .4s ease; +} + +.fade-out { + animation: fade-out .4s ease; +} + +.assoclist .ifacebadge { + display: flex; + flex-direction: column; + align-items: center; + white-space: normal; + text-align: center; +} + +.assoclist .ifacebadge > img { + margin: .2em; +} + +.assoclist .td:nth-of-type(3), +.assoclist .td:nth-of-type(5) { + width: 25%; +} + +.assoclist .td:nth-of-type(6) button { + word-break: normal; +} + +[data-darkmode="true"] [data-page="admin-statistics-graphs"] [data-plugin] img { + filter: invert(100%) hue-rotate(150deg); +} + +[data-page="admin-system-admin-sshkeys"] .cbi-dynlist { + max-width: none; +} + +[data-darkmode="true"] [data-page="admin-status-channel_analysis"] #view> div > div > div > div > div[style], +[data-darkmode="true"] [data-page="admin-status-realtime-load"] #view > div > div > div[style], +[data-darkmode="true"] [data-page="admin-status-realtime-bandwidth"] #view > div > div > div > div[style], +[data-darkmode="true"] [data-page="admin-status-realtime-wireless"] #view > div > div > div > div[style], +[data-darkmode="true"] [data-page="admin-status-realtime-connections"] #view > div > div > div[style] { + background-color: var(--background-color-high)!important; +} + +[data-darkmode="true"] [data-page="admin-status-channel_analysis"] #view> div > div > div > div > div > svg > line[style], +[data-darkmode="true"] [data-page="admin-status-realtime-load"] #view > div > div > div > svg > line[style], +[data-darkmode="true"] [data-page="admin-status-realtime-bandwidth"] #view > div > div > div > div > svg > line[style], +[data-darkmode="true"] [data-page="admin-status-realtime-wireless"] #view > div > div > div > div > svg > line[style], +[data-darkmode="true"] [data-page="admin-status-realtime-connections"] #view > div > div > div > svg > line[style] { + stroke: #fff!important; +} diff --git a/luci/luci-theme-studio/htdocs/luci-static/studio/logo.svg b/luci/luci-theme-studio/htdocs/luci-static/studio/logo.svg new file mode 100644 index 0000000..c7a445e --- /dev/null +++ b/luci/luci-theme-studio/htdocs/luci-static/studio/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/luci/luci-theme-studio/htdocs/luci-static/studio/logo_48.png b/luci/luci-theme-studio/htdocs/luci-static/studio/logo_48.png new file mode 100644 index 0000000..6fb1f75 Binary files /dev/null and b/luci/luci-theme-studio/htdocs/luci-static/studio/logo_48.png differ diff --git a/luci/luci-theme-studio/htdocs/luci-static/studio/mobile.css b/luci/luci-theme-studio/htdocs/luci-static/studio/mobile.css new file mode 100644 index 0000000..c6d02b2 --- /dev/null +++ b/luci/luci-theme-studio/htdocs/luci-static/studio/mobile.css @@ -0,0 +1,429 @@ +header h3 a, header .brand { + display:none !important; +} + +@media screen and (max-device-width: 600px) { + .modal { + margin: 1.5em auto; + } + + .tabs, .cbi-tabmenu { + background: linear-gradient(var(--background-color-high) 20%, var(--border-color-medium) 100%); + background-size: 1px 34px; + margin-bottom: 10px; + } + + .tabs > li, .cbi-tabmenu > li { + height: 30px; + } + + .tabs > li > a, .cbi-tabmenu > li > a { + padding: 0 8px; + line-height: 30px; + } + + .table { + display: flex; + flex-direction: column; + width: 100%; + } + + .tr { + display: flex; + flex-direction: row; + flex-wrap: wrap; + align-items: flex-end; + border-top: 1px solid var(--border-color-medium); + padding: 5px 0; + margin: 0 -3px; + } + + .table .th, + .table .td, + .table .tr::before { + flex: 1 1 50%; + align-self: flex-start; + overflow: hidden; + text-overflow: ellipsis; + word-wrap: break-word; + display: inline-block; + border-top: none; + padding: 3px; + box-sizing: border-box; + } + + .table .td.cbi-dropdown-open { + overflow: visible; + } + + .col-1 { flex: 1 1 30px !important; -webkit-flex: 1 1 30px !important; } + .col-2 { flex: 2 2 60px !important; -webkit-flex: 2 2 60px !important; } + .col-3 { flex: 3 3 90px !important; -webkit-flex: 3 3 90px !important; } + .col-4 { flex: 4 4 120px !important; -webkit-flex: 4 4 120px !important; } + .col-5 { flex: 5 5 150px !important; -webkit-flex: 5 5 150px !important; } + .col-6 { flex: 6 6 180px !important; -webkit-flex: 6 6 180px !important; } + .col-7 { flex: 7 7 210px !important; -webkit-flex: 7 7 210px !important; } + .col-8 { flex: 8 8 240px !important; -webkit-flex: 8 8 240px !important; } + .col-9 { flex: 9 9 270px !important; -webkit-flex: 9 9 270px !important; } + .col-10 { flex: 10 10 300px !important; -webkit-flex: 10 10 300px !important; } + + .td select { + word-wrap: normal; + } + + .td[data-widget="button"], + .td[data-widget="fvalue"] { + flex: 1 1 17%; + text-align: left; + } + + .td.cbi-value-field { + align-self: flex-start; + flex-basis: 100%; + margin-bottom: .3em; + } + + .td.cbi-value-field .cbi-button { + width: 100%; + } + + .table.cbi-section-table { + border: none; + background: none; + margin: 0; + } + + .tr.table-titles, + .cbi-section-table-titles, + .cbi-section-table-descr { + display: none; + } + + .cbi-section-table-row { + display: flex; + flex-direction: row; + flex-wrap: wrap; + margin: 0 0 .5em 0; + } + + .cbi-section-table + .cbi-section-create { + padding-top: 0; + } + + .tr[data-title]::before { + display: block; + flex: 1 1 100%; + background: var(--background-color-low) !important; + font-size: 16px; + border-bottom: 1px solid var(--border-color-medium); + } + + .td[data-title]::before { + content: attr(data-title); + font-weight: bold; + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: pre-line; + } + + .td[data-description]::after { + display: block; + } + + .td[data-title] ~ .td.cbi-section-actions { + align-self: flex-start; + } + + .td.cbi-section-actions { + overflow: initial; + max-width: 100%; + padding: 3px 2px; + flex-basis: 100%; + } + + .hide-sm, + .hide-xs { + display: none !important; + } + + .td.cbi-value-field[data-widget="CBI.DummyValue"], + .td.cbi-value-field[data-widget="CBI.Button"], + .td.cbi-value-field[data-widget="CBI.FlagValue"], + .td.cbi-value-field[data-widget="dvalue"], + .td.cbi-value-field[data-widget="button"], + .td.cbi-value-field[data-widget="fvalue"] { + flex-basis: 50%; + text-align: left; + } + + .cbi-section-table .tr:hover .td, + .cbi-section-table .tr:hover .th, + .cbi-section-table .tr:hover::before { + background-color: transparent; + } + + .cbi-tab-descr { + margin: 0 0 .5em 0; + } + + .cbi-value { + padding-bottom: .5em; + border-bottom: 1px solid var(--border-color-high); + margin-bottom: .5em; + display: block; + align-items: baseline; + } + + .cbi-value.hidden { + display: none; + } + + .cbi-value .cbi-value:last-child { + border-bottom: none; + padding-bottom: inherit; + margin-bottom: inherit; + } + + .cbi-value label.cbi-value-title { + font-weight: bold; + } + + .cbi-value-field, .cbi-select, .cbi-dynlist, .cbi-dropdown:not(.btn):not(.cbi-button) { + width: 100%; + margin: 0; + } + + .cbi-dynlist > .item { + min-height: 30px; + display: flex; + align-items: center; + } + + input, textarea, select, + .cbi-dropdown > ul > li { + font-size: 16px !important; + height: auto; + } + + .cbi-dropdown > ul > li input[type="text"] { + line-height: 28px; + } + + select, input[type="text"], input[type="password"], + .cbi-dropdown > ul > li input[type="text"], + .cbi-value-field .control-group { + width: 100%; + height: 30px; + } + + input.cbi-input-password { + width: calc(100% - 25px); + } + + input[type="text"] + .cbi-button, + input[type="password"] + .cbi-button, + select + .cbi-button { + overflow: hidden; + text-overflow: ellipsis; + } + + .cbi-value-field input[type="checkbox"], + .cbi-value-field input[type="radio"] { + margin: 0; + } + + button, .btn, .cbi-button { + font-size: 14px !important; + padding: 0 8px; + } + + .actions, + .cbi-page-actions { + border-top: none; + margin-top: -.5em; + padding: 8px; + } + + [data-page="admin-status-overview"] .td[width="33%"] { + flex-basis: 33%; + } + + header .pull-right .label { + white-space: normal; + display: inline-block; + text-align: center; + line-height: 12px; + margin: 1px 0; + } + + header > .fill { + padding: 1px; + } + + header > .fill > .container { + display: flex; + flex-direction: row; + } + + header .nav { + flex: 3 3 80%; + margin: 2px 5px 2px 0; + display: flex; + flex-wrap: wrap; + justify-content: flex-start; + } + + header .nav a { + padding: 2px 6px; + } + + header .pull-right { + flex: 0 1 20%; + display: flex; + flex-direction: column; + padding: 0; + justify-content: space-around; + margin: .2em 5px .2em auto; + } + + .menu-dropdown, .dropdown-menu { + top: 23px; + } + + .cbi-optionals, + .cbi-section-create { + padding: 0 0 14px 0; + } + + #cbi-network-switch_vlan .th, + #cbi-network-switch_vlan .td { + flex-basis: 12%; + } + + #cbi-network-switch_vlan .td.cbi-section-actions > * { + width: auto; + display: block; + } + + #wifi_assoclist_table .td, + [data-page="admin-status-processes"] .td { + flex-basis: 50% !important; + } + + [data-page="admin-status-processes"] .td[data-widget="button"] { + flex-basis: 33% !important; + } + + [data-page="admin-status-processes"] .td[data-name="PID"], + [data-page="admin-status-processes"] .td[data-name="USER"] { + flex-basis: 25% !important; + } + + [data-page="admin-system-fstab"] .td[data-widget="button"]::before, + [data-page="admin-system-startup"] .td[data-widget="button"]::before, + [data-page="admin-status-processes"] .td[data-widget="button"]::before { + display: none; + } + + [data-name="bridge-vlan"] > div { + overflow: visible !important; + } + + [data-name="bridge-vlan"] .td[data-title] { + flex-basis: 33%; + } + + [data-name="bridge-vlan"] .td[data-title]::before { + content: attr(data-title); + } + + [data-page="admin-network-network"] .td[data-name="_ifacebox"] { + flex-basis: 40%; + } + + [data-page="admin-network-network"] .td[data-name="_ifacestat"] { + flex-basis: 60%; + } +} + +@media screen and (max-device-width: 375px) { + .cbi-page-actions { + display: flex; + justify-content: space-between; + margin: 0 -1px; + padding: 0; + } + + .cbi-page-actions button { + overflow: hidden; + text-overflow: ellipsis; + } + + .cbi-page-actions .cbi-button { + flex: 1; + margin: 1px !important; + line-height: 2em; + } + + .cbi-page-actions .cbi-button-negative, + .cbi-page-actions .cbi-button-primary, + .cbi-page-actions .cbi-button-apply { + flex: 3; + } + + .cbi-section-actions .cbi-button { + overflow: hidden; + text-overflow: ellipsis; + margin: 1px !important; + } + + body[data-page="admin-network-wireless"] .td[data-name="_badge"] { + max-width: 50px; + align-self: center; + } + + body[data-page="admin-network-wireless"] .td[data-name="_badge"] .ifacebadge { + display: flex; + align-items: center; + flex-direction: column; + } + + body[data-page="admin-network-wireless"] .td[data-name="_stat"] { + flex-basis: 60%; + } + + body[data-page="admin-network-network"] .td.cbi-section-actions::before, + body[data-page="admin-network-wireless"] .td.cbi-section-actions::before { + content: none !important; + } + + [data-page="admin-network-network"] .td[data-name="_ifacebox"], + [data-page="admin-network-network"] .td[data-name="_ifacestat"] { + flex-basis: 100%; + } + + .td .ifacebox { + width: 100%; + margin: 0 !important; + flex-direction: row; + } + + .td .ifacebox .ifacebox-head { + min-width: 25%; + justify-content: space-around; + border-radius: 4px 0 0 4px; + } + + .td .ifacebox .ifacebox-head, + .td .ifacebox .ifacebox-body { + display: flex; + border-bottom: none; + align-items: center; + } + + .td .ifacebox .ifacebox-head > *, + .ifacebox .ifacebox-body > * { + margin: .125em; + } +} diff --git a/luci/luci-theme-studio/htdocs/luci-static/studio/studio.css b/luci/luci-theme-studio/htdocs/luci-static/studio/studio.css new file mode 100644 index 0000000..32cd9ba --- /dev/null +++ b/luci/luci-theme-studio/htdocs/luci-static/studio/studio.css @@ -0,0 +1,151 @@ +:root { + --studio-bg: #f3f5fb; + --studio-panel: #fff; + --studio-ink: #17213a; + --studio-muted: #60708b; + --studio-border: #dde3ee; + --studio-accent: #635bff; +} +:root[data-darkmode="true"] { + --studio-bg: #111522; + --studio-panel: #1b2133; + --studio-ink: #edf1ff; + --studio-muted: #a4b0cd; + --studio-border: #303952; + --studio-accent: #a89bff; +} +body { + background: var(--studio-bg); + color: var(--studio-ink); + font-family: + system-ui, + -apple-system, + "Segoe UI", + sans-serif; +} +header { + background: var(--studio-panel); + border-bottom: 1px solid var(--studio-border); + box-shadow: 0 4px 24px #1112; +} +header .brand { + color: var(--studio-accent); + font-weight: 750; +} +h2 { + font-weight: 750; + letter-spacing: -0.03em; +} +a { + color: var(--studio-accent); +} +.cbi-section, +.cbi-map-descr, +.studio-card { + border-radius: 16px; +} +.cbi-section, +.studio-card { + background: var(--studio-panel); + border: 1px solid var(--studio-border); + padding: 20px; + box-shadow: 0 5px 20px #11108; +} +.cbi-button-positive, +.cbi-button-apply, +.cbi-button-save { + background: var(--studio-accent); + border-radius: 8px; + color: #fff; +} +.studio-hero { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + margin: 16px 0 24px; +} +.studio-hero p, +.studio-muted { + color: var(--studio-muted); +} +.studio-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 16px; + margin-bottom: 20px; +} +.studio-card h3 { + margin: 0 0 12px; + font-size: 16px; +} +.studio-value { + font-size: 30px; + font-weight: 750; +} +.studio-links { + display: flex; + flex-wrap: wrap; + gap: 10px; +} +.studio-links a { + display: inline-block; + padding: 10px 14px; + border: 1px solid var(--studio-border); + border-radius: 10px; + background: var(--studio-panel); +} +.studio-meter { + height: 7px; + background: var(--studio-border); + border-radius: 9px; + overflow: hidden; + margin-top: 12px; +} +.studio-meter span { + display: block; + height: 100%; + background: var(--studio-accent); +} +.studio-status { + display: inline-block; + border-radius: 20px; + padding: 4px 10px; + background: #10b98122; + color: var(--studio-ink); +} +.studio-warn { + background: #f59e0b33; +} +textarea.studio-editor { + width: 100%; + min-height: 340px; + font-family: monospace; +} +.studio-actions { + display: flex; + gap: 10px; + flex-wrap: wrap; + margin: 18px 0; +} +.studio-card pre { + white-space: pre-wrap; + overflow-wrap: anywhere; + max-height: 450px; + overflow: auto; +} +button { + cursor: pointer; +} +@media (max-width: 600px) { + .studio-hero { + align-items: flex-start; + flex-direction: column; + } + .studio-grid { + grid-template-columns: 1fr; + } + .studio-card { + padding: 15px; + } +} diff --git a/luci/luci-theme-studio/htdocs/luci-static/studio/studio.js b/luci/luci-theme-studio/htdocs/luci-static/studio/studio.js new file mode 100644 index 0000000..82be104 --- /dev/null +++ b/luci/luci-theme-studio/htdocs/luci-static/studio/studio.js @@ -0,0 +1,55 @@ +/* OpenWrt Studio theme controls. No telemetry or external resources. */ +(function () { + "use strict"; + var key = "openwrt-studio-palette", + root = document.documentElement, + choice; + try { + choice = localStorage.getItem(key); + } catch (e) {} + function apply() { + root.setAttribute( + "data-darkmode", + choice === "dark" + ? "true" + : choice === "light" + ? "false" + : String(matchMedia("(prefers-color-scheme: dark)").matches), + ); + } + apply(); + matchMedia("(prefers-color-scheme: dark)").addEventListener("change", apply); + document.addEventListener("DOMContentLoaded", function () { + var header = document.querySelector("header"); + if (!header) return; + var button = document.createElement("button"); + button.className = "cbi-button"; + button.type = "button"; + button.textContent = "◐"; + button.title = "Сменить светлую / тёмную тему"; + button.setAttribute("aria-label", button.title); + button.addEventListener("click", function () { + choice = root.getAttribute("data-darkmode") === "true" ? "light" : "dark"; + try { + localStorage.setItem(key, choice); + } catch (e) {} + apply(); + }); + header.appendChild(button); + }); + document.addEventListener("keydown", function (event) { + if (!event.ctrlKey || event.altKey || event.metaKey) return; + var routes = { + 1: "admin/studio/overview", + 2: "admin/studio/overview", + 3: "admin/studio/diagnostics", + ",": "admin/studio/tools", + }, + route = routes[event.key]; + if (route && window.L) { + event.preventDefault(); + location.href = + L.url(route) + (event.key === "2" ? "#studio-clients" : ""); + } + }); +})(); diff --git a/luci/luci-theme-studio/root/etc/uci-defaults/90-luci-theme-studio b/luci/luci-theme-studio/root/etc/uci-defaults/90-luci-theme-studio new file mode 100755 index 0000000..6831887 --- /dev/null +++ b/luci/luci-theme-studio/root/etc/uci-defaults/90-luci-theme-studio @@ -0,0 +1,4 @@ +#!/bin/sh +uci -q set luci.themes.OpenWrtStudio='/luci-static/studio' +uci commit luci +exit 0 diff --git a/luci/luci-theme-studio/ucode/template/themes/studio/footer.ut b/luci/luci-theme-studio/ucode/template/themes/studio/footer.ut new file mode 100644 index 0000000..d51079b --- /dev/null +++ b/luci/luci-theme-studio/ucode/template/themes/studio/footer.ut @@ -0,0 +1,20 @@ + {% if (!blank_page): %} + + + + {% endif %} + + diff --git a/luci/luci-theme-studio/ucode/template/themes/studio/header.ut b/luci/luci-theme-studio/ucode/template/themes/studio/header.ut new file mode 100644 index 0000000..e0cd530 --- /dev/null +++ b/luci/luci-theme-studio/ucode/template/themes/studio/header.ut @@ -0,0 +1,86 @@ +{# + Copyright 2008 Steven Barth + Copyright 2012 David Menting + Copyright 2008-2022 Jo-Philipp Wich + Licensed to the public under the Apache License 2.0. +-#} + +{% + import { getuid, getspnam } from 'luci.core'; + + const boardinfo = ubus.call('system', 'board'); + const darkpref = (theme == 'bootstrap-dark' ? 'true' : (theme == 'bootstrap-light' ? 'false' : null)); + + http.prepare_content('text/html; charset=UTF-8'); +-%} + + + + + + {{ striptags(`${boardinfo.hostname ?? '?'}${node ? ` - ${node.title}` : ''}`) }} - LuCI + {% if (!darkpref): %} + + {% endif %} + + + + + + + + {% if (node?.css): %} + + {% endif %} + {% if (css): %} + + {% endif %} + + + + + + {% if (!blank_page): %} +
+ OpenWrt Studio · {{ striptags(boardinfo.hostname ?? '?') }} + +
+
+ +
+ {% if (getuid() == 0 && getspnam('root')?.pwdp === ''): %} +
+

{{ _('No password set!') }}

+

{{ _('There is no password set on this router. Please configure a root password to protect the web interface.') }}

+ {% if (dispatcher.lookup("admin/system/admin")): %} + + {% endif %} +
+ {% endif %} + + {% if (boardinfo.rootfs_type == "initramfs"): %} +
+

{{ _('System running in recovery (initramfs) mode.') }}

+

{{ _('No changes to settings will be stored and are lost after rebooting. This mode should only be used to install a firmware upgrade') }}

+ {% if (dispatcher.lookup("admin/system/flash")): %} + + {% endif %} +
+ {% endif %} + + + + + {% endif %} diff --git a/luci/scripts/build.py b/luci/scripts/build.py new file mode 100644 index 0000000..ef0b1d6 --- /dev/null +++ b/luci/scripts/build.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""Build architecture-independent opkg packages using only the Python standard library. +OpenWrt 24.10 IPK layout: gzipped tar containing debian-binary/control.tar.gz/data.tar.gz. +For apk-based firmware use the OpenWrt SDK and the package Makefiles. +""" +from pathlib import Path +import io,tarfile,gzip,json,hashlib +ROOT=Path(__file__).resolve().parents[1] +OUT=ROOT/'dist' +VERSION='1.0.0~rc1-1' +EPOCH=1781827200 + +def archive(files): + buf=io.BytesIO() + with tarfile.open(fileobj=buf,mode='w',format=tarfile.GNU_FORMAT) as tar: + for name,data,mode in sorted(files): + item=tarfile.TarInfo('./'+name);item.size=len(data);item.mode=mode;item.mtime=EPOCH;item.uid=item.gid=0;item.uname=item.gname='root';tar.addfile(item,io.BytesIO(data)) + return gzip.compress(buf.getvalue(),mtime=0) + +def build(package,depends): + folder=ROOT/package;files=[] + for source,dest in [('htdocs','www'),('root',''),('ucode','usr/share/ucode/luci')]: + for path in sorted((folder/source).rglob('*')): + if path.is_file(): + name='/'.join(p for p in [dest,str(path.relative_to(folder/source))] if p) + executable=name.startswith('usr/libexec/') or name.startswith('etc/uci-defaults/') or name.startswith('etc/init.d/') + files.append((name,path.read_bytes(),0o755 if executable else 0o644)) + license_name='Apache-2.0' if package=='luci-theme-studio' else 'MIT' + license_source=folder/'LICENSE' + files.append(('usr/share/licenses/'+package+'/LICENSE',license_source.read_bytes(),0o644)) + if (folder/'NOTICE').exists():files.append(('usr/share/licenses/'+package+'/NOTICE',(folder/'NOTICE').read_bytes(),0o644)) + control=f'Package: {package}\nVersion: {VERSION}\nArchitecture: all\nMaintainer: OpenWrt Studio contributors\nSection: luci\nPriority: optional\nLicense: {license_name}\nDepends: {depends}\nInstalled-Size: {sum(len(x[1]) for x in files)}\nDescription: OpenWrt Studio LuCI release candidate\n' + postinst='''#!/bin/sh +[ -n "$IPKG_INSTROOT" ] && exit 0 +''' + if package=='luci-app-studio':postinst+='sh /etc/uci-defaults/91-luci-app-studio || exit 1\nrm -f /etc/uci-defaults/91-luci-app-studio\n' + if package=='luci-theme-studio':postinst+='sh /etc/uci-defaults/90-luci-theme-studio || exit 1\nrm -f /etc/uci-defaults/90-luci-theme-studio\n' + postinst+='rm -f /tmp/luci-indexcache.*\n/etc/init.d/rpcd reload >/dev/null 2>&1 || true\nexit 0\n' + controls=[('control',control.encode(),0o644),('postinst',postinst.encode(),0o755)] + if package=='luci-app-studio': + controls.append(('conffiles',b'/etc/config/studio\n',0o644)) + controls.append(('prerm',b'#!/bin/sh\n[ -n "$IPKG_INSTROOT" ] && exit 0\n/etc/init.d/studio-sentinel stop\n/etc/init.d/studio-sentinel disable\nexit 0\n',0o755)) + if package=='luci-theme-studio': + postrm='''#!/bin/sh +[ -n "$IPKG_INSTROOT" ] && exit 0 +if [ "$(uci -q get luci.main.mediaurlbase)" = /luci-static/studio ]; then + uci set luci.main.mediaurlbase=/luci-static/bootstrap +fi +uci -q delete luci.themes.OpenWrtStudio +uci commit luci +exit 0 +''' + controls.append(('postrm',postrm.encode(),0o755)) + output=OUT/f'{package}_{VERSION}_all.ipk' + output.write_bytes(archive([('debian-binary',b'2.0\n',0o644),('control.tar.gz',archive(controls),0o644),('data.tar.gz',archive(files),0o644)])) + # Inspect packaged payload and ensure no test files or credentials entered the package. + with tarfile.open(output) as tar: + assert sorted(x.name for x in tar)==['./control.tar.gz','./data.tar.gz','./debian-binary'] + with tarfile.open(fileobj=io.BytesIO(tar.extractfile('./data.tar.gz').read())) as payload: + assert all(not x.name.endswith('.py') for x in payload) + return output + +if __name__=='__main__': + OUT.mkdir(exist_ok=True) + packages=[build('luci-theme-studio','luci-base, luci-theme-bootstrap'),build('luci-app-studio','luci-base, rpcd, rpcd-mod-file, rpcd-mod-iwinfo, luci-mod-admin-full, jshn, luci-app-firewall, luci-app-opkg')] + source=OUT/'OpenWrtStudio-LuCI-v1.0.0-rc1-source.tar.gz' + members=[] + for path in ROOT.rglob('*'): + if path.is_file() and not any(x in path.relative_to(ROOT).parts for x in ['dist','artifacts','__pycache__']): + members.append(('OpenWrtStudio-LuCI/'+str(path.relative_to(ROOT)),path.read_bytes(),0o755 if path.stat().st_mode&0o111 else 0o644)) + source.write_bytes(archive(members)) + packages.append(source) + (OUT/'SHA256SUMS.txt').write_text(''.join(hashlib.sha256(p.read_bytes()).hexdigest()+' '+p.name+'\n' for p in packages)) + for path in packages:print(path.name, path.stat().st_size,'bytes') diff --git a/luci/scripts/smoke-router.sh b/luci/scripts/smoke-router.sh new file mode 100755 index 0000000..985b6f9 --- /dev/null +++ b/luci/scripts/smoke-router.sh @@ -0,0 +1,44 @@ +#!/bin/sh +# Run on a test router as root, with locally copied candidate packages. +# Installs theme/app and leaves them installed; always restores original selected theme. +# Does not configure VPN, firewall, WAN, or wireless. +set -eu +[ "$(id -u)" = 0 ] || { echo 'Root is required' >&2; exit 1; } +[ "$#" = 2 ] || { echo 'Usage: smoke-router.sh /tmp/theme.ipk /tmp/app.ipk' >&2; exit 1; } +command -v opkg >/dev/null || { echo 'This candidate requires opkg/OpenWrt 24.10' >&2; exit 1; } +. /etc/openwrt_release +case "$DISTRIB_RELEASE" in 24.10*) ;; *) echo 'Test package targets OpenWrt 24.10; firmware differs' >&2; exit 1;; esac +[ -z "$(uci changes luci)" ] || { echo 'Pending LuCI settings exist; test has not changed anything' >&2; exit 1; } +original="$(uci -q get luci.main.mediaurlbase || true)" +original_present=0 +uci -q get luci.main.mediaurlbase >/dev/null && original_present=1 +restore() { + if [ "$original_present" = 1 ]; then + uci set "luci.main.mediaurlbase=$original" + else + uci -q delete luci.main.mediaurlbase || true + fi + if uci commit luci; then + current="$(uci -q get luci.main.mediaurlbase || true)" + if [ "$current" = "$original" ]; then echo 'Original theme restored and verified'; else echo 'ERROR: Theme restore mismatch' >&2; return 1; fi + else + echo 'ERROR: Failed to commit theme restoration' >&2 + return 1 + fi +} +trap restore EXIT +trap 'exit 1' HUP INT TERM +opkg install "$1" "$2" +/etc/init.d/rpcd restart +ubus -t 15 call studio status >/tmp/studio-status-smoke.json +jsonfilter -i /tmp/studio-status-smoke.json -e '@.ok' | grep -qx true +uci set luci.main.mediaurlbase=/luci-static/studio +uci commit luci +if command -v uclient-fetch >/dev/null; then + uclient-fetch -q -O /tmp/studio-login-smoke.html http://127.0.0.1/cgi-bin/luci/ +else + wget -q -O /tmp/studio-login-smoke.html http://127.0.0.1/cgi-bin/luci/ +fi +grep -q '/luci-static/studio/studio.css' /tmp/studio-login-smoke.html +[ "$(uci -q get studio.sentinel.enabled)" = 0 ] || { echo 'Sentinel was previously enabled; check existing configuration' >&2; exit 1; } +echo 'RPC registration and login template smoke checks passed' diff --git a/luci/tests/backend.py b/luci/tests/backend.py new file mode 100644 index 0000000..4b87af7 --- /dev/null +++ b/luci/tests/backend.py @@ -0,0 +1,66 @@ +"""Exercise rpcd dispatch in an isolated filesystem with stubbed OpenWrt tools.""" +from pathlib import Path +import tempfile,subprocess,json,os +source=Path(__file__).resolve().parents[1]/'luci-app-studio/root/usr/libexec/rpcd/studio' +with tempfile.TemporaryDirectory() as temporary: + root=Path(temporary);etc=root/'etc';(etc/'init.d').mkdir(parents=True);(etc/'mihomo').mkdir() + tools=root/'bin';tools.mkdir() + # jshn contract shim for scalar request/response methods. status is checked in browser fixtures only. + shim=root/'jshn.sh';shim.write_text('''json_load() { request_json=$(printf '%s' "$1" | jq -ce .); } +json_get_var() { local value; value=$(printf '%s' "$request_json" | jq -r --arg key "$2" '.[$key] // ""'); export "$1=$value"; } +json_init() { output_json='{}'; } +json_add_boolean() { output_json=$(printf '%s' "$output_json" | jq -c --arg key "$1" --argjson value "$2" '. + {($key): ($value == 1)}'); } +json_add_string() { output_json=$(printf '%s' "$output_json" | jq -c --arg key "$1" --arg value "$2" '. + {($key): $value}'); } +json_dump() { printf '%s\\n' "$output_json"; } +''') + script=root/'studio';script.write_text(source.read_text().replace('/usr/share/libubox/jshn.sh',str(shim)).replace('/etc/',str(etc)+'/')) + marker=root/'executed';service=etc/'init.d/mihomo';service.write_text('#!/bin/sh\nprintf "%s" "$1" > "'+str(marker)+'"\nexit "${SERVICE_EXIT:-0}"\n');service.chmod(0o755) + mihomo=tools/'mihomo';mihomo.write_text('#!/bin/sh\n[ "$MIHOMO_INVALID" != 1 ]\n');mihomo.chmod(0o755) + def call(method,payload,**env): + result=subprocess.run(['sh',str(script),'call',method],input=json.dumps(payload)+'\n',text=True,capture_output=True,env={**os.environ,'PATH':str(tools)+':'+os.environ['PATH'],**env}) + assert result.returncode==0,result.stderr + return json.loads(result.stdout) + assert call('action',{'name':'restart','service':'mihomo'})['ok'] + assert marker.read_text()=='restart';marker.unlink() + for payload in [{'name':'restart; touch injected','service':'mihomo'},{'name':'restart','service':'../../bin/sh'},{'name':'restart','service':'mihomo; touch injected'}]: + assert not call('action',payload)['ok'];assert not marker.exists() + assert not call('action',{'name':'restart','service':'mihomo'},SERVICE_EXIT='1')['ok'] + config=etc/'mihomo/config.yaml';config.write_text('old config') + assert not call('mihomo',{'content':'invalid'},MIHOMO_INVALID='1')['ok'];assert config.read_text()=='old config' + assert call('mihomo',{'content':'{"rules":["MATCH,DIRECT"]}'})['ok'] + assert config.read_text().strip()=='{"rules":["MATCH,DIRECT"]}' + assert (etc/'mihomo/config.yaml.studio-backup').read_text()=='old config' + assert config.stat().st_mode & 0o777 == 0o600 + config.unlink();target=root/'protected';target.write_text('untouched');config.symlink_to(target) + assert not call('mihomo',{'content':'{}'})['ok'];assert target.read_text()=='untouched' + assert not call('missing',{})['ok'] + assert not list((etc/'mihomo').glob('.studio.*')) + for service_name in ['sing-box','openvpn']: + service_file=etc/'init.d'/service_name;service_file.write_text(service.read_text());service_file.chmod(0o755) + core=tools/service_name;core.write_text('#!/bin/sh\n[ "$VPN_INVALID" != 1 ]\n');core.chmod(0o755) + uci=tools/'uci';uci.write_text('#!/bin/sh\nexit 0\n');uci.chmod(0o755) + assert not call('vpnconfig',{'kind':'../../bad','content':'{}'})['ok'] + assert not call('vpnconfig',{'kind':'sing-box','content':'{}'},VPN_INVALID='1')['ok'] + assert call('vpnconfig',{'kind':'sing-box','content':'{"log":{"level":"info"}}'})['ok'] + assert (etc/'sing-box/config.json').stat().st_mode & 0o777 == 0o600 + profile='client\ndev tun\nremote vpn.example.org 1194\n\nCERTIFICATE\n\n' + assert call('vpnconfig',{'kind':'openvpn','content':profile})['ok'] + ovpn=etc/'openvpn/studio.conf';assert ovpn.read_text().strip()==profile.strip() + for directive in ['up /tmp/script','plugin /tmp/lib.so','config /etc/other.conf','script-security 2','log /etc/passwd','','\nunterminated']: + assert not call('vpnconfig',{'kind':'openvpn','content':profile+directive+'\n'})['ok'],directive + assert ovpn.read_text().strip()==profile.strip() + + for name in ['tailscale','zerotier']: + init=etc/'init.d'/name;init.write_text(service.read_text());init.chmod(0o755) + for name in ['tailscale','zerotier-cli']: + core=tools/name;core.write_text('#!/bin/sh\nprintf "%s\\n" "$*" > "$MESH_ARGS"\nexit 0\n');core.chmod(0o755) + meshargs=root/'meshargs' + assert not call('mesh',{'kind':'zerotier','key':'abc; touch unsafe'},MESH_ARGS=str(meshargs))['ok'] + assert call('mesh',{'kind':'zerotier','key':'0123456789abcdef'},MESH_ARGS=str(meshargs))['ok'] + assert meshargs.read_text().strip()=='join 0123456789abcdef' + payload={'kind':'tailscale','key':'tskey-auth-TESTKEY','routes':'192.168.1.0/24','exitnode':True,'accept':True} + assert call('mesh',payload,MESH_ARGS=str(meshargs))['ok'] + args=meshargs.read_text();assert 'TESTKEY' not in args and 'file:' in args + for key,value in [('key','tskey-auth-X;bad'),('routes','192.168.1.0/24;bad'),('exitnode','anything')]: + assert not call('mesh',{**payload,key:value},MESH_ARGS=str(meshargs))['ok'] +print('Backend contracts passed: allowlists, traversal/injection rejection, failures, Mihomo validation, backup, permissions, symlink rejection, cleanup.') diff --git a/luci/tests/browser.py b/luci/tests/browser.py new file mode 100644 index 0000000..8ad163f --- /dev/null +++ b/luci/tests/browser.py @@ -0,0 +1,88 @@ +"""Browser contract tests against LuCI-shaped RPC fixtures, no router mutations.""" +from pathlib import Path +import json,os,shutil +from playwright.sync_api import sync_playwright +root=Path(__file__).resolve().parents[1] +views=root/'luci-app-studio/htdocs/luci-static/resources/view/studio' +fixtures={'board':{'model':'OpenWrt test router','release':{'description':'OpenWrt 24.10'}},'info':{'uptime':7260,'load':[65536,32768,0],'memory':{'total':1000,'available':350}},'status':{'services':[{'name':'mihomo','installed':True,'running':True},{'name':'sing-box','installed':False,'running':False}],'overlay':'30%','architecture':'aarch64_cortex-a53'},'getDHCPLeases':{'dhcp_leases':[{'hostname':'','ipaddr':'192.0.2.2','macaddr':'00:11:22:33:44:55'}]},'probe':{'ok':True,'output':'WAN: up\nDNS: ok'},'action':{'ok':True,'output':''},'mihomo':{'ok':False,'error':'Invalid test config'}} +setup=r''' +window.calls=[];window.polls=[];window.failRpc=false; +window.E=function(tag,attrs,children){const e=document.createElement(tag);for(const [k,v] of Object.entries(attrs||{})){if(k==='click')e.addEventListener('click',v);else if(k==='disabled')e.disabled=v;else e.setAttribute(k,v);}function add(c){if(Array.isArray(c))c.forEach(add);else if(c!=null)e.append(c instanceof Node?c:document.createTextNode(String(c)));}add(children);return e;}; +window.uci={load:()=>Promise.resolve(),get:()=>null,add:()=>'',set:()=>{},save:()=>Promise.resolve()};window.validation={parseIPv4:x=>/^([0-9]{1,3}\.){3}[0-9]{1,3}$/.test(x),parseIPv6:x=>x.includes(':')}; +window.L={url:x=>'/cgi-bin/luci/'+x,hasViewPermission:()=>true}; +window.view={extend:o=>o};window.poll={add:(f)=>polls.push(f)}; +window.rpc={declare:d=>(...args)=>{calls.push({method:d.method,args});if(window.failRpc)return Promise.reject(new Error('Router unavailable'));return Promise.resolve(fixtures[d.method]);}}; +window.ui={showModal:(title,children)=>{ui.hideModal();const modal=E('div',{id:'modal'},[E('h3',{},title),children]);document.body.append(modal);},hideModal:()=>document.querySelector('#modal')?.remove(),addNotification:(a,b)=>document.body.append(b)}; +''' +with sync_playwright() as p: + browser=p.chromium.launch(executable_path=os.environ.get('STUDIO_CHROMIUM') or shutil.which('chromium'),args=['--no-sandbox']) + page=browser.new_page(viewport={'width':1280,'height':900}) + errors=[];page.on('pageerror',lambda e:errors.append(str(e))) + def load(name): + page.set_content('
') + page.add_style_tag(path=str(root/'luci-theme-studio/htdocs/luci-static/studio/cascade.css')) + page.add_style_tag(path=str(root/'luci-theme-studio/htdocs/luci-static/studio/studio.css')) + page.evaluate('window.fixtures='+json.dumps(fixtures)) + page.evaluate(setup) + page.evaluate('(async()=>{window.app=(new Function('+json.dumps((views/(name+'.js')).read_text())+'))();const data=app.load?await app.load():null;document.querySelector("#view").append(app.render(data));})()') + load('overview') + assert page.get_by_text('65.0%',exact=True).count()==1 + assert page.get_by_text('',exact=True).count()==1 + assert page.locator('img').count()==0 + with page.expect_download() as download:page.get_by_text('Сохранить отчёт',exact=True).click() + report=Path(download.value.path()).read_text() + assert '192.0.2.2' not in report and '00:11' not in report and 'OpenWrt test router' in report + fixtures['getDHCPLeases']['dhcp_leases'][0]['hostname']='Laptop' + load('overview') + out=root/'artifacts';out.mkdir(exist_ok=True) + page.screenshot(path=str(out/'overview-dark.png'),full_page=True) + page.set_viewport_size({'width':390,'height':844}) + assert page.evaluate('document.documentElement.scrollWidth <= 390') + page.screenshot(path=str(out/'overview-mobile.png'),full_page=True) + page.evaluate('window.failRpc=true;polls[0]()') + assert page.get_by_text('Данные недоступны:',exact=False).count()==1 + assert page.get_by_text('65.0%',exact=True).count()==0 + load('capabilities') + page.get_by_text('restart',exact=True).click() + assert not any(x['method']=='action' for x in page.evaluate('calls')) + page.get_by_text('Выполнить',exact=True).click() + assert any(x['method']=='action' and x['args']==['restart','mihomo'] for x in page.evaluate('calls')) + load('diagnostics');page.get_by_text('Проверить сеть',exact=True).click() + assert page.get_by_text('WAN: up',exact=False).count()==1 + load('mihomo') + page.locator('input').fill('javascript:alert(1)');page.get_by_text('Сформировать',exact=True).click() + assert page.locator('textarea').input_value()=='' + page.locator('input').fill('https://example.org/private-subscription');page.get_by_text('Сформировать',exact=True).click() + cfg=json.loads(page.locator('textarea').input_value()) + assert cfg['allow-lan'] is False and cfg['proxy-providers']['subscription']['url'].startswith('https://') + page.get_by_text('Проверить ядром Mihomo и применить',exact=True).click() + assert not any(x['method']=='mihomo' for x in page.evaluate('calls')) + page.get_by_text('Проверить и применить',exact=True).click() + assert page.get_by_text('Ошибка: Invalid test config',exact=True).count()==1 + load('vpn') + key='A'*43+'=' + profile='[Interface]\nPrivateKey = '+key+'\nAddress = 10.0.0.2/24\nDNS = 1.1.1.1\nJc = 5\n[Peer]\nPublicKey = '+key+'\nAllowedIPs = 0.0.0.0/0, ::/0\nEndpoint = [2001:db8::1]:51820\nPersistentKeepalive = 25' + parsed=page.evaluate('(text)=>app.parseWireGuard(text,"awg0","amneziawg")',profile) + assert parsed['peers'][0]['endpoint_host']=='2001:db8::1' + assert parsed['values']['jc']=='5' and parsed['values']['dns']==['1.1.1.1'] + for bad in [profile+'\nPreUp = touch /tmp/test',profile.replace('51820','99999'),profile.replace('10.0.0.2/24','10.0.0.2/99')]: + assert page.evaluate('(text)=>{try{app.parseWireGuard(text,"awg0","amneziawg");return false;}catch(e){return true;}}',bad) + for protocol in ['vless','hysteria2','tuic']: + generated=page.evaluate('(protocol)=>app.buildSingbox({protocol,server:"example.org",port:"443",uuid:"00000000-0000-0000-0000-000000000000",password:"secret",sni:"example.org",publickey:"A".repeat(43),shortid:"abcd"})',protocol) + assert generated['outbounds'][0]['type']==protocol + load('mesh') + page.locator('input[type=password]').fill('bad') + page.get_by_text('Подключить Tailscale',exact=True).click() + assert not any(x['method']=='mesh' for x in page.evaluate('calls')) + page.locator('input[type=password]').fill('tskey-auth-EXAMPLE') + page.get_by_text('Подключить Tailscale',exact=True).click() + assert not any(x['method']=='mesh' for x in page.evaluate('calls')) + page.evaluate("fixtures.mesh={ok:true,output:'Connected'}") + page.get_by_text('Подключить',exact=True).click() + assert any(x['method']=='mesh' and x['args'][0]=='tailscale' for x in page.evaluate('calls')) + assert page.locator('input[type=password]').input_value()=='' + load('tools') + assert page.locator('a').count()>=14 + assert not errors,errors + browser.close() +print('Browser contracts passed: overview, polling failure, report privacy, mobile layout, service confirmation, diagnostics, Mihomo validation and error propagation, tools.') diff --git a/luci/tests/sentinel.py b/luci/tests/sentinel.py new file mode 100644 index 0000000..d12ac56 --- /dev/null +++ b/luci/tests/sentinel.py @@ -0,0 +1,23 @@ +from pathlib import Path +import tempfile,subprocess,os +source=Path(__file__).resolve().parents[1]/'luci-app-studio/root/usr/libexec/studio-sentinel' +with tempfile.TemporaryDirectory() as tmp: + root=Path(tmp);tools=root/'bin';tools.mkdir();etc=root/'init.d';etc.mkdir() + functions=root/'functions.sh';functions.write_text('''config_load() { :; } +config_get() { case "$3" in interval) export "$1=30";; failures) export "$1=3";; cooldown) export "$1=300";; proxy) export "$1=${TEST_PROXY:-none}";; esac; } +config_get_bool() { export "$1=1"; } +''') + def tool(name,text): + path=tools/name;path.write_text('#!/bin/sh\n'+text);path.chmod(0o755) + tool('ping','exit "${PING_EXIT:-0}"\n');tool('nslookup','exit "${DNS_EXIT:-1}"\n');tool('ubus','echo \'{"up":false}\'\n');tool('jsonfilter','echo false\n');tool('date','echo 10000\n');tool('logger','printf "%s\\n" "$*" >> "$LOG_FILE"\n') + for name in ['dnsmasq','mihomo']: + path=etc/name;path.write_text('#!/bin/sh\n[ "$1" = running ] && exit 1\nprintf "%s\\n" "'+name+' $1" >> "$ACTION_FILE"\n');path.chmod(0o755) + script=root/'sentinel';script.write_text(source.read_text().replace('/lib/functions.sh',str(functions)).replace('/etc/init.d',str(etc)).replace(' sleep "$interval"',' cycles=$((cycles + 1)); [ "$cycles" -ge 8 ] && exit 0')) + actions=root/'actions';log=root/'log' + def run(**env): + actions.write_text('');log.write_text('');subprocess.run(['sh',str(script)],env={**os.environ,'PATH':str(tools)+':'+os.environ['PATH'],'LOG_FILE':str(log),'ACTION_FILE':str(actions),**env},check=True,timeout=5);return actions.read_text(),log.read_text() + result,logs=run();assert result=='dnsmasq restart\n';assert 'local_dns' in logs + result,logs=run(PING_EXIT='1');assert result=='';assert 'wan_down' in logs + result,logs=run(PING_EXIT='1',TEST_PROXY='mihomo');assert result=='mihomo restart\n';assert 'proxy_stopped' in logs + result,logs=run(DNS_EXIT='0');assert result=='';assert 'healthy' in logs +print('Sentinel contracts passed: repeated failures, cooldown, DNS recovery, WAN observation, stopped proxy recovery, healthy state.') diff --git a/luci/tests/theme_restore.py b/luci/tests/theme_restore.py new file mode 100644 index 0000000..738a70a --- /dev/null +++ b/luci/tests/theme_restore.py @@ -0,0 +1,31 @@ +"""Verify the deployment smoke script restores the selected theme on success/failure.""" +from pathlib import Path +import tempfile,subprocess,os +source=Path(__file__).resolve().parents[1]/'scripts/smoke-router.sh' +with tempfile.TemporaryDirectory() as tmp: + root=Path(tmp);tools=root/'bin';tools.mkdir();state=root/'theme';release=root/'release';release.write_text("DISTRIB_RELEASE='24.10.4'\n") + def tool(name,body): + p=tools/name;p.write_text('#!/bin/sh\n'+body);p.chmod(0o755) + tool('id','echo 0\n') + tool('opkg','exit "${INSTALL_EXIT:-0}"\n') + tool('uci','''[ "$1" = -q ] && shift +case "$1" in + changes|commit) exit 0;; + get) case "$2" in luci.main.mediaurlbase) [ -f "$THEME_STATE" ] || exit 1; cat "$THEME_STATE";; studio.sentinel.enabled) echo 0;; esac;; + set) printf '%s' "${2#*=}" > "$THEME_STATE";; + delete) rm -f "$THEME_STATE";; +esac +''') + tool('ubus','echo \'{"ok":true}\'\n');tool('jsonfilter','echo true\n') + tool('uclient-fetch','[ "${TEMPLATE_FAIL:-0}" = 1 ] && exit 1\nprintf "%s" \'\' > "$3"\n') + service=root/'rpcd';service.write_text('#!/bin/sh\nexit 0\n');service.chmod(0o755) + script=root/'smoke';script.write_text(source.read_text().replace('/etc/openwrt_release',str(release)).replace('/etc/init.d/rpcd',str(service)).replace('/tmp/studio-',str(root)+'/studio-')) + for original in ['original-theme',None]: + for install_failure,template_failure in [('0','0'),('1','0'),('0','1')]: + if original is None:state.unlink(missing_ok=True) + else:state.write_text(original) + r=subprocess.run(['sh',str(script),'theme.ipk','app.ipk'],capture_output=True,text=True,env={**os.environ,'PATH':str(tools)+':'+os.environ['PATH'],'THEME_STATE':str(state),'INSTALL_EXIT':install_failure,'TEMPLATE_FAIL':template_failure}) + assert r.returncode == (0 if install_failure==template_failure=='0' else 1),(r.stdout,r.stderr) + assert (state.read_text() if state.exists() else None)==original,(r.stdout,r.stderr) + assert 'Original theme restored and verified' in r.stdout +print('Theme restoration contracts passed: existing/absent theme, successful install, failed install, failed template rendering.')