diff --git a/honk/Makefile b/honk/Makefile index a8d5950e2..507f11b54 100644 --- a/honk/Makefile +++ b/honk/Makefile @@ -4,7 +4,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=honk PKG_VERSION:=2026.10.9_pre3 -PKG_RELEASE:=15 +PKG_RELEASE:=16 # Raw upstream release tag. It keeps the dots in qualifiers (e.g. v0.0.1.beta.49) # because that is what the GitHub release asset name uses. diff --git a/honk/files/config.d/dns.dae b/honk/files/config.d/dns.dae index 6c3f1a730..f28060672 100644 --- a/honk/files/config.d/dns.dae +++ b/honk/files/config.d/dns.dae @@ -2,10 +2,11 @@ dns { bind: 'tcp+udp://[::]:53530' client_subnet: auto + max_cache_size: 50000 #ipversion_prefer: 4 upstream { local: 'udp://127.0.0.1:53' -> direct - remote: 'tcp+udp://one.one.one.one:53' + remote: 'tcp+udp://dns.google:53' } routing { request { diff --git a/luci-app-device-manager/.gitignore b/luci-app-device-manager/.gitignore new file mode 100644 index 000000000..4601eed48 --- /dev/null +++ b/luci-app-device-manager/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +*.py[cod] +node_modules/ +*.ipk +*.apk diff --git a/luci-app-device-manager/LICENSE b/luci-app-device-manager/LICENSE new file mode 100644 index 000000000..b58d903cd --- /dev/null +++ b/luci-app-device-manager/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 luci-app-device-manager contributors + +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-app-device-manager/Makefile b/luci-app-device-manager/Makefile new file mode 100644 index 000000000..5ee2bcb42 --- /dev/null +++ b/luci-app-device-manager/Makefile @@ -0,0 +1,23 @@ +include $(TOPDIR)/rules.mk + +LUCI_TITLE:=LuCI support for LAN Device Management +LUCI_DEPENDS:=+luci-base +rpcd-mod-iwinfo +jshn +LUCI_PKGARCH:=all +LUCI_MAINTAINER:=zhiyong zhang + +PKG_LICENSE:=MIT Apache-2.0 +PKG_LICENSE_FILES:=LICENSE htdocs/luci-static/resources/device-manager/device-icons/LICENSE + +define Package/luci-app-device-manager/conffiles +/etc/config/device_manager +endef + +# In-tree LuCI applications use the relative include. Standalone SDK builds +# use the installed feed, where this package lives outside applications/. +ifneq ($(wildcard ../../luci.mk),) +include $(TOPDIR)/feeds/luci/luci.mk +else +include $(TOPDIR)/feeds/luci/luci.mk +endif + +# call BuildPackage - OpenWrt buildroot signature diff --git a/luci-app-device-manager/README.md b/luci-app-device-manager/README.md new file mode 100644 index 000000000..27d380874 --- /dev/null +++ b/luci-app-device-manager/README.md @@ -0,0 +1,139 @@ +# luci-app-device-manager + +轻量级 OpenWrt LuCI 局域网设备管理插件,可按 MAC 地址保存名称、备注、分组和设备类型。已有自定义数据在升级时保留。 + +A lightweight LAN device manager for OpenWrt LuCI. Combine host hints, DHCP +leases, Wi-Fi associations and kernel neighbors; save names, remarks, groups +and manual device types by MAC address, including saved offline devices. + +## 功能 / Features + +- 展示 IPv4、IPv6、接口和状态依据,区分在线、离线和未知状态。 +- 按分组、状态和关键词筛选,支持排序、手动登记及地址隐藏。 +- 支持 29 种显示类型和本地 SVG 图标;名称和少量有来源的厂商线索用于推测,用户可手动覆盖。 +- 刷新列表只读取现有发现数据;“扫描局域网”由用户主动触发,需要写权限。 +- 只读账号可查看、刷新和单设备 Ping,不能修改记录或启动批量扫描。 +- 使用 LuCI 原生语言设置和翻译包,不覆盖浏览器或 LuCI 的语言选择。 + +The application distinguishes confirmed online, offline and unknown states. +Filters and sortable columns support device groups, status and text searches. +Manual registration, details, one-device Ping and address masking are available. +Type recognition is a best-effort display hint with a manual override; see +[identification rules and provenance](docs/device-identification.md). + +## 安装 / Installation + +本项目尚未合并到官方源。先用本仓库的 GitHub Actions 或 OpenWrt SDK 构建,再安装与目标系统版本匹配的本地包。 + +The package requires `luci-base`, `rpcd-mod-iwinfo` and `jshn`. +Standard OpenWrt BusyBox provides the required `ip` and `flock` commands; +`luci-base` supplies the file RPC dependency. Custom firmware that disables +these BusyBox applets must enable them or install `ip-tiny` (or `ip-full`) +and `flock` separately. + +OpenWrt with opkg, in the directory containing the build artifacts: + +```sh +opkg install luci-app-device-manager_*.ipk +opkg install luci-i18n-device-manager-zh-cn_*.ipk +``` + +OpenWrt with apk, for locally built packages: + +```sh +apk add --allow-untrusted ./luci-app-device-manager-*.apk +apk add --allow-untrusted ./luci-i18n-device-manager-zh-cn-*.apk +``` + +The Chinese language package is optional. Without an installed translation, +LuCI displays the English source messages. After installation, reload LuCI +and open **Network → Device Manager**. + +源码面向现代 JavaScript LuCI,构建 workflow 默认验证 SNAPSHOT,并支持指定稳定版 SDK。 +真实路由器安装、升级、主题和账户权限验收需按下方步骤执行,不能由本地单元测试代替。 + +## 扫描范围 / Scan scope + +默认仅扫描 netifd 的逻辑接口 `lan`。从接口的 `l3_device` 和内核 IPv4 地址获取真实网段, +不会根据全局路由表选择 WAN,也不会假设网段为 `/24`。 + +To add a separate trusted LAN, list its logical netifd interface explicitly: + +```uci +config settings 'settings' + list scan_interface 'lan' + list scan_interface 'guest' +``` + +An existing configuration without settings defaults to `lan`. An explicitly +empty `scan_interface` disables scanning. At most eight interfaces and 64 +subnets are considered. Never add an uplink interface unless probing that +interface is intentionally required. + +Each Ping is bound to the selected LAN device. Router, network and broadcast +addresses are excluded; known leases and neighbors are also checked against +the selected subnet. Prefixes with at most 4096 addresses can be enumerated. +Larger subnets probe known in-scope hosts only. IPv6 is discovered passively; +single-device Ping can use a known IPv6 address. + +Batch scans share a lock, allow at most 16 concurrent children and 128 targets +per request, stop after about eight seconds and use a 30-second cooldown +measured after completion. Further eligible clicks resume the sweep. +An unanswered ICMP Ping does not by itself prove a device is offline. + +## 构建和开发 / Build and development + +GitHub Actions **Build OpenWrt Package** accepts `SNAPSHOT` or a release +version and a `target/subtarget` such as `x86/64` or `mediatek/filogic`. +It verifies the official SDK checksum and builds an exported in-tree LuCI +application plus the Chinese language package. Standalone artifacts receive +LuCI-style versions from the source repository commit, rather than the +untracked export directory. Pull requests and main-branch +pushes build SNAPSHOT. Tags publish the resulting artifacts in a release. + +Independent-repository tools are not installed on the router or exported as +top-level files into the upstream LuCI repository: + +```sh +node --test test/test_*.js +python3 -B -m unittest discover -s test -p 'test_*.py' +python3 -B tools/update-translations.py --check --luci-dir /path/to/luci +``` + +Regenerate the native POT and merge the PO with the official LuCI extractor: + +```sh +python3 -B tools/update-translations.py --luci-dir /path/to/luci +``` + +Future upstream translations should follow LuCI's Weblate workflow. +`luci.mk` compiles `.lmo` language packages; compiled catalogues are not +committed. The standalone `tools/deploy.sh` is only for development: +it preserves existing device configuration and reuses one SSH authentication, +but requires installing the native language package separately. + +For upstream export and a suggested commit/PR description, see +[submission preparation](docs/upstream-submission.md) in the independent +repository. No tool commits, pushes or rewrites existing contributor history. + +## 真实设备验收 / Router acceptance + +1. Install the package and optional Chinese translation on a matching system; + verify English and Chinese language selection and a second LuCI language. +2. Save a device name, note, type and group; upgrade the package and reboot; + verify that the records and an intentionally empty group list survive. +3. Use a read-only account: verify refresh and details, and ensure editing and + batch scanning are denied by the RPC ACL even when called directly. +4. On isolated `/25` and `/23` LANs, inspect packet captures while scanning; + verify interface binding, subnet boundaries, concurrency and cooldown. +5. Test standard BusyBox `ip` and optionally `ip-tiny`/`ip-full`, an unavailable LAN, + IPv6-only details, Wi-Fi VLAN stations and incomplete discovery sources. +6. Check the stock Bootstrap/OpenWrt themes on desktop and mobile, including + dark mode, keyboard controls and dialogs. + +## 许可证 / Licensing + +Project code: MIT, see [LICENSE](LICENSE). Material Design Icons: Apache-2.0, +with source revision, per-icon URLs and local changes recorded under +[device-icons](htdocs/luci-static/resources/device-manager/device-icons/NOTICE). +Both applicable licenses are declared in the package Makefile. diff --git a/luci-app-device-manager/docs/device-identification.md b/luci-app-device-manager/docs/device-identification.md new file mode 100644 index 000000000..3056bf67f --- /dev/null +++ b/luci-app-device-manager/docs/device-identification.md @@ -0,0 +1,25 @@ +# Device identification + +Identification is a display hint, not an authenticated fingerprint. A saved +manual device type takes precedence. Specific hostname or custom-name clues +come next, followed by a small set of vendor hints and the default group. +Names and types never affect routing, firewall rules or DHCP configuration. + +Short English words require token boundaries. A generic `switch` means a +network switch; `nintendo-switch` means a console. `gateway` means a router, +and `Cambridge-PC` means a computer. Chinese device keywords are also accepted. + +The previous broad OUI tables had no recorded provenance. They were replaced +with the 13 documented entries in `oui-hints.json`. Manufacturer assignments +were checked against the Wireshark project's automatically generated +[manufacturer data](https://www.wireshark.org/download/automated/data/manuf.gz), +which incorporates IEEE registration data. The verification source checksum +is recorded with the entries. Only assignment facts are retained; the complete +database is not bundled. + +A manufacturer assignment identifies an organization, not a product. Apple, +Google, HP and other vendors with diverse products therefore have no automatic +device type hint. Ubiquiti and H3C use the general network-device category; +Espressif and Lumi use the general smart-home category. These remain guesses, +and users can override them. Locally administered or randomized MAC addresses +never use vendor hints. Hostname and manual identification still work for them. diff --git a/luci-app-device-manager/docs/oui-hints.json b/luci-app-device-manager/docs/oui-hints.json new file mode 100644 index 000000000..ebd635f96 --- /dev/null +++ b/luci-app-device-manager/docs/oui-hints.json @@ -0,0 +1,59 @@ +{ + "source": "https://www.wireshark.org/download/automated/data/manuf.gz", + "verified_on": "2026-10-10", + "decompressed_source_sha256": "d542945307e2ab7c11f70c741f41f4a55585088b05da139fe5583154cb60ccc1", + "entries": { + "001132": { + "organization": "Synology Incorporated", + "type_hint": "nas" + }, + "4CFCAA": { + "organization": "Tesla,Inc.", + "type_hint": "car" + }, + "2C2617": { + "organization": "Oculus VR, LLC", + "type_hint": "vr" + }, + "1012FB": { + "organization": "Hangzhou Hikvision Digital Technology Co.,Ltd.", + "type_hint": "camera" + }, + "0009BF": { + "organization": "Nintendo Co., Ltd.", + "type_hint": "game" + }, + "000E58": { + "organization": "Sonos, Inc.", + "type_hint": "speaker" + }, + "001788": { + "organization": "Philips Lighting BV", + "type_hint": "light" + }, + "18FE34": { + "organization": "Espressif Inc.", + "type_hint": "home" + }, + "54EF44": { + "organization": "Lumi United Technology Co., Ltd", + "type_hint": "home" + }, + "00156D": { + "organization": "Ubiquiti Inc", + "type_hint": "network" + }, + "000FE2": { + "organization": "Hangzhou H3C Technologies Co., Limited", + "type_hint": "network" + }, + "000C42": { + "organization": "Routerboard.com", + "type_hint": "router" + }, + "B827EB": { + "organization": "Raspberry Pi Foundation", + "type_hint": "computer" + } + } +} diff --git a/luci-app-device-manager/docs/upstream-submission.md b/luci-app-device-manager/docs/upstream-submission.md new file mode 100644 index 000000000..fd8ceabfe --- /dev/null +++ b/luci-app-device-manager/docs/upstream-submission.md @@ -0,0 +1,85 @@ +# Upstream submission + +Target: `openwrt/luci`, branch `master`, directory +`applications/luci-app-device-manager/`. + +The independent repository retains development tests, SDK build workflows and +SSH deployment tooling. They are not copied to the upstream repository root. +The configuration initializer lives under `tools/` for source deployments; +packages ship `/etc/config/device_manager` directly. The exported README uses +official-feed installation commands, while standalone build instructions stay +in the independent repository. +Export reviewed package files into a fresh LuCI feature branch: + +```sh +git clone https://github.com/openwrt/luci.git /tmp/luci-device-manager-upstream +git -C /tmp/luci-device-manager-upstream switch -c add-device-manager +git -C /tmp/luci-device-manager-upstream config user.name "zhiyong zhang" +git -C /tmp/luci-device-manager-upstream config user.email "zhang747@126.com" +python3 -B tools/prepare-upstream.py /tmp/luci-device-manager-upstream +git -C /tmp/luci-device-manager-upstream diff --check +``` + +The exporter refuses an existing application directory and does not change +Git history, commit or push. It uses the standard relative `luci.mk` include +and exports only package sources, PO files, licenses and package documentation. + +Suggested commit title: + +```text +luci-app-device-manager: add LAN device management +``` + +Suggested commit body: + +```text +Add a JavaScript LuCI view for LAN device discovery and metadata management. +Combine host hints, DHCP leases, wireless associations and kernel neighbors. +Save names, remarks, groups and manual type overrides by MAC address. + +Unlike the existing host list, this application maintains custom device +metadata and groups, including saved devices that are currently offline. + +Keep ordinary refreshes passive. Provide an explicit write-authorized scan +limited to configured netifd interfaces and their actual IPv4 prefixes. +Bound concurrent probes, scan duration and the shared scan cooldown. + +Use native LuCI message extraction and separately built translation packages. +Include pinned Material Design Icons attribution and Apache-2.0 notices. + +Signed-off-by: zhiyong zhang +``` + +Submission commits use `zhiyong zhang ` for the author, +committer and Signed-off-by identity. Configure this identity locally in the +LuCI checkout before committing, as shown above. Existing contributor +attribution must be preserved; these tools do not rewrite historical commits. + +Suggested PR description: + +```text +Add a LAN device manager with MAC-keyed custom names, remarks, groups and +manual device types. Discovery combines native LuCI host/lease/wireless RPCs +with kernel neighbor data and distinguishes online, offline and unknown. + +Refresh is passive. An explicit Scan LAN operation requires write permission +and scopes ICMP probes to configured netifd interfaces and actual prefixes. +Chinese translations are built by luci.mk; other languages use native LuCI +catalogues and English fallback. + +Validation: +- 100 Node and 8 Python regression tests passed. +- 33 RPC regressions passed with SDK-native jshn and isolated network inputs. +- Official LuCI message extraction, gettext checks and ESLint passed. +- Built main and Chinese language APKs in the official x86/64 SNAPSHOT SDK. +- Verified official SNAPSHOT and 24.10.4 BusyBox ip/flock compatibility; + 33 RPC regressions passed with each binary and SDK-native jshn. +- Checked resolved dependencies without extra ip or flock packages. +- Checked package license texts, configuration preservation metadata and LMO. + +Real-router installation, upgrades, account enforcement, UI acceptance and +a stable-release SDK build remain unverified. +``` + +Review the recorded validation results before sending the PR. +Local tests do not certify real-router or upstream acceptance. diff --git a/luci-app-device-manager/docs/validation.md b/luci-app-device-manager/docs/validation.md new file mode 100644 index 000000000..d24b5fc2d --- /dev/null +++ b/luci-app-device-manager/docs/validation.md @@ -0,0 +1,66 @@ +# Local upstream-readiness validation + +## PR review follow-up: 2026-10-11 + +Addressed the dependency, license installation, redundant initializer and +in-tree README comments on OpenWrt LuCI PR #9125. + +- Full Node regression suite: 100 passed, 0 failed, 0 skipped. +- Python regression suite, including the exported installation instructions: + 8 passed. +- Official LuCI ESLint, native translation extraction, gettext, shell syntax + and whitespace checks passed. +- Official OpenWrt BusyBox packages tested locally with the SDK musl loader: + SNAPSHOT 1.38.0-r3 and 24.10.4 1.36.1-r3. Both support + `ip -o -4 addr show dev lo`, `ip neigh show` and `flock -n 9`. + The actual RPC helper parsed IPv4 and IPv6 neighbor output from both binaries. +- The 33 shell/Ping/scan regressions passed with each official BusyBox binary, + its flock applet and SDK-native jshn. Network probes, netifd and UCI inputs + were isolated fixtures; no router was modified. +- Rebuilt the main and Chinese language APKs in the x86/64 SNAPSHOT SDK. + Main-package dependencies are libc, luci-base, rpcd-mod-iwinfo and jshn. + ip-tiny, ip-full and util-linux flock were disabled in the SDK configuration. + The extra license installation hook and packaged configuration initializer + were removed; MIT and Apache-2.0 declarations and source notices remain. + +Compatibility evidence and official binaries: /tmp/device-manager-review-compat/. +SDK build log: /tmp/device-manager-review-sdk-build.log. +Real-router acceptance and a stable-release SDK build remain unverified. + +## Initial validation: 2026-10-10 + +The checks below describe the initial submission snapshot, before the PR review +follow-up. No router was modified during that validation. + +## Passed checks + +- Node regression suite: 100 passed, 0 failed, 0 skipped. +- Python regression suite: 8 passed. +- Official LuCI master ESLint configuration: no errors or warnings. +- Native POT extraction: 184 source messages, including menu, ACL and UI. +- GNU gettext template/catalogue comparison and PO checks. +- SDK-native jshn regression run: 33 passed, 0 skipped. Network and UCI inputs were isolated fixtures. +- Workflow YAML parsing and every run block checked with bash -n. +- Shell syntax and git diff --check. + +## SDK package validation + +- Official x86/64 SNAPSHOT SDK archive SHA256: + 60b7182a36d0542810e5564dcf9f7c2531bbbc74546ededcbc619b2939b72127 +- SDK base source commit: 53ec2d05a487e6011cfa096de9211d15197c501b. +- LuCI reference commit: 36720dd716c7f7008af95c0d520edefab621adb7. +- SDK download checksum was verified. Official GitHub mirrors were used for feeds, retaining the SDK base commit. +- Main package and Chinese language package compiled successfully. +- ip-tiny and ip-full dependency variants both built; each main package depends only on its selected IP implementation. +- Unpacked the ip-tiny packages and checked executable RPC helper, configuration preservation metadata, both applicable license texts, icon attribution and absence of legacy bundled translations. +- The language package LMO matches a fresh compilation of the current PO using SDK po2lmo. + +Local APKs and manifest: /tmp/device-manager-built-packages/. These are local validation builds. +The local package version uses the existing source HEAD while compiling this uncommitted working tree. +CI builds calculate the version from the checked-out source commit; upstream builds use the LuCI feed revision. + +## Remaining external acceptance + +Real-router installation, upgrades, reboot persistence, account enforcement, packet captures, themes and mobile UI remain to be checked using the README acceptance steps. +GitHub Actions was validated locally but has not been triggered remotely. +Stable-release SDK/IPK builds have not been rerun in this validation. diff --git a/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-dialog.js b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-dialog.js new file mode 100644 index 000000000..d7b5d92c1 --- /dev/null +++ b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-dialog.js @@ -0,0 +1,336 @@ +// SPDX-License-Identifier: MIT +'use strict'; +'require baseclass'; +'require ui'; +'require device-manager.model as model'; + +function statusLabel(status) { + return status === 'online' ? _('Online') : status === 'offline' ? _('Offline') : _('Unknown'); +} + +function closeButton() { + return E('button', { 'type': 'button', 'class': 'dm-dialog-close', + 'aria-label': _('Close'), 'title': _('Close'), 'click': ui.hideModal + }, [ '×' ]); +} + +function deviceHero(dev) { + const typeInfo = model.getTypeInfo(dev.type); + return E('div', { 'class': 'dm-dialog-hero' }, [ + E('span', { 'class': 'dm-dialog-icon' }, [ E('img', { + 'src': L.resource('device-manager/device-icons/' + typeInfo.icon), 'alt': '' + }) ]), + E('div', { 'class': 'dm-dialog-heading' }, [ + E('strong', {}, [ dev.customName || dev.hostname || _('Unknown device') ]), + E('span', { 'class': 'dm-dialog-muted' }, [ model.getTypeLabel(dev.type) ]) + ]), + E('span', { 'class': 'dm-dialog-status dm-dialog-status-' + dev.status }, [ statusLabel(dev.status) ]) + ]); +} + +function infoGrid(fields) { + return E('div', { 'class': 'dm-info-grid' }, fields.map(field => E('div', { + 'class': 'dm-info-item' + (field[2] ? ' dm-info-wide' : '') + }, [ + E('span', { 'class': 'dm-info-label' }, [ field[0] ]), + E('div', { 'class': 'dm-info-value' }, [ field[1] || '—' ]) + ]))); +} + +return baseclass.extend({ + showEditModal: function(dev) { + if (this.readonly) return; + const self = this; + const isEdit = (dev !== null); + + const curMac = isEdit ? dev.mac : ''; + const curHostname = isEdit ? (dev.hostname || _('Not detected')) : ''; + const curIp = isEdit ? (dev.ipv4 || dev.ipv6 || _('Unavailable')) : ''; + const curName = isEdit ? (dev.customName || '') : ''; + const curRemark = isEdit ? (dev.remark || '') : ''; + const curGroup = isEdit ? (dev.group || 'ungrouped') : 'ungrouped'; + + const modalTitle = isEdit + ? _('Edit device') + : _('Add device'); + + let macInput = null; + let macRow = null; + + if (!isEdit) { + macInput = E('input', { + 'type': 'text', + 'class': 'cbi-input-text', + 'placeholder': _('e.g. AA:BB:CC:11:22:33'), + 'maxlength': 17, + 'style': 'width:100%;' + }); + macRow = E('div', { 'class': 'cbi-value dm-form-wide' }, [ + E('label', { 'class': 'cbi-value-title' }, [ + _('MAC address'), + E('span', { 'style': 'color:red;' }, [ ' *' ]) + ]), + E('div', { 'class': 'cbi-value-field' }, [ + macInput, + E('div', { 'class': 'cbi-value-description' }, [ _('Hardware address; colon or hyphen separators are accepted') ]) + ]) + ]); + } + + // Group dropdown options + const groupSelect = E('select', { 'class': 'cbi-input-select', 'style': 'width:100%;' }, [ + E('option', { 'value': 'ungrouped' }, [ _('Ungrouped') ]) + ]); + for (let i = 0; i < this.groups.length; i++) { + const g = this.groups[i]; + const opt = E('option', { 'value': g.id }, [ g.name ]); + if (curGroup === g.id) opt.selected = true; + groupSelect.appendChild(opt); + } + + const curType = isEdit ? (dev.customType || 'auto') : 'auto'; + const typeSelect = E('select', { 'class': 'cbi-input-select', 'style': 'width:100%;' }, [ + E('option', { 'value': 'auto' }, [ _('Auto (detect by MAC)') ]) + ]); + for (const [ typeKey, typeObj ] of Object.entries(model.DEVICE_TYPES)) { + const opt = E('option', { 'value': typeKey }, [ typeObj.label() ]); + if (curType === typeKey) opt.selected = true; + typeSelect.appendChild(opt); + } + + const nameInput = E('input', { + 'type': 'text', + 'class': 'cbi-input-text', + 'value': curName, + 'placeholder': _('e.g. Living room TV, work computer'), + 'maxlength': 64, + 'style': 'width:100%;' + }); + + const remarkTextarea = E('textarea', { + 'class': 'cbi-input-textarea', + 'rows': 3, + 'placeholder': _('e.g. Living room TV'), + 'maxlength': 256, + 'style': 'width:100%;' + }, [ curRemark ]); + + const errorDiv = E('div', { + 'class': 'alert-message danger', + 'style': 'display:none; margin-bottom:15px;' + }); + + const formFields = isEdit ? [] : [ macRow ]; + + formFields.push( + E('div', { 'class': 'cbi-value' }, [ + E('label', { 'class': 'cbi-value-title' }, [ _('Device type') ]), + E('div', { 'class': 'cbi-value-field' }, [ + typeSelect, + E('div', { 'class': 'cbi-value-description' }, [ + _('Automatically detected from names and MAC address, or customize manually') + ]) + ]) + ]), + E('div', { 'class': 'cbi-value' }, [ + E('label', { 'class': 'cbi-value-title' }, [ _('Device group') ]), + E('div', { 'class': 'cbi-value-field' }, [ + groupSelect, + E('div', { 'class': 'cbi-value-description' }, [ _('Organize this device by purpose or location') ]) + ]) + ]), + E('div', { 'class': 'cbi-value' }, [ + E('label', { 'class': 'cbi-value-title' }, [ _('Custom name') ]), + E('div', { 'class': 'cbi-value-field' }, [ + nameInput, + E('div', { 'class': 'cbi-value-description' }, [ _('Leave empty to display the default hostname') ]) + ]) + ]), + E('div', { 'class': 'cbi-value' }, [ + E('label', { 'class': 'cbi-value-title' }, [ _('Device remarks') ]), + E('div', { 'class': 'cbi-value-field' }, [ + remarkTextarea, + E('div', { 'class': 'cbi-value-description' }, [ _('Record the location, purpose or owner of this device') ]) + ]) + ]) + ); + + const btnCancel = E('button', { 'type': 'button', + 'class': 'btn cbi-button', + 'click': ui.hideModal + }, [ _('Cancel') ]); + + const btnSave = E('button', { 'type': 'button', + 'class': 'btn cbi-button cbi-button-action important dm-primary-button', + 'click': function(ev) { + const button = ev.currentTarget; + const targetMac = isEdit ? curMac : (macInput ? macInput.value : ''); + const normMac = model.normalizeMac(targetMac); + + if (!normMac) { + errorDiv.textContent = _('Enter a valid MAC address (e.g. AA:BB:CC:11:22:33)'); + errorDiv.style.display = 'block'; + if (macInput) macInput.focus(); + return; + } + + errorDiv.style.display = 'none'; + const newName = model.sanitizeInput(nameInput.value); + const newRemark = model.sanitizeInput(remarkTextarea.value); + const newGroup = groupSelect.value || 'ungrouped'; + const newType = typeSelect.value || 'auto'; + + button.classList.add('spinning'); + button.disabled = true; + + return self.handleSaveDevice(normMac, newName, newRemark, newGroup, dev, newType) + .then(function() { + ui.hideModal(); + ui.addNotification(null, E('p', [ _('Device "%s" saved.').format(newName || normMac) ]), 'info'); + return self.refresh(); + }) + .catch(function(err) { + errorDiv.textContent = _('Save failed: %s').format(err.message || err); + errorDiv.style.display = 'block'; + }) + .finally(function() { + button.classList.remove('spinning'); + button.disabled = false; + }); + } + }, [ _('Save') ]); + + const buttonRow = []; + + if (isEdit && dev.isSaved) { + const btnDelete = E('button', { 'type': 'button', + 'class': 'btn cbi-button cbi-button-remove dm-clear-record', + 'click': function() { + ui.hideModal(); + self.confirmDelete(dev); + } + }, [ _('Clear record') ]); + buttonRow.push(btnDelete); + } + buttonRow.push(E('div', { 'class': 'right dm-dialog-buttons' }, [ btnCancel, btnSave ])); + + const content = [ closeButton(), errorDiv ]; + if (isEdit) content.push(deviceHero(dev), infoGrid([ + [ _('MAC address'), curMac ], [ _('System hostname'), curHostname ], + [ _('Current IP address'), curIp ], [ _('Current status'), statusLabel(dev.status) ] + ])); + content.push(E('div', { 'class': 'dm-edit-fields' }, formFields), + E('div', { 'class': 'dm-dialog-footer' }, buttonRow)); + ui.showModal(modalTitle, [ E('div', { 'class': 'dm-dialog' }, content) ], 'dm-device-modal'); + }, + + confirmDelete: function(dev) { + if (this.readonly) return; + const self = this; + const name = dev.customName || dev.hostname || dev.mac; + + ui.showModal(_('Clear record'), [ E('div', { 'class': 'dm-dialog' }, [ + closeButton(), + deviceHero(dev), + E('p', { 'class': 'dm-clear-description' }, [ _('Clear the saved record for "%s" (%s)?').format(name, dev.mac) ]), + E('p', { 'class': 'dm-dialog-muted' }, [ _('Clears the custom name, remarks, group and device type. The device remains discoverable on the network.') ]), + E('div', { 'class': 'right dm-dialog-footer dm-dialog-buttons' }, [ + E('button', { 'type': 'button', 'class': 'btn cbi-button', 'click': ui.hideModal }, [ _('Cancel') ]), + ' ', + E('button', { 'type': 'button', + 'class': 'btn cbi-button cbi-button-remove', + 'click': function(ev) { + const button = ev.currentTarget; + button.classList.add('spinning'); + button.disabled = true; + + return self.handleDeleteDevice(dev) + .then(function() { + ui.hideModal(); + ui.addNotification(null, E('p', [ _('Saved record for "%s" cleared.').format(name) ]), 'info'); + return self.refresh(); + }) + .catch(function(err) { + ui.hideModal(); + ui.addNotification(null, E('p', [ _('Clear failed: %s').format(err.message || err) ]), 'danger'); + }).finally(function() { + button.classList.remove('spinning'); + button.disabled = false; + }); + } + }, [ _('Confirm clear') ]) + ]) + ]) ], 'dm-device-modal'); + }, + + showDetailModal: function(dev) { + const self = this; + const addresses = key => (dev[key + 'Addresses'] || []).length + ? dev[key + 'Addresses'].join('\n') : dev[key]; + const pingStatus = E('strong', { 'class': 'dm-ping-status', 'role': 'status', 'aria-live': 'polite' }, [ _('Probing…') ]); + const pingDetail = E('p', { 'class': 'dm-dialog-muted' }); + const pingOutput = E('pre', { 'class': 'dm-ping-output' }); + const pingLog = E('details', { 'class': 'dm-ping-log', 'style': 'display:none;' }, [ + E('summary', {}, [ _('Ping output') ]), pingOutput + ]); + let pending = null; + const runPing = () => { + if (pending) return pending; + pingButton.disabled = true; + pingButton.classList.add('spinning'); + pingStatus.classList.remove('dm-ping-success'); + pingStatus.classList.remove('dm-ping-warning'); + pingStatus.textContent = _('Probing…'); + pingDetail.textContent = _('Sending one Ping from the router to this device.'); + pingLog.style.display = 'none'; + pending = Promise.resolve().then(() => self.handlePingDevice(dev)).then(reply => { + pingStatus.textContent = reply.reachable ? _('Online · Ping replied') : _('No Ping reply'); + pingStatus.classList.add(reply.reachable ? 'dm-ping-success' : 'dm-ping-warning'); + pingDetail.textContent = _('Probe address: %s').format(reply.ip + (reply.interface ? ' (' + reply.interface + ')' : '')) + + (reply.reachable ? '' : '\n' + _('No ICMP reply was received. The device may block Ping; this does not confirm it is offline.')); + pingOutput.textContent = reply.output; + pingLog.style.display = reply.output ? '' : 'none'; + }).catch(error => { + pingStatus.textContent = _('Probe unavailable'); + pingStatus.classList.add('dm-ping-warning'); + pingDetail.textContent = error.message || String(error); + pingOutput.textContent = error.output || ''; + pingLog.style.display = error.output ? '' : 'none'; + }).finally(() => { + pending = null; + pingButton.disabled = false; + pingButton.classList.remove('spinning'); + }); + return pending; + }; + const pingButton = E('button', { 'type': 'button', 'class': 'cbi-button cbi-button-action', 'click': runPing }, [ _('Ping again') ]); + const sourceLabels = { + 'DHCP': _('DHCP'), 'Host hints': _('Host hints'), 'Wi-Fi': _('Wi-Fi'), + 'ARP': _('ARP'), 'Neighbor table': _('Neighbor table') + }; + const neighbors = (dev.neighbors || []).map(neighbor => [ neighbor.ip, neighbor.dev, neighbor.state ].filter(Boolean).join(' · ')).join('\n'); + ui.showModal(_('Device details'), [ E('div', { 'class': 'dm-dialog' }, [ + closeButton(), + deviceHero(dev), + E('section', { 'class': 'dm-ping-card' }, [ + E('div', { 'class': 'dm-ping-heading' }, [ pingStatus, pingButton ]), pingDetail, pingLog + ]), + infoGrid([ + [ _('MAC address'), dev.mac ], [ _('System hostname'), dev.hostname ], + [ _('Custom name'), dev.customName ], [ _('Device group'), self.getGroupName(dev.group) ], + [ _('IPv4 addresses'), addresses('ipv4') ], [ _('IPv6 addresses'), addresses('ipv6') ], + [ _('Network interfaces'), (dev.interfaces || []).join(', ') ], + [ _('Type detection'), dev.customType && dev.customType !== 'auto' ? _('Manual') : _('Automatic') ], + [ _('Saved record'), dev.isSaved ? _('Yes') : _('No') ], + [ _('Discovered on network'), dev.isDiscovered ? _('Yes') : _('No') ], + [ _('Discovery sources'), (dev.discoverySources || []).map(source => sourceLabels[source] || source).join(', '), true ], + [ _('Status evidence'), dev.statusDetail, true ], + [ _('Neighbor records'), neighbors, true ], [ _('Device remarks'), dev.remark, true ] + ]), + E('div', { 'class': 'right dm-dialog-footer dm-dialog-buttons' }, [ + E('button', { 'type': 'button', 'class': 'cbi-button', 'click': ui.hideModal }, [ _('Close') ]) + ]) + ]) ], 'dm-device-modal'); + return runPing(); + } +}); diff --git a/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/LICENSE b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/LICENSE new file mode 100644 index 000000000..d64569567 --- /dev/null +++ b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/LICENSE @@ -0,0 +1,202 @@ + + 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. diff --git a/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/NOTICE b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/NOTICE new file mode 100644 index 000000000..ba5408894 --- /dev/null +++ b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/NOTICE @@ -0,0 +1,12 @@ +Material Design Icons +https://github.com/Templarian/MaterialDesign +By the Pictogrammers icon group and Material Design Icons contributors. + +The SVG path geometry in this directory was verified against upstream revision +2424e748e0cc63ab7b9c095a099b9fe239b737c0. Original icon names and source URLs are +listed in sources.json. The Apache License, Version 2.0 and upstream license +summary are included as LICENSE and UPSTREAM-LICENSE. + +Local modifications: filenames map to device types; width and height are set +to 24 pixels; CSS selects foreground colours for light and dark browser themes. +SPDX license comments were added. No SVG path geometry was changed. diff --git a/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/UPSTREAM-LICENSE b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/UPSTREAM-LICENSE new file mode 100644 index 000000000..382f8a138 --- /dev/null +++ b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/UPSTREAM-LICENSE @@ -0,0 +1,20 @@ +Pictogrammers Free License +-------------------------- + +This icon collection is released as free, open source, and GPL friendly by +the [Pictogrammers](http://pictogrammers.com/) icon group. You may use it +for commercial projects, open source projects, or anything really. + +# Icons: Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) +Some of the icons are redistributed under the Apache 2.0 license. All other +icons are either redistributed under their respective licenses or are +distributed under the Apache 2.0 license. + +# Fonts: Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) +All web and desktop fonts are distributed under the Apache 2.0 license. Web +and desktop fonts contain some icons that are redistributed under the Apache +2.0 license. All other icons are either redistributed under their respective +licenses or are distributed under the Apache 2.0 license. + +# Code: MIT (https://opensource.org/licenses/MIT) +The MIT license applies to all non-font and non-icon files. diff --git a/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/airconditioner.svg b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/airconditioner.svg new file mode 100644 index 000000000..4335f0b0b --- /dev/null +++ b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/airconditioner.svg @@ -0,0 +1,12 @@ + + + + + diff --git a/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/airpurifier.svg b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/airpurifier.svg new file mode 100644 index 000000000..471c2b651 --- /dev/null +++ b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/airpurifier.svg @@ -0,0 +1,12 @@ + + + + + diff --git a/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/ap.svg b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/ap.svg new file mode 100644 index 000000000..2b30ec906 --- /dev/null +++ b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/ap.svg @@ -0,0 +1,12 @@ + + + + + diff --git a/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/camera.svg b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/camera.svg new file mode 100644 index 000000000..32468894e --- /dev/null +++ b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/camera.svg @@ -0,0 +1,12 @@ + + + + + diff --git a/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/car.svg b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/car.svg new file mode 100644 index 000000000..84037bbc7 --- /dev/null +++ b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/car.svg @@ -0,0 +1,12 @@ + + + + + diff --git a/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/computer.svg b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/computer.svg new file mode 100644 index 000000000..14a2576b8 --- /dev/null +++ b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/computer.svg @@ -0,0 +1,12 @@ + + + + + diff --git a/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/fridge.svg b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/fridge.svg new file mode 100644 index 000000000..a015f8677 --- /dev/null +++ b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/fridge.svg @@ -0,0 +1,12 @@ + + + + + diff --git a/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/game.svg b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/game.svg new file mode 100644 index 000000000..3b0c0bafc --- /dev/null +++ b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/game.svg @@ -0,0 +1,12 @@ + + + + + diff --git a/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/home.svg b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/home.svg new file mode 100644 index 000000000..59ee499b9 --- /dev/null +++ b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/home.svg @@ -0,0 +1,12 @@ + + + + + diff --git a/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/laptop.svg b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/laptop.svg new file mode 100644 index 000000000..58cff6cb5 --- /dev/null +++ b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/laptop.svg @@ -0,0 +1,12 @@ + + + + + diff --git a/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/light.svg b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/light.svg new file mode 100644 index 000000000..eb681b11f --- /dev/null +++ b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/light.svg @@ -0,0 +1,12 @@ + + + + + diff --git a/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/nas.svg b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/nas.svg new file mode 100644 index 000000000..8f83a657e --- /dev/null +++ b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/nas.svg @@ -0,0 +1,12 @@ + + + + + diff --git a/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/network.svg b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/network.svg new file mode 100644 index 000000000..8f43e68b7 --- /dev/null +++ b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/network.svg @@ -0,0 +1,12 @@ + + + + + diff --git a/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/phone.svg b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/phone.svg new file mode 100644 index 000000000..1e2dbcb40 --- /dev/null +++ b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/phone.svg @@ -0,0 +1,12 @@ + + + + + diff --git a/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/plug.svg b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/plug.svg new file mode 100644 index 000000000..725ee4a7d --- /dev/null +++ b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/plug.svg @@ -0,0 +1,12 @@ + + + + + diff --git a/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/printer.svg b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/printer.svg new file mode 100644 index 000000000..d97ca7934 --- /dev/null +++ b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/printer.svg @@ -0,0 +1,12 @@ + + + + + diff --git a/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/router.svg b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/router.svg new file mode 100644 index 000000000..a7df4c9cc --- /dev/null +++ b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/router.svg @@ -0,0 +1,12 @@ + + + + + diff --git a/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/sensor.svg b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/sensor.svg new file mode 100644 index 000000000..afcc8cc55 --- /dev/null +++ b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/sensor.svg @@ -0,0 +1,12 @@ + + + + + diff --git a/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/server.svg b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/server.svg new file mode 100644 index 000000000..9de68b46a --- /dev/null +++ b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/server.svg @@ -0,0 +1,12 @@ + + + + + diff --git a/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/sources.json b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/sources.json new file mode 100644 index 000000000..b828d75d9 --- /dev/null +++ b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/sources.json @@ -0,0 +1,121 @@ +{ + "revision": "2424e748e0cc63ab7b9c095a099b9fe239b737c0", + "icons": { + "sensor.svg": { + "name": "gauge", + "source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/gauge.svg" + }, + "light.svg": { + "name": "lightbulb", + "source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/lightbulb.svg" + }, + "computer.svg": { + "name": "monitor", + "source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/monitor.svg" + }, + "fridge.svg": { + "name": "fridge", + "source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/fridge.svg" + }, + "tablet.svg": { + "name": "tablet", + "source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/tablet.svg" + }, + "airpurifier.svg": { + "name": "air-purifier", + "source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/air-purifier.svg" + }, + "washer.svg": { + "name": "washing-machine", + "source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/washing-machine.svg" + }, + "camera.svg": { + "name": "cctv", + "source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/cctv.svg" + }, + "laptop.svg": { + "name": "laptop", + "source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/laptop.svg" + }, + "speaker.svg": { + "name": "speaker", + "source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/speaker.svg" + }, + "tv.svg": { + "name": "television", + "source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/television.svg" + }, + "phone.svg": { + "name": "cellphone", + "source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/cellphone.svg" + }, + "waterpurifier.svg": { + "name": "water-check", + "source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/water-check.svg" + }, + "tvbox.svg": { + "name": "set-top-box", + "source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/set-top-box.svg" + }, + "network.svg": { + "name": "lan", + "source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/lan.svg" + }, + "airconditioner.svg": { + "name": "air-conditioner", + "source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/air-conditioner.svg" + }, + "watch.svg": { + "name": "watch-variant", + "source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/watch-variant.svg" + }, + "vr.svg": { + "name": "virtual-reality", + "source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/virtual-reality.svg" + }, + "nas.svg": { + "name": "nas", + "source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/nas.svg" + }, + "plug.svg": { + "name": "power-plug", + "source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/power-plug.svg" + }, + "router.svg": { + "name": "router-wireless", + "source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/router-wireless.svg" + }, + "home.svg": { + "name": "home-automation", + "source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/home-automation.svg" + }, + "car.svg": { + "name": "car", + "source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/car.svg" + }, + "server.svg": { + "name": "server", + "source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/server.svg" + }, + "ap.svg": { + "name": "access-point", + "source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/access-point.svg" + }, + "unknown.svg": { + "name": "help-circle-outline", + "source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/help-circle-outline.svg" + }, + "printer.svg": { + "name": "printer", + "source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/printer.svg" + }, + "game.svg": { + "name": "controller", + "source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/controller.svg" + }, + "switch.svg": { + "name": "ethernet", + "source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/ethernet.svg" + } + } +} diff --git a/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/speaker.svg b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/speaker.svg new file mode 100644 index 000000000..6df48ebc1 --- /dev/null +++ b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/speaker.svg @@ -0,0 +1,12 @@ + + + + + diff --git a/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/switch.svg b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/switch.svg new file mode 100644 index 000000000..cc78d2e6c --- /dev/null +++ b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/switch.svg @@ -0,0 +1,12 @@ + + + + + diff --git a/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/tablet.svg b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/tablet.svg new file mode 100644 index 000000000..f08fb1ba0 --- /dev/null +++ b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/tablet.svg @@ -0,0 +1,12 @@ + + + + + diff --git a/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/tv.svg b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/tv.svg new file mode 100644 index 000000000..08175861a --- /dev/null +++ b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/tv.svg @@ -0,0 +1,12 @@ + + + + + diff --git a/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/tvbox.svg b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/tvbox.svg new file mode 100644 index 000000000..1c155036b --- /dev/null +++ b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/tvbox.svg @@ -0,0 +1,12 @@ + + + + + diff --git a/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/unknown.svg b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/unknown.svg new file mode 100644 index 000000000..b0fb8bbaf --- /dev/null +++ b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/unknown.svg @@ -0,0 +1,12 @@ + + + + + diff --git a/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/vr.svg b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/vr.svg new file mode 100644 index 000000000..e153f81eb --- /dev/null +++ b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/vr.svg @@ -0,0 +1,12 @@ + + + + + diff --git a/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/washer.svg b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/washer.svg new file mode 100644 index 000000000..7609cb67a --- /dev/null +++ b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/washer.svg @@ -0,0 +1,12 @@ + + + + + diff --git a/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/watch.svg b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/watch.svg new file mode 100644 index 000000000..28d01453d --- /dev/null +++ b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/watch.svg @@ -0,0 +1,12 @@ + + + + + diff --git a/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/waterpurifier.svg b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/waterpurifier.svg new file mode 100644 index 000000000..0c9cbb8ec --- /dev/null +++ b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-icons/waterpurifier.svg @@ -0,0 +1,12 @@ + + + + + diff --git a/luci-app-device-manager/htdocs/luci-static/resources/device-manager/group-dialog.js b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/group-dialog.js new file mode 100644 index 000000000..3065947f8 --- /dev/null +++ b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/group-dialog.js @@ -0,0 +1,239 @@ +// SPDX-License-Identifier: MIT +'use strict'; +'require baseclass'; +'require ui'; +'require device-manager.model as model'; + +return baseclass.extend({ + showGroupModal: function() { + if (this.readonly) return; + const self = this; + + // Calculate count of devices per group + const groupCounts = new Map(); + for (let i = 0; i < this.devices.length; i++) { + const g = this.devices[i].group; + if (g && g !== 'ungrouped') { + groupCounts.set(g, (groupCounts.get(g) || 0) + 1); + } + } + + const groupRows = this.groups.map(function(g) { + const count = groupCounts.get(g.id) || 0; + + const btnRename = E('button', { 'type': 'button', + 'class': 'btn cbi-button cbi-button-neutral', + 'style': 'margin-right:6px;', + 'click': function() { self.promptRenameGroup(g); } + }, [ _('Rename') ]); + + const btnDelete = E('button', { 'type': 'button', + 'class': 'btn cbi-button cbi-button-remove', + 'click': function() { self.confirmDeleteGroup(g, count); } + }, [ _('Delete') ]); + + return E('tr', { 'class': 'tr' }, [ + E('td', { 'class': 'td', 'style': 'font-weight:bold;' }, [ g.name ]), + E('td', { 'class': 'td' }, E('code', {}, [ g.id ])), + E('td', { 'class': 'td' }, [ _('%d devices').format(count) ]), + E('td', { 'class': 'td', 'style': 'text-align:right;' }, [ btnRename, btnDelete ]) + ]); + }); + + const newGroupInput = E('input', { + 'type': 'text', + 'class': 'cbi-input-text', + 'placeholder': _('e.g. Media devices, office devices'), + 'maxlength': 32, + 'style': 'width:240px; margin-right:8px;' + }); + + const errorDiv = E('div', { + 'class': 'alert-message danger', + 'style': 'display:none; margin-bottom:12px;' + }); + + const btnAddGroup = E('button', { 'type': 'button', + 'class': 'btn cbi-button cbi-button-positive', + 'click': function(ev) { + const button = ev.currentTarget; + const name = model.sanitizeInput(newGroupInput.value); + if (!name) { + errorDiv.textContent = _('Enter a valid group name'); + errorDiv.style.display = 'block'; + newGroupInput.focus(); + return; + } + + // Check duplicate name + if (self.groups.some(g => g.name === name)) { + errorDiv.textContent = _('A group with this name already exists'); + errorDiv.style.display = 'block'; + newGroupInput.focus(); + return; + } + + button.classList.add('spinning'); + button.disabled = true; + + return self.handleAddGroup(name) + .then(function() { + ui.hideModal(); + ui.addNotification(null, E('p', [ _('Group "%s" created.').format(name) ]), 'info'); + return self.refresh().then(function() { + self.showGroupModal(); + }); + }) + .catch(function(err) { + errorDiv.textContent = _('Could not create group: %s').format(err.message || err); + errorDiv.style.display = 'block'; + }) + .finally(function() { + button.classList.remove('spinning'); + button.disabled = false; + }); + } + }, [ _('Add group') ]); + + ui.showModal(_('Device groups'), [ + E('div', { 'class': 'cbi-section' }, [ + errorDiv, + E('table', { 'class': 'table', 'style': 'width:100%; margin-bottom:20px;' }, [ + E('tr', { 'class': 'tr table-titles' }, [ + E('th', { 'class': 'th' }, [ _('Group name') ]), + E('th', { 'class': 'th' }, [ _('Group ID') ]), + E('th', { 'class': 'th' }, [ _('Device count') ]), + E('th', { 'class': 'th', 'style': 'text-align:right;' }, [ _('Actions') ]) + ]), + E('tbody', {}, groupRows) + ]), + E('div', { 'class': 'cbi-value', 'style': 'padding-top:10px; border-top:1px solid #eee;' }, [ + E('label', { 'class': 'cbi-value-title' }, [ _('New group') ]), + E('div', { 'class': 'cbi-value-field' }, [ + newGroupInput, + btnAddGroup, + E('div', { 'class': 'cbi-value-description' }, [ _('Group names support Unicode text and common symbols') ]) + ]) + ]) + ]), + E('div', { 'class': 'button-row', 'style': 'margin-top:20px; text-align:right;' }, [ + E('button', { 'type': 'button', + 'class': 'btn cbi-button', + 'click': ui.hideModal + }, [ _('Close') ]) + ]) + ]); + }, + + promptRenameGroup: function(group) { + if (this.readonly) return; + const self = this; + const nameInput = E('input', { + 'type': 'text', + 'class': 'cbi-input-text', + 'value': group.name, + 'maxlength': 32, + 'style': 'width:100%;' + }); + + const errorDiv = E('div', { + 'class': 'alert-message danger', + 'style': 'display:none; margin-bottom:12px;' + }); + + ui.showModal(_('Rename group'), [ + E('div', { 'class': 'cbi-section' }, [ + errorDiv, + E('div', { 'class': 'cbi-value' }, [ + E('label', { 'class': 'cbi-value-title' }, [ _('Current group name') ]), + E('div', { 'class': 'cbi-value-field', 'style': 'padding-top:6px;' }, [ group.name ]) + ]), + E('div', { 'class': 'cbi-value' }, [ + E('label', { 'class': 'cbi-value-title' }, [ _('New group name') ]), + E('div', { 'class': 'cbi-value-field' }, [ + nameInput, + E('div', { 'class': 'cbi-value-description' }, [ _('Renaming preserves the group ID and all device assignments') ]) + ]) + ]) + ]), + E('div', { 'class': 'button-row', 'style': 'margin-top:20px; text-align:right;' }, [ + E('button', { 'type': 'button', 'class': 'btn cbi-button', 'click': function() { self.showGroupModal(); } }, [ _('Cancel') ]), + ' ', + E('button', { 'type': 'button', + 'class': 'btn cbi-button cbi-button-positive', + 'click': function(ev) { + const button = ev.currentTarget; + const newName = model.sanitizeInput(nameInput.value); + if (!newName) { + errorDiv.textContent = _('Enter a valid group name'); + errorDiv.style.display = 'block'; + nameInput.focus(); + return; + } + + if (newName === group.name) { + self.showGroupModal(); + return; + } + + button.classList.add('spinning'); + button.disabled = true; + + return self.handleRenameGroup(group.id, newName) + .then(function() { + ui.addNotification(null, E('p', [ _('Group renamed to "%s".').format(newName) ]), 'info'); + return self.refresh().then(function() { + self.showGroupModal(); + }); + }) + .catch(function(err) { + errorDiv.textContent = _('Rename failed: %s').format(err.message || err); + errorDiv.style.display = 'block'; + }) + .finally(function() { + button.classList.remove('spinning'); + button.disabled = false; + }); + } + }, [ _('Save changes') ]) + ]) + ]); + }, + + confirmDeleteGroup: function(group, deviceCount) { + if (this.readonly) return; + const self = this; + + ui.showModal(_('Delete group'), [ + E('p', {}, [ _('Delete group "%s"?').format(group.name) ]), + E('p', { 'class': 'cbi-value-description' }, [ _('The %d devices in this group will become ungrouped. Their names, MAC addresses and remarks will be preserved.').format(deviceCount) ]), + E('div', { 'class': 'button-row', 'style': 'margin-top:20px; text-align:right;' }, [ + E('button', { 'type': 'button', 'class': 'btn cbi-button', 'click': function() { self.showGroupModal(); } }, [ _('Cancel') ]), + ' ', + E('button', { 'type': 'button', + 'class': 'btn cbi-button cbi-button-remove', + 'click': function(ev) { + const button = ev.currentTarget; + button.classList.add('spinning'); + button.disabled = true; + + return self.handleDeleteGroup(group.id) + .then(function() { + ui.addNotification(null, E('p', [ _('Group "%s" deleted. Its devices are now ungrouped.').format(group.name) ]), 'info'); + return self.refresh().then(function() { + self.showGroupModal(); + }); + }) + .catch(function(err) { + ui.hideModal(); + ui.addNotification(null, E('p', [ _('Could not delete group: %s').format(err.message || err) ]), 'danger'); + }).finally(function() { + button.classList.remove('spinning'); + button.disabled = false; + }); + } + }, [ _('Confirm delete') ]) + ]) + ]); + } +}); diff --git a/luci-app-device-manager/htdocs/luci-static/resources/device-manager/model.js b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/model.js new file mode 100644 index 000000000..6e77f1407 --- /dev/null +++ b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/model.js @@ -0,0 +1,385 @@ +// SPDX-License-Identifier: MIT +'use strict'; +'require baseclass'; + +const ACTIVE_STATES = [ 'REACHABLE', 'DELAY', 'PROBE' ]; +const STATE_PRIORITY = { REACHABLE: 6, DELAY: 5, PROBE: 4, STALE: 3, PERMANENT: 2, NOARP: 2, INCOMPLETE: 1, FAILED: 0 }; + +// Localize untouched defaults; user-defined names remain literal configuration data. +const DEFAULT_GROUPS = { + smart_home: { stored: [ '智能家居', 'Smart home' ], label: () => _('Smart home') }, + phone: { stored: [ '手机设备', 'Phones' ], label: () => _('Phones') }, + computer: { stored: [ '电脑设备', 'Computers' ], label: () => _('Computers') }, + network: { stored: [ '网络设备', 'Network devices' ], label: () => _('Network devices') }, + other: { stored: [ '其他设备', 'Other devices' ], label: () => _('Other devices') } +}; + +const DEVICE_TYPES = { + computer: { id: 'computer', icon: 'computer.svg', label: () => _('Computer') }, + laptop: { id: 'laptop', icon: 'laptop.svg', label: () => _('Laptop') }, + phone: { id: 'phone', icon: 'phone.svg', label: () => _('Phone') }, + tablet: { id: 'tablet', icon: 'tablet.svg', label: () => _('Tablet') }, + tv: { id: 'tv', icon: 'tv.svg', label: () => _('TV') }, + tvbox: { id: 'tvbox', icon: 'tvbox.svg', label: () => _('TV box') }, + nas: { id: 'nas', icon: 'nas.svg', label: () => _('NAS') }, + printer: { id: 'printer', icon: 'printer.svg', label: () => _('Printer') }, + camera: { id: 'camera', icon: 'camera.svg', label: () => _('Camera') }, + speaker: { id: 'speaker', icon: 'speaker.svg', label: () => _('Speaker') }, + game: { id: 'game', icon: 'game.svg', label: () => _('Game console') }, + router: { id: 'router', icon: 'router.svg', label: () => _('Router') }, + switch: { id: 'switch', icon: 'switch.svg', label: () => _('Switch') }, + ap: { id: 'ap', icon: 'ap.svg', label: () => _('Access point') }, + server: { id: 'server', icon: 'server.svg', label: () => _('Server') }, + plug: { id: 'plug', icon: 'plug.svg', label: () => _('Smart plug') }, + light: { id: 'light', icon: 'light.svg', label: () => _('Smart light') }, + sensor: { id: 'sensor', icon: 'sensor.svg', label: () => _('Sensor') }, + home: { id: 'home', icon: 'home.svg', label: () => _('Smart home') }, + watch: { id: 'watch', icon: 'watch.svg', label: () => _('Watch') }, + car: { id: 'car', icon: 'car.svg', label: () => _('Vehicle') }, + vr: { id: 'vr', icon: 'vr.svg', label: () => _('VR headset') }, + network: { id: 'network', icon: 'network.svg', label: () => _('Network device') }, + airconditioner: { id: 'airconditioner', icon: 'airconditioner.svg', label: () => _('Air conditioner') }, + washer: { id: 'washer', icon: 'washer.svg', label: () => _('Washing machine') }, + fridge: { id: 'fridge', icon: 'fridge.svg', label: () => _('Refrigerator') }, + waterpurifier: { id: 'waterpurifier', icon: 'waterpurifier.svg', label: () => _('Water purifier') }, + airpurifier: { id: 'airpurifier', icon: 'airpurifier.svg', label: () => _('Air purifier') }, + unknown: { id: 'unknown', icon: 'unknown.svg', label: () => _('Unknown') } +}; + +// Small, documented vendor hints, not a fingerprint database. See docs/device-identification.md. +// Broad vendors such as Apple, Google and HP do not imply one device type. +const OUI_MAP = { + '001132': 'nas', '4CFCAA': 'car', '2C2617': 'vr', + '1012FB': 'camera', '0009BF': 'game', '000E58': 'speaker', + '001788': 'light', '18FE34': 'home', '54EF44': 'home', + '00156D': 'network', '000FE2': 'network', '000C42': 'router', + 'B827EB': 'computer' +}; + +function getTypeInfo(type) { + return DEVICE_TYPES[type] || DEVICE_TYPES.unknown; +} + +function getTypeLabel(type) { + const info = getTypeInfo(type); + return info.label(); +} + +function detectDeviceType(mac, hostname, customName, group) { + const normalized = normalizeMac(mac); + if (!normalized) return 'unknown'; + const text = (String(customName || '') + ' ' + String(hostname || '')).toLowerCase(); + // Specific device names precede broad vendor hints. Short tokens require + // boundaries: "Cambridge-PC" is not "cam"; a network switch is not a console. + const clues = [ + [ 'car', /(^|[^a-z0-9])(tesla|model[-_ ]*[3ysx]|byd|nio|xpeng|li-auto|polestar|rivian|zeekr)(?=$|[^a-z0-9])/ ], + [ 'vr', /(^|[^a-z0-9])(quest|oculus|pico|vive|vision[-_ ]?pro|vr)(?=$|[^a-z0-9])/ ], + [ 'nas', /(^|[^a-z0-9])(synology|diskstation|qnap|truenas|freenas|unraid|asustor|terramaster|openmediavault|omv|nas)(?=$|[^a-z0-9])/ ], + [ 'printer', /(^|[^a-z0-9])(printer|laserjet|deskjet|epson|xerox|print)(?=$|[^a-z0-9])/ ], + [ 'camera', /(^|[^a-z0-9])(camera|cctv|ipc|cam|webcam|hikvision|dahua|reolink|uniview|ezviz|wyze|imou)(?=$|[^a-z0-9])/ ], + [ 'game', /(^|[^a-z0-9])(playstation|ps[345]|xbox|nintendo|steamdeck|gamepad)(?=$|[^a-z0-9])/ ], + [ 'speaker', /(^|[^a-z0-9])(homepod|echo|alexa|sonos|soundbar|speaker|xiaoai)(?=$|[^a-z0-9])/ ], + [ 'tvbox', /(^|[^a-z0-9])(appletv|apple-tv|chromecast|mibox|mi-box|firetv|fire-tv|firestick|roku|shield-tv|tvbox|tv-box|stb)(?=$|[^a-z0-9])/ ], + [ 'tv', /(^|[^a-z0-9])(smarttv|smart-tv|mitv|mi-tv|bravia|tizen|webos|tv)(?=$|[^a-z0-9])/ ], + [ 'watch', /(^|[^a-z0-9])(applewatch|apple-watch|iwatch|galaxy-watch|smartwatch|fitbit|garmin|watch|band)(?=$|[^a-z0-9])/ ], + [ 'tablet', /(^|[^a-z0-9])(ipad|tablet|tab|mediapad|pad)(?=$|[^a-z0-9])/ ], + [ 'laptop', /(^|[^a-z0-9])(macbook|laptop|thinkpad|notebook|zenbook|ideapad|matebook|surface-laptop|latitude|inspiron|xps)(?=$|[^a-z0-9])/ ], + [ 'server', /(^|[^a-z0-9])(server|pve|proxmox|esxi|vmware|docker|kubernetes|k8s)(?=$|[^a-z0-9])/ ], + [ 'computer', /(^|[^a-z0-9])(desktop|imac|macmini|macstudio|macpro|pc|tower|workstation|optiplex|windows)(?=$|[^a-z0-9])/ ], + [ 'airconditioner', /(^|[^a-z0-9])(airconditioner|air-conditioner|aircon|air_cond|hvac|ac-unit|ac_unit)(?=$|[^a-z0-9])|空调/ ], + [ 'washer', /(^|[^a-z0-9])(washer|washing-machine|washingmachine|dryer|laundry)(?=$|[^a-z0-9])|洗衣机|烘干机/ ], + [ 'fridge', /(^|[^a-z0-9])(fridge|refrigerator|freezer)(?=$|[^a-z0-9])|冰箱|冷柜/ ], + [ 'waterpurifier', /(^|[^a-z0-9])(waterpurifier|water-purifier)(?=$|[^a-z0-9])|净水器|直饮机|净水机/ ], + [ 'airpurifier', /(^|[^a-z0-9])(airpurifier|air-purifier)(?=$|[^a-z0-9])|空净|空气净化/ ], + [ 'phone', /(^|[^a-z0-9])(iphone|galaxy|redmi|xiaomi|huawei|honor|pixel|oneplus|oppo|vivo|xperia|realme|meizu|phone|mobile)(?=$|[^a-z0-9])/ ], + [ 'plug', /(^|[^a-z0-9])(smartplug|smart-plug|socket|outlet|plug)(?=$|[^a-z0-9])/ ], + [ 'light', /(^|[^a-z0-9])(smartlight|smart-light|light|bulb|lamp|yeelight|hue|strip)(?=$|[^a-z0-9])/ ], + [ 'sensor', /(^|[^a-z0-9])(sensor|temp|humidity|motion|door|window|detector)(?=$|[^a-z0-9])/ ], + [ 'home', /(^|[^a-z0-9])(homeassistant|hass|homebridge|aqara|hub|smart-home|smarthome)(?=$|[^a-z0-9])/ ], + [ 'ap', /(^|[^a-z0-9])(accesspoint|unifi|uap|eap|ap)(?=$|[^a-z0-9])/ ], + [ 'switch', /(^|[^a-z0-9])(switch|usw|tl-sg)(?=$|[^a-z0-9])|交换机/ ], + [ 'router', /(^|[^a-z0-9])(router|openwrt|gateway|ax[0-9]{4}|ac[0-9]{4}|archer|asuswrt)(?=$|[^a-z0-9])/ ] + ]; + for (const [ type, pattern ] of clues) if (pattern.test(text)) return type; + // Locally administered/randomized MACs have no reliable vendor prefix. + if (!(parseInt(normalized.slice(0, 2), 16) & 2)) { + const oui = normalized.replace(/:/g, '').slice(0, 6); + if (OUI_MAP[oui]) return OUI_MAP[oui]; + } + const groupTypes = { smart_home: 'home', phone: 'phone', computer: 'computer', network: 'network' }; + return groupTypes[group] || 'unknown'; +} + +function normalizeMac(mac) { + if (typeof mac !== 'string') return null; + const value = mac.trim().toUpperCase().replace(/[:-]/g, ''); + if (!/^[0-9A-F]{12}$/.test(value) || /^(0{12}|F{12})$/.test(value)) return null; + return value.match(/.{2}/g).join(':'); +} + +function sanitizeInput(value) { + return value == null ? '' : String(value).replace(/[\x00-\x1f\x7f]/g, ' ').trim(); +} + +function sectionMac(section) { + return normalizeMac(section.mac) || normalizeMac((section['.name'] || '').replace(/^dev_/, '')); +} + +function groupName(groups, id) { + const group = groups.find(g => g.id === id); + return group ? group.name : _('Ungrouped'); +} + +function matchesDevice(device, groups, group, query) { + if (group !== 'all' && device.group !== group) return false; + if (!query) return true; + const typeInfo = getTypeInfo(device.type); + return [ device.customName, device.hostname, device.ipv4, device.ipv6, device.mac, + device.remark, groupName(groups, device.group), + typeInfo.label(), device.type ].some(value => String(value || '').toLowerCase().includes(query)); +} + +function parseDevices(data, groups) { + const devices = new Map(); + const addresses = new Map(); + const addressInterfaces = new Map(); + const neighborStates = new Map(); + const wifi = new Set(); + const arp = new Set(); + const getEntry = mac => { + const normalized = normalizeMac(mac); + if (!normalized) return null; + if (!devices.has(normalized)) devices.set(normalized, { + mac: normalized, hostname: '', ipv4: '', ipv6: '', customName: '', remark: '', + group: 'ungrouped', sid: null, isSaved: false, isDiscovered: false, + status: 'unknown', statusDetail: '', type: 'unknown', + ipv4Addresses: [], ipv6Addresses: [], interfaces: [], discoverySources: [], neighbors: [] + }); + return devices.get(normalized); + }; + const addAddress = (device, ip, preferred, iface) => { + if (!device || typeof ip !== 'string' || !ip) return; + // DHCPv6 may include a prefix length. Keep address matching consistent. + const address = ip.split('/')[0].toLowerCase(); + if (!addresses.has(address)) addresses.set(address, new Set()); + addresses.get(address).add(device.mac); + if (iface) { + if (!addressInterfaces.has(address)) addressInterfaces.set(address, new Set()); + addressInterfaces.get(address).add(iface); + } + const field = address.includes(':') ? 'ipv6' : 'ipv4'; + if (!device[field + 'Addresses'].includes(address)) device[field + 'Addresses'].push(address); + if (iface && !device.interfaces.includes(iface)) device.interfaces.push(iface); + if (!device[field] || preferred) device[field] = address; + }; + const addSource = (device, source) => { + if (!device.discoverySources.includes(source)) device.discoverySources.push(source); + }; + const toArray = value => Array.isArray(value) ? value : value == null ? [] : [ value ]; + const leases = data.leases || {}; + for (const lease of [ ...toArray(leases.dhcp_leases), ...toArray(leases.dhcp6_leases) ]) { + const device = getEntry(lease.macaddr); + if (!device) continue; + device.isDiscovered = true; + addSource(device, 'DHCP'); + device.hostname = sanitizeInput(lease.hostname || device.hostname); + for (const ip of [ ...toArray(lease.ipaddr), ...toArray(lease.ip6addrs || lease.ip6addr) ]) addAddress(device, ip); + } + for (const [ mac, hint ] of Object.entries(data.hints || {})) { + const device = getEntry(mac); + if (!device || !hint || typeof hint !== 'object') continue; + device.isDiscovered = true; + addSource(device, 'Host hints'); + if (!device.hostname) device.hostname = sanitizeInput(hint.name); + for (const ip of [ ...toArray(hint.ipaddrs || hint.ipv4), ...toArray(hint.ip6addrs || hint.ipv6) ]) addAddress(device, ip); + } + for (const section of data.devices || []) { + const device = getEntry(sectionMac(section)); + if (!device) continue; + device.isSaved = true; + device.sid = section['.name']; + device.customName = sanitizeInput(section.name); + device.remark = sanitizeInput(section.remark); + device.group = groups.some(g => g.id === section.group) ? section.group : 'ungrouped'; + if (section.type) device.customType = sanitizeInput(section.type); + } + for (const station of data.wifi || []) { + const device = getEntry(typeof station === 'string' ? station : station.mac); + if (!device) continue; + device.isDiscovered = true; + addSource(device, 'Wi-Fi'); + if (station.dev && !device.interfaces.includes(station.dev)) device.interfaces.push(station.dev); + wifi.add(device.mac); + } + for (const entry of data.arp || []) { + const device = getEntry(entry.mac); + if (!device) continue; + device.isDiscovered = true; + addSource(device, 'ARP'); + addAddress(device, entry.ip, false, entry.dev); + if (Number(entry.flags) === 2) arp.add(device.mac); + } + const neighbors = (data.neighbors || {}).neighbors || []; + // Index all resolved neighbors first, so FAILED association is order independent. + for (const neighbor of neighbors) { + const device = getEntry(neighbor.mac); + if (!device) continue; + device.isDiscovered = true; + addAddress(device, neighbor.ip, ACTIVE_STATES.includes(neighbor.state), neighbor.dev); + } + for (const neighbor of neighbors) { + let mac = normalizeMac(neighbor.mac); + if (!mac && typeof neighbor.ip === 'string') { + const address = neighbor.ip.split('/')[0].toLowerCase(); + const interfaces = addressInterfaces.get(address); + if (neighbor.dev && interfaces && !interfaces.has(neighbor.dev)) continue; + const owners = addresses.get(address); + // Never attach a failure to an ambiguous or reassigned address. + if (owners && owners.size === 1) mac = owners.values().next().value; + } + if (!mac || !devices.has(mac)) continue; + const device = devices.get(mac); + addSource(device, 'Neighbor table'); + device.neighbors.push({ ip: neighbor.ip || '', dev: neighbor.dev || '', state: neighbor.state || '' }); + if (neighbor.dev && !device.interfaces.includes(neighbor.dev)) device.interfaces.push(neighbor.dev); + const state = String(neighbor.state || '').toUpperCase(); + const previous = neighborStates.get(mac); + if (previous == null || (STATE_PRIORITY[state] ?? -1) > (STATE_PRIORITY[previous] ?? -1)) neighborStates.set(mac, state); + } + const complete = data.discoveryComplete === true; + for (const device of devices.values()) { + device.type = (device.customType && device.customType !== 'auto') + ? device.customType + : detectDeviceType(device.mac, device.hostname, device.customName, device.group); + const state = neighborStates.get(device.mac); + if (wifi.has(device.mac)) { + device.status = 'online'; + device.statusDetail = _('Active Wi-Fi association'); + } else if (ACTIVE_STATES.includes(state)) { + device.status = 'online'; + device.statusDetail = _('Active network neighbor (%s)').format(state); + } else if (state === 'FAILED') { + device.status = 'offline'; + device.statusDetail = _('Network neighbor probe failed (FAILED)'); + } else if (!complete) { + device.statusDetail = _('Some discovery sources are unavailable'); + } else if (!device.isDiscovered && !state && device.isSaved) { + device.status = 'offline'; + device.statusDetail = _('Saved device not found in the current network'); + } else if (state) { + device.statusDetail = _('No recent activity confirmed (%s)').format(state); + } else if (arp.has(device.mac)) { + device.statusDetail = _('ARP record exists without recent activity evidence'); + } else { + device.statusDetail = _('Known device without recent activity evidence'); + } + } + const statusOrder = { online: 0, unknown: 1, offline: 2 }; + return Array.from(devices.values()).sort((a, b) => { + if (a.status !== b.status) return statusOrder[a.status] - statusOrder[b.status]; + if (!!a.ipv4 !== !!b.ipv4) return a.ipv4 ? -1 : 1; + if (a.ipv4 && b.ipv4) { + const left = a.ipv4.split('.').map(Number), right = b.ipv4.split('.').map(Number); + for (let i = 0; i < 4; i++) if (left[i] !== right[i]) return left[i] - right[i]; + } + return a.mac.localeCompare(b.mac); + }); +} + +function parseIpv4(ip) { + if (!ip || typeof ip !== 'string') return null; + const parts = ip.split('.').map(Number); + if (parts.length !== 4 || parts.some(n => isNaN(n) || n < 0 || n > 255)) return null; + return parts; +} + +function sortDevices(devices, sortKey, sortDir, getGroupName) { + if (!sortKey || !Array.isArray(devices)) return devices || []; + const dir = (sortDir === 'desc') ? -1 : 1; + return devices.slice().sort((a, b) => { + if (sortKey === 'ip') { + const pa = parseIpv4(a.ipv4); + const pb = parseIpv4(b.ipv4); + // Devices with valid IPv4 always appear before devices without IPv4 + if (Boolean(pa) !== Boolean(pb)) { + return pa ? -1 : 1; + } + if (pa && pb) { + for (let i = 0; i < 4; i++) { + if (pa[i] !== pb[i]) return (pa[i] - pb[i]) * dir; + } + } + // Both lack IPv4 (or identical IPv4): check IPv6 + const v6a = a.ipv6 || ''; + const v6b = b.ipv6 || ''; + if (Boolean(v6a) !== Boolean(v6b)) { + return v6a ? -1 : 1; + } + if (v6a && v6b) { + const cmpV6 = v6a.localeCompare(v6b); + if (cmpV6 !== 0) return cmpV6 * dir; + } + // Tie-breaker: MAC + return (a.mac || '').localeCompare(b.mac || '') * dir; + } + + if (sortKey === 'name') { + const nameA = a.customName || a.hostname || ''; + const nameB = b.customName || b.hostname || ''; + if (Boolean(nameA) !== Boolean(nameB)) return nameA ? -1 : 1; + if (nameA && nameB) { + const cmp = nameA.localeCompare(nameB); + if (cmp !== 0) return cmp * dir; + } + return (a.mac || '').localeCompare(b.mac || '') * dir; + } + + if (sortKey === 'mac') { + return (a.mac || '').localeCompare(b.mac || '') * dir; + } + + if (sortKey === 'group') { + const gA = (typeof getGroupName === 'function' ? getGroupName(a.group) : a.group) || ''; + const gB = (typeof getGroupName === 'function' ? getGroupName(b.group) : b.group) || ''; + if (Boolean(gA) !== Boolean(gB)) return gA ? -1 : 1; + if (gA && gB) { + const cmp = gA.localeCompare(gB); + if (cmp !== 0) return cmp * dir; + } + return (a.mac || '').localeCompare(b.mac || '') * dir; + } + + if (sortKey === 'type') { + const tA = a.type || ''; + const tB = b.type || ''; + if (tA !== tB) return tA.localeCompare(tB) * dir; + return (a.mac || '').localeCompare(b.mac || '') * dir; + } + + return 0; + }); +} + +return baseclass.extend({ + DEVICE_TYPES: DEVICE_TYPES, + detectDeviceType: detectDeviceType, + getTypeInfo: getTypeInfo, + getTypeLabel: getTypeLabel, + normalizeMac: normalizeMac, + sanitizeInput: sanitizeInput, + sectionMac: sectionMac, + getSectionId: mac => 'dev_' + normalizeMac(mac).replace(/:/g, '').toLowerCase(), + groupName: groupName, + matchesDevice: matchesDevice, + parseDevices: parseDevices, + sortDevices: sortDevices, + parseGroups: sections => (sections || []).map(section => { + const id = section['.name'], name = sanitizeInput(section.name) || id; + const defaults = DEFAULT_GROUPS[id]; + return { id: id, name: defaults && defaults.stored.includes(name) ? defaults.label() : name }; + }), + parseArp: content => String(content || '').trim().split('\n').slice(1).map(line => { + const fields = line.trim().split(/\s+/); + return { ip: fields[0], flags: fields[2], mac: fields[3], dev: fields[5] }; + }).filter(entry => entry.dev && normalizeMac(entry.mac)) +}); diff --git a/luci-app-device-manager/htdocs/luci-static/resources/device-manager/preferences.js b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/preferences.js new file mode 100644 index 000000000..7a05bf109 --- /dev/null +++ b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/preferences.js @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: MIT +'use strict'; +'require baseclass'; + +const STORAGE_KEY = 'luci-device-manager-view'; +const DEFAULT_STATE = { tab: 'online', group: 'all' }; + +// Storage access and JSON parsing can throw synchronously; isolate them in promises. +return baseclass.extend({ + load: function() { + return Promise.resolve().then(() => window.localStorage.getItem(STORAGE_KEY)).then(raw => { + const state = raw ? JSON.parse(raw) : null; + if (!state || typeof state !== 'object') return Object.assign({}, DEFAULT_STATE); + const result = { + tab: typeof state.tab === 'string' ? state.tab : 'online', + group: typeof state.group === 'string' ? state.group : 'all' + }; + if (typeof state.maskInfo === 'boolean') result.maskInfo = state.maskInfo; + return result; + }).catch(() => Object.assign({}, DEFAULT_STATE)); + }, + save: function(tab, group, maskInfo) { + const payload = { tab: tab || 'online', group: group || 'all' }; + if (typeof maskInfo === 'boolean') payload.maskInfo = maskInfo; + return Promise.resolve().then(() => window.localStorage.setItem(STORAGE_KEY, + JSON.stringify(payload))).catch(() => {}); + } +}); diff --git a/luci-app-device-manager/htdocs/luci-static/resources/device-manager/service.js b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/service.js new file mode 100644 index 000000000..81f29fc56 --- /dev/null +++ b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/service.js @@ -0,0 +1,216 @@ +// SPDX-License-Identifier: MIT +'use strict'; +'require baseclass'; +'require rpc'; +'require uci'; +'require fs'; +'require device-manager.model as model'; + +const CONFIG = 'device_manager'; +const callHints = rpc.declare({ object: 'luci-rpc', method: 'getHostHints', reject: true }); +const callLeases = rpc.declare({ object: 'luci-rpc', method: 'getDHCPLeases', reject: true }); +const callNeighbors = rpc.declare({ object: 'luci.device-manager', method: 'get_online_status', reject: true }); +const callScan = rpc.declare({ object: 'luci.device-manager', method: 'scan_devices', reject: true }); +const callPing = rpc.declare({ object: 'luci.device-manager', method: 'ping_device', params: [ 'mac' ], reject: true }); +const callWireless = rpc.declare({ object: 'luci-rpc', method: 'getWirelessDevices', reject: true }); +const callAssoc = rpc.declare({ object: 'iwinfo', method: 'assoclist', params: [ 'device' ], reject: true }); +const callCommit = rpc.declare({ object: 'uci', method: 'commit', params: [ 'config' ], reject: true }); +const callRevert = rpc.declare({ object: 'uci', method: 'revert', params: [ 'config' ], reject: true }); + +function requireObject(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(_('Invalid discovery response')); + return value; +} + +function readSource(name, read) { + return Promise.resolve().then(read).then(value => ({ name: name, ok: true, value: value }), + error => ({ name: name, ok: false, error: error.message || String(error) })); +} + +function wifiStations() { + return callWireless().then(requireObject).then(radios => { + const interfaces = new Set(); + for (const radio of Object.values(radios)) { + for (const iface of radio.interfaces || []) { + const mode = (iface.config || {}).mode; + if (mode && mode !== 'ap' && mode !== 'ap-wds') continue; + if (iface.ifname) interfaces.add(iface.ifname); + for (const vlan of iface.vlans || []) if (vlan.ifname) interfaces.add(vlan.ifname); + } + } + return Promise.all(Array.from(interfaces).map(device => readSource(device, () => callAssoc(device).then(reply => { + if (!Array.isArray(requireObject(reply).results)) throw new Error(_('Invalid discovery response')); + return reply.results.map(station => ({ mac: station.mac, dev: device })); + })))).then(results => ({ + stations: results.filter(result => result.ok).flatMap(result => result.value), + complete: results.every(result => result.ok), + errors: results.filter(result => !result.ok).map(result => result.name + ': ' + result.error) + })); + }); +} + +function reloadConfig() { + uci.unload(CONFIG); + return uci.load(CONFIG); +} + +function validateText(value, limit) { + const text = model.sanitizeInput(value); + if (text.length > limit) throw new Error(_('Input is too long')); + return text; +} + +function validateGroupName(name, exceptId) { + const text = validateText(name, 32); + if (!text) throw new Error(_('Enter a valid group name')); + if (uci.sections(CONFIG, 'group').some(group => group['.name'] !== exceptId && model.sanitizeInput(group.name) === text)) + throw new Error(_('A group with this name already exists')); + return text; +} + +function requireGroup(id) { + if (!uci.sections(CONFIG, 'group').some(group => group['.name'] === id)) throw new Error(_('This group no longer exists')); +} + +return baseclass.extend({ + scanDevices: function() { + if (L.hasViewPermission() !== true) return Promise.reject(new Error(_('Read-only access'))); + return this.queue(() => callScan().then(reply => { + if (requireObject(reply).ok !== true) + throw new Error(_('LAN scanning is unavailable. Check the selected interfaces and probe dependencies.')); + })); + }, + pingDevice: function(mac) { + const normalized = model.normalizeMac(mac); + if (!normalized) return Promise.reject(new Error(_('Enter a valid MAC address'))); + return callPing(normalized).then(reply => { + const errors = { + 'No known IP address for this device': _('No known IP address for this device'), + 'Another device is being probed. Please retry.': _('Another device is being probed. Please retry.'), + 'Ping is unavailable': _('Ping is unavailable'), + 'Enter a valid MAC address': _('Enter a valid MAC address'), + 'Unable to read the kernel neighbor table': _('Unable to read the kernel neighbor table') + }; + if (requireObject(reply).ok !== true) { + const error = new Error(errors[reply.error] || _('Ping could not be completed')); + error.output = typeof reply.output === 'string' ? reply.output : ''; + throw error; + } + if (typeof reply.reachable !== 'boolean' || typeof reply.ip !== 'string' || typeof reply.output !== 'string') + throw new Error(_('Invalid discovery response')); + return reply; + }); + }, + // Reads and writes share the queue so a refresh cannot discard an in-flight edit. + queue: function(task) { + const result = (this.pending || Promise.resolve()).then(task); + this.pending = result.catch(() => {}); + return result; + }, + load: function() { + return this.queue(() => this.recoverPending().then(() => { + const config = reloadConfig().then(() => ({ + groups: uci.sections(CONFIG, 'group'), devices: uci.sections(CONFIG, 'device') + })); + const sources = Promise.all([ + readSource('hints', () => callHints().then(requireObject)), + readSource('leases', () => callLeases().then(requireObject)), + readSource('neighbors', () => callNeighbors().then(reply => { + if (requireObject(reply).ok !== true || !Array.isArray(reply.neighbors)) throw new Error(reply.error || _('Invalid discovery response')); + return reply; + })), + readSource('wifi', wifiStations), + readSource('arp', () => fs.read('/proc/net/arp').then(model.parseArp)) + ]); + return Promise.all([ config, sources ]).then(results => { + const data = Object.assign({ errors: [], discoveryComplete: true }, results[0]); + for (const result of results[1]) { + if (!result.ok) { + data.errors.push(result.name + ': ' + result.error); + data.discoveryComplete = false; + continue; + } + if (result.name === 'wifi') { + data.wifi = result.value.stations; + data.discoveryComplete = data.discoveryComplete && result.value.complete; + data.errors.push(...result.value.errors); + } else data[result.name] = result.value; + } + return data; + }); + })); + }, + recoverPending: function() { + if (!this.rollbackPending) return Promise.resolve(); + return callRevert(CONFIG).then(() => { this.rollbackPending = false; }); + }, + mutate: function(change) { + return this.queue(() => { + if (L.hasViewPermission() !== true) throw new Error(_('Read-only access')); + let staged = false; + return this.recoverPending().then(reloadConfig).then(change).then(() => { + staged = true; + return uci.save(); + }).then(() => callCommit(CONFIG)).catch(error => { + if (!staged) throw error; + this.rollbackPending = true; + return this.recoverPending().then(() => { throw error; }, rollbackError => { + throw new Error(_('%s; failed to discard pending changes: %s').format(error.message, rollbackError.message)); + }); + }).finally(() => uci.unload(CONFIG)); + }); + }, + saveDevice: function(mac, name, remark, group, type) { + return this.mutate(() => { + const normalized = model.normalizeMac(mac); + if (!normalized) throw new Error(_('Enter a valid MAC address')); + const deviceName = validateText(name, 64), deviceRemark = validateText(remark, 256); + if (group && group !== 'ungrouped') requireGroup(group); + const matches = uci.sections(CONFIG, 'device').filter(section => model.sectionMac(section) === normalized); + const sid = matches.length ? matches[matches.length - 1]['.name'] : model.getSectionId(normalized); + if (!matches.length) { + if (uci.get(CONFIG, sid)) throw new Error(_('A conflicting configuration record exists')); + uci.add(CONFIG, 'device', sid); + } + for (const section of matches) if (section['.name'] !== sid) uci.remove(CONFIG, section['.name']); + uci.set(CONFIG, sid, 'mac', normalized); + for (const [ key, value ] of Object.entries({ name: deviceName, remark: deviceRemark, + group: group && group !== 'ungrouped' ? group : '', + type: type && type !== 'auto' ? type : '' })) { + if (value) uci.set(CONFIG, sid, key, value); + else uci.unset(CONFIG, sid, key); + } + }); + }, + deleteDevice: function(device) { + return this.mutate(() => { + for (const section of uci.sections(CONFIG, 'device')) { + if (model.sectionMac(section) === device.mac) uci.remove(CONFIG, section['.name']); + } + }); + }, + addGroup: function(name) { + return this.mutate(() => { + const text = validateGroupName(name); + let id; + do { id = 'grp_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 8); } while (uci.get(CONFIG, id)); + uci.add(CONFIG, 'group', id); + uci.set(CONFIG, id, 'name', text); + }); + }, + renameGroup: function(id, name) { + return this.mutate(() => { + requireGroup(id); + uci.set(CONFIG, id, 'name', validateGroupName(name, id)); + }); + }, + deleteGroup: function(id) { + return this.mutate(() => { + requireGroup(id); + uci.remove(CONFIG, id); + for (const section of uci.sections(CONFIG, 'device')) { + if (section.group === id) uci.unset(CONFIG, section['.name'], 'group'); + } + }); + } +}); diff --git a/luci-app-device-manager/htdocs/luci-static/resources/device-manager/styles.css b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/styles.css new file mode 100644 index 000000000..ffc368c5d --- /dev/null +++ b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/styles.css @@ -0,0 +1,191 @@ +/* SPDX-License-Identifier: MIT */ +.dm-status-tabs { display:flex; flex-wrap:wrap; margin:0 0 16px 0; padding:0; border-bottom:2px solid #e0e0e0; list-style:none; gap:6px; } +.dm-status-tab { border:0; font-family:inherit; cursor:pointer; padding:8px 16px; border-radius:6px 6px 0 0; font-weight:500; font-size:14px; color:inherit; background:rgba(0,0,0,0.03); transition:all 0.15s ease; border-bottom:3px solid transparent; } +.dm-status-tab:hover { background:rgba(0,0,0,0.06); color:inherit; } +.dm-status-tab.active { background:transparent; color:#1a73e8; font-weight:bold; border-bottom:3px solid #1a73e8; } +.dm-tab-count { font-size:12px; margin-left:4px; padding:2px 7px; border-radius:10px; background:rgba(0,0,0,0.06); color:inherit; font-weight:normal; } +.dm-status-tab.active .dm-tab-count { background:#e8f0fe; color:#1a73e8; font-weight:bold; } +.dm-toolbar { display:flex; flex-wrap:wrap; gap:10px; justify-content:space-between; align-items:center; margin-bottom:15px; } +.dm-controls { display:flex; flex-wrap:wrap; gap:8px; align-items:center; flex:1; min-width:260px; } +.dm-btn-group { display:flex; flex-wrap:wrap; gap:8px; align-items:center; } +.dm-table-wrapper { overflow-x:auto; width:100%; -webkit-overflow-scrolling:touch; } +.dm-device-title-row { display:inline-flex; align-items:center; max-width:100%; vertical-align:middle; } +.dm-device-title { font-weight:bold; font-size:14px; display:inline-block; } +.dm-device-subtitle { font-size:12px; color:#777; margin-top:2px; } +.dm-status-dot { display:inline-block; width:6px; height:6px; border-radius:50%; margin-right:5px; flex-shrink:0; vertical-align:middle; transition:transform 0.15s ease, box-shadow 0.15s ease; cursor:help; } +.dm-status-dot:hover { transform:scale(1.3); } +.dm-status-dot-online { background-color:#34a853; box-shadow:0 0 0 1.5px rgba(52,168,83,0.25); } +.dm-status-dot-offline { background-color:#ea4335; box-shadow:0 0 0 1.5px rgba(234,67,53,0.2); } +.dm-status-dot-unknown { background-color:#fbbc04; box-shadow:0 0 0 1.5px rgba(251,188,4,0.25); } +[data-theme="dark"] .dm-status-dot-online, +body.dark-mode .dm-status-dot-online { background-color:#81c995; box-shadow:0 0 0 1.5px rgba(129,201,149,0.3); } +[data-theme="dark"] .dm-status-dot-offline, +body.dark-mode .dm-status-dot-offline { background-color:#f28b82; box-shadow:0 0 0 1.5px rgba(242,139,130,0.25); } +[data-theme="dark"] .dm-status-dot-unknown, +body.dark-mode .dm-status-dot-unknown { background-color:#fdd663; box-shadow:0 0 0 1.5px rgba(253,214,99,0.3); } +.dm-group-badge { display:inline-block; font-size:12px; padding:3px 8px; border-radius:4px; background:#f1f3f4; color:#3c4043; border:1px solid #dadce0; font-weight:500; } +.dm-group-ungrouped { color:#888; font-style:italic; } +.dm-mac-code { font-family:monospace; font-size:13px; font-weight:600; color:#1a73e8; } +.dm-actions { white-space:nowrap; } +.dm-table .dm-actions-column { width:64px; min-width:64px; text-align:center; } +.dm-table tbody .td { vertical-align:middle; } +.dm-action-links { display:flex; flex-direction:column; align-items:center; gap:4px; } +.dm-table .dm-action-link { display:inline-block; padding:0 2px; border:0; background:none; box-shadow:none; color:var(--dm-sort-color); font-size:12px; line-height:20px; text-decoration:none; cursor:pointer; } +.dm-table .dm-action-link:hover { text-decoration:underline; } +.dm-table .dm-action-link:focus-visible { outline:2px solid currentColor; outline-offset:2px; border-radius:2px; } + +.dm-status-tab:focus-visible { outline:2px solid #1a73e8; outline-offset:2px; } +.dm-source-warning { overflow-wrap:anywhere; } + +.dm-type-th { width: 56px; text-align: center; } +.dm-type-cell { text-align: center; vertical-align: middle; padding: 6px 8px; } +.dm-device-icon-wrap { display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 6px; background: rgba(0, 0, 0, 0.04); vertical-align: middle; transition: all 0.15s ease; } +.dm-device-icon-wrap:hover { background: rgba(26, 115, 232, 0.12); transform: scale(1.06); } +.dm-device-icon { width: 20px; height: 20px; display: block; } +[data-theme="dark"] .dm-device-icon-wrap, +body.dark-mode .dm-device-icon-wrap { background: rgba(255, 255, 255, 0.08); } + +.dm-table { min-width:900px; --dm-sort-color:#0069d6; --dm-header-hover:rgba(0,105,214,0.06); } +.dm-table thead .tr.table-titles > .th { + height:44px; + /* Override theme padding so all header contents share the same height. */ + padding:0; + background:rgba(148,163,184,0.08); + color:var(--text-color-high, #64748b); + font-size:12px; + font-weight:500; + line-height:20px; + text-align:left; + white-space:nowrap; + vertical-align:middle; +} +.dm-table .dm-index-column { + width:40px; + min-width:40px; + max-width:40px; + box-sizing:border-box; + padding:8px 6px; + text-align:center; + font-size:12px; + font-variant-numeric:tabular-nums; + color:var(--text-color-medium, #94a3b8); +} +.dm-table .dm-header-content { + display:flex; + align-items:center; + justify-content:flex-start; + gap:6px; + width:100%; + height:44px; + box-sizing:border-box; + margin:0; + padding:0 12px; + color:inherit; + font:inherit; + line-height:20px; + white-space:nowrap; +} +.dm-table .dm-sort-button { + border:0; + border-radius:0; + background:transparent; + box-shadow:none; + text-align:left; + cursor:pointer; + transition:background-color 0.15s ease, color 0.15s ease; +} +.dm-table .dm-type-th .dm-header-content, +.dm-table .dm-index-column .dm-header-content, +.dm-table .dm-actions-column .dm-header-content { justify-content:center; padding:0 6px; } +.dm-table .dm-sort-button:hover { background:var(--dm-header-hover); color:var(--dm-sort-color); } +.dm-sort-button:focus-visible { outline:2px solid var(--dm-sort-color); outline-offset:-2px; } +.dm-sort-icon { position:relative; display:block; flex:0 0 8px; width:8px; height:14px; color:inherit; } +.dm-sort-icon::before, +.dm-sort-icon::after { + content:''; + position:absolute; + left:1px; + width:4px; + height:4px; + transform:rotate(45deg); + opacity:0.25; + transition:opacity 0.15s ease; +} +.dm-sort-icon::before { top:1px; border-top:1.5px solid currentColor; border-left:1.5px solid currentColor; } +.dm-sort-icon::after { bottom:1px; border-bottom:1.5px solid currentColor; border-right:1.5px solid currentColor; } +.dm-sort-button:hover .dm-sort-icon::before, +.dm-sort-button:hover .dm-sort-icon::after { opacity:0.5; } +.dm-sortable.sorted-asc .dm-sort-button, +.dm-sortable.sorted-desc .dm-sort-button { color:var(--dm-sort-color); background:var(--dm-header-hover); } +.dm-sortable.sorted-asc .dm-sort-icon::before, +.dm-sortable.sorted-desc .dm-sort-icon::after { opacity:1; } +[data-theme="dark"] .dm-table, +[data-darkmode="true"] .dm-table, +body.dark-mode .dm-table { --dm-sort-color:#8ab4f8; --dm-header-hover:rgba(138,180,248,0.08); } + +.dm-btn-group .cbi-button.dm-btn-blue { + /* Reset the theme's background image as well as its background color. */ + background: #0069d6 !important; + border-color: #0069d6 !important; + color: #ffffff !important; + text-shadow: none; +} +.dm-btn-group .cbi-button.dm-btn-blue:hover, +.dm-btn-group .cbi-button.dm-btn-blue:focus-visible { + background: #0049d6 !important; + border-color: #0049d6 !important; + color: #ffffff !important; +} + +.modal.dm-device-modal { position:relative; width:680px; min-width:0; max-width:calc(100vw - 32px); max-height:calc(100vh - 48px); margin:24px auto; overflow-y:auto; padding:24px; border-radius:12px; box-sizing:border-box; } +.dm-device-modal > h4 { margin-bottom:18px; padding-right:32px; font-size:17px; } +.dm-dialog .dm-dialog-close { position:absolute; top:18px; right:18px; display:flex; align-items:center; justify-content:center; width:28px; height:28px; padding:0; border:0; border-radius:6px; background:none; box-shadow:none; color:var(--dm-dialog-muted); font-size:22px; line-height:1; cursor:pointer; } +.dm-dialog .dm-dialog-close:hover { background:var(--dm-dialog-panel); color:inherit; } +.dm-dialog .dm-dialog-close:focus-visible { outline:2px solid #0069d6; outline-offset:2px; } +.dm-dialog { --dm-dialog-border:var(--border-color-low, #e5e7eb); --dm-dialog-muted:var(--text-color-medium, #7b8492); --dm-dialog-panel:rgba(148,163,184,0.07); --dm-dialog-warning:#9a6700; } +.dm-dialog-hero { display:flex; align-items:center; gap:14px; padding:16px; margin-bottom:18px; border:1px solid var(--dm-dialog-border); border-radius:10px; background:var(--dm-dialog-panel); } +.dm-dialog-icon { display:flex; align-items:center; justify-content:center; flex:0 0 48px; width:48px; height:48px; background:rgba(0,105,214,0.08); border-radius:12px; } +.dm-dialog-icon img { width:30px; height:30px; } +.dm-dialog-heading { display:flex; flex-direction:column; gap:5px; flex:1; min-width:0; } +.dm-dialog-heading strong { font-size:17px; line-height:1.4; overflow-wrap:anywhere; } +.dm-dialog-muted { color:var(--dm-dialog-muted); font-size:12px; line-height:1.7; } +.dm-dialog-status { flex-shrink:0; padding:4px 10px; border-radius:20px; font-size:12px; line-height:1.5; } +.dm-dialog-status-online { color:var(--success-color-medium, #137333); background:rgba(52,168,83,0.12); } +.dm-dialog-status-offline { color:var(--error-color-medium, #c5221f); background:rgba(234,67,53,0.1); } +.dm-dialog-status-unknown { color:var(--dm-dialog-warning); background:rgba(251,188,4,0.12); } +.dm-info-grid { display:grid; grid-template-columns:repeat(2, minmax(0, 1fr)); gap:16px 24px; padding:16px; margin-bottom:18px; border:1px solid var(--dm-dialog-border); border-radius:10px; } +.dm-info-item { min-width:0; } +.dm-info-wide { grid-column:1 / -1; } +.dm-info-label { display:block; margin-bottom:5px; color:var(--dm-dialog-muted); font-size:12px; } +.dm-info-value { font-size:13px; line-height:1.65; white-space:pre-wrap; overflow-wrap:anywhere; } +.dm-edit-fields { display:grid; grid-template-columns:repeat(2, minmax(0, 1fr)); gap:16px; } +.dm-edit-fields .cbi-value { display:block; min-width:0; padding:0; margin:0; border:0; } +.dm-edit-fields .cbi-value:nth-last-child(-n+2), .dm-edit-fields .dm-form-wide { grid-column:1 / -1; } +.dm-dialog .dm-edit-fields .cbi-value-title { display:block; float:none; width:auto; margin-bottom:7px; padding:0; text-align:left; font-size:13px; line-height:1.5; } +.dm-edit-fields .cbi-value-field { margin:0; padding:0; width:100%; } +.dm-edit-fields .cbi-value-description { margin:5px 0 0; color:var(--dm-dialog-muted); font-size:12px; line-height:1.6; } +.dm-edit-fields .cbi-value-description::before { display:none; } +.dm-dialog-footer { display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:12px; padding-top:16px; margin-top:20px; border-top:1px solid var(--dm-dialog-border); } +.dm-dialog-buttons { display:flex; justify-content:flex-end; align-items:center; gap:8px; } +.dm-dialog-footer .dm-dialog-buttons { margin-left:auto; } +.dm-dialog-buttons .dm-primary-button { background:#0069d6; border-color:#0069d6; color:#fff; text-shadow:none; } +.dm-dialog-buttons .dm-primary-button:hover { background:#0049d6; } +.dm-clear-description { margin:18px 0 10px; font-size:14px; line-height:1.7; overflow-wrap:anywhere; } +.dm-ping-card { padding:16px; margin-bottom:18px; border:1px solid var(--dm-dialog-border); border-radius:10px; background:var(--dm-dialog-panel); } +.dm-ping-heading { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom:8px; } +.dm-ping-status { font-size:14px; line-height:1.5; } +.dm-ping-success { color:var(--success-color-medium, #137333); } +.dm-ping-warning { color:var(--dm-dialog-warning); } +.dm-ping-card p { margin:0; white-space:pre-line; } +.dm-ping-log { margin-top:10px; } +.dm-ping-log summary { color:var(--dm-dialog-muted); font-size:12px; cursor:pointer; } +.dm-ping-output { max-height:140px; overflow:auto; margin:12px 0 0; padding:12px; border:0; border-radius:6px; background:rgba(148,163,184,0.1); color:inherit; font-size:12px; line-height:1.6; white-space:pre-wrap; overflow-wrap:anywhere; } +[data-theme="dark"] .dm-dialog, +[data-darkmode="true"] .dm-dialog, +body.dark-mode .dm-dialog { --dm-dialog-warning:#f4ca64; } +@media (max-width:640px) { + .modal.dm-device-modal { padding:18px; } + .dm-info-grid, .dm-edit-fields { grid-template-columns:minmax(0, 1fr); } + .dm-dialog-hero { gap:10px; padding:12px; } + .dm-dialog-heading strong { font-size:15px; } +} diff --git a/luci-app-device-manager/htdocs/luci-static/resources/device-manager/table.js b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/table.js new file mode 100644 index 000000000..730657c31 --- /dev/null +++ b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/table.js @@ -0,0 +1,272 @@ +// SPDX-License-Identifier: MIT +'use strict'; +/* global preferences */ +'require baseclass'; +'require device-manager.model as model'; +'require dom'; +'require device-manager.preferences as preferences'; + +return baseclass.extend({ + renderTabs: function() { + if (!this.tabMenuNode) return; + dom.content(this.tabMenuNode, null); + + const self = this; + + const matching = this.devices.filter(device => model.matchesDevice(device, this.groups, this.activeGroup, this.filterText)); + const total = matching.length; + const online = matching.filter(device => device.status === 'online').length; + const offline = matching.filter(device => device.status === 'offline').length; + const unknown = total - online - offline; + + const tabs = [ + { key: 'online', label: _('Online devices'), count: online }, + { key: 'all', label: _('All devices'), count: total }, + { key: 'offline', label: _('Offline devices'), count: offline }, + { key: 'unknown', label: _('Unknown'), count: unknown } + ]; + + tabs.forEach(function(t) { + const isActive = (self.activeTab === t.key); + const tabItem = E('button', { + 'type': 'button', + 'role': 'tab', + 'data-tab': t.key, + 'aria-selected': String(isActive), + 'class': 'dm-status-tab' + (isActive ? ' active' : ''), + 'click': function() { + self.activeTab = t.key; + preferences.save(self.activeTab, self.activeGroup, self.maskInfo); + self.updateView(); + const current = Array.from(self.tabMenuNode.children).find(node => node.getAttribute('data-tab') === t.key); + if (current) current.focus(); + } + }, [ + t.label, + E('span', { 'class': 'dm-tab-count' }, [ t.count ]) + ]); + self.tabMenuNode.appendChild(tabItem); + }); + }, + + renderGroupSelect: function() { + if (!this.groupSelectNode) return; + dom.content(this.groupSelectNode, null); + + const self = this; + const options = [ + E('option', { 'value': 'all' }, [ _('All groups') ]) + ]; + + // Count devices per group + const groupCounts = new Map(); + let ungroupedCount = 0; + + for (let i = 0; i < this.devices.length; i++) { + const g = this.devices[i].group; + if (!g || g === 'ungrouped') { + ungroupedCount++; + } else { + groupCounts.set(g, (groupCounts.get(g) || 0) + 1); + } + } + + for (let i = 0; i < this.groups.length; i++) { + const g = this.groups[i]; + const count = groupCounts.get(g.id) || 0; + const opt = E('option', { 'value': g.id }, [ '%s (%d)'.format(g.name, count) ]); + if (self.activeGroup === g.id) opt.selected = true; + options.push(opt); + } + + const ungroupedOpt = E('option', { 'value': 'ungrouped' }, [ '%s (%d)'.format(_('Ungrouped'), ungroupedCount) ]); + if (self.activeGroup === 'ungrouped') ungroupedOpt.selected = true; + options.push(ungroupedOpt); + + dom.content(this.groupSelectNode, options); + }, + + handleSort: function(key) { + if (this.sortKey === key) { + this.sortDir = (this.sortDir === 'asc') ? 'desc' : 'asc'; + } else { + this.sortKey = key; + this.sortDir = 'asc'; + } + this.updateSortHeaders(); + this.renderTable(); + }, + + updateSortHeaders: function() { + if (!this.tableHeadNode) return; + const ths = this.tableHeadNode.querySelectorAll('th'); + for (let i = 0; i < ths.length; i++) { + const th = ths[i]; + const col = th.getAttribute('data-sort'); + if (!col) continue; + const isCurrent = (col === this.sortKey); + th.classList.remove('sorted-asc'); + th.classList.remove('sorted-desc'); + if (isCurrent) { + th.classList.add(this.sortDir === 'asc' ? 'sorted-asc' : 'sorted-desc'); + if (typeof th.setAttribute === 'function') { + th.setAttribute('aria-sort', this.sortDir === 'asc' ? 'ascending' : 'descending'); + } else if (th.attrs) { + th.attrs['aria-sort'] = this.sortDir === 'asc' ? 'ascending' : 'descending'; + } + } else { + if (typeof th.setAttribute === 'function') { + th.setAttribute('aria-sort', 'none'); + } else if (th.attrs) { + th.attrs['aria-sort'] = 'none'; + } + } + } + }, + + renderTable: function() { + if (!this.tableBodyNode) return; + dom.content(this.tableBodyNode, null); + + const filtered = this.devices.filter(device => + (this.activeTab === 'all' || device.status === this.activeTab) && + model.matchesDevice(device, this.groups, this.activeGroup, this.filterText)); + + if (filtered.length === 0) { + let emptyMsg = _('No devices found. Refresh the list to try again.'); + if (this.filterText) + emptyMsg = _('No devices match "%s"').format(this.filterText); + else if (this.activeTab === 'online') + emptyMsg = _('No online devices'); + else if (this.activeTab === 'offline') + emptyMsg = _('No offline devices'); + else if (this.activeTab === 'unknown') + emptyMsg = _('No devices with unknown status'); + else if (this.activeGroup !== 'all') + emptyMsg = _('No devices in this group'); + + this.tableBodyNode.appendChild(E('tr', { 'class': 'tr' }, [ + E('td', { 'class': 'td', 'colspan': 8, 'style': 'text-align:center; padding:36px 16px; color:#888;' }, [ emptyMsg ]) + ])); + return; + } + + const sorted = model.sortDevices(filtered, this.sortKey, this.sortDir, id => this.getGroupName(id)); + + for (let i = 0; i < sorted.length; i++) { + this.tableBodyNode.appendChild(this.renderDeviceRow(sorted[i], i + 1)); + } + }, + + renderDeviceRow: function(dev, rowNumber) { + const self = this; + + // 0. Device type icon column + const typeInfo = model.getTypeInfo(dev.type); + const typeLabel = model.getTypeLabel(dev.type); + const iconNode = E('span', { + 'class': 'dm-device-icon-wrap', + 'title': typeLabel + }, [ + E('img', { + 'class': 'dm-device-icon', + 'src': L.resource('device-manager/device-icons/' + typeInfo.icon), + 'alt': typeLabel + }) + ]); + + // 1. Device name column + const displayName = dev.customName || dev.hostname || _('Unknown device'); + + // Status dot with tooltip explanation (no text displayed) + let dotClass = 'dm-status-dot-unknown'; + let statusLabel = _('Unknown'); + + if (dev.status === 'online') { + dotClass = 'dm-status-dot-online'; + statusLabel = _('Online'); + } else if (dev.status === 'offline') { + dotClass = 'dm-status-dot-offline'; + statusLabel = _('Offline'); + } + + const statusTooltip = dev.statusDetail ? (statusLabel + ': ' + dev.statusDetail) : statusLabel; + + const nameChildren = [ + E('div', { 'class': 'dm-device-title-row' }, [ + E('span', { + 'class': 'dm-status-dot ' + dotClass, + 'title': statusTooltip + }), + E('span', { 'class': 'dm-device-title' }, [ displayName ]) + ]) + ]; + + if (dev.customName && dev.hostname && dev.customName !== dev.hostname) { + nameChildren.push(E('div', { 'class': 'dm-device-subtitle' }, [ dev.hostname ])); + } + + // 2. IP address column + const ipChildren = []; + if (dev.ipv4) { + ipChildren.push(E('div', {}, [ dev.ipv4 ])); + } else { + ipChildren.push(E('div', { 'style': 'color:#999;' }, [ '—' ])); + } + if (dev.ipv6) { + const displayIpv6 = (this.maskInfo && dev.ipv6) ? dev.ipv6.replace(/[0-9a-fA-F]/g, '*') : dev.ipv6; + ipChildren.push(E('div', { + 'class': 'dm-device-subtitle', + 'title': displayIpv6, + 'style': 'max-width:180px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;' + }, [ displayIpv6 ])); + } + + // 3. MAC address column + const displayMac = (this.maskInfo && dev.mac) ? dev.mac.replace(/[0-9a-fA-F]/g, '*') : dev.mac; + const macNode = E('span', { 'class': 'dm-mac-code' }, [ displayMac ]); + + // 4. Group column + const groupName = this.getGroupName(dev.group); + const groupNode = (dev.group && dev.group !== 'ungrouped') + ? E('span', { 'class': 'dm-group-badge' }, [ groupName ]) + : null; + + // 5. Remark column + const remarkNode = dev.remark + ? E('span', {}, [ dev.remark ]) + : null; + + // 6. Action column + const actionLink = (label, className, action) => E('a', { + 'href': '#', + 'role': 'button', + 'class': 'dm-action-link ' + className, + 'click': function(ev) { + ev.preventDefault(); + return action(); + }, + 'keydown': function(ev) { + if (ev.key === ' ') { + ev.preventDefault(); + return action(); + } + } + }, [ label ]); + const actions = [ actionLink(_('View'), 'dm-action-detail', () => self.showDetailModal(dev)) ]; + if (!self.readonly) actions.push(actionLink(_('Edit'), 'dm-action-edit', () => self.showEditModal(dev))); + + return E('tr', { 'class': 'tr' }, [ + E('td', { 'class': 'td dm-index-column' }, [ rowNumber == null ? '' : String(rowNumber) ]), + E('td', { 'class': 'td dm-type-cell', 'style': 'text-align:center; vertical-align:middle;' }, [ iconNode ]), + E('td', { 'class': 'td' }, nameChildren), + E('td', { 'class': 'td' }, ipChildren), + E('td', { 'class': 'td' }, macNode), + E('td', { 'class': 'td' }, groupNode), + E('td', { 'class': 'td' }, remarkNode), + E('td', { 'class': 'td dm-actions dm-actions-column' }, [ + E('div', { 'class': 'dm-action-links' }, actions) + ]) + ]); + } +}); diff --git a/luci-app-device-manager/htdocs/luci-static/resources/view/device-manager/devices.js b/luci-app-device-manager/htdocs/luci-static/resources/view/device-manager/devices.js new file mode 100644 index 000000000..dc35799b7 --- /dev/null +++ b/luci-app-device-manager/htdocs/luci-static/resources/view/device-manager/devices.js @@ -0,0 +1,249 @@ +// SPDX-License-Identifier: MIT +'use strict'; +/* global deviceDialog, groupDialog, preferences, service, table */ +'require view'; +'require ui'; +'require device-manager.model as model'; +'require device-manager.service as service'; +'require device-manager.preferences as preferences'; +'require device-manager.table as table'; +'require device-manager.device-dialog as deviceDialog'; +'require device-manager.group-dialog as groupDialog'; + +return view.extend({ + devices: [], + groups: [], + activeTab: 'online', + activeGroup: 'all', + maskInfo: false, + filterText: '', + readonly: true, + sortKey: null, + sortDir: 'asc', + handleSave: null, + handleSaveApply: null, + handleReset: null, + + load: function() { + return Promise.all([ service.load(), preferences.load() ]) + .then(data => ({ snapshot: data[0], preferences: data[1] })); + }, + + acceptData: function(data) { + this.groups = model.parseGroups(data.groups); + this.devices = model.parseDevices(data, this.groups); + this.sourceErrors = data.errors || []; + if (this.activeGroup !== 'all' && this.activeGroup !== 'ungrouped' && !this.getGroupById(this.activeGroup)) { + this.activeGroup = 'all'; + preferences.save(this.activeTab, this.activeGroup, this.maskInfo); + } + }, + + render: function(data) { + const self = this; + + this.readonly = L.hasViewPermission() !== true; + this.acceptData(data.snapshot); + const savedView = data.preferences || {}; + this.activeTab = ['all', 'online', 'offline', 'unknown'].includes(savedView.tab) ? savedView.tab : 'online'; + this.activeGroup = (savedView.group === 'all' || savedView.group === 'ungrouped' || this.getGroupById(savedView.group)) ? savedView.group : 'all'; + this.maskInfo = Boolean(savedView.maskInfo); + + const sortHeader = (key, label, width, centered) => E('th', { + 'class': 'th dm-sortable' + (centered ? ' dm-type-th' : ''), + 'scope': 'col', + 'data-sort': key, + 'style': 'width:' + width + ';' + }, [ E('button', { + 'type': 'button', + 'class': 'dm-header-content dm-sort-button', + 'title': _('Click to sort by %s').format(label), + 'click': function() { self.handleSort(key); } + }, [ + E('span', { 'class': 'dm-sort-label' }, [ label ]), + E('span', { 'class': 'dm-sort-icon', 'aria-hidden': 'true' }) + ]) ]); + + const theadNode = E('thead', {}, [ E('tr', { 'class': 'tr table-titles' }, [ + E('th', { 'class': 'th dm-index-column', 'scope': 'col', 'title': _('Row number') }, [ + E('span', { 'class': 'dm-header-content' }, [ _('#') ]) + ]), + sortHeader('type', _('Type'), '56px', true), + sortHeader('name', _('Device name'), '24%'), + sortHeader('ip', _('IP address'), '18%'), + sortHeader('mac', _('MAC address'), '18%'), + sortHeader('group', _('Group'), '14%'), + E('th', { 'class': 'th', 'scope': 'col', 'style': 'width:14%;' }, [ + E('span', { 'class': 'dm-header-content' }, [ _('Remarks') ]) + ]), + E('th', { 'class': 'th dm-actions-column', 'scope': 'col' }, [ + E('span', { 'class': 'dm-header-content' }, [ _('Actions') ]) + ]) + ]) ]); + + const viewNode = E('div', { 'class': 'cbi-map' }, [ + E('link', { 'rel': 'stylesheet', 'href': L.resource('device-manager/styles.css') }), + + E('h2', {}, [ _('LAN Device Management') ]), + E('div', { 'class': 'cbi-map-descr' }, [ _('Discover network devices and save custom names, remarks and groups by MAC address.') ]), + + E('div', { 'id': 'dm-source-warning', 'class': 'alert-message warning dm-source-warning', 'style': 'display:none;' }), + + // Row 1: Status Tabs + E('div', { 'class': 'dm-status-tabs', 'id': 'dm-tabs-container', 'role': 'tablist', 'aria-label': _('Device status') }), + + // Row 2: Controls Toolbar + E('div', { 'class': 'cbi-section dm-toolbar' }, [ + E('div', { 'class': 'dm-controls' }, [ + E('input', { + 'type': 'text', + 'id': 'dm-search-input', + 'class': 'cbi-input-text', + 'placeholder': _('Search names, IP, MAC, remarks or groups...'), + 'style': 'flex:1; min-width:200px; max-width:360px;', + 'value': self.filterText, + 'input': function(ev) { + self.filterText = ev.target.value.trim().toLowerCase(); + self.updateView(); + } + }), + E('select', { + 'id': 'dm-group-select', + 'class': 'cbi-input-select', + 'style': 'min-width:140px;', + 'change': function(ev) { + self.activeGroup = ev.target.value; + preferences.save(self.activeTab, self.activeGroup, self.maskInfo); + self.updateView(); + } + }) + ]), + E('div', { 'class': 'dm-btn-group' }, [ + E('button', { 'type': 'button', + 'class': 'cbi-button cbi-button-neutral', + 'disabled': self.readonly || null, + 'click': function() { self.showGroupModal(); } + }, [ _('Manage groups') ]), + E('button', { 'type': 'button', + 'class': 'cbi-button cbi-button-action dm-btn-blue', + 'disabled': self.readonly || null, + 'click': function() { self.showEditModal(null); } + }, [ _('Add device') ]), + E('button', { 'type': 'button', + 'class': 'cbi-button cbi-button-neutral', + 'id': 'dm-btn-mask', + 'title': _('Hide/Show MAC and IPv6'), + 'click': function() { + self.maskInfo = !self.maskInfo; + preferences.save(self.activeTab, self.activeGroup, self.maskInfo); + self.updateView(); + } + }, [ (self.maskInfo ? _('Show info') : _('Hide info')) ]), + E('button', { 'type': 'button', + 'class': 'cbi-button cbi-button-neutral', + 'id': 'dm-btn-refresh', + 'click': function(ev) { + const button = ev.currentTarget; + button.classList.add('spinning'); + button.disabled = true; + return self.refresh().finally(function() { + button.classList.remove('spinning'); + button.disabled = false; + }); + } + }, [ _('Refresh list') ]), + E('button', { 'type': 'button', + 'class': 'cbi-button cbi-button-neutral', + 'id': 'dm-btn-scan', + 'disabled': self.readonly || null, + 'title': _('Probe devices on the configured LAN interfaces'), + 'click': function(ev) { + const button = ev.currentTarget; + button.disabled = true; + button.classList.add('spinning'); + return self.scan().finally(() => { + button.disabled = self.readonly; + button.classList.remove('spinning'); + }); + } + }, [ _('Scan LAN') ]) + ]) + ]), + + // Row 3: Device Table + E('div', { 'class': 'cbi-section' }, [ + E('div', { 'class': 'dm-table-wrapper' }, [ + E('table', { 'class': 'table dm-table', 'id': 'device_manager_table' }, [ + theadNode, + E('tbody', { 'id': 'device_manager_tbody' }) + ]) + ]) + ]) + ]); + + this.tableHeadNode = theadNode; + this.warningNode = viewNode.querySelector('#dm-source-warning'); + this.tabMenuNode = viewNode.querySelector('#dm-tabs-container'); + this.tableBodyNode = viewNode.querySelector('#device_manager_tbody'); + this.groupSelectNode = viewNode.querySelector('#dm-group-select'); + this.maskBtnNode = viewNode.querySelector('#dm-btn-mask'); + + this.updateView(); + return viewNode; + }, + + getGroupById: function(id) { return this.groups.find(group => group.id === id) || null; }, + getGroupName: function(id) { return model.groupName(this.groups, id); }, + renderTabs: function() { return table.renderTabs.call(this); }, + renderGroupSelect: function() { return table.renderGroupSelect.call(this); }, + renderTable: function() { return table.renderTable.call(this); }, + renderDeviceRow: function(device, rowNumber) { return table.renderDeviceRow.call(this, device, rowNumber); }, + handleSort: function(key) { return table.handleSort.call(this, key); }, + updateSortHeaders: function() { return table.updateSortHeaders.call(this); }, + showEditModal: function(device) { return deviceDialog.showEditModal.call(this, device); }, + showDetailModal: function(device) { return deviceDialog.showDetailModal.call(this, device); }, + handlePingDevice: function(device) { return service.pingDevice(device.mac); }, + confirmDelete: function(device) { return deviceDialog.confirmDelete.call(this, device); }, + showGroupModal: function() { return groupDialog.showGroupModal.call(this); }, + promptRenameGroup: function(group) { return groupDialog.promptRenameGroup.call(this, group); }, + confirmDeleteGroup: function(group, count) { return groupDialog.confirmDeleteGroup.call(this, group, count); }, + handleSaveDevice: function(mac, name, remark, group, dev, type) { return service.saveDevice(mac, name, remark, group, type); }, + handleDeleteDevice: function(device) { return service.deleteDevice(device); }, + handleAddGroup: function(name) { return service.addGroup(name); }, + handleRenameGroup: function(id, name) { return service.renameGroup(id, name); }, + handleDeleteGroup: function(id) { return service.deleteGroup(id); }, + + updateView: function() { + this.renderTabs(); + this.renderGroupSelect(); + this.updateSortHeaders(); + if (this.maskBtnNode) { + this.maskBtnNode.textContent = this.maskInfo ? _('Show info') : _('Hide info'); + } + this.renderTable(); + if (this.warningNode) { + this.warningNode.textContent = this.sourceErrors.length + ? _('Some device data could not be read. Unconfirmed devices are shown as unknown: %s').format(this.sourceErrors.join('; ')) : ''; + this.warningNode.style.display = this.sourceErrors.length ? '' : 'none'; + } + }, + + scan: function() { + if (this.scanning) return this.scanning; + this.scanning = service.scanDevices().then(() => this.refresh()).catch(error => { + ui.addNotification(null, E('p', {}, [ _('Could not scan LAN devices: %s').format(error.message || error) ]), 'danger'); + }).finally(() => { this.scanning = null; }); + return this.scanning; + }, + + refresh: function() { + if (this.refreshing) return this.refreshing; + this.refreshing = service.load().then(data => { + this.acceptData(data); + this.updateView(); + }).catch(error => { + ui.addNotification(null, E('p', {}, [ _('Could not refresh the device list: %s').format(error.message || error) ]), 'danger'); + }).finally(() => { this.refreshing = null; }); + return this.refreshing; + } +}); diff --git a/luci-app-device-manager/po/templates/device-manager.pot b/luci-app-device-manager/po/templates/device-manager.pot new file mode 100644 index 000000000..1d359ac76 --- /dev/null +++ b/luci-app-device-manager/po/templates/device-manager.pot @@ -0,0 +1,789 @@ +msgid "" +msgstr "Content-Type: text/plain; charset=UTF-8" + +#: htdocs/luci-static/resources/view/device-manager/devices.js:69 +msgid "#" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/group-dialog.js:38 +msgid "%d devices" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/service.js:158 +msgid "%s; failed to discard pending changes: %s" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/service.js:172 +msgid "A conflicting configuration record exists" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/group-dialog.js:70 +#: htdocs/luci-static/resources/device-manager/service.js:67 +msgid "A group with this name already exists" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:309 +msgid "ARP" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/model.js:272 +msgid "ARP record exists without recent activity evidence" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/model.js:31 +msgid "Access point" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/group-dialog.js:106 +#: htdocs/luci-static/resources/view/device-manager/devices.js:80 +msgid "Actions" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/model.js:257 +msgid "Active Wi-Fi association" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/model.js:260 +msgid "Active network neighbor (%s)" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:55 +#: htdocs/luci-static/resources/view/device-manager/devices.js:131 +msgid "Add device" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/group-dialog.js:96 +msgid "Add group" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/model.js:41 +msgid "Air conditioner" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/model.js:45 +msgid "Air purifier" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/table.js:24 +msgid "All devices" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/table.js:58 +msgid "All groups" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/service.js:89 +msgid "Another device is being probed. Please retry." +msgstr "" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:93 +msgid "Auto (detect by MAC)" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:323 +msgid "Automatic" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:131 +msgid "" +"Automatically detected from names and MAC address, or customize manually" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/model.js:26 +msgid "Camera" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:161 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:238 +#: htdocs/luci-static/resources/device-manager/group-dialog.js:160 +#: htdocs/luci-static/resources/device-manager/group-dialog.js:211 +msgid "Cancel" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:255 +msgid "Clear failed: %s" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:212 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:232 +msgid "Clear record" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:235 +msgid "Clear the saved record for \"%s\" (%s)?" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:236 +msgid "" +"Clears the custom name, remarks, group and device type. The device remains " +"discoverable on the network." +msgstr "" + +#: htdocs/luci-static/resources/view/device-manager/devices.js:60 +msgid "Click to sort by %s" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:13 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:331 +#: htdocs/luci-static/resources/device-manager/group-dialog.js:123 +msgid "Close" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/model.js:18 +msgid "Computer" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/model.js:12 +msgid "Computers" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:261 +msgid "Confirm clear" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/group-dialog.js:235 +msgid "Confirm delete" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/group-dialog.js:88 +msgid "Could not create group: %s" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/group-dialog.js:229 +msgid "Could not delete group: %s" +msgstr "" + +#: htdocs/luci-static/resources/view/device-manager/devices.js:245 +msgid "Could not refresh the device list: %s" +msgstr "" + +#: htdocs/luci-static/resources/view/device-manager/devices.js:234 +msgid "Could not scan LAN devices: %s" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:220 +msgid "Current IP address" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/group-dialog.js:148 +msgid "Current group name" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:220 +msgid "Current status" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:143 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:320 +msgid "Custom name" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:308 +msgid "DHCP" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/group-dialog.js:33 +msgid "Delete" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/group-dialog.js:207 +msgid "Delete group" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/group-dialog.js:208 +msgid "Delete group \"%s\"?" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:189 +msgid "Device \"%s\" saved." +msgstr "" + +#: root/usr/share/luci/menu.d/luci-app-device-manager.json:3 +msgid "Device Manager" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/group-dialog.js:105 +msgid "Device count" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:312 +msgid "Device details" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:136 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:320 +msgid "Device group" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/group-dialog.js:98 +msgid "Device groups" +msgstr "" + +#: htdocs/luci-static/resources/view/device-manager/devices.js:72 +msgid "Device name" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:150 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:328 +msgid "Device remarks" +msgstr "" + +#: htdocs/luci-static/resources/view/device-manager/devices.js:93 +msgid "Device status" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:127 +msgid "Device type" +msgstr "" + +#: htdocs/luci-static/resources/view/device-manager/devices.js:88 +msgid "" +"Discover network devices and save custom names, remarks and groups by MAC " +"address." +msgstr "" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:325 +msgid "Discovered on network" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:326 +msgid "Discovery sources" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/table.js:257 +msgid "Edit" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:54 +msgid "Edit device" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/service.js:85 +#: htdocs/luci-static/resources/device-manager/service.js:91 +#: htdocs/luci-static/resources/device-manager/service.js:166 +msgid "Enter a valid MAC address" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:171 +msgid "Enter a valid MAC address (e.g. AA:BB:CC:11:22:33)" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/group-dialog.js:62 +#: htdocs/luci-static/resources/device-manager/group-dialog.js:168 +#: htdocs/luci-static/resources/device-manager/service.js:65 +msgid "Enter a valid group name" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/model.js:28 +msgid "Game console" +msgstr "" + +#: root/usr/share/rpcd/acl.d/luci-app-device-manager.json:3 +msgid "Grant access to LAN device manager" +msgstr "" + +#: htdocs/luci-static/resources/view/device-manager/devices.js:75 +msgid "Group" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/group-dialog.js:82 +msgid "Group \"%s\" created." +msgstr "" + +#: htdocs/luci-static/resources/device-manager/group-dialog.js:222 +msgid "Group \"%s\" deleted. Its devices are now ungrouped." +msgstr "" + +#: htdocs/luci-static/resources/device-manager/group-dialog.js:104 +msgid "Group ID" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/group-dialog.js:103 +msgid "Group name" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/group-dialog.js:115 +msgid "Group names support Unicode text and common symbols" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/group-dialog.js:184 +msgid "Group renamed to \"%s\"." +msgstr "" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:75 +msgid "Hardware address; colon or hyphen separators are accepted" +msgstr "" + +#: htdocs/luci-static/resources/view/device-manager/devices.js:141 +#: htdocs/luci-static/resources/view/device-manager/devices.js:221 +msgid "Hide info" +msgstr "" + +#: htdocs/luci-static/resources/view/device-manager/devices.js:135 +msgid "Hide/Show MAC and IPv6" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:308 +msgid "Host hints" +msgstr "" + +#: htdocs/luci-static/resources/view/device-manager/devices.js:73 +msgid "IP address" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:321 +msgid "IPv4 addresses" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:321 +msgid "IPv6 addresses" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/service.js:59 +msgid "Input is too long" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/service.js:21 +#: htdocs/luci-static/resources/device-manager/service.js:42 +#: htdocs/luci-static/resources/device-manager/service.js:100 +#: htdocs/luci-static/resources/device-manager/service.js:119 +msgid "Invalid discovery response" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/model.js:274 +msgid "Known device without recent activity evidence" +msgstr "" + +#: htdocs/luci-static/resources/view/device-manager/devices.js:87 +msgid "LAN Device Management" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/service.js:80 +msgid "" +"LAN scanning is unavailable. Check the selected interfaces and probe " +"dependencies." +msgstr "" + +#: htdocs/luci-static/resources/device-manager/model.js:19 +msgid "Laptop" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:146 +msgid "Leave empty to display the default hostname" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:70 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:219 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:319 +#: htdocs/luci-static/resources/view/device-manager/devices.js:74 +msgid "MAC address" +msgstr "" + +#: htdocs/luci-static/resources/view/device-manager/devices.js:126 +msgid "Manage groups" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:323 +msgid "Manual" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/model.js:24 +msgid "NAS" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:328 +msgid "Neighbor records" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:309 +msgid "Neighbor table" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/model.js:40 +msgid "Network device" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/model.js:13 +msgid "Network devices" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:322 +msgid "Network interfaces" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/model.js:263 +msgid "Network neighbor probe failed (FAILED)" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/group-dialog.js:111 +msgid "New group" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/group-dialog.js:152 +msgid "New group name" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:324 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:325 +msgid "No" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:290 +msgid "" +"No ICMP reply was received. The device may block Ping; this does not confirm " +"it is offline." +msgstr "" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:287 +msgid "No Ping reply" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/table.js:136 +msgid "No devices found. Refresh the list to try again." +msgstr "" + +#: htdocs/luci-static/resources/device-manager/table.js:146 +msgid "No devices in this group" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/table.js:138 +msgid "No devices match \"%s\"" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/table.js:144 +msgid "No devices with unknown status" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/service.js:88 +msgid "No known IP address for this device" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/table.js:142 +msgid "No offline devices" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/table.js:140 +msgid "No online devices" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/model.js:270 +msgid "No recent activity confirmed (%s)" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:47 +msgid "Not detected" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:8 +#: htdocs/luci-static/resources/device-manager/table.js:190 +msgid "Offline" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/table.js:25 +msgid "Offline devices" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:8 +#: htdocs/luci-static/resources/device-manager/table.js:187 +msgid "Online" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/table.js:23 +msgid "Online devices" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:287 +msgid "Online · Ping replied" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:139 +msgid "Organize this device by purpose or location" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/model.js:14 +msgid "Other devices" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/model.js:20 +msgid "Phone" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/model.js:11 +msgid "Phones" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:306 +msgid "Ping again" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/service.js:95 +msgid "Ping could not be completed" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/service.js:90 +msgid "Ping is unavailable" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:274 +msgid "Ping output" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/model.js:25 +msgid "Printer" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:289 +msgid "Probe address: %s" +msgstr "" + +#: htdocs/luci-static/resources/view/device-manager/devices.js:159 +msgid "Probe devices on the configured LAN interfaces" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:294 +msgid "Probe unavailable" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:270 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:283 +msgid "Probing…" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/service.js:77 +#: htdocs/luci-static/resources/device-manager/service.js:149 +msgid "Read-only access" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:153 +msgid "Record the location, purpose or owner of this device" +msgstr "" + +#: htdocs/luci-static/resources/view/device-manager/devices.js:154 +msgid "Refresh list" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/model.js:43 +msgid "Refrigerator" +msgstr "" + +#: htdocs/luci-static/resources/view/device-manager/devices.js:77 +msgid "Remarks" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/group-dialog.js:28 +msgid "Rename" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/group-dialog.js:190 +msgid "Rename failed: %s" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/group-dialog.js:144 +msgid "Rename group" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/group-dialog.js:155 +msgid "Renaming preserves the group ID and all device assignments" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/model.js:29 +msgid "Router" +msgstr "" + +#: htdocs/luci-static/resources/view/device-manager/devices.js:68 +msgid "Row number" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:201 +msgid "Save" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/group-dialog.js:198 +msgid "Save changes" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:193 +msgid "Save failed: %s" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/model.js:268 +msgid "Saved device not found in the current network" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:324 +msgid "Saved record" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:250 +msgid "Saved record for \"%s\" cleared." +msgstr "" + +#: htdocs/luci-static/resources/view/device-manager/devices.js:169 +msgid "Scan LAN" +msgstr "" + +#: htdocs/luci-static/resources/view/device-manager/devices.js:102 +msgid "Search names, IP, MAC, remarks or groups..." +msgstr "" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:284 +msgid "Sending one Ping from the router to this device." +msgstr "" + +#: htdocs/luci-static/resources/device-manager/model.js:35 +msgid "Sensor" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/model.js:32 +msgid "Server" +msgstr "" + +#: htdocs/luci-static/resources/view/device-manager/devices.js:141 +#: htdocs/luci-static/resources/view/device-manager/devices.js:221 +msgid "Show info" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/model.js:10 +#: htdocs/luci-static/resources/device-manager/model.js:36 +msgid "Smart home" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/model.js:34 +msgid "Smart light" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/model.js:33 +msgid "Smart plug" +msgstr "" + +#: htdocs/luci-static/resources/view/device-manager/devices.js:226 +msgid "" +"Some device data could not be read. Unconfirmed devices are shown as " +"unknown: %s" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/model.js:265 +msgid "Some discovery sources are unavailable" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/model.js:27 +msgid "Speaker" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:327 +msgid "Status evidence" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/model.js:30 +msgid "Switch" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:219 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:319 +msgid "System hostname" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/model.js:22 +msgid "TV" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/model.js:23 +msgid "TV box" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/model.js:21 +msgid "Tablet" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/group-dialog.js:209 +msgid "" +"The %d devices in this group will become ungrouped. Their names, MAC " +"addresses and remarks will be preserved." +msgstr "" + +#: htdocs/luci-static/resources/device-manager/service.js:72 +msgid "This group no longer exists" +msgstr "" + +#: htdocs/luci-static/resources/view/device-manager/devices.js:71 +msgid "Type" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:323 +msgid "Type detection" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/service.js:92 +msgid "Unable to read the kernel neighbor table" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:48 +msgid "Unavailable" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:82 +#: htdocs/luci-static/resources/device-manager/model.js:130 +#: htdocs/luci-static/resources/device-manager/table.js:82 +msgid "Ungrouped" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:8 +#: htdocs/luci-static/resources/device-manager/model.js:46 +#: htdocs/luci-static/resources/device-manager/table.js:26 +#: htdocs/luci-static/resources/device-manager/table.js:183 +msgid "Unknown" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:24 +#: htdocs/luci-static/resources/device-manager/table.js:179 +msgid "Unknown device" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/model.js:39 +msgid "VR headset" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/model.js:38 +msgid "Vehicle" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/table.js:256 +msgid "View" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/model.js:42 +msgid "Washing machine" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/model.js:37 +msgid "Watch" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/model.js:44 +msgid "Water purifier" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:308 +msgid "Wi-Fi" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:324 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:325 +msgid "Yes" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:64 +msgid "e.g. AA:BB:CC:11:22:33" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:113 +msgid "e.g. Living room TV" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:105 +msgid "e.g. Living room TV, work computer" +msgstr "" + +#: htdocs/luci-static/resources/device-manager/group-dialog.js:46 +msgid "e.g. Media devices, office devices" +msgstr "" diff --git a/luci-app-device-manager/po/zh-cn b/luci-app-device-manager/po/zh-cn new file mode 120000 index 000000000..8d69574dd --- /dev/null +++ b/luci-app-device-manager/po/zh-cn @@ -0,0 +1 @@ +zh_Hans \ No newline at end of file diff --git a/luci-app-device-manager/po/zh_Hans/device-manager.po b/luci-app-device-manager/po/zh_Hans/device-manager.po new file mode 100644 index 000000000..de624b024 --- /dev/null +++ b/luci-app-device-manager/po/zh_Hans/device-manager.po @@ -0,0 +1,784 @@ +msgid "" +msgstr "" +"Project-Id-Version: luci-app-device-manager\n" +"PO-Revision-Date: 2026-10-08 00:00+0800\n" +"Last-Translator: luci-app-device-manager contributors\n" +"Language-Team: Chinese (Simplified)\n" +"Language: zh_Hans\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" + +#: htdocs/luci-static/resources/view/device-manager/devices.js:69 +msgid "#" +msgstr "序号" + +#: htdocs/luci-static/resources/device-manager/group-dialog.js:38 +msgid "%d devices" +msgstr "%d 台设备" + +#: htdocs/luci-static/resources/device-manager/service.js:158 +msgid "%s; failed to discard pending changes: %s" +msgstr "%s;清理待提交配置失败:%s" + +#: htdocs/luci-static/resources/device-manager/service.js:172 +msgid "A conflicting configuration record exists" +msgstr "存在冲突的配置记录" + +#: htdocs/luci-static/resources/device-manager/group-dialog.js:70 +#: htdocs/luci-static/resources/device-manager/service.js:67 +msgid "A group with this name already exists" +msgstr "已存在同名分组,请使用其他名称" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:309 +msgid "ARP" +msgstr "ARP 表" + +#: htdocs/luci-static/resources/device-manager/model.js:272 +msgid "ARP record exists without recent activity evidence" +msgstr "存在 ARP 记录,缺少近期活动证据" + +#: htdocs/luci-static/resources/device-manager/model.js:31 +msgid "Access point" +msgstr "无线接入点" + +#: htdocs/luci-static/resources/device-manager/group-dialog.js:106 +#: htdocs/luci-static/resources/view/device-manager/devices.js:80 +msgid "Actions" +msgstr "操作" + +#: htdocs/luci-static/resources/device-manager/model.js:257 +msgid "Active Wi-Fi association" +msgstr "Wi-Fi 活跃连接" + +#: htdocs/luci-static/resources/device-manager/model.js:260 +msgid "Active network neighbor (%s)" +msgstr "网络邻居活跃 (%s)" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:55 +#: htdocs/luci-static/resources/view/device-manager/devices.js:131 +msgid "Add device" +msgstr "手动添加设备" + +#: htdocs/luci-static/resources/device-manager/group-dialog.js:96 +msgid "Add group" +msgstr "添加分组" + +#: htdocs/luci-static/resources/device-manager/model.js:41 +msgid "Air conditioner" +msgstr "空调" + +#: htdocs/luci-static/resources/device-manager/model.js:45 +msgid "Air purifier" +msgstr "空气净化器" + +#: htdocs/luci-static/resources/device-manager/table.js:24 +msgid "All devices" +msgstr "全部设备" + +#: htdocs/luci-static/resources/device-manager/table.js:58 +msgid "All groups" +msgstr "全部分组" + +#: htdocs/luci-static/resources/device-manager/service.js:89 +msgid "Another device is being probed. Please retry." +msgstr "另一个设备正在探测,请稍后重试。" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:93 +msgid "Auto (detect by MAC)" +msgstr "自动识别" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:323 +msgid "Automatic" +msgstr "自动识别" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:131 +msgid "Automatically detected from names and MAC address, or customize manually" +msgstr "根据名称和 MAC 地址推测类型,也可手动指定" + +#: htdocs/luci-static/resources/device-manager/model.js:26 +msgid "Camera" +msgstr "摄像头" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:161 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:238 +#: htdocs/luci-static/resources/device-manager/group-dialog.js:160 +#: htdocs/luci-static/resources/device-manager/group-dialog.js:211 +msgid "Cancel" +msgstr "取消" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:255 +msgid "Clear failed: %s" +msgstr "清空失败:%s" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:212 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:232 +msgid "Clear record" +msgstr "清空记录" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:235 +msgid "Clear the saved record for \"%s\" (%s)?" +msgstr "确定清空设备【%s】(%s)的保存记录吗?" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:236 +msgid "Clears the custom name, remarks, group and device type. The device remains discoverable on the network." +msgstr "将清空自定义名称、备注、分组和设备类型设置。设备仍可在网络中被发现。" + +#: htdocs/luci-static/resources/view/device-manager/devices.js:60 +msgid "Click to sort by %s" +msgstr "点击按%s排序" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:13 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:331 +#: htdocs/luci-static/resources/device-manager/group-dialog.js:123 +msgid "Close" +msgstr "关闭" + +#: htdocs/luci-static/resources/device-manager/model.js:18 +msgid "Computer" +msgstr "台式电脑" + +#: htdocs/luci-static/resources/device-manager/model.js:12 +msgid "Computers" +msgstr "电脑设备" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:261 +msgid "Confirm clear" +msgstr "确认清空" + +#: htdocs/luci-static/resources/device-manager/group-dialog.js:235 +msgid "Confirm delete" +msgstr "确认删除" + +#: htdocs/luci-static/resources/device-manager/group-dialog.js:88 +msgid "Could not create group: %s" +msgstr "创建分组失败: %s" + +#: htdocs/luci-static/resources/device-manager/group-dialog.js:229 +msgid "Could not delete group: %s" +msgstr "删除分组失败: %s" + +#: htdocs/luci-static/resources/view/device-manager/devices.js:245 +msgid "Could not refresh the device list: %s" +msgstr "刷新设备列表失败:%s" + +#: htdocs/luci-static/resources/view/device-manager/devices.js:234 +msgid "Could not scan LAN devices: %s" +msgstr "无法扫描局域网设备:%s" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:220 +msgid "Current IP address" +msgstr "当前 IP 地址" + +#: htdocs/luci-static/resources/device-manager/group-dialog.js:148 +msgid "Current group name" +msgstr "原分组名称" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:220 +msgid "Current status" +msgstr "当前在线状态" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:143 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:320 +msgid "Custom name" +msgstr "设备显示名称" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:308 +msgid "DHCP" +msgstr "DHCP 租约" + +#: htdocs/luci-static/resources/device-manager/group-dialog.js:33 +msgid "Delete" +msgstr "删除" + +#: htdocs/luci-static/resources/device-manager/group-dialog.js:207 +msgid "Delete group" +msgstr "确认删除分组" + +#: htdocs/luci-static/resources/device-manager/group-dialog.js:208 +msgid "Delete group \"%s\"?" +msgstr "确定要删除分组【%s】吗?" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:189 +msgid "Device \"%s\" saved." +msgstr "设备【%s】信息已保存并生效!" + +#: root/usr/share/luci/menu.d/luci-app-device-manager.json:3 +msgid "Device Manager" +msgstr "设备管理" + +#: htdocs/luci-static/resources/device-manager/group-dialog.js:105 +msgid "Device count" +msgstr "包含设备数" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:312 +msgid "Device details" +msgstr "设备详情" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:136 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:320 +msgid "Device group" +msgstr "所属分组" + +#: htdocs/luci-static/resources/device-manager/group-dialog.js:98 +msgid "Device groups" +msgstr "设备分组管理" + +#: htdocs/luci-static/resources/view/device-manager/devices.js:72 +msgid "Device name" +msgstr "设备名称" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:150 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:328 +msgid "Device remarks" +msgstr "设备备注" + +#: htdocs/luci-static/resources/view/device-manager/devices.js:93 +msgid "Device status" +msgstr "设备状态" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:127 +msgid "Device type" +msgstr "设备类型" + +#: htdocs/luci-static/resources/view/device-manager/devices.js:88 +msgid "Discover network devices and save custom names, remarks and groups by MAC address." +msgstr "自动识别局域网设备在线状态,支持按 MAC 地址自定义显示名称、备注和自定义设备分组,配置永久保存。" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:325 +msgid "Discovered on network" +msgstr "网络已发现" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:326 +msgid "Discovery sources" +msgstr "发现来源" + +#: htdocs/luci-static/resources/device-manager/table.js:257 +msgid "Edit" +msgstr "编辑" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:54 +msgid "Edit device" +msgstr "编辑设备" + +#: htdocs/luci-static/resources/device-manager/service.js:85 +#: htdocs/luci-static/resources/device-manager/service.js:91 +#: htdocs/luci-static/resources/device-manager/service.js:166 +msgid "Enter a valid MAC address" +msgstr "请输入有效的 MAC 地址" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:171 +msgid "Enter a valid MAC address (e.g. AA:BB:CC:11:22:33)" +msgstr "请输入有效的 MAC 地址(格式如:AA:BB:CC:11:22:33)" + +#: htdocs/luci-static/resources/device-manager/group-dialog.js:62 +#: htdocs/luci-static/resources/device-manager/group-dialog.js:168 +#: htdocs/luci-static/resources/device-manager/service.js:65 +msgid "Enter a valid group name" +msgstr "请输入有效的分组名称" + +#: htdocs/luci-static/resources/device-manager/model.js:28 +msgid "Game console" +msgstr "游戏机" + +#: root/usr/share/rpcd/acl.d/luci-app-device-manager.json:3 +msgid "Grant access to LAN device manager" +msgstr "允许访问局域网设备管理" + +#: htdocs/luci-static/resources/view/device-manager/devices.js:75 +msgid "Group" +msgstr "分组" + +#: htdocs/luci-static/resources/device-manager/group-dialog.js:82 +msgid "Group \"%s\" created." +msgstr "成功创建分组【%s】!" + +#: htdocs/luci-static/resources/device-manager/group-dialog.js:222 +msgid "Group \"%s\" deleted. Its devices are now ungrouped." +msgstr "分组【%s】已删除,所属设备已转为未分组。" + +#: htdocs/luci-static/resources/device-manager/group-dialog.js:104 +msgid "Group ID" +msgstr "分组标识" + +#: htdocs/luci-static/resources/device-manager/group-dialog.js:103 +msgid "Group name" +msgstr "分组名称" + +#: htdocs/luci-static/resources/device-manager/group-dialog.js:115 +msgid "Group names support Unicode text and common symbols" +msgstr "分组名称支持中文、英文及常用符号,稳定保存" + +#: htdocs/luci-static/resources/device-manager/group-dialog.js:184 +msgid "Group renamed to \"%s\"." +msgstr "分组已成功重命名为【%s】!" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:75 +msgid "Hardware address; colon or hyphen separators are accepted" +msgstr "设备的物理硬件地址,支持冒号或短横线分隔(不区分大小写)" + +#: htdocs/luci-static/resources/view/device-manager/devices.js:141 +#: htdocs/luci-static/resources/view/device-manager/devices.js:221 +msgid "Hide info" +msgstr "隐藏信息" + +#: htdocs/luci-static/resources/view/device-manager/devices.js:135 +msgid "Hide/Show MAC and IPv6" +msgstr "隐藏或显示 MAC 和 IPv6 地址" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:308 +msgid "Host hints" +msgstr "主机提示" + +#: htdocs/luci-static/resources/view/device-manager/devices.js:73 +msgid "IP address" +msgstr "IP 地址" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:321 +msgid "IPv4 addresses" +msgstr "IPv4 地址" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:321 +msgid "IPv6 addresses" +msgstr "IPv6 地址" + +#: htdocs/luci-static/resources/device-manager/service.js:59 +msgid "Input is too long" +msgstr "输入内容超出长度限制" + +#: htdocs/luci-static/resources/device-manager/service.js:21 +#: htdocs/luci-static/resources/device-manager/service.js:42 +#: htdocs/luci-static/resources/device-manager/service.js:100 +#: htdocs/luci-static/resources/device-manager/service.js:119 +msgid "Invalid discovery response" +msgstr "设备发现接口返回无效数据" + +#: htdocs/luci-static/resources/device-manager/model.js:274 +msgid "Known device without recent activity evidence" +msgstr "已知设备,缺少近期活动证据" + +#: htdocs/luci-static/resources/view/device-manager/devices.js:87 +msgid "LAN Device Management" +msgstr "局域网设备管理" + +#: htdocs/luci-static/resources/device-manager/service.js:80 +msgid "LAN scanning is unavailable. Check the selected interfaces and probe dependencies." +msgstr "局域网扫描不可用,请检查所选接口及探测依赖。" + +#: htdocs/luci-static/resources/device-manager/model.js:19 +msgid "Laptop" +msgstr "笔记本" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:146 +msgid "Leave empty to display the default hostname" +msgstr "自定义设备显示名称。若留空则恢复默认主机名" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:70 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:219 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:319 +#: htdocs/luci-static/resources/view/device-manager/devices.js:74 +msgid "MAC address" +msgstr "MAC 地址" + +#: htdocs/luci-static/resources/view/device-manager/devices.js:126 +msgid "Manage groups" +msgstr "分组管理" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:323 +msgid "Manual" +msgstr "手动指定" + +#: htdocs/luci-static/resources/device-manager/model.js:24 +msgid "NAS" +msgstr "网络存储" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:328 +msgid "Neighbor records" +msgstr "邻居记录" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:309 +msgid "Neighbor table" +msgstr "邻居表" + +#: htdocs/luci-static/resources/device-manager/model.js:40 +msgid "Network device" +msgstr "网络设备" + +#: htdocs/luci-static/resources/device-manager/model.js:13 +msgid "Network devices" +msgstr "网络设备" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:322 +msgid "Network interfaces" +msgstr "网络接口" + +#: htdocs/luci-static/resources/device-manager/model.js:263 +msgid "Network neighbor probe failed (FAILED)" +msgstr "网络邻居探测失败 (FAILED)" + +#: htdocs/luci-static/resources/device-manager/group-dialog.js:111 +msgid "New group" +msgstr "新增分组" + +#: htdocs/luci-static/resources/device-manager/group-dialog.js:152 +msgid "New group name" +msgstr "新分组名称" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:324 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:325 +msgid "No" +msgstr "否" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:290 +msgid "No ICMP reply was received. The device may block Ping; this does not confirm it is offline." +msgstr "未收到 ICMP 回复。设备可能禁用 Ping,不能据此确认设备已离线。" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:287 +msgid "No Ping reply" +msgstr "Ping 未响应" + +#: htdocs/luci-static/resources/device-manager/table.js:136 +msgid "No devices found. Refresh the list to try again." +msgstr "暂无局域网设备数据,点击右上角【刷新】重试或【+ 手动添加设备】" + +#: htdocs/luci-static/resources/device-manager/table.js:146 +msgid "No devices in this group" +msgstr "当前分组下暂无设备记录" + +#: htdocs/luci-static/resources/device-manager/table.js:138 +msgid "No devices match \"%s\"" +msgstr "未找到与 “%s” 匹配的设备记录" + +#: htdocs/luci-static/resources/device-manager/table.js:144 +msgid "No devices with unknown status" +msgstr "当前没有状态未知的设备" + +#: htdocs/luci-static/resources/device-manager/service.js:88 +msgid "No known IP address for this device" +msgstr "暂无该设备的已知 IP 地址,无法 Ping。" + +#: htdocs/luci-static/resources/device-manager/table.js:142 +msgid "No offline devices" +msgstr "当前没有离线设备" + +#: htdocs/luci-static/resources/device-manager/table.js:140 +msgid "No online devices" +msgstr "当前没有在线设备" + +#: htdocs/luci-static/resources/device-manager/model.js:270 +msgid "No recent activity confirmed (%s)" +msgstr "无法确认近期活动 (%s)" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:47 +msgid "Not detected" +msgstr "未检测到" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:8 +#: htdocs/luci-static/resources/device-manager/table.js:190 +msgid "Offline" +msgstr "离线" + +#: htdocs/luci-static/resources/device-manager/table.js:25 +msgid "Offline devices" +msgstr "离线设备" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:8 +#: htdocs/luci-static/resources/device-manager/table.js:187 +msgid "Online" +msgstr "在线" + +#: htdocs/luci-static/resources/device-manager/table.js:23 +msgid "Online devices" +msgstr "在线设备" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:287 +msgid "Online · Ping replied" +msgstr "在线 · Ping 已响应" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:139 +msgid "Organize this device by purpose or location" +msgstr "为设备指定业务或摆放区域分类" + +#: htdocs/luci-static/resources/device-manager/model.js:14 +msgid "Other devices" +msgstr "其他设备" + +#: htdocs/luci-static/resources/device-manager/model.js:20 +msgid "Phone" +msgstr "手机" + +#: htdocs/luci-static/resources/device-manager/model.js:11 +msgid "Phones" +msgstr "手机设备" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:306 +msgid "Ping again" +msgstr "重新 Ping" + +#: htdocs/luci-static/resources/device-manager/service.js:95 +msgid "Ping could not be completed" +msgstr "Ping 执行失败。" + +#: htdocs/luci-static/resources/device-manager/service.js:90 +msgid "Ping is unavailable" +msgstr "路由器未提供 Ping 命令。" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:274 +msgid "Ping output" +msgstr "查看 Ping 输出" + +#: htdocs/luci-static/resources/device-manager/model.js:25 +msgid "Printer" +msgstr "打印机" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:289 +msgid "Probe address: %s" +msgstr "探测地址:%s" + +#: htdocs/luci-static/resources/view/device-manager/devices.js:159 +msgid "Probe devices on the configured LAN interfaces" +msgstr "探测所配置的局域网接口中的设备" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:294 +msgid "Probe unavailable" +msgstr "无法完成探测" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:270 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:283 +msgid "Probing…" +msgstr "正在探测…" + +#: htdocs/luci-static/resources/device-manager/service.js:77 +#: htdocs/luci-static/resources/device-manager/service.js:149 +msgid "Read-only access" +msgstr "仅有只读权限" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:153 +msgid "Record the location, purpose or owner of this device" +msgstr "记录设备位置、用途、所有者等备注信息" + +#: htdocs/luci-static/resources/view/device-manager/devices.js:154 +msgid "Refresh list" +msgstr "刷新列表" + +#: htdocs/luci-static/resources/device-manager/model.js:43 +msgid "Refrigerator" +msgstr "冰箱" + +#: htdocs/luci-static/resources/view/device-manager/devices.js:77 +msgid "Remarks" +msgstr "备注" + +#: htdocs/luci-static/resources/device-manager/group-dialog.js:28 +msgid "Rename" +msgstr "重命名" + +#: htdocs/luci-static/resources/device-manager/group-dialog.js:190 +msgid "Rename failed: %s" +msgstr "重命名失败: %s" + +#: htdocs/luci-static/resources/device-manager/group-dialog.js:144 +msgid "Rename group" +msgstr "重命名分组" + +#: htdocs/luci-static/resources/device-manager/group-dialog.js:155 +msgid "Renaming preserves the group ID and all device assignments" +msgstr "重命名不会改变分组 ID,已关联该分组的设备保持关联不变" + +#: htdocs/luci-static/resources/device-manager/model.js:29 +msgid "Router" +msgstr "路由器" + +#: htdocs/luci-static/resources/view/device-manager/devices.js:68 +msgid "Row number" +msgstr "序号" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:201 +msgid "Save" +msgstr "保存" + +#: htdocs/luci-static/resources/device-manager/group-dialog.js:198 +msgid "Save changes" +msgstr "保存修改" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:193 +msgid "Save failed: %s" +msgstr "保存失败: %s" + +#: htdocs/luci-static/resources/device-manager/model.js:268 +msgid "Saved device not found in the current network" +msgstr "未在当前网络发现(已保存记录)" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:324 +msgid "Saved record" +msgstr "已保存记录" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:250 +msgid "Saved record for \"%s\" cleared." +msgstr "设备【%s】的保存记录已清空。" + +#: htdocs/luci-static/resources/view/device-manager/devices.js:169 +msgid "Scan LAN" +msgstr "扫描局域网" + +#: htdocs/luci-static/resources/view/device-manager/devices.js:102 +msgid "Search names, IP, MAC, remarks or groups..." +msgstr "搜索设备名称、主机名、IP、MAC、备注、分组..." + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:284 +msgid "Sending one Ping from the router to this device." +msgstr "正在从路由器向该设备发送一次 Ping。" + +#: htdocs/luci-static/resources/device-manager/model.js:35 +msgid "Sensor" +msgstr "传感器" + +#: htdocs/luci-static/resources/device-manager/model.js:32 +msgid "Server" +msgstr "服务器" + +#: htdocs/luci-static/resources/view/device-manager/devices.js:141 +#: htdocs/luci-static/resources/view/device-manager/devices.js:221 +msgid "Show info" +msgstr "显示信息" + +#: htdocs/luci-static/resources/device-manager/model.js:10 +#: htdocs/luci-static/resources/device-manager/model.js:36 +msgid "Smart home" +msgstr "智能家居" + +#: htdocs/luci-static/resources/device-manager/model.js:34 +msgid "Smart light" +msgstr "智能灯" + +#: htdocs/luci-static/resources/device-manager/model.js:33 +msgid "Smart plug" +msgstr "智能插座" + +#: htdocs/luci-static/resources/view/device-manager/devices.js:226 +msgid "Some device data could not be read. Unconfirmed devices are shown as unknown: %s" +msgstr "部分设备数据读取失败,无法确认的设备显示为状态未知:%s" + +#: htdocs/luci-static/resources/device-manager/model.js:265 +msgid "Some discovery sources are unavailable" +msgstr "部分设备发现数据源不可用" + +#: htdocs/luci-static/resources/device-manager/model.js:27 +msgid "Speaker" +msgstr "音箱" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:327 +msgid "Status evidence" +msgstr "状态依据" + +#: htdocs/luci-static/resources/device-manager/model.js:30 +msgid "Switch" +msgstr "交换机" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:219 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:319 +msgid "System hostname" +msgstr "系统主机名" + +#: htdocs/luci-static/resources/device-manager/model.js:22 +msgid "TV" +msgstr "电视" + +#: htdocs/luci-static/resources/device-manager/model.js:23 +msgid "TV box" +msgstr "电视盒子" + +#: htdocs/luci-static/resources/device-manager/model.js:21 +msgid "Tablet" +msgstr "平板" + +#: htdocs/luci-static/resources/device-manager/group-dialog.js:209 +msgid "The %d devices in this group will become ungrouped. Their names, MAC addresses and remarks will be preserved." +msgstr "删除分组后,该组下的 %d 台设备将自动转为【未分组】。设备名称、MAC 及备注将被完整保留,绝不删除设备。" + +#: htdocs/luci-static/resources/device-manager/service.js:72 +msgid "This group no longer exists" +msgstr "该分组已不存在" + +#: htdocs/luci-static/resources/view/device-manager/devices.js:71 +msgid "Type" +msgstr "类型" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:323 +msgid "Type detection" +msgstr "类型识别" + +#: htdocs/luci-static/resources/device-manager/service.js:92 +msgid "Unable to read the kernel neighbor table" +msgstr "无法读取内核邻居表。" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:48 +msgid "Unavailable" +msgstr "未获取" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:82 +#: htdocs/luci-static/resources/device-manager/model.js:130 +#: htdocs/luci-static/resources/device-manager/table.js:82 +msgid "Ungrouped" +msgstr "未分组" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:8 +#: htdocs/luci-static/resources/device-manager/model.js:46 +#: htdocs/luci-static/resources/device-manager/table.js:26 +#: htdocs/luci-static/resources/device-manager/table.js:183 +msgid "Unknown" +msgstr "状态未知" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:24 +#: htdocs/luci-static/resources/device-manager/table.js:179 +msgid "Unknown device" +msgstr "未知设备" + +#: htdocs/luci-static/resources/device-manager/model.js:39 +msgid "VR headset" +msgstr "VR 设备" + +#: htdocs/luci-static/resources/device-manager/model.js:38 +msgid "Vehicle" +msgstr "汽车" + +#: htdocs/luci-static/resources/device-manager/table.js:256 +msgid "View" +msgstr "查看" + +#: htdocs/luci-static/resources/device-manager/model.js:42 +msgid "Washing machine" +msgstr "洗衣机" + +#: htdocs/luci-static/resources/device-manager/model.js:37 +msgid "Watch" +msgstr "手表" + +#: htdocs/luci-static/resources/device-manager/model.js:44 +msgid "Water purifier" +msgstr "净水器" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:308 +msgid "Wi-Fi" +msgstr "Wi-Fi 连接" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:324 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:325 +msgid "Yes" +msgstr "是" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:64 +msgid "e.g. AA:BB:CC:11:22:33" +msgstr "例如:AA:BB:CC:11:22:33" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:113 +msgid "e.g. Living room TV" +msgstr "例如:55寸安卓电视,安装在客厅" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:105 +msgid "e.g. Living room TV, work computer" +msgstr "例如:客厅电视、主力工作电脑" + +#: htdocs/luci-static/resources/device-manager/group-dialog.js:46 +msgid "e.g. Media devices, office devices" +msgstr "例如:智能影音、办公设备" diff --git a/luci-app-device-manager/root/etc/config/device_manager b/luci-app-device-manager/root/etc/config/device_manager new file mode 100644 index 000000000..026099593 --- /dev/null +++ b/luci-app-device-manager/root/etc/config/device_manager @@ -0,0 +1,21 @@ +# LAN Device Manager configuration +# Stores custom device names, remarks, and groups mapped by MAC address. + +config group 'smart_home' + option name 'Smart home' + +config group 'phone' + option name 'Phones' + +config group 'computer' + option name 'Computers' + +config group 'network' + option name 'Network devices' + +config group 'other' + option name 'Other devices' + +# Logical netifd interfaces eligible for the explicit Scan LAN operation. +config settings 'settings' + list scan_interface 'lan' diff --git a/luci-app-device-manager/root/usr/libexec/rpcd/luci.device-manager b/luci-app-device-manager/root/usr/libexec/rpcd/luci.device-manager new file mode 100755 index 000000000..55890f263 --- /dev/null +++ b/luci-app-device-manager/root/usr/libexec/rpcd/luci.device-manager @@ -0,0 +1,272 @@ +#!/bin/sh +# SPDX-License-Identifier: MIT + +umask 077 + +# A nonblocking kernel lock covers all rpcd clients and is released on exit, +# including forced termination. Keep the lock file in place to preserve its inode. +probe_neighbors() ( + set -f + command -v ping >/dev/null 2>&1 && command -v flock >/dev/null 2>&1 || exit 2 + flock -n 9 || exit 0 + + read -r uptime rest < /proc/uptime + now="${uptime%%.*}" + last=""; cursor="" + read -r last cursor < /tmp/luci-device-manager-probe.state 2>/dev/null + case "$last" in ''|*[!0-9]*) last=0 ;; esac + case "$cursor" in ''|*[!0-9]*) cursor=0 ;; esac + if [ "$last" -gt 0 ] && [ "$now" -ge "$last" ] && [ "$((now-last))" -lt 30 ]; then + exit 0 + fi + + # Resolve only explicitly selected logical LAN interfaces through netifd. + # Never infer a LAN from connected routes or invent a /24 fallback. + interfaces="$(uci -q get device_manager.settings.scan_interface 2>/dev/null || printf lan)" + lan_subnets="$( + selected=0 + for interface in $interfaces; do + case "$interface" in ''|*[!a-zA-Z0-9_]*) continue ;; esac + selected=$((selected+1)) + [ "$selected" -le 8 ] || break + status="$(ubus call "network.interface.$interface" status 2>/dev/null)" || continue + json_load "$status" || continue + json_get_var device l3_device + case "$device" in ''|*[!a-zA-Z0-9_.:-]*) continue ;; esac + ip -o -4 addr show dev "$device" 2>/dev/null | + awk -v dev="$device" '$3=="inet" {print $4, dev}' + done | sort -u + )" + [ -n "$lan_subnets" ] || exit 2 + # Bind every target to its LAN device. Exclude router/network/broadcast + # addresses. Subnets exceeding 4096 addresses use known hosts only. + targets="$( + { + printf '%s\n' "$lan_subnets" | awk '{print "N", $1, $2}' + awk '{print "H", $1, $6}' /proc/net/arp 2>/dev/null + ip neigh show 2>/dev/null | awk '{dev=""; for(i=2;i/dev/null + } | awk ' + function number(ip, a,n,i,v) { + n=split(ip,a,"."); if(n!=4) return -1 + v=0; for(i=1;i<=4;i++) {if(a[i]!~/^[0-9]+$/ || a[i]>255) return -1; v=v*256+a[i]} + if(a[1]==0 || a[1]==127 || a[1]>=224) return -1 + return v + } + function literal(v, a,b,c) { + a=int(v/16777216); b=int(v/65536)%256; c=int(v/256)%256 + return a "." b "." c "." v%256 + } + function emit(v,i, key) { + key=v SUBSEP dev[i] + if(!own[v] && !seen[key]++) print literal(v), dev[i] + } + $1=="N" { + split($2,a,"/"); v=number(a[1]); p=a[2] + if(v<0 || p!~/^[0-9]+$/ || p>32 || count>=64) next + count++; size[count]=1; for(j=0;j<32-p;j++) size[count]*=2 + lo[count]=int(v/size[count])*size[count] + hi[count]=lo[count]+size[count]-1; dev[count]=$3; own[v]=1 + if(p<31) {lo[count]++; hi[count]--} + next + } + $1=="H" { + v=number($2); if(v<0) next + for(i=1;i<=count;i++) if(v>=lo[i] && v<=hi[i] && ($3=="" || $3==dev[i])) emit(v,i) + } + END { + for(i=1;i<=count;i++) if(size[i]<=4096) for(v=lo[i];v<=hi[i];v++) emit(v,i) + }' + )" || exit 2 + [ -n "$targets" ] || exit 0 + count="$(printf '%s\n' "$targets" | awk 'END {print NR}')" + [ "$cursor" -lt "$count" ] || cursor=0 + targets="$(printf '%s\n' "$targets" | awk -v offset="$cursor" ' + {ips[NR-1]=$0} + END {for (i=0; i/dev/null 2>&1 & + processed=$((processed+1)) + pending=$((pending+1)) + if [ "$pending" -ge 16 ]; then + wait 2>/dev/null + pending=0 + fi + done </dev/null + read -r uptime rest < /proc/uptime + printf '%s %s\n' "${uptime%%.*}" "$(((cursor+processed)%count))" > /tmp/luci-device-manager-probe.state +) 9>/tmp/luci-device-manager-probe.lock 2>/dev/null + +ping_error() { + json_init + json_add_boolean ok 0 + json_add_string error "$1" + json_dump +} + +# Resolve the target on the router; callers provide a MAC, never a shell command +# or an arbitrary destination. One global lock bounds simultaneous detail probes. +ping_device() ( + command -v flock >/dev/null 2>&1 || { ping_error 'Ping is unavailable'; exit 0; } + flock -n 9 || { ping_error 'Another device is being probed. Please retry.'; exit 0; } + json_load "$(cat)" || { ping_error 'Enter a valid MAC address'; exit 0; } + json_get_var mac mac + mac="$(printf '%s' "$mac" | tr 'A-F' 'a-f')" + if ! printf '%s\n' "$mac" | awk -F: ' + NF != 6 {exit 1} + {for (i=1; i<=6; i++) if ($i !~ /^[0-9a-f][0-9a-f]$/) exit 1} + $0 == "00:00:00:00:00:00" || $0 == "ff:ff:ff:ff:ff:ff" {exit 1} + '; then + ping_error 'Enter a valid MAC address'; exit 0 + fi + neighbors="$(ip neigh show 2>/dev/null)" || { ping_error 'Unable to read the kernel neighbor table'; exit 0; } + candidates="$( + { + printf '%s\n' "$neighbors" | awk -v mac="$mac" ' + { + owner=""; dev=""; active=0 + for (i=2; i<=NF; i++) { + if ($i=="lladdr") owner=tolower($(i+1)) + if ($i=="dev") dev=$(i+1) + if ($i=="REACHABLE") active=1 + } + if (owner==mac) { + priority=($1 ~ /:/) ? (tolower($1) ~ /^fe80:/ ? 4 : 3) : (active ? 0 : 2) + print priority, $1, dev + } + }' + awk -v mac="$mac" 'tolower($2)==mac {print 1, $3}' /tmp/dhcp.leases /var/dhcp.leases 2>/dev/null + } | sort -n -k1,1 + )" + address=""; iface="" + while read -r priority candidate candidate_iface; do + # Only numeric IP literals and kernel interface names reach ping. + if printf '%s\n' "$candidate" | awk ' + /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/ {split($0,a,"."); for(i=1;i<=4;i++) if(a[i]>255) exit 1; exit 0} + /^[0-9a-fA-F:]+$/ && /:/ {exit 0} + {exit 1}'; then + case "$candidate_iface" in *[!a-zA-Z0-9_.:-]*) continue ;; esac + # A stale lease must not cause another device at a reassigned IP to + # be reported as this MAC. Scope link-local ownership by interface. + if ! printf '%s\n' "$neighbors" | awk -v address="$candidate" -v mac="$mac" -v iface="$candidate_iface" ' + $1==address { + owner=""; dev="" + for(i=2;i<=NF;i++) { + if($i=="lladdr") owner=tolower($(i+1)) + if($i=="dev") dev=$(i+1) + } + if(owner!="" && owner!=mac && (iface=="" || iface==dev)) exit 1 + }'; then + continue + fi + address="$candidate"; iface="$candidate_iface"; break + fi + done </dev/null 2>&1; then + ping_command=ping6 + else + ping_command=ping + set -- -6 "$@" + fi ;; + *) ping_command=ping ;; + esac + command -v "$ping_command" >/dev/null 2>&1 || { ping_error 'Ping is unavailable'; exit 0; } + output="$("$ping_command" "$@" "$address" 2>&1)" + result=$? + json_init + json_add_string ip "$address" + json_add_string interface "$iface" + json_add_string output "$output" + if [ "$result" -eq 0 ] || [ "$result" -eq 1 ]; then + json_add_boolean ok 1 + json_add_boolean reachable "$((1-result))" + else + json_add_boolean ok 0 + json_add_string error 'Ping could not be completed' + fi + json_dump +) 9>/tmp/luci-device-manager-single-ping.lock 2>/dev/null + +case "$1:$2" in + list:*) + printf '{"get_online_status":{},"scan_devices":{},"ping_device":{"mac":"string"}}\n' + ;; + call:ping_device) + . /usr/share/libubox/jshn.sh + ping_device + ;; + call:scan_devices) + . /usr/share/libubox/jshn.sh + probe_neighbors + result=$? + json_init + if [ "$result" -eq 0 ]; then + json_add_boolean ok 1 + else + json_add_boolean ok 0 + json_add_string error 'LAN scanning is unavailable. Check the selected interfaces and probe dependencies.' + fi + json_dump + ;; + call:get_online_status) + . /usr/share/libubox/jshn.sh + json_init + if ! neighbors="$(ip neigh show 2>/dev/null)"; then + json_add_boolean ok 0 + json_add_string error 'Unable to read the kernel neighbor table' + json_dump + exit 0 + fi + json_add_boolean ok 1 + json_add_array neighbors + # A here document keeps parsing in this shell. jshn escapes all JSON strings. + while read -r address rest; do + [ -n "$address" ] || continue + mac=""; state=""; device=""; previous="" + set -f + for word in $rest; do + case "$previous" in + dev) device="$word" ;; + lladdr) mac="$word" ;; + esac + case "$word" in + REACHABLE|DELAY|PROBE|STALE|FAILED|INCOMPLETE|PERMANENT|NOARP|NONE) state="$word" ;; + esac + previous="$word" + done + [ -n "$device" ] || continue + # FAILED and INCOMPLETE commonly have no lladdr. Preserve their IP/state. + json_add_object '' + json_add_string ip "$address" + json_add_string mac "$mac" + json_add_string dev "$device" + json_add_string state "$state" + json_close_object + done < "$AUDIT_JSON_LOG"; } +json_add_boolean() { printf 'boolean\0%s\0%s\0' "$1" "$2" >> "$AUDIT_JSON_LOG"; } +json_add_string() { printf 'string\0%s\0%s\0' "$1" "$2" >> "$AUDIT_JSON_LOG"; } +json_add_array() { printf 'array\0%s\0\0' "$1" >> "$AUDIT_JSON_LOG"; } +json_add_object() { printf 'object\0%s\0\0' "$1" >> "$AUDIT_JSON_LOG"; } +json_close_array() { printf 'close\0\0\0' >> "$AUDIT_JSON_LOG"; } +json_close_object() { printf 'close\0\0\0' >> "$AUDIT_JSON_LOG"; } +json_dump() { "$AUDIT_NODE" "$AUDIT_JSON_ENCODER" "$AUDIT_JSON_LOG"; } +json_load() { + AUDIT_JSON_INPUT="$1" + export AUDIT_JSON_INPUT + "$AUDIT_NODE" -e 'JSON.parse(process.env.AUDIT_JSON_INPUT)' >/dev/null 2>&1 +} +json_get_var() { + # Match jshn assignments without losing embedded newlines or evaluating input. + eval "$("$AUDIT_NODE" -e ' + const value=JSON.parse(process.env.AUDIT_JSON_INPUT)[process.argv[2]]; + const text=typeof value==="string" ? value : ""; + const quote=String.fromCharCode(39); + process.stdout.write(process.argv[1]+"="+quote+text.split(quote).join(quote+"\\"+quote+quote)+quote); + ' "$1" "$2")" +} diff --git a/luci-app-device-manager/test/helpers/json-encoder.js b/luci-app-device-manager/test/helpers/json-encoder.js new file mode 100644 index 000000000..b02500b04 --- /dev/null +++ b/luci-app-device-manager/test/helpers/json-encoder.js @@ -0,0 +1,15 @@ +'use strict'; +const fs = require('node:fs'); +const fields = fs.readFileSync(process.argv[2], 'utf8').split('\0'); +const root = {}, stack = [root]; +for (let i = 0; i + 2 < fields.length; i += 3) { + const [type, key, raw] = fields.slice(i, i + 3); + if (type === 'init') continue; + if (type === 'close') { stack.pop(); continue; } + const value = type === 'array' ? [] : type === 'object' ? {} : type === 'boolean' ? raw === '1' : raw; + const current = stack[stack.length - 1]; + if (Array.isArray(current)) current.push(value); + else current[key] = value; + if (type === 'array' || type === 'object') stack.push(value); +} +process.stdout.write(JSON.stringify(root) + '\n'); diff --git a/luci-app-device-manager/test/helpers/runtime.js b/luci-app-device-manager/test/helpers/runtime.js new file mode 100644 index 000000000..87672ada7 --- /dev/null +++ b/luci-app-device-manager/test/helpers/runtime.js @@ -0,0 +1,155 @@ +'use strict'; +const fs = require('fs'); +const path = require('path'); +const root = path.resolve(__dirname, '../..'); + +String.prototype.format = function(...values) { + let index = 0; + return this.replace(/%[sd]/g, () => String(values[index++])); +}; + +class Node extends EventTarget { + constructor(tag, attrs = {}) { + super(); + this.tag = tag; + this.attrs = attrs; + this.children = []; + this.style = {}; + // LuCI sets every non-null attribute; even disabled="false" disables a button. + this.disabled = attrs.disabled != null; + this.selected = false; + this._value = attrs.value; + this.classes = new Set((attrs.class || '').split(/\s+/)); + this.classList = { add: name => this.classes.add(name), remove: name => this.classes.delete(name) }; + for (const [name, value] of Object.entries(attrs)) { + if (typeof value === 'function') this.addEventListener(name, event => { this.operation = value(event); }); + } + } + appendChild(child) { this.children.push(child); return child; } + get value() { + if (this._value != null) return this._value; + if (this.tag === 'select') { + const option = this.children.find(child => child.selected) || this.children[0]; + return option ? option.attrs.value : ''; + } + return ''; + } + set value(value) { this._value = value; } + get textContent() { return this.children.map(child => child instanceof Node ? child.textContent : String(child)).join(''); } + set textContent(value) { this.children = [String(value)]; } + querySelectorAll(selector) { + return this.children.filter(child => child instanceof Node).flatMap(child => [child, ...child.querySelectorAll('*')]) + .filter(child => selector === '*' || selector === child.tag || + (selector.startsWith('#') && child.attrs.id === selector.slice(1)) || + (selector.startsWith('.') && child.classes.has(selector.slice(1)))); + } + querySelector(selector) { return this.querySelectorAll(selector)[0] || null; } + focus() { this.focused = true; } + getAttribute(name) { return this.attrs[name]; } + click() { + if (this.disabled) return Promise.resolve(); + this.dispatchEvent(new Event('click')); + return Promise.resolve(this.operation); + } +} + +function environment(extra = {}) { + const htmlSinks = [], notifications = []; + const E = (tag, attrs, children) => { + if (!attrs || typeof attrs !== 'object' || Array.isArray(attrs)) { children = attrs; attrs = {}; } + const node = new Node(tag, attrs); + if (Array.isArray(children)) children.forEach(child => node.appendChild(child)); + else if (children instanceof Node) node.appendChild(children); + else if (children != null) { + // Match LuCI dom.append: scalar content uses innerHTML, array items use text nodes. + htmlSinks.push(String(children)); + node.appendChild(String(children)); + } + return node; + }; + const dom = { content: (node, children) => { + node.children = []; + if (Array.isArray(children)) children.forEach(child => node.appendChild(child)); + else if (children != null) node.appendChild(children); + } }; + const ui = { + modal: null, + showModal(title, children) { this.modal = E('modal', { title }, children); }, + hideModal() { ui.modal = null; }, + addNotification(title, content, level) { notifications.push({ content, level }); } + }; + return Object.assign({ + baseclass: { extend: value => value }, view: { extend: value => value }, E, dom, ui, + rpc: { declare: () => () => Promise.resolve({ language: 'auto' }) }, + _: value => value, htmlSinks, notifications, + L: { hasViewPermission: () => true, resource: value => '/resources/' + value }, + window: { localStorage: { getItem: () => null, setItem() {} } } + }, extra); +} + +function loadModule(name, env, cache = new Map()) { + if (cache.has(name)) return cache.get(name); + const file = path.join(root, 'htdocs/luci-static/resources', name.replace(/\./g, '/') + '.js'); + const source = fs.readFileSync(file, 'utf8'); + const dependencies = Object.assign({}, env); + for (const match of source.matchAll(/'require ([\w.-]+)(?: as (\w+))?';/g)) { + const [, dependency, alias] = match; + dependencies[alias || dependency] = env[dependency] || loadModule(dependency, env, cache); + } + const result = new Function(...Object.keys(dependencies), source)(...Object.values(dependencies)); + cache.set(name, result); + return result; +} + +function backend(initial = []) { + const clone = value => JSON.parse(JSON.stringify(value)); + const state = { saved: clone(initial), staged: null, cached: null, reads: 0, commits: 0, reverts: 0, calls: [], declarations: [] }; + const uci = { + unload() { state.cached = null; }, + load() { + if (!state.cached) { state.reads++; state.cached = clone(state.staged || state.saved); } + return Promise.resolve(); + }, + sections(config, type) { return state.cached.filter(section => section['.type'] === type); }, + get(config, id) { return state.cached.find(section => section['.name'] === id) || null; }, + add(config, type, id) { state.cached.push({ '.type': type, '.name': id }); return id; }, + set(config, id, key, value) { uci.get(config, id)[key] = value; }, + unset(config, id, key) { delete uci.get(config, id)[key]; }, + remove(config, id) { state.cached = state.cached.filter(section => section['.name'] !== id); }, + save() { + state.staged = clone(state.cached); + return state.failSave ? Promise.reject(new Error('save failed')) : Promise.resolve(); + } + }; + const replies = { + 'luci-rpc.getHostHints': {}, + 'luci-rpc.getDHCPLeases': { dhcp_leases: [], dhcp6_leases: [] }, + 'luci-rpc.getWirelessDevices': {}, + 'luci.device-manager.get_online_status': { ok: true, neighbors: [] }, + 'luci.device-manager.scan_devices': { ok: true }, + 'luci.device-manager.ping_device': { ok: true, ip: '192.168.1.20', interface: 'br-lan', reachable: true, output: '1 packets transmitted, 1 packets received' }, + 'iwinfo.assoclist': { results: [] } + }; + const rpc = { declare(options) { + state.declarations.push(options); + return (...args) => { + const key = options.object + '.' + options.method; + state.calls.push({ key, args }); + if (key === 'uci.commit') { + if (state.failCommit) return options.reject ? Promise.reject(new Error('commit failed')) : Promise.resolve(6); + state.saved = clone(state.staged || state.saved); state.staged = null; state.commits++; + return Promise.resolve({}); + } + if (key === 'uci.revert') { + state.reverts++; + if (state.failRevert) return Promise.reject(new Error('revert failed')); + state.staged = null; return Promise.resolve({}); + } + const result = typeof replies[key] === 'function' ? replies[key](...args) : replies[key]; + return result instanceof Error ? Promise.reject(result) : Promise.resolve(clone(result)); + }; + } }; + return { state, uci, rpc, replies, fs: { read: () => Promise.resolve('IP address HW type Flags HW address Mask Device\n') } }; +} + +module.exports = { root, Node, environment, loadModule, backend }; diff --git a/luci-app-device-manager/test/test_details.js b/luci-app-device-manager/test/test_details.js new file mode 100644 index 000000000..5bc4c88ff --- /dev/null +++ b/luci-app-device-manager/test/test_details.js @@ -0,0 +1,116 @@ +'use strict'; +const assert = require('node:assert/strict'); +const test = require('node:test'); +const { environment, loadModule, backend } = require('./helpers/runtime'); +const mac = 'AA:BB:CC:11:22:33'; +const attack = ''; + +async function context() { + const mocks = backend([ + { '.type': 'group', '.name': 'home', name: 'Home' }, + { '.type': 'device', '.name': 'saved', mac, name: 'Living room', remark: attack, group: 'home', type: 'tv' } + ]); + mocks.replies['luci-rpc.getHostHints'] = { [mac]: { + name: 'television', ipaddrs: ['192.168.1.20', '192.168.1.21'], + ip6addrs: ['fd00::20', 'fe80::20'] + } }; + mocks.replies['luci.device-manager.get_online_status'] = { ok: true, neighbors: [ + { mac, ip: '192.168.1.20', dev: 'br-lan', state: 'REACHABLE' }, + { mac, ip: 'fd00::20', dev: 'br-lan', state: 'STALE' } + ] }; + const env = environment(mocks); + const page = loadModule('view.device-manager.devices', env); + const node = page.render(await page.load()); + return Object.assign({ env, page, node, dev: page.devices[0] }, mocks); +} + +test('details retain all known addresses and evidence and open with exactly one independent Ping', async () => { + const ctx = await context(); + const scans = ctx.state.calls.filter(call => call.key === 'luci.device-manager.get_online_status').length; + assert.deepEqual(ctx.dev.ipv4Addresses, ['192.168.1.20', '192.168.1.21']); + assert.deepEqual(ctx.dev.ipv6Addresses, ['fd00::20', 'fe80::20']); + assert.deepEqual(ctx.dev.interfaces, ['br-lan']); + await ctx.node.querySelector('.dm-action-detail').click(); + const modal = ctx.env.ui.modal; + assert.equal(modal.attrs.title, 'Device details'); + for (const value of ['Living room', 'television', '192.168.1.20', '192.168.1.21', 'fd00::20', 'fe80::20', mac, 'Home', attack, 'br-lan', 'REACHABLE', 'STALE']) { + assert.ok(modal.textContent.includes(value), value); + } + assert.equal(modal.querySelector('.dm-ping-status').textContent, 'Online · Ping replied'); + const calls = ctx.state.calls.filter(call => call.key === 'luci.device-manager.ping_device'); + assert.equal(calls.length, 1); assert.deepEqual(calls[0].args, [mac]); + assert.equal(ctx.state.calls.filter(call => call.key === 'luci.device-manager.get_online_status').length, scans); + assert.equal(ctx.state.commits, 0); assert.deepEqual(ctx.env.htmlSinks, []); +}); + +test('unanswered Ping is distinct from offline and retry does not overlap an in-flight request', async () => { + const ctx = await context(); + let release; + ctx.page.handlePingDevice = () => new Promise(resolve => { release = resolve; }); + const pending = ctx.page.showDetailModal(ctx.dev); + await Promise.resolve(); + const modal = ctx.env.ui.modal; + const button = modal.querySelectorAll('button').find(node => node.textContent === 'Ping again'); + assert.equal(button.disabled, true); + await button.click(); + release({ reachable: false, ip: '192.168.1.20', output: attack }); + await pending; + assert.equal(modal.querySelector('.dm-ping-status').textContent, 'No Ping reply'); + assert.ok(modal.textContent.includes('does not confirm it is offline')); + assert.equal(modal.querySelector('pre').textContent, attack); + assert.equal(ctx.dev.status, 'online'); assert.equal(button.disabled, false); + assert.deepEqual(ctx.env.htmlSinks, []); + ctx.page.handlePingDevice = () => Promise.resolve({ reachable: true, ip: '192.168.1.20', output: 'reply' }); + await button.click(); + assert.equal(modal.querySelector('.dm-ping-status').textContent, 'Online · Ping replied'); +}); + +test('missing addresses and RPC failures remain visible and restore the retry control', async () => { + const ctx = await context(); + for (const response of [{ ok: false, error: 'No known IP address for this device' }, new Error('RPC unavailable'), { ok: true }]) { + ctx.replies['luci.device-manager.ping_device'] = response; + await ctx.page.showDetailModal(ctx.dev); + const modal = ctx.env.ui.modal; + assert.equal(modal.querySelector('.dm-ping-status').textContent, 'Probe unavailable'); + assert.equal(modal.querySelectorAll('button').find(node => node.textContent === 'Ping again').disabled, false); + assert.equal(ctx.dev.status, 'online'); + } +}); + +test('late probe completion updates only its own content and never replaces a newer dialog', async () => { + const ctx = await context(); + let release; + ctx.page.handlePingDevice = () => new Promise(resolve => { release = resolve; }); + const pending = ctx.page.showDetailModal(ctx.dev); + await Promise.resolve(); + ctx.page.showEditModal(ctx.dev); + const editModal = ctx.env.ui.modal; + release({ reachable: true, ip: '192.168.1.20', output: 'reply' }); + await pending; + assert.equal(ctx.env.ui.modal, editModal); assert.equal(editModal.attrs.title, 'Edit device'); +}); + +test('read-only sessions can view details and probe without getting editing or clearing controls', async () => { + const ctx = await context(); + ctx.env.L.hasViewPermission = () => false; + ctx.page.readonly = true; + ctx.page.updateView(); + assert.equal(ctx.node.querySelector('.dm-action-edit'), null); + await ctx.node.querySelector('.dm-action-detail').click(); + assert.equal(ctx.env.ui.modal.attrs.title, 'Device details'); + assert.ok(!ctx.env.ui.modal.textContent.includes('Clear record')); + assert.equal(ctx.state.commits, 0); +}); + +test('clear record removes metadata after confirmation while discovered devices and groups remain', async () => { + const ctx = await context(); + ctx.page.showEditModal(ctx.dev); + const clear = ctx.env.ui.modal.querySelectorAll('button').find(node => node.textContent === 'Clear record'); + await clear.click(); + assert.equal(ctx.state.saved.length, 2, 'opening confirmation must not clear anything'); + await ctx.env.ui.modal.querySelectorAll('button').find(node => node.textContent === 'Confirm clear').click(); + assert.equal(ctx.state.saved.length, 1); assert.equal(ctx.state.saved[0]['.type'], 'group'); + const dev = ctx.page.devices.find(device => device.mac === mac); + assert.equal(dev.isSaved, false); assert.equal(dev.customName, ''); assert.equal(dev.remark, ''); + assert.equal(dev.group, 'ungrouped'); assert.equal(dev.hostname, 'television'); assert.equal(dev.customType, undefined); +}); diff --git a/luci-app-device-manager/test/test_device_icons.js b/luci-app-device-manager/test/test_device_icons.js new file mode 100644 index 000000000..1ddf224d4 --- /dev/null +++ b/luci-app-device-manager/test/test_device_icons.js @@ -0,0 +1,177 @@ +'use strict'; +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); +const { environment, loadModule, backend, root } = require('./helpers/runtime'); + +const EXPECTED_ICONS = [ + 'computer.svg', 'laptop.svg', 'phone.svg', 'tablet.svg', + 'tv.svg', 'tvbox.svg', 'nas.svg', 'printer.svg', + 'camera.svg', 'speaker.svg', 'game.svg', 'router.svg', + 'switch.svg', 'ap.svg', 'server.svg', 'plug.svg', + 'light.svg', 'sensor.svg', 'home.svg', 'watch.svg', + 'car.svg', 'vr.svg', 'network.svg', + 'airconditioner.svg', 'washer.svg', 'fridge.svg', + 'waterpurifier.svg', 'airpurifier.svg', + 'unknown.svg' +]; + +function pageContext(initial = []) { + const mocks = backend(initial); + const env = environment(mocks); + const cache = new Map(); + const model = loadModule('device-manager.model', env, cache); + const service = loadModule('device-manager.service', env, cache); + const page = loadModule('view.device-manager.devices', env, cache); + return Object.assign({ env, page, model, service }, mocks); +} + +test('all 29 distributed icons have one source and pinned attribution', () => { + const directory = path.join(root, 'htdocs/luci-static/resources/device-manager/device-icons'); + const manifest = JSON.parse(fs.readFileSync(path.join(directory, 'sources.json'))); + assert.match(manifest.revision, /^[a-f0-9]{40}$/); + for (const icon of EXPECTED_ICONS) { + const content = fs.readFileSync(path.join(directory, icon), 'utf8'); + assert.match(content, /]+xmlns="http:\/\/www\.w3\.org\/2000\/svg"/); + assert.match(content, /<\/svg>/); + assert.ok(manifest.icons[icon].source.includes(manifest.revision)); + } + assert.ok(fs.existsSync(path.join(directory, 'LICENSE'))); + assert.ok(fs.existsSync(path.join(directory, 'NOTICE'))); +}); + +test('vendor hints remain conservative and randomized MACs do not imply a vendor', () => { + const env = environment(); + const model = loadModule('device-manager.model', env); + + const cases = [ + { mac: '00:11:32:11:22:33', expected: 'nas' }, + { mac: '4C:FC:AA:11:22:33', expected: 'car' }, + { mac: '2C:26:17:11:22:33', expected: 'vr' }, + { mac: '00:1B:78:11:22:33', expected: 'unknown' }, + { mac: '10:12:FB:11:22:33', expected: 'camera' }, + { mac: '00:09:BF:11:22:33', expected: 'game' }, + { mac: '00:0E:58:11:22:33', expected: 'speaker' }, + { mac: 'F4:F5:E8:11:22:33', expected: 'unknown' }, + { mac: '00:1A:9A:11:22:33', expected: 'unknown' }, + { mac: '18:FE:34:11:22:33', expected: 'home' }, + { mac: '00:17:88:11:22:33', expected: 'light' }, + { mac: '54:EF:44:11:22:33', expected: 'home' }, + { mac: '10:2C:6B:11:22:33', expected: 'unknown' }, + { mac: '52:54:00:11:22:33', expected: 'unknown' }, + { mac: '00:15:6D:11:22:33', expected: 'network' }, + { mac: '00:0F:E2:11:22:33', expected: 'network' }, + { mac: '00:0C:42:11:22:33', expected: 'router' }, + { mac: 'B8:27:EB:11:22:33', expected: 'computer' }, + { mac: 'F0:18:98:11:22:33', expected: 'unknown' }, + { mac: '12:34:56:78:9A:BC', expected: 'unknown' } + ]; + + for (const c of cases) { + const detected = model.detectDeviceType(c.mac, '', '', ''); + assert.equal(detected, c.expected, `MAC ${c.mac} should be ${c.expected}, got ${detected}`); + const info = model.getTypeInfo(detected); + assert.equal(info.icon, detected + '.svg'); + } +}); + +test('detectDeviceType refines classification using hostname or custom name clues', () => { + const env = environment(); + const model = loadModule('device-manager.model', env); + + const appleMac = 'F0:18:98:11:22:33'; + assert.equal(model.detectDeviceType(appleMac, 'My-iPhone', '', ''), 'phone'); + assert.equal(model.detectDeviceType(appleMac, 'MacBook-Pro', '', ''), 'laptop'); + assert.equal(model.detectDeviceType(appleMac, 'iPad-Mini', '', ''), 'tablet'); + assert.equal(model.detectDeviceType(appleMac, 'Apple-Watch-Ultra', '', ''), 'watch'); + assert.equal(model.detectDeviceType(appleMac, 'Apple-TV-4K', '', ''), 'tvbox'); + assert.equal(model.detectDeviceType(appleMac, 'HomePod-LivingRoom', '', ''), 'speaker'); + + const genericMac = '12:34:56:78:9A:BC'; + assert.equal(model.detectDeviceType(genericMac, 'DiskStation', '', ''), 'nas'); + assert.equal(model.detectDeviceType(genericMac, 'Tesla-Model-Y', '', ''), 'car'); + assert.equal(model.detectDeviceType(genericMac, 'Oculus-Quest-3', '', ''), 'vr'); + assert.equal(model.detectDeviceType(genericMac, 'HP-LaserJet', '', ''), 'printer'); + assert.equal(model.detectDeviceType(genericMac, 'Hikvision-IPC', '', ''), 'camera'); + assert.equal(model.detectDeviceType(genericMac, 'PlayStation-5', '', ''), 'game'); + assert.equal(model.detectDeviceType(genericMac, 'USW-Lite-16-PoE', '', ''), 'switch'); + assert.equal(model.detectDeviceType(genericMac, 'UniFi-U6-Pro', '', ''), 'ap'); + assert.equal(model.detectDeviceType(genericMac, 'Gree-AirConditioner', '', ''), 'airconditioner'); + assert.equal(model.detectDeviceType(genericMac, 'LivingRoom-空调', '', ''), 'airconditioner'); + assert.equal(model.detectDeviceType(genericMac, 'LG-Washer', '', ''), 'washer'); + assert.equal(model.detectDeviceType(genericMac, 'Haier-洗衣机', '', ''), 'washer'); + assert.equal(model.detectDeviceType(genericMac, 'Samsung-Fridge', '', ''), 'fridge'); + assert.equal(model.detectDeviceType(genericMac, '厨房冰箱', '', ''), 'fridge'); + assert.equal(model.detectDeviceType(genericMac, 'Xiaomi-WaterPurifier', '', ''), 'waterpurifier'); + assert.equal(model.detectDeviceType(genericMac, '小米净水器', '', ''), 'waterpurifier'); + assert.equal(model.detectDeviceType(genericMac, 'Mi-AirPurifier-Pro', '', ''), 'airpurifier'); + assert.equal(model.detectDeviceType(genericMac, '卧室空气净化器', '', ''), 'airpurifier'); +}); + +test('device table renders the device icon after the row number', async () => { + const ctx = pageContext(); + const nasMac = '00:11:32:11:22:33'; + ctx.replies['luci.device-manager.get_online_status'] = { + ok: true, + neighbors: [{ mac: nasMac, state: 'REACHABLE' }] + }; + ctx.replies['luci-rpc.getHostHints'] = { + [nasMac]: { name: 'Synology-NAS', ipaddrs: ['192.168.1.50'] } + }; + + const data = await ctx.page.load(); + const viewNode = ctx.page.render(data); + + // Check table thead has type column + const table = viewNode.querySelector('#device_manager_table'); + const thList = table.querySelectorAll('th'); + assert.equal(thList[1].classes.has('dm-type-th'), true); + + // Check tbody row has the icon after the row number + const tbody = viewNode.querySelector('#device_manager_tbody'); + const rows = tbody.querySelectorAll('tr'); + assert.equal(rows.length, 1); + + const row = rows[0]; + const tdList = row.querySelectorAll('td'); + assert.equal(tdList[1].classes.has('dm-type-cell'), true); + + const iconImg = tdList[1].querySelector('img'); + assert.ok(iconImg); + assert.equal(iconImg.classes.has('dm-device-icon'), true); + assert.match(iconImg.attrs.src, /device-icons\/nas\.svg/); +}); + +test('custom device type override can be saved and displayed', async () => { + const customMac = '00:11:32:55:66:77'; + const initial = [ + { '.type': 'device', '.name': 'dev_001132556677', mac: customMac, name: 'Backup Server', type: 'server' } + ]; + const ctx = pageContext(initial); + ctx.replies['luci.device-manager.get_online_status'] = { + ok: true, + neighbors: [{ mac: customMac, state: 'REACHABLE' }] + }; + const data = await ctx.page.load(); + const viewNode = ctx.page.render(data); + + const row = viewNode.querySelector('#device_manager_tbody').querySelectorAll('tr')[0]; + const iconImg = row.querySelectorAll('td')[1].querySelector('img'); + assert.match(iconImg.attrs.src, /device-icons\/server\.svg/); + + // Test saving a new type via service + await ctx.service.saveDevice(customMac, 'Backup Server', 'Rack 1', 'ungrouped', 'nas'); + assert.equal(ctx.state.saved[0].type, 'nas'); +}); + +test('ambiguous short tokens and general vendors do not override meaningful device names', () => { + const model = loadModule('device-manager.model', environment()); + for (const [name, expected] of [ + ['switch', 'switch'], ['gateway', 'router'], ['Cambridge-PC', 'computer'], + ['spring', 'unknown'], ['bookmark', 'unknown'], ['canvas', 'unknown'], + ['nintendo-switch', 'game'], ['switch-PC', 'computer'] + ]) assert.equal(model.detectDeviceType('12:34:56:78:9A:BC', name), expected, name); + assert.equal(model.detectDeviceType('02:11:32:11:22:33', ''), 'unknown'); + assert.equal(model.detectDeviceType('02:11:32:11:22:33', 'synology'), 'nas'); +}); diff --git a/luci-app-device-manager/test/test_device_manager.js b/luci-app-device-manager/test/test_device_manager.js new file mode 100644 index 000000000..564b1c97f --- /dev/null +++ b/luci-app-device-manager/test/test_device_manager.js @@ -0,0 +1,372 @@ +'use strict'; +const assert = require('node:assert/strict'); +const test = require('node:test'); +const { environment, loadModule, backend } = require('./helpers/runtime'); +const mac = 'AA:BB:CC:11:22:33', other = 'AA:BB:CC:44:55:66'; +const model = loadModule('device-manager.model', environment()); +const device = { '.type': 'device', '.name': 'dev_aabbcc112233', mac, name: 'TV', remark: 'Living room' }; +const group = { '.type': 'group', '.name': 'home', name: 'Home' }; +const data = overrides => Object.assign({ hints: {}, leases: {}, neighbors: { neighbors: [] }, wifi: [], arp: [], devices: [], discoveryComplete: true }, overrides); +const parse = input => model.parseDevices(data(input), model.parseGroups(input.groups || [])); +function service(initial) { + const mocks = backend(initial); + const env = environment(mocks); + return Object.assign({ env, service: loadModule('device-manager.service', env) }, mocks); +} + +test('MAC normalization, stable section ID and control-character removal', () => { + for (const value of ['aa:bb:cc:11:22:33', 'aa-bb-cc-11-22-33', 'aabbcc112233']) assert.equal(model.normalizeMac(value), mac); + for (const value of [null, 123, 'not-a-mac', '00:00:00:00:00:00', 'FF:FF:FF:FF:FF:FF']) assert.equal(model.normalizeMac(value), null); + assert.equal(model.getSectionId(mac), 'dev_aabbcc112233'); + assert.equal(model.sanitizeInput(' hi\nthere\x01 '), 'hi there'); +}); + +test('empty group configuration remains empty', () => assert.deepEqual(model.parseGroups([]), [])); +test('Wi-Fi-only, neighbor-only and ARP-only devices are discovered', () => { + assert.equal(parse({ wifi: [mac] })[0].status, 'online'); + const neighbor = parse({ neighbors: { neighbors: [{ mac, ip: '192.168.1.10', state: 'REACHABLE' }] } }); + assert.equal(neighbor[0].status, 'online'); assert.equal(neighbor[0].ipv4, '192.168.1.10'); + assert.equal(parse({ arp: [{ mac, ip: '192.168.1.10', flags: '0x2' }] })[0].status, 'unknown'); +}); +test('multiple evidence sources deduplicate a MAC and preserve user metadata', () => { + const result = parse({ hints: { [mac.toLowerCase()]: { name: 'hostname', ipaddrs: ['192.168.1.20'] } }, + wifi: [mac], devices: [Object.assign({}, device, { group: 'deleted' })] }); + assert.equal(result.length, 1); assert.equal(result[0].customName, 'TV'); + assert.equal(result[0].remark, 'Living room'); assert.equal(result[0].group, 'ungrouped'); +}); +test('active neighbor evidence outranks FAILED and STALE in every input order', () => { + for (const active of ['REACHABLE', 'DELAY', 'PROBE']) { + for (const states of [['FAILED', active], [active, 'FAILED'], ['STALE', active], [active, 'STALE']]) { + assert.equal(parse({ neighbors: { neighbors: states.map(state => ({ mac, state })) } })[0].status, 'online'); + } + } +}); +test('FAILED neighbors without MAC are associated to a unique known IP', () => { + const result = parse({ hints: { [mac]: { ipaddrs: ['192.168.1.20'] } }, + neighbors: { neighbors: [{ ip: '192.168.1.20', dev: 'br-lan', state: 'FAILED' }] } }); + assert.equal(result[0].status, 'offline'); +}); +test('ambiguous IP ownership never assigns a failure to either device', () => { + const result = parse({ hints: { [mac]: { ipaddrs: ['192.168.1.20'] }, [other]: { ipaddrs: ['192.168.1.20'] } }, + neighbors: { neighbors: [{ ip: '192.168.1.20', state: 'FAILED' }] } }); + assert.ok(result.every(item => item.status === 'unknown')); +}); +test('STALE, PERMANENT and unresolved discovery failures are unknown', () => { + for (const state of ['STALE', 'PERMANENT', 'INCOMPLETE']) assert.equal(parse({ neighbors: { neighbors: [{ mac, state }] } })[0].status, 'unknown'); + assert.equal(parse({ devices: [device], discoveryComplete: false })[0].status, 'unknown'); + assert.equal(parse({ devices: [device] })[0].status, 'offline'); + assert.equal(parse({ devices: [device], discoveryComplete: false, wifi: [mac] })[0].status, 'online'); +}); +test('current active IP and DHCP leases take precedence over stale hints', () => { + const result = parse({ hints: { [mac]: { ipaddrs: ['192.168.1.20'] } }, leases: { dhcp_leases: [{ macaddr: mac, ipaddr: '192.168.1.90' }] } }); + assert.equal(result[0].ipv4, '192.168.1.90'); + assert.equal(parse({ hints: { [mac]: { ipaddrs: ['192.168.1.20'] } }, neighbors: { neighbors: [{ mac, ip: '192.168.1.91', state: 'REACHABLE' }] } })[0].ipv4, '192.168.1.91'); +}); +test('IPv6 leases and all addresses participate in failed neighbor matching', () => { + const result = parse({ leases: { dhcp6_leases: [{ macaddr: mac, ip6addrs: ['fd00::2/64', 'fd00::3'] }] }, + neighbors: { neighbors: [{ ip: 'fd00::3', state: 'FAILED' }] } }); + assert.equal(result[0].ipv6, 'fd00::2'); assert.equal(result[0].status, 'offline'); +}); +test('combined group and text filtering uses names, remarks and addresses', () => { + const result = parse({ devices: [Object.assign({}, device, { group: 'home' })], groups: [group] }); + assert.ok(model.matchesDevice(result[0], model.parseGroups([group]), 'home', 'living')); + assert.ok(model.matchesDevice(result[0], model.parseGroups([group]), 'home', 'home')); + assert.equal(model.matchesDevice(result[0], [], 'ungrouped', ''), false); +}); +test('legacy sections recover their MAC from the section ID', () => { + assert.equal(parse({ devices: [{ '.name': 'dev_aabbcc112233', name: 'Legacy' }] })[0].mac, mac); +}); + +test('real service saves a MAC-only record and clearing metadata preserves it', async () => { + const ctx = service([]); + await ctx.service.saveDevice(mac, '', '', 'ungrouped'); + assert.equal(ctx.state.saved.length, 1); assert.equal(ctx.state.saved[0].mac, mac); + await ctx.service.saveDevice(mac, 'TV', 'Room', 'ungrouped'); + await ctx.service.saveDevice(mac, '', '', 'ungrouped'); + assert.equal(ctx.state.saved.length, 1); assert.equal(ctx.state.saved[0].name, undefined); +}); +test('group create, rename and delete preserve actual device name and remarks', async () => { + const ctx = service([device]); + await ctx.service.addGroup('Home'); + const created = ctx.state.saved.find(section => section['.type'] === 'group'); + await ctx.service.saveDevice(mac, 'TV', 'Living room', created['.name']); + await ctx.service.renameGroup(created['.name'], 'Media'); + assert.equal(ctx.state.saved.find(section => section.mac === mac).group, created['.name']); + await ctx.service.deleteGroup(created['.name']); + assert.deepEqual(ctx.state.saved, [device]); + assert.deepEqual((await ctx.service.load()).groups, []); +}); +test('duplicate device sections are consolidated and deletion removes all matches', async () => { + const ctx = service([device, Object.assign({}, device, { '.name': 'legacy' })]); + await ctx.service.saveDevice(mac, 'Edited', '', 'ungrouped'); + assert.equal(ctx.state.saved.length, 1); + await ctx.service.deleteDevice({ mac }); assert.equal(ctx.state.saved.length, 0); +}); +test('commit failure rejects, reverts staged changes and permits a clean retry', async () => { + const ctx = service([device]); ctx.state.failCommit = true; + await assert.rejects(ctx.service.saveDevice(mac, 'Failed edit', '', 'ungrouped'), /commit failed/); + assert.deepEqual(ctx.state.saved, [device]); assert.equal(ctx.state.staged, null); assert.equal(ctx.state.reverts, 1); + assert.equal(ctx.state.declarations.find(options => options.method === 'commit').reject, true); + ctx.state.failCommit = false; + await ctx.service.saveDevice(other, 'Phone', '', 'ungrouped'); + assert.equal(ctx.state.saved.find(section => section.mac === mac).name, 'TV'); +}); +test('partial save failure also discards staged edits', async () => { + const ctx = service([device]); ctx.state.failSave = true; + await assert.rejects(ctx.service.saveDevice(mac, 'Failed edit', '', 'ungrouped'), /save failed/); + assert.equal(ctx.state.staged, null); assert.equal(ctx.state.reverts, 1); +}); +test('rollback failure is visible instead of reporting a successful save', async () => { + const ctx = service([device]); ctx.state.failCommit = true; ctx.state.failRevert = true; + await assert.rejects(ctx.service.saveDevice(mac, 'Failed edit', '', 'ungrouped'), /failed to discard pending changes/); +}); +test('fresh UCI load observes external edits and validation rejects deleted groups', async () => { + const ctx = service([group, device]); await ctx.service.load(); + ctx.state.saved[1].name = 'External edit'; + assert.equal((await ctx.service.load()).devices[0].name, 'External edit'); + await assert.rejects(ctx.service.saveDevice(mac, 'TV', '', 'missing'), /no longer exists/); + assert.equal(ctx.state.commits, 0); +}); +test('renaming into an existing group name and overlong inputs are rejected', async () => { + const ctx = service([group, { '.type': 'group', '.name': 'other', name: 'Other' }]); + await assert.rejects(ctx.service.renameGroup('other', 'Home'), /already exists/); + await assert.rejects(ctx.service.saveDevice(mac, 'a'.repeat(65), '', 'ungrouped'), /too long/); +}); +test('service refuses writes for read-only sessions', async () => { + const ctx = service([device]); ctx.env.L.hasViewPermission = () => false; + await assert.rejects(ctx.service.saveDevice(mac, 'Forbidden', '', 'ungrouped'), /Read-only/); + assert.equal(ctx.state.reads, 0); assert.equal(ctx.state.commits, 0); +}); +test('refresh is serialized after an in-flight write and does not unload it', async () => { + const ctx = service([device]); let release, started; + const signal = new Promise(resolve => { started = resolve; }); + const blocker = new Promise(resolve => { release = resolve; }); + const save = ctx.uci.save; + ctx.uci.save = () => { started(); return blocker.then(save); }; + const writing = ctx.service.saveDevice(mac, 'New name', '', 'ungrouped'); + await signal; + const reading = ctx.service.load(); + assert.equal(ctx.state.cached[0].name, 'New name'); + release(); await writing; + assert.equal((await reading).devices[0].name, 'New name'); +}); +test('every refresh fetches current host hints without network library cache', async () => { + const ctx = service([]); + ctx.replies['luci-rpc.getHostHints'] = { [mac]: { ipaddrs: ['192.168.1.20'] } }; + await ctx.service.load(); + ctx.replies['luci-rpc.getHostHints'] = { [mac]: { ipaddrs: ['192.168.1.90'] } }; + assert.equal(model.parseDevices(await ctx.service.load(), [])[0].ipv4, '192.168.1.90'); +}); +test('discovery errors are reported and do not turn saved devices offline', async () => { + const ctx = service([device]); ctx.replies['luci-rpc.getHostHints'] = new Error('access denied'); + const snapshot = await ctx.service.load(); + assert.equal(snapshot.discoveryComplete, false); assert.match(snapshot.errors.join(), /access denied/); + assert.equal(model.parseDevices(snapshot, [])[0].status, 'unknown'); + ctx.replies['luci.device-manager.get_online_status'] = { ok: false, error: 'ip unavailable' }; + assert.match((await ctx.service.load()).errors.join(), /ip unavailable/); +}); +test('partial Wi-Fi RPC failure preserves active stations and VLAN discovery', async () => { + const ctx = service([]); + ctx.replies['luci-rpc.getWirelessDevices'] = { radio0: { interfaces: [ + { ifname: 'wlan0', config: { mode: 'ap' }, vlans: [{ ifname: 'wlan0.10' }] }, + { ifname: 'wlan1', config: { mode: 'ap' } }, { ifname: 'uplink', config: { mode: 'sta' } } + ] } }; + ctx.replies['iwinfo.assoclist'] = name => name === 'wlan1' ? new Error('radio unavailable') : { results: [{ mac }] }; + const snapshot = await ctx.service.load(); + assert.equal(snapshot.discoveryComplete, false); assert.equal(model.parseDevices(snapshot, [])[0].status, 'online'); + assert.ok(ctx.state.calls.some(call => call.key === 'iwinfo.assoclist' && call.args[0] === 'wlan0.10')); + assert.ok(!ctx.state.calls.some(call => call.args[0] === 'uplink')); +}); + +test('preference corruption and inaccessible storage safely fall back', async () => { + for (const storage of [{ getItem: () => '{broken' }, { getItem() { throw Error('blocked'); } }]) { + const env = environment({ window: { localStorage: storage } }); + const preferences = loadModule('device-manager.preferences', env); + assert.deepEqual(await preferences.load(), { tab: 'online', group: 'all' }); + await preferences.save('online', 'home'); + } +}); +test('preference storage contains only tab and group', async () => { + let raw; + const env = environment({ window: { localStorage: { getItem: () => raw, setItem: (key, value) => { raw = value; } } } }); + const preferences = loadModule('device-manager.preferences', env); + await preferences.save('online', 'home'); + assert.deepEqual(JSON.parse(raw), { tab: 'online', group: 'home' }); + assert.deepEqual(await preferences.load(), { tab: 'online', group: 'home' }); +}); + +function pageContext(initial = []) { + const mocks = backend(initial); + const env = environment(mocks); + return Object.assign({ env, page: loadModule('view.device-manager.devices', env) }, mocks); +} +async function renderPage(ctx) { + const node = ctx.page.render(await ctx.page.load()); + return node; +} +function button(node, label) { return node.querySelectorAll('button').find(item => item.textContent === label); } +const attack = ''; + +test('untrusted device/group text renders as text in rows, forms and confirmations', async () => { + const ctx = pageContext([Object.assign({}, group, { name: attack }), Object.assign({}, device, { name: attack, remark: attack, group: 'home' })]); + ctx.replies['luci-rpc.getHostHints'] = { [mac]: { name: attack, ipaddrs: ['192.168.1.20'] } }; + const node = await renderPage(ctx); + assert.ok(node.textContent.includes(attack)); + ctx.page.showEditModal(ctx.page.devices[0]); + assert.equal(ctx.env.ui.modal.attrs.title, 'Edit device'); + ctx.page.confirmDelete(ctx.page.devices[0]); + ctx.page.showGroupModal(); ctx.page.promptRenameGroup(ctx.page.groups[0]); + ctx.page.confirmDeleteGroup(ctx.page.groups[0], 1); + assert.deepEqual(ctx.env.htmlSinks, []); +}); +test('search empty-state messages safely display markup-like search text', async () => { + const ctx = pageContext([]); const node = await renderPage(ctx); + ctx.page.filterText = attack; ctx.page.updateView(); + assert.ok(node.textContent.includes(attack)); assert.deepEqual(ctx.env.htmlSinks, []); +}); +test('actual save button recovers after event.currentTarget is cleared', async () => { + const ctx = pageContext([]); await renderPage(ctx); ctx.page.showEditModal(null); + const modal = ctx.env.ui.modal; modal.querySelectorAll('input')[0].value = mac; + const save = button(modal, 'Save'); + await save.click(); + assert.equal(save.disabled, false); assert.equal(save.classes.has('spinning'), false); + assert.equal(ctx.state.saved[0].mac, mac); assert.equal(ctx.env.notifications[0].level, 'info'); +}); +test('save failure leaves the modal open, restores button and never notifies success', async () => { + const ctx = pageContext([]); await renderPage(ctx); ctx.page.showEditModal(null); + const modal = ctx.env.ui.modal; modal.querySelectorAll('input')[0].value = mac; + ctx.state.failCommit = true; + const save = button(modal, 'Save'); await save.click(); + assert.equal(ctx.env.ui.modal, modal); assert.equal(save.disabled, false); + assert.equal(ctx.env.notifications.length, 0); assert.match(modal.textContent, /commit failed/); + assert.equal(ctx.state.saved.length, 0); +}); +test('actual add, rename and delete group buttons restore their state', async () => { + const ctx = pageContext([]); await renderPage(ctx); ctx.page.showGroupModal(); + const modal = ctx.env.ui.modal; modal.querySelector('input').value = 'Home'; + const add = button(modal, 'Add group'); await add.click(); assert.equal(add.disabled, false); + ctx.page.promptRenameGroup(ctx.page.groups[0]); + ctx.env.ui.modal.querySelector('input').value = 'Media'; + const rename = button(ctx.env.ui.modal, 'Save changes'); await rename.click(); assert.equal(rename.disabled, false); + ctx.page.confirmDeleteGroup(ctx.page.groups[0], 0); + const remove = button(ctx.env.ui.modal, 'Confirm delete'); await remove.click(); assert.equal(remove.disabled, false); + assert.deepEqual(ctx.page.groups, []); +}); +test('actual clear record button restores its state on failure and retry', async () => { + const ctx = pageContext([device]); await renderPage(ctx); ctx.page.confirmDelete(ctx.page.devices[0]); + const remove = button(ctx.env.ui.modal, 'Confirm clear'); ctx.state.failCommit = true; + await remove.click(); assert.equal(remove.disabled, false); assert.equal(ctx.state.saved.length, 1); + ctx.state.failCommit = false; ctx.page.confirmDelete(ctx.page.devices[0]); + const retry = button(ctx.env.ui.modal, 'Confirm clear'); await retry.click(); + assert.equal(retry.disabled, false); assert.equal(ctx.state.saved.length, 0); +}); +test('refresh button restores state and external group deletion resets the active filter', async () => { + const ctx = pageContext([group]); const node = await renderPage(ctx); + ctx.page.activeGroup = 'home'; ctx.state.saved = []; + const refresh = button(node, 'Refresh list'); await refresh.click(); + assert.equal(refresh.disabled, false); assert.equal(ctx.page.activeGroup, 'all'); + assert.deepEqual(ctx.page.groups, []); +}); +test('failed refresh preserves the last displayed snapshot and reports refresh failure', async () => { + const ctx = pageContext([device]); const node = await renderPage(ctx); + ctx.uci.load = () => Promise.reject(new Error('configuration unavailable')); + await button(node, 'Refresh list').click(); + assert.equal(ctx.page.devices[0].customName, 'TV'); + assert.match(ctx.env.notifications[0].content.textContent, /Could not refresh/); +}); +test('read-only views have no edit actions or writable dialogs and disable the toolbar', async () => { + const ctx = pageContext([device]); ctx.env.L.hasViewPermission = () => false; + const node = await renderPage(ctx); + ctx.page.activeTab = 'all'; ctx.page.updateView(); + assert.equal(button(node, 'Edit'), undefined); assert.equal(button(node, 'Delete'), undefined); + assert.equal(node.querySelectorAll('.dm-action-edit').length, 0); + assert.equal(node.querySelectorAll('.dm-action-detail').length, 1); + assert.equal(button(node, 'Add device').disabled, true); assert.equal(button(node, 'Manage groups').disabled, true); + ctx.page.showEditModal(null); ctx.page.showGroupModal(); assert.equal(ctx.env.ui.modal, null); + assert.equal(ctx.page.handleSave, null); assert.equal(ctx.page.handleSaveApply, null); assert.equal(ctx.page.handleReset, null); +}); +test('rows offer edit/details links and saved records can only be cleared from the edit dialog', async () => { + const ctx = pageContext([device]); + ctx.replies['luci-rpc.getHostHints'] = { [other]: { name: 'New device' } }; + const node = await renderPage(ctx); + ctx.page.activeTab = 'all'; ctx.page.updateView(); + const rows = node.querySelector('#device_manager_tbody').querySelectorAll('tr'); + const savedRow = rows.find(row => row.textContent.includes('TV')); + const unsavedRow = rows.find(row => row.textContent.includes('New device')); + assert.equal(node.querySelectorAll('.dm-action-delete').length, 0); + assert.equal(unsavedRow.querySelectorAll('a').length, 2); + assert.equal(savedRow.querySelectorAll('a').length, 2); + const edit = savedRow.querySelector('.dm-action-edit'); + const detail = savedRow.querySelector('.dm-action-detail'); + assert.equal(edit.attrs.href, '#'); assert.equal(edit.attrs.role, 'button'); + const click = new Event('click', { cancelable: true }); + edit.dispatchEvent(click); + assert.equal(click.defaultPrevented, true); + assert.equal(ctx.env.ui.modal.attrs.title, 'Edit device'); + assert.equal(ctx.env.ui.modal.querySelector('input').value, 'TV'); + await button(ctx.env.ui.modal, 'Clear record').click(); + assert.equal(ctx.env.ui.modal.attrs.title, 'Clear record'); + await detail.click(); + assert.equal(ctx.env.ui.modal.attrs.title, 'Device details'); + const space = new Event('keydown', { cancelable: true }); + Object.defineProperty(space, 'key', { value: ' ' }); + edit.dispatchEvent(space); + assert.equal(space.defaultPrevented, true); + assert.equal(ctx.env.ui.modal.attrs.title, 'Edit device'); + await unsavedRow.querySelector('.dm-action-edit').click(); + assert.equal(button(ctx.env.ui.modal, 'Clear record'), undefined); +}); +test('tab counts match group/search filters and tabs support keyboard activation', async () => { + const ctx = pageContext([group, Object.assign({}, device, { group: 'home' })]); + ctx.replies['iwinfo.assoclist'] = { results: [{ mac: other }] }; + ctx.replies['luci-rpc.getWirelessDevices'] = { radio0: { interfaces: [{ ifname: 'wlan0' }] } }; + const node = await renderPage(ctx); + const tabs = node.querySelector('#dm-tabs-container').children; + assert.equal(tabs[0].textContent, 'Online devices1'); assert.equal(tabs[1].textContent, 'All devices2'); + assert.ok(tabs.every(tab => tab.tag === 'button' && tab.attrs.role === 'tab')); + ctx.page.activeGroup = 'home'; ctx.page.filterText = 'tv'; ctx.page.updateView(); + assert.equal(node.querySelector('#dm-tabs-container').children[0].textContent, 'Online devices0'); + assert.equal(node.querySelector('#dm-tabs-container').children[1].textContent, 'All devices1'); +}); + +test('a failed rollback must recover before a later operation can commit', async () => { + const ctx = service([device]); ctx.state.failCommit = true; ctx.state.failRevert = true; + await assert.rejects(ctx.service.saveDevice(mac, 'Failed edit', '', 'ungrouped')); + ctx.state.failCommit = false; + await assert.rejects(ctx.service.saveDevice(other, 'Phone', '', 'ungrouped'), /revert failed/); + assert.deepEqual(ctx.state.saved, [device]); + ctx.state.failRevert = false; + await ctx.service.saveDevice(other, 'Phone', '', 'ungrouped'); + assert.equal(ctx.state.saved.find(section => section.mac === mac).name, 'TV'); +}); +test('a write waits for the entire earlier refresh to finish, including slow discovery', async () => { + const ctx = service([device]); let release, started; + const signal = new Promise(resolve => { started = resolve; }); + const blocker = new Promise(resolve => { release = resolve; }); + ctx.fs.read = () => { started(); return blocker.then(() => ''); }; + const reading = ctx.service.load(); await signal; + const writing = ctx.service.saveDevice(mac, 'New name', '', 'ungrouped'); + await Promise.resolve(); assert.equal(ctx.state.commits, 0); + release(); assert.equal((await reading).devices[0].name, 'TV'); + await writing; assert.equal(ctx.state.saved[0].name, 'New name'); +}); + +test('MAC-less failures on a different interface do not mark an ARP-known device offline', () => { + const result = parse({ arp: [{ mac, ip: '192.168.1.20', flags: '0x2', dev: 'br-lan' }], + neighbors: { neighbors: [{ ip: '192.168.1.20', dev: 'wan', state: 'FAILED' }] } }); + assert.equal(result[0].status, 'unknown'); +}); +test('switching a status tab preserves focus on the newly rendered tab', async () => { + const ctx = pageContext([]); const node = await renderPage(ctx); + await node.querySelector('#dm-tabs-container').children[1].click(); + assert.equal(ctx.page.activeTab, 'all'); + assert.equal(node.querySelector('#dm-tabs-container').children[1].focused, true); +}); + +test('writable toolbar actions remain enabled under LuCI boolean attribute semantics', async () => { + const ctx = pageContext([]); const node = await renderPage(ctx); + assert.equal(button(node, 'Add device').disabled, false); + assert.equal(button(node, 'Manage groups').disabled, false); + await button(node, 'Add device').click(); + assert.equal(ctx.env.ui.modal.attrs.title, 'Add device'); +}); diff --git a/luci-app-device-manager/test/test_i18n.js b/luci-app-device-manager/test/test_i18n.js new file mode 100644 index 000000000..09f45d2bc --- /dev/null +++ b/luci-app-device-manager/test/test_i18n.js @@ -0,0 +1,98 @@ +'use strict'; +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); +const { environment, loadModule, backend, root } = require('./helpers/runtime'); + +function catalogue() { + const messages = {}; + let key = '', value = '', field = ''; + for (const line of (fs.readFileSync(path.join(root, 'po/zh_Hans/device-manager.po'), 'utf8') + '\n\n').split('\n')) { + if (line.startsWith('msgid ')) { key = JSON.parse(line.slice(6)); field = 'key'; } + else if (line.startsWith('msgstr ')) { value = JSON.parse(line.slice(7)); field = 'value'; } + else if (line.startsWith('"')) { + if (field === 'key') key += JSON.parse(line); + else if (field === 'value') value += JSON.parse(line); + } else if (!line.trim()) { + if (key && value) messages[key] = value; + key = ''; value = ''; field = ''; + } + } + return messages; +} +function context(translate = text => text, initial = []) { + const mocks = backend(initial); + const env = environment(Object.assign({}, mocks, { + _: translate, + window: { navigator: { languages: ['zh-CN'] }, localStorage: { getItem: () => null, setItem() {} } } + })); + const page = loadModule('view.device-manager.devices', env); + return { mocks, env, page }; +} +async function render(ctx) { return ctx.page.render(await ctx.page.load()); } +function button(node, label) { return node.querySelectorAll('button').find(item => item.textContent === label); } + +test('native LuCI catalogue translates the page, sort titles, types and dialogs', async () => { + const messages = catalogue(); + const ctx = context(text => messages[text] || text, [ + { '.type': 'device', '.name': 'saved', mac: 'AA:BB:CC:11:22:33', name: '客厅电视', type: 'tv' } + ]); + const node = await render(ctx); + assert.ok(button(node, messages['Refresh list'])); + assert.ok(button(node, messages['Add device'])); + assert.ok(button(node, messages['Hide info'])); + assert.ok(button(node, messages['Scan LAN'])); + const title = node.querySelectorAll('th').find(th => th.getAttribute('data-sort') === 'name').querySelector('button').attrs.title; + assert.equal(title, messages['Click to sort by %s'].format(messages['Device name'])); + ctx.page.showEditModal(ctx.page.devices[0]); + assert.equal(ctx.env.ui.modal.attrs.title, messages['Edit device']); + assert.ok(button(ctx.env.ui.modal, messages.Save)); + assert.ok(ctx.env.ui.modal.querySelectorAll('option').some(option => option.textContent === messages.TV)); + assert.deepEqual(ctx.env.htmlSinks, []); +}); +test('LuCI decides language without a browser override or custom language RPC', async () => { + for (const translate of [text => text, text => 'DE:' + text, text => '繁體:' + text]) { + const ctx = context(translate); + const node = await render(ctx); + assert.ok(button(node, translate('Refresh list'))); + assert.ok(node.textContent.includes(translate('LAN Device Management'))); + assert.ok(!ctx.mocks.state.calls.some(call => call.key.includes('get_language'))); + } +}); +test('legacy default group labels translate while custom metadata remains literal', async () => { + const initial = [ + { '.type': 'group', '.name': 'smart_home', name: '智能家居' }, + { '.type': 'group', '.name': 'phone', name: 'My phones' }, + { '.type': 'group', '.name': 'grp_custom', name: '办公设备' } + ]; + const ctx = context(text => 'DE:' + text, initial); + await render(ctx); + assert.deepEqual(ctx.page.groups.map(group => group.name), ['DE:Smart home', 'My phones', '办公设备']); + assert.deepEqual(ctx.mocks.state.saved, initial); +}); +test('loading and refreshing never start active scans; the explicit button scans then refreshes', async () => { + const ctx = context(); const node = await render(ctx); + await ctx.page.refresh(); + assert.equal(ctx.mocks.state.calls.filter(call => call.key.endsWith('.scan_devices')).length, 0); + await button(node, 'Scan LAN').click(); + const keys = ctx.mocks.state.calls.map(call => call.key); + assert.equal(keys.filter(key => key.endsWith('.scan_devices')).length, 1); + assert.ok(keys.lastIndexOf('luci.device-manager.get_online_status') > keys.indexOf('luci.device-manager.scan_devices')); + assert.equal(button(node, 'Scan LAN').disabled, false); +}); +test('read-only users cannot start scans and scan failures restore the control', async () => { + const ctx = context(); + ctx.env.L.hasViewPermission = () => false; + const node = await render(ctx); + assert.equal(button(node, 'Scan LAN').disabled, true); + await ctx.page.scan(); + assert.ok(!ctx.mocks.state.calls.some(call => call.key.endsWith('.scan_devices'))); + ctx.env.L.hasViewPermission = () => true; + ctx.page.readonly = false; + const writable = ctx.page.render(await ctx.page.load()); + ctx.mocks.replies['luci.device-manager.scan_devices'] = { ok: false }; + await button(writable, 'Scan LAN').click(); + assert.equal(button(writable, 'Scan LAN').disabled, false); + assert.match(ctx.env.notifications.at(-1).content.textContent, /Could not scan LAN devices/); +}); diff --git a/luci-app-device-manager/test/test_ping.js b/luci-app-device-manager/test/test_ping.js new file mode 100644 index 000000000..319b12c7d --- /dev/null +++ b/luci-app-device-manager/test/test_ping.js @@ -0,0 +1,105 @@ +'use strict'; +const assert = require('node:assert/strict'); +const test = require('node:test'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync, spawn } = require('node:child_process'); +const { root } = require('./helpers/runtime'); +const mac = 'AA:BB:CC:11:22:33'; + +function fixture(t, command, prefix) { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'device-manager-single-ping-')); + t.after(() => fs.rmSync(directory, { recursive: true, force: true })); + const leases = path.join(directory, 'leases'); fs.writeFileSync(leases, ''); + const source = fs.readFileSync(path.join(root, 'root/usr/libexec/rpcd/luci.device-manager'), 'utf8') + .replaceAll('. /usr/share/libubox/jshn.sh', '. "$AUDIT_JSHN"') + .replaceAll('/tmp/luci-device-manager-single-ping', path.join(directory, 'single-ping')) + .replaceAll('/tmp/dhcp.leases', leases).replaceAll('/var/dhcp.leases', leases); + const wrapper = ` +ip() { printf '%s\\n' "$AUDIT_NEIGH"; return "$AUDIT_IP_EXIT"; } +ping() { printf '%s\\n' "$@" >> "$AUDIT_PING_LOG"; sleep "$AUDIT_DELAY"; printf '%s\\n' "$AUDIT_OUTPUT"; return "$AUDIT_PING_EXIT"; } +ping6() { ping "$@"; } +` + source; + let sequence = 0; + function options(extra) { + const id = sequence++; + const log = path.join(directory, 'pings-' + id); + return { log, options: { encoding: 'utf8', timeout: 10000, env: Object.assign({}, process.env, { + AUDIT_JSHN: process.env.TEST_JSHN_PATH || path.join(root, 'test/helpers/jshn.sh'), AUDIT_NODE: process.execPath, + AUDIT_JSON_ENCODER: path.join(root, 'test/helpers/json-encoder.js'), + AUDIT_JSON_LOG: path.join(directory, 'json-' + id), AUDIT_PING_LOG: log, + AUDIT_NEIGH: '192.168.1.20 dev br-lan lladdr aa:bb:cc:11:22:33 REACHABLE', + AUDIT_IP_EXIT: '0', AUDIT_PING_EXIT: '0', AUDIT_DELAY: '0', AUDIT_OUTPUT: '1 packet received, time=0.5 ms' + }, extra) } }; + } + return { + directory, leases, + run(request = { mac }, extra = {}) { + const cfg = options(extra); + const result = spawnSync(command, [...prefix, '-c', wrapper, 'ping-test', 'call', 'ping_device'], + Object.assign({}, cfg.options, { input: JSON.stringify(request) })); + assert.equal(result.status, 0, result.stderr); + return { reply: JSON.parse(result.stdout), args: fs.existsSync(cfg.log) ? fs.readFileSync(cfg.log, 'utf8').trim().split('\n') : [] }; + }, + start(extra = {}) { + const cfg = options(extra); + const child = spawn(command, [...prefix, '-c', wrapper, 'ping-test', 'call', 'ping_device'], cfg.options); + child.stdin.end(JSON.stringify({ mac })); + let stdout = '', stderr = ''; + child.stdout.on('data', data => { stdout += data; }); child.stderr.on('data', data => { stderr += data; }); + const done = new Promise((resolve, reject) => { + child.on('error', reject); + child.on('close', code => code === 0 ? resolve(JSON.parse(stdout)) : reject(new Error(stderr))); + }); + return { done, log: cfg.log }; + } + }; +} + +for (const [command, prefix] of [['sh', []], ['busybox', ['sh']]]) { + const available = spawnSync(command, [...prefix, '-c', 'command -v flock >/dev/null']).status === 0; + test(`${command}: single-device Ping resolves the MAC, ignores caller targets and reports reply versus timeout`, { skip: !available }, t => { + const f = fixture(t, command, prefix); + const result = f.run({ mac, ip: '8.8.8.8', command: 'touch /tmp/unsafe' }); + assert.equal(result.reply.reachable, true); assert.equal(result.reply.ip, '192.168.1.20'); + assert.equal(result.args.at(-1), '192.168.1.20'); assert.ok(result.args.includes('br-lan')); + assert.deepEqual(result.args.slice(0, 6), ['-c', '1', '-w', '3', '-W', '2']); + const timeout = f.run({ mac }, { AUDIT_PING_EXIT: '1' }); + assert.equal(timeout.reply.ok, true); assert.equal(timeout.reply.reachable, false); + const error = f.run({ mac }, { AUDIT_PING_EXIT: '2', AUDIT_OUTPUT: 'invalid option' }); + assert.equal(error.reply.ok, false); assert.equal(error.reply.error, 'Ping could not be completed'); + }); + test(`${command}: invalid and unknown MACs never launch Ping`, { skip: !available }, t => { + const f = fixture(t, command, prefix); + for (const value of ['', null, 'aa:bb:cc:11:22:33; touch /tmp/unsafe', 'AA:BB:CC:11:22:33\ninvalid', "AA:BB:CC:11:22:33'$(touch /tmp/unsafe)'", 'FF:FF:FF:FF:FF:FF', '00:00:00:00:00:00', '11:22:33:44:55:66']) { + const result = f.run({ mac: value }); + assert.equal(result.reply.ok, false); assert.deepEqual(result.args, []); + } + assert.deepEqual(f.run({ mac }, { AUDIT_IP_EXIT: '127' }).args, []); + }); + test(`${command}: DHCP fallback and IPv6-only link-local neighbors use a single numeric destination`, { skip: !available }, t => { + const f = fixture(t, command, prefix); + fs.writeFileSync(f.leases, '123 aa:bb:cc:11:22:33 192.168.1.50 device *\n'); + const lease = f.run({ mac }, { AUDIT_NEIGH: '' }); + assert.equal(lease.reply.ip, '192.168.1.50'); assert.equal(lease.reply.interface, ''); + const reassigned = f.run({ mac }, { AUDIT_NEIGH: '192.168.1.50 dev br-lan lladdr aa:bb:cc:44:55:66 REACHABLE' }); + assert.equal(reassigned.reply.ok, false); assert.deepEqual(reassigned.args, []); + fs.writeFileSync(f.leases, ''); + const v6 = f.run({ mac }, { AUDIT_NEIGH: 'fe80::1234 dev br-lan lladdr aa:bb:cc:11:22:33 STALE' }); + assert.equal(v6.reply.ip, 'fe80::1234'); assert.equal(v6.reply.interface, 'br-lan'); + assert.equal(v6.args.at(-1), 'fe80::1234'); assert.ok(v6.args.includes('-I')); + fs.writeFileSync(f.leases, '123 aa:bb:cc:11:22:33 999.1.2.3 invalid *\n'); + assert.equal(f.run({ mac }, { AUDIT_NEIGH: '' }).reply.ok, false); + }); + test(`${command}: a global nonblocking lock prevents concurrent detail probes`, { skip: !available }, async t => { + const f = fixture(t, command, prefix); + const first = f.start({ AUDIT_DELAY: '0.3' }); + for (let attempt = 0; attempt < 100 && !fs.existsSync(first.log); attempt++) await new Promise(resolve => setTimeout(resolve, 10)); + assert.ok(fs.existsSync(first.log)); + const second = f.run(); + assert.equal(second.reply.ok, false); assert.match(second.reply.error, /Another device/); assert.deepEqual(second.args, []); + await first.done; + assert.equal(f.run().reply.reachable, true, 'lock must be released after the probe'); + }); +} diff --git a/luci-app-device-manager/test/test_probe.js b/luci-app-device-manager/test/test_probe.js new file mode 100644 index 000000000..b3ba80568 --- /dev/null +++ b/luci-app-device-manager/test/test_probe.js @@ -0,0 +1,209 @@ +'use strict'; +const assert = require('node:assert/strict'); +const test = require('node:test'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawn, spawnSync } = require('node:child_process'); +const { root } = require('./helpers/runtime'); + +function fixture(t, command, prefix) { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'device-manager-probe-')); + t.after(() => fs.rmSync(directory, { recursive: true, force: true })); + const clock = path.join(directory, 'uptime'); + fs.writeFileSync(clock, '100.00 0.00\n'); + fs.writeFileSync(path.join(directory, 'arp'), '192.168.1.200 0x1 0x2 aa:bb:cc:11:22:33 * br-lan\n'); + fs.writeFileSync(path.join(directory, 'leases'), '123 aa:bb:cc:11:22:33 192.168.1.200 known *\n123 aa:bb:cc:11:22:44 192.168.3.20 other *\n'); + const source = fs.readFileSync(path.join(root, 'root/usr/libexec/rpcd/luci.device-manager'), 'utf8') + .replaceAll('. /usr/share/libubox/jshn.sh', '. "$AUDIT_JSHN"') + .replaceAll('/tmp/luci-device-manager-probe', path.join(directory, 'probe')) + .replaceAll('/proc/uptime', clock) + .replaceAll('/proc/net/arp', path.join(directory, 'arp')) + .replaceAll('/tmp/dhcp.leases', path.join(directory, 'leases')) + .replaceAll('/var/dhcp.leases', path.join(directory, 'leases')); + const wrapper = ` +ip() { + if [ "$1" = -o ]; then + case "$6" in + br-lan) printf '%s\\n' "2: br-lan inet $AUDIT_LAN_CIDR scope global br-lan" ;; + br-guest) printf '%s\\n' "3: br-guest inet $AUDIT_GUEST_CIDR scope global br-guest" ;; + esac + elif [ "$1" = -4 ]; then + printf '%s\\n' '198.51.100.0/24 dev eth0 scope link' + else + printf '%s\\n' '192.168.1.200 dev br-lan lladdr aa:bb:cc:11:22:33 REACHABLE' + fi +} +uci() { printf '%s\\n' "$AUDIT_INTERFACES"; } +ubus() { + [ "$AUDIT_NO_INTERFACE" = 1 ] && return 1 + case "$2" in + network.interface.lan) printf '%s\\n' '{"l3_device":"br-lan"}' ;; + network.interface.guest) printf '%s\\n' '{"l3_device":"br-guest"}' ;; + *) return 1 ;; + esac +} +ping() { + previous=""; interface="" + for argument in "$@"; do + [ "$previous" = -I ] && interface="$argument" + previous="$argument"; target="$argument" + done + case "$interface" in br-lan|br-guest) ;; *) return 2 ;; esac + case "$target" in + 192.168.1.200|192.168.3.20) sleep "\${AUDIT_KNOWN_START_DELAY:-0}" ;; + esac + printf 'start %s\\n' "$target" >> "$AUDIT_PING_LOG" + sleep "$AUDIT_PING_DELAY" + if [ -n "$AUDIT_ADVANCE" ]; then printf '%s.00 0.00\\n' "$AUDIT_ADVANCE" > "$AUDIT_CLOCK"; fi + printf 'end %s\\n' "$target" >> "$AUDIT_PING_LOG" + return 1 +} +` + source; + let sequence = 0; + return { + clock, + directory, + run(extra = {}, method = 'scan_devices') { + const id = sequence++; + const log = path.join(directory, 'pings-' + id); + const child = spawn(command, [...prefix, '-c', wrapper, 'probe-test', 'call', method], { + env: Object.assign({}, process.env, { + AUDIT_JSHN: process.env.TEST_JSHN_PATH || path.join(root, 'test/helpers/jshn.sh'), + AUDIT_NODE: process.execPath, + AUDIT_JSON_ENCODER: path.join(root, 'test/helpers/json-encoder.js'), + AUDIT_JSON_LOG: path.join(directory, 'json-' + id), + AUDIT_PING_LOG: log, AUDIT_PING_DELAY: '0.02', AUDIT_KNOWN_START_DELAY: '0', + AUDIT_ADVANCE: '', AUDIT_CLOCK: clock, AUDIT_INTERFACES: 'lan guest', AUDIT_NO_INTERFACE: '0', + AUDIT_LAN_CIDR: '192.168.1.1/24', AUDIT_GUEST_CIDR: '192.168.2.1/24' + }, extra) + }); + let stdout = '', stderr = ''; + child.stdout.on('data', data => { stdout += data; }); + child.stderr.on('data', data => { stderr += data; }); + const timer = setTimeout(() => child.kill('SIGKILL'), 10000); + const done = new Promise((resolve, reject) => { + child.on('error', reject); + child.on('close', code => { + clearTimeout(timer); + if (code !== 0) { reject(new Error(stderr || 'RPC exit: ' + code)); return; } + resolve({ reply: JSON.parse(stdout), log }); + }); + }); + return { done, log }; + } + }; +} + +function starts(log) { + if (!fs.existsSync(log)) return []; + return fs.readFileSync(log, 'utf8').trim().split('\n') + .filter(line => line.startsWith('start ')).map(line => line.slice(6)); +} + +for (const [command, prefix] of [['sh', []], ['busybox', ['sh']]]) { + const available = spawnSync(command, [...prefix, '-c', 'command -v flock >/dev/null']).status === 0; + test(`${command}: active probes are bounded, deduplicated, cooled down and resume the sweep`, { skip: !available }, async t => { + const f = fixture(t, command, prefix); + const seen = new Set(); + for (let pass = 0; pass < 4; pass++) { + fs.writeFileSync(f.clock, `${100 + pass * 30}.00 0.00\n`); + // Delay known-host children to expose assertions that depend on scheduling order. + const result = await f.run({ AUDIT_KNOWN_START_DELAY: '0.08' }).done; + assert.equal(result.reply.ok, true); // Failed ICMP must still return the neighbor snapshot. + const addresses = starts(result.log); + assert.equal(addresses.length, 128); + assert.equal(new Set(addresses).size, 128); + if (pass === 0) { + // Children may log in any order; the batch barrier preserves batch membership. + const firstBatch = ['192.168.1.200', + ...Array.from({ length: 15 }, (_, i) => '192.168.1.' + (i + 2))]; + assert.deepEqual(addresses.slice(0, 16).sort(), firstBatch.sort(), + 'known hosts must be included in the first batch'); + } + if (pass === 1) assert.ok(addresses.every(ip => !seen.has(ip)), 'second pass must resume, not restart'); + addresses.forEach(ip => seen.add(ip)); + let active = 0, peak = 0; + for (const line of fs.readFileSync(result.log, 'utf8').trim().split('\n')) { + active += line.startsWith('start ') ? 1 : -1; + peak = Math.max(peak, active); + } + assert.ok(peak <= 16, `peak concurrency was ${peak}`); + assert.equal(active, 0, 'all probes must finish before releasing the lock'); + const cooldown = await f.run().done; + assert.equal(cooldown.reply.ok, true); + assert.deepEqual(starts(cooldown.log), []); + } + assert.equal(seen.size, 506, 'selected /24 subnets exclude router addresses and out-of-scope known hosts'); + }); + + test(`${command}: overlapping RPCs share one scan and the lock is released afterwards`, { skip: !available }, async t => { + const f = fixture(t, command, prefix); + const first = f.run({ AUDIT_PING_DELAY: '0.05' }); + for (let attempt = 0; attempt < 100 && starts(first.log).length === 0; attempt++) { + await new Promise(resolve => setTimeout(resolve, 10)); + } + assert.ok(starts(first.log).length > 0, 'first request must have acquired the scan lock'); + const second = await f.run().done; + assert.equal(second.reply.ok, true); + assert.deepEqual(starts(second.log), []); + await first.done; + fs.writeFileSync(f.clock, '130.00 0.00\n'); + const next = await f.run().done; + assert.equal(starts(next.log).length, 128, 'next eligible request must acquire the released lock'); + }); + + test(`${command}: elapsed-time budget stops a scan before its address limit`, { skip: !available }, async t => { + const f = fixture(t, command, prefix); + const result = await f.run({ AUDIT_ADVANCE: '108' }).done; + assert.equal(result.reply.ok, true); + assert.equal(starts(result.log).length, 16); + assert.equal(fs.readFileSync(path.join(f.directory, 'probe.state'), 'utf8'), '108 16\n'); + fs.writeFileSync(f.clock, '130.00 0.00\n'); + assert.deepEqual(starts((await f.run().done).log), [], 'cooldown starts after the scan finishes'); + }); + test(command + ': passive reads never probe and unavailable LANs never fall back to WAN', { skip: !available }, async t => { + const f = fixture(t, command, prefix); + const passive = await f.run({}, 'get_online_status').done; + assert.equal(passive.reply.ok, true); + assert.deepEqual(starts(passive.log), []); + const unavailable = await f.run({ AUDIT_NO_INTERFACE: '1' }).done; + assert.equal(unavailable.reply.ok, false); + assert.deepEqual(starts(unavailable.log), []); + }); + test(command + ': /25 and /23 probes respect subnet boundaries and exclude router addresses', { skip: !available }, async t => { + for (const cidr of ['192.168.1.130/25', '192.168.1.1/23']) { + const f = fixture(t, command, prefix); + const result = await f.run({ AUDIT_INTERFACES: 'lan', AUDIT_LAN_CIDR: cidr }).done; + assert.equal(result.reply.ok, true); + const addresses = starts(result.log); + assert.ok(addresses.length > 0); + assert.ok(!addresses.includes(cidr.split('/')[0])); + assert.ok(!addresses.some(ip => ip.startsWith('198.51.100.') || ip.startsWith('192.168.3.'))); + if (cidr.endsWith('/25')) { + assert.equal(addresses.length, 125); + assert.ok(addresses.every(ip => ip.startsWith('192.168.1.') && Number(ip.split('.')[3]) >= 129 && Number(ip.split('.')[3]) <= 254)); + } else { + assert.ok(addresses.every(ip => ip.startsWith('192.168.0.') || ip.startsWith('192.168.1.'))); + } + } + }); + test(command + ': a /16 probes known LAN hosts only and ignores invalid interface names', { skip: !available }, async t => { + const f = fixture(t, command, prefix); + const result = await f.run({ AUDIT_INTERFACES: 'lan ../wan', AUDIT_LAN_CIDR: '192.168.1.1/16' }).done; + assert.equal(result.reply.ok, true); + assert.deepEqual(starts(result.log).sort(), ['192.168.1.200', '192.168.3.20']); + }); + + test(command + ': /31 addresses and disabled scanning do not expand to a /24', { skip: !available }, async t => { + const f = fixture(t, command, prefix); + const result = await f.run({ AUDIT_INTERFACES: 'lan', AUDIT_LAN_CIDR: '192.168.1.130/31' }).done; + assert.equal(result.reply.ok, true); + assert.deepEqual(starts(result.log), ['192.168.1.131']); + fs.writeFileSync(f.clock, '130.00 0.00\n'); + const disabled = await f.run({ AUDIT_INTERFACES: '' }).done; + assert.equal(disabled.reply.ok, false); + assert.deepEqual(starts(disabled.log), []); + }); + +} diff --git a/luci-app-device-manager/test/test_sdk.py b/luci-app-device-manager/test/test_sdk.py new file mode 100644 index 000000000..0af165070 --- /dev/null +++ b/luci-app-device-manager/test/test_sdk.py @@ -0,0 +1,141 @@ +"""Offline checks for SDK selection and the actual translation catalogue.""" +import importlib.util +import io +import json +import pathlib +import re +import tempfile +import subprocess +import unittest +from unittest.mock import patch + +ROOT = pathlib.Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location("resolve_sdk", ROOT / "tools/resolve-sdk.py") +SDK = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(SDK) + + +class SDKTests(unittest.TestCase): + def test_requested_version_and_target_select_the_corresponding_sdk(self): + for version, target in [("24.10.0", "x86/64"), ("23.05.5", "mediatek/filogic")]: + filename = f"openwrt-sdk-{version}-test_gcc.Linux-x86_64.tar.zst" + checksum = "a" * 64 + listing = f'{filename}Parent' + with patch.object(SDK, "urlopen", side_effect=[io.BytesIO(listing.encode()), io.BytesIO(f"{checksum} *{filename}\n".encode())]) as fetch: + url, sha = SDK.resolve(version, target) + self.assertEqual(url, f"https://downloads.openwrt.org/releases/{version}/targets/{target}/{filename}") + self.assertEqual(sha, checksum) + self.assertEqual(fetch.call_args_list[0].args[0], f"https://downloads.openwrt.org/releases/{version}/targets/{target}/") + + def test_invalid_inputs_fail_before_any_network_request(self): + for version, target in [("$(id)", "x86/64"), ("24.10.0", "../x86"), ("24.10.0", "x86/64\nextra")]: + with patch.object(SDK, "urlopen") as fetch: + with self.assertRaises(ValueError): + SDK.resolve(version, target) + fetch.assert_not_called() + + def test_missing_sdk_or_checksum_is_rejected(self): + with patch.object(SDK, "urlopen", return_value=io.BytesIO(b"")): + with self.assertRaisesRegex(ValueError, "found 0"): + SDK.resolve("24.10.0", "x86/64") + filename = "openwrt-sdk-24.10.0-test.Linux-x86_64.tar.xz" + with patch.object(SDK, "urlopen", side_effect=[io.BytesIO(f'SDK'.encode()), io.BytesIO(b"invalid checksum")]): + with self.assertRaisesRegex(ValueError, "No checksum"): + SDK.resolve("24.10.0", "x86/64") + + def test_catalogues_cover_native_extracted_messages_including_menu_and_acl(self): + def ids(content): + result = set() + key = None + for line in content.splitlines(): + if line.startswith("msgid "): + key = json.loads(line[6:]) + elif key is not None and line.startswith('"'): + key += json.loads(line) + elif line.startswith("msgstr "): + if key: + result.add(re.sub(r" +", " ", key.strip())) + key = None + return result + strings = set() + for file in (ROOT / "htdocs").rglob("*.js"): + extracted = subprocess.run(["xgettext", "--from-code=UTF-8", "--language=JavaScript", + "--keyword=_:1", "--keyword=N_:2,3", "--no-wrap", "-o", "-", "-"], + input=file.read_text(), capture_output=True, text=True, check=True).stdout + strings.update(ids(extracted)) + for directory in ["root/usr/share/luci/menu.d", "root/usr/share/rpcd/acl.d"]: + for file in (ROOT / directory).glob("*.json"): + strings.update(re.findall(r'"(?:title|description)"\s*:\s*"([^"]+)"', file.read_text())) + for file in [ROOT / "po/templates/device-manager.pot", ROOT / "po/zh_Hans/device-manager.po"]: + self.assertEqual(ids(file.read_text()), strings) + + def test_snapshot_sdk_uses_snapshot_directory_and_unversioned_sdk_name(self): + filename = "openwrt-sdk-x86-64_gcc-14_musl.Linux-x86_64.tar.zst" + checksum = "b" * 64 + with patch.object(SDK, "urlopen", side_effect=[ + io.BytesIO(f'SDK'.encode()), + io.BytesIO(f"{checksum} *{filename}\n".encode()) + ]) as fetch: + url, sha = SDK.resolve("SNAPSHOT", "x86/64") + self.assertEqual(url, f"https://downloads.openwrt.org/snapshots/targets/x86/64/{filename}") + self.assertEqual(sha, checksum) + self.assertEqual(fetch.call_args_list[0].args[0], + "https://downloads.openwrt.org/snapshots/targets/x86/64/") + + def test_selected_configuration_preserves_target_and_disables_sdk_all_defaults(self): + spec = importlib.util.spec_from_file_location("configure_sdk", ROOT / "tools/configure-sdk.py") + configure = importlib.util.module_from_spec(spec) + spec.loader.exec_module(configure) + with tempfile.TemporaryDirectory() as tmp: + directory = pathlib.Path(tmp) + (directory / "rules.mk").touch() + (directory / "Makefile").touch() + (directory / ".config").write_text("CONFIG_TARGET_x86=y\nCONFIG_ALL=y\nCONFIG_ALL_KMODS=y\nCONFIG_ALL_NONSHARED=y\nCONFIG_PACKAGE_unrelated=y\nCONFIG_LUCI_LANG_de=y\n") + configure.configure(directory) + result = (directory / ".config").read_text() + self.assertIn("CONFIG_TARGET_x86=y", result) + self.assertIn("# CONFIG_ALL_KMODS is not set", result) + self.assertIn("CONFIG_PACKAGE_luci-app-device-manager=m", result) + self.assertIn("CONFIG_PACKAGE_luci-i18n-device-manager-zh-cn=m", result) + self.assertNotIn("CONFIG_PACKAGE_unrelated", result) + self.assertNotIn("CONFIG_LUCI_LANG_de", result) + configure.configure(directory) + self.assertEqual((directory / ".config").read_text(), result) + with self.assertRaises(ValueError): + configure.configure(directory / "not-an-sdk") + + def test_upstream_export_is_bounded_and_preserves_existing_checkout(self): + spec = importlib.util.spec_from_file_location("prepare_upstream", ROOT / "tools/prepare-upstream.py") + export = importlib.util.module_from_spec(spec) + spec.loader.exec_module(export) + with tempfile.TemporaryDirectory() as tmp: + checkout = pathlib.Path(tmp) + (checkout / "luci.mk").touch() + target = export.prepare(checkout) + self.assertTrue((target / "htdocs").is_dir()) + self.assertIn("include ../../luci.mk", (target / "Makefile").read_text()) + self.assertNotIn("feeds/luci/luci.mk", (target / "Makefile").read_text()) + self.assertTrue((target / "root/etc/config/device_manager").is_file()) + self.assertFalse((target / "root/etc/uci-defaults/80_device_manager").exists()) + readme = (target / "README.md").read_text() + self.assertIn("opkg install luci-app-device-manager\n", readme) + self.assertIn("apk add luci-app-device-manager\n", readme) + for obsolete in ["尚未合并到官方源", "--allow-untrusted", "GitHub Actions", "workflow 默认"]: + self.assertNotIn(obsolete, readme) + for name in [".github", "tools", "test", "device-icons"]: + self.assertFalse((target / name).exists()) + self.assertFalse((target / "docs/upstream-submission.md").exists()) + (target / "user-sentinel").write_text("preserve") + with self.assertRaises(FileExistsError): + export.prepare(checkout) + self.assertEqual((target / "user-sentinel").read_text(), "preserve") + + def test_scan_acl_requires_write_permission_but_keeps_passive_discovery_readable(self): + acl = json.loads((ROOT / "root/usr/share/rpcd/acl.d/luci-app-device-manager.json").read_text())["luci-app-device-manager"] + self.assertIn("get_online_status", acl["read"]["ubus"]["luci.device-manager"]) + self.assertNotIn("scan_devices", acl["read"]["ubus"]["luci.device-manager"]) + self.assertIn("scan_devices", acl["write"]["ubus"]["luci.device-manager"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/luci-app-device-manager/test/test_shell.js b/luci-app-device-manager/test/test_shell.js new file mode 100644 index 000000000..738171408 --- /dev/null +++ b/luci-app-device-manager/test/test_shell.js @@ -0,0 +1,111 @@ +'use strict'; +const assert = require('node:assert/strict'); +const test = require('node:test'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); +const { root } = require('./helpers/runtime'); + +function temporary(fn) { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'device-manager-test-')); + return Promise.resolve().then(() => fn(directory)).finally(() => fs.rmSync(directory, { recursive: true, force: true })); +} +function rpc(command, prefix, directory, neighbors, exit = '0', method = 'get_online_status', language = 'auto', uciExit = '0') { + const source = fs.readFileSync(path.join(root, 'root/usr/libexec/rpcd/luci.device-manager'), 'utf8') + .replaceAll('. /usr/share/libubox/jshn.sh', '. "$AUDIT_JSHN"') + .replaceAll('/tmp/luci-device-manager-probe', path.join(directory, 'probe')); + const wrapper = 'ip() { [ "$1" = -4 ] && return 0; printf "%s\\n" "$AUDIT_NEIGH"; return "$AUDIT_IP_EXIT"; }\n' + + 'uci() { [ "$3" = network.lan.ipaddr ] && return 1; if [ "$AUDIT_UCI_EXIT" = 0 ]; then printf "%s\\n" "$AUDIT_LANGUAGE"; fi; return "$AUDIT_UCI_EXIT"; }\n' + + 'ping() { return 1; }\n' + source; + return spawnSync(command, [...prefix, '-c', wrapper, 'luci.device-manager', 'call', method], { encoding: 'utf8', env: { + ...process.env, AUDIT_NEIGH: neighbors, AUDIT_IP_EXIT: exit, AUDIT_LANGUAGE: language, AUDIT_UCI_EXIT: uciExit, + AUDIT_JSHN: process.env.TEST_JSHN_PATH || path.join(root, 'test/helpers/jshn.sh'), AUDIT_NODE: process.execPath, + AUDIT_JSON_ENCODER: path.join(root, 'test/helpers/json-encoder.js'), AUDIT_JSON_LOG: path.join(directory, 'json.log') + } }); +} +for (const [command, prefix] of [['sh', []], ['busybox', ['sh']]]) { + const available = spawnSync(command, [...prefix, '-c', 'exit 0']).status === 0; + test(`${command}: RPC preserves MAC-less FAILED/INCOMPLETE and safely encodes strings`, { skip: !available }, () => temporary(directory => { + const result = rpc(command, prefix, directory, + '192.168.1.20 dev br-lan FAILED\n192.168.1.21 dev br-lan INCOMPLETE\nfe80::1 dev br-"lan lladdr aa:bb:cc:11:22:33 STALE proto kernel'); + assert.equal(result.status, 0, result.stderr); + const reply = JSON.parse(result.stdout); + assert.equal(reply.ok, true); assert.equal(reply.neighbors.length, 3); + assert.equal(reply.neighbors[0].state, 'FAILED'); assert.equal(reply.neighbors[0].mac, ''); + assert.equal(reply.neighbors[1].state, 'INCOMPLETE'); + assert.equal(reply.neighbors[2].state, 'STALE'); assert.equal(reply.neighbors[2].dev, 'br-"lan'); + })); + test(`${command}: RPC reports an unavailable ip command separately from an empty table`, { skip: !available }, () => temporary(directory => { + const failed = rpc(command, prefix, directory, '', '127'); + assert.equal(JSON.parse(failed.stdout).ok, false); + const empty = rpc(command, prefix, directory, ''); + assert.deepEqual(JSON.parse(empty.stdout), { ok: true, neighbors: [] }); + })); +} +function initialize(directory, content, failure = false) { + const config = path.join(directory, 'config'); + if (content !== null) fs.writeFileSync(config, content); + const source = fs.readFileSync(path.join(root, 'tools/initialize-config.sh'), 'utf8').replaceAll('/etc/config/device_manager', config); + const wrapper = 'uci() { printf "%s\\n" "$*" >> "$AUDIT_UCI_LOG"; if [ "$2" = batch ]; then cat >> "$AUDIT_UCI_LOG"; fi; return "$AUDIT_UCI_EXIT"; }\n' + source; + const result = spawnSync('busybox', ['sh', '-c', wrapper], { encoding: 'utf8', env: { + ...process.env, AUDIT_UCI_LOG: path.join(directory, 'uci.log'), AUDIT_UCI_EXIT: failure ? '1' : '0' + } }); + return { result, config, log: path.join(directory, 'uci.log') }; +} +test('initializer preserves populated and intentionally empty configurations', () => temporary(directory => { + for (const content of ['', "config device 'saved'\n\toption name 'Existing TV'\n"]) { + const { result, config, log } = initialize(directory, content); + assert.equal(result.status, 0); assert.equal(fs.readFileSync(config, 'utf8'), content); + assert.equal(fs.existsSync(log), false); + } +})); +test('first installation creates default groups exactly once', () => temporary(directory => { + const { result, config, log } = initialize(directory, null); + assert.equal(result.status, 0, result.stderr); assert.equal(fs.existsSync(config), true); + const first = fs.readFileSync(log, 'utf8'); assert.match(first, /smart_home/); assert.match(first, /commit device_manager/); + initialize(directory, null); assert.equal(fs.readFileSync(log, 'utf8'), first); +})); +test('failed initialization removes the new incomplete configuration for retry', () => temporary(directory => { + const { result, config } = initialize(directory, null, true); + assert.notEqual(result.status, 0); assert.equal(fs.existsSync(config), false); +})); +function deploy(directory, args, failure = false) { + const bin = path.join(directory, 'bin'); fs.mkdirSync(bin, { recursive: true }); + for (const command of ['ssh', 'scp']) { + fs.writeFileSync(path.join(bin, command), '#!/bin/sh\nprintf "%s: %s\\n" "'+command+'" "$*" >> "$AUDIT_COMMAND_LOG"\nexit "$AUDIT_REMOTE_EXIT"\n', { mode: 0o755 }); + } + return spawnSync('bash', [path.join(root, 'tools/deploy.sh'), ...args], { encoding: 'utf8', env: { + ...process.env, PATH: bin + ':' + process.env.PATH, + AUDIT_COMMAND_LOG: path.join(directory, 'commands.log'), AUDIT_REMOTE_EXIT: failure ? '1' : '0' + } }); +} +test('deployment succeeds with mocked remote commands and copies all frontend modules', () => temporary(directory => { + const result = deploy(directory, ['-p', '2222', 'audit.invalid']); + assert.equal(result.status, 0, result.stderr); + const log = fs.readFileSync(path.join(directory, 'commands.log'), 'utf8'); + assert.match(log, /resources\/device-manager /); assert.match(log, /view\/device-manager\/devices.js/); + assert.match(log, /-P 2222/); assert.match(log, /rm -f \/etc\/uci-defaults\/80_device_manager/); + assert.doesNotMatch(result.stderr, /EOF/); +})); +test('deployment rejects missing or invalid SSH ports before connecting', () => temporary(directory => { + for (const args of [['-p'], ['-p', 'bad', 'audit.invalid'], ['-p', '0', 'audit.invalid'], ['-p', '65536', 'audit.invalid']]) { + const result = deploy(directory, args); + assert.notEqual(result.status, 0); assert.match(result.stderr, /SSH port must/); + } + assert.equal(fs.existsSync(path.join(directory, 'commands.log')), false); +})); +test('failed SSH connectivity prevents deployment', () => temporary(directory => { + const result = deploy(directory, ['audit.invalid'], true); + assert.notEqual(result.status, 0); assert.match(result.stderr, /Failed to connect/); + assert.doesNotMatch(fs.readFileSync(path.join(directory, 'commands.log'), 'utf8'), /scp:/); +})); + +test('development deployment uses native translations and removes legacy bundled resources', () => temporary(directory => { + const result = deploy(directory, ['audit.invalid']); + assert.equal(result.status, 0, result.stderr); + const log = fs.readFileSync(path.join(directory, 'commands.log'), 'utf8'); + assert.match(log, /rm -f .*device-manager-builtin.zh-cn.lmo/); + assert.doesNotMatch(log, /scp:.*device-manager-builtin/); + assert.match(log, /i18n.js.*translations.js/); +})); diff --git a/luci-app-device-manager/test/test_table_sorting.js b/luci-app-device-manager/test/test_table_sorting.js new file mode 100644 index 000000000..03c2b083f --- /dev/null +++ b/luci-app-device-manager/test/test_table_sorting.js @@ -0,0 +1,309 @@ +'use strict'; +const assert = require('node:assert/strict'); +const test = require('node:test'); +const { environment, loadModule, backend } = require('./helpers/runtime'); + +const model = loadModule('device-manager.model', environment()); + +test('model.sortDevices sorts IPv4 addresses numerically in ascending and descending orders', () => { + const devices = [ + { mac: '00:00:00:00:00:03', ipv4: '192.168.1.100', hostname: 'PC-100' }, + { mac: '00:00:00:00:00:01', ipv4: '192.168.1.2', hostname: 'PC-2' }, + { mac: '00:00:00:00:00:04', ipv4: '', hostname: 'No-IP' }, + { mac: '00:00:00:00:00:02', ipv4: '192.168.1.10', hostname: 'PC-10' }, + { mac: '00:00:00:00:00:05', ipv4: '10.0.0.1', hostname: 'Router' } + ]; + + // Ascending: 10.0.0.1 -> 192.168.1.2 -> 192.168.1.10 -> 192.168.1.100 -> No-IP + const asc = model.sortDevices(devices, 'ip', 'asc'); + assert.deepEqual(asc.map(d => d.ipv4), [ + '10.0.0.1', + '192.168.1.2', + '192.168.1.10', + '192.168.1.100', + '' + ]); + + // Descending: 192.168.1.100 -> 192.168.1.10 -> 192.168.1.2 -> 10.0.0.1 -> No-IP (empty remains at bottom) + const desc = model.sortDevices(devices, 'ip', 'desc'); + assert.deepEqual(desc.map(d => d.ipv4), [ + '192.168.1.100', + '192.168.1.10', + '192.168.1.2', + '10.0.0.1', + '' + ]); +}); + +test('model.sortDevices handles IPv6-only devices and tie-breakers', () => { + const devices = [ + { mac: '00:00:00:00:00:02', ipv4: '', ipv6: 'fd00::2' }, + { mac: '00:00:00:00:00:01', ipv4: '', ipv6: 'fd00::1' }, + { mac: '00:00:00:00:00:03', ipv4: '', ipv6: '' } + ]; + + const sorted = model.sortDevices(devices, 'ip', 'asc'); + assert.equal(sorted[0].ipv6, 'fd00::1'); + assert.equal(sorted[1].ipv6, 'fd00::2'); + assert.equal(sorted[2].ipv6, ''); +}); + +test('model.sortDevices sorts by name, MAC, group, and type', () => { + const devices = [ + { mac: '00:00:00:00:00:03', customName: 'Zebra', group: 'grp2', type: 'phone' }, + { mac: '00:00:00:00:00:01', customName: 'Apple', group: 'grp1', type: 'laptop' }, + { mac: '00:00:00:00:00:02', customName: 'Banana', group: 'grp3', type: 'tv' } + ]; + + const byName = model.sortDevices(devices, 'name', 'asc'); + assert.deepEqual(byName.map(d => d.customName), ['Apple', 'Banana', 'Zebra']); + + const byMac = model.sortDevices(devices, 'mac', 'desc'); + assert.deepEqual(byMac.map(d => d.mac), ['00:00:00:00:00:03', '00:00:00:00:00:02', '00:00:00:00:00:01']); + + const byGroup = model.sortDevices(devices, 'group', 'asc', id => id); + assert.deepEqual(byGroup.map(d => d.group), ['grp1', 'grp2', 'grp3']); + + const byType = model.sortDevices(devices, 'type', 'asc'); + assert.deepEqual(byType.map(d => d.type), ['laptop', 'phone', 'tv']); +}); + +test('clicking the IP column header sorts devices in the table', async () => { + const initial = [ + { '.type': 'device', '.name': 'dev_1', mac: '00:11:22:33:44:01', name: 'Dev-1' }, + { '.type': 'device', '.name': 'dev_2', mac: '00:11:22:33:44:02', name: 'Dev-2' }, + { '.type': 'device', '.name': 'dev_3', mac: '00:11:22:33:44:03', name: 'Dev-3' } + ]; + + const mocks = backend(initial); + mocks.replies['luci.device-manager.get_online_status'] = { + ok: true, + neighbors: [ + { mac: '00:11:22:33:44:01', state: 'REACHABLE' }, + { mac: '00:11:22:33:44:02', state: 'REACHABLE' }, + { mac: '00:11:22:33:44:03', state: 'REACHABLE' } + ] + }; + mocks.replies['luci-rpc.getHostHints'] = { + '00:11:22:33:44:01': { name: 'host1', ipaddrs: ['192.168.1.100'] }, + '00:11:22:33:44:02': { name: 'host2', ipaddrs: ['192.168.1.5'] }, + '00:11:22:33:44:03': { name: 'host3', ipaddrs: ['192.168.1.20'] } + }; + + const env = environment(mocks); + const page = loadModule('view.device-manager.devices', env); + + const data = await page.load(); + const viewNode = page.render(data); + + const table = viewNode.querySelector('#device_manager_table'); + const thList = table.querySelectorAll('th'); + assert.equal(thList.length, 8); + assert.ok(thList[0].classes.has('dm-index-column')); + const ipTh = thList.find(th => th.getAttribute('data-sort') === 'ip'); + assert.ok(ipTh); + assert.equal(ipTh.classes.has('dm-sortable'), true); + assert.equal(ipTh.querySelector('button').attrs.type, 'button'); + + // First click: Sort by IP ascending (192.168.1.5 -> 192.168.1.20 -> 192.168.1.100) + await ipTh.querySelector('button').click(); + assert.equal(page.sortKey, 'ip'); + assert.equal(page.sortDir, 'asc'); + assert.equal(ipTh.classes.has('sorted-asc'), true); + assert.equal(ipTh.getAttribute('aria-sort'), 'ascending'); + + let tbody = viewNode.querySelector('#device_manager_tbody'); + let rows = tbody.querySelectorAll('tr'); + assert.equal(rows.length, 3); + assert.deepEqual(rows.map(row => row.querySelector('.dm-index-column').textContent), ['1', '2', '3']); + // 4th td in each row is IP address + assert.ok(rows[0].querySelectorAll('td')[3].textContent.includes('192.168.1.5')); + assert.ok(rows[1].querySelectorAll('td')[3].textContent.includes('192.168.1.20')); + assert.ok(rows[2].querySelectorAll('td')[3].textContent.includes('192.168.1.100')); + + // Second click: Sort by IP descending (192.168.1.100 -> 192.168.1.20 -> 192.168.1.5) + await ipTh.querySelector('button').click(); + assert.equal(page.sortKey, 'ip'); + assert.equal(page.sortDir, 'desc'); + assert.equal(ipTh.classes.has('sorted-desc'), true); + assert.equal(ipTh.getAttribute('aria-sort'), 'descending'); + + tbody = viewNode.querySelector('#device_manager_tbody'); + rows = tbody.querySelectorAll('tr'); + assert.ok(rows[0].querySelectorAll('td')[3].textContent.includes('192.168.1.100')); + assert.ok(rows[1].querySelectorAll('td')[3].textContent.includes('192.168.1.20')); + assert.ok(rows[2].querySelectorAll('td')[3].textContent.includes('192.168.1.5')); + assert.deepEqual(rows.map(row => row.querySelector('.dm-index-column').textContent), ['1', '2', '3']); + + // Click Name header: switch sorting to Name + const nameTh = thList.find(th => th.getAttribute('data-sort') === 'name'); + assert.ok(nameTh); + await nameTh.querySelector('button').click(); + assert.equal(page.sortKey, 'name'); + assert.equal(page.sortDir, 'asc'); + assert.equal(ipTh.classes.has('sorted-asc'), false); + assert.equal(ipTh.classes.has('sorted-desc'), false); + assert.equal(ipTh.getAttribute('aria-sort'), 'none'); + assert.equal(nameTh.classes.has('sorted-asc'), true); + assert.equal(nameTh.getAttribute('aria-sort'), 'ascending'); + + page.filterText = 'host2'; + page.updateView(); + rows = tbody.querySelectorAll('tr'); + assert.equal(rows.length, 1); + assert.equal(rows[0].querySelector('.dm-index-column').textContent, '1'); + assert.ok(rows[0].querySelectorAll('td')[3].textContent.includes('192.168.1.5')); + page.filterText = 'no matching device'; + page.updateView(); + assert.equal(tbody.querySelector('td').getAttribute('colspan'), thList.length); +}); + +test('status indicator renders as a small dot without text, and default tab is Online followed by All', async () => { + const initial = [ + { '.type': 'device', '.name': 'dev_1', mac: '00:11:22:33:44:01', name: 'Dev-Online' } + ]; + + const mocks = backend(initial); + mocks.replies['luci.device-manager.get_online_status'] = { + ok: true, + neighbors: [{ mac: '00:11:22:33:44:01', state: 'REACHABLE' }] + }; + mocks.replies['luci-rpc.getHostHints'] = { + '00:11:22:33:44:01': { name: 'host1', ipaddrs: ['192.168.1.10'] } + }; + + const env = environment(mocks); + const page = loadModule('view.device-manager.devices', env); + + const data = await page.load(); + const viewNode = page.render(data); + + // Check tabs order: 0 is online, 1 is all + const tabs = viewNode.querySelector('#dm-tabs-container').children; + assert.equal(tabs[0].getAttribute('data-tab'), 'online'); + assert.equal(tabs[1].getAttribute('data-tab'), 'all'); + assert.equal(page.activeTab, 'online'); + assert.equal(tabs[0].classes.has('active'), true); + + // Check row has dot and no badge text + const row = viewNode.querySelector('#device_manager_tbody').querySelectorAll('tr')[0]; + const dot = row.querySelector('.dm-status-dot'); + assert.ok(dot); + assert.equal(dot.classes.has('dm-status-dot-online'), true); + assert.equal(dot.textContent, ''); // No text displayed inside the dot! + assert.ok(dot.attrs.title.includes('Online') || dot.attrs.title.includes('REACHABLE')); +}); + +test('table row display omits no-hostname subtitle, prefix from hostname subtitle, and leaves ungrouped and remark blank', async () => { + const initial = [ + { '.type': 'device', '.name': 'dev_1', mac: '00:11:22:33:44:01', name: 'My-Server', group: 'ungrouped', remark: '' }, + { '.type': 'device', '.name': 'dev_2', mac: '00:11:22:33:44:02', name: '', group: 'ungrouped', remark: '' } + ]; + const mocks = backend(initial); + mocks.replies['luci.device-manager.get_online_status'] = { + ok: true, + neighbors: [ + { mac: '00:11:22:33:44:01', state: 'REACHABLE' }, + { mac: '00:11:22:33:44:02', state: 'REACHABLE' } + ] + }; + mocks.replies['luci-rpc.getHostHints'] = { + '00:11:22:33:44:01': { name: 'NAS', ipaddrs: ['192.168.1.10'] }, + '00:11:22:33:44:02': { name: '', ipaddrs: ['192.168.1.11'] } + }; + + const env = environment(mocks); + const page = loadModule('view.device-manager.devices', env); + + const data = await page.load(); + const viewNode = page.render(data); + + const rows = viewNode.querySelector('#device_manager_tbody').querySelectorAll('tr'); + assert.equal(rows.length, 2); + + // dev_1 has customName 'My-Server' and hostname 'NAS' + const row1 = rows[0]; + const row1Tds = row1.querySelectorAll('td'); + const titleRow1 = row1Tds[2].querySelector('.dm-device-title-row'); + assert.ok(titleRow1.textContent.includes('My-Server')); + const subtitle1 = row1Tds[2].querySelector('.dm-device-subtitle'); + assert.ok(subtitle1); + assert.equal(subtitle1.textContent, 'NAS'); // Only NAS, no "Hostname: " or "主机名: " prefix + assert.equal(row1Tds[5].textContent, ''); + assert.equal(row1Tds[6].textContent, ''); + + // dev_2 has no customName and no hostname + const row2 = rows[1]; + const row2Tds = row2.querySelectorAll('td'); + const subtitle2 = row2Tds[2].querySelector('.dm-device-subtitle'); + assert.equal(subtitle2, null); // No "No hostname detected" or "无检测主机名" + assert.equal(row2Tds[5].textContent, ''); + assert.equal(row2Tds[6].textContent, ''); +}); + +test('mask/show info button toggles MAC and IPv6 masking with asterisks and remembers preference', async () => { + let stored = null; + const initial = [ + { '.type': 'device', '.name': 'dev_1', mac: '00:11:22:33:44:01', name: 'PC' } + ]; + const mocks = backend(initial); + mocks.replies['luci.device-manager.get_online_status'] = { + ok: true, + neighbors: [{ mac: '00:11:22:33:44:01', state: 'REACHABLE' }] + }; + mocks.replies['luci-rpc.getHostHints'] = { + '00:11:22:33:44:01': { name: 'PC', ipaddrs: ['192.168.1.10'], ip6addrs: ['fe80::1234:5678:9abc:def0'] } + }; + + const env = environment(Object.assign({}, mocks, { + window: { + localStorage: { + getItem: () => stored, + setItem: (k, v) => { stored = v; } + } + } + })); + const page = loadModule('view.device-manager.devices', env); + + const data = await page.load(); + const viewNode = page.render(data); + + // By default maskInfo is false: MAC and IPv6 are unmasked + let row = viewNode.querySelector('#device_manager_tbody').querySelectorAll('tr')[0]; + let rowTds = row.querySelectorAll('td'); + assert.ok(rowTds[3].textContent.includes('192.168.1.10')); // IPv4 + assert.ok(rowTds[3].textContent.includes('fe80::1234:5678:9abc:def0')); // IPv6 unmasked + assert.ok(rowTds[4].textContent.includes('00:11:22:33:44:01')); // MAC unmasked + + // Click mask toggle button + const maskBtn = viewNode.querySelector('#dm-btn-mask'); + assert.ok(maskBtn); + await maskBtn.click(); + + // After clicking, maskInfo is true + assert.equal(page.maskInfo, true); + assert.deepEqual(JSON.parse(stored), { tab: 'online', group: 'all', maskInfo: true }); + + row = viewNode.querySelector('#device_manager_tbody').querySelectorAll('tr')[0]; + rowTds = row.querySelectorAll('td'); + // IPv4 remains completely unchanged + assert.ok(rowTds[3].textContent.includes('192.168.1.10')); + // IPv6 is masked with asterisks + assert.ok(rowTds[3].textContent.includes('****::****:****:****:****')); + assert.ok(!rowTds[3].textContent.includes('fe80')); + // MAC is masked with asterisks + assert.ok(rowTds[4].textContent.includes('**:**:**:**:**:**')); + assert.ok(!rowTds[4].textContent.includes('00:11:22')); + + // Test persistence on page reload + const pageReload = loadModule('view.device-manager.devices', env); + const reloadedData = await pageReload.load(); + const reloadedView = pageReload.render(reloadedData); + assert.equal(pageReload.maskInfo, true); + + row = reloadedView.querySelector('#device_manager_tbody').querySelectorAll('tr')[0]; + rowTds = row.querySelectorAll('td'); + assert.ok(rowTds[3].textContent.includes('192.168.1.10')); + assert.ok(rowTds[3].textContent.includes('****::****:****:****:****')); + assert.ok(rowTds[4].textContent.includes('**:**:**:**:**:**')); +}); diff --git a/luci-app-device-manager/tools/configure-sdk.py b/luci-app-device-manager/tools/configure-sdk.py new file mode 100644 index 000000000..baf15eed3 --- /dev/null +++ b/luci-app-device-manager/tools/configure-sdk.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +"""Select the plugin and Chinese translation without the SDK's all-package defaults.""" +import pathlib +import re +import sys + + +def configure(directory): + directory = pathlib.Path(directory) + if not (directory / "rules.mk").is_file() or not (directory / "Makefile").is_file(): + raise ValueError("Expected an OpenWrt SDK directory") + config = directory / ".config" + lines = config.read_text().splitlines() if config.exists() else [] + pattern = r"(?:# )?CONFIG_(?:ALL(?:_NONSHARED|_KMODS)?\b|PACKAGE_|LUCI_LANG_)" + retained = [line for line in lines if not re.match(pattern, line)] + selected = [ + "# CONFIG_ALL is not set", "# CONFIG_ALL_NONSHARED is not set", "# CONFIG_ALL_KMODS is not set", + "CONFIG_PACKAGE_luci-app-device-manager=m", "CONFIG_LUCI_LANG_zh_Hans=y", + "CONFIG_PACKAGE_luci-i18n-device-manager-zh-cn=m", + ] + config.write_text("\n".join(retained + selected) + "\n") + + +if __name__ == "__main__": + if len(sys.argv) != 2: + sys.exit("Usage: configure-sdk.py ") + configure(sys.argv[1]) diff --git a/luci-app-device-manager/tools/deploy.sh b/luci-app-device-manager/tools/deploy.sh new file mode 100755 index 000000000..c3b2bf166 --- /dev/null +++ b/luci-app-device-manager/tools/deploy.sh @@ -0,0 +1,258 @@ +#!/usr/bin/env bash +# +# deploy.sh - Deploy luci-app-device-manager directly to an OpenWrt router for development/testing. +# SSH authentication is performed once; all deployment commands reuse that connection. +# +# Usage: +# ./tools/deploy.sh [options] +# +# Examples: +# ./tools/deploy.sh 192.168.1.1 +# ./tools/deploy.sh root@192.168.1.1 +# ./tools/deploy.sh -p 2222 root@192.168.1.1 +# + +set -euo pipefail + +# Color formatting +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +NC='\033[0m' # No Color + +print_info() { + printf "${BLUE}[INFO]${NC} %s\n" "$1" +} + +print_success() { + printf "${GREEN}[SUCCESS]${NC} %s\n" "$1" +} + +print_error() { + printf "${RED}[ERROR]${NC} %s\n" "$1" >&2 +} + +show_usage() { + cat < + +Arguments: + Router IP address or SSH user@host (e.g., 192.168.1.1 or root@192.168.1.1) + +Options: + -p, --port SSH port number (default: 22) + -h, --help Show this help message + +Examples: + $0 192.168.1.1 + $0 root@192.168.1.1 + $0 -p 2222 root@192.168.31.1 +EOF +} + +# Parse options +SSH_PORT="22" +TARGET="" + +while [[ $# -gt 0 ]]; do + case "$1" in + -p|--port) + if [[ $# -lt 2 || ! "$2" =~ ^[0-9]+$ || ${#2} -gt 5 ]] || (( 10#$2 < 1 || 10#$2 > 65535 )); then + print_error "SSH port must be an integer between 1 and 65535." + exit 1 + fi + SSH_PORT="$2" + shift 2 + ;; + -h|--help) + show_usage + exit 0 + ;; + -*) + print_error "Unknown option: $1" + show_usage + exit 1 + ;; + *) + if [[ -z "$TARGET" ]]; then + TARGET="$1" + else + print_error "Unexpected argument: $1" + show_usage + exit 1 + fi + shift + ;; + esac +done + +if [[ -z "$TARGET" ]]; then + print_error "Target router address is required." + show_usage + exit 1 +fi + +# Normalize SSH target +if [[ "$TARGET" != *"@"* ]]; then + SSH_TARGET="root@${TARGET}" + ROUTER_IP="${TARGET}" +else + SSH_TARGET="${TARGET}" + ROUTER_IP="${TARGET#*@}" +fi + +# Check prerequisites +for cmd in ssh scp mktemp; do + if ! command -v "$cmd" &>/dev/null; then + print_error "Required local command '$cmd' is not found in PATH." + exit 1 + fi +done + +# Resolve workspace directory +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +# Verify essential project files exist +if [[ ! -f "${PROJECT_ROOT}/htdocs/luci-static/resources/view/device-manager/devices.js" ]]; then + print_error "Missing frontend view file: htdocs/.../devices.js" + exit 1 +fi + +if [[ ! -f "${PROJECT_ROOT}/root/usr/share/luci/menu.d/luci-app-device-manager.json" ]]; then + print_error "Missing menu definition file: root/.../menu.d/luci-app-device-manager.json" + exit 1 +fi + +if [[ ! -f "${PROJECT_ROOT}/root/usr/share/rpcd/acl.d/luci-app-device-manager.json" ]]; then + print_error "Missing ACL definition file: root/.../acl.d/luci-app-device-manager.json" + exit 1 +fi + +for resource in model.js service.js preferences.js table.js device-dialog.js group-dialog.js styles.css; do + if [[ ! -f "${PROJECT_ROOT}/htdocs/luci-static/resources/device-manager/${resource}" ]]; then + print_error "Missing frontend resource: device-manager/${resource}" + exit 1 + fi +done + +if [[ ! -f "${PROJECT_ROOT}/root/usr/libexec/rpcd/luci.device-manager" || ! -f "${PROJECT_ROOT}/tools/initialize-config.sh" ]]; then + print_error "Missing rpcd helper or configuration initializer." + exit 1 +fi + +# Keep the control socket private and its path short enough for Unix socket limits. +SSH_CONTROL_DIR="$(mktemp -d /tmp/luci-deploy.XXXXXX)" +SSH_CONTROL_PATH="${SSH_CONTROL_DIR}/ssh" +SSH_COMMON_OPTS=(-o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new -o "ControlPath=${SSH_CONTROL_PATH}") +SSH_OPTS=(-p "${SSH_PORT}" "${SSH_COMMON_OPTS[@]}" -o ControlMaster=no -o BatchMode=yes) +SCP_OPTS=(-P "${SSH_PORT}" "${SSH_COMMON_OPTS[@]}" -o ControlMaster=no -o BatchMode=yes) + +cleanup() { + ssh "${SSH_OPTS[@]}" -O exit "${SSH_TARGET}" >/dev/null 2>&1 || true + rm -rf -- "${SSH_CONTROL_DIR}" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +print_info "Connecting to OpenWrt router at ${SSH_TARGET} (port: ${SSH_PORT})..." + +# Authenticate once and keep the master connection open until cleanup. +if ! ssh -p "${SSH_PORT}" "${SSH_COMMON_OPTS[@]}" -o BatchMode=no -o ControlPersist=no -M -N -f "${SSH_TARGET}"; then + print_error "Failed to connect to ${SSH_TARGET} via SSH. Please check network, IP, port, and SSH key/password." + exit 1 +fi + +if ! ssh "${SSH_OPTS[@]}" "${SSH_TARGET}" "command -v ip >/dev/null && command -v flock >/dev/null && test -r /usr/share/libubox/jshn.sh"; then + print_error "Router is missing ip, flock or jshn. Standard OpenWrt BusyBox provides ip and flock; custom builds may need ip-tiny and flock. Install jshn if missing." + exit 1 +fi + +print_info "Connection verified. Preparing remote directories..." + +ssh "${SSH_OPTS[@]}" "${SSH_TARGET}" " + mkdir -p /www/luci-static/resources/view/device-manager \ + /usr/share/luci/menu.d \ + /usr/share/rpcd/acl.d \ + /usr/lib/lua/luci/i18n \ + /usr/libexec/rpcd \ + /etc/uci-defaults \ + /etc/config +" + +print_info "Deploying shared frontend modules and stylesheet..." +scp "${SCP_OPTS[@]}" -r \ + "${PROJECT_ROOT}/htdocs/luci-static/resources/device-manager" \ + "${SSH_TARGET}:/www/luci-static/resources/" + +print_info "Deploying frontend view (devices.js)..." +scp "${SCP_OPTS[@]}" \ + "${PROJECT_ROOT}/htdocs/luci-static/resources/view/device-manager/devices.js" \ + "${SSH_TARGET}:/www/luci-static/resources/view/device-manager/devices.js" + +print_info "Deploying LuCI menu and rpcd ACL definitions..." +scp "${SCP_OPTS[@]}" \ + "${PROJECT_ROOT}/root/usr/share/luci/menu.d/luci-app-device-manager.json" \ + "${SSH_TARGET}:/usr/share/luci/menu.d/luci-app-device-manager.json" + +scp "${SCP_OPTS[@]}" \ + "${PROJECT_ROOT}/root/usr/share/rpcd/acl.d/luci-app-device-manager.json" \ + "${SSH_TARGET}:/usr/share/rpcd/acl.d/luci-app-device-manager.json" + +# Native translations are installed as the matching luci-i18n package. +# Remove files left by older development deployments without changing LuCI language. +ssh "${SSH_OPTS[@]}" "${SSH_TARGET}" " + rm -f /www/luci-static/resources/device-manager/i18n.js /www/luci-static/resources/device-manager/translations.js + rm -f /usr/lib/lua/luci/i18n/device-manager-builtin.zh-cn.lmo /etc/uci-defaults/81_device_manager_i18n +" + +if [[ -f "${PROJECT_ROOT}/root/usr/libexec/rpcd/luci.device-manager" ]]; then + print_info "Deploying rpcd helper backend script..." + scp "${SCP_OPTS[@]}" \ + "${PROJECT_ROOT}/root/usr/libexec/rpcd/luci.device-manager" \ + "${SSH_TARGET}:/usr/libexec/rpcd/luci.device-manager" + ssh "${SSH_OPTS[@]}" "${SSH_TARGET}" "chmod +x /usr/libexec/rpcd/luci.device-manager" +fi + +if [[ -f "${PROJECT_ROOT}/tools/initialize-config.sh" ]]; then + print_info "Deploying development configuration initializer..." + scp "${SCP_OPTS[@]}" \ + "${PROJECT_ROOT}/tools/initialize-config.sh" \ + "${SSH_TARGET}:/etc/uci-defaults/80_device_manager" +fi + +print_info "Ensuring configuration persistence (protecting existing user remarks)..." +ssh "${SSH_OPTS[@]}" "${SSH_TARGET}" " + set -e + if [ ! -f /etc/config/device_manager ]; then + echo 'Initializing default /etc/config/device_manager' + else + echo 'Existing /etc/config/device_manager preserved.' + fi + if [ -f /etc/uci-defaults/80_device_manager ]; then + chmod +x /etc/uci-defaults/80_device_manager + /etc/uci-defaults/80_device_manager + rm -f /etc/uci-defaults/80_device_manager + fi +" + +print_info "Clearing LuCI cache and restarting rpcd..." +ssh "${SSH_OPTS[@]}" "${SSH_TARGET}" " + rm -f /tmp/luci-indexcache* /tmp/luci-modulecache* + /etc/init.d/rpcd restart +" + +print_success "Deployment completed successfully!" +printf "\n" +printf "${CYAN}================================================================${NC}\n" +printf " ${GREEN}luci-app-device-manager${NC} is now active on your router.\n" +printf "\n" +printf " Access URL: ${YELLOW}http://%s/cgi-bin/luci/admin/network/device-manager${NC}\n" "${ROUTER_IP}" +printf " Menu path: ${YELLOW}网络 -> 设备管理${NC}\n" +printf "\n" +printf " ${YELLOW}Note:${NC} If you are already logged in, please refresh the page\n" +printf " with ${CYAN}Ctrl + Shift + R${NC} to clear browser JavaScript cache.\n" +printf "${CYAN}================================================================${NC}\n" diff --git a/luci-app-device-manager/tools/initialize-config.sh b/luci-app-device-manager/tools/initialize-config.sh new file mode 100755 index 000000000..dde543b3c --- /dev/null +++ b/luci-app-device-manager/tools/initialize-config.sh @@ -0,0 +1,24 @@ +#!/bin/sh +# SPDX-License-Identifier: MIT +set -e + +# Existing configuration, including an intentionally empty file, is authoritative. +# Development deployment only; packages ship /etc/config/device_manager directly. +[ -e /etc/config/device_manager ] && exit 0 + +touch /etc/config/device_manager +trap 'uci -q revert device_manager || :; rm -f /etc/config/device_manager' EXIT +uci -q batch <<'EOF' +set device_manager.smart_home=group +set device_manager.smart_home.name='Smart home' +set device_manager.phone=group +set device_manager.phone.name='Phones' +set device_manager.computer=group +set device_manager.computer.name='Computers' +set device_manager.network=group +set device_manager.network.name='Network devices' +set device_manager.other=group +set device_manager.other.name='Other devices' +commit device_manager +EOF +trap - EXIT diff --git a/luci-app-device-manager/tools/package-version.py b/luci-app-device-manager/tools/package-version.py new file mode 100644 index 000000000..535f0f614 --- /dev/null +++ b/luci-app-device-manager/tools/package-version.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python3 +"""Print a LuCI-style version from this source repository for standalone SDK builds.""" +import datetime +import pathlib +import subprocess + +ROOT = pathlib.Path(__file__).resolve().parents[1] + + +def version(): + stamp, revision = subprocess.check_output( + ["git", "log", "-1", "--format=%ct %h", "--abbrev=7", "--", "."], + cwd=ROOT, text=True).strip().split() + moment = datetime.datetime.fromtimestamp(int(stamp), datetime.timezone.utc) + return f"{moment:%y}.{moment.timetuple().tm_yday:03d}.{int(stamp) % 86400:05d}~{revision}" + + +if __name__ == "__main__": + print(version()) diff --git a/luci-app-device-manager/tools/prepare-upstream.py b/luci-app-device-manager/tools/prepare-upstream.py new file mode 100644 index 000000000..f4f710639 --- /dev/null +++ b/luci-app-device-manager/tools/prepare-upstream.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Export only package sources into a LuCI checkout without modifying Git history.""" +import pathlib +import shutil +import sys + +ROOT = pathlib.Path(__file__).resolve().parents[1] + + +def prepare(directory): + directory = pathlib.Path(directory).resolve() + if not (directory / "luci.mk").is_file(): + raise ValueError("Expected a LuCI checkout containing luci.mk") + target = directory / "applications/luci-app-device-manager" + if target.exists(): + raise FileExistsError(f"Refusing to overwrite existing package: {target}") + target.mkdir(parents=True) + for name in ["htdocs", "root", "po"]: + shutil.copytree(ROOT / name, target / name) + for name in ["LICENSE"]: + shutil.copyfile(ROOT / name, target / name) + readme = (ROOT / "README.md").read_text() + start = readme.index("## 安装 / Installation") + end = readme.index("## 扫描范围 / Scan scope", start) + readme = readme[:start] + ( + "## Installation\n\n" + "On OpenWrt releases whose feeds include this application, install it\n" + "from the configured package repositories.\n\n" + "For systems using opkg:\n\n" + "```sh\n" + "opkg update\n" + "opkg install luci-app-device-manager\n" + "opkg install luci-i18n-device-manager-zh-cn\n" + "```\n\n" + "For systems using apk:\n\n" + "```sh\n" + "apk update\n" + "apk add luci-app-device-manager\n" + "apk add luci-i18n-device-manager-zh-cn\n" + "```\n\n" + "The Chinese language package is optional. Without a translation, LuCI\n" + "displays the English source messages. Reload LuCI after installation\n" + "and open **Network → Device Manager**.\n\n" + "Dependencies are resolved by the package manager. Standard OpenWrt\n" + "BusyBox supplies the required `ip` and `flock` applets. Custom firmware\n" + "that disables these applets must enable them or install `ip-tiny`\n" + "(or `ip-full`) and `flock` separately.\n\n" + ) + readme[end:] + start = readme.index("## 构建和开发") + end = readme.index("## 真实设备验收", start) + readme = readme[:start] + ( + "## Development\n\n" + "This application is built in the LuCI feed with the standard luci.mk.\n" + "Independent SDK workflows, tests and deployment tools are maintained in\n" + "[the source repository](https://github.com/xxi-arch/luci-app-device-manager).\n\n" + ) + readme[end:] + (target / "README.md").write_text(readme) + # Package-specific attribution and usage documentation, excluding release tooling. + shutil.copytree(ROOT / "docs", target / "docs", + ignore=shutil.ignore_patterns("upstream-submission.md", "validation.md")) + makefile = (ROOT / "Makefile").read_text() + start = makefile.index("# In-tree LuCI applications") + end = makefile.index("# call BuildPackage", start) + (target / "Makefile").write_text(makefile[:start] + "include ../../luci.mk\n\n" + makefile[end:]) + return target + + +if __name__ == "__main__": + if len(sys.argv) != 2: + sys.exit("Usage: prepare-upstream.py ") + print(prepare(sys.argv[1])) diff --git a/luci-app-device-manager/tools/resolve-sdk.py b/luci-app-device-manager/tools/resolve-sdk.py new file mode 100644 index 000000000..f03a139e4 --- /dev/null +++ b/luci-app-device-manager/tools/resolve-sdk.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +"""Resolve an official release or snapshot SDK URL and checksum for the requested target.""" +import re +import sys +from html.parser import HTMLParser +from urllib.request import urlopen + + +class Links(HTMLParser): + def __init__(self): + super().__init__() + self.files = set() + + def handle_starttag(self, tag, attrs): + if tag == "a": + self.files.update(value for key, value in attrs if key == "href" and value) + + +def resolve(version, target): + if version != "SNAPSHOT" and not re.fullmatch(r"\d+\.\d+\.\d+(?:-rc\d+)?", version): + raise ValueError("Expected SNAPSHOT or an OpenWrt release version, e.g. 24.10.0") + if not re.fullmatch(r"[a-z0-9_-]+/[a-z0-9_-]+", target): + raise ValueError("Expected an OpenWrt target/subtarget, e.g. x86/64") + base = (f"https://downloads.openwrt.org/snapshots/targets/{target}/" if version == "SNAPSHOT" + else f"https://downloads.openwrt.org/releases/{version}/targets/{target}/") + with urlopen(base, timeout=30) as response: + listing = response.read().decode("utf-8") + links = Links() + links.feed(listing) + sdk_prefix = "openwrt-sdk-" if version == "SNAPSHOT" else f"openwrt-sdk-{re.escape(version)}-" + pattern = rf"{sdk_prefix}[A-Za-z0-9_.+-]+\.Linux-x86_64\.tar\.(?:xz|zst)" + matches = sorted(filename for filename in links.files if re.fullmatch(pattern, filename)) + if len(matches) != 1: + raise ValueError(f"Expected one x86_64 host SDK at {base}; found {len(matches)}") + filename = matches[0] + with urlopen(base + "sha256sums", timeout=30) as response: + checksums = response.read().decode("utf-8") + for line in checksums.splitlines(): + match = re.fullmatch(r"([a-fA-F0-9]{64})\s+\*?(.+)", line) + if match and match[2] == filename: + return base + filename, match[1].lower() + raise ValueError(f"No checksum found for {filename}") + + +if __name__ == "__main__": + if len(sys.argv) != 3: + sys.exit("Usage: resolve-sdk.py ") + sdk_url, sdk_sha256 = resolve(sys.argv[1], sys.argv[2]) + print(f"sdk_url={sdk_url}") + print(f"sdk_sha256={sdk_sha256}") diff --git a/luci-app-device-manager/tools/update-translations.py b/luci-app-device-manager/tools/update-translations.py new file mode 100644 index 000000000..66d85b138 --- /dev/null +++ b/luci-app-device-manager/tools/update-translations.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +"""Extract native LuCI messages and merge the PO catalogue using official tools.""" +import argparse +import pathlib +import subprocess +import tempfile + +ROOT = pathlib.Path(__file__).resolve().parents[1] + + +def extract(luci_dir): + scanner = pathlib.Path(luci_dir).resolve() / "build/i18n-scan.pl" + if not scanner.is_file(): + raise ValueError("Expected a LuCI checkout containing build/i18n-scan.pl") + return subprocess.run(["perl", str(scanner), "htdocs", "root"], cwd=ROOT, + check=True, capture_output=True, text=True).stdout + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--luci-dir", required=True, help="Official LuCI checkout") + parser.add_argument("--check", action="store_true") + args = parser.parse_args() + template = ROOT / "po/templates/device-manager.pot" + catalogue = ROOT / "po/zh_Hans/device-manager.po" + content = extract(args.luci_dir) + with tempfile.TemporaryDirectory(prefix="device-manager-i18n-") as directory: + fresh = pathlib.Path(directory) / "device-manager.pot" + fresh.write_text(content) + if args.check: + if template.read_text() != content: + parser.error("Translation template is stale; regenerate with --luci-dir") + subprocess.run(["msgcmp", "--use-fuzzy", str(catalogue), str(fresh)], check=True) + subprocess.run(["msgfmt", "--check", "-o", str(pathlib.Path(directory) / "catalogue.mo"), + str(catalogue)], check=True) + else: + template.write_text(content) + subprocess.run(["msgmerge", "--update", "--no-fuzzy-matching", "--no-wrap", + "--backup=none", str(catalogue), str(fresh)], check=True) + + +if __name__ == "__main__": + main() diff --git a/luci-app-honk/Makefile b/luci-app-honk/Makefile index 3f939adf4..51f44fd42 100644 --- a/luci-app-honk/Makefile +++ b/luci-app-honk/Makefile @@ -7,7 +7,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-honk PKG_VERSION:=1.1.4 -PKG_RELEASE:=19 +PKG_RELEASE:=20 LUCI_TITLE:=LuCI support for honk LUCI_PKGARCH:=all diff --git a/luci-app-honk/root/www/luci-static/resources/honk/common.js b/luci-app-honk/root/www/luci-static/resources/honk/common.js index 6ffaecd25..94e13c538 100644 --- a/luci-app-honk/root/www/luci-static/resources/honk/common.js +++ b/luci-app-honk/root/www/luci-static/resources/honk/common.js @@ -94,6 +94,23 @@ function isServiceEnabled() { return ((sections[0] || {}).enabled === '1'); } +function updateStatus(data, targetEl) { + var el = targetEl || document.getElementById('honk_status'); + if (!el) return; + if (data && data.running) { + var mem = data.memory ? ' (' + _('Memory Usage') + ': ' + data.memory + ')' : ''; + dom.content(el, [ + E('span', { 'style': 'color: var(--success, #22c55e); font-weight: bold;' }, _('HONK') + ' ' + _('RUNNING')), + ' ', + E('span', { 'style': 'color: var(--text-muted, #888); font-size: 0.9em;' }, mem) + ]); + } else { + dom.content(el, [ + E('span', { 'style': 'color: var(--danger, #ef4444); font-weight: bold;' }, _('HONK') + ' ' + _('NOT RUNNING')) + ]); + } +} + function waitForHonkState(wantRunning, attempts) { attempts = attempts || 10; @@ -104,15 +121,23 @@ function waitForHonkState(wantRunning, attempts) { callHonkStatus().then(function(st) { var running = !!(st && st.running); - if (running === wantRunning || ++tries >= attempts) - resolve({ running: running }); - else + if (running === wantRunning || ++tries >= attempts) { + updateStatus(st); + if (poll && typeof(poll.start) === 'function') + poll.start(); + resolve({ running: running, memory: st ? st.memory : null }); + } else { setTimeout(step, 1200); + } }).catch(function() { - if (++tries >= attempts) + if (++tries >= attempts) { + updateStatus({ running: false }); + if (poll && typeof(poll.start) === 'function') + poll.start(); resolve({ running: null }); - else + } else { setTimeout(step, 1200); + } }); } @@ -137,6 +162,25 @@ function switchDashboardAndWait(type) { }); } +function applyChangesAndWait(mode) { + return new Promise(function(resolve) { + var timer = null; + var onApplied = function() { + if (timer) clearTimeout(timer); + document.removeEventListener('uci-applied', onApplied); + resolve(); + }; + + var holdoff = (window.L && L.env && +L.env.apply_holdoff) || 4; + var timeout = (window.L && L.env && +L.env.apply_timeout) || 5; + timer = setTimeout(onApplied, (holdoff + timeout) * 1000); + + document.addEventListener('uci-applied', onApplied); + + ui.changes.apply(mode == '0'); + }); +} + var DOWNLOAD_MAX_TICKS = 900; var DOWNLOAD_STALE_TICKS = 90; var activeDownloadCancel = null; @@ -621,6 +665,7 @@ function createConfigFileView(filePath, mapTitle, mapDesc, fieldTitle, successMs return { skipped: true }; return needRestart ? waitForHonkState(true) : callHonkStatus().then(function(st) { + updateStatus(st); return { running: !!(st && st.running) }; }); }).then(function(st) { @@ -750,30 +795,18 @@ function renderStatusHeader() { ]) ]); - function updateStatus(data) { - if (!statusEl) return; - if (data && data.running) { - var mem = data.memory ? ' (' + _('Memory Usage') + ': ' + data.memory + ')' : ''; - dom.content(statusEl, [ - E('span', { 'style': 'color: var(--success, #22c55e); font-weight: bold;' }, _('HONK') + ' ' + _('RUNNING')), - ' ', - E('span', { 'style': 'color: var(--text-muted, #888); font-size: 0.9em;' }, mem) - ]); - } else { - dom.content(statusEl, [ - E('span', { 'style': 'color: var(--danger, #ef4444); font-weight: bold;' }, _('HONK') + ' ' + _('NOT RUNNING')) - ]); - } - } - - callHonkStatus().then(updateStatus); + callHonkStatus().then(function(st) { + updateStatus(st, statusEl); + }); var pollStatusFn = function() { if (!document.body.contains(statusEl)) { poll.remove(pollStatusFn); return Promise.resolve(); } - return callHonkStatus().then(updateStatus).catch(function() { }); + return callHonkStatus().then(function(st) { + updateStatus(st, statusEl); + }).catch(function() { }); }; poll.add(pollStatusFn, 5); @@ -809,5 +842,7 @@ return baseclass.extend({ waitForHonkState: waitForHonkState, switchDashboardAndWait: switchDashboardAndWait, isServiceEnabled: isServiceEnabled, - showNotification: showNotification + showNotification: showNotification, + updateStatus: updateStatus, + applyChangesAndWait: applyChangesAndWait }); diff --git a/luci-app-honk/root/www/luci-static/resources/view/honk/global.js b/luci-app-honk/root/www/luci-static/resources/view/honk/global.js index f99f59e7b..7539755fb 100644 --- a/luci-app-honk/root/www/luci-static/resources/view/honk/global.js +++ b/luci-app-honk/root/www/luci-static/resources/view/honk/global.js @@ -62,30 +62,23 @@ return view.extend({ return this.handleSave(ev).then(function() { var newDash = uci.get('honk', sid, 'dashboard') || 'none'; + var enabled = honk.isServiceEnabled(); - if (newDash !== oldDash) { - return honk.switchDashboardAndWait(newDash).then(function(res) { - if (res && res.pending) - honk.showNotification(null, E('p', _('Saved, but the panel files are not installed yet — download the panel first.')), 'warning'); - else if (res.running === false) - throw new Error(_('HONK did not come back up; check the logs')); - }); - } + var pre = (newDash !== oldDash) ? honk.switchDashboardAndWait(newDash).then(function(res) { + if (res && res.pending) + honk.showNotification(null, E('p', _('Saved, but the panel files are not installed yet — download the panel first.')), 'warning'); + else if (res.running === false) + throw new Error(_('HONK did not come back up; check the logs')); + }) : Promise.resolve(); - return honk.callHonkReload().then(function(resp) { - if (!resp || resp.success === false) - throw new Error((resp && resp.message) || _('Service did not accept the request')); - - if (!honk.isServiceEnabled()) - return; - - return honk.waitForHonkState(true).then(function(st) { - if (st.running === false) - throw new Error(_('HONK did not come back up; check the logs')); - }); + return pre.then(function() { + return honk.applyChangesAndWait(mode); + }).then(function() { + return honk.waitForHonkState(enabled); + }).then(function(st) { + if (enabled && st && st.running === false) + throw new Error(_('HONK did not come back up; check the logs')); }); - }).then(function() { - return ui.changes.apply(mode == '0'); }).catch(function(err) { honk.showNotification(null, E('p', _('Failed to apply configuration:') + ' ' + (err.message || err)), 'error'); }); diff --git a/luci-app-open-box/Makefile b/luci-app-open-box/Makefile index 60067f73c..36b96ce9a 100644 --- a/luci-app-open-box/Makefile +++ b/luci-app-open-box/Makefile @@ -1,8 +1,8 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-open-box -PKG_VERSION:=0.1.310 -PKG_RELEASE:=22 +PKG_VERSION:=0.1.311 +PKG_RELEASE:=23 PKG_MAINTAINER:=liandu2024 PKG_LICENSE:=GPL-3.0-or-later diff --git a/luci-app-openvpn-plus/LICENSE b/luci-app-openvpn-plus/LICENSE new file mode 100755 index 000000000..cc02f4c44 --- /dev/null +++ b/luci-app-openvpn-plus/LICENSE @@ -0,0 +1,13 @@ +Copyright (C) 2008-2026 The OpenWrt Project + +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://apache.org + +Unless required by applicable law or agreed to in writing, Version 2.0 +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. diff --git a/luci-app-openvpn-plus/Makefile b/luci-app-openvpn-plus/Makefile new file mode 100755 index 000000000..a56d142d8 --- /dev/null +++ b/luci-app-openvpn-plus/Makefile @@ -0,0 +1,91 @@ +# Copyright (C) 2008-2026 The OpenWrt Project +# Copyright (C) 2026 Manfred Jaider +# +# This is free software, licensed under the Apache License, Version 2.0. +# See /LICENSE for more information. +# +# luci-app-openvpn-plus : Makefile.standalone + +include $(TOPDIR)/rules.mk + +PKG_NAME:=luci-app-openvpn-plus +PKG_VERSION:=1.0.0 +PKG_RELEASE:=1 +PKG_LICENSE:=Apache-2.0 +PKG_MAINTAINER:=Manfred Jaider + +include $(INCLUDE_DIR)/package.mk + +define Package/luci-app-openvpn-plus + SECTION:=luci + CATEGORY:=LuCI + SUBMENU:=3. Applications + TITLE:=LuCI support for Ready-to-Use OpenVPN Setup (Plus Edition) + URL:=https://github.com/masmbit/luci-app-openvpn-plus + DEPENDS:=+luci-base +openvpn-openssl +openssl-util +firewall4 +uclient-fetch + PKGARCH:=all +endef + +define Package/luci-app-openvpn-plus/description + This package provides an all-in-one web interface to configure OpenVPN servers + and clients quickly. It includes a setup wizard, a built-in key generator, + smart firewall protection, port and route checks, DDNS, SSL-VPN, and QR-code + import/export. With this app, the VPN setup is very simple and intuitive. +endef + +# --- Build & Compilation Stage --- +define Build/Compile + $(INSTALL_DIR) $(PKG_BUILD_DIR) +endef + +# --- Package Installation Stage --- +define Package/luci-app-openvpn-plus/install + + $(INSTALL_DIR) $(1)/etc/hotplug.d/iface + $(INSTALL_DATA) ./root/etc/hotplug.d/iface/35-luci-app-openvpn-plus-bootup $(1)/etc/hotplug.d/iface/35-luci-app-openvpn-plus-bootup + + $(INSTALL_DIR) $(1)/etc/hotplug.d/openvpn + $(INSTALL_DATA) ./root/etc/hotplug.d/openvpn/35-luci-app-openvpn-plus-lifecycle $(1)/etc/hotplug.d/openvpn/35-luci-app-openvpn-plus-lifecycle + + $(INSTALL_DIR) $(1)/etc/openvpn/luci + $(INSTALL_DATA) ./root/etc/openvpn/luci/server.default.conf $(1)/etc/openvpn/luci/server.default.conf + $(INSTALL_DATA) ./root/etc/openvpn/luci/client.default.conf $(1)/etc/openvpn/luci/client.default.conf + $(INSTALL_DATA) ./root/etc/openvpn/luci/default.nft $(1)/etc/openvpn/luci/default.nft + + $(INSTALL_DIR) $(1)/etc/uci-defaults + $(INSTALL_DATA) ./root/etc/uci-defaults/35_luci-app-openvpn-plus $(1)/etc/uci-defaults/35_luci-app-openvpn-plus + + $(INSTALL_DIR) $(1)/usr/libexec + $(INSTALL_BIN) ./root/usr/libexec/luci-app-openvpn-plus $(1)/usr/libexec/luci-app-openvpn-plus + + $(INSTALL_DIR) $(1)/usr/share/luci/menu.d + $(INSTALL_DATA) ./root/usr/share/luci/menu.d/luci-app-openvpn-plus.json $(1)/usr/share/luci/menu.d/luci-app-openvpn-plus.json + + $(INSTALL_DIR) $(1)/usr/share/rpcd/acl.d + $(INSTALL_DATA) ./root/usr/share/rpcd/acl.d/luci-app-openvpn-plus.json $(1)/usr/share/rpcd/acl.d/luci-app-openvpn-plus.json + $(INSTALL_DATA) ./root/usr/share/rpcd/acl.d/luci-app-openvpn-plus-status.json $(1)/usr/share/rpcd/acl.d/luci-app-openvpn-plus-status.json + + $(INSTALL_DIR) $(1)/www/luci-static/resources/view/vpn + $(INSTALL_DATA) ./root/www/luci-static/resources/view/vpn/openvpn.js $(1)/www/luci-static/resources/view/vpn/openvpn.js + $(INSTALL_DATA) ./root/www/luci-static/resources/view/vpn/openvpn-status.js $(1)/www/luci-static/resources/view/vpn/openvpn-status.js + $(INSTALL_DATA) ./root/www/luci-static/resources/view/vpn/openvpn-keygen.js $(1)/www/luci-static/resources/view/vpn/openvpn-keygen.js + $(INSTALL_DATA) ./root/www/luci-static/resources/view/vpn/openvpn-wizard.js $(1)/www/luci-static/resources/view/vpn/openvpn-wizard.js + + $(INSTALL_DIR) $(1)/www/luci-static/resources/view/status/include + $(INSTALL_DATA) ./root/www/luci-static/resources/view/status/include/35_openvpn.js $(1)/www/luci-static/resources/view/status/include/35_openvpn.js + +endef + +# --- Package Uninstallation Stage --- +define Package/luci-app-openvpn-plus/prerm +#!/bin/sh +if [ -z "$${IPKG_INSTROOT}" ]; then + rm -rf /etc/openvpn/keys/*default* + rm -rf /etc/openvpn/luci/*default* + rm -rf /tmp/run/openvpn.instance*.status +fi +exit 0 +endef + +# Evaluate the main package into the OpenWrt Buildroot +$(eval $(call BuildPackage,luci-app-openvpn-plus)) diff --git a/luci-app-openvpn-plus/Makefile.standalone b/luci-app-openvpn-plus/Makefile.standalone new file mode 100755 index 000000000..a56d142d8 --- /dev/null +++ b/luci-app-openvpn-plus/Makefile.standalone @@ -0,0 +1,91 @@ +# Copyright (C) 2008-2026 The OpenWrt Project +# Copyright (C) 2026 Manfred Jaider +# +# This is free software, licensed under the Apache License, Version 2.0. +# See /LICENSE for more information. +# +# luci-app-openvpn-plus : Makefile.standalone + +include $(TOPDIR)/rules.mk + +PKG_NAME:=luci-app-openvpn-plus +PKG_VERSION:=1.0.0 +PKG_RELEASE:=1 +PKG_LICENSE:=Apache-2.0 +PKG_MAINTAINER:=Manfred Jaider + +include $(INCLUDE_DIR)/package.mk + +define Package/luci-app-openvpn-plus + SECTION:=luci + CATEGORY:=LuCI + SUBMENU:=3. Applications + TITLE:=LuCI support for Ready-to-Use OpenVPN Setup (Plus Edition) + URL:=https://github.com/masmbit/luci-app-openvpn-plus + DEPENDS:=+luci-base +openvpn-openssl +openssl-util +firewall4 +uclient-fetch + PKGARCH:=all +endef + +define Package/luci-app-openvpn-plus/description + This package provides an all-in-one web interface to configure OpenVPN servers + and clients quickly. It includes a setup wizard, a built-in key generator, + smart firewall protection, port and route checks, DDNS, SSL-VPN, and QR-code + import/export. With this app, the VPN setup is very simple and intuitive. +endef + +# --- Build & Compilation Stage --- +define Build/Compile + $(INSTALL_DIR) $(PKG_BUILD_DIR) +endef + +# --- Package Installation Stage --- +define Package/luci-app-openvpn-plus/install + + $(INSTALL_DIR) $(1)/etc/hotplug.d/iface + $(INSTALL_DATA) ./root/etc/hotplug.d/iface/35-luci-app-openvpn-plus-bootup $(1)/etc/hotplug.d/iface/35-luci-app-openvpn-plus-bootup + + $(INSTALL_DIR) $(1)/etc/hotplug.d/openvpn + $(INSTALL_DATA) ./root/etc/hotplug.d/openvpn/35-luci-app-openvpn-plus-lifecycle $(1)/etc/hotplug.d/openvpn/35-luci-app-openvpn-plus-lifecycle + + $(INSTALL_DIR) $(1)/etc/openvpn/luci + $(INSTALL_DATA) ./root/etc/openvpn/luci/server.default.conf $(1)/etc/openvpn/luci/server.default.conf + $(INSTALL_DATA) ./root/etc/openvpn/luci/client.default.conf $(1)/etc/openvpn/luci/client.default.conf + $(INSTALL_DATA) ./root/etc/openvpn/luci/default.nft $(1)/etc/openvpn/luci/default.nft + + $(INSTALL_DIR) $(1)/etc/uci-defaults + $(INSTALL_DATA) ./root/etc/uci-defaults/35_luci-app-openvpn-plus $(1)/etc/uci-defaults/35_luci-app-openvpn-plus + + $(INSTALL_DIR) $(1)/usr/libexec + $(INSTALL_BIN) ./root/usr/libexec/luci-app-openvpn-plus $(1)/usr/libexec/luci-app-openvpn-plus + + $(INSTALL_DIR) $(1)/usr/share/luci/menu.d + $(INSTALL_DATA) ./root/usr/share/luci/menu.d/luci-app-openvpn-plus.json $(1)/usr/share/luci/menu.d/luci-app-openvpn-plus.json + + $(INSTALL_DIR) $(1)/usr/share/rpcd/acl.d + $(INSTALL_DATA) ./root/usr/share/rpcd/acl.d/luci-app-openvpn-plus.json $(1)/usr/share/rpcd/acl.d/luci-app-openvpn-plus.json + $(INSTALL_DATA) ./root/usr/share/rpcd/acl.d/luci-app-openvpn-plus-status.json $(1)/usr/share/rpcd/acl.d/luci-app-openvpn-plus-status.json + + $(INSTALL_DIR) $(1)/www/luci-static/resources/view/vpn + $(INSTALL_DATA) ./root/www/luci-static/resources/view/vpn/openvpn.js $(1)/www/luci-static/resources/view/vpn/openvpn.js + $(INSTALL_DATA) ./root/www/luci-static/resources/view/vpn/openvpn-status.js $(1)/www/luci-static/resources/view/vpn/openvpn-status.js + $(INSTALL_DATA) ./root/www/luci-static/resources/view/vpn/openvpn-keygen.js $(1)/www/luci-static/resources/view/vpn/openvpn-keygen.js + $(INSTALL_DATA) ./root/www/luci-static/resources/view/vpn/openvpn-wizard.js $(1)/www/luci-static/resources/view/vpn/openvpn-wizard.js + + $(INSTALL_DIR) $(1)/www/luci-static/resources/view/status/include + $(INSTALL_DATA) ./root/www/luci-static/resources/view/status/include/35_openvpn.js $(1)/www/luci-static/resources/view/status/include/35_openvpn.js + +endef + +# --- Package Uninstallation Stage --- +define Package/luci-app-openvpn-plus/prerm +#!/bin/sh +if [ -z "$${IPKG_INSTROOT}" ]; then + rm -rf /etc/openvpn/keys/*default* + rm -rf /etc/openvpn/luci/*default* + rm -rf /tmp/run/openvpn.instance*.status +fi +exit 0 +endef + +# Evaluate the main package into the OpenWrt Buildroot +$(eval $(call BuildPackage,luci-app-openvpn-plus)) diff --git a/luci-app-openvpn-plus/Makefile.upstream b/luci-app-openvpn-plus/Makefile.upstream new file mode 100755 index 000000000..2119249ae --- /dev/null +++ b/luci-app-openvpn-plus/Makefile.upstream @@ -0,0 +1,49 @@ +# Copyright (C) 2008-2026 The OpenWrt Project +# Copyright (C) 2026 Manfred Jaider +# +# This is free software, licensed under the Apache License, Version 2.0. +# See /LICENSE for more information. +# +# luci-app-openvpn-plus : Makefile.upstream + +include $(TOPDIR)/rules.mk + +PKG_NAME:=luci-app-openvpn-plus +PKG_VERSION:=1.0.0 +PKG_RELEASE:=1 +PKG_LICENSE:=Apache-2.0 +PKG_MAINTAINER:=Manfred Jaider + +LUCI_TITLE:=LuCI support for Ready-to-Use OpenVPN Setup (Plus Edition) +LUCI_DEPENDS:=+luci-base +openvpn-openssl +openssl-util +firewall4 +uclient-fetch +LUCI_PKGARCH:=all + +define Package/luci-app-openvpn-plus/description + This package provides an all-in-one web interface to configure OpenVPN servers + and clients quickly. It includes a setup wizard, a built-in key generator, + smart firewall protection, port and route checks, DDNS, SSL-VPN, and QR-code + import/export. With this app, the VPN setup is very simple and intuitive. +endef + +# --- LuCI custom installation hook for executable permissions --- +define LuCI/Install/Extra + $(INSTALL_DIR) $(1)/usr/libexec + $(INSTALL_BIN) ./root/usr/libexec/luci-app-openvpn-plus $(1)/usr/libexec/luci-app-openvpn-plus +endef + +# --- Package uninstall cleanup --- +define Package/luci-app-openvpn-plus/prerm +#!/bin/sh +if [ -z "$${IPKG_INSTROOT}" ]; then + rm -rf /etc/openvpn/keys/*default* + rm -rf /etc/openvpn/luci/*default* + rm -rf /tmp/run/openvpn.instance*.status +fi +exit 0 +endef + +# 3. LuCI build engine +include ../../luci.mk + +# call BuildPackage - OpenWrt buildroot signature + diff --git a/luci-app-openvpn-plus/README.md b/luci-app-openvpn-plus/README.md new file mode 100755 index 000000000..ac5d66cbd --- /dev/null +++ b/luci-app-openvpn-plus/README.md @@ -0,0 +1,114 @@ +# luci-app-openvpn-plus + +Modern and easy web interface to configure OpenVPN servers and clients on OpenWrt. + +## Description + +This package provides a modern, all-in-one web interface to configure OpenVPN servers and clients quickly. Featuring a built-in key generator, setup wizard, smart firewall protection, DDNS, SSL-VPN, and profile import/export via QR-code, your VPN is ready to use immediately. + +Thanks to this simple application, **anyone can build a professional SSL/TLS-VPN network now**. You do not need to understand complex crypto keys or write config files. The app does all the hard background work for you. With the step-by-step wizard, you can create secure VPN connections for travel and mobile devices in seconds. + +Setting up your mobile phone is incredibly easy—just scan the secure QR-code on your screen with your smartphone camera. In the past, running a VPN behind internet provider modems was very complex. To fix this, the app includes smart Double-NAT detection and automated live port checking. This makes the installation completely effortless and brings professional, enterprise-grade VPN features straight to standard OpenWrt routers. + +**High Performance for Affordable Hardware:** You don't need expensive enterprise equipment. Thanks to native **OpenVPN Data Channel Offload (DCO)** integration and our automatic MTU engine, the app achieves outstanding throughput rates—matching the speed of Wireguard even on budget-friendly wireless access points. + +## 📊 Real-World Performance Benchmarks (iperf3) + +We tested the real-world performance of `luci-app-openvpn-plus` using two affordable **Zyxel NWA50AX Pro** wireless access points. Both devices run as pure APs behind standard internet provider modems. + +Even though the traffic has to pass through **Double-NAT** and simple port forwardings, the performance matches expensive enterprise hardware: + +### 🔹 Test A: Site-to-Site Tunnel (UDP Mode) +- **Setup:** Zyxel AP ↔️ WAN Router ↔️ Internet ↔️ WAN Router ↔️ Zyxel AP +- **Network Status:** Both routers are behind Double-NAT. +- **Benchmark Result:** **450+ Mbit/s** 🚀 +- *The crypto load is perfectly split across multiple CPU cores thanks to OpenVPN DCO and our automatic MTU engine.* + +### 🔸 Test B: Mobile Client / Roadwarrior (TCP / SSL-VPN Mode) +- **Setup:** Zyxel AP ↔️ WAN Router ↔️ Internet ↔️ Smartphone with OpenVPN App +- **Network Status:** The server AP is behind Double-NAT +- **Benchmark Result:** **200+ Mbit/s** 📱 +- *This is the absolute maximum for a single CPU core under TCP. It bypasses strict public firewalls easily while keeping your smartphone connection incredibly fast.* + +## Features + +- **Easy Setup:** Build fully working OpenVPN servers or clients in seconds. +- **Connection Wizard:** Simple step-by-step setup for mobile phones (Roadwarrior), laptops, and router-to-router networks (**Site-to-Site with Multi-Clients Routing**). +- **Enterprise-Grade Speeds (DCO Support):** Out-of-the-box support for OpenVPN Data Channel Offload (DCO) to shift crypto processing directly into the Linux kernel, matching Wireguard performance. +- **Smart MTU Engine:** Automatically calculates and tunes packet sizes to eliminate fragmentation, splitting the crypto load perfectly across multiple CPU cores. +- **Secure Site-to-Site Routing:** Built-in `iroute()` hook dynamically maps matching subnets and includes dynamic Anti-Loop Prevention to automatically block internal routing loops and prevent kernel freezes. +- **Instant Mobile QR-Code Scan:** Shows a secure QR-code after setup. You can scan it with your phone camera to import the settings into the OpenVPN Mobile Connect app instantly. +- **Effortless Installation & Troubleshooting:** Built-in Double-NAT detection and automated live port-open checking make the first startup easy, even behind strict provider modems. +- **OpenVPN over TCP 443 (SSL-VPN):** Special travel-friendly presets to bypass strict public firewalls by hiding VPN traffic as standard HTTPS web traffic, achieving speeds over 200 Mbps on modern networks. +- **Built-in DDNS Daemon:** Automatically updates your VPN connection using built-in support for DuckDNS, IPv64, dynv6, Dynu, and No-IP. +- **Smart Firewall (DPI Defense):** Drops bad traffic and bans hackers in the Linux kernel using deep analysis for TCP and UDP ports. +- **Automated Firewall Zones:** Automatically creates all needed firewall zones, traffic rules, and port forwardings. +- **High-Security Keygen & Export:** Automatically builds RSA-2048/ECC-Prime256 keys, DH parameters, and TLS-crypt-v2, then packs them into one `.ovpn` file. +- **Hardware Crypto Optimization:** Smart cipher settings select fast hardware encryption (AES-GCM) for powerful chips, or optimized software encryption (CHACHA20-POLY1305) for older routers to keep speeds high. +- **Integrated Key Editor:** View, edit, download, and check key files and expiration dates directly inside your web browser. +- **Simple Profile Import & Export:** Upload existing `.ovpn` profiles or multi-key configuration files to start new client connections instantly. +- **Clear Status Table:** Shows a small overview of running tunnels, active process IDs (PIDs), encryption types, and live data transfer. + +## Configuration Layout + +The application manages settings inside `/etc/config/openvpn`: + +```ini +config openvpn 'instance1' + option enabled '0' + option role 'server' + option config '/etc/openvpn/luci/instance1.conf' +``` + +## Directory Structure + +```text +luci-app-openvpn-plus/ +├── Makefile +├── LICENSE +├── README.md +├── po/ +│ ├── de/ +│ │ └── luci-app-openvpn-plus.po +│ ├── zh-cn/ +│ │ └── luci-app-openvpn-plus.po +└── root/ + ├── etc/ + │ ├── config/ + │ └── hotplug.d/ + │ │ ├── iface/ + │ │ │ └── 35-luci-app-openvpn-plus-bootup + │ │ └── openvpn/ + │ │ └── 35-luci-app-openvpn-plus-lifecycle + │ ├── openvpn/ + │ │ ├── keys/ + │ │ └── luci/ + │ │ ├── default.nft + │ │ ├── client.default.conf + │ │ └── server.default.conf + │ └── uci-defaults/ + │ └── 35_luci-app-openvpn-plus + ├── usr/ + │ ├── libexec/ + │ │ └── luci-app-openvpn-plus + │ └── share/ + │ ├── luci/menu.d/ + │ │ └── luci-app-openvpn-plus.json + │ └── rpcd/acl.d/ + │ ├── luci-app-openvpn-plus.json + │ └── luci-app-openvpn-plus-status.json + └── www/ + └── luci-static/resources/view/ + ├── status/include/ + │ └── 35_openvpn.js + └── vpn/ + ├── openvpn.js + ├── openvpn-keygen.js + ├── openvpn-status.js + └── openvpn-wizard.js +``` + +## License + +Licensed under the Apache License 2.0. + diff --git a/luci-app-openvpn-plus/po/de/luci-app-openvpn-plus.po b/luci-app-openvpn-plus/po/de/luci-app-openvpn-plus.po new file mode 100755 index 000000000..3c1a5e5e4 --- /dev/null +++ b/luci-app-openvpn-plus/po/de/luci-app-openvpn-plus.po @@ -0,0 +1,1603 @@ +msgid "" +msgstr "Content-Type: text/plain; charset=UTF-8\n" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:134 +msgid "A client connection needs the server keys and config. Select an .ovpn profile to import them automatically, or click Cancel to use the default settings." +msgstr "Eine Client-Verbindung benötigt Server-Schlüssel und Konfiguration. Wählen Sie ein .ovpn-Profil aus, um diese automatisch zu importieren, oder klicken Sie auf Abbrechen, um die Standardeinstellungen zu verwenden." + +#: root/www/luci-static/resources/view/vpn/openvpn.js:125 +msgid "A secure firewall zone for all" +msgstr "Eine sichere Firewall-Zone für alle" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:87 +msgid "A unique TLS security key for one client. It encrypts and protects all data sent to the server." +msgstr "Ein eindeutiger TLS-Sicherheitsschlüssel für einen Client. Er verschlüsselt und schützt alle an den Server gesendeten Daten." + +#: root/www/luci-static/resources/view/vpn/openvpn.js:113 +msgid "ACTIVE" +msgstr "AKTIV" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:139 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:139 +msgid "API Token" +msgstr "API-Token" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:25 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:25 +msgid "Action" +msgstr "Aktion" + +#: root/www/luci-static/resources/view/status/include/35_openvpn.js:18 +#: root/www/luci-static/resources/view/vpn/openvpn.js:34 +msgid "Active" +msgstr "Aktiv" + +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:24 +msgid "Active PIDs" +msgstr "Aktive PIDs" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:2484 +msgid "Add" +msgstr "Hinzufügen" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:61 +msgid "Add Client Instance" +msgstr "Client-Instanz hinzufügen" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:77 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:77 +msgid "Add Office" +msgstr "Büro hinzufügen" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:62 +msgid "Add Server Instance" +msgstr "Server-Instanz hinzufügen" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:87 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:87 +msgid "Add your other offices below. Please enter the internal LAN network IP (e.g., 192.168.2.0) of each office, NOT their public WAN IP addresses. The server will connect all local networks automatically." +msgstr "Fügen Sie unten Ihre anderen Büros hinzu. Bitte geben Sie die interne LAN-Netzwerk-IP (z. B. 192.168.2.0) jedes Büros ein, NICHT deren öffentliche WAN-IP-Adressen. Der Server wird alle lokalen Netzwerke automatisch verbinden." + +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:25 +msgid "Aggregated RX" +msgstr "Aggregiertes RX" + +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:26 +msgid "Aggregated TX" +msgstr "Aggregiertes TX" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:88 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:88 +msgid "Another Router detected!" +msgstr "Ein anderer Router wurde erkannt!" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:86 +msgid "Are you sure you want to delete" +msgstr "Sind Sie sicher, dass Sie löschen möchten" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:115 +msgid "Automated Zone Setup:" +msgstr "Automatische Zonen-Einrichtung:" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:127 +msgid "Automatic port scans are a constant threat on the internet. The smart firewall detects, bans and drops these requests. This hides your system, prevents spying, saves CPU power and guarantees maximum VPN performance." +msgstr "Automatische Portscans sind eine ständige Bedrohung im Internet. Die intelligente Firewall erkennt, blockiert und verwirft diese Anfragen. Dies verbirgt Ihr System, verhindert Spionage, spart CPU-Leistung und garantiert maximale VPN-Performance." + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:48 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:48 +msgid "BLOCKED" +msgstr "BLOCKIERT" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:78 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:78 +msgid "Back" +msgstr "Zurück" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:47 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:47 +msgid "CLOSED" +msgstr "GESCHLOSSEN" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:37 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:79 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:79 +#: root/www/luci-static/resources/view/vpn/openvpn.js:63 +msgid "Cancel" +msgstr "Abbrechen" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:47 +msgid "Certificate" +msgstr "Zertifikat" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:133 +msgid "Certification Authority" +msgstr "Zertifizierungsstelle (CA)" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:64 +msgid "Change" +msgstr "Ändern" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:80 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:80 +msgid "Check Address Status" +msgstr "Adressstatus prüfen" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:116 +msgid "Check Traffic Rules:" +msgstr "Verkehrsregeln prüfen:" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:94 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:94 +msgid "Checking connection status in the background..." +msgstr "Verbindungsstatus wird im Hintergrund überprüft..." + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:82 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:82 +msgid "Choose File..." +msgstr "Datei auswählen..." + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:90 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:90 +msgid "Choose how your internet traffic is sent through the VPN. You can use any free OpenVPN app on your phone or laptop to import this profile." +msgstr "Wählen Sie aus, wie Ihr Internet-Datenverkehr durch das VPN geleitet wird. Sie können jede kostenlose OpenVPN-App auf Ihrem Smartphone oder Laptop verwenden, um dieses Profil zu importieren." + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:145 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:145 +msgid "Classic free provider with exceptional IPv6 and dual-stack support." +msgstr "Klassischer kostenloser Anbieter mit hervorragender IPv6- und Dual-Stack-Unterstützung." + +#: root/www/luci-static/resources/view/vpn/openvpn.js:43 +msgid "Clear Log" +msgstr "Protokoll löschen" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:35 +msgid "Clearing..." +msgstr "Lösche..." + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:33 +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:28 +msgid "Client" +msgstr "Client" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:145 +msgid "Client Certificate" +msgstr "Client-Zertifikat" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:48 +msgid "Client Certificate + Private key (issued by CA) + TLS Crypt v2 Client key" +msgstr "Client-Zertifikat + privater Schlüssel (von CA ausgestellt) + TLS-Crypt-v2-Client-Schlüssel" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:90 +msgid "Client Key" +msgstr "Client-Schlüssel" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:91 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:91 +msgid "Client for Branch Office (Branch)" +msgstr "Client für Außenstelle (Branch)" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:50 +msgid "Client keys exported successfully." +msgstr "Client-Schlüssel erfolgreich exportiert." + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:49 +msgid "Client keys generated successfully!" +msgstr "Client-Schlüssel erfolgreich generiert!" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:36 +#: root/www/luci-static/resources/view/vpn/openvpn.js:66 +msgid "Close" +msgstr "Schließen" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:52 +msgid "Common Name (CN)" +msgstr "Common Name (CN)" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:85 +msgid "Configuration changed! Applying will temporarily restart the OpenVPN instance." +msgstr "Konfiguration geändert! Beim Übernehmen wird die OpenVPN-Instanz neu gestartet." + +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:29 +msgid "Connected" +msgstr "Verbunden" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:26 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:26 +msgid "Connection Type" +msgstr "Verbindungstyp" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:81 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:81 +msgid "Create VPN" +msgstr "VPN erstellen" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:92 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:92 +msgid "Create a secure VPN connection in just a few clicks." +msgstr "Erstellen Sie mit nur wenigen Klicks eine sichere VPN-Verbindung." + +#: root/www/luci-static/resources/view/vpn/openvpn.js:135 +msgid "Create keys for a new office..." +msgstr "Schlüssel für ein neues Büro erstellen..." + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:27 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:27 +msgid "Creating VPN..." +msgstr "VPN wird erstellt..." + +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:27 +#: root/www/luci-static/resources/view/vpn/openvpn.js:36 +msgid "Creating..." +msgstr "Erstelle..." + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:82 +msgid "Crypto operation running. Please wait..." +msgstr "Krypto-Operation läuft. Bitte warten..." + +#: root/www/luci-static/resources/view/vpn/openvpn.js:154 +msgid "Cryptographic Key Warning" +msgstr "Warnung bezüglich kryptografischer Schlüssel" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:169 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:169 +msgid "Custom / Manual Update URL" +msgstr "Benutzerdefinierte / Manuelle Update-URL" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:53 +msgid "Custom CLI Options" +msgstr "Benutzerdefinierte CLI-Optionen" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:28 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:28 +msgid "Custom Update URL" +msgstr "Benutzerdefinierte Update-URL" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:160 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:160 +msgid "DDNS Key" +msgstr "DDNS-Schlüssel" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:29 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:29 +msgid "DDNS Provider" +msgstr "DDNS-Anbieter" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:161 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:161 +msgid "DDNS Username" +msgstr "DDNS-Benutzername" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:26 +msgid "Default" +msgstr "Standard" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:83 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:83 +#: root/www/luci-static/resources/view/vpn/openvpn.js:2485 +msgid "Delete" +msgstr "Löschen" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:2474 +msgid "Delete Client Profile..." +msgstr "Client-Profil löschen..." + +#: root/www/luci-static/resources/view/vpn/openvpn.js:67 +msgid "Delete Instance" +msgstr "Instanz löschen" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:95 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:95 +msgid "Delete this office" +msgstr "Dieses Büro löschen" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:68 +msgid "Deleted - Save & Apply" +msgstr "Gelöscht - Speichern & Anwenden" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:102 +msgid "Deployment Recommendation:" +msgstr "Bereitstellungsempfehlung:" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:101 +msgid "Deprecated TLS-Crypt V1 key (Not supported)" +msgstr "Veralteter TLS-Crypt-V1-Schlüssel (Nicht unterstützt)" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:31 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:31 +msgid "Destination" +msgstr "Ziel" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:136 +msgid "Diffie - Hellman Parameters" +msgstr "Diffie - Hellman-Parameter" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:64 +msgid "Diffie-Hellman Parameters (Perfect Forward Secrecy)" +msgstr "Diffie-Hellman-Parameter (Perfect Forward Secrecy)" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:54 +msgid "Diffie-Hellman parameters enforce numeric prime bit boundaries exclusively." +msgstr "Diffie-Hellman-Parameter erzwingen ausschließlich numerische Primbit-Grenzen." + +#: root/www/luci-static/resources/view/vpn/openvpn.js:37 +msgid "Disable" +msgstr "Deaktivieren" + +#: root/www/luci-static/resources/view/status/include/35_openvpn.js:19 +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:31 +#: root/www/luci-static/resources/view/vpn/openvpn.js:38 +msgid "Disabled" +msgstr "Deaktiviert" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:153 +msgid "Do you want to proceed and save this file anyway?" +msgstr "Möchten Sie fortfahren und diese Datei trotzdem speichern?" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:160 +msgid "Do you want to proceed with the import anyway?" +msgstr "Möchten Sie den Import trotzdem fortsetzen?" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:30 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:30 +msgid "Domain Name" +msgstr "Domainname" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:144 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:144 +msgid "Domain Update URL / Key" +msgstr "Domain-Update-URL / Schlüssel" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:166 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:166 +msgid "Domain Update URL / token" +msgstr "Domain-Update-URL / Token" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:38 +#: root/www/luci-static/resources/view/vpn/openvpn.js:69 +msgid "Download" +msgstr "Herunterladen" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:70 +msgid "Download (.ovpn)" +msgstr "Herunterladen (.ovpn)" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:87 +msgid "Download and QR code link are only available while this window is open." +msgstr "Download und QR-Code-Link sind nur verfügbar, solange dieses Fenster geöffnet ist." + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:138 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:138 +msgid "DuckDNS (duckdns.org)" +msgstr "DuckDNS (duckdns.org)" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:153 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:153 +msgid "Dynu Systems (dynu.com)" +msgstr "Dynu Systems (dynu.com)" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:107 +msgid "ERROR: Safety polling threshold exceeded boundaries. Process deadlocked." +msgstr "FEHLER: Sicherheits-Polling-Schwellenwert hat die Grenzen überschritten. Prozess befindet sich im Deadlock." + +#: root/www/luci-static/resources/view/vpn/openvpn.js:88 +msgid "Edit Config file" +msgstr "Konfigurationsdatei bearbeiten" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:40 +msgid "Enable" +msgstr "Aktivieren" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:39 +msgid "Enable Save" +msgstr "Speichern aktivieren" + +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:33 +#: root/www/luci-static/resources/view/vpn/openvpn.js:71 +msgid "Enabled" +msgstr "Aktiviert" + +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:47 +msgid "Encryption" +msgstr "Verschlüsselung" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:167 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:167 +msgid "Enter your own manual HTTP update URL structure." +msgstr "Tragen Sie Ihre eigene manuelle HTTP-Update-URL-Struktur ein." + +#: root/www/luci-static/resources/view/status/include/35_openvpn.js:21 +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:32 +#: root/www/luci-static/resources/view/vpn/openvpn.js:41 +msgid "Error" +msgstr "Fehler" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:187 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:187 +#: root/www/luci-static/resources/view/vpn/openvpn.js:170 +msgid "Error: An office with this name is already in the list." +msgstr "Fehler: Ein Büro mit diesem Namen existiert bereits in der Liste." + +#: root/www/luci-static/resources/view/vpn/openvpn.js:164 +msgid "Error: Configuration or cryptographic keys are missing on disk." +msgstr "Fehler: Konfiguration oder kryptografische Schlüssel fehlen auf dem Datenträger." + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:190 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:190 +msgid "Error: File is corrupted or missing vital fields." +msgstr "Fehler: Datei ist beschädigt oder es fehlen wichtige Felder." + +#: root/www/luci-static/resources/view/vpn/openvpn.js:2680 +msgid "Error: Network status check failed." +msgstr "Fehler: Netzwerkstatusprüfung fehlgeschlagen." + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:186 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:186 +msgid "Error: Network status check failed. Please try again." +msgstr "Fehler: Netzwerkstatusprüfung fehlgeschlagen. Bitte versuchen Sie es erneut." + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:182 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:182 +msgid "Error: Please add at least one office to the list before you continue." +msgstr "Fehler: Bitte fügen Sie mindestens ein Büro zur Liste hinzu, bevor Sie fortfahren." + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:185 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:185 +#: root/www/luci-static/resources/view/vpn/openvpn.js:169 +msgid "Error: This IP address is already used by another office. Every office needs a different IP address!" +msgstr "Fehler: Diese IP-Adresse wird bereits von einem anderen Büro verwendet. Jedes Büro benötigt eine andere IP-Adresse!" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:188 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:188 +#: root/www/luci-static/resources/view/vpn/openvpn.js:171 +msgid "Error: You cannot use this IP network! It is the local network of this router and will block all traffic." +msgstr "Fehler: Sie können dieses IP-Netzwerk nicht verwenden! Es ist das lokale Netzwerk dieses Routers und wird den gesamten Datenverkehr blockieren." + +#: root/www/luci-static/resources/view/vpn/openvpn.js:90 +msgid "Export (.ovpn)" +msgstr "Exportieren (.ovpn)" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:55 +msgid "Export Keys" +msgstr "Schlüssel exportieren" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:91 +msgid "Export OpenVPN Connect Client Profile" +msgstr "Exportiere OpenVPN Connect Client-Profil" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:39 +msgid "Export Profile" +msgstr "Profil exportieren" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:32 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:32 +msgid "External Port" +msgstr "Externer Port" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:163 +msgid "Failed to build profile:" +msgstr "Profilerstellung fehlgeschlagen:" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:183 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:183 +msgid "Failed to create new OpenVPN server:" +msgstr "Erstellung des neuen OpenVPN-Servers fehlgeschlagen:" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:27 +msgid "File Location:" +msgstr "Dateipfad:" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:118 +msgid "Filtered" +msgstr "Gefiltert" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:96 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:96 +msgid "Finding your public IP address..." +msgstr "Ermittle Ihre öffentliche IP-Adresse..." + +#: root/www/luci-static/resources/view/vpn/openvpn.js:119 +msgid "Firewall" +msgstr "Firewall" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:120 +msgid "Firewall & Routing Information" +msgstr "Firewall & Routing-Informationen" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:97 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:97 +msgid "For Phones & Laptops (OpenVPN Connect App)" +msgstr "Für Smartphones & Laptops (OpenVPN Connect App)" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:165 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:165 +msgid "FreeMYIP (freemyip.com)" +msgstr "FreeMYIP (freemyip.com)" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:65 +msgid "Full PKI Suite (CA + Server Certificate + Private Server Key)" +msgstr "Vollständige PKI-Suite (CA + Server-Zertifikat + privater Server-Schlüssel)" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:33 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:33 +msgid "Gateway" +msgstr "Gateway" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:40 +msgid "Generate" +msgstr "Generieren" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:100 +msgid "Generated keys are not saved! Close window and discard keys?" +msgstr "Generierte Schlüssel sind nicht gespeichert! Fenster schließen und Schlüssel verwerfen?" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:1054 +msgid "Generating TLS crypt v2 client key..." +msgstr "Generiere TLS-Crypt-v2-Client-Schlüssel..." + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:56 +msgid "Generating TLS-Crypt key..." +msgstr "Generiere TLS-Crypt-Schlüssel..." + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:59 +msgid "Generating private client key..." +msgstr "Generiere privaten Client-Schlüssel..." + +#: root/www/luci-static/resources/view/vpn/openvpn.js:72 +msgid "Generating..." +msgstr "Generiere..." + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:149 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:149 +msgid "HTTP Token String" +msgstr "HTTP-Token-String" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:34 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:34 +msgid "Help" +msgstr "Hilfe" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:96 +msgid "Here you can manage multiple OpenVPN Server and Client instances dynamically." +msgstr "Hier können Sie mehrere OpenVPN-Server- und Client-Instanzen dynamisch verwalten." + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:143 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:143 +msgid "IPv64 (ipv64.net)" +msgstr "IPv64 (ipv64.net)" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:98 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:98 +msgid "If your internet provides a static IP address." +msgstr "Wenn Ihr Internetanschluss eine statische IP-Adresse bereitstellt." + +#: root/www/luci-static/resources/view/vpn/openvpn.js:92 +msgid "Import (.ovpn)" +msgstr "Importieren (.ovpn)" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:38 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:38 +msgid "Import Failed:" +msgstr "Import fehlgeschlagen:" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:93 +msgid "Import OpenVPN Connect Client Profile" +msgstr "OpenVPN Connect Client-Profil importieren" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:35 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:35 +#: root/www/luci-static/resources/view/vpn/openvpn.js:94 +msgid "Import Profile" +msgstr "Profil importieren" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:157 +msgid "Import cancelled due to missing TLS-Crypt v2 protection." +msgstr "Import abgebrochen wegen fehlendem TLS-Crypt-v2-Schutz." + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:94 +msgid "Important: Use a unique name for each office to configure the network routing correctly." +msgstr "Wichtig: Verwenden Sie einen eindeutigen Namen für jedes Büro, um das Netzwerk-Routing korrekt zu konfigurieren." + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:37 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:37 +msgid "Imported Client" +msgstr "Importierter Client" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:36 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:36 +msgid "Importing profile..." +msgstr "Importiere Profil..." + +#: root/www/luci-static/resources/view/vpn/openvpn.js:121 +msgid "Inbound Access:" +msgstr "Eingehender Zugriff:" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:39 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:39 +msgid "Inside Port" +msgstr "Interner Port" + +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:34 +msgid "Instance" +msgstr "Instanz" + +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:35 +#: root/www/luci-static/resources/view/vpn/openvpn.js:42 +msgid "Instance #" +msgstr "Instanz #" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:51 +msgid "Instance Management" +msgstr "Instanzverwaltung" + +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:36 +msgid "Instances" +msgstr "Instanzen" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:40 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:40 +msgid "Internal Port" +msgstr "Interner Port" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:41 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:41 +msgid "Internet Traffic" +msgstr "Internet-Datenverkehr" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:165 +msgid "Invalid file. Important parts are missing (CA, Cert, or Private Key)." +msgstr "Ungültige Datei. Wichtige Teile fehlen (CA, Zertifikat oder privater Schlüssel)." + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:103 +msgid "It is highly recommended to use the automated Setup Wizard to deploy a Mobile Server (OpenVPN Connect) or a Headquarters LAN-to-LAN Server. In just a few clicks, the wizard fully configures easy DDNS provider setup, multi-client remote networks, optimal firewall zones, and scan-ready QR codes." +msgstr "Es wird dringend empfohlen, den automatischen Einrichtungsassistenten zu verwenden, um einen mobilen Server (OpenVPN Connect) oder einen Hauptsitz-LAN-zu-LAN-Server bereitzustellen. Mit nur wenigen Klicks konfiguriert der Assistent die Einrichtung von DDNS-Anbietern, Multi-Client-Remotenetzwerke, optimale Firewall-Zonen und scannbare QR-Codes vollständig." + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:106 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:106 +msgid "Keep IP Setup anyway" +msgstr "IP-Einrichtung trotzdem beibehalten" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:60 +msgid "Key Strength" +msgstr "Schlüssellänge" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:43 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:43 +msgid "Key Update Active" +msgstr "Schlüssel-Update aktiv" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:166 +msgid "Key check failed. One security key is broken or invalid." +msgstr "Schlüsselprüfung fehlgeschlagen. Ein Sicherheits-Schlüssel ist fehlerhaft oder ungültig." + +#: root/www/luci-static/resources/view/vpn/openvpn.js:141 +msgid "Key file is empty or does not exist on disk yet." +msgstr "Schlüsseldatei ist leer oder existiert noch nicht auf dem Datenträger." + +#: root/www/luci-static/resources/view/vpn/openvpn.js:167 +msgid "Key generation failed." +msgstr "Schlüsselgenerierung fehlgeschlagen." + +#: root/www/luci-static/resources/view/vpn/openvpn.js:139 +msgid "Key generation in progress..." +msgstr "Schlüsselgenerierung läuft..." + +#: root/www/luci-static/resources/view/vpn/openvpn.js:168 +msgid "Key type mismatch" +msgstr "Schlüsseltyp-Fehlanpassung" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:137 +msgid "Key verification failed" +msgstr "Schlüsselverifizierung fehlgeschlagen" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:138 +msgid "KeyGen" +msgstr "Schlüsselgenerator" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:107 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:107 +msgid "Keys updated successfully!" +msgstr "Schlüssel erfolgreich aktualisiert!" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:42 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:42 +msgid "LAN IP" +msgstr "LAN IP" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:95 +msgid "LAN-to-LAN Router Profile Selection" +msgstr "LAN-zu-LAN-Router Profilauswahl" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:52 +msgid "LOG" +msgstr "Protokoll (Log)" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:61 +msgid "Loading key contents..." +msgstr "Lade Schlüsselinhalt..." + +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:48 +msgid "Local IP / Port" +msgstr "Lokale IP / Port" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:44 +msgid "Log Cleared" +msgstr "Protokoll gelöscht" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:51 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:51 +msgid "MISSING" +msgstr "FEHLT" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:53 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:53 +msgid "Mask" +msgstr "Maske" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:62 +msgid "Master CA" +msgstr "Haupt-CA" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:89 +msgid "Master Server Secret Key" +msgstr "Hauptschlüssel des Servers (Master Secret)" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:140 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:140 +msgid "Modern free provider with perfect IPv6 and Dual-Stack support." +msgstr "Moderner kostenloser Anbieter mit nativer IPv6- und Dual-Stack-Unterstützung." + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:72 +msgid "N/A - openvpn --genkey accepts no extra flags" +msgstr "Nicht verfügbar - openvpn --genkey akzeptiert keine zusätzlichen Flags" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:44 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:44 +#: root/www/luci-static/resources/view/vpn/openvpn.js:54 +msgid "Netmask" +msgstr "Netzmaske" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:122 +msgid "Network" +msgstr "Netzwerk" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:184 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:184 +msgid "Network Error: DNS/IP query failed." +msgstr "Netzwerkfehler: DNS/IP-Abfrage fehlgeschlagen." + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:93 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:93 +msgid "Network verification failed / Timeout" +msgstr "Netzwerküberprüfung fehlgeschlagen / Zeitüberschreitung" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:84 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:84 +#: root/www/luci-static/resources/view/vpn/openvpn.js:73 +msgid "Next" +msgstr "Weiter" + +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:38 +#: root/www/luci-static/resources/view/vpn/openvpn.js:45 +msgid "No" +msgstr "Nein" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:51 +msgid "No (Clone Default Keys)" +msgstr "Nein (Standard-Schlüssel klonen)" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:99 +msgid "No OpenVPN instances configured yet. Use the Wizard to create new OpenVPN instances." +msgstr "Noch keine OpenVPN-Instanzen konfiguriert. Verwenden Sie den Assistenten, um neue OpenVPN-Instanzen zu erstellen." + +#: root/www/luci-static/resources/view/vpn/openvpn.js:98 +#: root/www/luci-static/resources/view/vpn/openvpn.js:100 +msgid "No active OpenVPN log entries found." +msgstr "Keine aktiven OpenVPN-Protokolleinträge gefunden." + +#: root/www/luci-static/resources/view/vpn/openvpn.js:46 +msgid "No changes detected" +msgstr "Keine Änderungen erkannt" + +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:39 +msgid "No clients connected" +msgstr "Keine Clients verbunden" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:109 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:109 +msgid "No file chosen" +msgstr "Keine Datei ausgewählt" + +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:49 +msgid "No instances configured" +msgstr "Keine Instanzen konfiguriert" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:108 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:108 +msgid "No offices added yet. Use the fields below to add the first office." +msgstr "Noch keine Büros hinzugefügt. Verwenden Sie die Felder unten, um das erste Büro hinzuzufügen." + +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:40 +msgid "No server connected" +msgstr "Kein Server verbunden" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:159 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:159 +msgid "No-IP (noip.com)" +msgstr "No-IP (noip.com)" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:63 +msgid "Not applicable" +msgstr "Nicht zutreffend" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:135 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:135 +msgid "Number 1 for Open-Source and Home-Assistant setups." +msgstr "Nummer 1 für Open-Source- und Home-Assistant-Installationen." + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:50 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:50 +#: root/www/luci-static/resources/view/vpn/openvpn.js:74 +msgid "OK" +msgstr "OK" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:49 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:49 +msgid "OPEN" +msgstr "OFFEN" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:101 +msgid "Office Profile:" +msgstr "Büroprofil:" + +#: root/www/luci-static/resources/view/status/include/35_openvpn.js:17 +#: root/www/luci-static/resources/view/vpn/openvpn.js:47 +msgid "OpenVPN" +msgstr "OpenVPN" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:84 +msgid "OpenVPN Client Export" +msgstr "OpenVPN-Client Export" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:97 +msgid "OpenVPN Connect Mobile Export" +msgstr "OpenVPN Connect Export für Mobilgeräte" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:91 +msgid "OpenVPN Instance Key Generator" +msgstr "OpenVPN-Instanz Schlüsselgenerator" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:142 +msgid "OpenVPN Keys" +msgstr "OpenVPN-Schlüssel" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:176 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:176 +msgid "OpenVPN Keys Detected:" +msgstr "OpenVPN-Schlüssel erkannt:" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:54 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:54 +msgid "OpenVPN Profile Valid:" +msgstr "OpenVPN-Profil gültig:" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:53 +msgid "OpenVPN Server/Client" +msgstr "OpenVPN-Server/Client" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:66 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:66 +msgid "OpenVPN Setup Wizard" +msgstr "OpenVPN-Einrichtungsassistent" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:123 +msgid "OpenVPN tunnel interfaces (tun0, tun1, tun3, etc.)" +msgstr "OpenVPN-Tunnel-Schnittstellen (tun0, tun1, tun3, etc.)" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:55 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:55 +msgid "Outside Port" +msgstr "Externer Port" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:73 +msgid "PKI Suite components saved successfully." +msgstr "PKI-Suite-Komponenten erfolgreich gespeichert." + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:154 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:154 +msgid "Password as plain text (Auto-SHA256)" +msgstr "Passwort als Klartext (Auto-SHA256)" + +#: root/www/luci-static/resources/view/status/include/35_openvpn.js:20 +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:41 +#: root/www/luci-static/resources/view/vpn/openvpn.js:48 +msgid "Pending..." +msgstr "Ausstehend..." + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:114 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:114 +msgid "Please add static routing inside your Main Internet Router (IP: %s) using this OpenVPN Router (IP: %s) as Gateway." +msgstr "Bitte fügen Sie in Ihrem Haupt-Internet-Router (IP: %s) eine statische Route hinzu, die diesen OpenVPN-Router (IP: %s) als Gateway nutzt." + +#: root/www/luci-static/resources/view/vpn/openvpn.js:103 +msgid "Please assign a unique device name (Common Name) for this mobile profile:" +msgstr "Bitte vergeben Sie einen eindeutigen Gerätenamen (Common Name) für dieses mobile Profil:" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:115 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:115 +msgid "Please check your profile file. A valid configuration file needs a remote target endpoint and key blocks." +msgstr "Bitte überprüfen Sie Ihre Profildatei. Eine gültige Konfigurationsdatei benötigt einen Remote-Zielendpunkt und Schlüsselblöcke." + +#: root/www/luci-static/resources/view/vpn/openvpn.js:65 +msgid "Please click «Save & Apply»." +msgstr "Bitte klicken Sie auf «Speichern & Anwenden»." + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:118 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:118 +msgid "Please create a port forwarding rule inside your Main Internet Router (IP: %s) using this OpenVPN Router (IP: %s) as receiver." +msgstr "Bitte erstellen Sie in Ihrem Haupt-Internet-Router (IP: %s) eine Portweiterleitungsregel, die diesen OpenVPN-Router (IP: %s) als Empfänger nutzt." + +#: root/www/luci-static/resources/view/vpn/openvpn.js:140 +msgid "Please wait while secure cryptographic, router-unique default assets are being generated." +msgstr "Bitte warten Sie, während sichere, kryptografische und router-eindeutige Standard-Ressourcen generiert werden." + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:56 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:56 +msgid "Port Forwarding" +msgstr "Portweiterleitung" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:111 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:111 +#, fuzzy +msgid "Port Open Success" +msgstr "Port erfolgreich geöffnet" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:146 +msgid "Private Client Key" +msgstr "Privater Client-Schlüssel" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:77 +msgid "Private Key" +msgstr "Privater Schlüssel" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:144 +msgid "Private Server Key" +msgstr "Privater Server-Schlüssel" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:152 +msgid "Proceeding with invalid cryptographic keys will cause total connection failure and may lead to service instability or infinite daemon crash loops." +msgstr "Das Fortfahren mit ungültigen kryptografischen Schlüsseln führt zum totalen Verbindungsausfall und kann zu Dienstinstabilität oder unendlichen Daemon-Absturzkreisläufen führen." + +#: root/www/luci-static/resources/view/vpn/openvpn.js:75 +msgid "Processing..." +msgstr "Verarbeitung läuft..." + +#: root/www/luci-static/resources/view/vpn/openvpn.js:155 +msgid "Profile link active (Native uqr framework (QR-Code) not loaded)." +msgstr "Profil-Link aktiv (Natives uqr-Framework (QR-Code) nicht geladen)." + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:78 +msgid "Progress & Output" +msgstr "Fortschritt & Ausgabe" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:128 +msgid "Protected since" +msgstr "Geschützt seit" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:57 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:57 +msgid "Protocol" +msgstr "Protokoll" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:59 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:59 +msgid "Public IP Address or Domain" +msgstr "Öffentliche IP-Adresse oder Domain" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:60 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:60 +msgid "Public Static IP" +msgstr "Öffentliche statische IP" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:79 +msgid "Pure ECC" +msgstr "Reines ECC" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:80 +msgid "Pure RSA" +msgstr "Reines RSA" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:81 +msgid "Ready for key generation." +msgstr "Bereit für die Schlüsselgenerierung." + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:120 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:120 +msgid "Ready for openvpn connections." +msgstr "Bereit für OpenVPN-Verbindungen." + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:85 +msgid "Recommended: RSA-2048 + ECC-Prime256v1. RSA ensures CA compatibility, while ECC accelerates the data tunnel." +msgstr "Empfohlen: RSA-2048 + ECC-Prime256v1. RSA stellt die CA-Kompatibilität sicher, während ECC den Datentunnel beschleunigt." + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:150 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:150 +msgid "Reliable free provider. Never expires, perfect for IPv6 and Dual-Stack." +msgstr "Zuverlässiger kostenloser Anbieter. Läuft nie ab, perfekt für IPv6 und Dual-Stack." + +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:50 +msgid "Remote IP / Port" +msgstr "Entfernte IP / Port" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:45 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:45 +#: root/www/luci-static/resources/view/vpn/openvpn.js:55 +msgid "Remote LAN Subnet" +msgstr "Entferntes LAN-Subnetz" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:46 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:46 +#: root/www/luci-static/resources/view/vpn/openvpn.js:56 +msgid "Remote Name" +msgstr "Entfernter Name" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:61 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:61 +msgid "Remote Office Name" +msgstr "Name des Remote-Büros" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:62 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:62 +msgid "Resolving Target..." +msgstr "Löse Zieladresse auf..." + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:63 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:63 +msgid "Reveal/hide password" +msgstr "Passwort anzeigen/ausblenden" + +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:42 +msgid "Running" +msgstr "Aktiv (Läuft)" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:41 +msgid "Save & Apply" +msgstr "Speichern & Anwenden" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:42 +#: root/www/luci-static/resources/view/vpn/openvpn.js:76 +msgid "Save Config" +msgstr "Konfiguration speichern" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:44 +#: root/www/luci-static/resources/view/vpn/openvpn.js:78 +msgid "Saved" +msgstr "Gespeichert" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:43 +#: root/www/luci-static/resources/view/vpn/openvpn.js:77 +msgid "Saving..." +msgstr "Speichere..." + +#: root/www/luci-static/resources/view/vpn/openvpn.js:105 +msgid "Scan QR Code with Mobile Phone Camera:" +msgstr "Scannen Sie den QR-Code mit Ihrer Smartphone-Kamera:" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:83 +msgid "Secret CA Key" +msgstr "Privater CA-Schlüssel" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:67 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:67 +msgid "Secure Full Tunnel (Route all traffic over VPN)" +msgstr "Sicherer Volltunnel (Gesamten Datenverkehr über das VPN leiten)" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:106 +msgid "Secure Temporary Profile URL Field:" +msgstr "Sicheres Feld für temporäre Profil-URL:" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:156 +msgid "Security Notice:" +msgstr "Sicherheitshinweis:" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:158 +msgid "Security Warning!" +msgstr "Sicherheitswarnung!" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:64 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:64 +msgid "Select VPN Type" +msgstr "VPN-Typ auswählen" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:2522 +msgid "Select clients to remove:" +msgstr "Zu entfernende Clients auswählen:" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:124 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:124 +msgid "Select the (.ovpn) profile or (.crt) multi-key bundle. Settings will be configured or updated automatically." +msgstr "Wählen Sie das (.ovpn)-Profil oder das (.crt) Multi-Schlüssel-Paket aus. Die Einstellungen werden automatisch konfiguriert oder aktualisiert." + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:32 +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:30 +msgid "Server" +msgstr "Server" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:68 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:68 +msgid "Server Address" +msgstr "Serveradresse" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:143 +msgid "Server Certificate" +msgstr "Server-Zertifikat" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:125 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:125 +msgid "Server for Main Office (Headquarters)" +msgstr "Server für das Hauptbüro (Zentrale)" + +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:45 +msgid "Session" +msgstr "Sitzung" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:65 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:65 +msgid "Setup" +msgstr "Einrichtung" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:79 +msgid "Show" +msgstr "Anzeigen" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:84 +msgid "Signing client certificate using server CA..." +msgstr "Signiere Client-Zertifikat mit der Server-CA..." + +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:43 +msgid "Since" +msgstr "Seit" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:126 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:126 +msgid "Skip Check & Unlock Create Button" +msgstr "Prüfung überspringen & Erstellen-Button freischalten" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:126 +msgid "Smart Firewall" +msgstr "Intelligente Firewall" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:69 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:69 +msgid "Split Tunnel (Access home LAN only, faster web browsing)" +msgstr "Split-Tunnel (Nur Zugriff auf das Heim-LAN, schnelleres Websurfen)" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:49 +msgid "Starting..." +msgstr "Starte..." + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:99 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:99 +msgid "Static IP check bypassed by administrator." +msgstr "Überprüfung der statischen IP durch Administrator umgangen." + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:100 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:100 +msgid "Static IP now checked by administrator." +msgstr "Statische IP wird jetzt vom Administrator überprüft." + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:70 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:70 +msgid "Static Route" +msgstr "Statische Route" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:123 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:123 +#, fuzzy +msgid "Static Routing Success" +msgstr "Statisches Routing erfolgreich" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:122 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:122 +msgid "Static Routing missing!" +msgstr "Statisches Routing fehlt!" + +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:44 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:71 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:71 +#: root/www/luci-static/resources/view/vpn/openvpn.js:50 +msgid "Status" +msgstr "Status" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:74 +msgid "Step 1/3: Generating Certificate Authority (CA)..." +msgstr "Schritt 1/3: Generiere Zertifizierungsstelle (CA)..." + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:75 +msgid "Step 2/3: Generating Private Server Key..." +msgstr "Schritt 2/3: Generiere privaten Server-Schlüssel..." + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:76 +msgid "Step 3/3: Generating & Signing Server Certificate..." +msgstr "Schritt 3/3: Generiere & Signiere Server-Zertifikat..." + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:103 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:103 +msgid "Success: The IP address is valid and fully resolvable!" +msgstr "Erfolg: Die IP-Adresse ist gültig und vollständig auflösbar!" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:162 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:162 +msgid "Super fast setup without registration. WARNING: Blocked in some networks because it is completely anonymous." +msgstr "Super schnelle Einrichtung ohne Registrierung. WARNUNG: In einigen Netzwerken blockiert, da sie komplett anonym ist." + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:127 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:127 +msgid "Switch to Dynamic DNS (DDNS)" +msgstr "Zu Dynamischem DNS (DDNS) wechseln" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:108 +msgid "System logs ..." +msgstr "Systemprotokolle..." + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:129 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:129 +msgid "TCP (Good for traveling - works behind firewalls)" +msgstr "TCP (Gut für Reisen - funktioniert hinter Firewalls)" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:148 +msgid "TLS Crypt v2 Client Key" +msgstr "TLS-Crypt-v2-Client-Schlüssel" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:67 +msgid "TLS Crypt v2 Client Key (Anti-DoS / Port-Scan Protection)" +msgstr "TLS-Crypt-v2-Client-Schlüssel (Anti-DoS- / Portscan-Schutz)" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:147 +msgid "TLS Crypt v2 Server Master Key" +msgstr "TLS-Crypt-v2-Server-Hauptschlüssel" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:66 +msgid "TLS Crypt v2 Server Master Key (Anti-DoS / Port-Scan Protection)" +msgstr "TLS-Crypt-v2-Server-Hauptschlüssel (Anti-DoS- / Portscan-Schutz)" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:88 +msgid "TLS-Crypt key saved successfully." +msgstr "TLS-Crypt-Schlüssel erfolgreich gespeichert." + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:121 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:121 +msgid "The VPN routing via Main Internet Router (IP: %s) to remote subnet(s) (Net: %s)" +msgstr "Das VPN-Routing über den Haupt-Internet-Router (IP: %s) zu entfernten Subnetz(en) (Netz: %s)" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:86 +msgid "The main TLS security key on the server. It encrypts the connection and creates the keys for all clients." +msgstr "Der Haupt-TLS-Sicherheitsschlüssel auf dem Server. Er verschlüsselt die Verbindung und erstellt die Schlüssel für alle Clients." + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:128 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:128 +msgid "The port forward" +msgstr "Die Portweiterleitung" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:117 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:117 +msgid "The port used on this OpenWrt router." +msgstr "Der auf diesem OpenWrt-Router verwendete Port." + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:116 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:116 +msgid "The port used on your main internet router." +msgstr "Der auf Ihrem Haupt-Internet-Router verwendete Port." + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:119 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:119 +msgid "The public IP address or your own external domain name." +msgstr "Die öffentliche IP-Adresse oder Ihr eigener externe Domainname." + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:106 +msgid "The saved key data contains structural format corruption!" +msgstr "Die gespeicherten Schlüsseldaten enthalten strukturelle Formatbeschädigungen!" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:172 +msgid "The uploaded file is empty." +msgstr "Die hochgeladene Datei ist leer." + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:89 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:89 +msgid "This OpenVPN Router is not the Main Internet Router." +msgstr "Dieser OpenVPN-Router ist nicht der Haupt-Internet-Router." + +#: root/www/luci-static/resources/view/vpn/openvpn.js:104 +msgid "This automated initialization process can take a few minutes on your device..." +msgstr "Dieser automatische Initialisierungsprozess kann auf Ihrem Gerät einige Minuten dauern..." + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:110 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:110 +msgid "This file contains only VPN client keys." +msgstr "Diese Datei enthält nur VPN-Client-Schlüssel." + +#: root/www/luci-static/resources/view/vpn/openvpn.js:89 +msgid "This is the address your VPN clients will use to connect from the internet. Scan the QR code with your phone camera or use the link below to download your connection profile." +msgstr "Dies ist die Adresse, die Ihre VPN-Clients verwenden, um sich aus dem Internet zu verbinden. Scannen Sie den QR-Code mit Ihrer Smartphone-Kamera oder verwenden Sie den unten stehenden Link, um Ihr Verbindungsprofil herunterzuladen." + +#: root/www/luci-static/resources/view/vpn/openvpn.js:159 +msgid "This profile does not use TLS-Crypt v2. It is highly recommended to use TLS-Crypt v2 for maximum security, better performance, and protection against unauthorized scanning." +msgstr "Dieses Profil verwendet kein TLS-Crypt v2. Es wird dringend empfohlen, TLS-Crypt v2 zu verwenden, um maximale Sicherheit, bessere Performance und Schutz vor unbefugten Scans zu gewährleisten." + +#: root/www/luci-static/resources/view/vpn/openvpn.js:107 +msgid "This server handles network rules for multiple remote offices. Please choose an existing office name or write a name to make a new connection profile." +msgstr "Dieser Server verwaltet Netzwerkregeln für mehrere entfernte Büros. Bitte wählen Sie einen bestehenden Büronamen oder tragen Sie einen neuen Namen ein, um ein neues Verbindungsprofil zu erstellen." + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:58 +msgid "To be secure, a new instance needs its own key files. Do you want to create them now? (Recommended - usually takes just a few moments)" +msgstr "Um sicher zu sein, benötigt eine neue Instanz eigene Schlüsseldateien. Möchten Sie diese jetzt erstellen? (Empfohlen - dauert meist nur wenige Augenblicke)" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:72 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:170 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:72 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:170 +msgid "Token / Password" +msgstr "Token / Passwort" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:129 +msgid "Traffic Rules" +msgstr "Filterregeln" + +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:46 +msgid "Transfer" +msgstr "Übertragung" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:28 +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:51 +msgid "Type" +msgstr "Typ" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:130 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:130 +msgid "UDP (High Performance - maximum throughput)" +msgstr "UDP (Hohe Leistung - maximaler Durchsatz)" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:93 +msgid "Unknown Asset" +msgstr "Unbekannte Ressource" + +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:52 +msgid "UpTime" +msgstr "Laufzeit (Uptime)" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:80 +msgid "Upload" +msgstr "Hochladen" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:109 +msgid "Uploaded file is invalid or corrupt!" +msgstr "Hochgeladene Datei ist ungültig oder beschädigt!" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:131 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:131 +msgid "Use Dynamic DNS (DDNS) Service" +msgstr "Dynamischen DNS-Dienst (DDNS) verwenden" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:81 +msgid "Use Fallback" +msgstr "Fallback verwenden" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:132 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:132 +msgid "Use Public IP Address / Domain" +msgstr "Öffentliche IP-Adresse / Domain verwenden" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:73 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:73 +msgid "Username" +msgstr "Benutzername" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:155 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:155 +msgid "Username (Optional)" +msgstr "Benutzername (Optional)" + +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:53 +msgid "VPN" +msgstr "VPN" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:110 +msgid "VPN Client Connection Domain or Address:" +msgstr "VPN-Client Verbindungs-Domain oder -Adresse:" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:96 +msgid "VPN Key Type:" +msgstr "VPN-Schlüsseltyp:" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:74 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:74 +msgid "VPN Name" +msgstr "VPN-Name" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:29 +msgid "Valid Until" +msgstr "Gültig bis" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:189 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:189 +msgid "Validation Error: The VPN Name must contain only letters, numbers, hyphens or underscores." +msgstr "Validierungsfehler: Der VPN-Name darf nur Buchstaben, Zahlen, Bindestriche oder Unterstriche enthalten." + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:101 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:101 +msgid "Validation bypassed after network failure." +msgstr "Validierung nach Netzwerkausfall umgangen." + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:102 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:102 +msgid "Validation bypassed by administrator." +msgstr "Validierung durch Administrator umgangen." + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:95 +msgid "Validity (Days)" +msgstr "Gültigkeit (Tage)" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:156 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:156 +msgid "Very popular provider. WARNING: Free accounts require manual activation/renewal every 30 days via email." +msgstr "Sehr beliebter Anbieter. WARNUNG: Kostenlose Konten erfordern alle 30 Tage eine manuelle Aktivierung/Verlängerung per E-Mail." + +#: root/www/luci-static/resources/view/vpn/openvpn.js:130 +msgid "WAN ports" +msgstr "WAN-Ports" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:99 +msgid "WARNING: Clicking save will instantly break all active VPN tunnels. You must generate and share new client profiles (.ovpn) to restore the connections." +msgstr "WARNUNG: Das Klicken auf Speichern trennt sofort alle aktiven VPN-Tunnel. Sie müssen neue Client-Profile (.ovpn) generieren und verteilen, um die Verbindungen wiederherzustellen." + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:52 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:52 +msgid "WORKING" +msgstr "AKTIV" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:178 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:178 +msgid "Warning: If you upload wrong keys, your connection will stop!" +msgstr "Warnung: Wenn Sie falsche Schlüssel hochladen, wird Ihre Verbindung unterbrochen!" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:174 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:174 +msgid "Warning: The IP address cannot be resolved. Your router might be offline." +msgstr "Warnung: Die IP-Adresse kann nicht aufgelöst werden. Ihr Router ist möglicherweise offline." + +#: root/www/luci-static/resources/view/vpn/openvpn.js:151 +msgid "Warning: The uploaded file structure does not match the expected key type." +msgstr "Warnung: Die Struktur der hochgeladenen Datei entspricht nicht dem erwarteten Schlüsseltyp." + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:175 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:175 +msgid "Warning: You did not select «Public Static IP». If your public IP address changes, your VPN tunnel will crash." +msgstr "Warnung: Sie haben «Öffentliche statische IP» nicht ausgewählt. Wenn sich Ihre öffentliche IP-Adresse ändert, bricht Ihr VPN-Tunnel ab." + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:177 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:177 +msgid "Warning: You have un-saved text in the input fields. Please click the blue «Add Office» button to save it, or clear the fields to continue." +msgstr "Warnung: Sie haben ungespeicherten Text in den Eingabefeldern. Bitte klicken Sie auf den blauen Button «Büro hinzufügen», um ihn zu speichern, oder leeren Sie die Felder, um fortzufahren." + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:31 +#: root/www/luci-static/resources/view/vpn/openvpn.js:57 +msgid "Wizard" +msgstr "Assistent" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:173 +msgid "Wrong key type! Please upload the correct cryptographic file." +msgstr "Falscher Schlüsseltyp! Bitte laden Sie die korrekte kryptografische Datei hoch." + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:30 +msgid "Years" +msgstr "Jahre" + +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:37 +#: root/www/luci-static/resources/view/vpn/openvpn.js:58 +msgid "Yes" +msgstr "Ja" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:57 +msgid "Yes (Generate Unique Keys)" +msgstr "Ja (Eindeutige Schlüssel generieren)" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:114 +msgid "are opened automatically for secure OpenVPN connections." +msgstr "werden automatisch für sichere OpenVPN-Verbindungen geöffnet." + +#: root/www/luci-static/resources/view/vpn/openvpn.js:117 +msgid "devices is created automatically." +msgstr "Geräte wird automatisch erstellt." + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:148 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:148 +msgid "dynv6 (dynv6.com)" +msgstr "dynv6 (dynv6.com)" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:58 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:58 +msgid "e.g." +msgstr "e.g." + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:70 +msgid "e.g. -sha256 ; -noout ; -sha256" +msgstr "z. B. -sha256 ; -noout ; -sha256" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:69 +msgid "e.g. -text (shows hex output)" +msgstr "z.B. -text (zeigt Hex-Ausgabe)" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:71 +msgid "e.g. -traditional ; ; -sha256" +msgstr "z.B. -traditional ; ; -sha256" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:113 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:113 +msgid "e.g. Mobile-Server, Main-Office" +msgstr "z.B. Mobile-Server, Haupt-Buero" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:68 +msgid "e.g. Remote Office 1" +msgstr "z.B. Externes Büro 1" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:171 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:171 +msgid "e.g. https://custom-provider.com?domain=...&key=..." +msgstr "z.B. https://eigener-anbieter.com?domain=...&key=..." + +#: root/www/luci-static/resources/view/vpn/openvpn.js:102 +msgid "e.g. my-smartphone" +msgstr "e.g. mein-smartphone" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:112 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:112 +msgid "e.g. mydomain.com or your public IP" +msgstr "z.B. meinewebsite.de oder deine öffentliche IP" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:168 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:168 +msgid "e.g. myname-custom-provider.com" +msgstr "z.B. meinname-eigener-anbieter.com" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:158 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:158 +msgid "e.g. myname.ddns.net" +msgstr "z.B. meinname.ddns.net" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:137 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:137 +msgid "e.g. myname.duckdns.org" +msgstr "z.B. meinname.duckdns.org" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:152 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:152 +msgid "e.g. myname.dynu.net" +msgstr "z.B. meinname.dynu.net" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:147 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:147 +msgid "e.g. myname.dynv6.net" +msgstr "z.B. meinname.dynv6.net" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:164 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:164 +msgid "e.g. myname.freemyip.com" +msgstr "z.B. meinname.freemyip.com" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:142 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:142 +msgid "e.g. myname.ipv64.net" +msgstr "z.B. meinname.ipv64.net" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:104 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:104 +msgid "is already" +msgstr "ist bereits" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:105 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:105 +msgid "is currently" +msgstr "ist aktuell" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:124 +msgid "packets" +msgstr "Pakete" diff --git a/luci-app-openvpn-plus/po/templates/luci-app-openvpn-plus.pot b/luci-app-openvpn-plus/po/templates/luci-app-openvpn-plus.pot new file mode 100755 index 000000000..c1cb5a09a --- /dev/null +++ b/luci-app-openvpn-plus/po/templates/luci-app-openvpn-plus.pot @@ -0,0 +1,1601 @@ +msgid "" +msgstr "Content-Type: text/plain; charset=UTF-8" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:134 +msgid "A client connection needs the server keys and config. Select an .ovpn profile to import them automatically, or click Cancel to use the default settings." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:125 +msgid "A secure firewall zone for all" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:87 +msgid "A unique TLS security key for one client. It encrypts and protects all data sent to the server." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:113 +msgid "ACTIVE" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:139 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:139 +msgid "API Token" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:25 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:25 +msgid "Action" +msgstr "" + +#: root/www/luci-static/resources/view/status/include/35_openvpn.js:18 +#: root/www/luci-static/resources/view/vpn/openvpn.js:34 +msgid "Active" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:24 +msgid "Active PIDs" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:2484 +msgid "Add" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:61 +msgid "Add Client Instance" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:77 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:77 +msgid "Add Office" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:62 +msgid "Add Server Instance" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:87 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:87 +msgid "Add your other offices below. Please enter the internal LAN network IP (e.g., 192.168.2.0) of each office, NOT their public WAN IP addresses. The server will connect all local networks automatically." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:25 +msgid "Aggregated RX" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:26 +msgid "Aggregated TX" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:88 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:88 +msgid "Another Router detected!" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:86 +msgid "Are you sure you want to delete" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:115 +msgid "Automated Zone Setup:" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:127 +msgid "Automatic port scans are a constant threat on the internet. The smart firewall detects, bans and drops these requests. This hides your system, prevents spying, saves CPU power and guarantees maximum VPN performance." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:48 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:48 +msgid "BLOCKED" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:78 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:78 +msgid "Back" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:47 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:47 +msgid "CLOSED" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:37 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:79 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:79 +#: root/www/luci-static/resources/view/vpn/openvpn.js:63 +msgid "Cancel" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:47 +msgid "Certificate" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:133 +msgid "Certification Authority" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:64 +msgid "Change" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:80 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:80 +msgid "Check Address Status" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:116 +msgid "Check Traffic Rules:" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:94 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:94 +msgid "Checking connection status in the background..." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:82 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:82 +msgid "Choose File..." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:90 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:90 +msgid "Choose how your internet traffic is sent through the VPN. You can use any free OpenVPN app on your phone or laptop to import this profile." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:145 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:145 +msgid "Classic free provider with exceptional IPv6 and dual-stack support." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:43 +msgid "Clear Log" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:35 +msgid "Clearing..." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:33 +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:28 +msgid "Client" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:145 +msgid "Client Certificate" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:48 +msgid "Client Certificate + Private key (issued by CA) + TLS Crypt v2 Client key" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:90 +msgid "Client Key" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:91 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:91 +msgid "Client for Branch Office (Branch)" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:50 +msgid "Client keys exported successfully." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:49 +msgid "Client keys generated successfully!" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:36 +#: root/www/luci-static/resources/view/vpn/openvpn.js:66 +msgid "Close" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:52 +msgid "Common Name (CN)" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:85 +msgid "Configuration changed! Applying will temporarily restart the OpenVPN instance." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:29 +msgid "Connected" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:26 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:26 +msgid "Connection Type" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:81 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:81 +msgid "Create VPN" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:92 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:92 +msgid "Create a secure VPN connection in just a few clicks." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:135 +msgid "Create keys for a new office..." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:27 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:27 +msgid "Creating VPN..." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:27 +#: root/www/luci-static/resources/view/vpn/openvpn.js:36 +msgid "Creating..." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:82 +msgid "Crypto operation running. Please wait..." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:154 +msgid "Cryptographic Key Warning" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:169 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:169 +msgid "Custom / Manual Update URL" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:53 +msgid "Custom CLI Options" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:28 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:28 +msgid "Custom Update URL" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:160 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:160 +msgid "DDNS Key" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:29 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:29 +msgid "DDNS Provider" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:161 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:161 +msgid "DDNS Username" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:26 +msgid "Default" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:83 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:83 +#: root/www/luci-static/resources/view/vpn/openvpn.js:2485 +msgid "Delete" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:2474 +msgid "Delete Client Profile..." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:67 +msgid "Delete Instance" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:95 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:95 +msgid "Delete this office" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:68 +msgid "Deleted - Save & Apply" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:102 +msgid "Deployment Recommendation:" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:101 +msgid "Deprecated TLS-Crypt V1 key (Not supported)" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:31 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:31 +msgid "Destination" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:136 +msgid "Diffie - Hellman Parameters" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:64 +msgid "Diffie-Hellman Parameters (Perfect Forward Secrecy)" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:54 +msgid "Diffie-Hellman parameters enforce numeric prime bit boundaries exclusively." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:37 +msgid "Disable" +msgstr "" + +#: root/www/luci-static/resources/view/status/include/35_openvpn.js:19 +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:31 +#: root/www/luci-static/resources/view/vpn/openvpn.js:38 +msgid "Disabled" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:153 +msgid "Do you want to proceed and save this file anyway?" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:160 +msgid "Do you want to proceed with the import anyway?" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:30 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:30 +msgid "Domain Name" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:144 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:144 +msgid "Domain Update URL / Key" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:166 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:166 +msgid "Domain Update URL / token" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:38 +#: root/www/luci-static/resources/view/vpn/openvpn.js:69 +msgid "Download" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:70 +msgid "Download (.ovpn)" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:87 +msgid "Download and QR code link are only available while this window is open." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:138 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:138 +msgid "DuckDNS (duckdns.org)" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:153 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:153 +msgid "Dynu Systems (dynu.com)" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:107 +msgid "ERROR: Safety polling threshold exceeded boundaries. Process deadlocked." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:88 +msgid "Edit Config file" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:40 +msgid "Enable" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:39 +msgid "Enable Save" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:33 +#: root/www/luci-static/resources/view/vpn/openvpn.js:71 +msgid "Enabled" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:47 +msgid "Encryption" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:167 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:167 +msgid "Enter your own manual HTTP update URL structure." +msgstr "" + +#: root/www/luci-static/resources/view/status/include/35_openvpn.js:21 +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:32 +#: root/www/luci-static/resources/view/vpn/openvpn.js:41 +msgid "Error" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:187 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:187 +#: root/www/luci-static/resources/view/vpn/openvpn.js:170 +msgid "Error: An office with this name is already in the list." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:164 +msgid "Error: Configuration or cryptographic keys are missing on disk." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:190 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:190 +msgid "Error: File is corrupted or missing vital fields." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:2680 +msgid "Error: Network status check failed." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:186 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:186 +msgid "Error: Network status check failed. Please try again." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:182 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:182 +msgid "Error: Please add at least one office to the list before you continue." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:185 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:185 +#: root/www/luci-static/resources/view/vpn/openvpn.js:169 +msgid "Error: This IP address is already used by another office. Every office needs a different IP address!" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:188 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:188 +#: root/www/luci-static/resources/view/vpn/openvpn.js:171 +msgid "Error: You cannot use this IP network! It is the local network of this router and will block all traffic." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:90 +msgid "Export (.ovpn)" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:55 +msgid "Export Keys" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:91 +msgid "Export OpenVPN Connect Client Profile" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:39 +msgid "Export Profile" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:32 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:32 +msgid "External Port" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:163 +msgid "Failed to build profile:" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:183 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:183 +msgid "Failed to create new OpenVPN server:" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:27 +msgid "File Location:" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:118 +msgid "Filtered" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:96 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:96 +msgid "Finding your public IP address..." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:119 +msgid "Firewall" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:120 +msgid "Firewall & Routing Information" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:97 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:97 +msgid "For Phones & Laptops (OpenVPN Connect App)" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:165 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:165 +msgid "FreeMYIP (freemyip.com)" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:65 +msgid "Full PKI Suite (CA + Server Certificate + Private Server Key)" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:33 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:33 +msgid "Gateway" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:40 +msgid "Generate" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:100 +msgid "Generated keys are not saved! Close window and discard keys?" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:1054 +msgid "Generating TLS crypt v2 client key..." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:56 +msgid "Generating TLS-Crypt key..." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:59 +msgid "Generating private client key..." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:72 +msgid "Generating..." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:149 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:149 +msgid "HTTP Token String" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:34 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:34 +msgid "Help" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:96 +msgid "Here you can manage multiple OpenVPN Server and Client instances dynamically." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:143 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:143 +msgid "IPv64 (ipv64.net)" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:98 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:98 +msgid "If your internet provides a static IP address." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:92 +msgid "Import (.ovpn)" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:38 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:38 +msgid "Import Failed:" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:93 +msgid "Import OpenVPN Connect Client Profile" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:35 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:35 +#: root/www/luci-static/resources/view/vpn/openvpn.js:94 +msgid "Import Profile" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:157 +msgid "Import cancelled due to missing TLS-Crypt v2 protection." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:94 +msgid "Important: Use a unique name for each office to configure the network routing correctly." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:37 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:37 +msgid "Imported Client" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:36 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:36 +msgid "Importing profile..." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:121 +msgid "Inbound Access:" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:39 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:39 +msgid "Inside Port" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:34 +msgid "Instance" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:35 +#: root/www/luci-static/resources/view/vpn/openvpn.js:42 +msgid "Instance #" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:51 +msgid "Instance Management" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:36 +msgid "Instances" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:40 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:40 +msgid "Internal Port" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:41 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:41 +msgid "Internet Traffic" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:165 +msgid "Invalid file. Important parts are missing (CA, Cert, or Private Key)." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:103 +msgid "It is highly recommended to use the automated Setup Wizard to deploy a Mobile Server (OpenVPN Connect) or a Headquarters LAN-to-LAN Server. In just a few clicks, the wizard fully configures easy DDNS provider setup, multi-client remote networks, optimal firewall zones, and scan-ready QR codes." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:106 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:106 +msgid "Keep IP Setup anyway" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:60 +msgid "Key Strength" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:43 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:43 +msgid "Key Update Active" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:166 +msgid "Key check failed. One security key is broken or invalid." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:141 +msgid "Key file is empty or does not exist on disk yet." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:167 +msgid "Key generation failed." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:139 +msgid "Key generation in progress..." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:168 +msgid "Key type mismatch" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:137 +msgid "Key verification failed" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:138 +msgid "KeyGen" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:107 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:107 +msgid "Keys updated successfully!" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:42 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:42 +msgid "LAN IP" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:95 +msgid "LAN-to-LAN Router Profile Selection" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:52 +msgid "LOG" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:61 +msgid "Loading key contents..." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:48 +msgid "Local IP / Port" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:44 +msgid "Log Cleared" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:51 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:51 +msgid "MISSING" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:53 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:53 +msgid "Mask" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:62 +msgid "Master CA" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:89 +msgid "Master Server Secret Key" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:140 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:140 +msgid "Modern free provider with perfect IPv6 and Dual-Stack support." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:72 +msgid "N/A - openvpn --genkey accepts no extra flags" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:44 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:44 +#: root/www/luci-static/resources/view/vpn/openvpn.js:54 +msgid "Netmask" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:122 +msgid "Network" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:184 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:184 +msgid "Network Error: DNS/IP query failed." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:93 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:93 +msgid "Network verification failed / Timeout" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:84 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:84 +#: root/www/luci-static/resources/view/vpn/openvpn.js:73 +msgid "Next" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:38 +#: root/www/luci-static/resources/view/vpn/openvpn.js:45 +msgid "No" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:51 +msgid "No (Clone Default Keys)" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:99 +msgid "No OpenVPN instances configured yet. Use the Wizard to create new OpenVPN instances." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:98 +#: root/www/luci-static/resources/view/vpn/openvpn.js:100 +msgid "No active OpenVPN log entries found." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:46 +msgid "No changes detected" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:39 +msgid "No clients connected" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:109 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:109 +msgid "No file chosen" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:49 +msgid "No instances configured" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:108 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:108 +msgid "No offices added yet. Use the fields below to add the first office." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:40 +msgid "No server connected" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:159 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:159 +msgid "No-IP (noip.com)" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:63 +msgid "Not applicable" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:135 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:135 +msgid "Number 1 for Open-Source and Home-Assistant setups." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:50 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:50 +#: root/www/luci-static/resources/view/vpn/openvpn.js:74 +msgid "OK" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:49 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:49 +msgid "OPEN" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:101 +msgid "Office Profile:" +msgstr "" + +#: root/www/luci-static/resources/view/status/include/35_openvpn.js:17 +#: root/www/luci-static/resources/view/vpn/openvpn.js:47 +msgid "OpenVPN" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:84 +msgid "OpenVPN Client Export" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:97 +msgid "OpenVPN Connect Mobile Export" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:91 +msgid "OpenVPN Instance Key Generator" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:142 +msgid "OpenVPN Keys" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:176 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:176 +msgid "OpenVPN Keys Detected:" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:54 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:54 +msgid "OpenVPN Profile Valid:" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:53 +msgid "OpenVPN Server/Client" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:66 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:66 +msgid "OpenVPN Setup Wizard" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:123 +msgid "OpenVPN tunnel interfaces (tun0, tun1, tun3, etc.)" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:55 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:55 +msgid "Outside Port" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:73 +msgid "PKI Suite components saved successfully." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:154 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:154 +msgid "Password as plain text (Auto-SHA256)" +msgstr "" + +#: root/www/luci-static/resources/view/status/include/35_openvpn.js:20 +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:41 +#: root/www/luci-static/resources/view/vpn/openvpn.js:48 +msgid "Pending..." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:114 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:114 +msgid "Please add static routing inside your Main Internet Router (IP: %s) using this OpenVPN Router (IP: %s) as Gateway." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:103 +msgid "Please assign a unique device name (Common Name) for this mobile profile:" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:115 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:115 +msgid "Please check your profile file. A valid configuration file needs a remote target endpoint and key blocks." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:65 +msgid "Please click «Save & Apply»." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:118 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:118 +msgid "Please create a port forwarding rule inside your Main Internet Router (IP: %s) using this OpenVPN Router (IP: %s) as receiver." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:140 +msgid "Please wait while secure cryptographic, router-unique default assets are being generated." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:56 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:56 +msgid "Port Forwarding" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:111 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:111 +msgid "Port Open Success" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:146 +msgid "Private Client Key" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:77 +msgid "Private Key" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:144 +msgid "Private Server Key" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:152 +msgid "Proceeding with invalid cryptographic keys will cause total connection failure and may lead to service instability or infinite daemon crash loops." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:75 +msgid "Processing..." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:155 +msgid "Profile link active (Native uqr framework (QR-Code) not loaded)." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:78 +msgid "Progress & Output" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:128 +msgid "Protected since" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:57 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:57 +msgid "Protocol" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:59 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:59 +msgid "Public IP Address or Domain" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:60 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:60 +msgid "Public Static IP" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:79 +msgid "Pure ECC" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:80 +msgid "Pure RSA" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:81 +msgid "Ready for key generation." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:120 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:120 +msgid "Ready for openvpn connections." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:85 +msgid "Recommended: RSA-2048 + ECC-Prime256v1. RSA ensures CA compatibility, while ECC accelerates the data tunnel." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:150 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:150 +msgid "Reliable free provider. Never expires, perfect for IPv6 and Dual-Stack." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:50 +msgid "Remote IP / Port" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:45 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:45 +#: root/www/luci-static/resources/view/vpn/openvpn.js:55 +msgid "Remote LAN Subnet" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:46 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:46 +#: root/www/luci-static/resources/view/vpn/openvpn.js:56 +msgid "Remote Name" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:61 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:61 +msgid "Remote Office Name" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:62 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:62 +msgid "Resolving Target..." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:63 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:63 +msgid "Reveal/hide password" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:42 +msgid "Running" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:41 +msgid "Save & Apply" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:42 +#: root/www/luci-static/resources/view/vpn/openvpn.js:76 +msgid "Save Config" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:44 +#: root/www/luci-static/resources/view/vpn/openvpn.js:78 +msgid "Saved" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:43 +#: root/www/luci-static/resources/view/vpn/openvpn.js:77 +msgid "Saving..." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:105 +msgid "Scan QR Code with Mobile Phone Camera:" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:83 +msgid "Secret CA Key" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:67 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:67 +msgid "Secure Full Tunnel (Route all traffic over VPN)" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:106 +msgid "Secure Temporary Profile URL Field:" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:156 +msgid "Security Notice:" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:158 +msgid "Security Warning!" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:64 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:64 +msgid "Select VPN Type" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:2522 +msgid "Select clients to remove:" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:124 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:124 +msgid "Select the (.ovpn) profile or (.crt) multi-key bundle. Settings will be configured or updated automatically." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:32 +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:30 +msgid "Server" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:68 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:68 +msgid "Server Address" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:143 +msgid "Server Certificate" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:125 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:125 +msgid "Server for Main Office (Headquarters)" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:45 +msgid "Session" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:65 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:65 +msgid "Setup" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:79 +msgid "Show" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:84 +msgid "Signing client certificate using server CA..." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:43 +msgid "Since" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:126 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:126 +msgid "Skip Check & Unlock Create Button" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:126 +msgid "Smart Firewall" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:69 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:69 +msgid "Split Tunnel (Access home LAN only, faster web browsing)" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:49 +msgid "Starting..." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:99 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:99 +msgid "Static IP check bypassed by administrator." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:100 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:100 +msgid "Static IP now checked by administrator." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:70 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:70 +msgid "Static Route" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:123 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:123 +msgid "Static Routing Success" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:122 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:122 +msgid "Static Routing missing!" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:44 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:71 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:71 +#: root/www/luci-static/resources/view/vpn/openvpn.js:50 +msgid "Status" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:74 +msgid "Step 1/3: Generating Certificate Authority (CA)..." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:75 +msgid "Step 2/3: Generating Private Server Key..." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:76 +msgid "Step 3/3: Generating & Signing Server Certificate..." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:103 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:103 +msgid "Success: The IP address is valid and fully resolvable!" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:162 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:162 +msgid "Super fast setup without registration. WARNING: Blocked in some networks because it is completely anonymous." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:127 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:127 +msgid "Switch to Dynamic DNS (DDNS)" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:108 +msgid "System logs ..." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:129 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:129 +msgid "TCP (Good for traveling - works behind firewalls)" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:148 +msgid "TLS Crypt v2 Client Key" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:67 +msgid "TLS Crypt v2 Client Key (Anti-DoS / Port-Scan Protection)" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:147 +msgid "TLS Crypt v2 Server Master Key" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:66 +msgid "TLS Crypt v2 Server Master Key (Anti-DoS / Port-Scan Protection)" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:88 +msgid "TLS-Crypt key saved successfully." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:121 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:121 +msgid "The VPN routing via Main Internet Router (IP: %s) to remote subnet(s) (Net: %s)" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:86 +msgid "The main TLS security key on the server. It encrypts the connection and creates the keys for all clients." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:128 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:128 +msgid "The port forward" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:117 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:117 +msgid "The port used on this OpenWrt router." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:116 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:116 +msgid "The port used on your main internet router." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:119 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:119 +msgid "The public IP address or your own external domain name." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:106 +msgid "The saved key data contains structural format corruption!" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:172 +msgid "The uploaded file is empty." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:89 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:89 +msgid "This OpenVPN Router is not the Main Internet Router." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:104 +msgid "This automated initialization process can take a few minutes on your device..." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:110 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:110 +msgid "This file contains only VPN client keys." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:89 +msgid "This is the address your VPN clients will use to connect from the internet. Scan the QR code with your phone camera or use the link below to download your connection profile." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:159 +msgid "This profile does not use TLS-Crypt v2. It is highly recommended to use TLS-Crypt v2 for maximum security, better performance, and protection against unauthorized scanning." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:107 +msgid "This server handles network rules for multiple remote offices. Please choose an existing office name or write a name to make a new connection profile." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:58 +msgid "To be secure, a new instance needs its own key files. Do you want to create them now? (Recommended - usually takes just a few moments)" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:72 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:170 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:72 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:170 +msgid "Token / Password" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:129 +msgid "Traffic Rules" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:46 +msgid "Transfer" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:28 +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:51 +msgid "Type" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:130 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:130 +msgid "UDP (High Performance - maximum throughput)" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:93 +msgid "Unknown Asset" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:52 +msgid "UpTime" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:80 +msgid "Upload" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:109 +msgid "Uploaded file is invalid or corrupt!" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:131 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:131 +msgid "Use Dynamic DNS (DDNS) Service" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:81 +msgid "Use Fallback" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:132 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:132 +msgid "Use Public IP Address / Domain" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:73 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:73 +msgid "Username" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:155 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:155 +msgid "Username (Optional)" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:53 +msgid "VPN" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:110 +msgid "VPN Client Connection Domain or Address:" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:96 +msgid "VPN Key Type:" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:74 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:74 +msgid "VPN Name" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:29 +msgid "Valid Until" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:189 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:189 +msgid "Validation Error: The VPN Name must contain only letters, numbers, hyphens or underscores." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:101 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:101 +msgid "Validation bypassed after network failure." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:102 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:102 +msgid "Validation bypassed by administrator." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:95 +msgid "Validity (Days)" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:156 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:156 +msgid "Very popular provider. WARNING: Free accounts require manual activation/renewal every 30 days via email." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:130 +msgid "WAN ports" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:99 +msgid "WARNING: Clicking save will instantly break all active VPN tunnels. You must generate and share new client profiles (.ovpn) to restore the connections." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:52 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:52 +msgid "WORKING" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:178 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:178 +msgid "Warning: If you upload wrong keys, your connection will stop!" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:174 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:174 +msgid "Warning: The IP address cannot be resolved. Your router might be offline." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:151 +msgid "Warning: The uploaded file structure does not match the expected key type." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:175 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:175 +msgid "Warning: You did not select «Public Static IP». If your public IP address changes, your VPN tunnel will crash." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:177 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:177 +msgid "Warning: You have un-saved text in the input fields. Please click the blue «Add Office» button to save it, or clear the fields to continue." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:31 +#: root/www/luci-static/resources/view/vpn/openvpn.js:57 +msgid "Wizard" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:173 +msgid "Wrong key type! Please upload the correct cryptographic file." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:30 +msgid "Years" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:37 +#: root/www/luci-static/resources/view/vpn/openvpn.js:58 +msgid "Yes" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:57 +msgid "Yes (Generate Unique Keys)" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:114 +msgid "are opened automatically for secure OpenVPN connections." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:117 +msgid "devices is created automatically." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:148 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:148 +msgid "dynv6 (dynv6.com)" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:58 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:58 +msgid "e.g." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:70 +msgid "e.g. -sha256 ; -noout ; -sha256" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:69 +msgid "e.g. -text (shows hex output)" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:71 +msgid "e.g. -traditional ; ; -sha256" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:113 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:113 +msgid "e.g. Mobile-Server, Main-Office" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:68 +msgid "e.g. Remote Office 1" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:171 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:171 +msgid "e.g. https://custom-provider.com?domain=...&key=..." +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:102 +msgid "e.g. my-smartphone" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:112 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:112 +msgid "e.g. mydomain.com or your public IP" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:168 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:168 +msgid "e.g. myname-custom-provider.com" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:158 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:158 +msgid "e.g. myname.ddns.net" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:137 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:137 +msgid "e.g. myname.duckdns.org" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:152 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:152 +msgid "e.g. myname.dynu.net" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:147 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:147 +msgid "e.g. myname.dynv6.net" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:164 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:164 +msgid "e.g. myname.freemyip.com" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:142 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:142 +msgid "e.g. myname.ipv64.net" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:104 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:104 +msgid "is already" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:105 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:105 +msgid "is currently" +msgstr "" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:124 +msgid "packets" +msgstr "" diff --git a/luci-app-openvpn-plus/po/zh-cn/luci-app-openvpn-plus.po b/luci-app-openvpn-plus/po/zh-cn/luci-app-openvpn-plus.po new file mode 100755 index 000000000..424f5ef44 --- /dev/null +++ b/luci-app-openvpn-plus/po/zh-cn/luci-app-openvpn-plus.po @@ -0,0 +1,1619 @@ +msgid "" +msgstr "Content-Type: text/plain; charset=UTF-8\n" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:134 +msgid "A client connection needs the server keys and config. Select an .ovpn profile to import them automatically, or click Cancel to use the default settings." +msgstr "客户端连接需要服务器密钥和配置。选择一个 .ovpn 配置文件来自动导入,或点击取消以使用默认设置。" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:125 +msgid "A secure firewall zone for all" +msgstr "适用于所有组件的安全防火墙区域" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:87 +msgid "A unique TLS security key for one client. It encrypts and protects all data sent to the server." +msgstr "用于单个客户端 brain 的唯一 TLS 安全密钥。它用于加密并保护发送到服务器的所有数据。" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:113 +msgid "ACTIVE" +msgstr "活动" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:139 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:139 +msgid "API Token" +msgstr "API 令牌" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:25 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:25 +msgid "Action" +msgstr "操作" + +#: root/www/luci-static/resources/view/status/include/35_openvpn.js:18 +#: root/www/luci-static/resources/view/vpn/openvpn.js:34 +msgid "Active" +msgstr "活动" + +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:24 +msgid "Active PIDs" +msgstr "活动 PID" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:2484 +msgid "Add" +msgstr "添加" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:61 +msgid "Add Client Instance" +msgstr "添加客户端实例" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:77 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:77 +msgid "Add Office" +msgstr "添加办公室" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:62 +msgid "Add Server Instance" +msgstr "添加服务器实例" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:87 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:87 +msgid "Add your other offices below. Please enter the internal LAN network IP (e.g., 192.168.2.0) of each office, NOT their public WAN IP addresses. The server will connect all local networks automatically." +msgstr "在下方添加您的其他办公室。请输入每个办公室 regional 的内部 LAN 网络 IP(例如 192.168.2.0),而**不是**它们的公网 WAN IP 地址。服务器将自动连接所有本地网络。" + +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:25 +msgid "Aggregated RX" +msgstr "总计接收 (RX)" + +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:26 +msgid "Aggregated TX" +msgstr "总计发送 (TX)" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:88 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:88 +msgid "Another Router detected!" +msgstr "检测到另一个路由器!" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:86 +msgid "Are you sure you want to delete" +msgstr "您确定要删除吗" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:115 +msgid "Automated Zone Setup:" +msgstr "自动区域设置:" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:127 +msgid "Automatic port scans are a constant threat on the internet. The smart firewall detects, bans and drops these requests. This hides your system, prevents spying, saves CPU power and guarantees maximum VPN performance." +msgstr "自动端口扫描是互联网上的持续威胁。智能防火墙可以检测、封禁并 code 丢弃这些请求。这能隐藏您的系统、防止间谍活动、节省 CPU 算力并确保最大的 VPN 性能。" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:48 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:48 +msgid "BLOCKED" +msgstr "已拦截" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:78 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:78 +msgid "Back" +msgstr "返回" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:47 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:47 +msgid "CLOSED" +msgstr "已关闭" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:37 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:79 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:79 +#: root/www/luci-static/resources/view/vpn/openvpn.js:63 +msgid "Cancel" +msgstr "取消" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:47 +msgid "Certificate" +msgstr "证书" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:133 +msgid "Certification Authority" +msgstr "证书颁发机构 (CA)" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:64 +msgid "Change" +msgstr "修改" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:80 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:80 +msgid "Check Address Status" +msgstr "检查地址状态" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:116 +msgid "Check Traffic Rules:" +msgstr "检查流量规则:" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:94 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:94 +msgid "Checking connection status in the background..." +msgstr "正在后台检查连接状态..." + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:82 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:82 +msgid "Choose File..." +msgstr "选择文件..." + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:90 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:90 +msgid "Choose how your internet traffic is sent through the VPN. You can use any free OpenVPN app on your phone or laptop to import this profile." +msgstr "选择您的互联网流量如何通过 VPN 发送。您可以在手机或笔记本电脑上使用任何免费的 OpenVPN 应用程序来导入此配置文件。" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:145 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:145 +msgid "Classic free provider with exceptional IPv6 and dual-stack support." +msgstr "经典的免费服务商,支持极佳的 IPv6 和双栈。" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:43 +msgid "Clear Log" +msgstr "清除日志" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:35 +msgid "Clearing..." +msgstr "正在清除..." + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:33 +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:28 +msgid "Client" +msgstr "客户端" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:145 +msgid "Client Certificate" +msgstr "客户端证书" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:48 +msgid "Client Certificate + Private key (issued by CA) + TLS Crypt v2 Client key" +msgstr "客户端证书 + 私钥(由 CA 颁发)+ TLS Crypt v2 客户端密钥" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:90 +msgid "Client Key" +msgstr "客户端密钥" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:91 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:91 +msgid "Client for Branch Office (Branch)" +msgstr "分支机构客户端 (Branch)" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:50 +msgid "Client keys exported successfully." +msgstr "客户端密钥成功导出。" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:49 +msgid "Client keys generated successfully!" +msgstr "客户端密钥成功生成!" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:36 +#: root/www/luci-static/resources/view/vpn/openvpn.js:66 +msgid "Close" +msgstr "关闭" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:52 +msgid "Common Name (CN)" +msgstr "通用名称 (CN)" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:85 +msgid "Configuration changed! Applying will temporarily restart the OpenVPN instance." +msgstr "配置已更改!应用设置将暂时重启 OpenVPN 实例。" + +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:29 +msgid "Connected" +msgstr "已连接" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:26 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:26 +msgid "Connection Type" +msgstr "连接类型" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:81 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:81 +msgid "Create VPN" +msgstr "创建 VPN" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:92 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:92 +msgid "Create a secure VPN connection in just a few clicks." +msgstr "只需点击几次即可创建安全的 VPN 连接。" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:135 +msgid "Create keys for a new office..." +msgstr "为新办公室创建密钥..." + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:27 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:27 +msgid "Creating VPN..." +msgstr "正在创建 VPN..." + +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:27 +#: root/www/luci-static/resources/view/vpn/openvpn.js:36 +msgid "Creating..." +msgstr "正在创建..." + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:82 +msgid "Crypto operation running. Please wait..." +msgstr "加密操作正在运行。请稍候..." + +#: root/www/luci-static/resources/view/vpn/openvpn.js:154 +msgid "Cryptographic Key Warning" +msgstr "加密密钥警告" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:169 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:169 +msgid "Custom / Manual Update URL" +msgstr "自定义 / 手动更新 URL" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:53 +msgid "Custom CLI Options" +msgstr "自定义 CLI 选项" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:28 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:28 +msgid "Custom Update URL" +msgstr "自定义更新 URL" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:160 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:160 +msgid "DDNS Key" +msgstr "DDNS 密钥" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:29 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:29 +msgid "DDNS Provider" +msgstr "DDNS 服务商" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:161 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:161 +msgid "DDNS Username" +msgstr "DDNS 用户名" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:26 +msgid "Default" +msgstr "默认" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:83 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:83 +#: root/www/luci-static/resources/view/vpn/openvpn.js:2485 +msgid "Delete" +msgstr "删除" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:2474 +msgid "Delete Client Profile..." +msgstr "删除客户端配置文件..." + +#: root/www/luci-static/resources/view/vpn/openvpn.js:67 +msgid "Delete Instance" +msgstr "删除实例" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:95 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:95 +msgid "Delete this office" +msgstr "删除此办公室" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:68 +msgid "Deleted - Save & Apply" +msgstr "已删除 - 保存并应用" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:102 +msgid "Deployment Recommendation:" +msgstr "部署推荐:" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:101 +msgid "Deprecated TLS-Crypt V1 key (Not supported)" +msgstr "已弃用的 TLS-Crypt V1 密钥(不支持)" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:31 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:31 +msgid "Destination" +msgstr "目的地" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:136 +msgid "Diffie - Hellman Parameters" +msgstr "Diffie-Hellman 参数" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:64 +msgid "Diffie-Hellman Parameters (Perfect Forward Secrecy)" +msgstr "Diffie-Hellman 参数 (完全 forward 保密/PFS)" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:54 +msgid "Diffie-Hellman parameters enforce numeric prime bit boundaries exclusively." +msgstr "Diffie-Hellman 参数仅强制执行数字质数位边界。" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:37 +msgid "Disable" +msgstr "禁用" + +#: root/www/luci-static/resources/view/status/include/35_openvpn.js:19 +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:31 +#: root/www/luci-static/resources/view/vpn/openvpn.js:38 +msgid "Disabled" +msgstr "已禁用" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:153 +msgid "Do you want to proceed and save this file anyway?" +msgstr "您确定要继续并保存此文件吗?" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:160 +msgid "Do you want to proceed with the import anyway?" +msgstr "您确定要继续导入吗?" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:30 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:30 +msgid "Domain Name" +msgstr "域名" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:144 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:144 +msgid "Domain Update URL / Key" +msgstr "域名更新 URL / 密钥" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:166 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:166 +msgid "Domain Update URL / token" +msgstr "域名更新 URL / 令牌" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:38 +#: root/www/luci-static/resources/view/vpn/openvpn.js:69 +msgid "Download" +msgstr "下载" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:70 +msgid "Download (.ovpn)" +msgstr "下载 (.ovpn)" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:87 +msgid "Download and QR code link are only available while this window is open." +msgstr "下载和二维码链接仅在此窗口打开时可用。" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:138 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:138 +msgid "DuckDNS (duckdns.org)" +msgstr "DuckDNS (duckdns.org)" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:153 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:153 +msgid "Dynu Systems (dynu.com)" +msgstr "Dynu Systems (dynu.com)" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:107 +msgid "ERROR: Safety polling threshold exceeded boundaries. Process deadlocked." +msgstr "错误: 安全轮询阈值超过边界。进程死锁。" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:88 +msgid "Edit Config file" +msgstr "编辑配置文件" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:40 +msgid "Enable" +msgstr "启用" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:39 +msgid "Enable Save" +msgstr "启用保存" + +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:33 +#: root/www/luci-static/resources/view/vpn/openvpn.js:71 +msgid "Enabled" +msgstr "已启用" + +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:47 +msgid "Encryption" +msgstr "加密" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:167 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:167 +msgid "Enter your own manual HTTP update URL structure." +msgstr "输入您自己的手动 HTTP 更新 URL 结构。" + +#: root/www/luci-static/resources/view/status/include/35_openvpn.js:21 +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:32 +#: root/www/luci-static/resources/view/vpn/openvpn.js:41 +msgid "Error" +msgstr "错误" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:187 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:187 +#: root/www/luci-static/resources/view/vpn/openvpn.js:170 +msgid "Error: An office with this name is already in the list." +msgstr "错误: 列表中已存在此名称的办公室。" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:164 +msgid "Error: Configuration or cryptographic keys are missing on disk." +msgstr "错误: 磁盘上缺少配置或加密密钥。" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:190 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:190 +msgid "Error: File is corrupted or missing vital fields." +msgstr "错误: 文件损坏或缺少重要字段。" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:2680 +msgid "Error: Network status check failed." +msgstr "错误:网络状态检查失败。" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:186 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:186 +msgid "Error: Network status check failed. Please try again." +msgstr "错误: 网络状态检查失败。请重试。" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:182 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:182 +msgid "Error: Please add at least one office to the list before you continue." +msgstr "错误: 请在继续之前向列表中至少添加一个办公室。" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:185 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:185 +#: root/www/luci-static/resources/view/vpn/openvpn.js:169 +msgid "Error: This IP address is already used by another office. Every office needs a different IP address!" +msgstr "错误: 此 IP 地址已被另一个办公室使用。每个办公室都需要不同的 IP 地址!" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:188 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:188 +#: root/www/luci-static/resources/view/vpn/openvpn.js:171 +msgid "Error: You cannot use this IP network! It is the local network of this router and will block all traffic." +msgstr "错误: 您不能使用此 IP 网络!它是此路由器的本地网络,将阻塞所有流量。" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:90 +msgid "Export (.ovpn)" +msgstr "导出 (.ovpn)" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:55 +msgid "Export Keys" +msgstr "导出密钥" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:91 +msgid "Export OpenVPN Connect Client Profile" +msgstr "导出 OpenVPN Connect 客户端配置文件" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:39 +msgid "Export Profile" +msgstr "导出配置文件" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:32 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:32 +msgid "External Port" +msgstr "外部端口" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:163 +msgid "Failed to build profile:" +msgstr "构建配置文件失败:" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:183 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:183 +msgid "Failed to create new OpenVPN server:" +msgstr "创建新 OpenVPN 服务器失败:" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:27 +msgid "File Location:" +msgstr "文件位置:" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:118 +msgid "Filtered" +msgstr "已过滤" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:96 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:96 +msgid "Finding your public IP address..." +msgstr "正在寻找您的公网 IP 地址..." + +#: root/www/luci-static/resources/view/vpn/openvpn.js:119 +msgid "Firewall" +msgstr "防火墙" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:120 +msgid "Firewall & Routing Information" +msgstr "防火墙和路由信息" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:97 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:97 +msgid "For Phones & Laptops (OpenVPN Connect App)" +msgstr "适用于手机和笔记本电脑 (OpenVPN Connect 应用)" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:165 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:165 +msgid "FreeMYIP (freemyip.com)" +msgstr "FreeMYIP (freemyip.com)" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:65 +msgid "Full PKI Suite (CA + Server Certificate + Private Server Key)" +msgstr "完整 PKI 套件 (CA + 服务器证书 + 服务器私钥)" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:33 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:33 +msgid "Gateway" +msgstr "网关" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:40 +msgid "Generate" +msgstr "生成" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:100 +msgid "Generated keys are not saved! Close window and discard keys?" +msgstr "生成的密钥未保存!是否关闭窗口并放弃密钥?" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:1054 +msgid "Generating TLS crypt v2 client key..." +msgstr "正在生成 TLS crypt v2 客户端密钥..." + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:56 +msgid "Generating TLS-Crypt key..." +msgstr "正在生成 TLS-Crypt 密钥..." + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:59 +msgid "Generating private client key..." +msgstr "正在生成客户端私钥..." + +#: root/www/luci-static/resources/view/vpn/openvpn.js:72 +msgid "Generating..." +msgstr "正在生成..." + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:149 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:149 +msgid "HTTP Token String" +msgstr "HTTP 令牌字符串" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:34 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:34 +msgid "Help" +msgstr "帮助" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:96 +msgid "Here you can manage multiple OpenVPN Server and Client instances dynamically." +msgstr "您可以在这里动态管理多个 OpenVPN 服务器和客户端实例。" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:143 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:143 +msgid "IPv64 (ipv64.net)" +msgstr "IPv64 (ipv64.net)" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:98 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:98 +msgid "If your internet provides a static IP address." +msgstr "如果您的互联网提供静态 IP 地址。" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:92 +msgid "Import (.ovpn)" +msgstr "导入 (.ovpn)" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:38 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:38 +msgid "Import Failed:" +msgstr "导入失败:" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:93 +msgid "Import OpenVPN Connect Client Profile" +msgstr "导入 OpenVPN Connect 客户端配置文件" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:35 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:35 +#: root/www/luci-static/resources/view/vpn/openvpn.js:94 +msgid "Import Profile" +msgstr "导入配置文件" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:157 +msgid "Import cancelled due to missing TLS-Crypt v2 protection." +msgstr "由于缺少 TLS-Crypt v2 保护,导入已取消。" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:94 +msgid "Important: Use a unique name for each office to configure the network routing correctly." +msgstr "重要: 为每个办公室使用唯一的名称,以便正确配置网络路由。" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:37 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:37 +msgid "Imported Client" +msgstr "已导入的客户端" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:36 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:36 +msgid "Importing profile..." +msgstr "正在导入配置文件..." + +#: root/www/luci-static/resources/view/vpn/openvpn.js:121 +msgid "Inbound Access:" +msgstr "入站访问:" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:39 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:39 +msgid "Inside Port" +msgstr "内部端口" + +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:34 +msgid "Instance" +msgstr "实例" + +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:35 +#: root/www/luci-static/resources/view/vpn/openvpn.js:42 +msgid "Instance #" +msgstr "实例 #" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:51 +msgid "Instance Management" +msgstr "实例管理" + +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:36 +msgid "Instances" +msgstr "实例" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:40 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:40 +msgid "Internal Port" +msgstr "内部端口" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:41 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:41 +msgid "Internet Traffic" +msgstr "互联网流量" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:165 +msgid "Invalid file. Important parts are missing (CA, Cert, or Private Key)." +msgstr "无效文件。缺少重要部分 (CA、证书或私钥)。" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:103 +msgid "It is highly recommended to use the automated Setup Wizard to deploy a Mobile Server (OpenVPN Connect) or a Headquarters LAN-to-LAN Server. In just a few clicks, the wizard fully configures easy DDNS provider setup, multi-client remote networks, optimal firewall zones, and scan-ready QR codes." +msgstr "强烈建议使用自动设置向导来部署移动服务器 (OpenVPN Connect) 或总部 LAN-to-LAN 服务器。只需点击几下,向导即可完全配置简易的 DDNS 服务商设置、多客户端远程网络、优化的防火墙区域以及支持扫描的二维码。" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:106 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:106 +msgid "Keep IP Setup anyway" +msgstr "仍然保留 IP 设置" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:60 +msgid "Key Strength" +msgstr "密钥强度" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:43 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:43 +msgid "Key Update Active" +msgstr "密钥更新激活" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:166 +msgid "Key check failed. One security key is broken or invalid." +msgstr "密钥检查失败。一个安全密钥损坏或无效。" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:141 +msgid "Key file is empty or does not exist on disk yet." +msgstr "密钥文件为空或在磁盘上尚不存在。" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:167 +msgid "Key generation failed." +msgstr "密钥生成失败。" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:139 +msgid "Key generation in progress..." +msgstr "正在生成密钥..." + +#: root/www/luci-static/resources/view/vpn/openvpn.js:168 +msgid "Key type mismatch" +msgstr "密钥类型不匹配" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:137 +msgid "Key verification failed" +msgstr "密钥验证失败" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:138 +msgid "KeyGen" +msgstr "密钥生成" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:107 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:107 +msgid "Keys updated successfully!" +msgstr "密钥更新成功!" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:42 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:42 +msgid "LAN IP" +msgstr "LAN IP" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:95 +msgid "LAN-to-LAN Router Profile Selection" +msgstr "LAN-to-LAN 路由器配置文件选择" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:52 +msgid "LOG" +msgstr "日志" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:61 +msgid "Loading key contents..." +msgstr "正在加载密钥内容..." + +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:48 +msgid "Local IP / Port" +msgstr "本地 IP / 端口" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:44 +msgid "Log Cleared" +msgstr "日志已清除" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:51 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:51 +msgid "MISSING" +msgstr "缺失" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:53 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:53 +msgid "Mask" +msgstr "掩码" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:62 +msgid "Master CA" +msgstr "主 CA" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:89 +msgid "Master Server Secret Key" +msgstr "服务器主密钥" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:140 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:140 +msgid "Modern free provider with perfect IPv6 and Dual-Stack support." +msgstr "现代免费服务商,支持完善的 IPv6 和双栈(Dual-Stack)。" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:72 +msgid "N/A - openvpn --genkey accepts no extra flags" +msgstr "不适用 - openvpn --genkey 不接受额外的标记" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:44 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:44 +#: root/www/luci-static/resources/view/vpn/openvpn.js:54 +msgid "Netmask" +msgstr "子网掩码" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:122 +msgid "Network" +msgstr "网络" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:184 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:184 +msgid "Network Error: DNS/IP query failed." +msgstr "网络错误: DNS/IP 查询失败。" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:93 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:93 +msgid "Network verification failed / Timeout" +msgstr "网络验证失败 / 超时" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:84 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:84 +#: root/www/luci-static/resources/view/vpn/openvpn.js:73 +msgid "Next" +msgstr "下一步" + +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:38 +#: root/www/luci-static/resources/view/vpn/openvpn.js:45 +msgid "No" +msgstr "否" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:51 +msgid "No (Clone Default Keys)" +msgstr "否 (克隆默认密钥)" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:99 +msgid "No OpenVPN instances configured yet. Use the Wizard to create new OpenVPN instances." +msgstr "尚未配置 OpenVPN 实例。使用向导创建新的 OpenVPN 实例。" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:98 +#: root/www/luci-static/resources/view/vpn/openvpn.js:100 +msgid "No active OpenVPN log entries found." +msgstr "未找到活动的 OpenVPN 日志条目。" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:46 +msgid "No changes detected" +msgstr "未检测到更改" + +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:39 +msgid "No clients connected" +msgstr "无客户端连接" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:109 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:109 +msgid "No file chosen" +msgstr "未选择文件" + +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:49 +msgid "No instances configured" +msgstr "未配置实例" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:108 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:108 +msgid "No offices added yet. Use the fields below to add the first office." +msgstr "尚未添加办公室。使用下方的字段添加第一个办公室。" + +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:40 +msgid "No server connected" +msgstr "未连接服务器" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:159 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:159 +msgid "No-IP (noip.com)" +msgstr "No-IP (noip.com)" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:63 +msgid "Not applicable" +msgstr "不适用" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:135 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:135 +msgid "Number 1 for Open-Source and Home-Assistant setups." +msgstr "开源和 Home-Assistant 设置的首选。" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:50 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:50 +#: root/www/luci-static/resources/view/vpn/openvpn.js:74 +msgid "OK" +msgstr "确定" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:49 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:49 +msgid "OPEN" +msgstr "打开" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:101 +msgid "Office Profile:" +msgstr "办公室配置文件:" + +#: root/www/luci-static/resources/view/status/include/35_openvpn.js:17 +#: root/www/luci-static/resources/view/vpn/openvpn.js:47 +msgid "OpenVPN" +msgstr "OpenVPN" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:84 +msgid "OpenVPN Client Export" +msgstr "OpenVPN 客户端导出" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:97 +msgid "OpenVPN Connect Mobile Export" +msgstr "OpenVPN Connect 移动端导出" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:91 +msgid "OpenVPN Instance Key Generator" +msgstr "OpenVPN 实例密钥生成器" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:142 +msgid "OpenVPN Keys" +msgstr "OpenVPN 密钥" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:176 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:176 +msgid "OpenVPN Keys Detected:" +msgstr "检测到 OpenVPN 密钥:" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:54 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:54 +msgid "OpenVPN Profile Valid:" +msgstr "OpenVPN 配置文件有效:" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:53 +msgid "OpenVPN Server/Client" +msgstr "OpenVPN 服务器/客户端" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:66 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:66 +msgid "OpenVPN Setup Wizard" +msgstr "OpenVPN 设置向导" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:123 +msgid "OpenVPN tunnel interfaces (tun0, tun1, tun3, etc.)" +msgstr "OpenVPN 隧道接口 (tun0, tun1, tun3 等)" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:55 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:55 +msgid "Outside Port" +msgstr "外部端口" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:73 +msgid "PKI Suite components saved successfully." +msgstr "PKI 套件组件成功保存。" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:154 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:154 +msgid "Password as plain text (Auto-SHA256)" +msgstr "明文密码(自动 SHA256)" + +#: root/www/luci-static/resources/view/status/include/35_openvpn.js:20 +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:41 +#: root/www/luci-static/resources/view/vpn/openvpn.js:48 +msgid "Pending..." +msgstr "等待中..." + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:114 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:114 +msgid "Please add static routing inside your Main Internet Router (IP: %s) using this OpenVPN Router (IP: %s) as Gateway." +msgstr "请在您的主互联网路由器(IP: %s)中添加一条静态路由,并将此 OpenVPN 路由器(IP: %s)用作网关。" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:103 +msgid "Please assign a unique device name (Common Name) for this mobile profile:" +msgstr "请为此移动端配置文件分配一个唯一的设备名称(通用名称/CN):" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:115 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:115 +msgid "Please check your profile file. A valid configuration file needs a remote target endpoint and key blocks." +msgstr "请检查您的配置文件。有效的配置文件需要一个远程目标端点和密钥块。" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:65 +msgid "Please click «Save & Apply»." +msgstr "请点击 «保存并应用»。" + +# #: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:118 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:118 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:118 +msgid "Please create a port forwarding rule inside your Main Internet Router (IP: %s) using this OpenVPN Router (IP: %s) as receiver." +msgstr "请在您的主互联网路由器(IP: %s)中创建一条端口转发规则,并将此 OpenVPN 路由器(IP: %s)设 for 接收端。" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:140 +msgid "Please wait while secure cryptographic, router-unique default assets are being generated." +msgstr "请稍候,正在生成安全的、路由器唯一的默认加密资产。" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:56 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:56 +msgid "Port Forwarding" +msgstr "端口转发" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:111 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:111 +#, fuzzy +msgid "Port Open Success" +msgstr "端口打开成功" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:146 +msgid "Private Client Key" +msgstr "客户端私钥" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:77 +msgid "Private Key" +msgstr "私钥" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:144 +msgid "Private Server Key" +msgstr "服务器私钥" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:152 +msgid "Proceeding with invalid cryptographic keys will cause total connection failure and may lead to service instability or infinite daemon crash loops." +msgstr "使用无效的加密密钥继续操作将导致彻底的连接失败,并可能导致服务不稳定或守护进程的无限崩溃循环。" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:75 +msgid "Processing..." +msgstr "正在处理..." + +#: root/www/luci-static/resources/view/vpn/openvpn.js:155 +msgid "Profile link active (Native uqr framework (QR-Code) not loaded)." +msgstr "配置文件链接已激活 (未加载本地 uqr 框架/二维码)。" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:78 +msgid "Progress & Output" +msgstr "进度与输出" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:128 +msgid "Protected since" +msgstr "保护始于" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:57 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:57 +msgid "Protocol" +msgstr "协议" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:59 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:59 +msgid "Public IP Address or Domain" +msgstr "公网 IP 地址或域名" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:60 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:60 +msgid "Public Static IP" +msgstr "公网静态 IP" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:79 +msgid "Pure ECC" +msgstr "纯 ECC" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:80 +msgid "Pure RSA" +msgstr "纯 RSA" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:81 +msgid "Ready for key generation." +msgstr "准备生成密钥。" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:120 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:120 +msgid "Ready for openvpn connections." +msgstr "已准备好进行 OpenVPN 连接。" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:85 +msgid "Recommended: RSA-2048 + ECC-Prime256v1. RSA ensures CA compatibility, while ECC accelerates the data tunnel." +msgstr "推荐: RSA-2048 + ECC-Prime256v1。RSA 确保 CA 兼容性,而 ECC 加速数据隧道。" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:150 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:150 +msgid "Reliable free provider. Never expires, perfect for IPv6 and Dual-Stack." +msgstr "可靠的免费服务商。永不过期,非常适合 IPv6 和双栈。" + +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:50 +msgid "Remote IP / Port" +msgstr "远程 IP / 端口" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:45 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:45 +#: root/www/luci-static/resources/view/vpn/openvpn.js:55 +msgid "Remote LAN Subnet" +msgstr "远程 LAN 子网" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:46 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:46 +#: root/www/luci-static/resources/view/vpn/openvpn.js:56 +msgid "Remote Name" +msgstr "远程名称" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:61 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:61 +msgid "Remote Office Name" +msgstr "远程办公室名称" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:62 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:62 +msgid "Resolving Target..." +msgstr "正在解析目标..." + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:63 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:63 +msgid "Reveal/hide password" +msgstr "显示/隐藏密码" + +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:42 +msgid "Running" +msgstr "正在运行" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:41 +msgid "Save & Apply" +msgstr "保存并应用" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:42 +#: root/www/luci-static/resources/view/vpn/openvpn.js:76 +msgid "Save Config" +msgstr "保存配置" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:44 +#: root/www/luci-static/resources/view/vpn/openvpn.js:78 +msgid "Saved" +msgstr "已保存" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:43 +#: root/www/luci-static/resources/view/vpn/openvpn.js:77 +msgid "Saving..." +msgstr "正在保存..." + +#: root/www/luci-static/resources/view/vpn/openvpn.js:105 +msgid "Scan QR Code with Mobile Phone Camera:" +msgstr "使用手机摄像头扫描二维码:" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:83 +msgid "Secret CA Key" +msgstr "CA 私钥" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:67 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:67 +msgid "Secure Full Tunnel (Route all traffic over VPN)" +msgstr "安全全局隧道 (通过 VPN 路由所有流量)" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:106 +msgid "Secure Temporary Profile URL Field:" +msgstr "安全临时配置文件 URL 字段:" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:156 +msgid "Security Notice:" +msgstr "安全通知:" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:158 +msgid "Security Warning!" +msgstr "安全警告!" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:64 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:64 +msgid "Select VPN Type" +msgstr "选择 VPN 类型" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:2522 +msgid "Select clients to remove:" +msgstr "选择要删除的客户端:" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:124 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:124 +msgid "Select the (.ovpn) profile or (.crt) multi-key bundle. Settings will be configured or updated automatically." +msgstr "选择 (.ovpn) 配置文件或 (.crt) 多密钥捆绑包。设置将自动配置或更新。" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:32 +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:30 +msgid "Server" +msgstr "服务器" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:68 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:68 +msgid "Server Address" +msgstr "服务器地址" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:143 +msgid "Server Certificate" +msgstr "服务器证书" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:125 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:125 +msgid "Server for Main Office (Headquarters)" +msgstr "主办公室服务器 (总部)" + +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:45 +msgid "Session" +msgstr "会话" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:65 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:65 +msgid "Setup" +msgstr "设置" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:79 +msgid "Show" +msgstr "显示" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:84 +msgid "Signing client certificate using server CA..." +msgstr "正在使用服务器 CA 签名客户端证书..." + +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:43 +msgid "Since" +msgstr "自" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:126 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:126 +msgid "Skip Check & Unlock Create Button" +msgstr "跳过检查并解锁创建按钮" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:126 +msgid "Smart Firewall" +msgstr "智能防火墙" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:69 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:69 +msgid "Split Tunnel (Access home LAN only, faster web browsing)" +msgstr "分流隧道 (仅访问家庭局域网,网页浏览更快)" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:49 +msgid "Starting..." +msgstr "正在启动..." + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:99 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:99 +msgid "Static IP check bypassed by administrator." +msgstr "管理员已绕过静态 IP 检查。" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:100 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:100 +msgid "Static IP now checked by administrator." +msgstr "管理员现已检查静态 IP。" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:70 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:70 +msgid "Static Route" +msgstr "静态路由" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:123 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:123 +#, fuzzy +msgid "Static Routing Success" +msgstr "静态路由成功" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:122 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:122 +msgid "Static Routing missing!" +msgstr "缺少静态路由!" + +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:44 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:71 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:71 +#: root/www/luci-static/resources/view/vpn/openvpn.js:50 +msgid "Status" +msgstr "状态" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:74 +msgid "Step 1/3: Generating Certificate Authority (CA)..." +msgstr "步骤 1/3: 正在生成证书颁发机构 (CA)..." + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:75 +msgid "Step 2/3: Generating Private Server Key..." +msgstr "步骤 2/3: 正在生成服务器私钥..." + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:76 +msgid "Step 3/3: Generating & Signing Server Certificate..." +msgstr "步骤 3/3: 正在生成并签名服务器证书..." + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:103 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:103 +msgid "Success: The IP address is valid and fully resolvable!" +msgstr "成功: IP 地址有效且可完全解析!" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:162 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:162 +msgid "Super fast setup without registration. WARNING: Blocked in some networks because it is completely anonymous." +msgstr "超快速设置,无需注册。警告:在某些网络中可能被拦截,因其完全匿名。" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:127 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:127 +msgid "Switch to Dynamic DNS (DDNS)" +msgstr "切换到动态 DNS (DDNS)" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:108 +msgid "System logs ..." +msgstr "系统日志..." + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:129 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:129 +msgid "TCP (Good for traveling - works behind firewalls)" +msgstr "TCP (适合旅行 - 可在防火墙后工作)" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:148 +msgid "TLS Crypt v2 Client Key" +msgstr "TLS Crypt v2 客户端密钥" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:67 +msgid "TLS Crypt v2 Client Key (Anti-DoS / Port-Scan Protection)" +msgstr "TLS Crypt v2 客户端密钥 (防御 DoS / 端口扫描保护)" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:147 +msgid "TLS Crypt v2 Server Master Key" +msgstr "TLS Crypt v2 服务器主密钥" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:66 +msgid "TLS Crypt v2 Server Master Key (Anti-DoS / Port-Scan Protection)" +msgstr "TLS Crypt v2 服务器主密钥 (防御 DoS / 端口扫描保护)" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:88 +msgid "TLS-Crypt key saved successfully." +msgstr "TLS-Crypt 密钥保存成功。" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:121 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:121 +msgid "The VPN routing via Main Internet Router (IP: %s) to remote subnet(s) (Net: %s)" +msgstr "经由主互联网路由器(IP: %s)到远程子网(网络: %s)的 VPN 路由" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:86 +msgid "The main TLS security key on the server. It encrypts the connection and creates the keys for all clients." +msgstr "服务器上的主 TLS 安全密钥。它用于加密连接并为所有客户端创建密钥。" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:128 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:128 +msgid "The port forward" +msgstr "端口转发" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:117 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:117 +msgid "The port used on this OpenWrt router." +msgstr "此 OpenWrt 路由器上使用的端口。" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:116 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:116 +msgid "The port used on your main internet router." +msgstr "您的主 internet 路由器上使用的端口。" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:119 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:119 +msgid "The public IP address or your own external domain name." +msgstr "公网 IP 地址或您自己的外部域名。" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:106 +msgid "The saved key data contains structural format corruption!" +msgstr "保存的密钥数据包含结构格式损坏!" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:172 +msgid "The uploaded file is empty." +msgstr "上传的文件为空。" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:89 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:89 +msgid "This OpenVPN Router is not the Main Internet Router." +msgstr "此 OpenVPN 路由器不是主 internet 路由器。" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:104 +msgid "This automated initialization process can take a few minutes on your device..." +msgstr "此自动初始化过程可能需要几分钟时间..." + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:110 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:110 +msgid "This file contains only VPN client keys." +msgstr "此文件仅包含 VPN 客户端密钥。" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:89 +msgid "This is the address your VPN clients will use to connect from the internet. Scan the QR code with your phone camera or use the link below to download your connection profile." +msgstr "这是您的 VPN 客户端从互联网连接时使用的地址。使用手机摄像头扫描二维码或点击下方链接下载您的连接配置文件。" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:159 +msgid "This profile does not use TLS-Crypt v2. It is highly recommended to use TLS-Crypt v2 for maximum security, better performance, and protection against unauthorized scanning." +msgstr "此配置文件未使用 TLS-Crypt v2。强烈建议使用 TLS-Crypt v2 以获得最高安全性、更好的性能以及防御未经授权的扫描保护。" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:107 +msgid "This server handles network rules for multiple remote offices. Please choose an existing office name or write a name to make a new connection profile." +msgstr "此服务器处理多个远程办公室的网络规则。请选择现有的办公室名称或写入一个新名称以建立新的连接配置文件。" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:58 +msgid "To be secure, a new instance needs its own key files. Do you want to create them now? (Recommended - usually takes just a few moments)" +msgstr "为了安全起见,新实例需要自己的密钥文件。是否现在创建?(推荐 - 通常只需几分钟)" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:72 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:170 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:72 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:170 +msgid "Token / Password" +msgstr "令牌 / 密码" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:129 +msgid "Traffic Rules" +msgstr "流量规则" + +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:46 +msgid "Transfer" +msgstr "传输" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:28 +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:51 +msgid "Type" +msgstr "类型" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:130 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:130 +msgid "UDP (High Performance - maximum throughput)" +msgstr "UDP(高性能 - 最大吞吐量)" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:93 +msgid "Unknown Asset" +msgstr "未知资产" + +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:52 +msgid "UpTime" +msgstr "运行时间" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:80 +msgid "Upload" +msgstr "上传" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:109 +msgid "Uploaded file is invalid or corrupt!" +msgstr "上传的文件无效或已损坏!" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:131 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:131 +msgid "Use Dynamic DNS (DDNS) Service" +msgstr "使用动态 DNS (DDNS) 服务" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:81 +msgid "Use Fallback" +msgstr "使用备用 (Fallback)" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:132 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:132 +msgid "Use Public IP Address / Domain" +msgstr "使用公网 IP 地址 / 域名" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:73 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:73 +msgid "Username" +msgstr "用户名" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:155 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:155 +msgid "Username (Optional)" +msgstr "用户名 (可选)" + +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:53 +msgid "VPN" +msgstr "VPN" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:110 +msgid "VPN Client Connection Domain or Address:" +msgstr "VPN 客户端连接域名或地址:" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:96 +msgid "VPN Key Type:" +msgstr "VPN 密钥类型:" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:74 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:74 +msgid "VPN Name" +msgstr "VPN 名称" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:29 +msgid "Valid Until" +msgstr "有效期至" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:189 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:189 +msgid "Validation Error: The VPN Name must contain only letters, numbers, hyphens or underscores." +msgstr "验证错误: VPN 名称只能包含字母、数字、连字符或下划线。" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:101 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:101 +msgid "Validation bypassed after network failure." +msgstr "网络故障后已绕过验证。" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:102 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:102 +msgid "Validation bypassed by administrator." +msgstr "管理员已绕过验证。" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:95 +msgid "Validity (Days)" +msgstr "有效期 (天)" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:156 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:156 +msgid "Very popular provider. WARNING: Free accounts require manual activation/renewal every 30 days via email." +msgstr "非常受欢迎的服务商。警告:免费账户需要每 30 天通过电子邮件手动激活/续期一次。" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:130 +msgid "WAN ports" +msgstr "WAN 端口" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:99 +msgid "WARNING: Clicking save will instantly break all active VPN tunnels. You must generate and share new client profiles (.ovpn) to restore the connections." +msgstr "警告: 点击保存将立即中断所有活动的 VPN 隧道。您必须生成并共享新的客户端配置文件 (.ovpn) 才能恢复连接。" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:52 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:52 +msgid "WORKING" +msgstr "正常" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:178 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:178 +msgid "Warning: If you upload wrong keys, your connection will stop!" +msgstr "警告: 如果您上传了错误的密钥,您的连接将会停止!" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:174 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:174 +msgid "Warning: The IP address cannot be resolved. Your router might be offline." +msgstr "警告: 无法解析该 IP 地址。您的路由器可能处于离线状态。" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:151 +msgid "Warning: The uploaded file structure does not match the expected key type." +msgstr "警告: 上传的文件结构与预期的密钥类型不匹配。" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:175 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:175 +msgid "Warning: You did not select «Public Static IP». If your public IP address changes, your VPN tunnel will crash." +msgstr "警告: 您未选择 «公网静态 IP»。如果您的公网 IP 地址发生更改,您的 VPN 隧道将会崩溃。" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:177 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:177 +msgid "Warning: You have un-saved text in the input fields. Please click the blue «Add Office» button to save it, or clear the fields to continue." +msgstr "警告: 输入框中有未保存的文本。请点击蓝色的 «添加办公室» 按钮进行保存,或清空字段以继续。" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:31 +#: root/www/luci-static/resources/view/vpn/openvpn.js:57 +msgid "Wizard" +msgstr "向导" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:173 +msgid "Wrong key type! Please upload the correct cryptographic file." +msgstr "错误的密钥类型!请上传正确的加密文件。" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:30 +msgid "Years" +msgstr "年" + +#: root/www/luci-static/resources/view/vpn/openvpn-status.js:37 +#: root/www/luci-static/resources/view/vpn/openvpn.js:58 +msgid "Yes" +msgstr "是" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:57 +msgid "Yes (Generate Unique Keys)" +msgstr "是 (生成唯一密钥)" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:114 +msgid "are opened automatically for secure OpenVPN connections." +msgstr "会自动开启以用于安全的 OpenVPN 连接。" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:117 +msgid "devices is created automatically." +msgstr "设备将自动创建。" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:148 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:148 +msgid "dynv6 (dynv6.com)" +msgstr "dynv6 (dynv6.com)" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:58 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:58 +msgid "e.g." +msgstr "例如:" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:70 +msgid "e.g. -sha256 ; -noout ; -sha256" +msgstr "例如:-sha256 ; -noout ; -sha256" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:69 +msgid "e.g. -text (shows hex output)" +msgstr "例如:-text(显示十六进制输出)" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:71 +msgid "e.g. -traditional ; ; -sha256" +msgstr "例如:-traditional ; ; -sha256" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:113 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:113 +msgid "e.g. Mobile-Server, Main-Office" +msgstr "例如: Mobile-Server, Main-Office" + +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:68 +msgid "e.g. Remote Office 1" +msgstr "例如:远程办公室 1" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:171 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:171 +msgid "e.g. https://custom-provider.com?domain=...&key=..." +msgstr "例如: https://custom-provider.com?domain=...&key=..." + +#: root/www/luci-static/resources/view/vpn/openvpn.js:102 +msgid "e.g. my-smartphone" +msgstr "例如: my-smartphone" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:112 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:112 +msgid "e.g. mydomain.com or your public IP" +msgstr "例如: mydomain.com 或您的公网 IP" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:168 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:168 +msgid "e.g. myname-custom-provider.com" +msgstr "例如: myname-custom-provider.com" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:158 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:158 +msgid "e.g. myname.ddns.net" +msgstr "例如: myname.ddns.net" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:137 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:137 +msgid "e.g. myname.duckdns.org" +msgstr "例如: myname.duckdns.org" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:152 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:152 +msgid "e.g. myname.dynu.net" +msgstr "例如: myname.dynu.net" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:147 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:147 +msgid "e.g. myname.dynv6.net" +msgstr "例如: myname.dynv6.net" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:164 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:164 +msgid "e.g. myname.freemyip.com" +msgstr "例如: myname.freemyip.com" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:142 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:142 +msgid "e.g. myname.ipv64.net" +msgstr "例如: myname.ipv64.net" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:104 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:104 +msgid "is already" +msgstr "已经" + +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js:105 +#: root/www/luci-static/resources/view/vpn/openvpn-wizard.js.linter.tmp.js:105 +msgid "is currently" +msgstr "当前正在" + +#: root/www/luci-static/resources/view/vpn/openvpn.js:124 +msgid "packets" +msgstr "数据包" + +#~ msgid "Actual" +#~ msgstr "实际" + +#~ msgid "The external port" +#~ msgstr "外部端口" + +#~ msgid "as Gateway." +#~ msgstr "作为网关。" + +#~ msgid "as receiver." +#~ msgstr "作为接收方。" + +#~ msgid "using this OpenVPN Router" +#~ msgstr "使用此 OpenVPN 路由器" diff --git a/luci-app-openvpn-plus/po/zh_Hans b/luci-app-openvpn-plus/po/zh_Hans new file mode 120000 index 000000000..41451e4a1 --- /dev/null +++ b/luci-app-openvpn-plus/po/zh_Hans @@ -0,0 +1 @@ +zh-cn \ No newline at end of file diff --git a/luci-app-openvpn-plus/root/etc/hotplug.d/iface/35-luci-app-openvpn-plus-bootup b/luci-app-openvpn-plus/root/etc/hotplug.d/iface/35-luci-app-openvpn-plus-bootup new file mode 100755 index 000000000..fbea6d5ed --- /dev/null +++ b/luci-app-openvpn-plus/root/etc/hotplug.d/iface/35-luci-app-openvpn-plus-bootup @@ -0,0 +1,73 @@ +#!/bin/sh + +# Copyright (C) 2008-2026 The OpenWrt Project +# Copyright (C) 2026 Manfred Jaider +# +# This is free software, licensed under the Apache License, Version 2.0. +# See /LICENSE for more information. +# +# luci-app-openvpn-plus : Network-ready bootup event handler with race-condition lock +# /etc/hotplug.d/iface/35-luci-app-openvpn-plus-bootup + +# Only trigger this script when an interface finishes initializing +if [ "$ACTION" != "ifup" ]; then + exit 0 +fi + +# STRIP LOOPBACK: Ignore the internal system loopback to prevent a premature trigger +if [ "$INTERFACE" = "loopback" ]; then + exit 0 +fi + +LOCK_FILE="/var/run/luci-app-openvpn-plus-bootup.lock" + +# Mutex implementation preventing parallel execution threads from racing each other +if [ -f "$LOCK_FILE" ]; then + # Clean verification via signal 0: check if the holding PID is still alive + if kill -0 $(cat "$LOCK_FILE" 2>/dev/null) 2>/dev/null; then + exit 0 + fi +fi + +# Register active PID into the lock file to serialize the bootup checks +echo "$$" > "$LOCK_FILE" + +# Set traps to delete the lock file reliably on unexpected script exits +trap "rm -f $LOCK_FILE" EXIT INT TERM + +logger -t openvpn-luci "Bootup: Interface '${INTERFACE}' is up. Checking active OpenVPN profile running state in 10 seconds..." + +# Give the system firewall and routing tables enough time to init +sleep 10 + +# Loop through all configured openvpn sections inside the UCI configuration +uci -q show openvpn | grep "=openvpn" | while read -r line; do + # Extract the pure instance identity string from the current UCI line + inst="${line##*.}" + inst="${inst%%=*}" + + # Process the instance only if it is explicitly enabled by the user + if [ "$(uci -q get openvpn.${inst}.enabled)" = "1" ]; then + # Bulletproof process check utilizing the immune syslog tag pattern + if ! ps | grep -v "grep" | grep -q "openvpn(${inst})"; then + logger -t openvpn-luci "Bootup: Detected unstarted instance '${inst}'. Initializing process." + + # Restart the service cleanly to clear out any dead zombie states or locked ports + /etc/init.d/openvpn stop "$inst" >/dev/null 2>&1 + sleep 1 >/dev/null 2>&1 + /etc/init.d/openvpn restart "$inst" >/dev/null 2>&1 + sleep 1 >/dev/null 2>&1 + /etc/init.d/openvpn reload "$inst" >/dev/null 2>&1 + else + logger -t openvpn-luci "Bootup: Instance '${inst}' is already running safely." + fi + fi +done + +# Delete the lock file manually after successful execution +rm -f "$LOCK_FILE" + +# Unregister and reset all traps to system default values before exiting +trap - EXIT INT TERM + +exit 0 diff --git a/luci-app-openvpn-plus/root/etc/hotplug.d/openvpn/35-luci-app-openvpn-plus-lifecycle b/luci-app-openvpn-plus/root/etc/hotplug.d/openvpn/35-luci-app-openvpn-plus-lifecycle new file mode 100755 index 000000000..8b3c0aa09 --- /dev/null +++ b/luci-app-openvpn-plus/root/etc/hotplug.d/openvpn/35-luci-app-openvpn-plus-lifecycle @@ -0,0 +1,111 @@ +#!/bin/sh + +# Copyright (C) 2008-2026 The OpenWrt Project +# Copyright (C) 2026 Manfred Jaider +# +# This is free software, licensed under the Apache License, Version 2.0. +# See /LICENSE for more information. +# +# luci-app-openvpn-plus : OpenWrt hotplug lifecycle manager for ddns server and client common name +# /etc/hotplug.d/openvpn/35-luci-app-openvpn-plus-lifecycle + +# Ensure we catch the active OpenVPN name context across all OpenWrt variants +ovpn_instance="${INSTANCE:-$NAME}" + +# Fallback to $1 if $dev is empty depending on the environment context +ovpn_dev="${dev:-$1}" + +if [ -z "$ovpn_instance" ]; then + exit 0 +fi + +CONF_FILE="/etc/openvpn/luci/${ovpn_instance}.conf" +PID_FILE="/var/run/openvpn-ddns-${ovpn_instance}.pid" + +case "$ACTION" in + + up) + + inst_role=$(uci -q get openvpn."$ovpn_instance".role) + + # Save the active interface name into the OpenWrt UCI configuration + if [ "$inst_role" = "server" ] || [ "$inst_role" = "client" ]; then + if [ -n "$ovpn_dev" ]; then + current_devname=$(uci -q get openvpn."$ovpn_instance".devname) + if [ "$current_devname" != "$ovpn_dev" ]; then + uci -q set openvpn."$ovpn_instance".devname="$ovpn_dev" + uci commit openvpn + logger -t openvpn-luci "Hotplug (${ovpn_instance}): set devname ${ovpn_dev}" + fi + fi + fi + + # SERVER: DDNS SERVICE START + if [ "$inst_role" = "server" ]; then + + # Stop any old DDNS service processes if they are still running + if start-stop-daemon -K -q -p "$PID_FILE" -t 2>/dev/null; then + logger -t openvpn-luci "Hotplug: Stopping active DDNS service process for ${ovpn_instance} before configuration reload." + start-stop-daemon -K -q -p "$PID_FILE" 2>/dev/null + fi + rm -f "$PID_FILE" + + # Process the new configuration only if the file exists + if [ -f "$CONF_FILE" ]; then + domain=$(grep -E "^setenv[[:space:]]+DDNS[[:space:]]+" "$CONF_FILE" | sed -E 's/^setenv[[:space:]]+DDNS[[:space:]]+"(.*)"/\1/') + url=$(grep -E "^setenv[[:space:]]+DDNS_URL[[:space:]]+" "$CONF_FILE" | sed -E 's/^setenv[[:space:]]+DDNS_URL[[:space:]]+"(.*)"/\1/') + + if [ -n "$domain" ] && [ -n "$url" ]; then + logger -t openvpn-luci "Hotplug: Starting DDNS background service for ${ovpn_instance} (Domain: ${domain})" + start-stop-daemon -S -b -m -p "$PID_FILE" -x /usr/libexec/luci-app-openvpn-plus -- ddnsservice "$domain" "$url" "$ovpn_instance" + fi + fi + # CLIENT: EXTRACT COMMON NAME + elif [ "$inst_role" = "client" ]; then + cn_update="0" + # Extract the last connection initiation line from logread for this specific instance + log_line=$(logread | grep "openvpn(${ovpn_instance})" | grep "Peer Connection Initiated" | tail -n 1) + + if [ -n "$log_line" ]; then + # Parse the pure CN string from inside the brackets (Example: [OfficeServer] -> OfficeServer) + server_cn=$(echo "$log_line" | sed -n 's/.*: \[\([^]]*\)\].*Peer Connection Initiated.*/\1/p') + + if [ -n "$server_cn" ]; then + # Write the resolved Common Name safely into the OpenWrt UCI configuration + uci -q set openvpn."$ovpn_instance".server_common_name="$server_cn" + uci commit openvpn + cn_update="1" + fi + fi + if [ "$cn_update" = "1" ]; then + logger -t openvpn-luci "Hotplug (${ovpn_instance}): Server Common Name '${server_cn}' resolved" + else + logger -t openvpn-luci "Hotplug (${ovpn_instance}): Server Common Name not found in Peer Connection log entry" + fi + fi + ;; + + down) + + inst_role=$(uci -q get openvpn."$ovpn_instance".role) + + # Clear the mapped network device because the tunnel is offline + if [ "$inst_role" = "server" ] || [ "$inst_role" = "client" ]; then + uci -q delete openvpn."$ovpn_instance".devname + uci commit openvpn + fi + + # SERVER: DDNS SERVICE STOP + if [ "$inst_role" = "server" ]; then + + # Clean application stop via standard system manager signals + if start-stop-daemon -K -q -p "$PID_FILE" -t 2>/dev/null; then + logger -t openvpn-luci "Hotplug: Stopping DDNS service for ${ovpn_instance} because tunnel is down." + start-stop-daemon -K -q -p "$PID_FILE" 2>/dev/null + fi + rm -f "$PID_FILE" + fi + ;; +esac + +exit 0 diff --git a/luci-app-openvpn-plus/root/etc/openvpn/luci/client.default.conf b/luci-app-openvpn-plus/root/etc/openvpn/luci/client.default.conf new file mode 100755 index 000000000..c3e108562 --- /dev/null +++ b/luci-app-openvpn-plus/root/etc/openvpn/luci/client.default.conf @@ -0,0 +1,37 @@ +# Modern OpenVPN Client Configuration Instance + +# --- Connection Mode --- +client +dev tun +proto udp + +# --- Network & IP Management --- +remote 127.0.0.1 1194 +float + +# --- Tuning & Optimization --- +explicit-exit-notify 1 +mssfix 1360 +tun-mtu 1420 + +# --- Certificates & Keys --- +ca /etc/openvpn/keys/ca_default.crt +cert /etc/openvpn/keys/client_default.crt +key /etc/openvpn/keys/client_default.key + +# --- Hardening & Security --- +tls-crypt-v2 /etc/openvpn/keys/client_default.tls2.key +tls-groups secp256r1 +cipher AES-256-GCM +data-ciphers AES-256-GCM:AES-128-GCM:CHACHA20-POLY1305 + +# --- Verification & Logging --- +status /tmp/run/openvpn.instance.status 5 +status-version 1 +setenv logfilter on +connect-retry 10 60 +resolv-retry infinite +nobind +persist-tun +remote-cert-tls server +verb 3 diff --git a/luci-app-openvpn-plus/root/etc/openvpn/luci/default.nft b/luci-app-openvpn-plus/root/etc/openvpn/luci/default.nft new file mode 100755 index 000000000..1868e2b52 --- /dev/null +++ b/luci-app-openvpn-plus/root/etc/openvpn/luci/default.nft @@ -0,0 +1,158 @@ +#!/bin/sh + +# Copyright (C) 2008-2026 The OpenWrt Project +# Copyright (C) 2026 Manfred Jaider +# +# This is free software, licensed under the Apache License, Version 2.0. +# See /LICENSE for more information. +# +# luci-app-openvpn : smart firewall rules + +# USEFUL COMMANDS FOR FIREWALL MANAGEMENT & LIVE MONITORING +# +# * Core Firewall Controls +# - Restart entire firewall framework: /etc/init.d/firewall restart +# - Reload UCI configuration profiles: /etc/init.d/firewall reload +# - View permanent firewall settings: cat /etc/config/firewall +# +# * Monitoring Chains & Structure +# - View entire dynamic fw4 table: nft -a list table inet fw4 +# - View main input chain with handles: nft -a list chain inet fw4 input +# - View main forward chain with handles: nft -a list chain inet fw4 forward +# - View your custom OpenVPN sub-chain: nft -a list chain inet fw4 ovpn_filter_instance0 +# - Quick view of custom sub-chain: nft -a list chain inet fw4 ovpn_filter_instance0 | sed -E -n 's/.*(openvpn[^"]+).*counter (packets [0-9]+ bytes [0-9]+).*(# handle [0-9]+).*/\3 -> "\1" counter \2/p' +# +# * Live Security & Attacker Tracking (The most useful commands) +# - View active IPv4 blocked IPs & timers: nft list set inet fw4 ovpn_block_ipv4_instance0 +# - Flush/Clear a specific ban list now: nft flush set inet fw4 ovpn_block_ipv4_instance0 + +fwPort=443 +fwProto="tcp" +SUB_CHAIN="ovpn_filter_instance0" + +# STEP 1: CLEAN UP OLD RULES AND SETS (Prevents duplicates and memory leaks) + +# Find and delete the old jump rule handle inside the main input chain to unlock the sub-chain +oldJumpHandle=$(nft -a list chain inet fw4 input | grep "jump $SUB_CHAIN" | grep -oE 'handle [0-9]+' | tail -n 1 | awk '{print $2}') +if [ -n "$oldJumpHandle" ]; then + nft "delete rule inet fw4 input handle $oldJumpHandle" +fi + +# Now the sub-chain is unlocked and can be safely deleted without resource busy errors +nft "flush chain inet fw4 $SUB_CHAIN" 2>/dev/null +nft "delete chain inet fw4 $SUB_CHAIN" 2>/dev/null +nft "flush set inet fw4 ovpn_block_ipv4_instance0" 2>/dev/null +nft "delete set inet fw4 ovpn_block_ipv4_instance0" 2>/dev/null +nft "flush set inet fw4 ovpn_block_ipv6_instance0" 2>/dev/null +nft "delete set inet fw4 ovpn_block_ipv6_instance0" 2>/dev/null + +# STEP 2: BUILD FRESH NAMED SETS WITH AUTOMATIC TIMEOUT +nft "add set inet fw4 ovpn_block_ipv4_instance0 { type ipv4_addr ; flags timeout ; }" +nft "add set inet fw4 ovpn_block_ipv6_instance0 { type ipv6_addr ; flags timeout ; }" + +# STEP 3: CREATE THE CLEAN SUB-CHAIN CONTAINER +nft "add chain inet fw4 $SUB_CHAIN" + +# STEP 4: BUILD THE CASCADE RULES (based on protocol) +if [ "$fwProto" = "tcp" ]; then + + # TCP-S1: Instantly rejects traffic from IPv4/IPv6 addresses listed in the block sets. + nft "add rule inet fw4 $SUB_CHAIN tcp dport $fwPort ct packets < 12 ip saddr @ovpn_block_ipv4_instance0 log prefix \"openvpn(instance0)[firewall]{ban}: \" counter reject with tcp reset" + nft "add rule inet fw4 $SUB_CHAIN tcp dport $fwPort ct packets < 12 ip6 saddr @ovpn_block_ipv6_instance0 log prefix \"openvpn(instance0)[firewall]{ban}: \" counter reject with tcp reset" + + # TCP-S2: Blocks fragmented TCP packets before they can cause buffer overflows. + nft "add rule inet fw4 $SUB_CHAIN tcp dport $fwPort ct packets < 12 meta nfproto ipv4 ip frag-off & 0x3fff != 0 log prefix \"openvpn(instance0)[firewall]{fragment}: \" counter reject with tcp reset" + nft "add rule inet fw4 $SUB_CHAIN tcp dport $fwPort ct packets < 12 meta nfproto ipv6 exthdr frag exists log prefix \"openvpn(instance0)[firewall]{fragment}: \" counter reject with tcp reset" + + # TCP-S3: Validates strict TCP headers and forces standard handshake behaviors. + # - TCP Timestamps is required (This forces the TCP header size to be exactly 32 bytes for every data packets) + # - SYN packets must have maxseg, window and sack-perm option. + # - Data packets are forbidden to have the maxseg option. + nft "add rule inet fw4 $SUB_CHAIN tcp dport $fwPort ct packets < 12 meta nfproto ipv4 tcp option timestamp missing log prefix \"openvpn(instance0)[firewall]{timestamp}: \" counter update @ovpn_block_ipv4_instance0 { ip saddr timeout 5m } reject with tcp reset" + nft "add rule inet fw4 $SUB_CHAIN tcp dport $fwPort ct packets < 12 meta nfproto ipv4 tcp flags & (syn) == syn tcp option maxseg missing log prefix \"openvpn(instance0)[firewall]{maxseg}: \" counter update @ovpn_block_ipv4_instance0 { ip saddr timeout 5m } reject with tcp reset" + nft "add rule inet fw4 $SUB_CHAIN tcp dport $fwPort ct packets < 12 meta nfproto ipv4 tcp flags & (syn) == 0 tcp option maxseg exists log prefix \"openvpn(instance0)[firewall]{maxseg}: \" counter update @ovpn_block_ipv4_instance0 { ip saddr timeout 5m } reject with tcp reset" + nft "add rule inet fw4 $SUB_CHAIN tcp dport $fwPort ct packets < 12 meta nfproto ipv4 tcp flags & (syn) == syn tcp option window missing log prefix \"openvpn(instance0)[firewall]{wscale}: \" counter update @ovpn_block_ipv4_instance0 { ip saddr timeout 5m } reject with tcp reset" + nft "add rule inet fw4 $SUB_CHAIN tcp dport $fwPort ct packets < 12 meta nfproto ipv4 tcp flags & (syn) == syn tcp option sack-perm missing log prefix \"openvpn(instance0)[firewall]{sack}: \" counter update @ovpn_block_ipv4_instance0 { ip saddr timeout 5m } reject with tcp reset" + nft "add rule inet fw4 $SUB_CHAIN tcp dport $fwPort ct packets < 12 meta nfproto ipv6 tcp option timestamp missing log prefix \"openvpn(instance0)[firewall]{timestamp}: \" counter update @ovpn_block_ipv6_instance0 { ip6 saddr timeout 5m } reject with tcp reset" + nft "add rule inet fw4 $SUB_CHAIN tcp dport $fwPort ct packets < 12 meta nfproto ipv6 tcp flags & (syn) == syn tcp option maxseg missing log prefix \"openvpn(instance0)[firewall]{maxseg}: \" counter update @ovpn_block_ipv6_instance0 { ip6 saddr timeout 5m } reject with tcp reset" + nft "add rule inet fw4 $SUB_CHAIN tcp dport $fwPort ct packets < 12 meta nfproto ipv6 tcp flags & (syn) == 0 tcp option maxseg exists log prefix \"openvpn(instance0)[firewall]{maxseg}: \" counter update @ovpn_block_ipv6_instance0 { ip6 saddr timeout 5m } reject with tcp reset" + nft "add rule inet fw4 $SUB_CHAIN tcp dport $fwPort ct packets < 12 meta nfproto ipv6 tcp flags & (syn) == syn tcp option window missing log prefix \"openvpn(instance0)[firewall]{wscale}: \" counter update @ovpn_block_ipv6_instance0 { ip6 saddr timeout 5m } reject with tcp reset" + nft "add rule inet fw4 $SUB_CHAIN tcp dport $fwPort ct packets < 12 meta nfproto ipv6 tcp flags & (syn) == syn tcp option sack-perm missing log prefix \"openvpn(instance0)[firewall]{sack}: \" counter update @ovpn_block_ipv6_instance0 { ip6 saddr timeout 5m } reject with tcp reset" + + # TCP-S4: Catches smart bots that delay data transmission to bypass early scanners. + # * CRITICAL MINIMUM DATA SIZES (Worst-Case Real OpenVPN Client): + # - Smallest possible tls-crypt-v2 key payload from a real client: 264 Bytes. + # - IPv4: Handshake (Packets 1-3) + Key (Packet 4) = 488 Bytes minimum. + # If network reception is very bad up to Packet 10 (empty ACKs): 748 Bytes total min. + # - IPv6: Handshake (Packets 1-3) + Key (Packet 4) = 548 Bytes minimum. + # If network reception is very bad up to Packet 10 (empty ACKs): 928 Bytes total min. + # * SAFE LOCKOUT THRESHOLDS (How the firewall decides): + # - IPv4 limit is set to < 700 Bytes. Bots with no data are smaller and get killed. + # - IPv6 limit is set to < 900 Bytes. Bots with no data are smaller and get killed. + # - Real worst-case OpenVPN users always stay safely above this byte radar. + nft "add rule inet fw4 $SUB_CHAIN tcp dport $fwPort ct packets < 12 ct packets > 9 meta nfproto ipv4 ct bytes < 700 log prefix \"openvpn(instance0)[firewall]{stealth}: \" counter update @ovpn_block_ipv4_instance0 { ip saddr timeout 5m } reject with tcp reset" + nft "add rule inet fw4 $SUB_CHAIN tcp dport $fwPort ct packets < 12 ct packets > 9 meta nfproto ipv6 ct bytes < 900 log prefix \"openvpn(instance0)[firewall]{stealth}: \" counter update @ovpn_block_ipv6_instance0 { ip6 saddr timeout 5m } reject with tcp reset" + + # TCP-S5: Blocks payload data hidden inside the very first SYN packet (80 bytes for IPv4 packets and max 100 bytes for IPv6) + nft "add rule inet fw4 $SUB_CHAIN tcp dport $fwPort ct packets < 12 meta nfproto ipv4 tcp flags & (syn) == syn tcp option maxseg exists meta length > 80 log prefix \"openvpn(instance0)[firewall]{fastopen}: \" counter update @ovpn_block_ipv4_instance0 { ip saddr timeout 5m } reject with tcp reset" + nft "add rule inet fw4 $SUB_CHAIN tcp dport $fwPort ct packets < 12 meta nfproto ipv6 tcp flags & (syn) == syn tcp option maxseg exists meta length > 100 log prefix \"openvpn(instance0)[firewall]{fastopen}: \" counter update @ovpn_block_ipv6_instance0 { ip6 saddr timeout 5m } reject with tcp reset" + + # TCP-S6: Deep Packet Inspection for bad protocol lengths and fake small sizes. + # - Starts only when a data packet is bigger than 53 bytes (IPv4) or 73 bytes (IPv6). + # - Reads the 16-bit OpenVPN packet length field at the exact bit offset pointer 256. + # - Catches bad opcodes, web text (like GET/POST), and fake sizes (payload < 155, payload > 1499). + # - Real users with tls-crypt-v2 are safe because encrypted traffic never matches this bad matrix. + nft "add rule inet fw4 $SUB_CHAIN tcp dport $fwPort ct packets < 12 meta nfproto ipv4 tcp flags & (syn) == 0 meta length > 53 @th,256,16 < 0x009b log prefix \"openvpn(instance0)[firewall]{payload}: \" counter update @ovpn_block_ipv4_instance0 { ip saddr timeout 5m } reject with tcp reset" + nft "add rule inet fw4 $SUB_CHAIN tcp dport $fwPort ct packets < 12 meta nfproto ipv4 tcp flags & (syn) == 0 meta length > 53 @th,256,16 > 0x05db log prefix \"openvpn(instance0)[firewall]{payload}: \" counter update @ovpn_block_ipv4_instance0 { ip saddr timeout 5m } reject with tcp reset" + nft "add rule inet fw4 $SUB_CHAIN tcp dport $fwPort ct packets < 12 meta nfproto ipv6 tcp flags & (syn) == 0 meta length > 73 @th,256,16 < 0x009b log prefix \"openvpn(instance0)[firewall]{payload}: \" counter update @ovpn_block_ipv6_instance0 { ip6 saddr timeout 5m } reject with tcp reset" + nft "add rule inet fw4 $SUB_CHAIN tcp dport $fwPort ct packets < 12 meta nfproto ipv6 tcp flags & (syn) == 0 meta length > 73 @th,256,16 > 0x05db log prefix \"openvpn(instance0)[firewall]{payload}: \" counter update @ovpn_block_ipv6_instance0 { ip6 saddr timeout 5m } reject with tcp reset" + + # TCP-S7: Packets that survived all previous checks are genuine. + nft "add rule inet fw4 $SUB_CHAIN tcp dport $fwPort accept" + +else + + # UDP-S1: Instantly drops traffic from IPv4/IPv6 addresses listed in the block sets. + # (Bleibt bei ct state new, damit bestehender established Tunnel-Traffic niemals blockiert wird!) + nft "add rule inet fw4 $SUB_CHAIN udp dport $fwPort ct state new ip saddr @ovpn_block_ipv4_instance0 log prefix \"openvpn(instance0)[firewall]{ban}: \" counter drop" + nft "add rule inet fw4 $SUB_CHAIN udp dport $fwPort ct state new ip6 saddr @ovpn_block_ipv6_instance0 log prefix \"openvpn(instance0)[firewall]{ban}: \" counter drop" + + # UDP-S2: Blocks fragmented UDP packets before they can cause buffer overflows. + nft "add rule inet fw4 $SUB_CHAIN udp dport $fwPort ct state new meta nfproto ipv4 ip frag-off & 0x3fff != 0 log prefix \"openvpn(instance0)[firewall]{fragment}: \" counter drop" + nft "add rule inet fw4 $SUB_CHAIN udp dport $fwPort ct state new meta nfproto ipv6 exthdr frag exists log prefix \"openvpn(instance0)[firewall]{fragment}: \" counter drop" + + # UDP-S3: Smallest valid OpenVPN packet is 48 bytes. Anything smaller is dropped. + nft "add rule inet fw4 $SUB_CHAIN udp dport $fwPort ct state new udp length < 48 log prefix \"openvpn(instance0)[firewall]{short}: \" counter drop" + + # UDP-S4: Drops and bans new IPv4 bots that scan with a zero checksum to save time. + nft "add rule inet fw4 $SUB_CHAIN udp dport $fwPort ct state new meta nfproto ipv4 udp checksum 0 log prefix \"openvpn(instance0)[firewall]{chksum}: \" counter update @ovpn_block_ipv4_instance0 { ip saddr timeout 5m } drop" + + # UDP-S5: Whitelist valid initial OpenVPN opcodes P_CONTROL_HARD_RESET_CLIENT_V1,2,3 (skipping the 64-bit UDP header) + nft "add rule inet fw4 $SUB_CHAIN udp dport $fwPort ct state new meta nfproto ipv4 @th,64,8 & 0xf8 == 0x08 udp length > 250 accept" + nft "add rule inet fw4 $SUB_CHAIN udp dport $fwPort ct state new meta nfproto ipv4 @th,64,8 & 0xf8 == 0x38 udp length > 250 accept" + nft "add rule inet fw4 $SUB_CHAIN udp dport $fwPort ct state new meta nfproto ipv4 @th,64,8 & 0xf8 == 0x50 udp length > 250 accept" + nft "add rule inet fw4 $SUB_CHAIN udp dport $fwPort ct state new meta nfproto ipv6 @th,64,8 & 0xf8 == 0x08 udp length > 250 accept" + nft "add rule inet fw4 $SUB_CHAIN udp dport $fwPort ct state new meta nfproto ipv6 @th,64,8 & 0xf8 == 0x38 udp length > 250 accept" + nft "add rule inet fw4 $SUB_CHAIN udp dport $fwPort ct state new meta nfproto ipv6 @th,64,8 & 0xf8 == 0x50 udp length > 250 accept" + + # UDP-S6: Instantly ban and drop any other packet that tries to start a new connection + nft "add rule inet fw4 $SUB_CHAIN udp dport $fwPort ct state new meta nfproto ipv4 log prefix \"openvpn(instance0)[firewall]{opcode}: \" counter update @ovpn_block_ipv4_instance0 { ip saddr timeout 5m } drop" + nft "add rule inet fw4 $SUB_CHAIN udp dport $fwPort ct state new meta nfproto ipv6 log prefix \"openvpn(instance0)[firewall]{opcode}: \" counter update @ovpn_block_ipv6_instance0 { ip6 saddr timeout 5m } drop" + + # UDP-S7: Packets that survived all previous checks are genuine. + nft "add rule inet fw4 $SUB_CHAIN udp dport $fwPort accept" + +fi + +# STEP 4: LINK THE SUB-CHAIN TO THE MAIN INPUT (single jump rule) + +# Find the starting anchor in the main input chain (Loopback handle) +mainAnchorHandle=$(nft -a list chain inet fw4 input | grep -E 'iif ["'\'']?lo["'\'']? accept' | grep -oE 'handle [0-9]+' | tail -n 1 | awk '{print $2}') + +if [ -z "$mainAnchorHandle" ]; then + mainAnchorHandle=35 +fi + +# Inject the dynamic jump trigger right below the loopback anchor rule +nft "add rule inet fw4 input handle $mainAnchorHandle $fwProto dport $fwPort jump $SUB_CHAIN" + diff --git a/luci-app-openvpn-plus/root/etc/openvpn/luci/server.default.conf b/luci-app-openvpn-plus/root/etc/openvpn/luci/server.default.conf new file mode 100755 index 000000000..9b474d3fa --- /dev/null +++ b/luci-app-openvpn-plus/root/etc/openvpn/luci/server.default.conf @@ -0,0 +1,47 @@ +# Modern OpenVPN Server Configuration Instance + +# --- Connection Mode --- +dev tun +proto udp + +# --- Network & IP Management --- +port 1194 +setenv portextern 1194 +server 10.8.0.0 255.255.255.0 +server-ipv6 fd00:db8:0:1::/64 +topology subnet + +# --- Tuning & Optimization --- +tls-timeout 5 +mssfix 1360 +tun-mtu 1420 +# Increased max-clients absorbs attacks and client roaming +max-clients 4096 + +# --- Certificates & Keys --- +ca /etc/openvpn/keys/ca_default.crt +cert /etc/openvpn/keys/server_default.crt +key /etc/openvpn/keys/server_default.key +dh /etc/openvpn/keys/dh_default.pem + +# --- Hardening & Security --- +tls-crypt-v2 /etc/openvpn/keys/server_default.tls2.key +tls-groups secp256r1 +cipher AES-256-GCM +data-ciphers AES-256-GCM:AES-128-GCM:CHACHA20-POLY1305 + +# --- Smart Firewall --- +setenv ovpnfirewall on + +# --- Client Push Options --- +push "dhcp-option DNS 9.9.9.9" +push "dhcp-option DNS 149.112.112.112" +push "dhcp-option DNS6 2620:fe::fe" +push "dhcp-option DNS6 2620:fe::9" + +# --- Verification & Logging --- +status /tmp/run/openvpn.instance.status 5 +status-version 1 +setenv logfilter on +keepalive 10 60 +verb 3 diff --git a/luci-app-openvpn-plus/root/etc/uci-defaults/35_luci-app-openvpn-plus b/luci-app-openvpn-plus/root/etc/uci-defaults/35_luci-app-openvpn-plus new file mode 100755 index 000000000..df3f6c844 --- /dev/null +++ b/luci-app-openvpn-plus/root/etc/uci-defaults/35_luci-app-openvpn-plus @@ -0,0 +1,70 @@ +#!/bin/sh + +# Copyright (C) 2008-2026 The OpenWrt Project +# Copyright (C) 2026 Manfred Jaider +# +# This is free software, licensed under the Apache License, Version 2.0. +# See /LICENSE for more information. +# +# luci-app-openvpn-plus : clean default sample profiles and prepare empty configuration +# /etc/uci-defaults/35_luci-app-openvpn-plus + +# Path to the UCI configuration file +CONFIG_FILE="${IPKG_INSTROOT}/etc/config/openvpn" + +if [ -f "$CONFIG_FILE" ]; then + # 1. Verify that all three standard firmware sample entries exist exactly as delivered + if grep -q "config openvpn custom_config" "$CONFIG_FILE" 2>/dev/null && \ + grep -q "config openvpn sample_server" "$CONFIG_FILE" 2>/dev/null && \ + grep -q "config openvpn sample_client" "$CONFIG_FILE" 2>/dev/null; then + + # 2. Check specifically for the UCI section pattern created by LuCI + if ! grep -q "config openvpn 'instance" "$CONFIG_FILE" 2>/dev/null; then + logger -t luci-app-openvpn-plus "Unmodified factory samples detected. Backing up to openvpn-def." + + # Backup the cluttered firmware default file safely + cp -f "$CONFIG_FILE" "${IPKG_INSTROOT}/etc/config/openvpn-def" 2>/dev/null + + # Create a clean, empty base configuration for your LuCI app + echo "# Clean OpenVPN base configuration" > "$CONFIG_FILE" + + CLEAN_OPENVPN_SERVICE=1 + + fi + else + # If any check fails, the user has modified the file. DO NOT TOUCH IT! + logger -t luci-app-openvpn-plus "Modified or custom OpenVPN configuration detected. Keeping user data safe." + fi + + # 3. Check if we are running on a live runtime system (opkg install / apk add) + if [ -z "${IPKG_INSTROOT}" ]; then + logger -t luci-app-openvpn-plus "Package installed at runtime. Clearing all cache and reloading services." + + # Restart rpcd in the background after 3 seconds! This gives LuCI enough time to finish the installation in the browser. + ( + # Restart the openvpn service only if we cleaned the config above + if [ "$CLEAN_OPENVPN_SERVICE" = "1" ] && [ -x "/etc/init.d/openvpn" ]; then + /etc/init.d/openvpn stop >/dev/null 2>&1 + sleep 1 + /etc/init.d/openvpn restart >/dev/null 2>&1 + sleep 1 + /etc/init.d/openvpn reload >/dev/null 2>&1 + sleep 1 + else + sleep 3 + fi + + # Delete ALL temporary LuCI files. + rm -rf /tmp/luci-* >/dev/null 2>&1 + + # Hard reset rpcd to finds the VPN menu and new ACL files. + if [ -x "/etc/init.d/rpcd" ]; then + /etc/init.d/rpcd restart >/dev/null 2>&1 + fi + ) & + else + logger -t luci-app-openvpn-plus "Image building state detected. Skipping live runtime triggers." + fi +fi + +exit 0 diff --git a/luci-app-openvpn-plus/root/usr/libexec/luci-app-openvpn-plus b/luci-app-openvpn-plus/root/usr/libexec/luci-app-openvpn-plus new file mode 100755 index 000000000..d2a7834c0 --- /dev/null +++ b/luci-app-openvpn-plus/root/usr/libexec/luci-app-openvpn-plus @@ -0,0 +1,1706 @@ +#!/bin/sh + +# Copyright (C) 2008-2026 The OpenWrt Project +# Copyright (C) 2026 Manfred Jaider +# +# This is free software, licensed under the Apache License, Version 2.0. +# See /LICENSE for more information. +# +# luci-app-openvpn-plus : helper tools and background workers +# /usr/libexec/luci-app-openvpn-plus + +# Check if the user gave an argument +if [ -z "$1" ]; then + echo "Usage: $0 {getmac|lanip|getnetstat|bestcrypto|ovpnver|ovpnstart|ovpnrestart|ovpnstop|ovpnservice|firewallservice|firewallnft|openssldgst|symlink|readstatus|iroute|cleanipdns|checkip|resolveip|checkport|checksroute|checkddns|publicip|wgetddns|ddnsservice|ucisortovpn|cleanup|keymeta|keygenclean|initkeys|keygen} [parameters]" + exit 1 +fi + +# Get mac address of lan interface +getmac() { + if [ -f "/sys/class/net/br-lan/address" ]; then + cat /sys/class/net/br-lan/address + else + echo "00:00:00:00:00:00" + fi + return 0 +} + +# Get the active LAN IP with mask +lanip() { + shift # Remove "lanip" from the arguments list + local cidr="$1" + local dump=$(ubus call network.interface.lan status 2>/dev/null) + + # Try to extract the first IPv4 address and subnet mask from the JSON data + local ip=$(echo "$dump" | jsonfilter -e '@["ipv4-address"][0].address' 2>/dev/null) + local mask=$(echo "$dump" | jsonfilter -e '@["ipv4-address"][0].mask' 2>/dev/null) + + # If no IPv4 address exists, try to get the first IPv6 address and prefix length + if [ -z "$ip" ]; then + ip=$(echo "$dump" | jsonfilter -e '@["ipv6-address"][0].address' 2>/dev/null) + mask=$(echo "$dump" | jsonfilter -e '@["ipv6-address"][0].mask' 2>/dev/null) + fi + + if [ -z "$ip" ]; then + # Error + if [ "$cidr" = "cidr" ]; then + echo "0.0.0.0/0" + else + echo "0.0.0.0" + fi + return 1 + else + # Success + if [ "$cidr" = "cidr" ]; then + echo "${ip}/${mask}" + else + echo "$ip" + fi + return 0 + fi +} + +# Get the transfer statistics of a network interface +getnetstat() { + shift # Remove "getnetstat" from the arguments list + local interface="$1" + + # Check if network interface statistics directory exists + if [ ! -d "/sys/class/net/$interface/statistics" ]; then + echo "0|0|0|0|false|$interface" + return 0 + fi + + # Read statistics directly from sysfs files + local rxb=$(cat /sys/class/net/"$interface"/statistics/rx_bytes 2>/dev/null || echo 0) + local txb=$(cat /sys/class/net/"$interface"/statistics/tx_bytes 2>/dev/null || echo 0) + local rxp=$(cat /sys/class/net/"$interface"/statistics/rx_packets 2>/dev/null || echo 0) + local txp=$(cat /sys/class/net/"$interface"/statistics/tx_packets 2>/dev/null || echo 0) + + echo "$rxb|$txb|$rxp|$txp|true|$interface" + return 0 +} + +# Evaluates system hardware to determine the highest-performing cipher suite anchor +bestcrypto() { + if grep -E -q 'aes|pmull' /proc/cpuinfo; then + echo "AES" + else + echo "CHACHA" + fi + return 0 +} + +# OpenVPN version +ovpnver() { + openvpn --version + return 0; +} + +# OpenVPN self healing start engine +ovpnstart() { + shift # Remove "ovpnstart" from the arguments list + local inst="$1" + + # If $inst contains only spaces or is empty, ensure it is completely empty + if [ -z "$(echo "$inst" | tr -d ' ')" ]; then + inst="" + fi + + local all_running="1" + + if [ -n "$inst" ] ; then + # A) SINGLE INSTANCE MODE: Check if this specific instance is running + if ! ps | grep -v "grep" | grep -q "openvpn(${inst})"; then + all_running="0" + fi + else + # B) GLOBAL MODE: Check all enabled profiles in the configuration + uci -q show openvpn | grep "=openvpn" | while read -r line; do + local check_inst="${line##*.}" + check_inst="${check_inst%%=*}" + + # Only check profiles that are enabled by the user + if [ "$(uci -q get openvpn."${check_inst}".enabled)" = "1" ]; then + # If an enabled instance process is not running, trigger the repair + if ! ps | grep -v "grep" | grep -q "openvpn(${check_inst})"; then + exit 1 + fi + fi + done + + # Catch the result from the pipeline while-loop check safely + if [ "$?" -ne 0 ]; then + all_running="0" + fi + fi + + # Recovery cascade: runs only if one or more instances failed to start + if [ "$all_running" = "0" ]; then + /etc/init.d/openvpn stop "$inst" >/dev/null 2>&1 + sleep 1 + /etc/init.d/openvpn restart "$inst" >/dev/null 2>&1 + sleep 1 + /etc/init.d/openvpn reload "$inst" >/dev/null 2>&1 + fi +} + +# OpenVPN self healing restart engine +ovpnrestart() { + shift # Remove "ovpnstart" from the arguments list + local inst="$1" + + # If $inst contains only spaces or is empty, ensure it is completely empty + if [ -z "$(echo "$inst" | tr -d ' ')" ]; then + inst="" + fi + + # Failsave restart cascade + /etc/init.d/openvpn stop "$inst" >/dev/null 2>&1 + sleep 1 + /etc/init.d/openvpn restart "$inst" >/dev/null 2>&1 + sleep 1 + /etc/init.d/openvpn reload "$inst" >/dev/null 2>&1 +} + +# OpenVPN force stop engine +ovpnstop() { + shift # Remove "ovpnstop" from the arguments list + local inst="$1" + + # If $inst contains only spaces or is empty, ensure it is completely empty + if [ -z "$(echo "$inst" | tr -d ' ')" ]; then + inst="" + fi + + # Double stop sequence with 1 second delay to clear locked procd pids safely + /etc/init.d/openvpn stop "$inst" >/dev/null 2>&1 + sleep 1 + /etc/init.d/openvpn stop "$inst" >/dev/null 2>&1 +} + +# openvpn service stop/start (PID-guarded with dynamic stop and restart/reload) +ovpnservice() { + shift # Remove "ovpnservice" from the arguments list + local action_cmd="$1" + local inst="$2" + local pid_file="/var/run/luci-app-openvpn-plus-ovpn-service.pid" + + # If $inst contains only spaces or is empty, ensure it is completely empty + if [ -z "$(echo "$inst" | tr -d ' ')" ]; then + inst="" + fi + + if [ "$action_cmd" = "stop" ] || [ "$action_cmd" = "start" ] || [ "$action_cmd" = "restart" ]; then + # 1. Clean up any stale or previous ghost workers instantly + start-stop-daemon -K -q -p "$pid_file" 2>/dev/null + rm -f "$pid_file" + + # 2. Start the self-healing sequence asynchronously in the background + if [ "$action_cmd" = "start" ]; then + start-stop-daemon -S -b -m -p "$pid_file" -x /bin/sh -- -c "sleep 3; /usr/libexec/luci-app-openvpn-plus ovpnstart \"$inst\"; rm -f $pid_file" + elif [ "$action_cmd" = "restart" ]; then + start-stop-daemon -S -b -m -p "$pid_file" -x /bin/sh -- -c "sleep 3; /usr/libexec/luci-app-openvpn-plus ovpnrestart \"$inst\"; rm -f $pid_file" + elif [ "$action_cmd" = "stop" ]; then + start-stop-daemon -S -b -m -p "$pid_file" -x /bin/sh -- -c "sleep 3; /usr/libexec/luci-app-openvpn-plus ovpnstop \"$inst\"; rm -f $pid_file" + fi + fi + return 0 +} + +# firewall service stop/start (PID-guarded with automatic self-cleanup) +firewallservice() { + shift # Remove "firewallservice" from the arguments list + local action_cmd="$1" + local pid_file="/var/run/luci-app-openvpn-plus-firewall-service.pid" + + if [ "$action_cmd" = "stop" ] || [ "$action_cmd" = "start" ] || [ "$action_cmd" = "restart" ]; then + # 1. Clean up any stale or previous ghost workers instantly + start-stop-daemon -K -q -p "$pid_file" 2>/dev/null + rm -f "$pid_file" + + # 2. Start worker and append the self-delete command to the end of the execution chain + start-stop-daemon -S -b -m -p "$pid_file" -x /bin/sh -- -c "sleep 3; /etc/init.d/firewall $action_cmd >/dev/null 2>&1; rm -f $pid_file" + fi + return 0 +} + +# Secure helper to read and change firewall rules from LuCI without restrictions +firewallnft() { + shift # Remove "firewallnft" from the arguments list + # 2>&1 redirects stderr to stdout so LuCI catches syntax errors cleanly. + /usr/sbin/nft "$@" 2>&1 + return 0 +} + +# Secure helper to calculate cryptographic hashes using openssl dgst +openssldgst() { + shift # Remove "openssldgst" from the arguments list + # We safely pipe the text inside the secure script layer + echo -n "$2" | /usr/bin/openssl dgst "$1" -r 2>&1 + return 0 +} + +# Manage temporary download links for mobile profiles [create|delete] +symlink() { + shift # Remove "symlink" from the arguments list + local ovpn_num="$1" + local action="$2" + local raw_display_name="$3" + local export_static_path="/etc/openvpn/luci/instance${ovpn_num}.ovpn" + local lan_ip="" + local link_filename="" + + # Check if instance number is a valid digit + if [ -z "$ovpn_num" ] || ! echo "$ovpn_num" | grep -q '^[0-9]\+$'; then + echo "ERROR: Invalid instance number." + return 1 + fi + + # Replace spaces with underscores or use instance name as fallback + if [ -n "$raw_display_name" ]; then + link_filename=`echo "$raw_display_name" | tr ' ' '_'` + else + link_filename="instance${ovpn_num}" + fi + + # Full path for the web root link + local public_symlink_path="/www/${link_filename}_client.ovpn" + + case "$action" in + create) + # Check if the source profile exists on disk + if [ ! -f "$export_static_path" ]; then + echo "ERROR: Profile file missing at $export_static_path" + return 1 + fi + + # Create the web link + ln -sf "$export_static_path" "$public_symlink_path" + + # Verify link and find active local router IP + if [ -L "$public_symlink_path" ]; then + lan_ip=`uci -q get network.lan.ipaddr` + if [ -z "$lan_ip" ]; then + lan_ip=`ubus call network.interface.lan status 2>/dev/null | jsonfilter -e '@["ipv4-address"].address'` + fi + if [ -z "$lan_ip" ]; then + lan_ip="localhost" + fi + echo "SUCCESS: Link created at http://${lan_ip}/${link_filename}_client.ovpn" + else + echo "ERROR: Failed to create web link." + return 1 + fi + ;; + delete) + # Remove the link from the web root + rm -f "$public_symlink_path" + echo "SUCCESS: Link deleted from web root." + ;; + *) + echo "Usage: /usr/libexec/luci-app-openvpn-plus symlink [num] [create|delete] [display_name]" + return 1 + ;; + esac + return 0 +} + +# Read active multi-user server status logs securely bypassing rpcd nobody restrictions in LuCI +readstatus() { + shift # Remove the "readstatus" command word from the arguments list + local instance_id="$1" + + if [ -z "$instance_id" ]; then + echo "ERROR: Missing instance ID parameter." + return 1 + fi + + local status_path="/var/run/openvpn.${instance_id}.status" + + # Check if the targeted runtime status file exists and contains real data payload + if [ -s "$status_path" ]; then + cat "$status_path" + return 0 + else + # Return an explicit empty line to indicate a non-initialized routing socket channel + echo "" + return 0 + fi +} + +# Validates connecting clients and applies dynamic routing topologies +iroute() { + shift # Remove "iroute" from the arguments list + + case $proto in + *tcp*) proto="tcp" ;; + esac + + # Anti-Loop Protection + if [ -n "$trusted_ip" ]; then + # Check all locally assigned interfaces for an exact match with the peer source IP + if ip addr show | grep -q -E "inet (6 )?$trusted_ip/"; then + logger -t openvpn-luci "Connection rejected: A loopback loop was detected while establishing a connection using the local router IP '$trusted_ip'." + return 1 + fi + fi + + local i=1 + local matched=0 + local var_cn val_cn + local var_rt val_rt + + while [ -n "$(eval echo \$CLIENT_CNAME_$i 2>/dev/null)" ]; do + var_cn="CLIENT_CNAME_$i" + eval "val_cn=\$$var_cn" 2>/dev/null + + if [ -n "$val_cn" ] && [ "$val_cn" = "$common_name" ] 2>/dev/null; then + var_rt="CLIENT_ROUTE_$i" + eval "val_rt=\$$var_rt" 2>/dev/null + + if [ -n "$val_rt" ] 2>/dev/null; then + # Write internal route (e.g. iroute 192.168.20.0 ...) + echo "$val_rt" > "$1" + matched=1 + return 0 + fi + fi + i=$((i+1)) + done + + if [ "$matched" -eq 0 ]; then + logger -t openvpn-luci "Connection rejected: missing CLIENT_CNAME or CLIENT_ROUTE parameters for '$common_name'." + return 1 + fi + return 0 +} + +# Clean any string from spaces, tabs, newlines, http(s)://, paths, ports and URL brackets +cleanipdns() { + shift # Remove the command word from the arguments list + local input="$1" + + # 1. Delete spaces, tabs, and carriage returns + input=$(echo "$input" | tr -d ' \t\r\n') + + # 2. Strip http:// or https:// from the beginning (Case-Insensitive) + input=$(echo "$input" | sed -E 's|^https?://||I') + + # 3. Strip everything from the first remaining slash onwards (removes paths/folders) + input=$(echo "$input" | sed -E 's|/.*||') + + # 4. PERFECT ORDER PORT FILTER: RFC-compliant separation based on your discovery + if echo "$input" | grep -q "\]"; then + # Case A: String contains IPv6 brackets -> Cut everything instantly after the closing bracket ']' + input=$(echo "$input" | sed -E 's|(\]).*|\1|') + elif echo "$input" | grep -q -E "\.[0-9a-zA-Z:-]+:[0-9]+$|^[0-9.]+: [0-9]+$"; then + # Case B: Verified IPv4 or Domain WITH a trailing port (contains dots and ends on :numbers) + input=$(echo "$input" | sed -E 's|:[0-9]+$||') + else + # Case C: Pure raw IPv6 address without brackets -> Strictly DO NOT touch the end of the address! + : + fi + + # 5. Remove brackets [ and ] used for IPv6 URL notation + input=$(echo "$input" | tr -d '[]') + + # Output the final perfectly cleaned domain, IPv4 or IPv6 string + if [ -n "$input" ]; then + echo "$input" + return 0 + fi + return 1 +} + +# Validate and clean any given IPv4 or IPv6 address string +checkip() { + shift # Remove the command word from the arguments list + local ip="$1" + + # Delete all spaces, tabs and hidden lines from the text + ip=$(echo "$ip" | tr -d ' \t\r\n') + [ -z "$ip" ] && return 1 + + local ipv4_regex="^([0-9]{1,3}\.){3}[0-9]{1,3}$" + # The exact working combination from successful shell test! + local ipv6_regex="^([0-9a-fA-F]{1,4}:|:){1,7}[0-9a-fA-F]{1,4}$|^([0-9a-fA-F]{1,4}:){1,7}$" + + # Case 1: Check if the text is a valid IPv4 address (numbers 0 to 255) + if echo "$ip" | grep -E -q "$ipv4_regex"; then + echo "$ip" | awk -F. '$1<=255 && $2<=255 && $3<=255 && $4<=255 {print $0}' | grep -q "." && { + echo "$ip" + return 0 + } + fi + + # Case 2: Check if the text matches the verified IPv6 regex + if echo "$ip" | grep -E -q "$ipv6_regex"; then + if echo "$ip" | grep -q ":"; then + # Change all hex letters to lowercase for safe matching later + echo "$ip" | tr 'A-Z' 'a-z' + return 0 + fi + fi + + return 1 +} + +# Resolve domain name to IP or validate raw IPv4/IPv6 input +resolveip() { + shift # Remove the command word from the arguments list + local target="$1" + + # Use cleanipdns to fix the input string first + target=$(cleanipdns "cleanipdns" "$target") + [ -z "$target" ] && return 1 + + local resolved_ip="" + local is_domain=false + + # 1. Check if the input is a domain name or a raw IP address + case "$target" in + *":"*) + # If it contains a colon, it must be an IPv6. + # If it has characters outside of hex (a-f), it's an invalid domain/input. + case "$target" in + *[!0-9a-fA-F:]*) is_domain=true ;; + esac + ;; + *) + # If it contains no colon, it is either IPv4 or a Domain. + # If it contains any letters (a-z), it is a domain name. + case "$target" in + *[a-zA-Z]*) is_domain=true ;; + esac + ;; + esac + + # 2. Resolve domains or pass raw IPs through + if [ "$is_domain" = true ]; then + # OpenWrt safe: We delete the first 2 lines (local dns server info). + # The IPv6 regex safely handles all compressed and mixed-case IPv6 formats from nslookup. + resolved_ip=$(nslookup -timeout=5 "$target" 2>/dev/null | \ + sed '1,2d' | \ + grep "Address" | \ + sed -E 's/Address:[[:space:]]*|Address[[:space:]]*//I' | \ + grep -E -o "([0-9]{1,3}\.){3}[0-9]{1,3}|(([0-9a-fA-F]{1,4}:){1,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|:(:[0-9a-fA-F]{1,4}){1,7}|::)" | \ + head -n 1) + else + # It is a raw IPv4 or IPv6 address typed directly into the field + resolved_ip="$target" + fi + + # 3. Mathematically validate the final IP string structure with checkip engine + resolved_ip=$(checkip "checkip" "$resolved_ip") + if [ -z "$resolved_ip" ]; then + return 1 + else + echo "$resolved_ip" + return 0 + fi +} + +# Check if TCP or UDP port is accessible from outside on Double NAT +checkport() { + shift # Remove the word "checkport" from the arguments list + + local public_host="$1" # Public WAN IP or DDNS domain name + local ext_port="$2" # External Port on WAN main modem (e.g. 58493) + local int_port="$3" # Internal Port on LAN OpenVPN router (e.g. 58493) + local proto="$4" # Protocol "udp" or "tcp" + local debug="$5" # "-debug" parameter shows firewall counter + + local usage="\n\nUsage: luci-app-openvpn-plus checkport [-debug]\n\nExample: luci-app-openvpn-plus checkport mydomain.net 443 444 tcp -debug\n\n" + + # check parameters + if [ -z "$public_host" ] || [ -z "$ext_port" ] || [ -z "$int_port" ] || [ -z "$proto" ]; then + printf "ERROR: Missing parameters.%b" "$usage" + return 1 + fi + case "$public_host:$ext_port:$int_port" in + *[\ \ ]*) + printf "ERROR: public_host or ports contain spaces.%b" "$usage" + return 1 + ;; + *:*[!0-9]*:*) + printf "ERROR: ext_port is not a valid number.%b" "$usage" + return 1 + ;; + *:*:*[!0-9]*) + printf "ERROR: int_port is not a valid number.%b" "$usage" + return 1 + ;; + esac + if [ "$proto" != "udp" ] && [ "$proto" != "tcp" ]; then + printf "ERROR: udp or tcp missing.%b" "$usage" + return 1 + fi + local debug_mode=false + if [ "$debug" = "-debug" ]; then + debug_mode=true + elif [ -n "$debug" ]; then + printf "ERROR: Invalid optional parameter '%s'.%b" "$debug" "$usage" + return 1 + fi + + # check and resolve ip from hostname + local resolved_ip=$(resolveip "resolveip" "$public_host") + if [ -z "$resolved_ip" ]; then + printf "ERROR: Invalid public hostname or ip: '%s'.%b" "$public_host" "$usage" + return 1 + fi + local ipv="ipv4" + local ip="ip" + if echo "$resolved_ip" | grep -q ":"; then + ipv="ipv6" + ip="ip6" + fi + + # Create id for firewall table + local checkport_id="luci_app_openvpn_plus_checkport_${ext_port}_${int_port}" + + # 1. Open the firewall tracking chain matching the target protocol exactly + if [ "$debug_mode" = true ]; then + + nft add chain inet fw4 "$checkport_id" + + if [ "$proto" = "tcp" ]; then + # a) if incoming port is valid on this ip/host, count it and drop (IPv4 or IPv6) + nft add rule inet fw4 "$checkport_id" meta nfproto "$ipv" "$ip" saddr "$resolved_ip" tcp dport "$int_port" counter drop + + # b) Catch EVERYTHING else from this IP on TCP layer, forcing a drop regardless of TCP flags/states (IPv4 or IPv6) + nft add rule inet fw4 "$checkport_id" meta nfproto "$ipv" "$ip" saddr "$resolved_ip" meta l4proto tcp drop + else + # a) if incoming port is valid on this ip/host, count it and drop (IPv4 or IPv6) + nft add rule inet fw4 "$checkport_id" meta nfproto "$ipv" "$ip" saddr "$resolved_ip" udp dport "$int_port" counter drop + + # b) Catch EVERYTHING else from this IP on UDP layer (IPv4 or IPv6) + nft add rule inet fw4 "$checkport_id" meta nfproto "$ipv" "$ip" saddr "$resolved_ip" meta l4proto udp drop + fi + + # Insert the firewall tracking chain in the input rule at first position + nft insert rule inet fw4 input jump "$checkport_id" + + # Debug: firewall table content on start + echo "--- DEBUG: FIREWALL FILTER TABLE - ON START CHECK" + nft -a list chain inet fw4 "$checkport_id" 2>/dev/null + echo "---" + + else + + nft add chain inet fw4 "$checkport_id" 2>/dev/null + + if [ "$proto" = "tcp" ]; then + # a) if incoming port is valid on this ip/host, count it and drop (IPv4 or IPv6) + nft add rule inet fw4 "$checkport_id" meta nfproto "$ipv" "$ip" saddr "$resolved_ip" tcp dport "$int_port" counter drop 2>/dev/null + + # b) Catch EVERYTHING else from this IP on TCP layer, forcing a drop regardless of TCP flags/states (IPv4 or IPv6) + nft add rule inet fw4 "$checkport_id" meta nfproto "$ipv" "$ip" saddr "$resolved_ip" meta l4proto tcp drop 2>/dev/null + else + # a) if incoming port is valid on this ip/host, count it and drop (IPv4 or IPv6) + nft add rule inet fw4 "$checkport_id" meta nfproto "$ipv" "$ip" saddr "$resolved_ip" udp dport "$int_port" counter drop 2>/dev/null + + # b) Catch EVERYTHING else from this IP on UDP layer (IPv4 or IPv6) + nft add rule inet fw4 "$checkport_id" meta nfproto "$ipv" "$ip" saddr "$resolved_ip" meta l4proto udp drop 2>/dev/null + fi + + # Insert the firewall tracking chain in the input rule at first position + nft insert rule inet fw4 input jump "$checkport_id" 2>/dev/null + + fi + + # Force refresh by querying the chain + nft -a list chain inet fw4 "$checkport_id" >/dev/null 2>&1 + nft -a list chain inet fw4 input >/dev/null 2>&1 + + # 2. Send the probe packet over wan/net + if [ "$proto" = "tcp" ]; then + # Robust HTTPS connection check with connection-close signaling + uclient-fetch -T 1 -q -s --no-check-certificate --header="Connection: close" "https://${resolved_ip}:${ext_port}/" >/dev/null 2>&1 + else + # UDP Mode: Freeze the port using native busybox nslookup to prevent increments + nslookup -port="$ext_port" portcheck.local "$resolved_ip" >/dev/null 2>&1 + fi + + # 3. Read the nftables chain and verify if packets hit the internal counter of rule a) + local packet_count=$(nft list chain inet fw4 "$checkport_id" 2>/dev/null | grep "dport $int_port" | grep -oE "counter packets [0-9]+" | awk '{print $3}') + [ -z "$packet_count" ] && packet_count=0 + + local scan_res="PORT_CLOSED" + if [ "$packet_count" -gt 0 ]; then + scan_res="PORT_OPEN" + fi + + echo "$scan_res" + + # 4. Clean up ALL old ghost firewall rules to keep the router fast and safe + if [ "$debug_mode" = true ]; then + + # Debug: firewall table content on end + echo "--- DEBUG: FIREWALL FILTER TABLE - ON END CHECK" + nft -a list chain inet fw4 "$checkport_id" 2>/dev/null + echo "---" + + # 4. Clean up ALL old ghost firewall rules to keep the router fast and safe + local handle_ids=$(nft -a list chain inet fw4 input | grep "jump $checkport_id" | grep -oE "handle [0-9]+" | awk '{print $2}') + for handle in $handle_ids; do + nft delete rule inet fw4 input handle "$handle" + done + nft delete chain inet fw4 "$checkport_id" + + else + + local handle_ids=$(nft -a list chain inet fw4 input | grep "jump $checkport_id" | grep -oE "handle [0-9]+" | awk '{print $2}') + for handle in $handle_ids; do + nft delete rule inet fw4 input handle "$handle" 2>/dev/null + done + nft delete chain inet fw4 "$checkport_id" 2>/dev/null + fi + + if [ "$scan_res" = "PORT_OPEN" ]; then + return 0 + else + return 1 + fi +} + +# Check if the main WAN router has a working static route to a remote net pointing to this VPN router +checksroute() { + shift # Remove the word "checksroute" from the arguments list + + local wan_router_ip="$1" # The main WAN gateway IP (e.g. 192.168.50.1) + local remote_test_net="$2" # The remote subnet base (e.g. 192.168.60.0/24) + local debug="$3" # "-debug" parameter shows firewall counter + + local usage="\n\nUsage: luci-app-openvpn-plus checksroute [-debug]\n\nExample: luci-app-openvpn-plus checksroute 192.168.50.1 192.168.60.0/24 -debug\n\n" + + # check parameters + if [ -z "$remote_test_net" ] || [ -z "$wan_router_ip" ]; then + printf "ERROR: Missing parameters.%b" "$usage" + return 1 + fi + # Clean trailing slashes, prefixes or spaces + local clean_netip=$(echo "$remote_test_net" | cut -d'/' -f1 | tr -d ' ') + local clean_wanip=$(echo "$wan_router_ip" | cut -d'/' -f1 | tr -d ' ') + + # Check if IPs contain only good characters (numbers, letters a-f, dots, colons) + case "${clean_netip}:${clean_wanip}" in + *[!0-9a-fA-F.:]*) + printf "ERROR: Invalid characters in IP address.%b" "$usage" + return 1 + ;; + esac + + # DETECT IP MIXES: Check if one is IPv4 and the other is IPv6 + local net_is_v6=false + local wan_is_v6=false + echo "$clean_netip" | grep -q ":" && net_is_v6=true + echo "$clean_wanip" | grep -q ":" && wan_is_v6=true + + if [ "$net_is_v6" != "$wan_is_v6" ]; then + printf "ERROR: Mixed IP protocols detected (IPv4 and IPv6 cannot be mixed).%b" "$usage" + return 1 + fi + + local debug_mode=false + if [ "$debug" = "-debug" ]; then + debug_mode=true + elif [ -n "$debug" ]; then + printf "ERROR: Invalid optional parameter '%s'.%b" "$debug" "$usage" + return 1 + fi + + # Change dots (.) and colons (:) to underscores (_) to make the IDs + local netid=$(echo "$clean_netip" | tr '.:' '__') + local wanid=$(echo "$clean_wanip" | tr '.:' '__') + + # create id for firewall table + local checksroute_id="luci_app_openvpn_plus_checksroute_${netid}__${wanid}" + + local test_ip="" + local ping_cmd="ping" + + # Structure test_ip based on the actual network type (now verified matching) + if [ "$net_is_v6" = true ]; then + # --- IPv6 --- + ping_cmd="ping6" + + # Remove any trailing colons first + local base_v6=$(echo "$clean_netip" | sed 's/:*$//') + + # If the subnet ends with a double colon (e.g., fd00:1::) + if echo "$clean_netip" | grep -q "::$"; then + test_ip="${base_v6}::254" + else + # If it is a full IP or notation without trailing "::" (e.g., fd00:1:2:3:4:5:6:0) + test_ip=$(echo "$base_v6" | awk -F: '{OFS=":"; $NF="254"; print}') + fi + else + # --- IPv4 --- + ping_cmd="ping" + test_ip=$(echo "$clean_netip" | awk -F. '{print $1"."$2"."$3".254"}') + fi + + # 1. Create a minimal temporary NAT rule + if [ "$debug_mode" = true ]; then + nft add chain inet fw4 "$checksroute_id" "{ type nat hook postrouting priority srcnat; }" + if [ "$wan_is_v6" = true ]; then + nft add rule inet fw4 "$checksroute_id" ip6 daddr "$clean_wanip" counter snat to "$test_ip" + else + nft add rule inet fw4 "$checksroute_id" ip daddr "$clean_wanip" counter snat to "$test_ip" + fi + + echo "--- DEBUG: FIREWALL FILTER TABLE - ON START CHECK" + nft -a list chain inet fw4 "$checksroute_id" 2>/dev/null + echo "---" + else + nft add chain inet fw4 "$checksroute_id" "{ type nat hook postrouting priority srcnat; }" 2>/dev/null + if [ "$wan_is_v6" = true ]; then + nft add rule inet fw4 "$checksroute_id" ip6 daddr "$clean_wanip" counter snat to "$test_ip" 2>/dev/null + else + nft add rule inet fw4 "$checksroute_id" ip daddr "$clean_wanip" counter snat to "$test_ip" 2>/dev/null + fi + + # Force refresh by querying the chain + nft -a list chain inet fw4 "$checkport_id" >/dev/null 2>&1 + fi + + # 2. Fire the probe ping to the WAN router and evaluate the exit code + if $ping_cmd -c 1 -W 1 "$clean_wanip" >/dev/null 2>&1; then + echo "ROUTE_OPEN" + local route_res=0 + else + echo "ROUTE_CLOSED" + local route_res=1 + fi + + # Debug: firewall table content on end + if [ "$debug_mode" = true ]; then + echo "--- DEBUG: FIREWALL FILTER TABLE - ON END CHECK" + nft -a list chain inet fw4 "$checksroute_id" 2>/dev/null + echo "---" + + # Clean up (Errors visible) + nft delete chain inet fw4 "$checksroute_id" + else + # Clean up (Errors hidden) + nft delete chain inet fw4 "$checksroute_id" 2>/dev/null + fi + + return $route_res +} + +# Check a DDNS domain name or validate a raw public WAN IP address, and verify if it is online +checkddns() { + shift # Remove the command word from the arguments list + local target="$1" + + local resolved_ip=$(resolveip "resolveip" "$target") + [ -z "$resolved_ip" ] && return 1 + + # Live connection verification via native OpenWrt core ping utilities + # Tuning: If the first ping succeeds, we return immediately. Second ping acts as a backup. + if echo "$resolved_ip" | grep -q ":"; then + # Target is IPv6 + if ping6 -c 1 -W 2 "$resolved_ip" >/dev/null 2>&1 || ping6 -c 1 -W 2 "$resolved_ip" >/dev/null 2>&1; then + echo "$resolved_ip" + return 0 + fi + else + # Target is IPv4 + if ping -c 1 -W 2 "$resolved_ip" >/dev/null 2>&1 || ping -c 1 -W 2 "$resolved_ip" >/dev/null 2>&1; then + echo "$resolved_ip" + return 0 + fi + fi + + return 1 +} + +# Detect the real public WAN IP (IPv4 or IPv6) +publicip() { + local resolved_ip="" + local valid_ip="" + + # Stage 1: Google Anycast TXT reflection (Flexible pattern extraction captures both IPv4 and IPv6 inside quotes) + resolved_ip=$(nslookup -timeout=5 -type=txt o-o.myaddr.l.google.com ns1.google.com 2>/dev/null | grep -E -o '"[^"]+"' | tr -d '"' | head -n 1) + + # Stage 2: Cisco OpenDNS fallback (Flexible pattern extraction captures both IPv4 and IPv6 structures) + if [ -z "$resolved_ip" ]; then + resolved_ip=$(nslookup -timeout=5 myip.opendns.com resolver1.opendns.com 2>/dev/null | grep -A 1 "Name:" | grep "Address" | sed 's/Address:[[:space:]]*//I' | grep -E -o "([0-9]{1,3}\.){3}[0-9]{1,3}|([0-9a-f]{1,4}:|:){1,7}[0-9a-f]{1,4}" | head -n 1) + fi + + # check the returned + valid_ip=$(checkip "checkip" "$resolved_ip") + + # Output the final validated IP address or return empty string on complete failure + if [ -n "$valid_ip" ]; then + echo "$valid_ip" + return 0 + fi + + return 1 +} + +# Sends the update URL to the DDNS provider, checks the response for errors, and returns a clean status message. +wgetddns() { + shift # Remove the command word from the arguments list + local url="$1" + local domain="$2" + + if [ -z "$url" ]; then + echo "ERROR: Missing URL parameter for ${domain}." + return 1 + fi + + local clean_res="" + local wget_output + local wget_status=1 + local wget_status=1 + local attempt=1 + local max_attempts=3 + while [ $attempt -le $max_attempts ]; do + wget_output=$(wget -T 10 --no-check-certificate --user-agent="'Mozilla/5.0'" -qO- "$url" 2>&1) + wget_status=$? + + # ok - break + if [ "$wget_status" -eq 0 ]; then + break + fi + + # error - try again + if [ $attempt -lt $max_attempts ]; then + echo "Wget failed (code $wget_status) for ${domain}. Retry $attempt/$max_attempts in 2 seconds..." + sleep 2 + fi + attempt=$((attempt + 1)) + done + + if [ "$wget_status" -ne 0 ]; then + # -i ignores case, -E matches specific HTTP error strings from wget stderr + if echo "$wget_output" | grep -q -i -E "401|unauthorized"; then + clean_res="badauth" + elif echo "$wget_output" | grep -q -i -E "404|not found"; then + clean_res="notfound" + elif echo "$wget_output" | grep -q -i -E "403|forbidden"; then + clean_res="blocked" + elif echo "$wget_output" | grep -q -i -E "429|too many requests"; then + clean_res="abuse" + elif echo "$wget_output" | grep -q -i -E "400|bad request"; then + clean_res="badrequest" + elif echo "$wget_output" | grep -q -i -E "502|bad gateway"; then + clean_res="dnserr" + elif echo "$wget_output" | grep -q -i -E "500|503|server error"; then + clean_res="server_error" + else + echo "Update network connection failed for ${domain} (wget code: $wget_status)." + return 1 + fi + else + clean_res=$(echo "$wget_output" | tr -d ' \t\r\n' | head -c 50) + fi + + local nice_output + if [ "$wget_status" -ne 0 ]; then + nice_output="HTTP Error ($clean_res)" + else + nice_output=$(echo "$wget_output" | tr -d '\r\n' | head -c 60) + fi + + # --- THE UNIVERSAL DDNS ENGINE (DuckDNS, IPv64, dynv6, Dyn, Dynu, No-IP & FreeDNS) --- + case "$clean_res" in + *DOCTYPE*|*html*|*xml*) + echo "Error: Provider returned a web page instead of API text for ${domain}. Please check your Custom Update URL!" + return 1 + ;; + *good*|*nochg*|ok|OK|*success*|*[Uu]pdated*|*[Nn]o[Cc]hange*|*[Uu]nchanged*|*[Tt]rue*|200|*[Aa]ccepted*|*[Cc]hanged*|*[Rr]enewed*|*[Rr]efresh*|*[Mm]odified*|*[Dd]one*) + echo "Update successful for ${domain}. Provider response: $nice_output" + return 0 + ;; + *badauth*|*badkey*|*invalid*|*[Aa]uthentication*|*[Ii]nvalid[Tt]oken*) + echo "Warning: Provider rejected credentials for ${domain}. Response: $nice_output. Check your token or username!" + return 1 + ;; + *unknown*) + echo "Error: Unknown or badly formatted request sent to ${domain}. Please check your Wizard inputs!" + return 1 + ;; + *badrequest*) + echo "Error: Invalid parameters sent to ${domain}. Please verify your domain syntax!" + return 1 + ;; + *badagent*) + echo "Error: Client blocked by provider for ${domain}. Your User-Agent string is invalid!" + return 1 + ;; + *nohost*|*notfqdn*|*notfound*|*[Uu]nableto[Ll]ocate*) + echo "Warning: Provider domain, hostname, or format parameters not found for ${domain}. Check your setup!" + return 1 + ;; + *numhost*) + echo "Error: Too many hostnames specified or DNS configuration is ambiguous on provider side for ${domain}!" + return 1 + ;; + *badip*|*[Aa]ddress[Mm]issing*) + echo "Error: Provider rejected the IP address for ${domain}. Invalid IP address format detected!" + return 1 + ;; + *noipv6*) + echo "Error: IPv6 update failed for ${domain}. IPv6 records are not supported under this profile!" + return 1 + ;; + *dnserr*|*dns_error*) + echo "Error: Server-side DNS update failed on provider infrastructure for ${domain}. Response: $nice_output" + return 1 + ;; + *!donator*) + echo "Error: Feature not allowed for ${domain}. You are using premium features on a free account!" + return 1 + ;; + *abuse*|*blocked*|*disver*) + echo "Error: Update blocked due to abuse or rate limit for ${domain}. Response: $nice_output" + return 1 + ;; + *servererror*|*server_error*|*[Ee]mergency[Ss]ervices*) + echo "Error: Internal server error on provider side for ${domain}. Response: $nice_output" + return 1 + ;; + *) + echo "Update finished for ${domain}. Raw response: $nice_output" + return 0 + ;; + esac +} + +# Autonomous background DDNS checker for openvpn server instances (called from '/etc/hotplug.d/openvpn/35-luci-app-openvpn-plus-lifecycle' on openvpn server instance start) +ddnsservice() { + shift # Remove "ddnsservice" string modifier from the system arguments list + + local domain="$1" + local url="$2" + local instance_name="$3" + + local standard_check=60 + local cool_down_period=3600 # 1 hour security window lock + + local status_file="/var/run/openvpn-ddns-${instance_name}.status" + + if [ -z "$url" ] || [ -z "$domain" ]; then + return 0 + fi + + logger -t openvpn-luci "DDNS Service: Background DDNS checker initiated for ${instance_name}." + + # Protect process lifecycle: disable exit-on-error during network drops + set +e + + local current_router_ip="" + local last_resolved_ip="" + + while true; do + current_router_ip=$(publicip) + + # Resolve active live DNS bindings via nslookup pipelines + if echo "$current_router_ip" | grep -q ":"; then + last_resolved_ip=$(nslookup -timeout=5 -type=aaaa "$domain" 2>/dev/null | grep -A 1 "Name:" | grep "Address" | sed 's/Address:[[:space:]]*//I' | grep -E -o "([0-9a-f]{1,4}:|:){1,7}[0-9a-f]{1,4}" | head -n 1) + else + last_resolved_ip=$(nslookup -timeout=5 -type=a "$domain" 2>/dev/null | grep -A 1 "Name:" | grep "Address" | sed 's/Address:[[:space:]]*//I' | grep -E -o "([0-9]{1,3}\.){3}[0-9]{1,3}" | head -n 1) + fi + + current_router_ip=$(checkip "checkip" "$current_router_ip") + last_resolved_ip=$(checkip "checkip" "$last_resolved_ip") + + if [ -z "$current_router_ip" ] || [ -z "$last_resolved_ip" ]; then + sleep "$standard_check" + continue + fi + + # Process evaluation logic + if [ "$current_router_ip" != "$last_resolved_ip" ]; then + local current_uptime=$(awk '{print int($1)}' /proc/uptime) + local last_update_uptime=0 + local last_url="" + + if [ -f "$status_file" ]; then + last_update_uptime=$(sed -n '1p' "$status_file" | tr -d '[:space:]' | grep -E -o "^[0-9]+$") + last_url=$(sed -n '2p' "$status_file" | tr -d '\r\n') # FIXED: Retains string variables structure layout + [ -z "$last_update_uptime" ] && last_update_uptime=0 + fi + + local seconds_passed=$((current_uptime - last_update_uptime)) + + # Evaluate core synchronization conditions + if [ "$last_update_uptime" -eq 0 ] || [ "$seconds_passed" -ge "$cool_down_period" ] || [ "$seconds_passed" -lt 0 ] || [ "$url" != "$last_url" ]; then + if [ -n "$last_url" ] && [ "$url" != "$last_url" ]; then + logger -t openvpn-luci "DDNS Service: URL change detected for ${instance_name}. Forcing immediate update." + else + logger -t openvpn-luci "DDNS Service: Different IP found for ${instance_name}. WAN: ${current_router_ip} vs DNS: ${last_resolved_ip}. Sending update URL." + fi + + local wgetddns_response + wgetddns_response=$(wgetddns "wgetddns" "$url" "$instance_name") + + logger -t openvpn-luci "DDNS Service: ${wgetddns_response}" + + local fresh_uptime=$(awk '{print int($1)}' /proc/uptime) + echo "$fresh_uptime" > "$status_file" + echo "$url" >> "$status_file" + fi + else + # Flash-safe evaluation window prevents loop locks on identical states + if [ -f "$status_file" ]; then + local current_uptime=$(awk '{print int($1)}' /proc/uptime) + local last_update_uptime=$(sed -n '1p' "$status_file" | tr -d '[:space:]' | grep -E -o "^[0-9]+$") + [ -z "$last_update_uptime" ] && last_update_uptime=0 + local seconds_passed=$((current_uptime - last_update_uptime)) + + if [ "$seconds_passed" -ge "$cool_down_period" ] || [ "$seconds_passed" -lt 0 ]; then + rm -f "$status_file" + fi + fi + fi + + sleep "$standard_check" + done + return 0 +} + +# This function dynamically check and sort if necessary sorts all openvpn sections +ucisortovpn() { + + # 1. Get the current raw list of instances from the file + local sections=$(uci -q show openvpn | grep "=openvpn" | cut -d. -f2 | cut -d= -f1) + [ -z "$sections" ] && return 0 + + local previous_num=-1 + local need_reorder=false + + # 2. Check if a smaller number comes after a bigger number + for section_name in $sections; do + # Extract only the digits from the name (e.g., "instance2" -> "2") + local current_num=$(echo "$section_name" | grep -oE "[0-9]+") + [ -z "$current_num" ] && continue + + # If the current number is smaller than the last one, we must reorder! + if [ "$current_num" -lt "$previous_num" ]; then + need_reorder=true + break + fi + previous_num="$current_num" + done + + # 3. Run the reorder process only if necessary + if [ "$need_reorder" = "true" ]; then + local index=0 + + # Get the naturally sorted list (instance1, instance2) + local sorted_sections=$(echo "$sections" | sort -V) + + for section_name in $sorted_sections; do + [ -z "$section_name" ] && continue + + # Execute reorder for every section (e.g uci reorder openvpn.instance1=0) + uci reorder "openvpn.${section_name}=${index}" 2>/dev/null + index=$((index + 1)) + done + + # commit to save + uci commit openvpn 2>/dev/null + fi + + return 0 +} + +# Delete configuration and key files of deleted VPN instances +cleanup() { + shift # Remove "cleanup" from the arguments list + local conf_file + local instname + local conf_dir="/etc/openvpn/luci" + local key_dir="/etc/openvpn/keys" + + # Step 1: Stop here if there are no instance config files on the router + ls /etc/openvpn/luci/instance*.conf >/dev/null 2>&1 || return 0 + + # Step 2: Loop through all existing instance configuration files + for conf_file in "$conf_dir"/instance*.conf; do + if [ -f "$conf_file" ]; then + # Extract the pure instance name from the file path + instname="${conf_file##*/}" + instname="${instname%.conf}" + + # Step 3: If the UCI section does not exist, delete files and crypto keys + if ! uci -q get openvpn."$instname" >/dev/null; then + rm -f "$conf_file" + rm -f "$conf_dir/${instname}.nft" + rm -f "$conf_dir/${instname}.s2s.nft" + rm -f "$key_dir/ca_${instname}.crt" + rm -f "$key_dir/dh_${instname}.pem" + rm -f "$key_dir/server_${instname}.crt" + rm -f "$key_dir/client_${instname}.crt" + rm -f "$key_dir/server_${instname}.key" + rm -f "$key_dir/client_${instname}.key" + rm -f "$key_dir/server_${instname}.tls2.key" + rm -f "$key_dir/client_${instname}.tls2.key" + fi + fi + done + + # Step 4: Delete all remaining temporary symlink files from the system + rm -f /www/*.ovpn + rm -f "$conf_dir"/instance*.ovpn + return 0 +} + + +# Validate and extract cryptographic metadata for LuCI +keymeta() { + shift # Remove "keymeta" from the arguments list + local filename="$1" + local absolute_path="/etc/openvpn/keys/$filename" + local b64_payload="" + + # Hard validation guard against directory traversal attacks + if [ -z "$filename" ] || echo "$filename" | grep -q '\.\.'; then + echo "ERROR: Invalid or restricted filename frame context." + return 1 + fi + + if [ -s "$absolute_path" ]; then + # Case A: Check for valid Certificate (CA, Server or Client Certificate) + if grep -q "BEGIN CERTIFICATE" "$absolute_path"; then + local cert_text + cert_text=$(openssl x509 -in "$absolute_path" -noout -text 2>/dev/null) + + if echo "$cert_text" | grep -q "CA:TRUE"; then + if grep -q "BEGIN PRIVATE KEY" "$absolute_path" || grep -q "BEGIN RSA PRIVATE KEY" "$absolute_path" || grep -q "BEGIN EC PRIVATE KEY" "$absolute_path"; then + echo "TYPE: CERTIFICATE AUTHORITY (CA:TRUE) - SECRET-KEY-CA BUNDLED" + else + echo "TYPE: CERTIFICATE AUTHORITY (CA:TRUE)" + fi + else + echo "TYPE: STANDARD CERTIFICATE" + fi + echo "$cert_text" | grep -E "Subject:|Not After|Public-Key|ASN1 OID|[Nn][Ii][Ss][Tt]|[Cc]urve" | sed 's/^[[:space:]]*//' + + # Case B: Check for standalone Private Keys (RSA or ECC) + elif grep -q "BEGIN PRIVATE KEY" "$absolute_path" || grep -q "BEGIN RSA PRIVATE KEY" "$absolute_path" || grep -q "BEGIN EC PRIVATE KEY" "$absolute_path"; then + echo "TYPE: PRIVATE-KEY" + openssl pkey -in "$absolute_path" -noout -text 2>/dev/null | \ + grep -E "Private-Key|ASN1 OID|[Nn][Ii][Ss][Tt]|[Cc]urve" | sed 's/^[[:space:]]*//' + + # Case C: Check for standalone Diffie-Hellman Parameter matrices + elif grep -q "DH PARAMETERS" "$absolute_path"; then + openssl pkeyparam -in "$absolute_path" -noout -text 2>/dev/null | \ + grep -E "DH Parameters|bit" | sed 's/^[[:space:]]*//' + + # Case D: Modern OpenVPN Static & Asymmetric V2 Key Validator + elif grep -q "BEGIN OpenVPN" "$absolute_path"; then + + if grep -q "BEGIN OpenVPN Static key V1" "$absolute_path"; then + # Old Hex V1 Format check (16 lines of hex data) + local hex_lines_count + hex_lines_count=$(grep -v "BEGIN" "$absolute_path" | grep -v "END" | tr -d '\r' | grep -E -c "^[0-9a-fA-F]{32}$") + if [ "$hex_lines_count" -eq 16 ] && grep -q "END OpenVPN Static key V1" "$absolute_path"; then + echo "WARNING: Symmetric Static Secret (2048 Bit) - TLS-CRYPT V1 Unsupported" + else + echo "ERROR: OpenVPN static key V1 validation failed (Structural format corruption)." + fi + + elif grep -q "BEGIN OpenVPN tls-crypt-v2 server key" "$absolute_path"; then + # Modern Asymmetric V2 Base64 extraction and integrity test + b64_payload=$(grep -v "BEGIN" "$absolute_path" | grep -v "END" | tr -d '\r\n') + # OpenSSL base64 pipe checks if decode throws errors (corrupted characters) + if echo "$b64_payload" | openssl base64 -d >/dev/null 2>&1 && grep -q "END OpenVPN tls-crypt-v2 server key" "$absolute_path"; then + echo "TYPE: TLSv2-Server-Key - TLS-CRYPT V2 SERVER MASTER KEY Validated" + else + echo "ERROR: OpenVPN tls-crypt-v2 server key validation failed (Base64 encoding or structural format corruption)." + fi + + elif grep -q "BEGIN OpenVPN tls-crypt-v2 client key" "$absolute_path"; then + # Modern Asymmetric V2 Base64 extraction and integrity test + b64_payload=$(grep -v "BEGIN" "$absolute_path" | grep -v "END" | tr -d '\r\n') + if echo "$b64_payload" | openssl base64 -d >/dev/null 2>&1 && grep -q "END OpenVPN tls-crypt-v2 client key" "$absolute_path"; then + echo "TYPE: TLSv2-Client-Key - TLS-CRYPT V2 CLIENT KEY Validated" + else + echo "ERROR: OpenVPN tls-crypt-v2 client key validation failed (Base64 encoding or structural format corruption)." + fi + + else + # Fallback for generic old static keys + if grep -q "END OpenVPN Static key" "$absolute_path"; then + echo "WARNING: Symmetric Static Secret (2048 Bit) - STATIC KEY Unsupported" + else + echo "ERROR: OpenVPN static key validation failed (Unknown layout or structural corruption)." + fi + fi + else + echo "TYPE: Custom Raw Data Structure" + fi + else + echo "ERROR: File is empty or missing." + fi + return 0 +} + +# Remove keygen_worker temporary keys +keygenclean() { + rm -f /tmp/openvpn.keygen.* + return 0 +} + +# Create default fallback certificates and keys using the router name +initkeys() { + shift # Remove "initkeys" from the arguments list + local key_dir="/etc/openvpn/keys" + local lock_file="/var/run/openvpn.keygen.lock" + local missing=0 + local exit_code=0 + local dummy_payload="MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzV1c4B9x9C3x" + + # Step 1: Check if all default files exist on the disk (Including new client-cert nodes) + [ -f "$key_dir/ca_default.crt" ] || missing=1 + [ -f "$key_dir/server_default.crt" ] || missing=1 + [ -f "$key_dir/server_default.key" ] || missing=1 + [ -f "$key_dir/client_default.crt" ] || missing=1 + [ -f "$key_dir/client_default.key" ] || missing=1 + [ -f "$key_dir/dh_default.pem" ] || missing=1 + [ -f "$key_dir/server_default.tls2.key" ] || missing=1 + [ -f "$key_dir/client_default.tls2.key" ] || missing=1 + + # If all files exist, delete the lock file and stop immediately + if [ "$missing" -eq 0 ]; then + rm -f "$lock_file" + return 0 + fi + + # Step 2: Files are missing, create lock file and set clean traps + mkdir -p "$key_dir" + touch "$lock_file" + trap "rm -f $lock_file" EXIT INT TERM + + # Step 3: Check if openssl and openvpn are installed on this router + if command -v openssl >/dev/null 2>&1 && command -v openvpn >/dev/null 2>&1; then + local router_name="" + local client_name="" + router_name=$(cat /proc/sys/kernel/hostname 2>/dev/null) + if [ -z "$router_name" ]; then router_name=$(uci -q get system.@system.hostname); fi + if [ -z "$router_name" ]; then router_name="OpenWrt"; fi + + # Clean the router name string from bad characters and spaces + router_name=$(echo "$router_name" | tr -d '"'\''\\' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//') + + # Create temporary extension configuration files in RAM + local ext_server_tmp="/tmp/openvpn.initkeys.server.ext.$$" + local ext_client_tmp="/tmp/openvpn.initkeys.client.ext.$$" + + echo "basicConstraints=CA:FALSE" > "$ext_server_tmp" + echo "keyUsage=digitalSignature,keyEncipherment" >> "$ext_server_tmp" + echo "extendedKeyUsage=serverAuth" >> "$ext_server_tmp" + + echo "basicConstraints=CA:FALSE" > "$ext_client_tmp" + echo "keyUsage=digitalSignature" >> "$ext_client_tmp" + echo "extendedKeyUsage=clientAuth" >> "$ext_client_tmp" + + # Step 4: Generate all crypto parts in a fast waterfall chain (Strictly separated roles) + + # 4a) Generate Certificate Authority (CA) + openssl req -new -x509 -nodes -days 36500 -newkey rsa:2048 \ + -keyout "$key_dir/ca_default.crt" \ + -subj "/CN=${router_name} (OpenVPN CA)" 2>/dev/null && \ + openssl req -new -x509 -nodes -days 36500 -key "$key_dir/ca_default.crt" \ + -subj "/CN=${router_name} (OpenVPN CA)" 2>/dev/null >> "$key_dir/ca_default.crt" && \ + \ + # 4b) Generate Server Private Key and CSR + openssl req -new -nodes -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \ + -keyout "$key_dir/server_default.key" \ + -out "$key_dir/server_default.csr" \ + -subj "/CN=${router_name} (OpenVPN Server)" 2>/dev/null && \ + \ + # 4c) Sign Server Certificate using stable RAM config file + openssl x509 -req -days 36500 \ + -in "$key_dir/server_default.csr" \ + -CA "$key_dir/ca_default.crt" \ + -CAkey "$key_dir/ca_default.crt" \ + -set_serial "$$" \ + -extfile "$ext_server_tmp" \ + -out "$key_dir/server_default.crt" 2>/dev/null && \ + \ + # 4d) Generate Client Private Key and CSR + openssl req -new -nodes -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \ + -keyout "$key_dir/client_default.key" \ + -out "$key_dir/client_default.csr" \ + -subj "/CN=${router_name} (OpenVPN Client)" 2>/dev/null && \ + \ + # 4e) Sign Client Certificate using stable RAM config file + openssl x509 -req -days 36500 \ + -in "$key_dir/client_default.csr" \ + -CA "$key_dir/ca_default.crt" \ + -CAkey "$key_dir/ca_default.crt" \ + -set_serial "$(( $$ + 1 ))" \ + -extfile "$ext_client_tmp" \ + -out "$key_dir/client_default.crt" 2>/dev/null && \ + \ + # 4f) Generate DH Parameters and OpenVPN static server keys + openssl dhparam -dsaparam -out "$key_dir/dh_default.pem" 2048 2>/dev/null && \ + openvpn --genkey tls-crypt-v2-server "$key_dir/server_default.tls2.key" 2>/dev/null && \ + \ + # 4g) Derive OpenVPN static client keys + client_name=$(echo -n "$router_name (OpenVPN Client TLSv2)" | openssl enc -base64) && \ + openvpn --tls-crypt-v2 "$key_dir/server_default.tls2.key" --genkey tls-crypt-v2-client "$key_dir/client_default.tls2.key" "$client_name" + + # Save the status code of the complete command chain + exit_code=$? + + # Delete temporary files from the RAM disk safely + rm -f "$key_dir/server_default.csr" "$key_dir/client_default.csr" + rm -f "$ext_server_tmp" "$ext_client_tmp" + + if [ "$exit_code" -ne 0 ]; then + missing=1 + else + missing=0 + fi + fi + + # Step 5: If generation failed, create dummy files so openvpn does not crash + if [ "$missing" -eq 1 ] ; then + [ -f "$key_dir/ca_default.crt" ] || printf -- "-----BEGIN CERTIFICATE-----\n%s\n-----END CERTIFICATE-----\n" "$dummy_payload" > "$key_dir/ca_default.crt" + [ -f "$key_dir/server_default.crt" ] || printf -- "-----BEGIN CERTIFICATE-----\n%s\n-----END CERTIFICATE-----\n" "$dummy_payload" > "$key_dir/server_default.crt" + [ -f "$key_dir/server_default.key" ] || printf -- "-----BEGIN PRIVATE KEY-----\n%s\n-----END PRIVATE KEY-----\n" "$dummy_payload" > "$key_dir/server_default.key" + [ -f "$key_dir/client_default.crt" ] || printf -- "-----BEGIN CERTIFICATE-----\n%s\n-----END CERTIFICATE-----\n" "$dummy_payload" > "$key_dir/client_default.crt" + [ -f "$key_dir/client_default.key" ] || printf -- "-----BEGIN PRIVATE KEY-----\n%s\n-----END PRIVATE KEY-----\n" "$dummy_payload" > "$key_dir/client_default.key" + [ -f "$key_dir/dh_default.pem" ] || printf -- "-----BEGIN DH PARAMETERS-----\n%s\n-----END DH PARAMETERS-----\n" "$dummy_payload" > "$key_dir/dh_default.pem" + [ -f "$key_dir/server_default.tls2.key" ] || printf -- "-----BEGIN OpenVPN tls-crypt-v2 server key-----\n%s\n-----END OpenVPN tls-crypt-v2 server key-----\n" "$dummy_payload" > "$key_dir/server_default.tls2.key" + [ -f "$key_dir/client_default.tls2.key" ] || printf -- "-----BEGIN OpenVPN tls-crypt-v2 client key-----\n%s\n-----END OpenVPN tls-crypt-v2 client key-----\n" "$dummy_payload" > "$key_dir/client_default.tls2.key" + fi + + # Step 6: Delete the lock file and remove all system traps when finished + rm -f "$lock_file" + trap - EXIT INT TERM + return 0 +} + +# Asynchronous key and certificate generator +keygen_worker() { + shift # Remove "keygen" from the arguments list + local type="${1:-unknown}" + local instance="${2:-default}" + local bits="${3:-2048}" + local days="${4:-36500}" + local cname="$5" + local custom="$6" + local log_file="/tmp/openvpn.keygen.log" + local out_ca_file="/tmp/openvpn.keygen.ca.tmp" + local out_server_key_file="/tmp/openvpn.keygen.server-key.tmp" + local out_server_cert_file="/tmp/openvpn.keygen.server-cert.tmp" + local out_client_key_file="/tmp/openvpn.keygen.client-key.tmp" + local out_client_cert_file="/tmp/openvpn.keygen.client-cert.tmp" + local out_dh_file="/tmp/openvpn.keygen.dh.tmp" + local out_server_tls2_file="/tmp/openvpn.keygen.server-tls2.tmp" + local out_client_tls2_file="/tmp/openvpn.keygen.client-tls2.tmp" + local active_out="" + local ca_key_buffer="" + local ca_cert_buffer="" + local ca_engine="" + local ec_param_tmp="" + + rm -f "$log_file" && touch "$log_file" && chmod 666 "$log_file" + case "$type" in + "ca") + active_out="$out_ca_file" + rm -f "$out_ca_file" "$out_server_key_file" "$out_server_cert_file" "$out_client_key_file" "$out_client_cert_file" + ca_key_buffer="$out_ca_file" + ca_cert_buffer="$out_server_cert_file" + ca_engine="-newkey rsa:$bits" + if [ "$bits" = "ec" ]; then + ec_param_tmp="/tmp/openvpn.keygen.dh.tmp" + rm -f "$ec_param_tmp" + echo "[CMD] openssl ecparam -name prime256v1 -out $ec_param_tmp" >> "$log_file" + openssl ecparam -name prime256v1 -out "$ec_param_tmp" 2>> "$log_file" + ca_engine="-newkey ec:$ec_param_tmp" + fi + local target_cn="OpenVPN-CA-$instance" + if [ -n "$cname" ]; then target_cn="$cname"; fi + ( + set +e + echo "LOG: Invoking openssl engine for self-signed CA generation..." >> "$log_file" + echo "[CMD] openssl req -new -x509 -nodes -days $days $ca_engine -subj \"/CN=$target_cn\" -keyout $ca_key_buffer -out $ca_cert_buffer $custom" >> "$log_file" + openssl req -new -x509 -nodes -days "$days" $ca_engine -subj "/CN=$target_cn" -keyout "$ca_key_buffer" -out "$ca_cert_buffer" $custom >/dev/null 2>> "$log_file" + if [ -f "$ca_key_buffer" ] && [ -s "$ca_key_buffer" ] && [ -f "$ca_cert_buffer" ] && [ -s "$ca_cert_buffer" ]; then + cat "$ca_cert_buffer" >> "$ca_key_buffer" + chmod 644 "$active_out" + echo "LOG: WORKFLOW_SUCCESSFUL" >> "$log_file" + else + echo "ERROR: Cryptographic CA asset generation returned structural failure." >> "$log_file" + fi + rm -f "$ca_cert_buffer" "/tmp/openvpn.keygen.dh.tmp" + set -e + ) & + ;; + "server-key") + active_out="$out_server_key_file" + rm -f "$active_out" + ( + set +e + if [ "$bits" = "ec" ]; then + echo "LOG: Invoking openssl engine for ECC private key generation..." >> "$log_file" + echo "[CMD] openssl ecparam -name prime256v1 -genkey -out $active_out $custom" >> "$log_file" + openssl ecparam -name prime256v1 -genkey -out "$active_out" $custom 2>> "$log_file" + else + echo "LOG: Invoking openssl engine for RSA private key generation..." >> "$log_file" + echo "[CMD] openssl genrsa -out $active_out $custom $bits" >> "$log_file" + openssl genrsa -out "$active_out" $custom "$bits" 2>> "$log_file" + fi + if [ -f "$active_out" ] && [ -s "$active_out" ]; then + chmod 644 "$active_out" + echo "LOG: WORKFLOW_SUCCESSFUL" >> "$log_file" + else + echo "ERROR: Server private key buffer generation returned structural failure." >> "$log_file" + fi + set -e + ) & + ;; + "server-cert") + active_out="$out_server_cert_file" + rm -f "$active_out" + ( + set +e + echo "LOG: Validating local Certificate Authority access..." >> "$log_file" + if [ ! -s "$out_ca_file" ] || [ ! -s "$out_server_key_file" ]; then + echo "ERROR: Required multi-stage RAM staging nodes are missing or empty!" >> "$log_file" + else + local target_cn="OpenVPN-Server-$instance" + if [ -n "$cname" ]; then target_cn="$cname"; fi + + # Create a tiny temporary extension file in RAM + local ext_tmp="/tmp/openvpn.keygen.ext.$$" + echo "basicConstraints=CA:FALSE" > "$ext_tmp" + echo "keyUsage=digitalSignature,keyEncipherment" >> "$ext_tmp" + echo "extendedKeyUsage=serverAuth" >> "$ext_tmp" + + echo "LOG: Invoking openssl engine for dynamic certificate signing..." >> "$log_file" + echo "[CMD] openssl req -new -nodes -key $out_server_key_file -subj \"/CN=$target_cn\" | openssl x509 -req -days $days -CA $out_ca_file -CAkey $out_ca_file -set_serial $$ -extfile $ext_tmp -out $active_out $custom" >> "$log_file" + + openssl req -new -nodes -key "$out_server_key_file" -subj "/CN=$target_cn" 2>/dev/null |\ + openssl x509 -req -days "$days" -CA "$out_ca_file" -CAkey "$out_ca_file" -set_serial "$$" \ + -extfile "$ext_tmp" \ + -out "$active_out" $custom 2>> "$log_file" + + # Immediate cleanup of the temporary extension file + rm -f "$ext_tmp" + + if [ -f "$active_out" ] && [ -s "$active_out" ]; then + chmod 644 "$active_out" + echo "LOG: WORKFLOW_SUCCESSFUL" >> "$log_file" + else + echo "ERROR: Server certificate dynamic signing pipeline returned structural failure." >> "$log_file" + fi + fi + set -e + )& + + ;; + "client-key") + active_out="$out_client_key_file" + rm -f "$active_out" + ( + set +e + if [ "$bits" = "ec" ]; then + echo "LOG: Invoking openssl engine for client ECC private key generation..." >> "$log_file" + echo "[CMD] openssl ecparam -name prime256v1 -genkey -out $active_out $custom" >> "$log_file" + openssl ecparam -name prime256v1 -genkey -out "$active_out" $custom 2>> "$log_file" + else + echo "LOG: Invoking openssl engine for client RSA private key generation..." >> "$log_file" + echo "[CMD] openssl genrsa -out $active_out $custom $bits" >> "$log_file" + openssl genrsa -out "$active_out" $custom "$bits" 2>> "$log_file" + fi + if [ -f "$active_out" ] && [ -s "$active_out" ]; then + chmod 644 "$active_out" + echo "LOG: WORKFLOW_SUCCESSFUL" >> "$log_file" + else + echo "ERROR: Client private key buffer generation returned structural failure." >> "$log_file" + fi + set -e + ) & + ;; + "client-cert") + active_out="$out_client_cert_file" + rm -f "$active_out" + ( + set +e + echo "LOG: Validating local Certificate Authority access..." >> "$log_file" + local real_ca_file="/etc/openvpn/keys/ca_${instance}.crt" + local real_ca_key="/etc/openvpn/keys/ca_${instance}.crt" + if [ ! -s "$real_ca_file" ] || [ ! -s "$out_client_key_file" ]; then + echo "ERROR: Required CA assets (/etc/openvpn/keys/ca_${instance}.crt) or client keys are missing!" >> "$log_file" + else + local target_cn="OpenVPN-Client-$instance" + if [ -n "$cname" ]; then target_cn="$cname"; fi + + # Create a tiny temporary extension file in RAM + local ext_tmp="/tmp/openvpn.keygen.client.ext.$$" + echo "basicConstraints=CA:FALSE" > "$ext_tmp" + echo "keyUsage=digitalSignature" >> "$ext_tmp" + echo "extendedKeyUsage=clientAuth" >> "$ext_tmp" + + echo "LOG: Invoking openssl engine for client certificate signing..." >> "$log_file" + echo "[CMD] openssl req -new -nodes -key '$out_client_key_file' -subj '/CN=$target_cn' | openssl x509 -req -days '$days' -CA '$real_ca_file' -CAkey '$real_ca_key' -set_serial '$$' -extfile $ext_tmp -out '$active_out' $custom" >> "$log_file" + + openssl req -new -nodes -key "$out_client_key_file" -subj "/CN=$target_cn" 2>/dev/null |\ + openssl x509 -req -days "$days" -CA "$real_ca_file" -CAkey "$real_ca_key" -set_serial "$$" \ + -extfile "$ext_tmp" \ + -out "$active_out" $custom 2>> "$log_file" + + # Immediate cleanup of the temporary extension file + rm -f "$ext_tmp" + + if [ -f "$active_out" ] && [ -s "$active_out" ]; then + chmod 644 "$active_out" + echo "LOG: WORKFLOW_SUCCESSFUL" >> "$log_file" + else + echo "ERROR: Client certificate dynamic signing pipeline returned structural failure." >> "$log_file" + fi + fi + set -e + )& + + ;; + "dh") + active_out="$out_dh_file" + rm -f "$active_out" + ( + set +e + echo "LOG: Invoking openssl engine for fast Diffie-Hellman parameters..." >> "$log_file" + echo "[CMD] openssl dhparam -dsaparam $custom -out '$active_out' '$bits'" >> "$log_file" + openssl dhparam -dsaparam $custom -out "$active_out" "$bits" >> "$log_file" 2>&1 + if [ -f "$active_out" ] && [ -s "$active_out" ]; then + chmod 644 "$active_out" + echo "LOG: WORKFLOW_SUCCESSFUL" >> "$log_file" + else + echo "ERROR: Diffie-Hellman cryptographic prime generation returned structural failure." >> "$log_file" + fi + set -e + ) & + ;; + "server-tls2") + active_out="$out_server_tls2_file" + rm -f "$active_out" + ( + set +e + echo "LOG: Invoking openvpn engine for asymmetric tls-crypt-v2 master server key generation..." >> "$log_file" + echo "[CMD] openvpn --genkey tls-crypt-v2-server '$active_out'" >> "$log_file" + openvpn --genkey tls-crypt-v2-server "$active_out" 2>> "$log_file" + if [ -f "$active_out" ] && [ -s "$active_out" ]; then + chmod 644 "$active_out" + echo "LOG: WORKFLOW_SUCCESSFUL" >> "$log_file" + else + echo "ERROR: Asymmetric v2 master server key engine tokens returned structural failure." >> "$log_file" + fi + set -e + ) & + ;; + + "client-tls2") + active_out="$out_client_tls2_file" + rm -f "$active_out" + local target_tls_name="OpenVPN-Client-TLSv2-${instance}" + if [ -n "$cname" ]; then target_tls_name="${cname}-TLSv2"; fi + local server_tls2_key="/etc/openvpn/keys/server_${instance}.tls2.key" + ( + set +e + echo "LOG: Invoking openvpn engine for asymmetric tls-crypt-v2 client key generation..." >> "$log_file" + local target_tls_name_base64=$(echo -n "$target_tls_name" | openssl enc -base64) + echo "[CMD] openvpn --tls-crypt-v2 '$server_tls2_key' --genkey tls-crypt-v2-client '$active_out' '$target_tls_name_base64'" >> "$log_file" + openvpn --tls-crypt-v2 "$server_tls2_key" --genkey tls-crypt-v2-client "$active_out" "$target_tls_name_base64" 2>> "$log_file" + if [ -f "$active_out" ] && [ -s "$active_out" ]; then + chmod 644 "$active_out" + echo "LOG: WORKFLOW_SUCCESSFUL" >> "$log_file" + else + echo "ERROR: Asymmetric v2 client key derivation engine tokens returned structural failure." >> "$log_file" + fi + set -e + ) & + ;; + *) + echo "ERROR: Invalid cryptographic profile requested." >> "$log_file" + return 1 + ;; + esac + return 0 +} + +# Main entry point +case "$1" in + getmac) getmac ;; + lanip) lanip "$@" ;; + getnetstat) getnetstat "$@" ;; + bestcrypto) bestcrypto ;; + ovpnver) ovpnver ;; + ovpnstart) ovpnstart "$@" ;; + ovpnrestart) ovpnrestart "$@" ;; + ovpnstop) ovpnstop "$@" ;; + ovpnservice) ovpnservice "$@" ;; + firewallservice) firewallservice "$@" ;; + firewallnft) firewallnft "$@" ;; + openssldgst) openssldgst "$@" ;; + symlink) symlink "$@" ;; + readstatus) readstatus "$@" ;; + iroute) iroute "$@" ;; + cleanipdns) cleanipdns "$@" ;; + checkip) checkip "$@" ;; + resolveip) resolveip "$@" ;; + checkport) checkport "$@" ;; + checksroute) checksroute "$@" ;; + checkddns) checkddns "$@" ;; + publicip) publicip ;; + wgetddns) wgetddns "$@" ;; + ddnsservice) ddnsservice "$@" ;; + ucisortovpn) ucisortovpn ;; + cleanup) cleanup "$@" ;; + keymeta) keymeta "$@" ;; + keygenclean) keygenclean ;; + + initkeys) + # 1. Check if the key generator is running + if start-stop-daemon -K -q -p "/var/run/openvpn.initkeys.pid" -t 2>/dev/null; then + exit 0 + fi + # 2. Start the background worker and create the PID file automatically + start-stop-daemon -S -b -m -p "/var/run/openvpn.initkeys.pid" -x "$0" -- "initkeys_worker" + exit 0 + ;; + + initkeys_worker) + # 3. Run the key creation function quietly in the background + initkeys >/dev/null 2>&1 + # 4. Delete the PID file when the keys are finished + rm -f "/var/run/openvpn.initkeys.pid" + exit 0 + ;; + + keygen) + # 1. Start the background keygen worker and create the PID file automatically + start-stop-daemon -S -b -m -p "/var/run/openvpn.keygen-${2}.pid" -x "$0" -- "keygen_worker" "$@" + exit 0 + ;; + + keygen_worker) + # 2. Run the key creation function quietly in the background + shift # Remove "keygen_worker" from the arguments list to fix $@ + keygen_worker "$@" + # 3. Delete the PID file when keygen finished + rm -f "/var/run/openvpn.keygen-${2}.pid" + ;; + + *) + echo "Error: Unknown command '$1'" + echo "Valid commands: getmac lanip getnetstat bestcrypto ovpnver ovpnstart ovpnrestart ovpnstop ovpnservice firewallservice firewallnft openssldgst symlink readstatus iroute cleanipdns checkip resolveip checkport checksroute checkddns publicip wgetddns ddnsservice ucisortovpn cleanup keymeta keygenclean initkeys keygen" + exit 1 + ;; +esac + +# Global exit handler guarantees instant thread termination +exit "$?" + diff --git a/luci-app-openvpn-plus/root/usr/share/luci/menu.d/luci-app-openvpn-plus.json b/luci-app-openvpn-plus/root/usr/share/luci/menu.d/luci-app-openvpn-plus.json new file mode 100755 index 000000000..7fd3b7c64 --- /dev/null +++ b/luci-app-openvpn-plus/root/usr/share/luci/menu.d/luci-app-openvpn-plus.json @@ -0,0 +1,9 @@ +{ + "admin/vpn/openvpn": { + "title": "OpenVPN", + "action": { "type": "view", "path": "vpn/openvpn" }, + "order": 10, + "depends": { "acl": [ "luci-app-openvpn-plus" ] } + } +} + diff --git a/luci-app-openvpn-plus/root/usr/share/rpcd/acl.d/luci-app-openvpn-plus-status.json b/luci-app-openvpn-plus/root/usr/share/rpcd/acl.d/luci-app-openvpn-plus-status.json new file mode 100755 index 000000000..8c5de059e --- /dev/null +++ b/luci-app-openvpn-plus/root/usr/share/rpcd/acl.d/luci-app-openvpn-plus-status.json @@ -0,0 +1,19 @@ +{ + "luci-app-openvpn-plus-status": { + "description": "Status access for OpenVPN LuCI app", + "read": { + "cgi-bin": [ "luci" ], + "uci": [ "openvpn" ], + "ubus": { + "system": [ "info" ], + "service": [ "list" ], + "network": [ "status" ] + }, + "file": { + "/etc/openvpn/luci/*": [ "read" ], + "/proc/uptime": [ "read" ], + "/usr/libexec/luci-app-openvpn-plus": [ "exec" ] + } + } + } +} diff --git a/luci-app-openvpn-plus/root/usr/share/rpcd/acl.d/luci-app-openvpn-plus.json b/luci-app-openvpn-plus/root/usr/share/rpcd/acl.d/luci-app-openvpn-plus.json new file mode 100755 index 000000000..fe9b3aef9 --- /dev/null +++ b/luci-app-openvpn-plus/root/usr/share/rpcd/acl.d/luci-app-openvpn-plus.json @@ -0,0 +1,39 @@ +{ + "luci-app-openvpn-plus": { + "description": "Configuration access for OpenVPN LuCI app", + "read": { + "cgi-bin": [ "luci" ], + "uci": [ "openvpn" ], + "ubus": { + "system": [ "info" ], + "service": [ "list" ], + "network": [ "status" ] + }, + "file": { + "/etc/openvpn/luci/*": [ "read" ], + "/etc/openvpn/keys/*": [ "read" ], + "/var/run/openvpn.keygen.lock": [ "read" ], + "/proc/uptime": [ "read" ], + "/tmp/openvpn.keygen.log": [ "read" ], + "/tmp/openvpn.keygen.ca.tmp": [ "read" ], + "/tmp/openvpn.keygen.server-key.tmp": [ "read" ], + "/tmp/openvpn.keygen.client-key.tmp": [ "read" ], + "/tmp/openvpn.keygen.server-cert.tmp": [ "read" ], + "/tmp/openvpn.keygen.client-cert.tmp": [ "read" ], + "/tmp/openvpn.keygen.dh.tmp": [ "read" ], + "/tmp/openvpn.keygen.server-tls2.tmp": [ "read" ], + "/tmp/openvpn.keygen.client-tls2.tmp": [ "read" ], + "/sbin/logread": [ "exec" ], + "/usr/libexec/luci-app-openvpn-plus": [ "exec" ] + } + }, + "write": { + "uci": [ "openvpn" ], + "file": { + "/etc/openvpn/luci/*": [ "write" ], + "/etc/openvpn/keys/*": [ "write" ], + "/etc/config/openvpn": [ "write" ] + } + } + } +} diff --git a/luci-app-openvpn-plus/root/www/luci-static/resources/view/status/include/35_openvpn.js b/luci-app-openvpn-plus/root/www/luci-static/resources/view/status/include/35_openvpn.js new file mode 100755 index 000000000..a64ffe208 --- /dev/null +++ b/luci-app-openvpn-plus/root/www/luci-static/resources/view/status/include/35_openvpn.js @@ -0,0 +1,189 @@ +/* + * Copyright (C) 2008-2026 The OpenWrt Project + * Copyright (C) 2026 Manfred Jaider + * + * This is free software, licensed under the Apache License, Version 2.0. + * See /LICENSE for more information. + * + * luci-app-openvpn-plus : LuCI status overview page + * /www/luci-static/resources/view/status/include/35_openvpn.js + */ + +/* global E */ +'use strict'; + +const TXT = { + STATUS: { + openvpn: _('OpenVPN'), + active: _('Active'), + disabled: _('Disabled'), + pending: _('Pending...'), + error: _('Error') + } +}; +const CFG = Object.freeze({ + FILE: Object.freeze({ + vpn_disabled_img: '/luci-static/resources/icons/tunnel_disabled.svg', + vpn_enabled_img: '/luci-static/resources/icons/tunnel.svg' + }) +}); +const OPENVPN = Object.freeze({ + STATE: Object.freeze({ + pending: 'pending', + active: 'active', + error: 'error' + }) +}); +/** + * Main data storage for the OpenVPN page + */ +const APP_DATA_TEMPLATE = Object.freeze({ + statusClass: null, + wizardClass: null, + keygenClass: null, + uptime: 0, + serverTemplate: '', + clientTemplate: '', + logread: '', + keysReady: false, + ovpnUciSections: null, + instances: null +}); + +/** + * Main entry point where the page starts loading + */ +return L.Class.extend({ + title: TXT.STATUS.openvpn, + + // Create main data storage + APP_DATA: Object.assign({}, APP_DATA_TEMPLATE, { + ovpnUciSections: [], + instances: [] + }), + + // Shared HTML container nodes + statusTableContainer: null, + ifaceBoxContainer: null, + + /** + * Load code and data before rendering the page elements + */ + load: function () { + const self = this; + + // Load all required classes + return Promise.all([ + L.require('view.vpn.openvpn-status'), + L.uci.load('openvpn').catch(function () { return null; }) + + ]).then(async function (results) { + + const openvpnStatus = results[0]; + if (!openvpnStatus) return null; + + await openvpnStatus.onLoad(); + + // Gets the openvon sections from LuCI + const ovpnSections = L.uci.sections('openvpn', 'openvpn') || []; + // Sorts the array alphabetically based on the internal '.name' (instance1, instance2, ...) + ovpnSections.sort(function (a, b) { + return a['.name'].localeCompare(b['.name'], undefined, { numeric: true, sensitivity: 'base' }); + }); + + // If no instances exist, clear title and hide the card block completely + if (ovpnSections.length === 0) { + self.title = ''; + return null; + } + + self.title = TXT.STATUS.openvpn; + self.statusTableContainer = E('div', { 'id': 'openvpn_overview_table_wrapper', 'style': 'display: block;' }, [E('div', {})]); + self.ifaceBoxContainer = E('div', { 'style': 'display:block; margin: 10px 0 15px 0; text-align: left;' }, []); + + // Use the safe outer context reference "self" to read the view data + const appData = self.APP_DATA; + appData.ovpnUciSections = ovpnSections; + + // Callback function to refresh the status table + const refreshStatusBoxCallback = function (ovpnState, tooltip, appData) { + if (!self.ifaceBoxContainer) return + + let labelText = TXT.STATUS.disabled + let headBg = 'background:var(--background-color-medium, #f4f4f5) !important;' + let textColor = 'var(--text-color-high, #333333)' + let imgIcon = CFG.FILE.vpn_disabled_img + + if (ovpnState === OPENVPN.STATE.active) { + labelText = TXT.STATUS.active + headBg = 'background:var(--success-color-high, #00ac59) !important;' + textColor = 'var(--on-success-color, white)' + imgIcon = CFG.FILE.vpn_enabled_img + } else if (ovpnState === OPENVPN.STATE.pending) { + labelText = TXT.STATUS.pending + headBg = 'background:var(--warn-color-high, #efbd0b) !important;' + textColor = 'var(--on-warn-color, #000000)' + } else if (ovpnState === OPENVPN.STATE.error) { + labelText = TXT.STATUS.error + headBg = 'background:var(--error-color-high, #f62b12) !important;' + textColor = 'var(--on-error-color, white)' + } + + const boxHeadNode = E('div', { + 'class': 'ifacebox-head', + 'style': 'padding:3px 8px; font-size:12px; font-weight:bold; text-shadow:none !important; text-align:center; white-space:nowrap; ' + headBg + }, [ + E('strong', { 'style': 'color: ' + textColor + ' !important;' }, labelText) + ]); + + const badgeImgNode = E('img', { 'src': imgIcon, 'style': 'width:32px; height:36px; vertical-align:middle;' }); + + const tooltipContainer = E('span', { 'class': 'cbi-tooltip-container' }, [ + badgeImgNode, + tooltip + ]); + const boxBodyNode = E('div', { + 'class': 'ifacebox-body', + 'style': 'padding:6px; text-align:center; min-height:0; background:transparent !important;' + }, [ + tooltipContainer + ]); + + const flatIfaceBox = E('div', { + 'class': 'ifacebox', + 'style': 'display:inline-block; margin:0; border-radius:4px; overflow:hidden;' + }, [boxHeadNode, boxBodyNode]); + + if (self.ifaceBoxContainer.firstChild) { + self.ifaceBoxContainer.replaceChild(flatIfaceBox, self.ifaceBoxContainer.firstChild); + } else { + self.ifaceBoxContainer.appendChild(flatIfaceBox); + } + }; + + try { + // Use the clean await operator to trigger the live dashboard refresh loop flatly + await openvpnStatus.refreshLiveDashboard(appData, self.statusTableContainer, refreshStatusBoxCallback); + return openvpnStatus; + } catch (err) { + console.error('Error during initial dashboard telemetry launch:', err); + return openvpnStatus; + } + }); + }, + + /** + * Render the page elements onto the interface + */ + render: function (openvpnStatus) { + if (!openvpnStatus || !this.statusTableContainer || !this.ifaceBoxContainer) { + return null; + } + + return E('div', { 'class': 'cbi-section', 'style': 'margin:0; padding:0; width:100%;' }, [ + this.ifaceBoxContainer, + this.statusTableContainer + ]); + } +}); + diff --git a/luci-app-openvpn-plus/root/www/luci-static/resources/view/vpn/openvpn-keygen.js b/luci-app-openvpn-plus/root/www/luci-static/resources/view/vpn/openvpn-keygen.js new file mode 100755 index 000000000..8ec33c6b6 --- /dev/null +++ b/luci-app-openvpn-plus/root/www/luci-static/resources/view/vpn/openvpn-keygen.js @@ -0,0 +1,1252 @@ +/* + * Copyright (C) 2008-2026 The OpenWrt Project + * Copyright (C) 2026 Manfred Jaider + * + * This is free software, licensed under the Apache License, Version 2.0. + * See /LICENSE for more information. + * + * luci-app-openvpn-plus : key editor and generator for easy certificate management + * /www/luci-static/resources/view/vpn/openvpn-keygen.js + * + * 1. --- TEXT & DEFINITIONS --- ....Global translations and system definitions + * 2. --- KEY EDITOR --- ........... Text area editor for key files + * 3. --- KEY GENERATOR --- ........ Background key generation wizard + */ + +/* global E, URL, Blob, */ +'use strict'; +'require uqr'; +'require network'; + +/* + * --- TEXT & DEFINITIONS --- + */ +const TXT = { + INFO: { + default: _('Default'), + file_location: _('File Location: '), + type: _('Type'), + valid_until: _('Valid Until'), + years: _('Years'), + wizard: _('Wizard'), + server: _('Server'), + client: _('Client') + }, + BTN: { + close: _('Close'), + cancel: _('Cancel'), + download: _('Download'), + enable_save: _('Enable Save'), + generate: _('Generate'), + save_apply: _('Save & Apply'), + save_config: _('Save Config'), + saving: _('Saving...'), + saved: _('Saved'), + }, + KEY: { + certificate: _('Certificate'), + client_ca_private_key_issued_by_ca: _('Client Certificate + Private key (issued by CA) + TLS Crypt v2 Client key'), + client_key_generated: _('Client keys generated successfully!'), + client_keys_exported: _('Client keys exported successfully.'), + clone_default_keys: _('No (Clone Default Keys)'), + common_name: _('Common Name (CN)'), + custom_cli: _('Custom CLI Options'), + dh_description: _('Diffie-Hellman parameters enforce numeric prime bit boundaries exclusively.'), + export_keys: _('Export Keys'), + generate_tls_key: _('Generating TLS-Crypt key...'), + generate_unique_keys: _('Yes (Generate Unique Keys)'), + generate_unique_keys_new_instance: _('To be secure, a new instance needs its own key files. Do you want to create them now? (Recommended - usually takes just a few moments)'), + generating_client_keys: _('Generating private client key...'), + key_strength: _('Key Strength'), + loading_contents: _('Loading key contents...'), + master_ca: _('Master CA'), + not_applicable: _('Not applicable'), + opt_dh: _('Diffie-Hellman Parameters (Perfect Forward Secrecy)'), + opt_full_pki: _('Full PKI Suite (CA + Server Certificate + Private Server Key)'), + opt_tls_server: _('TLS Crypt v2 Server Master Key (Anti-DoS / Port-Scan Protection)'), + opt_tls_client: _('TLS Crypt v2 Client Key (Anti-DoS / Port-Scan Protection)'), + placeholder_cn: _("e.g. Remote Office 1"), + placeholder_dh: _('e.g. -text (shows hex output)'), + placeholder_pki: _('e.g. -sha256 ; -noout ; -sha256'), + placeholder_pki_client: _('e.g. -traditional ; ; -sha256'), + placeholder_tls: _('N/A - openvpn --genkey accepts no extra flags'), + pki_saved: _('PKI Suite components saved successfully.'), + pki_step1: _('Step 1/3: Generating Certificate Authority (CA)...'), + pki_step2: _('Step 2/3: Generating Private Server Key...'), + pki_step3: _('Step 3/3: Generating & Signing Server Certificate...'), + private_key: _('Private Key'), + progress: _('Progress & Output'), + pure_ecc: _('Pure ECC'), + pure_rsa: _('Pure RSA'), + ready: _('Ready for key generation.'), + running: _('Crypto operation running. Please wait...'), + secret_ca: _('Secret CA Key'), + signing_client_using_server_ca: _('Signing client certificate using server CA...'), + strength_description: _('Recommended: RSA-2048 + ECC-Prime256v1. RSA ensures CA compatibility, while ECC accelerates the data tunnel.'), + tls_description_server: _('The main TLS security key on the server. It encrypts the connection and creates the keys for all clients.'), + tls_description_client: _('A unique TLS security key for one client. It encrypts and protects all data sent to the server.'), + tls_key_saved: _('TLS-Crypt key saved successfully.'), + tls_key_server: _('Master Server Secret Key'), + tls_key_client: _('Client Key'), + title_main: _('OpenVPN Instance Key Generator'), + key_allocation: ('Key Allocation'), + unknown_asset: _('Unknown Asset'), + use_unique_name_for_each_office: _('Important: Use a unique name for each office to configure the network routing correctly.'), + validity_days: _('Validity (Days)'), + vpn_key_type: _('VPN Key Type:') + }, + WARNING: { + clicking_save_will_break_vpn: _('WARNING: Clicking save will instantly break all active VPN tunnels. You must generate and share new client profiles (.ovpn) to restore the connections.'), + key_not_saved: _('Generated keys are not saved! Close window and discard keys?'), + key_decrepted: _('Deprecated TLS-Crypt V1 key (Not supported)'), + use_wizard_hint_title: _('Deployment Recommendation:'), + use_wizard_hint_desc: _('It is highly recommended to use the automated Setup Wizard to deploy a Mobile Server (OpenVPN Connect) or a Headquarters LAN-to-LAN Server. In just a few clicks, the wizard fully configures easy DDNS provider setup, multi-client remote networks, optimal firewall zones, and scan-ready QR codes.') + }, + ERROR: { + format_corruption: _('The saved key data contains structural format corruption!'), + polling_threshold: _('ERROR: Safety polling threshold exceeded boundaries. Process deadlocked.'), + } +} + +const CFG = Object.freeze({ + FILE: Object.freeze({ + dir_keys: '/etc/openvpn/keys/', + openvpn_keygen_log: '/tmp/openvpn.keygen.log', + temp_openvpn_keygen: '/tmp/openvpn.keygen.', + }), + LIBEXEC: Object.freeze({ + keygen: 'keygen', + keymeta: 'keymeta', + keygenclean: 'keygenclean' + }), + ID: Object.freeze({ + central_keygen_mode: 'central_keygen_mode', + keygen_bits_desc: 'keygen_bits_desc', + keygen_bits_dh: 'keygen_bits_dh', + keygen_bits_pki: 'keygen_bits_pki', + keygen_years: 'keygen_years', + row_keygen_years: 'row_keygen_years', + keygen_cn: 'keygen_cn', + row_keygen_cn: 'row_keygen_cn', + }) +}) + +const ICON = Object.freeze({ + ARROW: '➔ ', + ERROR: '❌ ', + LAPTOP: '💻 ', + ROCKET: '🚀 ', + SAVE: '💾 ', + SUCCESS: '✅ ', + WARNING: '⚠️ ', + EXPORT: '📤 ', +}); + +const OPENVPN = Object.freeze({ + ROLE: Object.freeze({ + SERVER: 'server', + }), +}); + +const KEYTYPE = Object.freeze({ + ca: 'ca', + dh: 'dh', + pki: 'pki', + client_pki: 'client_pki', + server_key: 'server-key', + client_key: 'client-key', + server_cert: 'server-cert', + client_cert: 'client-cert', + server_tls2: 'server-tls2', + client_tls2: 'client-tls2', + + rsa2048_ec: 'rsa2048_ec', + rsa4096_ec: 'rsa4096_ec', + ec: 'ec', + b2048: '2048', + b4096: '4096', + y100: "100", +}); + +/** + * Strips all accidental Windows or Mac line breaks and trims spaces. + */ +const sanitizeInputLine = function (value) { + return String(value || '').trim().replace(/[\r\n]/g, ''); +}; + +/** + * Normalizes all Windows and Mac line breaks into clean UNIX line breaks for textareas. + */ +const sanitizeInputText = function (value) { + const rawText = value ? String(value).trim() : ''; + return rawText.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); +}; + + +/** + * --- KEY EDITOR --- + */ + + +/** + * Show simple key editor that verifies and displays key metadata. + */ +const openKeyEditorModal = function (filename, instance_id, displayId, role, appData, uciSaveApplyRestartCallback, rpcFileCallbacks) { + const absolutePath = CFG.FILE.dir_keys + filename; + let hasSaved = false; + + const modalKeyTextArea = E('textarea', { + 'class': 'cbi-input-textarea', + 'style': 'width:100%; max-width:100%; resize:vertical; font-family:var(--font-monospace, monospace); font-size:12px; background:#18181b; color:#4ade80; padding:15px; border-radius:4px; border:1px solid var(--border-color-medium, #cbd5e1); text-shadow:none;', + 'rows': '18', + 'wrap': 'off' + }, [TXT.KEY.loading_contents]); + + const quickInfoBox = E('div', { + 'style': 'margin-bottom:12px; padding:10px 15px; border-left:4px solid var(--primary-color-high, #1976d2); background:color-mix(in srgb, var(--primary-color-high, #1976d2) 10%, transparent); border-radius:4px; font-size:12px; line-height:1.6; color:var(--text-color-high, #3f3f46); display:none;' + }); + + // Update the quick info box content + const quickInfoBoxUpdate = function (rawMeta) { + let keyTypeInfo = TXT.KEY.unknown_asset; + let expirationInfo = TXT.KEY.not_applicable; + let commonNameInfo = ''; + let isInvalid = false; + const cleanMeta = String(rawMeta || '').trim(); + + // Parse expiration date safely + const expiryMatch = cleanMeta.match(/Not After\s*:\s*([^\n\r]+)/i); + if (expiryMatch && expiryMatch[1]) { + expirationInfo = expiryMatch[1].trim(); + } + + // Extract Subject Common Name perfectly supporting optional spaces + const cnMatch = cleanMeta.match(/Subject\s*:\s*CN\s*=\s*([^,\n\r]+)/i); + if (cnMatch && cnMatch[1]) { + commonNameInfo = cnMatch[1].trim(); + } + + // Identify key type and strength using clean text patterns + if ((cleanMeta.indexOf('Public-Key: (4096 bit)') !== -1) || (cleanMeta.indexOf('Private-Key: (4096 bit)') !== -1)) { + keyTypeInfo = 'RSA 4096 Bit'; + } else if (cleanMeta.indexOf('Public-Key: (256 bit)') !== -1 || cleanMeta.indexOf('prime256v1') !== -1) { + keyTypeInfo = 'ECC Prime256v1'; + } else if ((cleanMeta.indexOf('Public-Key: (2048 bit)') !== -1) || (cleanMeta.indexOf('Private-Key: (2048 bit)') !== -1 || cleanMeta.indexOf('PRIVATE-KEY') !== -1)) { + keyTypeInfo = 'RSA 2048 Bit'; + } else if (cleanMeta.indexOf('DH PARAMETERS') !== -1 || cleanMeta.indexOf('bit') !== -1) { + const dhBits = cleanMeta ? cleanMeta.match(/([0-9]+)\s*bit/i) : null; + const dbits_info = dhBits ? '(' + dhBits[1] + ' Bit)' : ''; + keyTypeInfo = 'Diffie-Hellman ' + dbits_info; + } else if (cleanMeta.indexOf('TLSv2-Server-Key') !== -1) { + keyTypeInfo = 'TLS Crypt v2'; + } else if (cleanMeta.indexOf('TLSv2-Client-Key') !== -1) { + keyTypeInfo = 'TLS Crypt v2'; + } else if (cleanMeta.indexOf('Unsupported') !== -1) { + keyTypeInfo = '' + ICON.WARNING + TXT.WARNING.key_decrepted + ''; + isInvalid = true; + } else if (cleanMeta.indexOf('ERROR') !== -1) { + keyTypeInfo = '' + ICON.ERROR + TXT.ERROR.format_corruption + ''; + isInvalid = true; + } + + // Role identities to make the type text easy to read for the admin + if (!isInvalid) { + const strRole = (role === OPENVPN.ROLE.SERVER) ? TXT.INFO.server : TXT.INFO.client; + + if (cleanMeta.indexOf('AUTHORITY') !== -1 || cleanMeta.indexOf('CA:TRUE') !== -1) { + if (cleanMeta.indexOf('SECRET-KEY-CA') !== -1) { + keyTypeInfo += ' - [' + TXT.KEY.master_ca + ' ' + TXT.KEY.certificate + ' ' + TXT.KEY.secret_ca + ']'; + } else { + keyTypeInfo += ' - [' + TXT.KEY.master_ca + ' ' + TXT.KEY.certificate + ']'; + } + } else if (cleanMeta.indexOf('STANDARD CERTIFICATE') !== -1) { + keyTypeInfo += ' - [' + strRole + ' ' + TXT.KEY.certificate + ']'; + } else if (cleanMeta.indexOf('Private-Key') !== -1 || cleanMeta.indexOf('PRIVATE-KEY') !== -1) { + keyTypeInfo += ' - [' + strRole + ' ' + TXT.KEY.private_key + ']'; + } else if (cleanMeta.indexOf('TLSv2-Server-Key') !== -1) { + keyTypeInfo += ' - [' + TXT.KEY.tls_key_server + ']'; + } else if (cleanMeta.indexOf('TLSv2-Client-Key') !== -1) { + keyTypeInfo += ' - [' + TXT.KEY.tls_key_client + ']'; + } + } + + // Apply styles and render HTML nodes dynamically + if (isInvalid) { + quickInfoBox.style.borderLeftColor = 'var(--success-color-high, #00ac59)'; + quickInfoBox.innerHTML = keyTypeInfo; + } else { + quickInfoBox.style.borderLeftColor = 'var(--primary-color-high, #1976d2)'; + let innerHtmlMarkup = '
' + TXT.INFO.type + ': ' + keyTypeInfo + '
'; + + if (commonNameInfo) { + innerHtmlMarkup += '
' + TXT.KEY.common_name + ': ' + commonNameInfo + '
'; + } + innerHtmlMarkup += '
' + TXT.INFO.valid_until + ': ' + expirationInfo + '
'; + quickInfoBox.innerHTML = innerHtmlMarkup; + } + quickInfoBox.style.display = 'block'; + }; + + // Read file and parse metadata + rpcFileCallbacks.rpcReadFile(absolutePath).then(function (content) { + modalKeyTextArea.value = content ? content.trim() + '\n' : '--- EMPTY OR BLANK KEY FILE ---'; + + if (!content || content.trim() === '') return; + + rpcFileCallbacks.luci_app_openvpn_plus([CFG.LIBEXEC.keymeta, filename]).then(function (res) { + if (res.length > 0) { + quickInfoBoxUpdate(res); + } + }); + }); + + const modalSaveApplyBtn = E('button', { + 'class': 'cbi-button cbi-button-save', + 'style': 'margin-right:10px;' + }, [TXT.BTN.save_apply]); + + modalSaveApplyBtn.addEventListener('click', function () { + modalSaveApplyBtn.disabled = true; + modalSaveApplyBtn.textContent = TXT.BTN.saving; + + // Standardize all copy-pasted line endings instantly (Supports UNIX \n, Windows \r\n, and Mac \r) + const sanitizedKeyContent = sanitizeInputText(modalKeyTextArea.value) + '\n'; + + // Write file and re-validate key metadata (Using our clean sanitized text string) + rpcFileCallbacks.rpcWriteFile(absolutePath, sanitizedKeyContent) + .then(function () { + return rpcFileCallbacks.luci_app_openvpn_plus([CFG.LIBEXEC.keymeta, filename]); + }) + .then(function (keymeta) { + + if (keymeta.indexOf('ERROR') !== -1) { + quickInfoBoxUpdate(keymeta); + modalSaveApplyBtn.disabled = false; + modalSaveApplyBtn.textContent = TXT.BTN.save_apply; + return Promise.reject(new Error('Editor validation failed')); + } + + hasSaved = true; + modalSaveApplyBtn.textContent = TXT.BTN.saved; + + setTimeout(function () { + modalSaveApplyBtn.disabled = false; + modalSaveApplyBtn.textContent = TXT.BTN.save_apply; + if (keymeta !== '') { + quickInfoBoxUpdate(keymeta); + } + }, 1200); + }) + .catch(function (err) { + if (err.message !== 'Editor validation failed') { + console.error('Editor save chain caught unhandled error context:', err); + modalSaveApplyBtn.disabled = false; + modalSaveApplyBtn.textContent = TXT.BTN.save_apply; + } + }); + }); + + const modalDownloadKeyBtn = E('button', { + 'class': 'cbi-button cbi-button-apply', + 'style': 'margin-right:10px;' + }, [TXT.BTN.download]); + + modalDownloadKeyBtn.addEventListener('click', function () { + const blob = new Blob([modalKeyTextArea.value], { type: 'text/plain' }); + const link = document.createElement('a'); + link.href = URL.createObjectURL(blob); + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }); + + const modalCloseEditBtn = E('button', { + 'class': 'cbi-button cbi-button-neutral' + }, [TXT.BTN.close]); + + modalCloseEditBtn.addEventListener('click', function () { + L.ui.hideModal(); + if (hasSaved) { + uciSaveApplyRestartCallback(instance_id); + } + }); + + L.ui.showModal(displayId + ' - ' + filename, [ + E('div', { 'class': 'cbi-map' }, [ + E('div', { 'class': 'cbi-section' }, [ + E('div', { 'class': 'cbi-section-descr', 'style': 'margin-bottom:12px; font-style:italic; color:color-mix(in srgb, var(--text-color-medium, #71717a) 85%, #0055ff);' }, [TXT.INFO.file_location + absolutePath]), + quickInfoBox, + E('div', { 'style': 'margin-bottom:20px;' }, [modalKeyTextArea]), + E('div', { 'style': 'text-align:right;' }, [modalSaveApplyBtn, modalDownloadKeyBtn, modalCloseEditBtn]) + ]) + ]) + ]); +}; + + +/** + * --- KEY GENERATOR --- + */ + + +/** + * Creates keys and certificates using background background scripts + */ +const executeAsynchronousKeyGen = function (instance_id, active_mode, rawBitsSelection, selectedYears, targetCnName, customCliString, statusOutputNode, rpcFileCallbacks, finalCallback, displayMode) { + if (!statusOutputNode) { + return; + } + if (!(active_mode === KEYTYPE.pki || active_mode === KEYTYPE.client_pki || + active_mode === KEYTYPE.ca || active_mode === KEYTYPE.server_key || active_mode === KEYTYPE.server_cert || active_mode === KEYTYPE.client_key || active_mode === KEYTYPE.client_cert || + active_mode === KEYTYPE.dh || active_mode === KEYTYPE.server_tls2 || active_mode === KEYTYPE.client_tls2)) { + return; + } + + const keygenCleanUp = function () { rpcFileCallbacks.luci_app_openvpn_plus([CFG.LIBEXEC.keygenclean]); }; + + const days = (parseInt(selectedYears, 10) * 365).toString(); + + const rawCustom = customCliString ? customCliString.trim() : ''; + const customArgsArray = rawCustom ? rawCustom.split(';') : []; + const hasMultiStageArgs = (customArgsArray.length >= 2); + + // Safe index mapping with strict empty-string fallback protection + const step1Custom = hasMultiStageArgs ? (customArgsArray[0] ? customArgsArray[0].trim() : '') : rawCustom; + const step2Custom = hasMultiStageArgs ? (customArgsArray[1] ? customArgsArray[1].trim() : '') : ''; + const step3Custom = hasMultiStageArgs ? (customArgsArray[2] ? customArgsArray[2].trim() : '') : ''; + + // Check the display mode to clear or keep the old text field contents + const isAppend = (displayMode === 'append'); + let accumulatedPkiLog = isAppend ? statusOutputNode.value : ''; + const pki_payload = { ca: '', key: '', cert: '' }; + let caBits = KEYTYPE.b2048; + let bits = KEYTYPE.b2048; + let pollCount = 0; + + let currentPkiStep = 0; + let backendType = active_mode; + if (active_mode === KEYTYPE.pki) { + // on pki start with ca + backendType = KEYTYPE.ca; + currentPkiStep = 1; + } else if (active_mode === KEYTYPE.client_pki) { + // set the initial backend execution type trigger command for the client key pair + backendType = KEYTYPE.client_key; + // client_pki will skip step 1 (CA) since it derives assets directly from the server's existing CA + currentPkiStep = 2; + } + + // Choose the correct key strength and bits selection parameters + if (active_mode === KEYTYPE.pki || active_mode === KEYTYPE.client_pki) { + if (rawBitsSelection === KEYTYPE.rsa2048_ec) { + caBits = KEYTYPE.b2048; + bits = KEYTYPE.ec; + } else if (rawBitsSelection === KEYTYPE.rsa4096_ec) { + caBits = KEYTYPE.b4096; + bits = KEYTYPE.ec; + } else if (rawBitsSelection === KEYTYPE.ec) { + caBits = KEYTYPE.ec; + bits = KEYTYPE.ec; + } else { + caBits = rawBitsSelection; + bits = rawBitsSelection; + } + } else { + caBits = rawBitsSelection; + bits = rawBitsSelection; + } + + if (isAppend) { + statusOutputNode.value += TXT.KEY.running + '\n'; + } else { + statusOutputNode.value = TXT.KEY.running + '\n'; + } + + // Run the system command to start the key background script + rpcFileCallbacks.luci_app_openvpn_plus([CFG.LIBEXEC.keygen, backendType, instance_id, caBits, days, targetCnName, step1Custom]); + + if (active_mode === KEYTYPE.pki) { + accumulatedPkiLog = ICON.ARROW + TXT.KEY.pki_step1 + '\n'; + statusOutputNode.value = accumulatedPkiLog; + } else if (active_mode === KEYTYPE.client_pki) { + // Set initial status logging output text message for the client workflow track + accumulatedPkiLog = ICON.ARROW + TXT.KEY.generating_client_keys + '\n'; + statusOutputNode.value = accumulatedPkiLog; + } else { + if (isAppend) { + accumulatedPkiLog = statusOutputNode.value + ICON.ARROW + TXT.KEY.running + '\n'; + } else { + accumulatedPkiLog = ICON.ARROW + TXT.KEY.running + '\n'; + } + statusOutputNode.value = accumulatedPkiLog; + } + + // Start a timer loop to read the progress log file every 200 milliseconds + const logPollerInterval = setInterval(function () { + pollCount++; + + rpcFileCallbacks.rpcReadFile(CFG.FILE.openvpn_keygen_log).then(function (logContent) { + if (logContent && logContent.trim() !== '') { + const beautifiedLog = logContent.replace(/\[CMD\]/g, ICON.LAPTOP); + statusOutputNode.value = accumulatedPkiLog + beautifiedLog; + statusOutputNode.scrollTop = statusOutputNode.scrollHeight; + + // Check if the log file says that the current step was successful + if (logContent.lastIndexOf('LOG: WORKFLOW_SUCCESSFUL') !== -1) { + + // Map target key file name dynamically + let targetTmpFile = CFG.FILE.temp_openvpn_keygen + backendType + '.tmp'; + + // Read the temporary file containing the generated text data + rpcFileCallbacks.rpcReadFile(targetTmpFile).then(function (finalAsset) { + + if (!finalAsset || finalAsset.trim() === '') { + return; + } + const finalBeautifiedLog = beautifiedLog.replace('LOG: WORKFLOW_SUCCESSFUL', '').trim(); + const log_separator_line = '\n\n--------------------------------------------------\n\n'; + + // Symmetrically route multi-stage full PKI certificate generation loops + if (active_mode === KEYTYPE.pki || active_mode === KEYTYPE.client_pki) { + if (currentPkiStep === 1) { + pki_payload.ca = finalAsset; + currentPkiStep = 2; + backendType = KEYTYPE.server_key; + accumulatedPkiLog += finalBeautifiedLog + log_separator_line + ICON.ARROW + TXT.KEY.pki_step2 + '\n'; + + rpcFileCallbacks.luci_app_openvpn_plus([CFG.LIBEXEC.keygen, backendType, instance_id, bits, days, targetCnName, step2Custom]); + + } else if (currentPkiStep === 2) { + pki_payload.key = finalAsset; + currentPkiStep = 3; + backendType = (active_mode === KEYTYPE.client_pki) ? KEYTYPE.client_cert : KEYTYPE.server_cert; + + const displayStepText = (active_mode === KEYTYPE.client_pki) ? TXT.KEY.signing_client_using_server_ca : TXT.KEY.pki_step3; + accumulatedPkiLog += finalBeautifiedLog + log_separator_line + ICON.ARROW + displayStepText + '\n'; + + rpcFileCallbacks.luci_app_openvpn_plus([CFG.LIBEXEC.keygen, backendType, instance_id, bits, days, targetCnName, step3Custom]); + + } else if (currentPkiStep === 3) { + pki_payload.cert = finalAsset; + clearInterval(logPollerInterval); + + // Check if we need to read the CA file (only for client_pki mode) + var caPromise; + + if (active_mode === KEYTYPE.client_pki) { + // Read the file and use empty string if it fails + caPromise = rpcFileCallbacks.rpcReadFile(CFG.FILE.dir_keys + 'ca_' + instance_id + '.crt'); + } else { + // Do not read the file, just send an empty string immediately + caPromise = Promise.resolve(''); + } + + // This part always runs, no matter what mode we are in + caPromise.then(function (liveCaContent) { + + // Only save the CA content if we are in client_pki mode + if (active_mode === KEYTYPE.client_pki) { + pki_payload.ca = String(liveCaContent || '').trim(); + } + + // Update the text area on the screen with the results + statusOutputNode.value = accumulatedPkiLog + finalBeautifiedLog + '\n\n' + + ICON.SUCCESS + ' ' + TXT.KEY.client_key_generated + '\n\n' + + '--- ca.crt ---\n' + pki_payload.ca + '\n\n' + + '--- client.key ---\n' + pki_payload.key + '\n\n' + + '--- client.crt ---\n' + pki_payload.cert + '\n'; + + // Scroll to the bottom of the text area automatically + statusOutputNode.scrollTop = statusOutputNode.scrollHeight; + + // Call the final callback function if it exists + if (typeof finalCallback === 'function') { + finalCallback(true, pki_payload, null); + keygenCleanUp(); + } + }); + } + } else { + // For DH or TLS modes stop the timer and send back the results + clearInterval(logPollerInterval); + + statusOutputNode.value = accumulatedPkiLog + finalBeautifiedLog + log_separator_line + finalAsset; + statusOutputNode.scrollTop = statusOutputNode.scrollHeight; + + if (typeof finalCallback === 'function') { + finalCallback(true, null, finalAsset); + keygenCleanUp(); + } + } + }); + } + + // Stop the timer loop if the background script logs an error + if (logContent.indexOf('ERROR:') !== -1) { + clearInterval(logPollerInterval); + if (typeof finalCallback === 'function') { + finalCallback(false, null, null); + keygenCleanUp(); + } + } + } + + // Force stop the timer if the process takes too long + if (pollCount > 3000) { + clearInterval(logPollerInterval); + statusOutputNode.value += '\n' + TXT.ERROR.polling_threshold; + if (typeof finalCallback === 'function') { + finalCallback(false, null, null); + keygenCleanUp(); + } + } + }).catch(function () { + // Ignore folder lag and keep running the loop + }); + }, 200); +}; + +/** + * Commits raw cryptographic key components or single key assets sequentially to disk storage. + */ +const saveKeysToDisk = function (instance_id, active_mode, pkiPayload, singlePayload, rpcFileCallbacks) { + if (active_mode === KEYTYPE.pki) { + if (!pkiPayload || !pkiPayload.ca || !pkiPayload.key || !pkiPayload.cert) { + return Promise.reject(new Error('Incomplete multi-stage PKI suite payload buffer contents.')); + } + + // Write PKI certificates and keys sequentially to disk + return rpcFileCallbacks.rpcWriteFile(CFG.FILE.dir_keys + 'ca_' + instance_id + '.crt', pkiPayload.ca.trim() + '\n') + .then(function () { + return rpcFileCallbacks.rpcWriteFile(CFG.FILE.dir_keys + 'server_' + instance_id + '.key', pkiPayload.key.trim() + '\n'); + }) + .then(function () { + return rpcFileCallbacks.rpcWriteFile(CFG.FILE.dir_keys + 'server_' + instance_id + '.crt', pkiPayload.cert.trim() + '\n'); + }); + } else { + if (!singlePayload) { + return Promise.reject(new Error('Empty single-asset cryptographic asset payload buffer.')); + } + + // Write standalone DH or TLS assets to disk + let targetFile; + if (active_mode === KEYTYPE.dh) { + targetFile = 'dh_' + instance_id + '.pem'; + } else if (active_mode === KEYTYPE.server_tls2) { + targetFile = 'server_' + instance_id + '.tls2.key'; + } else if (active_mode === KEYTYPE.client_tls2) { + targetFile = 'client_' + instance_id + '.tls2.key'; + } + if (targetFile) { + return rpcFileCallbacks.rpcWriteFile(CFG.FILE.dir_keys + targetFile, singlePayload.trim() + '\n'); + } else { + return Promise.reject(new Error('Unknown keytype.')); + } + } +}; + +/** + * Opens a window to ask the user if they want to create new unique keys + */ +const openAutomatedPostKeyGenModal = async function (newInstanceItem, appData, wizardParams, callbacks, optionalShowBtnCancel) { + const instance_id = newInstanceItem.id; + const cnName = wizardParams ? appData.wizardClass.getValidCommonName(wizardParams.displayName) : ''; + + const progressTextArea = E('textarea', { + 'class': 'cbi-input-textarea', + 'style': 'width:100%; max-width:100%; resize:none; font-family:var(--font-monospace, monospace); font-size:12px; background:#18181b; color:#4ade80 !important; padding:15px; border-radius:4px; border:1px solid var(--border-color-medium, #cbd5e1); text-shadow:none !important; display:none;', + 'rows': '14', + 'readonly': 'readonly' + }, TXT.KEY.ready); + + const promptMessageNode = E('p', { 'style': 'margin-bottom:15px; font-size:13px; line-height:1.5; color:var(--text-color-medium, #71717a);' }, + TXT.KEY.generate_unique_keys_new_instance + ); + + let styleHide = ''; + if (optionalShowBtnCancel !== true) { + styleHide = ' display:none !important;'; + } + + const yesBtn = E('button', { 'class': 'btn cbi-button cbi-button-action important', 'style': 'margin-right:10px;' }, TXT.KEY.generate_unique_keys); + const noBtn = E('button', { 'class': 'btn cbi-button cbi-button-neutral', 'style': 'margin-right:10px;' }, TXT.KEY.clone_default_keys); + const cancelBtn = E('button', { 'class': 'btn cbi-button cbi-button-neutral', 'style': 'margin-right:10px;' + styleHide }, TXT.BTN.cancel); + const closeBtn = E('button', { 'class': 'cbi-button cbi-button-action important', 'style': 'display:none;' }, TXT.BTN.close); + + const buttonContainer = E('div', { 'style': 'text-align:right; margin-top:15px;' }, [yesBtn, noBtn, cancelBtn, closeBtn]); + + const dashedDivider = E('hr', { + 'style': 'margin:20px 0; border:0; border-top:1px dashed var(--border-color-medium, #cbd5e1);' + styleHide + }); + + const wizardAdvertNoticeBox = E('div', { + 'class': 'alert-message', + 'style': 'margin-bottom:15px; padding:0px; font-size:12px; line-height:1.5; text-align:left; background:none !important; box-shadow:none !important; border:none !important;' + styleHide + }, [ + E('strong', { 'style': 'display:block; margin-bottom:4px; font-size:13px; color:var(--primary-color-high, #1976d2);' }, [ICON.ARROW, TXT.WARNING.use_wizard_hint_title]), + E('div', { 'style': 'color:var(--primary-color-high, #1976d2); margin-top:10px; padding:8px 12px; background:color-mix(in srgb, var(--primary-color-high, #1976d2) 10%, transparent); border-left:4px solid var(--primary-color-high, #1976d2); border-radius:4px;' }, [TXT.WARNING.use_wizard_hint_desc]) + ]); + + const openWizardBtn = E('button', { + 'class': 'cbi-button cbi-button-apply important', + 'style': 'text-shadow: none !important; ' + + 'box-shadow: 0 4px 6px -1px color-mix(in srgb, var(--primary-color-high, #1976d2) 20%, transparent) !important; ' + + 'white-space: nowrap; ' + + 'padding: 6px 16px; ' + + 'font-weight: bold; ' + + 'display: inline-flex; ' + + 'align-items: center; ' + + 'justify-content: center; ' + + 'gap: 8px;' + + styleHide + }, [ + E('span', { 'style': 'font-size: 16px; line-height: 1;' }, ICON.ROCKET), + E('span', {}, TXT.INFO.wizard + ' ...') + ]); + + + const wizardButtonWrapperRow = E('div', { + 'style': 'display:flex; justify-content:flex-end; width:100%; margin-top:5px;' + styleHide + }, [openWizardBtn]); + + const wizardHelperLayoutBlock = E('div', { 'style': 'display:block;' }, [ + dashedDivider, + wizardAdvertNoticeBox, + wizardButtonWrapperRow + ]); + + openWizardBtn.addEventListener('click', function (ev) { + ev.preventDefault(); + L.ui.hideModal(); + callbacks.openWizardBtnClick(appData, null, true); + }); + + // Declared anonymous handler as ASYNC to authorize internal await callbacks.createInstance + noBtn.addEventListener('click', async function (ev) { + ev.preventDefault(); + L.ui.hideModal(); + await callbacks.createInstance(newInstanceItem, appData, wizardParams); + callbacks.uciSaveApplyRestart(instance_id); + }); + + cancelBtn.addEventListener('click', function (ev) { + ev.preventDefault(); + L.ui.hideModal(); + }); + + // Declared anonymous handler as ASYNC to authorize internal await callbacks.createInstance + yesBtn.addEventListener('click', async function (ev) { + ev.preventDefault(); + yesBtn.style.display = 'none'; + noBtn.style.display = 'none'; + cancelBtn.style.display = 'none'; + promptMessageNode.style.display = 'none'; + wizardHelperLayoutBlock.style.display = 'none'; + progressTextArea.style.display = 'block'; + closeBtn.style.display = 'inline-block'; + closeBtn.disabled = true; + closeBtn.className = 'cbi-button cbi-button-neutral'; + + await callbacks.createInstance(newInstanceItem, appData, wizardParams); + + executeAsynchronousKeyGen(instance_id, KEYTYPE.pki, KEYTYPE.rsa2048_ec, KEYTYPE.y100, cnName, '', progressTextArea, callbacks.rpcFileCallbacks, function (pkiSuccess, pkiData, nullData) { + if (!pkiSuccess || !pkiData) { + progressTextArea.value += '\n' + ICON.ERROR + ' PKI generation failed.'; + closeBtn.disabled = false; + closeBtn.className = 'cbi-button cbi-button-action important'; + return; + } + const log_separator_line = '\n\n--------------------------------------------------\n\n'; + progressTextArea.value += log_separator_line + ICON.ARROW + TXT.KEY.generate_tls_key + '\n'; + progressTextArea.scrollTop = progressTextArea.scrollHeight; + + executeAsynchronousKeyGen(instance_id, KEYTYPE.server_tls2, 'none', '0', cnName, '', progressTextArea, callbacks.rpcFileCallbacks, function (tlsSuccess, nullData, tlsData) { + if (!tlsSuccess || !tlsData) { + progressTextArea.value += '\n' + ICON.ERROR + 'TLS crypt v2 server master key generation failed.'; + closeBtn.disabled = false; + closeBtn.className = 'cbi-button cbi-button-action important'; + return; + } + Promise.all([ + saveKeysToDisk(instance_id, KEYTYPE.pki, pkiData, null, callbacks.rpcFileCallbacks), + saveKeysToDisk(instance_id, KEYTYPE.server_tls2, null, tlsData, callbacks.rpcFileCallbacks) + ]).then(function () { + progressTextArea.value += '\n' + ICON.SAVE + ' ' + TXT.KEY.pki_saved + '\n' + ICON.SAVE + ' ' + TXT.KEY.tls_key_saved; + progressTextArea.scrollTop = progressTextArea.scrollHeight; + closeBtn.disabled = false; + closeBtn.className = 'cbi-button cbi-button-action important'; + }).catch(function (err) { + progressTextArea.value += '\n' + ICON.ERROR + ' Storage Write Failed: ' + err.message; + closeBtn.disabled = false; + closeBtn.className = 'cbi-button cbi-button-action important'; + }); + }, 'append'); + }, 'fresh'); + }); + + closeBtn.addEventListener('click', function (ev) { + ev.preventDefault(); + L.ui.hideModal(); + callbacks.uciSaveApplyRestart(instance_id); + }); + + L.ui.showModal(TXT.KEY.title_main + ' - ' + TXT.KEY.key_allocation + ' (' + instance_id + ')', [ + E('div', { 'class': 'cbi-map' }, [ + E('div', { 'class': 'cbi-section' }, [ + promptMessageNode, + progressTextArea, + buttonContainer, + wizardHelperLayoutBlock + ]) + ]) + ]); +}; + +/** + * Creates all structural visual elements for the key generator + */ +const createKeyGenUiElements = function (role, defaultName, appData) { + // Render DH options and Client-PKI generation tools ONLY for active server profiles + const isServer = (role === OPENVPN.ROLE.SERVER); + const dhOptionHtml = isServer ? '' : ''; + const tlsOptionHtml = isServer ? '' : ''; + const clientPkiOptionHtml = isServer ? '' : ''; + + const fallbackCn = appData.wizardClass.getValidCommonName(defaultName); + + // Build the settings selectors using simple HTML string layers + const optionsHtml = '
' + + '' + + '
' + + '' + + '
' + + '
' + + + '
' + + '' + + '
' + + '' + + '' + + '
' + TXT.KEY.strength_description + '
' + + '
' + + '
' + + + '
' + + '' + + '
' + + '' + + '
' + + '
' + + + '
' + + '' + + '
' + + '' + + '
' + TXT.KEY.use_unique_name_for_each_office + '
' + + '
' + + '
'; + + const customCli = E('input', { 'id': 'keygen_custom_cmd', 'type': 'text', 'class': 'cbi-input-text', 'style': 'width:100%; margin-top:5px;' }); + const textArea = E('textarea', { + 'id': 'keygen_output', + 'class': 'cbi-input-textarea', + 'rows': '12', + 'readonly': 'readonly', + 'style': 'width: 100%; ' + + 'max-width: 100%; ' + + 'resize: vertical; ' + + 'font-family: var(--font-monospace, monospace); ' + + 'font-size: 12px; ' + + 'background: #18181b; ' + + 'color: #4ade80 !important; ' + + 'padding: 15px; ' + + 'border-radius: 4px; ' + + 'border: 1px solid var(--border-color-medium, #cbd5e1); ' + + 'text-shadow: none !important; ' + + 'line-height: 1.5;' + }, [TXT.KEY.ready]); + + const warnBox = E('div', { + 'class': 'cbi-value', + 'style': 'background: color-mix(in srgb, var(--warn-color-high, #efbd0b) 18%, transparent); ' + + 'display:none !important; ' + + 'border-left: 4px solid var(--warn-color-high, #efbd0b); ' + + 'padding: 10px; ' + + 'margin: 10px; ' + + 'border-radius: 4px;' + }, [ + E('div', { + 'style': 'color: var(--text-color-high, #3f3f46); font-weight: bold; line-height: 1.5;' + }, [ICON.WARNING, TXT.WARNING.clicking_save_will_break_vpn]) + ]); + + const optionsBox = E('div', { 'id': 'central_options_container' }); + optionsBox.innerHTML = optionsHtml; + + return { + nodes: { warn: warnBox, options: optionsBox, customCli: customCli, output: textArea }, + buttons: { + generate: E('button', { 'class': 'cbi-button cbi-button-action', 'style': 'margin-right:10px;' }, TXT.BTN.generate), + save: E('button', { 'class': 'cbi-button cbi-button-save disabled', 'style': 'margin-right:10px; opacity: 0.5;', 'disabled': true }, TXT.BTN.save_config), + export: E('button', { 'class': 'cbi-button cbi-button-action important disabled', 'style': 'margin-right:10px; opacity: 0.5; display:none;', 'disabled': true }, ICON.EXPORT + ' ' + TXT.KEY.export_keys), + close: E('button', { 'class': 'cbi-button cbi-button-neutral' }, TXT.BTN.close) + } + }; +}; + +/** + * Connects all interactive timers, actions and file saving events + */ +const setupKeyGenEvents = function (ui, instance_id, state, appData, uciSaveApplyRestartCallback, rpcFileCallbacks) { + // Use ui.nodes.options to safely find the unique mode dropdown element layout + const modeSelect = (ui.nodes && ui.nodes.options) ? ui.nodes.options.querySelector('#' + CFG.ID.central_keygen_mode) : null; + + if (!modeSelect) { + console.warn('Central keygen mode select dropdown element not found inside UI schema.'); + return; + } + + const updateKeyStrengthDropdowns = function () { + const pkiSelect = document.getElementById(CFG.ID.keygen_bits_pki); + const dhSelect = document.getElementById(CFG.ID.keygen_bits_dh); + const descContainer = document.getElementById(CFG.ID.keygen_bits_desc); + + if (!pkiSelect || !dhSelect || !descContainer) return; + + // Dynamic visibility tracking including the client_pki option + if (state.mode === KEYTYPE.pki || state.mode === KEYTYPE.client_pki) { + pkiSelect.style.display = 'inline-block'; + dhSelect.style.display = 'none'; + descContainer.textContent = TXT.KEY.strength_description; + } else if (state.mode === KEYTYPE.dh) { + pkiSelect.style.display = 'none'; + dhSelect.style.display = 'inline-block'; + descContainer.textContent = TXT.KEY.dh_description; + } else if (state.mode === KEYTYPE.server_tls2) { + pkiSelect.style.display = 'none'; + dhSelect.style.display = 'none'; + descContainer.textContent = TXT.KEY.tls_description_server; + } else if (state.mode === KEYTYPE.client_tls2) { + pkiSelect.style.display = 'none'; + dhSelect.style.display = 'none'; + descContainer.textContent = TXT.KEY.tls_description_client; + } + + }; + + const updateCustomCliPlaceholder = function () { + if (!ui.nodes || !ui.nodes.customCli) return; + ui.nodes.customCli.value = ''; + + // Added specific placeholder rules layout mapping for the client keys generator mode + if (state.mode === KEYTYPE.pki) { + ui.nodes.customCli.placeholder = TXT.KEY.placeholder_pki; + ui.nodes.customCli.disabled = false; + ui.nodes.customCli.style.opacity = '0.5'; + } else if (state.mode === KEYTYPE.client_pki) { + ui.nodes.customCli.placeholder = TXT.KEY.placeholder_pki_client; + ui.nodes.customCli.disabled = false; + ui.nodes.customCli.style.opacity = '0.5'; + } else if (state.mode === KEYTYPE.dh) { + ui.nodes.customCli.placeholder = TXT.KEY.placeholder_dh; + ui.nodes.customCli.disabled = false; + ui.nodes.customCli.style.opacity = '0.5'; + } else if ((state.mode === KEYTYPE.server_tls2) || (state.mode === KEYTYPE.client_tls2)) { + ui.nodes.customCli.placeholder = TXT.KEY.placeholder_tls; + ui.nodes.customCli.disabled = true; + ui.nodes.customCli.style.opacity = '0.2'; + } + }; + + modeSelect.addEventListener('change', function (ev) { + ev.preventDefault(); + state.mode = ev.target.value; + + const rowYears = document.getElementById(CFG.ID.row_keygen_years); + const rowCn = document.getElementById(CFG.ID.row_keygen_cn); + const saveBtn = ui.buttons.save; + const exportBtn = ui.buttons.export; + + if (ui.nodes.warn) ui.nodes.warn.style.display = 'none'; + + // Toggle distinct button visibilities and the Common Name row layer instantly + if (state.mode === KEYTYPE.client_pki) { + saveBtn.style.display = 'none'; + exportBtn.style.display = 'inline-block'; + exportBtn.enabled = false; + exportBtn.style.opacity = '0.5'; + + if (rowYears) rowYears.style.display = 'flex'; + if (rowCn) rowCn.style.display = 'flex'; + } else { + saveBtn.style.display = 'inline-block'; + saveBtn.enabled = false; + saveBtn.style.opacity = '0.5'; + exportBtn.style.display = 'none'; + + if (state.mode === KEYTYPE.pki) { + // Show Common Name field for server PKI setups + if (rowYears) rowYears.style.display = 'flex'; + if (rowCn) rowCn.style.display = 'flex'; + } else { + if (rowYears) rowYears.style.display = 'none'; + if (rowCn) rowCn.style.display = 'none'; + } + } + + updateKeyStrengthDropdowns(); + updateCustomCliPlaceholder(); + }); + + ui.buttons.generate.addEventListener('click', function (ev) { + ev.preventDefault(); + const selectedYears = document.getElementById(CFG.ID.keygen_years) ? document.getElementById(CFG.ID.keygen_years).value : KEYTYPE.y100; + const baseCustomArgs = (ui.nodes.customCli && ui.nodes.customCli.value) ? sanitizeInputLine(ui.nodes.customCli.value) : ''; + let finalCn = ''; + state.isGenerated = false; + + if (state.mode === KEYTYPE.pki || state.mode === KEYTYPE.client_pki) { + const cnInput = document.getElementById(CFG.ID.keygen_cn); + finalCn = cnInput ? appData.wizardClass.getValidCommonName(cnInput.value) : ''; + } + + let rawBitsSelection = KEYTYPE.b2048; + if (state.mode === KEYTYPE.pki || state.mode === KEYTYPE.client_pki) { + rawBitsSelection = document.getElementById(CFG.ID.keygen_bits_pki).value; + } else if (state.mode === KEYTYPE.dh) { + rawBitsSelection = document.getElementById(CFG.ID.keygen_bits_dh).value; + } + + ui.buttons.generate.disabled = true; + ui.buttons.save.disabled = true; + ui.buttons.save.style.opacity = '0.5'; + ui.buttons.export.disabled = true; + ui.buttons.export.style.opacity = '0.5'; + modeSelect.disabled = true; + + // STEP 1: Start core asynchronous cryptographic generation + executeAsynchronousKeyGen(instance_id, state.mode, rawBitsSelection, selectedYears, finalCn, baseCustomArgs, ui.nodes.output, rpcFileCallbacks, function (isSuccessful, pkiData, singleData) { + if (isSuccessful) { + // Scenario A: If a Site-to-Site client bundle is requested, chain the asymmetric client token derivation instantly + if (state.mode === KEYTYPE.client_pki) { + const log_separator_line = '\n\n--------------------------------------------------\n\n'; + ui.nodes.output.value += log_separator_line + ICON.ARROW + _('Generating TLS crypt v2 client key...') + '\n'; + ui.nodes.output.scrollTop = ui.nodes.output.scrollHeight; + + // STEP 2: Asynchronously generate the client-tls2 credentials into the volatile RAM buffer + executeAsynchronousKeyGen(instance_id, KEYTYPE.client_tls2, 'none', '0', finalCn, '', ui.nodes.output, rpcFileCallbacks, function (tlsSuccess, nullData, tlsData) { + ui.buttons.generate.disabled = false; + modeSelect.disabled = false; + + if (!tlsSuccess || !tlsData) { + ui.nodes.output.value += '\n' + ICON.ERROR + ' TLS crypt v2 client key generation failed.'; + return; + } + + // Package both components cleanly into the operational memory context state variables + state.isGenerated = true; + state.pkiData = pkiData; + state.singleData = tlsData; + + ui.buttons.export.disabled = false; + ui.buttons.export.style.opacity = '1'; + }, 'append'); + + } else { + // Scenario B: Standard processing path for single-stage assets (PKI, DH, server-tls2) + ui.buttons.generate.disabled = false; + modeSelect.disabled = false; + state.isGenerated = true; + state.pkiData = pkiData; + state.singleData = singleData; + + if (!ui.buttons.save.dataset.confirmSave) { + if ((state.mode === KEYTYPE.pki) || (state.mode === KEYTYPE.server_tls2) || (state.mode === KEYTYPE.client_tls2)) { + if (ui.nodes.warn) ui.nodes.warn.style.display = 'block'; + ui.buttons.save.textContent = ICON.WARNING + TXT.BTN.enable_save; + ui.buttons.save.dataset.confirmSave = 'true'; + } + } + + ui.buttons.save.disabled = false; + ui.buttons.save.style.opacity = '1'; + } + } else { + // Central fallback handler if Step 1 crashes out early + ui.buttons.generate.disabled = false; + modeSelect.disabled = false; + } + }, 'fresh'); + }); + + // action listener reserved strictly for local disk writing configurations + ui.buttons.save.addEventListener('click', function (ev) { + + ev.preventDefault(); + if (!state.isGenerated) return; + if (ui.buttons.save.dataset.confirmSave) { + delete ui.buttons.save.dataset.confirmSave; + ui.buttons.save.textContent = TXT.BTN.save_config; + return; + } + + ui.buttons.save.disabled = true; + ui.buttons.save.style.opacity = '0.5'; + + saveKeysToDisk(instance_id, state.mode, state.pkiData, state.singleData, rpcFileCallbacks) + .then(function () { + state.hasSaved = true; + state.isGenerated = false; + ui.nodes.output.value += '\n\n' + ICON.SAVE + ' ' + TXT.BTN.saved; + ui.nodes.output.scrollTop = ui.nodes.output.scrollHeight; + }) + .catch(function (err) { + ui.buttons.save.disabled = false; + ui.buttons.save.style.opacity = '1'; + ui.nodes.output.value += '\n' + ICON.ERROR + ' ' + err.message; + }); + }); + + // action listener reserved strictly for raw cryptographic keys export (.crt bundle) + ui.buttons.export.addEventListener('click', async function (ev) { + ev.preventDefault(); + if (!state.isGenerated) return; + + ui.buttons.export.disabled = true; + ui.buttons.save.style.opacity = '0.5'; + + const rawCaContent = state.pkiData ? state.pkiData.ca : ''; + const clientCertContent = state.pkiData ? state.pkiData.cert : ''; + const clientKeyContent = state.pkiData ? state.pkiData.key : ''; + + // SECURITY FILTER: Strip out the secret CA Private Key block completely if it exists in the data + const cleanCaContent = rawCaContent.replace(/-----BEGIN[^\n]*PRIVATE KEY-----[\s\S]*?-----END[^\n]*PRIVATE KEY-----\n*/g, ''); + + // Assemble ... ... ... ... + let cryptoBundlePayload = cleanCaContent.trim() + '\n\n' + + clientCertContent.trim() + '\n\n' + + clientKeyContent.trim() + '\n'; + + if (state.singleData) { + cryptoBundlePayload += '\n' + state.singleData.trim() + '\n'; + } + + const cnInput = document.getElementById(CFG.ID.keygen_cn); + const userEnteredName = cnInput ? cnInput.value : ''; + let cleanFilename = appData.wizardClass.getValidCommonName(userEnteredName); + if (!cleanFilename) { + cleanFilename = 'client_' + instance_id; + } + + const blob = new Blob([cryptoBundlePayload], { type: 'application/x-x509-ca-cert' }); + const link = E('a', { + href: URL.createObjectURL(blob), + download: 'keys_' + cleanFilename + '.crt' + }); + + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + ui.buttons.export.disabled = false; + ui.buttons.export.style.opacity = '1'; + ui.nodes.output.value += '\n\n' + ICON.SUCCESS + ' ' + TXT.KEY.client_keys_exported; + }); + + ui.buttons.close.addEventListener('click', function (ev) { + ev.preventDefault(); + if (state.isGenerated && !window.confirm(TXT.WARNING.key_not_saved)) return; + L.ui.hideModal(); + if (state.hasSaved) { + uciSaveApplyRestartCallback(instance_id); + } + }); + + // Trigger initial UI setup alignments + updateKeyStrengthDropdowns(); + updateCustomCliPlaceholder(); +}; + +/** + * Opens the key generator window to create keys and certificates (Slim and Modular) + */ +const openKeyGenModal = function (instance_id, displayId, role, appData, uciSaveApplyRestartCallback, rpcFileCallbacks) { + // Create the configuration elements and buttons using our sub-routines + const ui = createKeyGenUiElements(role, displayId, appData); + + // Group the mutable state variables inside a single state context object + const state = { + mode: KEYTYPE.pki, + isGenerated: false, + hasSaved: false, + pkiData: null, + singleData: null + }; + + // Setup all interactive listeners and event routing logic + setupKeyGenEvents(ui, instance_id, state, appData, uciSaveApplyRestartCallback, rpcFileCallbacks); + + // Render the complete key generator overlay view layout structure + L.ui.showModal(TXT.KEY.title_main + ' (' + displayId + ')', [ + E('div', { 'class': 'cbi-map' }, [ + E('div', { 'class': 'cbi-section' }, [ + ui.nodes.options, + + // Layout row for the custom cli input box + E('div', { 'style': 'margin-top:20px; padding:0 10px;' }, [ + E('label', { 'class': 'cbi-value-title', 'style': 'display:block; font-weight:bold; margin-bottom:5px; float:none; text-align:left; width:100%;' }, [TXT.KEY.custom_cli]), + E('div', { 'style': 'width:100%;' }, [ui.nodes.customCli]) + ]), + + // Layout row for the progress log output box + E('div', { 'style': 'margin-top:20px; padding:0 10px;' }, [ + E('label', { 'class': 'cbi-value-title', 'style': 'display:block; font-weight:bold; margin-bottom:8px; float:none; text-align:left; width:100%;' }, [TXT.KEY.progress]), + E('div', { 'style': 'width:100%;' }, [ui.nodes.output]) + ]), + + ui.nodes.warn, + + E('div', { 'style': 'text-align:right; margin-top:20px;' }, [ + ui.buttons.generate, + ui.buttons.save, + ui.buttons.export, + ui.buttons.close + ]) + ]) + ]) + ]); +}; + +/** + * Export the open wizard modal function to LuCI + */ +return L.Class.extend( + { + KEYTYPE: KEYTYPE, + executeAsynchronousKeyGen: executeAsynchronousKeyGen, + openKeyEditorModal: openKeyEditorModal, + openAutomatedPostKeyGenModal: openAutomatedPostKeyGenModal, + openKeyGenModal: openKeyGenModal + }); + diff --git a/luci-app-openvpn-plus/root/www/luci-static/resources/view/vpn/openvpn-status.js b/luci-app-openvpn-plus/root/www/luci-static/resources/view/vpn/openvpn-status.js new file mode 100755 index 000000000..85fee9be4 --- /dev/null +++ b/luci-app-openvpn-plus/root/www/luci-static/resources/view/vpn/openvpn-status.js @@ -0,0 +1,1875 @@ +/* + * Copyright (C) 2008-2026 The OpenWrt Project + * Copyright (C) 2026 Manfred Jaider + * + * This is free software, licensed under the Apache License, Version 2.0. + * See /LICENSE for more information. + * + * luci-app-openvpn-plus : shared status class + * /www/luci-static/resources/view/vpn/openvpn-status.js + * + * 1. --- TEXT & CONSTANTS --- ..... Global translations and constants + * 2. --- HELPER --- ............... Router connections and file setup + * 3. --- STATUS VIEW --- .......... Live statistics and traffic tables + */ + +/* global E */ +'use strict'; + +/* + * --- TEXT & CONSTANTS --- + */ +const TXT = { + INFO: { + active_pids: _('Active PIDs'), + aggregated_rx: _('Aggregated RX'), + aggregated_tx: _('Aggregated TX'), + creating: _('Creating...'), + client: _('Client'), + connected: _('Connected'), + server: _('Server'), + disabled: _('Disabled'), + error: _('Error'), + enabled: _('Enabled'), + instance: _('Instance'), + instance_x: _('Instance #'), + instances: _('Instances'), + yes: _('Yes'), + no: _('No'), + no_clients_connected: _('No clients connected'), + no_server_connected: _('No server connected'), + pending: _('Pending...'), + running: _('Running'), + since: _('Since'), + status: _('Status'), + session: _('Session'), + transfer: _('Transfer'), + encryption: _('Encryption'), + local_ip_port: _('Local IP / Port'), + no_inst: _('No instances configured'), + remote_ip_port: _('Remote IP / Port'), + type: _('Type'), + uptime: _('UpTime'), + vpn: _('VPN') + } +}; + +const CFG = Object.freeze({ + FILE: Object.freeze({ + dir_cfg: '/etc/openvpn/luci/', + proc_uptime: '/proc/uptime', + vpn_disabled_img: '/luci-static/resources/icons/tunnel_disabled.svg', + vpn_enabled_img: '/luci-static/resources/icons/tunnel.svg' + }), + LIBEXEC: Object.freeze({ + luci_app_openvpn_plus: '/usr/libexec/luci-app-openvpn-plus', + readstatus: 'readstatus', + getmac: 'getmac', + getnetstat: 'getnetstat', + ovpnver: 'ovpnver', + }), + CMD: Object.freeze({ + openvpn: 'openvpn', + }), +}) + +const OPENVPN = Object.freeze({ + ROLE: Object.freeze({ + SERVER: 'server', + CLIENT: 'client' + }), + PROTO: Object.freeze({ + TCP: 'tcp', + UDP: 'udp' + }), + PORT: Object.freeze({ + s1194: '1194' + }), + IPv4: Object.freeze({ + LOOPBACK: '127.0.0.1', + MASK24: '255.255.255.0' + }), + STATE: Object.freeze({ + disabled: 'disabled', + pending: 'pending', + active: 'active', + error: 'error' + }) +}); + +/** + * Default structure template for single OpenVPN running instances + */ +const INSTANCE_TEMPLATE = Object.freeze({ + id: '', + instNum: 1, + devName: 'tun0', + displayName: '', + ddns: '', + role: OPENVPN.ROLE.SERVER, + port: 0, + portExtern: 0, + proto: 'udp', + smartFirewall: false, + serverSiteToSite: false, + cipher: '-', + localIp: '0.0.0.0', + remoteClient: '-', + remoteSubnets: null, + loopbackServerId: null, + confContent: '', + isRunning: false, + pid: '-', + startTime: 0, + clientRefresh: 60, + netstat: null, + useLogFilter: false, + connectedClients: null +}); + +/** + * Default structure for connectedClients + */ +const CLIENT_TEMPLATE = Object.freeze({ + commonName: '', + realAddress: '', + bytesReceived: 0, + bytesSent: 0, + connectedSince: '', + timeMs: 0, +}); + +/* + * --- HELPER --- + */ + + +/** + * Caches static configuration structures to optimize system flash access + */ +const configAssetCache = {}; +const statusFileCache = {}; + +const sanitizeInputText = function (value) { + const rawText = value ? String(value).trim() : ''; + return rawText.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); +}; + +/** + * Declare the native file read RPC once globally (LuCI caches this definition) + */ +const nativeFileReadRpc = L.rpc.declare({ + object: 'file', + method: 'read', + params: ['path'] +}); + +/** + * Native RPC wrapper to read a file as a plain string (replaces L.fs.read cleanly) + */ +const rpcReadFile = async function (path) { + const res = await L.resolveDefault(nativeFileReadRpc(path), null); + if (res && typeof res === 'object' && typeof res.data === 'string') { + return res.data; + } + return null; +}; + +/** + * Global single registration for file statistics (captures the full stat object) + */ +const nativeFileStatRpc = L.rpc.declare({ + object: 'file', + method: 'stat', + params: ['path'] +}); + +/** + * Native RPC wrapper to check file statistics (replaces L.fs.stat cleanly) + */ +const rpcStatFile = async function (filepath) { + const res = await L.resolveDefault(nativeFileStatRpc(filepath), {}); + if (res && typeof res === 'object' && (typeof res.type === 'string' || typeof res.mtime === 'number')) { + return res; + } + return null; +}; + +/** + * Declare the native file exec RPC once globally (LuCI caches this definition) + */ +const nativeFileExecRpc = L.rpc.declare({ + object: 'file', + method: 'exec', + params: ['command', 'params'] +}); + +/** + * Wrapper for /usr/libexec/luci-app-openvpn-plus using the native ubus RPC directly + */ +const luci_app_openvpn_plus = async function (args) { + const extraArgs = (typeof args === 'string') ? [args] : (Array.isArray(args) ? args : []); + const res = await L.resolveDefault(nativeFileExecRpc(CFG.LIBEXEC.luci_app_openvpn_plus, extraArgs), {}); + if (res && typeof res === 'object' && typeof res.stdout === 'string') { + return res.stdout.trim(); + } + return ''; +}; + +/** + * Declare the native service list RPC once globally (LuCI caches this definition) + */ +const nativeServiceListRpc = L.rpc.declare({ + object: 'service', + method: 'list', + params: ['name'], + expect: { 'openvpn': {} } +}); + +/** + * Gets the active status of the OpenVPN service from ubus (replaces 'sh -c ubus' shell calls). + */ +const rpcGetServiceStatus = async function () { + const res = await L.resolveDefault(nativeServiceListRpc(CFG.CMD.openvpn), {}); + if (res && typeof res === 'object') { + return res; + } + return {}; +}; + +/** + * Gets the active dev name from uci (with default fallback) + */ +const getDevName = function (id, instNum, newinstance) { + const defaultDevName = 'tun' + String(instNum - 1); + if (newinstance) { + // on a new instance devname ist not set in uci + return defaultDevName; + } else { + const devName = L.uci.get(CFG.CMD.openvpn, id, 'devname'); + return devName || defaultDevName; + } +} + +/** + * Checks if an OpenVPN instance is enabled + */ +const isInstanceEnabled = function (instance_id) { + return L.uci.get(CFG.CMD.openvpn, instance_id, 'enabled') === '1'; +}; + +/** + * Checks if at least one OpenVPN instance is enabled in the configuration array + */ +const isAnyInstanceEnabled = function (ovpnUciSections) { + const ovpnSections = Array.isArray(ovpnUciSections) ? ovpnUciSections : []; + for (let i = 0; i < ovpnSections.length; i++) { + if (ovpnSections[i] && ovpnSections[i]['.name'] && isInstanceEnabled(ovpnSections[i]['.name'])) { + return true; + } + } + return false; +}; + +const openVpnInfo = { + macAddress: 0x0000, + version: null, + openssl: null, + openssl_type: null, + doc: null, +} + +/** + * Checks if at least one OpenVPN instance is enabled in the configuration array + */ +const getOpenVpnInfo = async function () { + + // get mac address + let rawMac = await luci_app_openvpn_plus([CFG.LIBEXEC.getmac]); + + if (rawMac.length > 0 && rawMac !== '00:00:00:00:00:00') { + const cleanMac = rawMac.replace(/[^a-fA-F0-9]/g, ''); + + // Isolate the last 3 hex characters (12-bit entropy seed) + if (cleanMac.length >= 3) { + const lastThreeHex = cleanMac.substring(cleanMac.length - 3); + + // Save into the static memory register for all future allocations + const parsedSeed = parseInt(lastThreeHex, 16); + openVpnInfo.macAddress = !isNaN(parsedSeed) ? parsedSeed : 0x0000; + } + } + + // get ovpn version + const rawVersion = await luci_app_openvpn_plus([CFG.LIBEXEC.ovpnver]); + if (rawVersion.length > 0) { + // Looks for "OpenVPN" followed by spaces, and stops at the first space or bracket. + // This safely ignores git tags like [git:DSM7-...] or OS tags. + const ovpnMatch = rawVersion.match(/OpenVPN\s+([0-9.]+)/); + if (ovpnMatch && ovpnMatch[1]) { + openVpnInfo.version = ovpnMatch[1]; + } + + // This searches for keywords like OpenSSL, mbedTLS, or wolfSSL followed by their numbers. + const sslMatch = rawVersion.match(/(OpenSSL|mbedTLS|wolfSSL)\s+([0-9a-zA-Z.]+)/i); + if (sslMatch) { + // sslMatch[1] is the name (e.g. "OpenSSL"), sslMatch[2] is the version (e.g. "3.5.8") + openVpnInfo.openssl = sslMatch[2]; + openVpnInfo.openssl_type = sslMatch[1]; + } + + // Looks for "DCO version:". If it finds "N/A", we keep the default 'Disabled' state. + const dcoMatch = rawVersion.match(/DCO version:\s+([0-9.]+)/); + if (dcoMatch && dcoMatch[1]) { + openVpnInfo.dco = dcoMatch[1]; + } + } +} + +/** + * Reads system uptime from the proc filesystem asynchronously + */ +const getSystemUptime = async function () { + const rawUptime = await rpcReadFile(CFG.FILE.proc_uptime); + // Split the output string by spaces to separate uptime and idle time + const partsUptime = String(rawUptime || '0').trim().split(/\s+/); + const uptime = (partsUptime && partsUptime[0]) ? parseFloat(partsUptime[0]) : 0; + return uptime; +}; + +/** + * Reads network statistics using parallel RPC requests and updates each instance object + */ +const refreshNetStat = async function (instances) { + try { + // Check if the input is a valid array with data + if (!Array.isArray(instances) || instances.length === 0) { + return; + } + + // Reset all netstat fields to a default empty structure first + for (let i = 0; i < instances.length; i++) { + instances[i].netstat = { + rxBytes: 0, + txBytes: 0, + rxPkts: 0, + txPkts: 0, + hasData: false + }; + } + + const promises = []; + + // Step 1: Start RPC requests for running instances at the same time + for (let j = 0; j < instances.length; j++) { + const inst = instances[j]; + if (inst && inst.isRunning === true && inst.devName) { + promises.push(luci_app_openvpn_plus([CFG.LIBEXEC.getnetstat, inst.devName])); + } + } + + // Step 2: Wait for all parallel backend calls to finish + const results = await Promise.all(promises); + + // Step 3: Parse the results and map them directly into the correct instance + for (let k = 0; k < results.length; k++) { + const res_getnetstat = results[k] ? String(results[k]).trim() : ''; + const partsMetrics = res_getnetstat.split('|'); + + // Check if the backend returned all 6 fields (rxb|txb|rxp|txp|status|iface) + if (partsMetrics && partsMetrics.length === 6) { + const ifaceName = partsMetrics[5]; + const isUp = (partsMetrics[4] === 'true'); + + // Find the matching instance by comparing the device name + for (let m = 0; m < instances.length; m++) { + const targetInst = instances[m]; + const expectedDev = targetInst.devName; + + if (expectedDev === ifaceName) { + targetInst.netstat.rxBytes = parseInt(partsMetrics[0], 10) || 0; + targetInst.netstat.txBytes = parseInt(partsMetrics[1], 10) || 0; + targetInst.netstat.rxPkts = parseInt(partsMetrics[2], 10) || 0; + targetInst.netstat.txPkts = parseInt(partsMetrics[3], 10) || 0; + targetInst.netstat.hasData = isUp; + break; + } + } + } + } + + // Step 4: Sanity check to fix DCO kernel sync delays against active session bytes + for (let n = 0; n < instances.length; n++) { + const activeInst = instances[n]; + + // Only compare if the instance is running and has connected clients + if (activeInst && activeInst.isRunning === true && Array.isArray(activeInst.connectedClients)) { + let totalClientRx = 0; + let totalClientTx = 0; + + // Sum up the bytes from all currently connected clients + for (let c = 0; c < activeInst.connectedClients.length; c++) { + const client = activeInst.connectedClients[c]; + if (client) { + totalClientRx += client.bytesReceived || 0; + totalClientTx += client.bytesSent || 0; + } + } + + // If kernel statistics are lower than session bytes, force the session values + if (activeInst.netstat.rxBytes < totalClientRx) { + activeInst.netstat.rxBytes = totalClientRx; + activeInst.netstat.hasData = true; + } + if (activeInst.netstat.txBytes < totalClientTx) { + activeInst.netstat.txBytes = totalClientTx; + activeInst.netstat.hasData = true; + } + } + } + + } catch (err) { + console.error('Error refreshing network data:', err); + } +}; + + +/** + * Parses client IP addresses from the OpenVPN status log text file + */ +const parseConnectedClients = function (statusContent, inst) { + const rawClients = []; + if (!statusContent) { + return rawClients; + } + const lines = statusContent.split('\n'); + const connectedClients = []; + + if (inst.role === OPENVPN.ROLE.CLIENT) { + + let logDateStr = ''; + let timestampMs = 0; + let bytesRxInt = 0; + let bytesTxInt = 0; + + // Loop through client stats lines to extract bytes and update time + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (line.length === 0) continue; + + // Extract the log update timestamp line + if (line.indexOf('Updated,') === 0) { + const datePart = line.split(',')[1]; + if (datePart) { + const parsedDate = new Date(datePart.trim().replace(/-/g, '/')); + if (!isNaN(parsedDate.getTime())) { + logDateStr = parsedDate.toLocaleString(); + timestampMs = parsedDate.getTime(); + } + } + } + // Extract read bytes (Bytes received from server perspective) + else if (line.indexOf('TCP/UDP read bytes,') === 0) { + bytesRxInt = parseInt(line.split(',')[1], 10) || 0; + } + // Extract write bytes (Bytes sent to server perspective) + else if (line.indexOf('TCP/UDP write bytes,') === 0) { + bytesTxInt = parseInt(line.split(',')[1], 10) || 0; + } + } + + // Read the persistent server common name directly from the UCI storage layer + const serverCN = L.uci.get(CFG.CMD.openvpn, inst.id, 'server_common_name') || 'Remote Server'; + + // Push the client structure into the return array connectedClients + const client = Object.assign({}, CLIENT_TEMPLATE, { + commonName: serverCN, + realAddress: inst.remoteClient, + bytesReceived: bytesRxInt, + bytesSent: bytesTxInt, + connectedSince: logDateStr || new Date().toLocaleString(), + timeMs: timestampMs || new Date().getTime() + }); + connectedClients.push(client); + + } else { + + let insideClientList = false; + + // Loop through rows to extract all initial raw client connections + for (let c = 0; c < lines.length; c++) { + const line = lines[c].trim(); + + if (line.indexOf('OpenVPN CLIENT LIST') !== -1 || line.indexOf('Common Name,Real Address') !== -1) { + insideClientList = true; + continue; + } + + if (line.indexOf('ROUTING TABLE') !== -1 || line.indexOf('GLOBAL STATS') !== -1 || line.indexOf('END') === 0) { + break; + } + + if (insideClientList && line.length > 0) { + const tokens = line.split(','); + + if (tokens.length >= 5 && tokens[0] !== 'Common Name' && tokens[0] !== 'Updated') { + const rawRealAddress = tokens[1].trim(); + const cleanRealAddress = rawRealAddress.replace(/[[\]]/g, ''); + + let formattedDate = tokens[4].trim(); + let timestampMs = 0; + const parsedClientDate = new Date(formattedDate.replace(/-/g, '/')); + if (!isNaN(parsedClientDate.getTime())) { + formattedDate = parsedClientDate.toLocaleString(); + timestampMs = parsedClientDate.getTime(); + } + + const bytesRxInt = parseInt(tokens[2].trim(), 10) || 0; + const bytesTxInt = parseInt(tokens[3].trim(), 10) || 0; + + const client = Object.assign({}, CLIENT_TEMPLATE, { + commonName: tokens[0].trim(), + realAddress: cleanRealAddress, + bytesReceived: bytesRxInt, + bytesSent: bytesTxInt, + connectedSince: formattedDate, + timeMs: timestampMs + }); + rawClients.push(client); + } + } + } + const youngestTimePerIp = {}; + + // PASS 1: Find the newest connection timestamp for each IP address + for (let i = 0; i < rawClients.length; i++) { + const client = rawClients[i]; + + // Extract base IP address without port + let baseIp = client.realAddress; + if (client.realAddress.indexOf('[') !== -1) { + const ipv6Match = client.realAddress.match(/^\[(.*)\]:\d+$/); + baseIp = ipv6Match ? ipv6Match[1].trim() : baseIp; + } else { + const lastColonIdx = client.realAddress.lastIndexOf(':'); + baseIp = (lastColonIdx !== -1) ? client.realAddress.substring(0, lastColonIdx).trim() : client.realAddress; + } + + // Save the youngest timestamp for this IP + if (baseIp && (!youngestTimePerIp[baseIp] || client.timeMs > youngestTimePerIp[baseIp])) { + youngestTimePerIp[baseIp] = client.timeMs; + } + } + + // PASS 2: Only keep the newest connection for each IP address + for (let j = 0; j < rawClients.length; j++) { + const targetClient = rawClients[j]; + + // Always drop UNDEF connections immediately + if (targetClient.commonName === 'UNDEF') { + continue; + } + + // Extract base IP address without port + let baseIp = targetClient.realAddress; + if (targetClient.realAddress.indexOf('[') !== -1) { + const ipv6Match = targetClient.realAddress.match(/^\[(.*)\]:\d+$/); + baseIp = ipv6Match ? ipv6Match[1].trim() : baseIp; + } else { + const lastColonIdx = targetClient.realAddress.lastIndexOf(':'); + baseIp = (lastColonIdx !== -1) ? targetClient.realAddress.substring(0, lastColonIdx).trim() : targetClient.realAddress; + } + + // Drop the connection if there is a newer connection for this IP + if (baseIp && youngestTimePerIp[baseIp] && targetClient.timeMs < youngestTimePerIp[baseIp]) { + continue; + } + + connectedClients.push(targetClient); + } + } + + return connectedClients; +}; + +/** + * Calculates a unique router-individual port using hexadecimal grid markers. + */ +const calcPortFromId = function (instance_id, optional_instance_number) { + let instNum; + if (optional_instance_number) { + instNum = (typeof optional_instance_number === 'number') ? optional_instance_number : parseInt(optional_instance_number, 10); + if (isNaN(instNum)) { + instNum = getInstanceNumber(instance_id); + } + } else { + instNum = getInstanceNumber(instance_id); + } + + // Read instantly from the static OnLoad memory register + if (openVpnInfo.macAddress !== null) { + return 0xE000 + openVpnInfo.macAddress + instNum; + } + + // Unwrapped hardware fallback if the interface was missing during OnLoad + return 0xE000 + instNum; +}; + +/** + * Extracts the OpenVPN port number from the configuration text safely + */ +const parsePortFromConfig = function (role, content) { + if (!content) return null; + + let lines + if (Array.isArray(content)) { + lines = content; + } else { + lines = content.split('\n'); + } + + for (let i = 0; i < lines.length; i++) { + let line = lines[i].trim(); + + // Skip empty lines and full line comment markers + if (line.length === 0 || line.charAt(0) === '#' || line.charAt(0) === ';') { + continue; + } + + // Strip inline comments from the end of the lines + const hashIdx = line.indexOf('#'); + if (hashIdx !== -1) { + line = line.substring(0, hashIdx).trim(); + } + + const semiIdx = line.indexOf(';'); + if (semiIdx !== -1) { + line = line.substring(0, semiIdx).trim(); + } + + // Split spaces or tabs into clean text tokens + const tokens = line.split(/\s+/); + if (tokens.length < 2) { + continue; + } + + const directive = tokens[0].toLowerCase(); + + // Server profile check: look for "port number" syntax + if (role === OPENVPN.ROLE.SERVER && directive === 'port') { + const portNum = parseInt(tokens[1], 10); + if (!isNaN(portNum) && portNum > 0 && portNum <= 65535) { + return portNum; + } + } + + // Client profile check: look for "remote host port" syntax + if (role === OPENVPN.ROLE.CLIENT && directive === 'remote' && tokens.length >= 3) { + const portNum = parseInt(tokens[2], 10); + if (!isNaN(portNum) && portNum > 0 && portNum <= 65535) { + return portNum; + } + } + } + + return null; +}; + +/** + * Extracts the OpenVPN protocol string (udp/tcp) from the configuration text safely + */ +const parseProtoFromConfig = function (content) { + if (!content) return OPENVPN.PROTO.UDP; + + let lines + if (Array.isArray(content)) { + lines = content; + } else { + lines = content.split('\n'); + } + + for (let i = 0; i < lines.length; i++) { + let line = lines[i].trim(); + if (line.length === 0 || line.charAt(0) === '#' || line.charAt(0) === ';') { + continue; + } + const hashIdx = line.indexOf('#'); + if (hashIdx !== -1) line = line.substring(0, hashIdx).trim(); + const semiIdx = line.indexOf(';'); + if (semiIdx !== -1) line = line.substring(0, semiIdx).trim(); + + const tokens = line.split(/\s+/); + if (tokens.length < 2) continue; + + // Look for the "proto" directive line + if (tokens[0].toLowerCase() === 'proto') { + const protoVal = tokens[1].toLowerCase(); + // Handle standard openvpn proto values (udp, tcp, udp4, tcp4, udp6, tcp6) + if (protoVal.indexOf(OPENVPN.PROTO.TCP) !== -1) return OPENVPN.PROTO.TCP; + if (protoVal.indexOf(OPENVPN.PROTO.UDP) !== -1) return OPENVPN.PROTO.UDP; + return protoVal; + } + } + return OPENVPN.PROTO.UDP; // Default fallback matching standard OpenVPN defaults +}; + +/** + * Safe parser to get ONLY the active dynamic DDNS domain name from the configuration file text + */ +const parseDdnsFromConfig = function (content) { + if (!content) { + return ''; + } + + // Example: setenv DDNS "my.ddns.net" + + // Match line with setenv DDNS followed by optional single or double quotes + const ddnsMatch = content.match(/^setenv\s+DDNS\s+["']?([^"'\s\r\n]+)["']?$/m); + + if (ddnsMatch && ddnsMatch[1]) { + return ddnsMatch[1].trim(); + } + + return ''; +}; + +/** + * Formats any IP and netmask cleanly into a mathematically correct NET IP/XX CIDR boundary (e.g. 192.168.1.5 -> 192.168.1.0/24). + */ +const netIPCIDR = function (raw_ip, raw_mask) { + if (!raw_ip) return ''; + + // (raw_mask === 'cidr') -> return NET IPv6/64 or NET IPv4/24 + // (raw_mask === null) -> return NET IP without CIDR + + // Flag to detect if the input IP already contains its own mask boundary + const hasInlineCidr = raw_ip.includes('/'); + + // --- IPv6 --- + + if (raw_ip.includes(':')) { + let ip6Part = raw_ip; + let prefix6 = '64'; + + // Extract prefix if it is already inline (e.g. "fd00:db8::5/64") + if (hasInlineCidr) { + const parts6 = raw_ip.split('/'); + ip6Part = parts6[0]; + prefix6 = parts6[1]; + } else if (raw_mask && raw_mask !== 'cidr') { + prefix6 = raw_mask; + } + + // Expands short notation, splits into 16-bit blocks and zeroes out the host portion + let blocks = []; + + // Handle double-colon expansion safely to get exactly 8 blocks + if (ip6Part.includes('::')) { + const splitColons = ip6Part.split('::'); + const leftBlocks = splitColons[0] ? splitColons[0].split(':') : []; + const rightBlocks = splitColons[1] ? splitColons[1].split(':') : []; + const missingCount = 8 - (leftBlocks.length + rightBlocks.length); + + for (let l = 0; l < leftBlocks.length; l++) blocks.push(leftBlocks[l]); + for (let m = 0; m < missingCount; m++) blocks.push('0'); + for (let r = 0; r < rightBlocks.length; r++) blocks.push(rightBlocks[r]); + } else { + blocks = ip6Part.split(':'); + } + + // Normalize block hex values to numbers for bitwise calculations + const parsedBlocks = []; + for (let b = 0; b < 8; b++) { + parsedBlocks.push(blocks[b] ? parseInt(blocks[b], 16) : 0); + } + + // Calculate numeric bits to zero out from the back based on the prefix length + const numPrefix6 = parseInt(prefix6, 10) || 64; + for (let bit = 0; bit < 128; bit++) { + if (bit >= numPrefix6) { + const targetBlock = Math.floor(bit / 16); + const targetBitInBlock = 15 - (bit % 16); + parsedBlocks[targetBlock] &= ~(1 << targetBitInBlock); + } + } + + // Rebuild clean hex blocks stream + const cleanedBlocks = []; + for (let c = 0; c < 8; c++) { + cleanedBlocks.push(parsedBlocks[c].toString(16)); + } + + // Shorten the output into standard zero compressed notation (::) for clean display + let cleanNetIp6 = cleanedBlocks.join(':').replace(/(^|:)0:0(:0)*(:|$)/, '::'); + if (cleanNetIp6 === ':') cleanNetIp6 = '::'; + + if (!raw_mask && !hasInlineCidr) { + // Return NET IPv6 without CIDR suffix + return cleanNetIp6; + } + // Return NET IPv6 with CIDR suffix + return cleanNetIp6 + '/' + numPrefix6; + } + + // --- IPv4 --- + + // Bitwise AND network identification matching + const calculateNetId = function (ip, mask = OPENVPN.IPv4.MASK24) { + const cleanMask = (mask === null || mask === '') ? OPENVPN.IPv4.MASK24 : mask; + const ipOctets = ip.split('.').map(Number); + let maskOctets = cleanMask.split('.').map(Number); + // 1. If the input IP is broken or incomplete, bypass calculations and return the original string safely [s1] + if (ipOctets.length < 4) { + return ip; + } + // 2. If the mask is broken, override it immediately with the clean 24-bit matrix [s1] + if (maskOctets.length < 4) { + maskOctets = OPENVPN.IPv4.MASK24.split('.').map(Number); + } + // Symmetrical bitwise calculation layers + const net1 = ipOctets[0] & maskOctets[0]; + const net2 = ipOctets[1] & maskOctets[1]; + const net3 = ipOctets[2] & maskOctets[2]; + const net4 = ipOctets[3] & maskOctets[3]; + return net1 + '.' + net2 + '.' + net3 + '.' + net4; + }; + + // Iterative bitmask to bitcount prefix translation + const getCidrNet = function (ip, mask) { + const maskOctets = mask ? mask.split('.').map(Number) : []; + let cidr = 0; + if (maskOctets.length < 4) { + return ip + '/24'; + } + // The for-loop iterates over each octet + for (let i = 0; i < maskOctets.length; i++) { + const octet = maskOctets[i]; + if (octet === 255) { + cidr += 8; + } else { + if (octet === 254) cidr += 7; + else if (octet === 252) cidr += 6; + else if (octet === 248) cidr += 5; + else if (octet === 240) cidr += 4; + else if (octet === 224) cidr += 3; + else if (octet === 192) cidr += 2; + else if (octet === 128) cidr += 1; + // Break instantly if octet is smaller than 255 or hits 0 + break; + } + } + // Symmetrical validation: If calculation fails or hits an invalid mask (like 0.0.0.0), enforce 24 + if (cidr === 0) { + cidr = 24; + } + return ip + '/' + cidr; + }; + + let inputIp = raw_ip; + let inputMask = raw_mask; + + // Handle extraction if input brings an inline prefix (e.g. "192.168.1.5/24") + if (hasInlineCidr) { + const parts4 = raw_ip.split('/'); + inputIp = parts4[0]; + const inlinePrefix = parseInt(parts4[1], 10) || 24; + + // Rebuild explicit netmask format string from the prefix + const maskArray = [0, 0, 0, 0]; + for (let bit4 = 0; bit4 < 32; bit4++) { + if (bit4 < inlinePrefix) { + maskArray[Math.floor(bit4 / 8)] |= (1 << (7 - (bit4 % 8))); + } + } + inputMask = maskArray.join('.'); + } + + if (inputMask === 'cidr' && !hasInlineCidr) { + inputMask = OPENVPN.IPv4.MASK24; + } + + if (!raw_mask && !hasInlineCidr) { + // Strict baseline extraction: Returns pure clean NET IP block without trailing slash + return calculateNetId(inputIp, inputMask); + } else { + // Return full clean NET IP paired with its calculated CIDR prefix + const cleanNet = calculateNetId(inputIp, inputMask); + return getCidrNet(cleanNet, inputMask); + } +}; + +/** + * Parses the OpenVPN configuration text line by line to extract remote subnets and format them into correct CIDR notation using the netIPCIDR function. + */ +const parseRemoteSubnets = function (content, role, noCIDR) { + if (!content) return null; + const remoteSubnets = []; + + let lines + if (Array.isArray(content)) { + lines = content; + } else { + lines = content.split('\n'); + } + + if (role === OPENVPN.ROLE.SERVER) { + + for (let i = 0; i < lines.length; i++) { + // Trim leading/trailing spaces and skip empty or commented out lines + const line = lines[i].trim(); + if (line === '' || line.indexOf('#') === 0 || line.indexOf(';') === 0) continue; + + // Match "route " or "route " + if (line.indexOf('route ') === 0) { + const parts = line.split(/\s+/); // Splits by any consecutive spaces + if (parts.length >= 2) { + const foundIp = parts[1]; + let foundMask = ''; + + // If it is IPv4 with a separate subnet mask (e.g., "route 192.168.10.0 255.255.255.0") + if (parts.length >= 3 && !foundIp.includes('/') && !foundIp.includes(':')) { + foundMask = parts[2]; + } + // Convert to correct CIDR format and push to array safely if not duplicate + let foundSubnet; + if (noCIDR === true) { + foundSubnet = netIPCIDR(foundIp, null); + } else { + foundSubnet = netIPCIDR(foundIp, foundMask); + } + if (foundSubnet && !remoteSubnets.includes(foundSubnet)) { + remoteSubnets.push(foundSubnet); + } + } + } + } + } + else if (role === OPENVPN.ROLE.CLIENT) { + + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (line === '' || line.indexOf('#') === 0 || line.indexOf(';') === 0) continue; + + // Match "setenv remotelan " or "setenv remotelan " + if (line.indexOf('setenv remotelan ') === 0) { + const parts = line.split(/\s+/); + if (parts.length >= 3) { + const foundIp = parts[2]; + let foundMask = ''; + + // If it is IPv4 with a separate subnet mask (e.g., "setenv remotelan 192.168.20.0 255.255.255.0") + if (parts.length >= 4 && !foundIp.includes('/') && !foundIp.includes(':')) { + foundMask = parts[3]; + } + let foundSubnet; + if (noCIDR === true) { + foundSubnet = netIPCIDR(foundIp, null); + } else { + foundSubnet = netIPCIDR(foundIp, foundMask); + } + + if (foundSubnet && !remoteSubnets.includes(foundSubnet)) { + remoteSubnets.push(foundSubnet); + } + } + } + } + } + return remoteSubnets; +}; + +/** + * Safe parser to get ONLY the active status refresh seconds from the configuration file text + */ +const parseClientRefresh = function (content, instNum) { + if (!content) { + return 5; + } + + // Example: status /tmp/run/openvpn.instance1.status 5 + + // Match line starting with status, path, instance filename, space, and digits + const patternStr = '^status\\s+\\S+openvpn\\.instance' + instNum + '\\.status\\s+(\\d+)(?:\\s|$)'; + const refreshRegex = new RegExp(patternStr, 'm'); + const refreshMatch = content.match(refreshRegex); + + if (refreshMatch && refreshMatch[1]) { + return parseInt(refreshMatch[1], 10) || 5; + } + + return 5; +}; + +/** + * Check if site-to-site configuration is explicitly turned on + */ +const parseSiteToSite = function (content) { + const sitetosite_regex = /^[ \t]*setenv\s+sitetosite\s+on/mi; + return sitetosite_regex.test(content); +} + +const parseSmartFirewall = function (content) { + const sitetosite_regex = /^[ \t]*setenv\s+ovpnfirewall\s+on/mi; + return sitetosite_regex.test(content); +} + +/** + * Trigger ovpnPending state for 5 seconds + */ +const ovpnPending = { + isPending: false, + + // Activates the pending state. Stays permanent if the session is dead. + trigger: function () { + // 1. Abort immediately if the session expired (Autologout happened) + if (typeof L.session === 'object' && typeof L.session.isAlive === 'function') { + if (!L.session.isAlive()) { + this.isPending = true; + return; + } + } + // 2. Activates the pending state and automatically clears it after 5 seconds + if (this.isPending === false) { + this.isPending = true; + const self = this; + setTimeout(function () { + self.isPending = false; + }, 5000); + } + } +}; + +/** + * Parses the raw list of LuCI modifications to find changed instances + */ +const getPendingInstances = function (ovpnChanges) { + const pendingInstances = []; + if (!Array.isArray(ovpnChanges)) return pendingInstances; + + for (let i = 0; i < ovpnChanges.length; i++) { + const change = ovpnChanges[i]; + if (Array.isArray(change) && change.length >= 3) { + const operation = change[0]; + const sectionName = change[1]; + const optionName = change[2]; + + if (operation === 'set' && optionName === 'enabled' && sectionName) { + if (pendingInstances.indexOf(sectionName) === -1) { + pendingInstances.push(sectionName); + } + } + } + } + return pendingInstances; +}; + +/** + * Checks the overall running state of all OpenVPN profiles together + */ +const getCurrentOvpnState = function (ovpnUciSections, updatedInstances, pendingInstances) { + const uciEnabled = isAnyInstanceEnabled(ovpnUciSections); + if (!uciEnabled) { + return OPENVPN.STATE.disabled; + } + + // First check if pending state + if (ovpnPending.isPending === true) { + return OPENVPN.STATE.pending; + } + if (Array.isArray(pendingInstances) && pendingInstances.length > 0) { + return OPENVPN.STATE.pending; + } + // If the updatedInstances array from ubus is missing or empty during a refresh drop, return 'pending' + if (!updatedInstances || updatedInstances.length === 0) { + return OPENVPN.STATE.pending; + } + + let totalEnabledCount = 0; + let totalRunningCount = 0; + + for (let i = 0; i < updatedInstances.length; i++) { + const inst = updatedInstances[i]; + if (inst && isInstanceEnabled(inst.id)) { + totalEnabledCount++; + if (inst.isRunning === true) { + totalRunningCount++; + } + } + } + + if (totalEnabledCount > 0 && totalRunningCount === totalEnabledCount) { + return OPENVPN.STATE.active; + } + + // Returns error ONLY if an enabled instance is genuinely dead after a solid live read + return OPENVPN.STATE.error; +}; + +/** + * Safe parser to get the numeric instance index from a string ID. + */ +const getInstanceNumber = function (instance_id, default_number) { + if (!default_number) { + default_number = 1; + } else { + const fallbackNum = (typeof default_number === 'number') ? default_number : parseInt(default_number, 10); + const safeFallback = !isNaN(fallbackNum) ? fallbackNum : 1; + default_number = safeFallback; + } + if (typeof instance_id !== 'string' || !instance_id) { + return default_number; + } + const numMatch = instance_id.match(/\d+$/); + if (numMatch) { + const parsedNum = parseInt(numMatch[0], 10); + return !isNaN(parsedNum) ? parsedNum : default_number; + } + return default_number; +}; + +/** + * Compiles or load from cache the compiled INSTANCE_TEMPLATE configuration + */ +const getInstanceConfig = async function (id, instNum, role, path_conf) { + let currentMtimeConf = 0; + let currentSizeConf = 0; + + try { + const confStat = await rpcStatFile(path_conf); + if (confStat) { + if (typeof confStat.mtime === 'object' && confStat.mtime.sec) { + currentMtimeConf = parseInt(confStat.mtime.sec, 10) || 0; + } else if (typeof confStat.mtime === 'number') { + currentMtimeConf = Math.floor(confStat.mtime); + } + currentSizeConf = parseInt(confStat.size, 10) || 0; + + // Cache HIT path + if (configAssetCache[id] && + configAssetCache[id].mtime === currentMtimeConf && + configAssetCache[id].size === currentSizeConf) { + return Object.assign({}, configAssetCache[id].cachedBase); + } + } + } catch { + // Proceed to live compilation on error + } + + let confContent = sanitizeInputText(await rpcReadFile(path_conf)); + if (confContent.length === 0) { + // Signals readSingleInstanceStatus to execute early exit template + return null; + } + const lines = confContent.split('\n'); + const devName = getDevName(id, instNum, false); + + const detectedDdnsTarget = parseDdnsFromConfig(confContent); + const inst = Object.assign({}, INSTANCE_TEMPLATE, { + id: id, + instNum: instNum, + devName: devName, + role: role, + ddns: detectedDdnsTarget, + confContent: confContent, + connectedClients: [] + }); + + let currentPort = parsePortFromConfig(role, lines); + if (!currentPort || isNaN(currentPort)) { + currentPort = calcPortFromId(id, instNum); + } + inst.port = currentPort; + + const portExternMatch = inst.confContent.match(/^setenv\s+portextern\s+(\d+)/m); + inst.portExtern = portExternMatch ? parseInt(portExternMatch[1], 10) : currentPort; + inst.proto = parseProtoFromConfig(lines); + inst.clientRefresh = parseClientRefresh(inst.confContent, instNum); + + + if (role === OPENVPN.ROLE.SERVER) { + inst.smartFirewall = parseSmartFirewall(inst.confContent); + inst.serverSiteToSite = parseSiteToSite(inst.confContent); + } + if ((inst.serverSiteToSite || role === OPENVPN.ROLE.CLIENT)) { + inst.remoteSubnets = parseRemoteSubnets(lines, role, false); + } + + + // // check uci + // const uciSmartFirewall = L.uci.get(CFG.CMD.openvpn, id, CFG.CMD.ovpnfirewall) === '1'; + // if (inst.smartFirewall != uciSmartFirewall) { + // if (inst.smartFirewall === true) { + // await uciSetBackground(CFG.CMD.openvpn, id, CFG.CMD.ovpnfirewall, '1'); + // } else { + // await uciDelBackground(CFG.CMD.openvpn, id, CFG.CMD.ovpnfirewall); + // } + // } + // const uciSiteToSite = L.uci.get(CFG.CMD.openvpn, id, CFG.CMD.sitetosite) == '1'; + // if (inst.serverSiteToSite != uciSiteToSite) { + // if (inst.smartFirewall === true) { + // await uciSetBackground(CFG.CMD.openvpn, id, CFG.CMD.sitetosite, '1'); + // } else { + // await uciDelBackground(CFG.CMD.openvpn, id, CFG.CMD.sitetosite); + // } + // } + + inst.useLogFilter = inst.confContent.match(/setenv\s+logfilter\s+on/i) ? true : false; + + for (var i = 0; i < lines.length; i++) { + var cleanLine = lines[i].trim(); + + if (cleanLine.indexOf('cipher ') === 0) { + inst.cipher = cleanLine.replace('cipher ', '').trim().toUpperCase(); + } else if (cleanLine.indexOf('data-ciphers ') === 0) { + var dcParts = cleanLine.replace('data-ciphers ', '').trim().split(':'); + if (dcParts && dcParts[0]) { + inst.cipher = dcParts[0].trim().toUpperCase(); + } + } + + if (role === OPENVPN.ROLE.CLIENT && cleanLine.indexOf('remote ') === 0) { + var rParts = cleanLine.split(/\s+/); + var remoteIp = (rParts.length >= 2) ? rParts[1] : OPENVPN.IPv4.LOOPBACK; + var remotePort = (rParts.length >= 3) ? rParts[2] : OPENVPN.PORT.s1194; + inst.localIp = OPENVPN.IPv4.LOOPBACK; + inst.remoteClient = remoteIp + ':' + remotePort; + } else if (role === OPENVPN.ROLE.SERVER) { + if (cleanLine.indexOf('server ') === 0) { + var sParts = cleanLine.split(/\s+/); + if (sParts.length >= 2) inst.localIp = sParts[1].replace(/\.0$/, '.1'); + } + } + } + + if (currentMtimeConf > 0) { + configAssetCache[id] = { + mtime: currentMtimeConf, + size: currentSizeConf, + cachedBase: Object.assign({}, inst) + }; + } + + return inst; +}; + +/** + * Measures the process start uptime using the Linux /proc framework securely + */ +const getProcessStartTime = async function (id, pidVal, currentUptime) { + try { + // Read the file status metadata of the process folder inside the proc filesystem + const statObj = await rpcStatFile('/proc/' + pidVal); + + if (statObj && statObj.mtime) { + let rawMtimeSec = 0; + + // Extract the modification timestamp depending on the variable data type + if (typeof statObj.mtime === 'object' && statObj.mtime.sec) { + rawMtimeSec = parseInt(statObj.mtime.sec, 10) || 0; + } else if (typeof statObj.mtime === 'number') { + rawMtimeSec = Math.floor(statObj.mtime); + } else if (typeof statObj.mtime === 'string') { + rawMtimeSec = parseInt(statObj.mtime, 10) || 0; + } + + // CASE 1: The system works with simple uptime seconds (small timestamp under 1 billion) + if (rawMtimeSec < 1000000000) { + return Math.max(1, Math.floor(rawMtimeSec)); + } + + // CASE 2: The system uses real-world dates (large timestamp) + // Get the current real UNIX time from the browser/system clock + const currentUnixTime = Math.floor(new Date().getTime() / 1000); + const secondsAgo = currentUnixTime - rawMtimeSec; + + // Check if the calculated time makes sense within the current router uptime + // The process cannot be older than the router uptime and cannot be from the future + if (secondsAgo >= 0 && secondsAgo <= currentUptime) { + return Math.max(1, Math.floor(currentUptime - secondsAgo)); + } + + // CASE 3: Fallback for NTP clock jumps or time sync shifts + // If the time is invalid, the process started early during the boot phase. + // We return 5 seconds after boot to keep the dashboard stable. + return 5; + } + } catch { + // Silent catch + } + return 0; +}; + + +/** + * Read the connected client status file if necessary or use the statusFileCache + */ +const getConnectedClientsStatus = async function (inst) { + try { + const statusFilePath = '/tmp/run/openvpn.' + inst.id + '.status'; + const fileStat = await rpcStatFile(statusFilePath); + + const maxAllowedFileAge = inst.clientRefresh * 5; + + if (fileStat) { + let currentMtime = 0; + if (typeof fileStat.mtime === 'object' && fileStat.mtime.sec) { + currentMtime = parseInt(fileStat.mtime.sec, 10) || 0; + } else if (typeof fileStat.mtime === 'number') { + currentMtime = Math.floor(fileStat.mtime); + } + const currentSize = parseInt(fileStat.size, 10) || 0; + + if (!statusFileCache[inst.id]) { + statusFileCache[inst.id] = { mtime: 0, size: 0, parsedClients: [] }; + } + + const currentRouterUnixTime = Math.floor(new Date().getTime() / 1000); + const fileAgeSeconds = Math.abs(currentRouterUnixTime - currentMtime); + + // Invalidate cache immediately if openvpn daemon froze in deadlock + if (fileAgeSeconds > maxAllowedFileAge) { + statusFileCache[inst.id].parsedClients = []; + return []; + } + + // use cache if no change + if (statusFileCache[inst.id].mtime === currentMtime && + statusFileCache[inst.id].size === currentSize) { + return statusFileCache[inst.id].parsedClients; + } + + // change detected - parse status file + const statusContent = await luci_app_openvpn_plus([CFG.LIBEXEC.readstatus, inst.id]); + + if (statusContent.length > 0) { + const freshClients = parseConnectedClients(statusContent, inst); + + statusFileCache[inst.id].mtime = currentMtime; + statusFileCache[inst.id].size = currentSize; + statusFileCache[inst.id].parsedClients = freshClients; + + return freshClients; + } + } + } catch { + // Silent catch fallback execution + } + + if (statusFileCache[inst.id]) { + statusFileCache[inst.id].parsedClients = []; + } + return []; +}; + +/** + * Reads the active runtime status and telemetry fields + */ +const readSingleInstanceStatus = async function (id, instancesObj, currentUptime) { + const instNum = getInstanceNumber(id); + const path_conf = CFG.FILE.dir_cfg + id + '.conf'; + const role = L.uci.get(CFG.CMD.openvpn, id, 'role') || OPENVPN.ROLE.SERVER; + const devName = getDevName(id, instNum, false); + + // Step 1: Get instance configuration profile from file or cache + let inst = await getInstanceConfig(id, instNum, role, path_conf); + + // Early exit if no instance configuration found + if (inst === null) { + return Object.assign({}, INSTANCE_TEMPLATE, { + id: id, + instNum: instNum, + devName: devName, + role: role, + isRunning: false, + pid: '-', + connectedClients: [] + }); + } + + // Get process running state and pid from service list + const runtimeInstance = instancesObj[id] || {}; + const isRunning = (runtimeInstance.running === true); + const pidVal = isRunning ? (runtimeInstance.pid || '-') : '-'; + + inst.isRunning = isRunning; + inst.pid = pidVal; + + // Exit if the server daemon process is offline + if (isRunning === false || pidVal === '-') { + inst.startTime = 0; + inst.connectedClients = []; + return inst; + } + + // Step 2: Measure dynamic runtime process uptime via proc stats + inst.startTime = await getProcessStartTime(id, pidVal, currentUptime); + + // Step 3: Resolve connected users matrix buffer via cached shell routine + inst.connectedClients = await getConnectedClientsStatus(inst); + + return inst; +}; + +/** + * Reads the active runtime status and telemetry fields for all instances + */ +const readInstanceStatus = async function (ovpnUciSections, instancesObj, systemUptime) { + try { + const instPromises = []; + const currentUptime = parseFloat(systemUptime) || 0; + const ovpnSections = Array.isArray(ovpnUciSections) ? ovpnUciSections : []; + + // Modern, flat iterator to populate the background scanning registers + for (const section of ovpnSections) { + if (!section) continue; + const id = section['.name']; + + // Push the independent worker promises into the central pipeline array container + instPromises.push(readSingleInstanceStatus(id, instancesObj, currentUptime)); + } + // Fire all configuration scans simultaneously in parallel for rapid modal loads + return await Promise.all(instPromises); + } + catch { + return null; + } +}; + + +/* + * --- STATUS VIEW --- + */ + + +/** + * Renders the remote node network column context based on instance roles + */ +const renderRemoteNode = function (role, isRunning, connectedClients, protoStr, netstat) { + if (!isRunning) { + return E('span', { 'style': 'color:color-mix(in srgb, var(--text-color-medium, #71717a) 85%, #0055ff);' }, '-'); + } + if (!Array.isArray(connectedClients) || connectedClients.length === 0) { + const no_connencted = (role === OPENVPN.ROLE.CLIENT) ? TXT.INFO.no_server_connected : TXT.INFO.no_clients_connected; + return E('span', { 'style': 'font-style: italic; color:color-mix(in srgb, var(--text-color-medium, #71717a) 85%, #0055ff);' }, no_connencted); + } + + const remoteName = (role === OPENVPN.ROLE.CLIENT) ? TXT.INFO.server : TXT.INFO.client; + const clientRows = []; + + connectedClients.forEach(function (client) { + const rawAddress = client.realAddress || ''; + let displayAddress = rawAddress; + let currentProto = protoStr; + + // Check if address contains a colon (like udp4:192.168.1.1:51820) + if (rawAddress.indexOf(':') !== -1) { + const parts = rawAddress.split(':'); + // Get the first part and convert to lowercase for the check + const firstPart = parts[0].toLowerCase(); + // If first part is a protocol prefix, process and clean it + if (firstPart.indexOf(OPENVPN.PROTO.UDP) === 0 || firstPart.indexOf(OPENVPN.PROTO.TCP) === 0) { + // Remove everything after the dash (e.g. "tcp4-server" becomes "tcp4") + currentProto = firstPart.split('-')[0]; + displayAddress = parts.slice(1).join(':'); // Keep only IP and port + } + } + + clientRows.push(E('div', { + 'style': 'font-family: var(--font-monospace, monospace); margin: 0; padding: 0; border: none !important; line-height: 1.3;' + }, [ + E('strong', {}, [ + E('span', { 'style': 'color: var(--primary-color-high, #3b82f6);' }, displayAddress), + E('span', { 'style': 'color:color-mix(in srgb, var(--text-color-medium, #71717a) 85%, #0055ff); font-weight: normal' }, '/' + currentProto) + ]), + E('small', { + 'style': 'display: block; font-size: 11px; color:color-mix(in srgb, var(--text-color-medium, #71717a) 85%, #0055ff); margin-top: 0px;' + }, [ + remoteName + ': ', + E('strong', { 'style': 'color: var(--success-color-high, #10b981); font-weight: bold;' }, client.commonName) + ]), + E('small', { + 'style': 'display: block; font-size: 11px; color:color-mix(in srgb, var(--text-color-medium, #71717a) 85%, #0055ff); margin-top: 0px;' + }, TXT.INFO.since + ': ' + client.connectedSince), + E('small', { + 'style': 'display: block; font-size: 11px; color:color-mix(in srgb, var(--text-color-medium, #71717a) 85%, #0055ff); margin-top: 0px;' + }, TXT.INFO.session + ': ' + formatStatusBytes(client.bytesReceived) + ' / ' + formatStatusBytes(client.bytesSent)) + ])); + + }); + + return E('div', { 'style': 'display: block; margin: 0; padding: 0; border: none !important;' }, clientRows); +}; + +/** + * Formats a raw byte count into a standard OpenWRT binary data size string (B, KiB, MiB, GiB, or TiB) + */ +const formatStatusBytes = function (b) { + if (b >= 1099511627776) { + return (b / 1099511627776).toFixed(2) + ' TiB'; + } + if (b >= 1073741824) { + return (b / 1073741824).toFixed(2) + ' GiB'; + } + if (b >= 1048576) { + return (b / 1048576).toFixed(2) + ' MiB'; + } + if (b >= 1024) { + return (b / 1024).toFixed(2) + ' KiB'; + } + // Return as a whole number because fractions of bytes do not exist + return parseInt(b, 10) + ' B'; +}; + +/** + * Calculates and formats individual tunnel runtime durations using system uptime seconds + */ +const calculateInstanceUptime = function (procStartSeconds, systemUptime) { + const startSec = parseInt(procStartSeconds, 10); + if (isNaN(startSec) || startSec <= 0) return '-'; + + const diff = Math.floor(systemUptime) - startSec; + if (isNaN(diff) || diff < 0) return '0h 0m 0s'; + + const days = Math.floor(diff / 86400); + const hours = Math.floor((diff % 86400) / 3600); + const minutes = Math.floor((diff % 3600) / 60); + const seconds = diff % 60; + + if (days > 0) { + return days + 'd ' + hours + 'h ' + minutes + 'm ' + seconds + 's'; + } + return hours + 'h ' + minutes + 'm ' + seconds + 's'; +}; + +/** + * Get the openvpn instance status and telemetry table + */ +const getStatusTable = function (instances, systemUptime, isLiveRefresh, pendingInstances) { + const tableRows = []; + + for (let i = 0; i < instances.length; i++) { + const inst = instances[i]; + const role = inst.role || OPENVPN.ROLE.SERVER; + + const netstat = inst.netstat || { rxBytes: 0, rxPkts: 0, txBytes: 0, txPkts: 0, hasData: false }; + + const protoStr = (inst.isRunning && inst.proto) ? inst.proto : ''; + const remoteIpNode = renderRemoteNode(role, inst.isRunning, inst.connectedClients, protoStr, netstat); + + const customUciName = L.uci.get(CFG.CMD.openvpn, inst.id, 'displayname') || ''; + const displayId = customUciName ? customUciName : TXT.INFO.instance_x + inst.instNum; + + // Create the instance type badge (Server / Client) + const typeBadge = E('span', { + 'class': 'ifacebadge', + 'style': 'font-weight:normal !important; padding:2px 6px; border-radius:3px; background:var(--background-color, transparent) !important; border:1px solid var(--border-color-medium, #cbd5e1);' + }, role.charAt(0).toUpperCase() + role.slice(1)); + + const isPending = (Array.isArray(pendingInstances) && pendingInstances.indexOf(inst.id) !== -1); + let statusBadge; + + // Select the correct status badge style based on the runtime state + if (isPending) { + statusBadge = E('span', { + 'class': 'ifacebadge', + 'style': 'font-weight:normal !important; padding:2px 8px; border-radius:3px; background:color-mix(in srgb, var(--warn-color-high, #eab308) 12%, transparent) !important; color: var(--warn-color-high, #eab308) !important; border:1px solid var(--warn-color-high, #eab308); text-shadow:none !important; box-shadow:none !important;' + }, TXT.INFO.pending); + } else if (inst.isRunning) { + statusBadge = E('span', { + 'class': 'ifacebadge', + 'style': 'font-weight:normal !important; padding:2px 8px; border-radius:3px; background:color-mix(in srgb, var(--primary-color-high, #3b82f6) 12%, transparent) !important; color:var(--primary-color-high, #3b82f6) !important; border:1px solid var(--primary-color-high, #3b82f6); text-shadow:none !important; box-shadow:none !important;' + }, TXT.INFO.running + ' (PID: ' + inst.pid + ')'); + } else if (L.uci.get(CFG.CMD.openvpn, inst.id, 'enabled') === '1') { + if (!isLiveRefresh) { + statusBadge = E('span', { + 'class': 'ifacebadge', + 'style': 'font-weight:normal !important; padding:2px 8px; border-radius:3px; background:color-mix(in srgb, var(--warn-color-high, #eab308) 12%, transparent) !important; color: var(--warn-color-high, #eab308) !important; border:1px solid var(--warn-color-high, #eab308); text-shadow:none !important; box-shadow:none !important;' + }, TXT.INFO.creating); + } else { + statusBadge = E('span', { + 'class': 'ifacebadge', + 'style': 'font-weight:normal !important; padding:2px 8px; border-radius:3px; background:color-mix(in srgb, var(--error-color-high, #ef4444) 12%, transparent) !important; color: var(--error-color-high, #ef4444) !important; border:1px solid var(--error-color-high, #ef4444); text-shadow:none !important; box-shadow:none !important;' + }, TXT.INFO.error); + } + } else { + statusBadge = E('span', { + 'class': 'ifacebadge', + 'style': 'font-weight:normal !important; padding:2px 8px; border-radius:3px; background:var(--background-color-medium, #f4f4f5) !important; color:color-mix(in srgb, var(--text-color-medium, #71717a) 85%, #0055ff) !important; border:1px solid var(--border-color-medium, #cbd5e1); text-shadow:none !important; box-shadow:none !important;' + }, TXT.INFO.disabled); + } + + + let localConnectionNode = '-'; + if (inst.isRunning) { + const activePort = inst.port || '?'; + const activeProto = inst.proto ? '/' + inst.proto.toLowerCase() : ''; + + localConnectionNode = E('span', { + 'style': 'font-family:var(--font-monospace, monospace);' + }, [ + inst.localIp + ':' + activePort, + E('span', { 'style': 'color:color-mix(in srgb, var(--text-color-medium, #71717a) 85%, #0055ff);' }, activeProto) + ]); + } + + // Clean separation between primary Data Bytes and volatile Packet Counters + let transferNode = '-'; + if (inst.isRunning && netstat.hasData) { + transferNode = E('div', { + 'style': 'font-family:var(--font-monospace, monospace); line-height:1.4; white-space:nowrap; padding:0; margin:0;' + }, [ + E('div', { 'style': 'white-space:nowrap;' }, [ + '▲ ', formatStatusBytes(netstat.txBytes), + E('small', { 'style': 'font-size:11px; color:color-mix(in srgb, var(--text-color-medium, #71717a) 85%, #0055ff); margin-left:4px;' }, '(' + netstat.txPkts + ' pkt)') + ]), + E('div', { 'style': 'white-space:nowrap; margin-top:1px;' }, [ + '▼ ', formatStatusBytes(netstat.rxBytes), + E('small', { 'style': 'font-size:11px; color:color-mix(in srgb, var(--text-color-medium, #71717a) 85%, #0055ff); margin-left:4px;' }, '(' + netstat.rxPkts + ' pkt)') + ]) + ]); + } else if (inst.isRunning) { + transferNode = E('div', { + 'style': 'font-family:var(--font-monospace, monospace);' + }, [ + E('span', {}, '-'), + ]); + } + + const uptimeDisplay = calculateInstanceUptime(inst.startTime, systemUptime); + const cipherLabel = String(inst.cipher || 'AES-256-GCM'); + + // Create alternate row styling classes for visual structure + const rowStyleClass = (i % 2 === 0) ? 'tr cbi-section-table-row cbi-rowstyle-1' : 'tr cbi-section-table-row cbi-rowstyle-2'; + + // Push the complete generated row node array into the table loop memory + tableRows.push(E('tr', { 'class': rowStyleClass }, [ + E('td', { 'class': 'td' }, '#' + inst.instNum), + E('td', { 'class': 'td', 'style': 'font-weight:bold;' }, displayId), + E('td', { 'class': 'td' }, typeBadge), + E('td', { 'class': 'td' }, statusBadge), + E('td', { 'class': 'td' }, localConnectionNode), + E('td', { 'class': 'td' }, remoteIpNode), + E('td', { 'class': 'td', 'style': 'font-family:var(--font-monospace, monospace); font-weight:bold; color: var(--success-color-high, #10b981);' }, inst.isRunning ? cipherLabel : '-'), + E('td', { 'class': 'td' }, transferNode), + E('td', { 'class': 'td', 'style': 'font-family:var(--font-monospace, monospace);' }, inst.isRunning ? uptimeDisplay : '-') + ])); + + } + + let statusTableRows; + if (tableRows.length > 0) { + statusTableRows = tableRows; + } else { + statusTableRows = [E('tr', { 'class': 'tr' }, [ + E('td', { 'class': 'td', 'colspan': '9', 'style': 'text-align:center; font-style:italic; color:color-mix(in srgb, var(--text-color-medium, #71717a) 85%, #0055ff);' }, TXT.INFO.no_inst)]) + ] + } + + // Create the status table + const statusTable = E('table', { 'class': 'table cbi-section-table' }, [ + E('tr', { 'class': 'tr cbi-section-table-titles' }, [ + E('th', { 'class': 'th' }, TXT.INFO.vpn), + E('th', { 'class': 'th' }, TXT.INFO.instance), + E('th', { 'class': 'th' }, TXT.INFO.type), + E('th', { 'class': 'th' }, TXT.INFO.status), + E('th', { 'class': 'th' }, TXT.INFO.local_ip_port), + E('th', { 'class': 'th' }, TXT.INFO.remote_ip_port), + E('th', { 'class': 'th' }, TXT.INFO.encryption), + E('th', { 'class': 'th' }, TXT.INFO.transfer), + E('th', { 'class': 'th' }, TXT.INFO.uptime) + ]) + ].concat(statusTableRows)); + return statusTable; +}; + +/** + * Calculates aggregated traffic statistics from all running instances + */ +const calculateTunnelTraffic = function (instances) { + const traffic = { rx: 0, tx: 0 }; + + // Check if the input is a valid array with data + if (!Array.isArray(instances) || instances.length === 0) { + return traffic; + } + + // Loop through all instances and sum up the data + for (let i = 0; i < instances.length; i++) { + const inst = instances[i]; + + // Only add the bytes if the netstat object has active data + if (inst && inst.isRunning && inst.netstat && inst.netstat.hasData) { + traffic.rx += inst.netstat.rxBytes; + traffic.tx += inst.netstat.txBytes; + } + } + + return traffic; +}; + +/** + * Get status tooltip + */ +const getTooltipNode = function (ovpnState, appData) { + + const totalInstances = appData.instances.length; + const instances = appData.instances; + + let runningInstances = 0; + const activePids = []; + + for (let i = 0; i < instances.length; i++) { + if (instances[i].isRunning) { + runningInstances++; + if (instances[i].pid && instances[i].pid !== '-') { + activePids.push(instances[i].pid); + } + } + } + + const textState = (ovpnState !== OPENVPN.STATE.disabled) ? TXT.INFO.yes : TXT.INFO.no; + const imageState = (ovpnState !== OPENVPN.STATE.disabled) ? CFG.FILE.vpn_enabled_img : CFG.FILE.vpn_disabled_img; + const textContented = runningInstances + '/' + totalInstances; + const textPids = (activePids.length > 0) ? activePids.join(', ') : '-'; + const traffic = calculateTunnelTraffic(instances); + const textRx = formatStatusBytes(traffic.rx); + const textTx = formatStatusBytes(traffic.tx); + + let versionTooltip = []; + if (openVpnInfo.version) { + versionTooltip.push(E('span', { 'class': 'nowrap' }, [E('strong', {}, 'OpenVPN: '), 'v.' + openVpnInfo.version])); + versionTooltip.push(E('br')); + } + if (openVpnInfo.openssl) { + versionTooltip.push(E('span', { 'class': 'nowrap' }, [E('strong', {}, openVpnInfo.openssl_type + ': '), 'v.' + openVpnInfo.openssl])); + versionTooltip.push(E('br')); + } + if (openVpnInfo.dco) { + versionTooltip.push(E('span', { 'class': 'nowrap' }, [E('strong', {}, 'Data Channel Offload: '), 'v.' + openVpnInfo.dco])); + versionTooltip.push(E('br')); + } + + // Build the status summary tooltip + const tooltipNode = E('span', { 'class': 'cbi-tooltip ifacebadge large', 'style': 'text-align:left; font-weight:normal;' }, [ + E('img', { 'src': imageState, 'style': 'float:left; margin-right:10px; width:24px; height:24px;' }), + E('span', { 'class': 'left', 'style': 'display:block; overflow:hidden; font-size:0.8em;' }, [ + E('span', { 'class': 'nowrap' }, [E('strong', {}, TXT.INFO.enabled + ': '), textState]), E('br'), + E('span', { 'class': 'nowrap' }, [E('strong', {}, TXT.INFO.instances + ': '), String(totalInstances)]), E('br'), + E('span', { 'class': 'nowrap' }, [E('strong', {}, TXT.INFO.connected + ': '), textContented]), E('br'), + E('span', { 'class': 'nowrap' }, [E('strong', {}, TXT.INFO.active_pids + ': '), textPids]), E('br'), + E('span', { 'class': 'nowrap' }, [E('strong', {}, TXT.INFO.aggregated_rx + ': '), textRx]), E('br'), + E('span', { 'class': 'nowrap' }, [E('strong', {}, TXT.INFO.aggregated_tx + ': '), textTx]), E('br'), + E('br') + ].concat(versionTooltip)) + ]); + + return tooltipNode +} + +/** + * Refreshes the status table and updates the main dashboard view continuously. + */ +const refreshLiveDashboard = async function (appData, tableContainerElement, refreshStatusBoxCallback) { + try { + // 2. Fetch service data and uci changes simultaneously + const results = await Promise.all([ + rpcGetServiceStatus(), + L.uci.changes(), + getSystemUptime() + ]); + + const serviceData = results[0]; + const uciChanges = results[1]; + const systemUptime = results[2]; + appData.uptime = systemUptime; + + // Check A: If ubus blocked the query (Session Timeout / Tab Sleep), abort instantly! Do NOT overwrite appData with corrupt empty elements. + if (!serviceData || typeof serviceData !== 'object') { + return; + } + + const instancesObj = serviceData.instances || {}; + const ovpnChanges = (uciChanges && uciChanges[CFG.CMD.openvpn]) ? uciChanges[CFG.CMD.openvpn] : null; + const pendingInstances = getPendingInstances(ovpnChanges); + + // 3. Wait for the instance status data safely + const updatedInstances = await readInstanceStatus(appData.ovpnUciSections, instancesObj, systemUptime); + + // Check B: Structural integrity check. If the array dropped to zero but UCI has profiles, a background processing block occurred -> Abort! + if ((!updatedInstances || updatedInstances.length === 0) && appData.ovpnUciSections && appData.ovpnUciSections.length > 0) { + return; + } + + // refresh netstat in the instances (e.g. /sys/class/net/tun0/statistics) + await refreshNetStat(updatedInstances); + + // Save the fresh verified data into the global cache object safely + appData.instances = updatedInstances; + + // 4. Update the live status table layout on the screen + if (tableContainerElement && tableContainerElement.firstChild) { + const freshTableNode = getStatusTable(appData.instances, systemUptime, true, pendingInstances); + tableContainerElement.replaceChild(freshTableNode, tableContainerElement.firstChild); + } + + // 5. Fire the callback method to update the main view badges + if (typeof refreshStatusBoxCallback === 'function') { + const ovpnState = getCurrentOvpnState(appData.ovpnUciSections, appData.instances, pendingInstances); + const tooltip = getTooltipNode(ovpnState, appData); + + refreshStatusBoxCallback(ovpnState, tooltip, appData); + } + + } catch (err) { + console.error('LuCI Live Dashboard Refresh Failed:', err.message); + } +}; + +/** + * Hardware initializer executed on system class load + */ +const onLoad = async function () { + try { + + // Register a global listener for LuCI RPC errors (Autologout tracking) + document.addEventListener('luci-request-error', function (ev) { + // Set pending if the error status indicates an expired session (403 Forbidden or 6) + if (ev && ev.detail && (ev.detail.status === 403 || ev.detail.code === 6)) { + ovpnPending.trigger(); + } + }); + + await getOpenVpnInfo(); + + } catch { + // silent catch + } +}; + +/** + * Export the status functions to the main LuCI view layer + */ +return L.Class.extend({ + INSTANCE_TEMPLATE: INSTANCE_TEMPLATE, + CLIENT_TEMPLATE: CLIENT_TEMPLATE, + onLoad: onLoad, + getDevName: getDevName, + getInstanceNumber: getInstanceNumber, + calcPortFromId: calcPortFromId, + parseSiteToSite: parseSiteToSite, + parseSmartFirewall: parseSmartFirewall, + parsePortFromConfig: parsePortFromConfig, + parseProtoFromConfig: parseProtoFromConfig, + parseDdnsFromConfig: parseDdnsFromConfig, + netIPCIDR: netIPCIDR, + formatStatusBytes: formatStatusBytes, + parseRemoteSubnets: parseRemoteSubnets, + readInstanceStatus: readInstanceStatus, + getTooltipNode: getTooltipNode, + getStatusTable: getStatusTable, + getSystemUptime: getSystemUptime, + ovpnPending: ovpnPending, + refreshNetStat: refreshNetStat, + refreshLiveDashboard: refreshLiveDashboard +}); + diff --git a/luci-app-openvpn-plus/root/www/luci-static/resources/view/vpn/openvpn-wizard.js b/luci-app-openvpn-plus/root/www/luci-static/resources/view/vpn/openvpn-wizard.js new file mode 100755 index 000000000..359e1dd49 --- /dev/null +++ b/luci-app-openvpn-plus/root/www/luci-static/resources/view/vpn/openvpn-wizard.js @@ -0,0 +1,2912 @@ +/* + * Copyright (C) 2008-2026 The OpenWrt Project + * Copyright (C) 2026 Manfred Jaider + * + * This is free software, licensed under the Apache License, Version 2.0. + * See /LICENSE for more information. + * + * luci-app-openvpn-plus : configuration wizard for quick server and client deployment + * /www/luci-static/resources/view/vpn/openvpn-wizard.js + * + * 1. --- TEXT & DEFINITIONS --- ....Global translations and system definitions + * 2. --- HELPER --- ............... Router connections and file setup + * 3. --- IMPORT / EXPORT --- ...... Import Client Instance / Export Server instance + * 4. --- SETUP WIZARD --- ......... OpenVPN connection wizard modal + */ + +/* global E, FileReader */ +'use strict'; + +/* + * --- TEXT & DEFINITIONS --- + */ +const TXT = { + INFO: { + action: _('Action'), + connection_type: _('Connection Type'), + creating_vpn: _('Creating VPN...'), + custom_update_url: _('Custom Update URL'), + ddns_provider: _('DDNS Provider'), + domain_name: _('Domain Name'), + destination: _('Destination'), + external_port: _('External Port'), + gateway: _('Gateway'), + help: _('Help'), + import_profile: _('Import Profile'), + importing_profile: _('Importing profile...'), + imported_clients: _('Imported Client'), + imported_failed: _('Import Failed:'), + inside_port: _('Inside Port'), + internal_port: _('Internal Port'), + internet_traffic: _('Internet Traffic'), + lan_ip: _('LAN IP'), + key_update_active: _('Key Update Active'), + netmask: _('Netmask'), + remote_lan_subnet: _('Remote LAN Subnet'), + remote_name: _('Remote Name'), + CLOSED: _('CLOSED'), + BLOCKED: _('BLOCKED'), + OPEN: _('OPEN'), + OK: _('OK'), + MISSING: _('MISSING'), + WORKING: _('WORKING'), + mask: _('Mask'), + openvpn_profile_valid: _('OpenVPN Profile Valid:'), + outside_port: _('Outside Port'), + port_forwarding: _('Port Forwarding'), + protocol: _('Protocol'), + placeholder_eg: _('e.g.'), + public_ip_or_domain: _('Public IP Address or Domain'), + public_static_ip: _('Public Static IP'), + remote_office_name: _('Remote Office Name'), + resolving_target: _('Resolving Target...'), + reveal_hide_password: _('Reveal/hide password'), + select_vpn_type: _('Select VPN Type'), + setup: _('Setup'), + setup_wizard: _('OpenVPN Setup Wizard'), + secure_tunnel: _('Secure Full Tunnel (Route all traffic over VPN)'), + server_address: _('Server Address'), + split_tunnel: _('Split Tunnel (Access home LAN only, faster web browsing)'), + static_route: _('Static Route'), + status: _('Status'), + token_password: _('Token / Password'), + username: _('Username'), + vpn_name: _('VPN Name') + }, + BTN: { + add_office: _('Add Office'), + back: _('Back'), + cancel: _('Cancel'), + check_address: _('Check Address Status'), + create_vpn: _('Create VPN'), + choose_file: _('Choose File...'), + delete: _('Delete'), + next: _('Next') + }, + MSG: { + add_your_other_office: _('Add your other offices below. Please enter the internal LAN network IP (e.g., 192.168.2.0) of each office, NOT their public WAN IP addresses. The server will connect all local networks automatically.'), + another_router_detected: _('Another Router detected!'), + this_router_not_main_internet_router: _('This OpenVPN Router is not the Main Internet Router.'), + choose_how_send_through_vpn: _('Choose how your internet traffic is sent through the VPN. You can use any free OpenVPN app on your phone or laptop to import this profile.'), + client_for_branch_office: _('Client for Branch Office (Branch)'), + create_vpn_few_clicks: _('Create a secure VPN connection in just a few clicks.'), + network_verification_failed: _('Network verification failed / Timeout'), + checking_connection_background: _('Checking connection status in the background...'), + delete_this_profile: _('Delete this office'), + finding_your_public_ip: _('Finding your public IP address...'), + for_phones_and_laptops: _('For Phones & Laptops (OpenVPN Connect App)'), + internet_provides_static_ip: _('If your internet provides a static IP address.'), + ip_static_bypassed_by_admin: _('Static IP check bypassed by administrator.'), + ip_static_now_checked_by_admin: _('Static IP now checked by administrator.'), + ip_validation_bypassed_after_net_failure: _('Validation bypassed after network failure.'), + ip_validation_bypassed_by_admin: _('Validation bypassed by administrator.'), + ip_valid_and_resolvable: _('Success: The IP address is valid and fully resolvable!'), + is_already: _('is already'), + is_currently: _('is currently'), + keep_ip_setup: _('Keep IP Setup anyway'), + keys_updated_successfully: _('Keys updated successfully!'), + no_offices_added_yet: _('No offices added yet. Use the fields below to add the first office.'), + no_file_choosen: _('No file chosen'), + only_vpn_client_keys: _('This file contains only VPN client keys.'), + open_port_success: _('Port Open Success'), + placeholder_mydomain_publicip: _('e.g. mydomain.com or your public IP'), + placeholder_myhomevpn: _('e.g. Mobile-Server, Main-Office'), + please_add_static_routing: _('Please add static routing inside your Main Internet Router (IP: %s) using this OpenVPN Router (IP: %s) as Gateway.'), + please_check_profe_file: _('Please check your profile file. A valid configuration file needs a remote target endpoint and key blocks.'), + port_used_main_router: _('The port used on your main internet router.'), + port_used_this_router: _('The port used on this OpenWrt router.'), + please_create_port_forwarding: _('Please create a port forwarding rule inside your Main Internet Router (IP: %s) using this OpenVPN Router (IP: %s) as receiver.'), + public_ip_or_domainname: _('The public IP address or your own external domain name.'), + ready_for_connection: _('Ready for openvpn connections.'), + routing_vpn_main_internet: _('The VPN routing via Main Internet Router (IP: %s) to remote subnet(s) (Net: %s)'), + static_routing_missing: _('Static Routing missing!'), + static_routing_success: _('Static Routing Success'), + select_ovpn_profile: _('Select the (.ovpn) profile or (.crt) multi-key bundle. Settings will be configured or updated automatically.'), + server_for_main_office: _('Server for Main Office (Headquarters)'), + skip_check_unlock_create: _('Skip Check & Unlock Create Button'), + switch_to_ddns: _('Switch to Dynamic DNS (DDNS)'), + the_port_forward: _('The port forward'), + tcp_for_traveling: _('TCP (Good for traveling - works behind firewalls)'), + udp_performance: _('UDP (High Performance - maximum throughput)'), + use_ddns_service: _('Use Dynamic DNS (DDNS) Service'), + use_public_ip: _('Use Public IP Address / Domain') + }, + DDNS: { + duckdns_description: _('Number 1 for Open-Source and Home-Assistant setups.'), + duckdns_link: 'https://duckdns.org', + duckdns_placeholder: _('e.g. myname.duckdns.org'), + duckdns_provider: _('DuckDNS (duckdns.org)'), + duckdns_token: _('API Token'), + ipv64_description: _('Modern free provider with perfect IPv6 and Dual-Stack support.'), + ipv64_link: 'https://ipv64.net', + ipv64_placeholder: _('e.g. myname.ipv64.net'), + ipv64_provider: _('IPv64 (ipv64.net)'), + ipv64_token: _('Domain Update URL / Key'), + dynv6_description: _('Classic free provider with exceptional IPv6 and dual-stack support.'), + dynv6_link: 'https://dynv6.com', + dynv6_placeholder: _('e.g. myname.dynv6.net'), + dynv6_provider: _('dynv6 (dynv6.com)'), + dynv6_token: _('HTTP Token String'), + dynu_description: _('Reliable free provider. Never expires, perfect for IPv6 and Dual-Stack.'), + dynu_link: 'https://www.dynu.com', + dynu_placeholder: _('e.g. myname.dynu.net'), + dynu_provider: _('Dynu Systems (dynu.com)'), + dynu_token: _('Password as plain text (Auto-SHA256)'), + dynu_username: _('Username (Optional)'), + noip_description: _('Very popular provider. WARNING: Free accounts require manual activation/renewal every 30 days via email.'), + noip_link: 'https://www.noip.com', + noip_placeholder: _('e.g. myname.ddns.net'), + noip_provider: _('No-IP (noip.com)'), + noip_token: _('DDNS Key'), + noip_username: _('DDNS Username'), + freemyip_description: _('Super fast setup without registration. WARNING: Blocked in some networks because it is completely anonymous.'), + freemyip_link: 'https://freemyip.com', + freemyip_placeholder: _('e.g. myname.freemyip.com'), + freemyip_provider: _('FreeMYIP (freemyip.com)'), + freemyip_token: _('Domain Update URL / token'), + custom_description: _('Enter your own manual HTTP update URL structure.'), + custom_placeholder: _('e.g. myname-custom-provider.com'), + custom_provider: _('Custom / Manual Update URL'), + custom_token: _('Token / Password'), + custom_url_placeholder: _('e.g. https://custom-provider.com?domain=...&key=...') + }, + WARNING: { + ip_cannot_be_resolved: _('Warning: The IP address cannot be resolved. Your router might be offline.'), + no_static_ip_selected: _('Warning: You did not select «Public Static IP». If your public IP address changes, your VPN tunnel will crash.'), + openvpn_key_detected: _('OpenVPN Keys Detected:'), + unsaved_text_input: _('Warning: You have un-saved text in the input fields. Please click the blue «Add Office» button to save it, or clear the fields to continue.'), + upload_wrong_keys: _('Warning: If you upload wrong keys, your connection will stop!') + }, + ERROR: { + ERROR: 'ERROR', + add_at_least_one_office: _('Error: Please add at least one office to the list before you continue.'), + create_openvpn_server: _('Failed to create new OpenVPN server:'), + dns_ip_check_failed: _('Network Error: DNS/IP query failed.'), + ip_used_on_other_office: _('Error: This IP address is already used by another office. Every office needs a different IP address!'), + net_status_check: _('Error: Network status check failed. Please try again.'), + office_name_already_exist: _('Error: An office with this name is already in the list.'), + use_local_ipnetwork: _('Error: You cannot use this IP network! It is the local network of this router and will block all traffic.'), + vpn_name_validation: _('Validation Error: The VPN Name must contain only letters, numbers, hyphens or underscores.'), + file_corrupted: _('Error: File is corrupted or missing vital fields.'), + } +}; + +const ICON = Object.freeze({ + ARROW: '➔ ', + BRANCH: '🏪 ', + CHECK: '✓ ', + ERROR: '❌ ', + GLOBE: '🌐 ', + IMPORT: '📂 ', + INFO: 'ℹ️ ', + LINK: '🔗 ', + LOADING: '⏳ ', + OFFICE: '🏢 ', + PHONE: '📱 ', + PLUS: '➕ ', + REMOVE: '🗑 ', + ROCKET: '🚀 ', + SHIELD: '🛡️ ', + SPLIT: '🔀 ', + SUCCESS: '✅ ', + TAG: '🏷️ ', + WARNING: '⚠️ ', + WIZARD: '🪄 ' +}); + +const OPENVPN = Object.freeze({ + ROLE: Object.freeze({ + SERVER: 'server', + CLIENT: 'client' + }), + STRATEGY: Object.freeze({ + STANDARD: 'standard', + REDIRECT: 'redirect', + SITETOSITE: 'sitetosite' + }), + PROTO: Object.freeze({ + TCP: 'tcp', + UDP: 'udp' + }), + PORT: Object.freeze({ + s1194: '1194', + s443: '443', + s444: '444', + n1194: 1194 + }), + IPv4: Object.freeze({ + ZERO: '0.0.0.0', + MASK24: '255.255.255.0' + }), + CONN_TYPE: Object.freeze({ + IP: 'ip', + DDNS: 'ddns' + }), + SCENARIO: Object.freeze({ + CONNECT_SERVER: 'connect_server', + SITE_TO_SITE_SERVER: 'sitetosite_server', + SITE_TO_SITE_CLIENT: 'sitetosite_client' + }) +}); + +/** + * Data structure used on wizard creation. + * Contains core dashboard runtime variables and asynchronous logic execution callbacks. + */ +const WIZARD_DATA_TEMPLATE = Object.freeze({ + appData: null, // Holds the main application interface context data + addNewInstanceCallback: null, // Function to create or update an OpenVPN instance configuration + networkCallbacks: null, // Network callbacks container + uciSaveApplyRestartCallback: null, // Function to trigger the official green save-and-apply banner in LuCI + importOvpnClientProfileCallback: null, // Function to parse text, key bundles, and routing layouts from uploaded files + instanceNumber: 1, // Stores the current or next available numeric instance identification index + forcedScenario: null, // Bypasses the initial role selection step if the scenario context is locked +}); + +/** + * Data structure returned by the wizard. + * Holds the validated, sanitized user inputs ready for the UCI configuration builder staging. + */ +const WIZARD_PARAMS_TEMPLATE = Object.freeze({ + role: OPENVPN.ROLE.SERVER, // Target configuration profile role mapping (server or client) + port: OPENVPN.PORT.n1194, // Internal numeric system port assignment for the local process listener + portExtern: OPENVPN.PORT.n1194, // External port offset mapping used for firewall rule deployments + proto: OPENVPN.PROTO.UDP, // Transport layer connection protocol string selection (udp or tcp) + displayName: '', // Localized custom alias name assigned to distinguish the specific tunnel + strategy: OPENVPN.STRATEGY.STANDARD, // Network traffic management profile (standard, redirect, or sitetosite) + remoteServer: '', // Client role parameter: Target remote server public IP or domain string + remoteServerLan: '', // Client role parameter: Target remote server LAN Subnet (e.g. 192.168.10.0/24) + clients: null, // Multi-client server list container mapping routing subnets for branch offices + isApMode: false, // Gateway infrastructure toggle specifying if the local device runs in AP mode + connectionType: OPENVPN.CONN_TYPE.IP, // Public endpoint resolution strategy definition toggling between ip and ddns + ddnsOrPublicIp: '', // Cleaned WAN endpoint value storing the public lookup target domain or static IP + isStaticIp: false, // Semiautomatic safety override bypassing the active IP migration checks + ddnsProvider: '', // Selected dynamic DNS provider configuration key profile token identification + ddnsUrl: '', // Generated secure HTTP GET request string dispatched for provider synchronization + importedRawOvpn: '' // Temporary payload staging buffer holding raw client file profile contents +}); + + +/* + * --- HELPER --- + */ + + +/** + * Generates the final update URL for the selected DDNS provider. + */ +const buildDdnsUpdateUrl = async function (provider, domain, token, ddnsUser, wizardData, maskedStars) { + // Security check: We always need a provider, a domain, and a token/password + if (!provider || !domain) { + return ''; + } + let url = ''; + if (provider === 'custom') { + url = ddnsUser; + } else { + if (!token || typeof token !== 'string') { + return ''; + } + // If the token is a full HTTPS URL, use it directly + if (token.startsWith('https://')) { + if (maskedStars) { + url = maskedStars; + } else { + url = token; + } + } else { + if (maskedStars) { + token = maskedStars; + } + // Generate the final update URL structure using the clean domain + if (provider === 'duckdns') { + url = 'https://duckdns.org/update?domains=' + domain + '&token=' + token; + } else if (provider === 'ipv64') { + url = 'https://ipv64.net/nic/update?key=' + token + '&domain=' + domain; + } else if (provider === 'freemyip') { + url = 'https://freemyip.com/update?token=' + token + '&domain=' + domain; + } else if (provider === 'dynv6') { + url = 'https://dynv6.com/api/update?zone=' + domain + '&token=' + token + '&ipv4=auto&ipv6=auto'; + } else if (provider === 'dynu') { + if (ddnsUser) { + url = 'https://api.dynu.com/nic/update?hostname=' + domain + '&username=' + ddnsUser + '&password=' + String(await wizardData.networkCallbacks.sha256sum(token)); + } else { + url = 'https://api.dynu.com/nic/update?hostname=' + domain + '&password=' + String(await wizardData.networkCallbacks.sha256sum(token)); + } + } else if (provider === 'noip') { + if (!ddnsUser) { + return ''; + } + url = 'https://' + ddnsUser + ':' + token + '@dynupdate.no-ip.com/nic/update?hostname=' + domain; + } + } + } + return url; +} + +/** + * Strips all accidental Windows or Mac line breaks and trims spaces. + */ +const sanitizeInputLine = function (value) { + return String(value || '').trim().replace(/[\r\n]/g, ''); +}; + +/** + * Normalizes all Windows and Mac line breaks into clean UNIX line breaks for textareas. + */ +const sanitizeInputText = function (value) { + const rawText = value ? String(value).trim() : ''; + return rawText.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); +}; + +/** + * Cleans a name string to remove bad characters and make it safe for files or text. + */ +const getValidCommonName = function (name) { + if (!name) { + return null; + } + + // Define X.509 RFC length bounds + const MIN_LENGTH = 2; + const MAX_LENGTH = 64; + const trimmedName = sanitizeInputLine(name); + + // Enforce a unified safe string rule by converting all spaces and symbols to underscores + const cleanName = trimmedName + .replace(/[^a-zA-Z0-9_-]/g, '_') + .replace(/_+/g, '_') + .replace(/-+/g, '-') + .trim() + .replace(/^[_-]+|[_-]+$/g, ''); + + // Return null if the sanitized string violates X.509 length constraints + if (cleanName.length < MIN_LENGTH || cleanName.length > MAX_LENGTH) { + return null; + } + + return cleanName; +}; + +/** + * Validate and clean any given IPv4 or IPv6 address string + */ +const checkIPAddress = function (rawIp) { + if (!rawIp) { + return null; + } + + // Delete all spaces, tabs and hidden lines from the text (tr -d ' \t\r\n') + const ip = rawIp.replace(/[\s\t\r\n]/g, ''); + if (ip === '') { + return null; + } + + const ipv4Regex = /^([0-9]{1,3}\.){3}[0-9]{1,3}$/; + + // FIXED: Standard compliant IPv6 pattern that correctly handles "::" and blocks ":::" + const ipv6Regex = /^(?:[0-9a-fA-F]{1,4}:){1,7}:$|^(?:[0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}$|^(?:[0-9a-fA-F]{1,4}:){1,5}(?::[0-9a-fA-F]{1,4}){1,2}$|^(?:[0-9a-fA-F]{1,4}:){1,4}(?::[0-9a-fA-F]{1,4}){1,3}$|^(?:[0-9a-fA-F]{1,4}:){1,3}(?::[0-9a-fA-F]{1,4}){1,4}$|^(?:[0-9a-fA-F]{1,4}:){1,2}(?::[0-9a-fA-F]{1,4}){1,5}$|^[0-9a-fA-F]{1,4}:(?::[0-9a-fA-F]{1,4}){1,6}$|^:(?::[0-9a-fA-F]{1,4}){1,7}$|^::$/; + + // Case 1: Check if the text is a valid IPv4 address (numbers 0 to 255) + if (ipv4Regex.test(ip)) { + const segments = ip.split('.'); + const octet1 = parseInt(segments[0], 10); + const octet2 = parseInt(segments[1], 10); + const octet3 = parseInt(segments[2], 10); + const octet4 = parseInt(segments[3], 10); + + // Match the awk rule: $1<=255 && $2<=255 && $3<=255 && $4<=255 + if (octet1 <= 255 && octet2 <= 255 && octet3 <= 255 && octet4 <= 255) { + return { ip: ip, isV6: false }; + } + } + + // Case 2: Check if the text matches the verified IPv6 regex + if (ipv6Regex.test(ip)) { + if (ip.indexOf(':') !== -1) { + // Change all hex letters to lowercase for safe matching later (tr 'A-Z' 'a-z') + return { ip: ip.toLowerCase(), isV6: true }; + } + } + + return null; +}; + +/** + * Generates an isolated, self-validating Site-to-Site network inputs row layout block + */ +const renderSubnetInputs = function () { + // Create the 3 input fields with simple, easy text placeholders + const cnInput = E('input', { 'type': 'text', 'class': 'cbi-input-text', 'placeholder': TXT.INFO.remote_office_name, 'style': 'width:32%; font-family:monospace; margin-right:1%;' }); + const subnetInput = E('input', { 'type': 'text', 'class': 'cbi-input-text', 'placeholder': TXT.INFO.lan_ip + ' (192.168.2.0)', 'style': 'width:32%; font-family:monospace; margin-right:1%;' }); + const maskInput = E('input', { 'type': 'text', 'class': 'cbi-input-text', 'placeholder': TXT.INFO.netmask + ' (255.255.255.0)', 'style': 'width:32%; font-family:monospace; margin-right:1%;' }); + + // Put the 3 input fields together in one horizontal line + const container = E('div', { 'style': 'margin-top:4px;' }, [cnInput, subnetInput, maskInput]); + + // Remove the red error borders when the user starts typing again + const clearErrorFlags = function () { + cnInput.classList.remove('cbi-input-invalid'); + subnetInput.classList.remove('cbi-input-invalid'); + maskInput.classList.remove('cbi-input-invalid'); + }; + + cnInput.addEventListener('input', clearErrorFlags); + subnetInput.addEventListener('input', clearErrorFlags); + maskInput.addEventListener('input', clearErrorFlags); + + return { + // Return the HTML container node to the main wizard layout + node: container, + + // Return the links to the fields so we can clear or focus them + fields: { cn: cnInput, sub: subnetInput, mask: maskInput }, + + clearErrors: function () { + clearErrorFlags(); + }, + + // This function checks if the typed text is correct + validateAndFetchData: function () { + clearErrorFlags(); + + let rawCn = sanitizeInputLine(cnInput.value); + let rawSub = sanitizeInputLine(subnetInput.value); + let rawMask = sanitizeInputLine(maskInput.value); + + let hasValidationError = false; + let cleanCn = ''; + let cleanSub = ''; + let cleanMask = ''; + + // 1. Check if the Office Name is valid + cleanCn = getValidCommonName(rawCn); + if (!rawCn || !cleanCn) { + cnInput.classList.add('cbi-input-invalid'); + hasValidationError = true; + } + + // --- SMART CIDR INLINE PARSER LAYER --- + // Check if the user typed a slash '/' directly into the IP address field + if (rawSub.indexOf('/') !== -1) { + const urlParts = rawSub.split('/'); + rawSub = urlParts[0].trim(); // Save the raw IP block + + // Only overwrite rawMask if the mask field was left completely empty + if (!rawMask) { + rawMask = urlParts[1].trim(); + } + } + + // 2. Validate IP Address using your verified template logic + const ipResult = checkIPAddress(rawSub); + if (!ipResult) { + subnetInput.classList.add('cbi-input-invalid'); + hasValidationError = true; + } + + // 3. Validate Netmask based on the detected protocol version + if (!rawMask) { + maskInput.classList.add('cbi-input-invalid'); + hasValidationError = true; + } else if (ipResult && ipResult.isV6) { + // IPv6 Prefix Validation (e.g., 64) + const prefixTmp = parseInt(rawMask, 10); + if (isNaN(prefixTmp) || prefixTmp < 0 || prefixTmp > 128) { + maskInput.classList.add('cbi-input-invalid'); + hasValidationError = true; + } else { + cleanMask = prefixTmp.toString(); + cleanSub = ipResult.ip; + } + } else if (ipResult && !ipResult.isV6) { + // Convert pure numeric CIDR strings like "24" or "25" into real netmasks 255.255.255.0 + let maskTmp = rawMask; + if (!isNaN(maskTmp) && parseInt(maskTmp, 10) >= 0 && parseInt(maskTmp, 10) <= 32) { + const numericPrefix = parseInt(maskTmp, 10); + const calcOctets = []; + for (let o = 0; o < 4; o++) { + if (numericPrefix >= (o + 1) * 8) { + calcOctets.push(255); + } else if (numericPrefix <= o * 8) { + calcOctets.push(0); + } else { + const remainingBits = numericPrefix % 8; + calcOctets.push(256 - Math.pow(2, 8 - remainingBits)); + } + } + maskTmp = calcOctets.join('.'); + } + + if (!maskTmp.match(/^([0-9]{1,3}\.){3}[0-9]{1,3}$/)) { + maskInput.classList.add('cbi-input-invalid'); + hasValidationError = true; + } else if (!hasValidationError) { + // Mathematical network boundary fix (Bitwise AND) for IPv4 + const ipSegments = ipResult.ip.split('.'); + const maskSegments = maskTmp.split('.'); + const netSegments = []; + + for (let i = 0; i < 4; i++) { + const ipOctet = parseInt(ipSegments[i], 10); + const maskOctet = parseInt(maskSegments[i], 10); + + if (maskOctet > 255) { + hasValidationError = true; + break; + } + netSegments.push(ipOctet & maskOctet); + } + + if (hasValidationError) { + maskInput.classList.add('cbi-input-invalid'); + } else { + cleanSub = netSegments.join('.'); + cleanMask = maskTmp; + } + } + } + + if (hasValidationError) { + return null; + } + + return { commonName: cleanCn, subnet: cleanSub, mask: cleanMask, isV6: ipResult.isV6 }; + } + }; +}; + +/** + * Creates Port Forwarding warning block with dynamic port state checking and local caching. + */ +const renderPortForwardingAlert = function (networkCallbacks) { + + // Token to stop old background checks if inputs change quickly + let currentExecutionToken = 0; + + // Create the main alert box element + const portNode = E('div', { + 'class': 'alert-message', + 'style': 'margin-bottom:10px; padding:10px; font-size:12px; line-height:1.5; display:none !important;' + }); + const srouteNode = E('div', { + 'class': 'alert-message', + 'style': 'margin-bottom:10px; padding:10px; font-size:12px; line-height:1.5; display:none !important;' + }); + const alertNode = E('div', { + 'style': 'margin-bottom:10px; padding:0; border:0; display:none !important;' + }, [ + portNode, srouteNode + ]); + + const styleAlertInfo = 'margin-bottom:20px; padding:10px; font-size:12px; line-height:1.5; background:color-mix(in srgb, var(--primary-color-high, #1976d2) 6%, transparent); border:1px solid var(--border-color-medium, #cbd5e1); color:var(--text-color-high, #3f3f46); border-radius:4px;'; + const styleStatusMsg = 'display:block; height:30px; box-sizing:border-box; padding-top:4px;'; + const styleLabelSuccess = 'background: var(--success-color-high, #00ac59) !important; color: white !important; font-weight: bold !important; padding: 2px 6px !important; border-radius: 3px !important; margin-left: 7px !important; text-transform: uppercase !important;'; + const styleLabelWarning = 'background: var(--error-color-high, #f62b12) !important; color: white !important; font-weight: bold !important; padding: 2px 6px !important; border-radius: 3px !important; margin-left: 7px !important; text-transform: uppercase !important;'; + const styleLabelFixHint = 'font-weight:bold !important; font-family: var(--font-sans-serif, sans-serif);'; + const styleHintOk = 'margin-top:10px; padding: 5px; background:color-mix(in srgb, var(--primary-color-high, #1976d2) 10%, transparent); border-left:4px solid var(--primary-color-high, #1976d2); font-family:var(--font-monospace, monospace); line-height: 1.6; font-weight:bold; font-size: 13px; text-align: center; border-radius: 4px;'; + const styleHintWarning = 'margin-top:10px; padding: 5px; background:color-mix(in srgb, var(--warn-color-high, #efbd0b) 18%, transparent); border-left:4px solid var(--warn-color-high, #efbd0b); font-family:var(--font-monospace, monospace); line-height: 1.6; font-weight:bold; font-size: 13px; text-align: center; border-radius: 4px;'; + const loadingSpinner = ''; + + const ALERT_DATA_TEMPLATE = Object.freeze({ + doubleNat: false, + publicIp: '', + wanRouterIp: '', + vpnRouterIp: '', + externalPort: '', + internalPort: '', + protoValue: '', + roleValue: '', + remoteSubnets: '', + portCheckTime: '', + isPortOpen: false, + isPortValid: false, + isPortCheckLocked: false, + portStatusText: '', + portStatusIcon: '', + sRouteMissing: false, + sRouteValid: false, + sRouteCheckLocked: false, + sRouteStatusText: '', + sRouteStatusIcon: '' + }); + const alertData = Object.assign({}, ALERT_DATA_TEMPLATE, {}); + const last_alertData = Object.assign({}, ALERT_DATA_TEMPLATE, {}); + + // Server Only: Port forwarding check + const runPortForwardingSubCheck = async function (type) { + + // Create the static layout text layers + let forwardLine; + if (alertData.wanRouterIp && alertData.wanRouterIp !== OPENVPN.IPv4.ZERO) { + forwardLine = alertData.wanRouterIp + ':' + alertData.externalPort + ' ' + ICON.ARROW + ' ' + alertData.vpnRouterIp + ':' + alertData.internalPort; + } else { + forwardLine = TXT.INFO.outside_port + ': ' + alertData.externalPort + ' ' + ICON.ARROW + ' ' + TXT.INFO.inside_port + ': ' + alertData.internalPort; + } + + const portInfo = TXT.MSG.the_port_forward + ' (Internet) ' + alertData.externalPort + ' ' + ICON.ARROW + ' ' + alertData.internalPort + ' (VPN) '; + + if (type == 'init') { + + // a) Show init port alert box immediately as neutral INFO box without networkCallbacks.checkPort() + + let forwardHint = '
' + TXT.INFO.port_forwarding + ': ' + forwardLine + ' (' + alertData.protoValue + ')'; + + alertData.portStatusIcon = ICON.INFO; + alertData.portStatusText = TXT.MSG.another_router_detected; + + portNode.className = 'alert-message'; + portNode.style.cssText = styleAlertInfo; + forwardHint = forwardHint + '
'; + portNode.innerHTML = '' + alertData.portStatusIcon + alertData.portStatusText + '
' + + TXT.MSG.this_router_not_main_internet_router + ' ' + portInfo + '...
' + + '
' + loadingSpinner + '' + TXT.MSG.checking_connection_background + '' + '
' + forwardHint; + + return { valid: true, portOpen: false } + + } else if (type == 'error') { + + // b) Show error port alert box + + let forwardHint = '
' + TXT.INFO.port_forwarding + ': ' + forwardLine + ' (' + alertData.protoValue + ')'; + + portNode.className = 'alert-message'; + portNode.style.cssText = styleAlertInfo; + + alertData.portStatusIcon = ICON.WARNING; + alertData.portStatusText = TXT.MSG.another_router_detected; + + forwardHint = forwardHint + '' + TXT.ERROR.ERROR + '
'; + + portNode.innerHTML = '' + alertData.portStatusIcon + alertData.portStatusText + '
' + + TXT.MSG.this_router_not_main_internet_router + ' ' + portInfo + '
' + '
' + + '' + ICON.ERROR + TXT.MSG.network_verification_failed + '' + '
' + forwardHint; + + + return { valid: true, portOpen: false } + + } else { + + // c) Show normal port alert box with full info + + const isPortOpen = await networkCallbacks.checkPort(alertData.publicIp, alertData.externalPort, alertData.internalPort, alertData.protoValue); + let statusContent = ''; + + if (isPortOpen === true) { + + let forwardHint = '
' + TXT.INFO.port_forwarding + ': ' + forwardLine + ' (' + alertData.protoValue + ')'; + + alertData.portStatusIcon = ICON.CHECK; + alertData.portStatusText = TXT.MSG.open_port_success; + + portNode.className = 'alert-message'; + portNode.style.cssText = styleAlertInfo; + forwardHint = forwardHint + '' + TXT.INFO.OK + '
'; + statusContent = '' + TXT.INFO.status + ': ' + TXT.MSG.ready_for_connection; + + portNode.innerHTML = '' + alertData.portStatusIcon + alertData.portStatusText + '
' + + TXT.MSG.this_router_not_main_internet_router + ' ' + portInfo + TXT.MSG.is_already + + '' + TXT.INFO.OPEN + '
' + + '
' + statusContent + '
' + forwardHint; + } else { + + let forwardHint = '
' + TXT.INFO.port_forwarding + ': ' + forwardLine + ' (' + alertData.protoValue + ')'; + + portNode.className = 'alert-message'; + portNode.style.cssText = styleAlertInfo; + + alertData.portStatusIcon = ICON.WARNING; + alertData.portStatusText = TXT.MSG.another_router_detected; + + forwardHint = forwardHint + '' + TXT.INFO.MISSING + '
'; + statusContent = '' + TXT.INFO.action + ': ' + TXT.MSG.please_create_port_forwarding.format('' + alertData.wanRouterIp + '', '' + alertData.vpnRouterIp + ''); + + portNode.innerHTML = '' + alertData.portStatusIcon + alertData.portStatusText + '
' + + TXT.MSG.this_router_not_main_internet_router + ' ' + portInfo + TXT.MSG.is_currently + + '' + TXT.INFO.CLOSED + '
' + + '
' + statusContent + '
' + forwardHint; + } + return { valid: true, portOpen: isPortOpen }; + } + }; + + // Server & Client: Static routing check + const runStaticRoutingSubCheck = async function () { + const remoteSubnets = alertData.remoteSubnets; + if (!Array.isArray(remoteSubnets) || remoteSubnets.length === 0) { + return { valid: false, sRoutingMissing: false }; + } + + const nostaticSubnetList = []; + const staticSubnetList = []; + let staticRoutingMissingDetected = false; + + let sheetHtml = ''; + + for (let i = 0; i < remoteSubnets.length; i++) { + if (!remoteSubnets[i]) continue; + + // Extract pure clean NET IP block safely from the cached array strings + let pureNetIp = remoteSubnets[i] || ''; + let extractedPrefix = '24'; + if (pureNetIp.includes('/')) { + const res = pureNetIp.split('/'); + pureNetIp = res[0].trim(); + extractedPrefix = res[1]; + } + + // Core Dual-Stack background script trigger pipeline + try { + if (pureNetIp.length > 0) { + const isStaticRoute = await networkCallbacks.checkStaticRoute(alertData.wanRouterIp, pureNetIp); + if (isStaticRoute === true) { + if (!staticSubnetList.includes(pureNetIp)) { + staticSubnetList.push(pureNetIp); + } + } else { + staticRoutingMissingDetected = true; + if (!nostaticSubnetList.includes(pureNetIp)) { + nostaticSubnetList.push(pureNetIp); + } + } + if (sheetHtml.length > 0) { + sheetHtml += '
' + } + if (remoteSubnets[i].includes(':')) { + + // IPv6: Compact CIDR Notation (e.g. Destination: fd00:db8:10::/64) + sheetHtml += ICON.LINK + TXT.INFO.static_route + ' ' + ICON.ARROW + ' IP: ' + remoteSubnets[i] + ', ' + TXT.INFO.gateway + ' IP: ' + alertData.vpnRouterIp; + } else { + + // IPv4: Split Matrix Layout (e.g. Destination: 192.168.10.0, Mask: 255.255.255.0) + let calculatedMask = OPENVPN.IPv4.MASK24; + if (extractedPrefix !== '24') { + const maskArray = [0, 0, 0, 0]; + const inlinePrefixNum = parseInt(extractedPrefix, 10) || 24; + for (let bit4 = 0; bit4 < 32; bit4++) { + if (bit4 < inlinePrefixNum) { + maskArray[Math.floor(bit4 / 8)] |= (1 << (7 - (bit4 % 8))); + } + } + calculatedMask = maskArray.join('.'); + } + sheetHtml += ICON.LINK + TXT.INFO.static_route + ' ' + ICON.ARROW + ' ' + TXT.INFO.destination + ' IP: ' + pureNetIp + ', ' + TXT.INFO.mask + ': ' + calculatedMask + ', ' + TXT.INFO.gateway + ' IP: ' + alertData.vpnRouterIp; + } + if (isStaticRoute) { + sheetHtml += '' + TXT.INFO.OK + ''; + } else { + sheetHtml += '' + TXT.INFO.MISSING + ''; + } + } + + } catch (err) { + console.error('Background static route scanner failed:', err); + } + } + + // no subnet found + if (nostaticSubnetList.length === 0 && staticSubnetList.length === 0) { + return { valid: false, sRoutingMissing: false }; + } + + if (staticRoutingMissingDetected === false) { + + const staticRouteHint = '
' + sheetHtml + '
'; + const staticCommaSeparatedNets = staticSubnetList.join(', '); + const statusContent = '' + TXT.INFO.status + ': ' + TXT.MSG.ready_for_connection; + + alertData.sRouteStatusIcon = ICON.CHECK; + alertData.sRouteStatusText = TXT.MSG.static_routing_success; + + srouteNode.className = 'alert-message'; + srouteNode.style.cssText = styleAlertInfo; + + srouteNode.innerHTML = '' + alertData.sRouteStatusIcon + alertData.sRouteStatusText + '
' + + TXT.MSG.routing_vpn_main_internet.format('' + alertData.wanRouterIp + '', '' + staticCommaSeparatedNets + '') + ' ' + TXT.MSG.is_already + + '' + TXT.INFO.WORKING + '
' + + '
' + statusContent + '
' + staticRouteHint; + + } else { + + const staticRouteHint = '
' + sheetHtml + '
'; + const nostaticCommaSeparatedNets = nostaticSubnetList.join(', '); + const actionContent = '' + TXT.INFO.action + ': ' + TXT.MSG.please_add_static_routing.format('' + alertData.wanRouterIp + '', '' + alertData.vpnRouterIp + ''); + + alertData.sRouteStatusIcon = ICON.WARNING; + alertData.sRouteStatusText = TXT.MSG.static_routing_missing; + + srouteNode.className = 'alert-message'; + srouteNode.style.cssText = styleAlertInfo; + + srouteNode.innerHTML = '' + alertData.sRouteStatusIcon + alertData.sRouteStatusText + '
' + + TXT.MSG.routing_vpn_main_internet.format('' + alertData.wanRouterIp + '', '' + nostaticCommaSeparatedNets + '') + ' ' + TXT.MSG.is_currently + + '' + TXT.INFO.BLOCKED + '
' + + '
' + actionContent + '
' + staticRouteHint; + } + + return { valid: true, sRoutingMissing: staticRoutingMissingDetected }; + }; + + // Background port open and static routing check + const runBackgroundPortCheck = async function (executionToken, force) { + const isServer = (alertData.roleValue === OPENVPN.ROLE.SERVER); + try { + alertData.publicIp = await networkCallbacks.queryPublicIp(force) || alertData.vpnRouterIp; + let result = null; + + // Get isPortOpen + if (alertData.isPortCheckLocked === false) { + alertData.isPortCheckLocked = true; + { + alertData.isPortValid = false; + alertData.isPortOpen = false; + if (isServer === true) { + result = await runPortForwardingSubCheck('normal'); + if (result) { + alertData.isPortOpen = result.portOpen; + alertData.isPortValid = result.valid; + } + portNode.style.setProperty('display', 'block', 'important'); + } else { + portNode.style.setProperty('display', 'none', 'important'); + } + } + alertData.isPortCheckLocked = false; + } + + // Stop execution loops safely if a newer input check is already active + if (executionToken !== currentExecutionToken) { + return; + } + + // Get staticRoute + if (alertData.sRouteCheckLocked === false) { + alertData.sRouteCheckLocked = true; + { + alertData.sRouteValid = false; + alertData.sRouteMissing = false; + result = await runStaticRoutingSubCheck(); + if (result) { + alertData.sRouteMissing = result.sRoutingMissing; + alertData.sRouteValid = result.valid; + } + if (alertData.sRouteValid === true) { + srouteNode.style.setProperty('display', 'block', 'important'); + } else { + srouteNode.style.setProperty('display', 'none', 'important'); + } + } + alertData.sRouteCheckLocked = false; + } + + // Stop execution loops safely if a newer input check is already active + if (executionToken !== currentExecutionToken) { + return; + } + + // Update last_alertData for caching + Object.assign(last_alertData, alertData, { + portCheckTime: Date.now() + }); + + if (alertData.doubleNat === true) { + alertNode.style.setProperty('display', 'block', 'important'); + } else { + alertNode.style.setProperty('display', 'none', 'important'); + } + return alertData; + + } catch (e) { + console.error("Background multi-diagnostic check pipeline crashed:", e); + if (isServer === true) { + if (executionToken !== currentExecutionToken) return; + await runPortForwardingSubCheck('error'); + portNode.style.setProperty('display', 'block', 'important'); + alertNode.style.setProperty('display', 'block', 'important'); + } + } + }; + + return { + + // The HTML element to insert into the view layout + node: alertNode, + + // Check if the alert is currently visible on the screen + isAlertActive: function () { + return (alertNode.style.display !== 'none'); + }, + + // Check if we need to show the PortForwarding and Static Routing Alert + check: async function (protoValue, roleValue, portValue, clientPortValue, remoteSubnets, appData, force, runBackgroundPortCheckExtern) { + + const isServer = (roleValue === OPENVPN.ROLE.SERVER); + + // STAGE 1: Fast check of the local network structure + const networkState = await networkCallbacks.checkNetworkStructure(appData); + + // Stop instantly if topology operates in standard gateway routing + if (!networkState || networkState.doubleNat !== true || networkState.apMode === true) { + alertNode.style.setProperty('display', 'none', 'important'); + alertData.doubleNat = false; + return true; + } else { + alertData.doubleNat = true; + } + + alertData.protoValue = protoValue; + alertData.roleValue = roleValue; + alertData.portValue = portValue; + alertData.remoteSubnets = remoteSubnets; + alertData.vpnRouterIp = await networkCallbacks.getLanIp(); + + // Safe parsing of port values if they are arrays (takes the first element) + const rawIntPort = Array.isArray(portValue) ? portValue[0] : portValue; + alertData.internalPort = String(rawIntPort || OPENVPN.PORT.s1194).trim(); + const rawExtPort = Array.isArray(clientPortValue) ? clientPortValue[0] : clientPortValue; + alertData.externalPort = String(rawExtPort || alertData.internalPort).trim(); + alertData.wanRouterIp = networkState.gateway || OPENVPN.IPv4.ZERO; + + // Check if we can skip the intensive check and use cached results + let skipPortcheck = false; + if (force !== true) { + let keysAreEqual = true; + const templateKeys = Object.keys(ALERT_DATA_TEMPLATE); + for (let i = 0; i < templateKeys.length; i++) { + const key = templateKeys[i]; + // Skip the timestamp during the hard equality match loop + if (key === 'portCheckTime') continue; + if (last_alertData[key] !== alertData[key]) { + keysAreEqual = false; + // Exit instantly if even one single metric mismatches + break; + } + } + // If all configuration metrics match AND the cache is younger than 60 seconds, skip the check! + if (keysAreEqual === true && Math.abs(Date.now() - last_alertData.portCheckTime) < 60000) { + skipPortcheck = true; + } + } + + // If cache is valid, wrap the UI rendering into the external execution path too + if (skipPortcheck === true) { + // runBackgroundPortCheck: set for later execute + if (runBackgroundPortCheckExtern && typeof runBackgroundPortCheckExtern === 'object') { + runBackgroundPortCheckExtern.execute = function () { + if (alertData.doubleNat === true) { + alertNode.style.setProperty('display', 'block', 'important'); + } else { + alertNode.style.setProperty('display', 'none', 'important'); + } + if (runBackgroundPortCheckExtern.callback && typeof runBackgroundPortCheckExtern.callback === 'function') { + const show = (alertData.isPortValid && !alertData.isPortOpen) || (alertData.sRouteValid && alertData.sRouteMissing); + runBackgroundPortCheckExtern.callback(show, alertData) + } + }; + } + return true; + } + + // STAGE 2: initial Rendering (show the alert box immediately as neutral INFO box) + if (isServer) { + await runPortForwardingSubCheck('init'); + portNode.style.setProperty('display', 'block', 'important'); + alertNode.style.setProperty('display', 'block', 'important'); + } + + // STAGE 3: runBackgroundPortCheck: if extern set for later execute or run directly + currentExecutionToken = currentExecutionToken + 1; + const executionToken = currentExecutionToken; + if (runBackgroundPortCheckExtern && typeof runBackgroundPortCheckExtern === 'object') { + runBackgroundPortCheckExtern.execute = function () { + if (alertData.doubleNat === true) { + runBackgroundPortCheck(executionToken, force).then(function () { + // refresh after the background port check the visibility again (unfold or collapsed the portcheck view) + if (runBackgroundPortCheckExtern.callback && typeof runBackgroundPortCheckExtern.callback === 'function') { + const show = (alertData.isPortValid && !alertData.isPortOpen) || (alertData.sRouteValid && alertData.sRouteMissing); + runBackgroundPortCheckExtern.callback(show, alertData) + } + }); + } + // refresh immediatly the visibility (show collapsed by default) + if (runBackgroundPortCheckExtern.callback && typeof runBackgroundPortCheckExtern.callback === 'function') { + runBackgroundPortCheckExtern.callback(false, alertData) + } + }; + } else { + runBackgroundPortCheck(executionToken, force); + } + + return true; + } + }; +} + +/** + * Converts a wizard scenario string into the official server or client role definition. + */ +const getRoleFromScenarioValue = function (scenarioValue) { + const isAnyServer = (scenarioValue === OPENVPN.SCENARIO.CONNECT_SERVER || scenarioValue === OPENVPN.SCENARIO.SITE_TO_SITE_SERVER); + if (isAnyServer) { + return OPENVPN.ROLE.SERVER; + } else { + return OPENVPN.ROLE.CLIENT; + } +} + +/** + * Supported message types: 'error' (red), 'ok' (green), 'info' (blue), 'warning' (yellow/orange). + */ +const MESSAGE_TYPE = Object.freeze({ + OK: 'ok', + INFO: 'info', + ERROR: 'error', + WARNING: 'warning' +}); + +/** + * Shows an inline status message inside the wizard. + */ +const showMessage = function (text, type, box) { + if (!box) { + return; + } + + // Clear old text or old action buttons + box.innerHTML = ''; + + // Default system colors matching official LuCI framework tokens + let activeColor = 'var(--primary-color-high, #1976d2)'; + let bgPercent = '10%'; + + // Switch color tokens based on the strict frozen MESSAGE_TYPE + if (type === MESSAGE_TYPE.ERROR) { + activeColor = 'var(--success-color-high, #00ac59)'; + } else if (type === MESSAGE_TYPE.OK) { + activeColor = 'var(--success-color-high, #00ac59)'; + } else if (type === MESSAGE_TYPE.WARNING) { + activeColor = 'var(--warn-color-high, #efbd0b)'; + bgPercent = '18%'; + } else if (type === MESSAGE_TYPE.INFO) { + activeColor = 'var(--primary-color-high, #1976d2)'; + } + + // Dynamically mix the background transparency based on the selected color token + const activeBg = 'color-mix(in srgb, ' + activeColor + ' ' + bgPercent + ', transparent)'; + + // Build a clean, styled message block matching LuCI guidelines + const innerBlock = E('div', { + 'style': 'padding:10px; border-radius:4px; font-weight:bold; font-size:13px; line-height:1.5; ' + + 'font-family:var(--font-monospace, monospace); ' + + 'border-left:4px solid ' + activeColor + '; ' + + 'background:' + activeBg + '; ' + + 'color:var(--text-color-high, #3f3f46);' + }, text); + + box.appendChild(innerBlock); + box.style.setProperty('display', 'block', 'important'); +}; + + +const hideMessage = function (box) { + box.style.setProperty('display', 'none', 'important'); +} + + +/* + * --- IMPORT / EXPORT --- + */ + + +/** + * Reads and saves an uploaded file or updates keys for a client vpn. + */ +const importClientInstance = async function (elements, wizardData, nextBtn, prevBtn) { + const optSiteClient = elements.subNodes.siteClient; + const displayNameInput = elements.inputs.displayName; + if (!optSiteClient.validate(false)) { + return; + } + + // Disable buttons so the user cannot click twice during import + nextBtn.disabled = true; + prevBtn.disabled = true; + nextBtn.textContent = ICON.LOADING + TXT.INFO.importing_profile; + nextBtn.classList.remove('cbi-input-invalid'); + + const activeSubParams = elements.subNodes.siteClient.getParams(); + const cleanCustomName = sanitizeInputLine(displayNameInput.value); + let targetInstanceId = 'instance' + wizardData.instanceNumber; + + if (cleanCustomName && cleanCustomName.match(/^instance\d+$/i)) { + targetInstanceId = cleanCustomName.toLowerCase(); + } + + if (typeof wizardData.importOvpnClientProfileCallback !== 'function') { + return; + } + + try { + const extractedParams = await wizardData.importOvpnClientProfileCallback( + activeSubParams.importedRawOvpn, + targetInstanceId + ); + + // Path A: Update security keys only + if (extractedParams.isCryptoUpdateOnly === true) { + L.ui.hideModal(); + await new Promise(window.requestAnimationFrame); + await new Promise(window.requestAnimationFrame); + + L.ui.addNotification(null, E('p', {}, TXT.MSG.keys_updated_successfully), 'ok'); + + if (typeof wizardData.uciSaveApplyRestartCallback === 'function') { + wizardData.uciSaveApplyRestartCallback(targetInstanceId); + } + return; + } + + // Path B: Full setup for a brand new profile installation + const wizardParams = Object.assign({}, WIZARD_PARAMS_TEMPLATE, { + clients: [], + role: OPENVPN.ROLE.CLIENT, + port: extractedParams.port, + proto: extractedParams.proto, + displayName: extractedParams.cname || cleanCustomName || TXT.INFO.imported_clients, + strategy: OPENVPN.STRATEGY.STANDARD, + remoteServer: extractedParams.remoteServer, + remoteServerLan: extractedParams.remoteServerLan, + isApMode: false, + ddnsOrPublicIp: '', + importedRawOvpn: activeSubParams.importedRawOvpn || '' + }); + + if (typeof wizardData.addNewInstanceCallback === 'function') { + L.ui.hideModal(); + await new Promise(window.requestAnimationFrame); + await new Promise(window.requestAnimationFrame); + await wizardData.addNewInstanceCallback(wizardParams.role, wizardData.appData, wizardParams); + } + } catch (err) { + // Reset buttons instantly so the user can try another file right away + nextBtn.classList.add('cbi-input-invalid'); + nextBtn.disabled = false; + prevBtn.disabled = false; + nextBtn.textContent = TXT.BTN.next + ' ' + ICON.ARROW; + + // NEW: Use the global showMessage function with elements.info.messageBox + const errorContent = E('div', {}, [ + E('strong', {}, ICON.ERROR + ' ' + TXT.INFO.imported_failed), + E('p', { 'style': 'margin-top:4px;' }, err.message) + ]); + + showMessage(errorContent, MESSAGE_TYPE.ERROR, elements.info.messageBox); + } +}; + +/** + * Puts all settings together and creates the OpenVPN server. + */ +const createServerInstance = async function (scenarioSelect, elements, protoSelect, wizardData, nextBtn, prevBtn) { + const scenario = scenarioSelect.value; + nextBtn.disabled = true; + prevBtn.disabled = true; + nextBtn.textContent = ' ' + TXT.INFO.creating_vpn; + + let activeSubParams = {}; + if (scenario === OPENVPN.SCENARIO.CONNECT_SERVER) { + activeSubParams = elements.subNodes.connectServer.getParams(); + } + if (scenario === OPENVPN.SCENARIO.SITE_TO_SITE_SERVER) { + activeSubParams = elements.subNodes.siteServer.getParams(); + } + + const internalPortVal = parseInt(sanitizeInputLine(elements.inputs.port.value), 10) || (wizardData.appData.statusClass.calcPortFromId(null, wizardData.instanceNumber)); + const externalPortVal = elements.inputs.externPort ? (parseInt(sanitizeInputLine(elements.inputs.externPort.value), 10) || internalPortVal) : internalPortVal; + const connType = elements.inputs.connectionType.value; + + let provider = ''; + let url = ''; + let domain = ''; + let publicIpOrDomain = ''; // Cleaned buffer for the raw IP/Domain input field + + // PATH A: If Dynamic DNS is selected, clean the ddnsDomain input field + if (connType === OPENVPN.CONN_TYPE.DDNS) { + provider = elements.inputs.ddnsProvider.value; + const rawDomain = sanitizeInputLine(elements.inputs.ddnsDomain.value); + domain = await wizardData.networkCallbacks.cleanIpOrDomain(rawDomain); + let token = ''; + let ddnsUser = ''; + if (provider === 'custom') { + if (elements.inputs.ddnsCustomUrl) { + ddnsUser = sanitizeInputLine(elements.inputs.ddnsCustomUrl.value); + } + } + else { + token = sanitizeInputLine(elements.inputs.ddnsToken.value); + if (elements.inputs.ddnsUsername) { + ddnsUser = sanitizeInputLine(elements.inputs.ddnsUsername.value); + } + } + + url = await buildDdnsUpdateUrl(provider, domain, token, ddnsUser, wizardData, null); + + } + // PATH B: If standard IP connection is selected, clean the ddns input field + else { + const rawIpOrDomain = sanitizeInputLine(elements.inputs.ddns.value); + // clean the raw IP or connection host field + publicIpOrDomain = await wizardData.networkCallbacks.cleanIpOrDomain(rawIpOrDomain); + } + + // Assemble only the structural parameters required by the system + const wizardParams = Object.assign({}, WIZARD_PARAMS_TEMPLATE, { + role: OPENVPN.ROLE.SERVER, + port: internalPortVal, + portExtern: activeSubParams.isApMode ? externalPortVal : internalPortVal, + proto: protoSelect.value, + displayName: sanitizeInputLine(elements.inputs.displayName.value), + strategy: activeSubParams.strategy || OPENVPN.STRATEGY.STANDARD, + clients: Array.isArray(activeSubParams.clients) ? activeSubParams.clients : [], + isApMode: activeSubParams.isApMode, + connectionType: connType, + // SMART ROUTING: Passes the correctly cleaned field based on connection type selection + ddnsOrPublicIp: connType === OPENVPN.CONN_TYPE.DDNS ? domain : publicIpOrDomain, + isStaticIp: elements.inputs.isStatic.checked, + ddnsProvider: provider, + ddnsUrl: url + }); + if (typeof wizardData.addNewInstanceCallback === 'function') { + L.ui.hideModal(); + await new Promise(window.requestAnimationFrame); + await new Promise(window.requestAnimationFrame); + try { + await wizardData.addNewInstanceCallback(wizardParams.role, wizardData.appData, wizardParams); + } catch (err) { + L.ui.addNotification(null, E('p', {}, TXT.ERROR.create_openvpn_server + ' ' + err.message), 'error'); + } + } +}; + + +/* + * --- SETUP WIZARD --- + */ + + +/** + * OPTION 1: Mobile Devices & Laptops (OpenVPN Connect App) + */ +const renderOptionConnectServer = function (portForwardingAlert) { + // Create two simple radio buttons to choose the internet traffic rule + const radioRedirect = E('input', { 'type': 'radio', 'name': 'wizard_strategy_group', 'value': OPENVPN.STRATEGY.REDIRECT, 'checked': 'checked', 'style': 'width:18px; height:18px; margin:0; cursor:pointer;' }); + const radioLanOnly = E('input', { 'type': 'radio', 'name': 'wizard_strategy_group', 'value': OPENVPN.STRATEGY.STANDARD, 'style': 'width:18px; height:18px; margin:0; cursor:pointer;' }); + + // Build the HTML layout block with simple labels + const node = E('div', { 'style': 'margin-bottom:15px;' }, [ + E('div', { 'class': 'cbi-value' }, [ + E('label', { 'class': 'cbi-value-title' }, TXT.INFO.internet_traffic), + E('div', { 'class': 'cbi-value-field', 'style': 'width:100%; display:flex; flex-direction:column; gap:10px; padding-top:4px;' }, [ + E('label', { 'style': 'display:inline-flex; align-items:center; cursor:pointer; font-weight:bold;' }, [ + radioRedirect, + E('span', { 'style': 'margin-left:8px;' }, [ICON.SHIELD + TXT.INFO.secure_tunnel]) + ]), + E('label', { 'style': 'display:inline-flex; align-items:center; cursor:pointer; font-weight:bold;' }, [ + radioLanOnly, + E('span', { 'style': 'margin-left:8px;' }, [ICON.SPLIT + TXT.INFO.split_tunnel]) + ]), + E('div', { 'class': 'cbi-value-description', 'style': 'margin-top:6px; margin-left:0; padding-left:0; color:color-mix(in srgb, var(--text-color-medium, #71717a) 85%, #0055ff)' }, TXT.MSG.choose_how_send_through_vpn) + ]) + ]), + // Inject the physical HTML element from the alert component framework + portForwardingAlert.node + ]); + + // Return data and functions back to the main wizard framework + return { + node: node, + + // No special field check needed for this option, always return true + validate: function () { return true; }, + + getParams: function () { + return { + role: OPENVPN.ROLE.SERVER, + strategy: radioRedirect.checked ? OPENVPN.STRATEGY.REDIRECT : OPENVPN.STRATEGY.STANDARD, + isApMode: portForwardingAlert.isAlertActive() + }; + } + }; +}; + +/** + * OPTION 2: OpenVPN Server LAN-to-LAN (Main Headquarters Multi-Client) + */ +const renderOptionSiteServer = function (portForwardingAlert, wizardData) { + // 1. Create the main HTML box + const containerNode = E('div', { 'id': 'wizard_site_server_container' }); + + // This list stores all added remote offices + let clients = []; + + // 2. Short description at the top + containerNode.appendChild(E('div', { 'class': 'cbi-section-descr', 'style': 'margin-bottom:15px; font-size:12px;' }, [ICON.LINK + TXT.MSG.add_your_other_office])); + + // 3. Create the table to show added offices + const tableBody = E('tbody'); + const branchesTable = E('table', { 'class': 'table cbi-section-table', 'style': 'width:100%; margin-bottom:15px; text-align:left;' }, [ + E('thead', {}, [ + E('tr', { 'class': 'cbi-section-table-titles' }, [ + E('th', { 'class': 'cbi-section-table-cell' }, [TXT.INFO.remote_name]), + E('th', { 'class': 'cbi-section-table-cell' }, [TXT.INFO.remote_lan_subnet]), + E('th', { 'class': 'cbi-section-table-cell' }, [TXT.INFO.netmask]), + E('th', { 'class': 'cbi-section-table-cell', 'style': 'width:10%; text-align:right;' }, [TXT.INFO.action]) + ]) + ]), + tableBody + ]); + + // Creates a row in the table for an office + const renderClientRow = function (clientItem, index) { + const deleteBtn = E('button', { + 'class': 'cbi-button cbi-button-remove', + 'title': TXT.MSG.delete_this_profile + }, [ICON.REMOVE + TXT.BTN.delete]); + + // Remove the office from the list when clicking delete + deleteBtn.addEventListener('click', function (ev) { + ev.preventDefault(); + clients.splice(index, 1); + refreshTableDisplay(); + }); + + return E('tr', { 'class': 'cbi-section-table-row' }, [ + E('td', { 'style': 'font-family:monospace; font-weight:bold;' }, [clientItem.commonName]), + E('td', { 'style': 'font-family:monospace;' }, [clientItem.subnet]), + E('td', { 'style': 'font-family:monospace;' }, [clientItem.mask]), + E('td', { 'style': 'text-align:right;' }, [deleteBtn]) + ]); + }; + + + // Updates the table display + const refreshTableDisplay = function () { + tableBody.innerHTML = ''; + + if (clients.length === 0) { + tableBody.appendChild(E('tr', {}, [ + E('td', { 'colspan': '4', 'style': 'text-align:center; font-style:italic; padding:15px; color:color-mix(in srgb, var(--text-color-medium, #71717a) 85%, #0055ff);' }, [ + TXT.MSG.no_offices_added_yet + ]) + ])); + branchesTable.style.display = 'none'; + } else { + clients.forEach(function (client, idx) { + tableBody.appendChild(renderClientRow(client, idx)); + }); + branchesTable.style.display = 'table'; + } + }; + + // 4. Load the input fields row component + const networkInputComponent = renderSubnetInputs(); + + // 5. Create the "Add" button + const addEntryBtn = E('button', { + 'class': 'cbi-button cbi-button-add', + 'style': 'margin-top:8px; margin-bottom:20px; font-weight:bold;' + }, [ICON.PLUS + TXT.BTN.add_office]); + + // 6. Create the red error box below the button + const errorDisplayNode = E('div', { + 'style': 'display:none; margin-top:10px; margin-bottom:15px; padding:10px; border-left:4px solid var(--success-color-high, #00ac59); background:color-mix(in srgb, var(--success-color-high, #00ac59) 10%, transparent); color:var(--success-color-high, #00ac59); font-weight:bold; border-radius:4px; font-size:13px;' + }); + + // When clicking the "Add Office" button + addEntryBtn.addEventListener('click', function (ev) { + ev.preventDefault(); + errorDisplayNode.style.display = 'none'; + + const validatedData = networkInputComponent.validateAndFetchData(); + if (!validatedData) { + // Stop if input syntax is wrong (field turns red) + return; + } + + // Run the asynchronous network state tracker check + wizardData.networkCallbacks.checkNetworkStructure(wizardData.appData).then(function (networkState) { + const blockedSubnets = networkState.localSubnets || []; + + // HARD PROTECTION: Check if input matches any local LAN or WAN network range + let invalidSubNet = false; + blockedSubnets.forEach(function (subnet) { + if (subnet.indexOf(validatedData.subnet) !== -1) { + errorDisplayNode.innerHTML = ICON.WARNING + TXT.ERROR.use_local_ipnetwork + errorDisplayNode.style.display = 'block'; + networkInputComponent.fields.sub.classList.add('cbi-input-invalid'); + invalidSubNet = true; + return; + } + }); + if (invalidSubNet == true) { + return; + } + + // Check for duplicate names inside the active memory list + const isDuplicateCN = clients.some(function (c) { + return c.commonName.toLowerCase() === validatedData.commonName.toLowerCase(); + }); + + // Check for duplicate IP networks inside the active memory list + const isDuplicateSubnet = clients.some(function (c) { + return c.subnet === validatedData.subnet; + }); + + if (isDuplicateCN) { + errorDisplayNode.innerHTML = ICON.WARNING + TXT.ERROR.office_name_already_exist; + errorDisplayNode.style.display = 'block'; + return; + } + + if (isDuplicateSubnet) { + errorDisplayNode.innerHTML = ICON.WARNING + TXT.ERROR.ip_used_on_other_office; + errorDisplayNode.style.display = 'block'; + return; + } + + // Save the verified office data to the list + clients.push({ + commonName: validatedData.commonName, + subnet: validatedData.subnet, + mask: validatedData.mask + }); + + // Clear text boxes for the next entry + networkInputComponent.fields.cn.value = ''; + networkInputComponent.fields.sub.value = ''; + networkInputComponent.fields.mask.value = ''; + + refreshTableDisplay(); + networkInputComponent.fields.cn.focus(); + }).catch(function () { + errorDisplayNode.innerHTML = ICON.WARNING + TXT.ERROR.net_status_check; + errorDisplayNode.style.display = 'block'; + }); + }); + + // 7. Add all built elements to the main container box + containerNode.appendChild(branchesTable); + containerNode.appendChild(networkInputComponent.node); + containerNode.appendChild(addEntryBtn); + containerNode.appendChild(errorDisplayNode); + + refreshTableDisplay(); + + // 8. Return data back to the main wizard framework + return { + node: containerNode, + + // This validation interceptor runs automatically when clicking the main "Next" button + validate: function () { + errorDisplayNode.style.display = 'none'; + + const cnVal = sanitizeInputLine(networkInputComponent.fields.cn.value); + const subVal = sanitizeInputLine(networkInputComponent.fields.sub.value); + const maskVal = sanitizeInputLine(networkInputComponent.fields.mask.value); + + // Case A: All input text boxes are empty + if (cnVal === '' && subVal === '' && maskVal === '') { + if (clients.length === 0) { + errorDisplayNode.innerHTML = ICON.WARNING + TXT.ERROR.add_at_least_one_office; + errorDisplayNode.style.display = 'block'; + networkInputComponent.fields.cn.focus(); + return false; // Blocks navigation to step 3 + } + // List has items, progression permitted + return true; + } + + // Case B: User filled out fields but forgot to click the "Add Office" button + const validatedData = networkInputComponent.validateAndFetchData(); + if (!validatedData) { + // Input check failed, block step transition + return false; + } + + // Since validate() must be synchronous in LuCI view hooks, we check against the + // last cached state or block unsafe inline commits to enforce hitting the Add button. + errorDisplayNode.innerHTML = ICON.WARNING + TXT.WARNING.unsaved_text_input; + errorDisplayNode.style.display = 'block'; + // Guard routing loop safely + return false; + }, + + getParams: function () { + return { + role: OPENVPN.ROLE.SERVER, + strategy: OPENVPN.STRATEGY.SITETOSITE, + isApMode: portForwardingAlert.isAlertActive(), + clients: clients + }; + } + }; +}; + +/** + * OPTION 3: OpenVPN Client LAN-to-LAN (Branch Office) - Supports full .ovpn and naked multi-key .crt rotation. + */ +const renderOptionSiteClient = function (buttons, messageBox) { + // 1. The structural raw file input (hidden from the user) + const fileInput = E('input', { + 'type': 'file', + 'accept': '.ovpn,.crt', + 'style': 'display: none;' + }); + + // 2. The new styled LuCI/Bootstrap replacement button containing the icon + const customUploadBtn = E('button', { + 'class': 'cbi-button cbi-button-neutral', + 'style': 'margin-right: 10px; font-weight: bold; min-width: 140px;', + 'click': function (clickEv) { + clickEv.preventDefault(); + // Forwards the click into the hidden file input layer safely + fileInput.click(); + } + }, [ICON.IMPORT + TXT.BTN.choose_file]); + + // 3. Dynamic label element showing the selected filename text line + const fileNameLabel = E('span', { + 'style': 'font-style: italic; font-size: 13px; color:color-mix(in srgb, var(--text-color-medium, #71717a) 85%, #0055ff);' + }, TXT.MSG.no_file_choosen); + + // 4. Flexbox wrapper aligning our custom button and the filename label perfectly + const uploadWrapperField = E('div', { + 'style': 'display: inline-flex; align-items: center; width: 100%;' + }, [ + fileInput, + customUploadBtn, + fileNameLabel + ]); + + if (buttons && buttons.next) { + buttons.next.disabled = true; + } + + let uploadedFileContentStr = ''; + + // When the user selects a file (triggered after selecting via the custom button) + fileInput.addEventListener('change', function (ev) { + fileInput.classList.remove('cbi-input-invalid'); + // Clear custom button error track + customUploadBtn.classList.remove('cbi-input-invalid'); + + if (buttons && buttons.next) { + buttons.next.disabled = true; + } + + const files = ev.target.files; + if (!files || files.length === 0) { + fileNameLabel.textContent = TXT.MSG.no_file_choosen; + if (messageBox) hideMessage(messageBox); + uploadedFileContentStr = ''; + return; + } + + // Update the custom label text dynamically with the real filename + fileNameLabel.textContent = files[0].name; + + const fileNameString = String(files[0].name || '').toLowerCase(); + const isCrtExtension = fileNameString.endsWith('.crt'); + + const reader = new FileReader(); + reader.onload = function (e) { + const text = sanitizeInputText(e.target.result); + + if (!text) { + fileInput.classList.add('cbi-input-invalid'); + // Mark custom button red on failure + customUploadBtn.classList.add('cbi-input-invalid'); + fileInput.value = ''; + fileNameLabel.textContent = TXT.MSG.no_file_choosen; + if (messageBox) hideMessage(messageBox); + uploadedFileContentStr = ''; + return; + } + + const remoteServerMatch = text.match(/^remote\s+(\S+)\s+(\d+)/m); + const protoMatch = text.match(/^proto\s+(\S+)/m); + const hasCaTag = (text.indexOf('') !== -1); + + if (remoteServerMatch) { + // PATH A: Valid full configuration profile (.ovpn) + uploadedFileContentStr = text; + + const successContent = E('div', {}, [ + E('strong', {}, ICON.SUCCESS + ' ' + TXT.INFO.openvpn_profile_valid), + E('br'), + E('span', {}, [ + ICON.ARROW + ' ' + TXT.INFO.server_address + ': ', + E('code', { 'style': 'font-weight:bold; color:var(--primary-color-high, #1976d2);' }, + remoteServerMatch[1] + ':' + remoteServerMatch[2] + ' (' + (protoMatch ? protoMatch[1] : OPENVPN.PROTO.UDP) + ')' + ) + ]) + ]); + + showMessage(successContent, MESSAGE_TYPE.OK, messageBox); + + if (buttons && buttons.next) { + buttons.next.disabled = false; + } + } + else if (isCrtExtension === true || (hasCaTag === true && !remoteServerMatch)) { + // PATH B: Valid cryptographic multi-key bundle (.crt) + uploadedFileContentStr = text; + + const warningContent = E('div', {}, [ + E('strong', {}, ICON.WARNING + ' ' + TXT.WARNING.openvpn_key_detected), + E('br'), + E('span', { 'style': 'color:var(--success-color-high, #00ac59); font-weight:bold;' }, ICON.ARROW + ' ' + TXT.INFO.key_update_active), + E('br'), + E('span', {}, TXT.MSG.only_vpn_client_keys), + E('br'), + E('span', { 'style': 'color:var(--success-color-high, #00ac59); font-weight:bold;' }, TXT.WARNING.upload_wrong_keys) + ]); + + + showMessage(warningContent, MESSAGE_TYPE.WARNING, messageBox); + + if (buttons && buttons.next) { + buttons.next.disabled = false; + } + } + else { + // PATH C: File is corrupted or missing vital fields + fileInput.classList.add('cbi-input-invalid'); + customUploadBtn.classList.add('cbi-input-invalid'); + fileInput.value = ''; + fileNameLabel.textContent = TXT.MSG.no_file_choosen; + // Build a clean, styled DOM element block for the dynamic error container + const corruptedFileError = E('div', {}, [ + E('strong', {}, ICON.ERROR + ' ' + TXT.ERROR.file_corrupted), + E('p', { 'style': 'margin-top:4px;' }, TXT.MSG.please_check_profe_file) + ]); + showMessage(corruptedFileError, MESSAGE_TYPE.ERROR, messageBox); + uploadedFileContentStr = ''; + } + }; + reader.readAsText(files[0]); + }); + + // 5. Build the clean HTML row block layout structure + const node = E('div', { 'style': 'margin-bottom:15px;' }, [ + E('div', { 'class': 'cbi-value' }, [ + // Left side title row label is now clean and clear + E('label', { 'class': 'cbi-value-title' }, TXT.INFO.import_profile), + E('div', { 'class': 'cbi-value-field' }, [ + uploadWrapperField, + E('div', { 'class': 'cbi-value-description', 'style': 'margin-top:4px; color:color-mix(in srgb, var(--text-color-medium, #71717a) 85%, #0055ff);' }, TXT.MSG.select_ovpn_profile) + ]) + ]) + ]); + + return { + node: node, + validate: function (newStep) { + fileInput.classList.remove('cbi-input-invalid'); + customUploadBtn.classList.remove('cbi-input-invalid'); + if (!uploadedFileContentStr) { + if (!newStep) { + fileInput.classList.add('cbi-input-invalid'); + customUploadBtn.classList.add('cbi-input-invalid'); + } + return false; + } + return true; + }, + getParams: function () { + return { + role: OPENVPN.ROLE.CLIENT, + strategy: OPENVPN.STRATEGY.STANDARD, + importedRawOvpn: uploadedFileContentStr + }; + } + }; +}; + +/** + * Builds the HTML layout block for Step 1 (VPN Name and Selection) + */ +const buildWizardStep1Row = function (displayNameInput, radioConnectServer, radioSiteServer, radioSiteClientMatrix, hideClient) { + + let styleHide = ''; + if (hideClient === true) { + styleHide = ' display:none !important;'; + } + + return E('div', { 'style': 'display:block; margin-bottom:15px;' }, [ + // Row A: Choose a custom name for the VPN instance + E('div', { 'class': 'cbi-value' }, [ + E('label', { 'class': 'cbi-value-title' }, TXT.INFO.vpn_name), + E('div', { 'class': 'cbi-value-field' }, [displayNameInput]) + ]), + + // Row B: Select the VPN connection type + E('div', { 'class': 'cbi-value' }, [ + E('label', { 'class': 'cbi-value-title', 'style': 'margin-bottom:10px; display:block;' }, TXT.INFO.select_vpn_type), + E('div', { 'class': 'cbi-value-field', 'style': 'display:flex; flex-direction:column; gap:12px; padding-left:2px;' }, [ + + // Option 1: Server for mobile clients + E('label', { 'style': 'display:inline-flex; align-items:center; cursor:pointer; font-weight:bold;' }, [ + radioConnectServer, + E('span', { 'style': 'margin-left:8px;' }, ICON.PHONE + TXT.MSG.for_phones_and_laptops) + ]), + + // Option 2: Server for main office + E('label', { 'style': 'display:inline-flex; align-items:center; cursor:pointer; font-weight:bold;' }, [ + radioSiteServer, + E('span', { 'style': 'margin-left:8px;' }, ICON.OFFICE + TXT.MSG.server_for_main_office) + ]), + + // Option 3: Client for branch office + E('label', { 'style': 'display:inline-flex; align-items:center; cursor:pointer; font-weight:bold;' + styleHide }, [ + radioSiteClientMatrix, + E('span', { 'style': 'margin-left:8px;' }, ICON.BRANCH + TXT.MSG.client_for_branch_office) + ]) + + ]) + ]) + ]); +}; + +/** + * Builds the step 2 container row encapsulating all scenario sub-nodes + */ +const buildWizardStep2Row = function (optConnectServerNode, optSiteServerNode, optSiteClientNode) { + return E('div', { 'style': 'display:none; margin-bottom:15px;' }, [ + optConnectServerNode, + optSiteServerNode, + optSiteClientNode + ]); +}; + +/** + * Builds the HTML layout block for Step 3 (Network and Port Settings). + */ +const buildWizardStep3Row = function (elements_inputs, radioUdp, radioTcp, buttons, apModeAlertNode) { + const ipContainer = E('div', { 'id': 'ovpn_wizard_ip_block', 'style': 'display:block; opacity:1; transition:opacity 0.3s ease;' }); + const ddnsContainer = E('div', { 'id': 'ovpn_wizard_ddns_block', 'style': 'display:none; opacity:0; transition:opacity 0.3s ease;' }); + + // Assemble the IP + ipContainer.append( + E('div', { 'class': 'cbi-value', 'style': 'margin-top:12px;' }, [ + E('label', { 'class': 'cbi-value-title' }, TXT.INFO.public_ip_or_domain), + E('div', { 'class': 'cbi-value-field' }, [ + elements_inputs.ddns, + E('div', { 'class': 'cbi-value-description', 'style': 'margin-top:4px; color:color-mix(in srgb, var(--text-color-medium, #71717a) 85%, #0055ff);' }, TXT.MSG.public_ip_or_domainname) + ]) + ]), + E('div', { 'class': 'cbi-value' }, [ + E('label', { 'class': 'cbi-value-title' }, TXT.INFO.public_static_ip), + E('div', { 'class': 'cbi-value-field' }, [ + elements_inputs.isStatic, + E('div', { 'class': 'cbi-value-description' }, [ + E('span', { 'class': 'cbi-value-helpicon', 'style': 'color:color-mix(in srgb, var(--text-color-medium, #71717a) 85%, #0055ff);', 'title': TXT.INFO.help }, TXT.MSG.internet_provides_static_ip) + ]) + ]) + ]) + ); + + const togglePasswordBtn = E('button', { + 'class': 'cbi-button cbi-button-neutral', + 'style': 'margin-left: calc(-.2em + -2px); border-radius: 0 3px 3px 0; padding: 0 6px; font-weight: bold;', + 'title': TXT.INFO.reveal_hide_password, + 'aria-label': TXT.INFO.reveal_hide_password, + 'click': function (ev) { + ev.preventDefault(); + if (elements_inputs.ddnsToken.type === 'password') { + elements_inputs.ddnsToken.type = 'text'; + } else { + elements_inputs.ddnsToken.type = 'password'; + } + } + }, ['∗']); + + const ddnsUsernameContainer = E('div', { 'id': 'ddns_username_container', 'class': 'cbi-value', 'style': 'display:none !important' }, [ + // set No-Ip (noip.com) default + E('label', { 'id': 'ddns_username_label', 'class': 'cbi-value-title', 'style': 'word-break: keep-all; hyphens: none;' }, TXT.DDNS.noip_username), + E('div', { 'class': 'cbi-value-field' }, [elements_inputs.ddnsUsername]) + ]); + const ddnsTokenContainer = E('div', { 'class': 'cbi-value' }, [ + E('label', { 'id': 'ddns_token_label', 'class': 'cbi-value-title', 'style': 'word-break: keep-all; hyphens: none;' }, TXT.DDNS.duckdns_token), + E('div', { 'class': 'cbi-value-field', 'style': 'display:flex; width:100%;' }, [ + elements_inputs.ddnsToken, + togglePasswordBtn + ]) + ]); + const ddnsCustomUrlContainer = E('div', { 'class': 'cbi-value', 'style': 'display:none !important; word-break: keep-all; hyphens: none;' }, [ + E('label', { 'class': 'cbi-value-title' }, TXT.INFO.custom_update_url), + E('div', { 'class': 'cbi-value-field' }, [elements_inputs.ddnsCustomUrl]) + ]); + + + // Assemble the Dynamic DNS Section + ddnsContainer.append( + E('div', { 'class': 'cbi-value', 'style': 'margin-top:12px;' }, [ + E('label', { 'class': 'cbi-value-title' }, TXT.INFO.ddns_provider), + E('div', { 'class': 'cbi-value-field' }, [ + elements_inputs.ddnsProvider, + // Inject ICON.INFO in cbi-value-description + E('div', { 'id': 'ddns_provider_description', 'style': 'margin-top:4px; color:color-mix(in srgb, var(--text-color-medium, #71717a) 85%, #0055ff);' }, [ + E('a', { 'href': TXT.DDNS.duckdns_link, 'target': '_blank', 'rel': 'noopener noreferrer', 'style': 'margin-right: 4px; display: inline-flex; align-items: center; text-decoration: none !important; cursor: pointer; border: none !important;' }, [ICON.GLOBE]), + E('span', {}, TXT.DDNS.duckdns_description) + ]), + ]), + ]), + E('div', { 'class': 'cbi-value' }, [ + E('label', { 'class': 'cbi-value-title' }, TXT.INFO.domain_name), + E('div', { 'class': 'cbi-value-field' }, [elements_inputs.ddnsDomain]) + ]), + ddnsUsernameContainer, + ddnsTokenContainer, + ddnsCustomUrlContainer + ); + + // Toggle Visibility between IP and DDNS Blocks + elements_inputs.connectionType.addEventListener('change', function () { + + lockCreateButton(buttons); + + if (this.value === OPENVPN.CONN_TYPE.IP) { + ddnsContainer.style.setProperty('display', 'none'); + ddnsContainer.style.setProperty('opacity', '0'); + ipContainer.style.setProperty('display', 'block'); + setTimeout(function () { ipContainer.style.setProperty('opacity', '1'); }, 10); + } else { + ipContainer.style.setProperty('display', 'none'); + ipContainer.style.setProperty('opacity', '0'); + ddnsContainer.style.setProperty('display', 'block'); + setTimeout(function () { ddnsContainer.style.setProperty('opacity', '1'); }, 10); + } + }); + + // Toggle Custom URL field if "Custom" provider is selected + elements_inputs.ddnsProvider.addEventListener('change', function () { + if (this.value === 'custom') { + // hide the token container completely: + ddnsTokenContainer.style.setProperty('display', 'none', 'important'); + // show the url container safely without breaking the theme layout + ddnsCustomUrlContainer.style.removeProperty('display') + } else { + ddnsTokenContainer.style.removeProperty('display') + ddnsCustomUrlContainer.style.setProperty('display', 'none', 'important'); + } + }); + + return E('div', { 'style': 'display:none; margin-bottom:15px;' }, [ + // Master Switch + E('div', { 'class': 'cbi-value' }, [ + E('label', { 'class': 'cbi-value-title', 'id': 'ovpn_wizard_type_label' }, TXT.INFO.connection_type), + E('div', { 'class': 'cbi-value-field' }, [elements_inputs.connectionType]) + ]), + + ipContainer, + ddnsContainer, + + E('hr', { 'style': 'margin:15px 0; border:0; border-top:1px solid var(--border-color-medium, #cbd5e1);' }), + + E('div', { 'class': 'cbi-value', 'style': 'margin-top:12px;' }, [ + E('label', { 'class': 'cbi-value-title' }, TXT.INFO.internal_port), + E('div', { 'class': 'cbi-value-field' }, [ + elements_inputs.port, + E('div', { 'class': 'cbi-value-description', 'style': 'margin-top:4px; color:color-mix(in srgb, var(--text-color-medium, #71717a) 85%, #0055ff);' }, TXT.MSG.port_used_this_router) + ]) + ]), + E('div', { 'class': 'cbi-value', 'id': 'row_client_port_container', 'style': 'display: none !important;' }, [ + E('label', { 'class': 'cbi-value-title' }, TXT.INFO.external_port), + E('div', { 'class': 'cbi-value-field' }, [ + elements_inputs.externPort, + E('div', { 'class': 'cbi-value-description', 'style': 'margin-top:4px; color:color-mix(in srgb, var(--text-color-medium, #71717a) 85%, #0055ff);' }, TXT.MSG.port_used_main_router) + ]) + ]), + + E('div', { 'class': 'cbi-value' }, [ + E('label', { 'class': 'cbi-value-title' }, TXT.INFO.protocol), + E('div', { 'class': 'cbi-value-field', 'style': 'display:inline-flex; flex-direction:column; gap:10px; padding-top:4px;' }, [ + E('label', { 'style': 'display:inline-flex; align-items:center; cursor:pointer; font-weight:bold;' }, [ + radioUdp, E('span', { 'style': 'margin-left:6px;' }, TXT.MSG.udp_performance) + ]), + E('label', { 'style': 'display:inline-flex; align-items:center; cursor:pointer; font-weight:bold;' }, [ + radioTcp, E('span', { 'style': 'margin-left:6px; color:var(--success-color-high, #00ac59);' }, TXT.MSG.tcp_for_traveling) + ]) + ]) + ]), + apModeAlertNode, + ]); +}; + +/** + * Creates all the text fields, buttons, and radio controls for the wizard steps + */ +const createWizardInputElements = function (buttons, wizardData, selectedScenario, portForwardingAlert, hideClient) { + let styleHide = ''; + if (hideClient === true) { + styleHide = ' display:none !important;'; + } + + const radioConnect = E('input', { 'type': 'radio', 'name': 'wizard_scenario_group', 'value': OPENVPN.SCENARIO.CONNECT_SERVER, 'style': 'width:18px; height:18px; margin:0; cursor:pointer;' }); + const radioSiteServer = E('input', { 'type': 'radio', 'name': 'wizard_scenario_group', 'value': OPENVPN.SCENARIO.SITE_TO_SITE_SERVER, 'style': 'width:18px; height:18px; margin:0; cursor:pointer;' }); + const radioSiteClient = E('input', { 'type': 'radio', 'name': 'wizard_scenario_group', 'value': OPENVPN.SCENARIO.SITE_TO_SITE_CLIENT, 'style': 'width:18px; height:18px; margin:0; cursor:pointer;' + styleHide }); + + const forcedScenario = wizardData.forcedScenario; + + + if (selectedScenario === OPENVPN.SCENARIO.CONNECT_SERVER) { + radioConnect.checked = true; + } + if (selectedScenario === OPENVPN.SCENARIO.SITE_TO_SITE_SERVER) { + radioSiteServer.checked = true; + } + if (selectedScenario === OPENVPN.SCENARIO.SITE_TO_SITE_CLIENT) { + radioSiteClient.checked = true; + } + if (forcedScenario) { + radioConnect.disabled = true; + radioSiteServer.disabled = true; + radioSiteClient.disabled = true; + } + + const radioUdp = E('input', { 'type': 'radio', 'name': 'wizard_proto_group', 'value': OPENVPN.PROTO.UDP, 'style': 'width:18px; height:18px; margin:0; cursor:pointer;' }); + const radioTcp = E('input', { 'type': 'radio', 'name': 'wizard_proto_group', 'value': OPENVPN.PROTO.TCP, 'style': 'width:18px; height:18px; margin:0; cursor:pointer;' }); + const displayNameInput = E('input', { 'type': 'text', 'class': 'cbi-input-text', 'placeholder': TXT.MSG.placeholder_myhomevpn, 'style': 'width:100%;' }); + const ddnsInput = E('input', { 'type': 'text', 'class': 'cbi-input-text', 'placeholder': TXT.MSG.placeholder_mydomain_publicip, 'style': 'width:100%; font-weight:bold; color:var(--primary-color-high, #1976d2);' }); + const portInput = E('input', { 'type': 'text', 'class': 'cbi-input-text', 'style': 'font-family:var(--font-monospace, monospace)' }); + const externPortInput = E('input', { 'type': 'text', 'class': 'cbi-input-text', 'style': 'font-family:var(--font-monospace, monospace)' }); + + const messageBoxInfo = E('div', { 'id': 'dns_check_result_container', 'style': 'margin: 15px 0; display: none;' }); + + const optConnectServer = renderOptionConnectServer(portForwardingAlert); + const optSiteServer = renderOptionSiteServer(portForwardingAlert, wizardData); + const optSiteClient = renderOptionSiteClient(buttons, messageBoxInfo); + const staticIpCheckbox = E('input', { 'type': 'checkbox', 'style': 'width:18px; height:18px; margin:0; cursor:pointer;' }); + + // Create the new select dropdown inputs for the connection mode handling + const connectionTypeSelect = E('select', { 'class': 'cbi-input-select', 'style': 'width:100%; font-weight:bold;' }, [ + E('option', { 'value': OPENVPN.CONN_TYPE.IP }, TXT.MSG.use_public_ip), + E('option', { 'value': OPENVPN.CONN_TYPE.DDNS }, TXT.MSG.use_ddns_service) + ]); + + const ddnsProviderSelect = E('select', { + 'class': 'cbi-input-select', + 'style': 'width:100%; font-weight:bold;' + }, [ + + // 1. Top Recommended + E('option', { 'value': 'duckdns', 'data-placeholder': TXT.DDNS.duckdns_placeholder, 'data-description': TXT.DDNS.duckdns_description, 'data-link': TXT.DDNS.duckdns_link, 'data-token-label': TXT.DDNS.duckdns_token }, TXT.DDNS.duckdns_provider), + E('option', { 'value': 'ipv64', 'data-placeholder': TXT.DDNS.ipv64_placeholder, 'data-description': TXT.DDNS.ipv64_description, 'data-link': TXT.DDNS.ipv64_link, 'data-token-label': TXT.DDNS.ipv64_token }, TXT.DDNS.ipv64_provider), + E('option', { 'value': 'dynv6', 'data-placeholder': TXT.DDNS.dynv6_placeholder, 'data-description': TXT.DDNS.dynv6_description, 'data-link': TXT.DDNS.dynv6_link, 'data-token-label': TXT.DDNS.dynv6_token }, TXT.DDNS.dynv6_provider), + E('option', { 'value': 'dynu', 'data-placeholder': TXT.DDNS.dynu_placeholder, 'data-description': TXT.DDNS.dynu_description, 'data-link': TXT.DDNS.dynu_link, 'data-token-label': TXT.DDNS.dynu_token, 'data-username': 'optional', 'data-username-label': TXT.DDNS.dynu_username }, TXT.DDNS.dynu_provider), + + // 2. Commercial & Legacy Standards (High compatibility but Free-Tier limitations) + E('option', { 'value': 'noip', 'data-placeholder': TXT.DDNS.noip_placeholder, 'data-description': TXT.DDNS.noip_description, 'data-link': TXT.DDNS.noip_link, 'data-token-label': TXT.DDNS.noip_token, 'data-username': 'visible', 'data-username-label': TXT.DDNS.noip_username }, TXT.DDNS.noip_provider), + + // 3. Free no registration - may blocked by some ISP + E('option', { 'value': 'freemyip', 'data-placeholder': TXT.DDNS.freemyip_placeholder, 'data-description': TXT.DDNS.freemyip_description, 'data-link': TXT.DDNS.freemyip_link, 'data-token-label': TXT.DDNS.freemyip_token }, TXT.DDNS.freemyip_provider), + + // 4. Manual Fallback Configuration + E('option', { 'value': 'custom', 'data-placeholder': TXT.DDNS.custom_placeholder, 'data-description': TXT.DDNS.custom_description, 'data-token-label': TXT.DDNS.custom_token }, TXT.DDNS.custom_provider) + ]); + + const ddnsDomainInput = E('input', { 'type': 'text', 'class': 'cbi-input-text', 'placeholder': TXT.DDNS.duckdns_placeholder, 'style': 'width:100%; font-family:var(--font-monospace, monospace)' }); + const ddnsUsernameInput = E('input', { 'type': 'text', 'class': 'cbi-input-text', 'style': 'width:100%; font-family:var(--font-monospace, monospace);' }); + const ddnsTokenInput = E('input', { 'type': 'password', 'class': 'cbi-input-text cbi-input-password', 'style': 'width:100%; font-family:var(--font-monospace, monospace);' }); + const ddnsCustomUrlInput = E('input', { 'type': 'text', 'class': 'cbi-input-text', 'placeholder': TXT.DDNS.custom_url_placeholder, 'style': 'width:100%; font-family:var(--font-monospace, monospace)' }); + + return { + radios: { connectServer: radioConnect, siteServer: radioSiteServer, siteClient: radioSiteClient, udp: radioUdp, tcp: radioTcp }, + inputs: { + displayName: displayNameInput, + ddns: ddnsInput, + connectionType: connectionTypeSelect, + ddnsProvider: ddnsProviderSelect, + ddnsDomain: ddnsDomainInput, + ddnsUsername: ddnsUsernameInput, + ddnsToken: ddnsTokenInput, + ddnsCustomUrl: ddnsCustomUrlInput, + isStatic: staticIpCheckbox, + port: portInput, + externPort: externPortInput + }, + subNodes: { connectServer: optConnectServer, siteServer: optSiteServer, siteClient: optSiteClient }, + info: { messageBox: messageBoxInfo } + }; +}; + +/** + * Generates the standard bottom navigation buttons for the modal flow + */ +const createWizardNavigationButtons = function () { + return { + prev: E('button', { 'class': 'cbi-button cbi-button-neutral', 'style': 'display:none; margin-right:10px;' }, TXT.BTN.back), + next: E('button', { 'class': 'cbi-button cbi-button-action important', 'style': 'margin-right:10px;' }, TXT.BTN.next + ' ' + ICON.ARROW), + check: E('button', { 'class': 'cbi-button cbi-button-action important', 'style': 'display:none; margin-right:10px;' }, ICON.GLOBE + TXT.BTN.check_address), + create: E('button', { 'class': 'cbi-button cbi-button-action important', 'style': 'display:none; margin-right:10px;' }, ICON.WIZARD + TXT.BTN.create_vpn), + close: E('button', { 'class': 'cbi-button cbi-button-neutral' }, TXT.BTN.cancel) + }; +}; + +/** + * Changes the default ports automatically based on protocol and network type + */ +const updateDefaultWizardPorts = async function (protoValue, portForwardingAlert, portInput, externPortInput, scenario, wizardData) { + let defaultPort; + let defaultPortExtern; + const isTcp = (protoValue === OPENVPN.PROTO.TCP); + if (isTcp) { + const ports = getPortsTcp(wizardData); + defaultPort = ports.port + defaultPortExtern = ports.portExtern; + } else { + defaultPort = String(wizardData.appData.statusClass.calcPortFromId(null, wizardData.instanceNumber)); + defaultPortExtern = defaultPort; + } + + const role = getRoleFromScenarioValue(scenario); + const isServer = (role === OPENVPN.ROLE.SERVER); + let isAp = false; + + // Port tests are only needed if this machine works as a VPN server + if (isServer) { + try { + const networkState = await wizardData.networkCallbacks.checkNetworkStructure(wizardData.appData); + if (networkState.doubleNat === true || networkState.apMode === true) { + isAp = true; + } + } catch { + isAp = false; + } + } + + const clientPortRow = document.getElementById('row_client_port_container'); + if (clientPortRow) { + if (isAp === true) { + // show (remove display none !important) + clientPortRow.style.removeProperty('display'); + clientPortRow.style.display = ''; + } else { + // hide + clientPortRow.style.setProperty('display', 'none', 'important'); + } + } + + if (isTcp) { + // HELP-RULE: If TCP is selected behind Double-NAT, auto-fill External=443 and Internal=444! + if (isAp === true) { + // Internal target port (444) + portInput.value = defaultPort; + if (externPortInput) { + // External WAN port (443) + externPortInput.value = defaultPortExtern; + } + } else { + portInput.value = defaultPort; + if (externPortInput) externPortInput.value = defaultPort; + } + } else { + // Default standard UDP port calculation + portInput.value = defaultPort; + if (externPortInput) externPortInput.value = defaultPortExtern; + } +}; + +/** + * Helper to find out which VPN type is selected + */ +const getSelectedScenarioValue = function (elements) { + if (elements.radios.connectServer.checked) return OPENVPN.SCENARIO.CONNECT_SERVER; + if (elements.radios.siteServer.checked) return OPENVPN.SCENARIO.SITE_TO_SITE_SERVER; + if (elements.radios.siteClient.checked) return OPENVPN.SCENARIO.SITE_TO_SITE_CLIENT; + return OPENVPN.SCENARIO.CONNECT_SERVER; +}; + +/** + * Show or hide sub-options based on the selected VPN type + */ +const syncScenarioSubNodeVisibility = function (elements) { + const val = getSelectedScenarioValue(elements); + elements.subNodes.connectServer.node.style.display = (val === OPENVPN.SCENARIO.CONNECT_SERVER) ? 'block' : 'none'; + elements.subNodes.siteServer.node.style.display = (val === OPENVPN.SCENARIO.SITE_TO_SITE_SERVER) ? 'block' : 'none'; + elements.subNodes.siteClient.node.style.display = (val === OPENVPN.SCENARIO.SITE_TO_SITE_CLIENT) ? 'block' : 'none'; +}; + +/** + * Find out if UDP or TCP is selected + */ +const getActiveProtoValue = function (elements) { + const protoValue = elements.radios.tcp.checked ? OPENVPN.PROTO.TCP : OPENVPN.PROTO.UDP; + return protoValue; +}; + +/** + * Show or hide steps (Step 1, Step 2, Step 3) and update descriptions. + */ +const updateStepVisibility = function (elements, state, buttons, wizardData) { + const scenario = getSelectedScenarioValue(elements); + + // Step 1: Scenario and VPN name selection landing page + if (state.currentStep === 1) { + state.rows.step1.style.setProperty('display', 'block', 'important'); + state.rows.step2.style.setProperty('display', 'none', 'important'); + state.rows.step3.style.setProperty('display', 'none', 'important'); + hideMessage(elements.info.messageBox); + + buttons.prev.style.setProperty('display', 'none', 'important'); + buttons.next.disabled = false; + buttons.next.style.setProperty('display', 'inline-block', 'important'); + buttons.check.style.setProperty('display', 'none', 'important'); + buttons.create.style.setProperty('display', 'none', 'important'); + + state.nodes.descr.textContent = TXT.MSG.create_vpn_few_clicks; + } + // Sub-step rendering configurations for active settings pages + else if (state.currentStep === 2 || state.currentStep === 3) { + state.rows.step1.style.setProperty('display', 'none', 'important'); + + // Step 2: Advanced scenario input configurations + if (state.currentStep === 2) { + state.rows.step2.style.setProperty('display', 'block', 'important'); + state.rows.step3.style.setProperty('display', 'none', 'important'); + hideMessage(elements.info.messageBox); + + wizardData.forcedScenario ? buttons.prev.style.setProperty('display', 'none', 'important') : buttons.prev.style.setProperty('display', 'inline-block', 'important'); + // Lock Next button if client import has no file uploaded yet + if (scenario === OPENVPN.SCENARIO.SITE_TO_SITE_CLIENT && !elements.subNodes.siteClient.validate(true)) { + buttons.next.disabled = true; + } else { + buttons.next.disabled = false; + } + buttons.next.style.setProperty('display', 'inline-block', 'important'); + buttons.check.style.setProperty('display', 'none', 'important'); + buttons.create.style.setProperty('display', 'none', 'important'); + + syncScenarioSubNodeVisibility(elements); + } + // Step 3: Public WAN and port mapping settings + else { + state.rows.step2.style.setProperty('display', 'none', 'important'); + state.rows.step3.style.setProperty('display', 'block', 'important'); + hideMessage(elements.info.messageBox); + + buttons.next.style.setProperty('display', 'none', 'important'); + buttons.prev.style.setProperty('display', 'inline-block', 'important'); + + // Force network check verification before unlocking deployment flow + lockCreateButton(buttons); + } + + // Generate dynamic modal header text with context emojis + let scenarioTitle = ''; + if (scenario === OPENVPN.SCENARIO.CONNECT_SERVER) { + scenarioTitle = TXT.INFO.setup + ': ' + ICON.PHONE + TXT.MSG.for_phones_and_laptops; + } else if (scenario === OPENVPN.SCENARIO.SITE_TO_SITE_SERVER) { + scenarioTitle = TXT.INFO.setup + ': ' + ICON.OFFICE + TXT.MSG.server_for_main_office; + } else if (scenario === OPENVPN.SCENARIO.SITE_TO_SITE_CLIENT) { + scenarioTitle = TXT.INFO.setup + ': ' + ICON.BRANCH + TXT.MSG.client_for_branch_office; + } + + // Append custom virtual instance tag string if filled out + if (elements.inputs.displayName.value) { + scenarioTitle += '\u2003\u2003' + ICON.TAG + elements.inputs.displayName.value; + } + + state.nodes.descr.textContent = scenarioTitle; + } +}; + +/** + * Runs the check and synchronizes the external port field visibility dynamically + */ +const triggerWizardNetworkCheck = function (elements, portForwardingAlert, wizardData, force) { + const protoStr = getActiveProtoValue(elements); + const scenarioStr = getSelectedScenarioValue(elements); + const roleStr = getRoleFromScenarioValue(scenarioStr); + const internalPort = sanitizeInputLine(elements.inputs.port.value); + const externalPort = sanitizeInputLine(elements.inputs.externPort.value); + + // Promises inside .check() handle the background thread automatically. + return portForwardingAlert.check(protoStr, roleStr, internalPort, externalPort, null, wizardData.appData, force, null); +}; + +/** + * Renders an inline interactive warning box when a dynamic IP is detected. + */ +const renderIpValidationWarning = function (elements, scenario, wizardData, buttons) { + return E('div', {}, [ + E('p', { 'style': 'margin-bottom: 8px;' }, TXT.WARNING.no_static_ip_selected), + E('button', { + 'class': 'cbi-button cbi-button-action important', + 'style': 'font-weight: bold; margin-right: 8px;', + 'click': function (clickEv) { + clickEv.preventDefault(); + // Action: Switch interface to Dynamic DNS (DDNS) mode + elements.inputs.connectionType.value = OPENVPN.CONN_TYPE.DDNS; + + const changeEvent = document.createEvent('HTMLEvents'); + changeEvent.initEvent('change', true, false); + elements.inputs.connectionType.dispatchEvent(changeEvent); + + const typeLabel = document.getElementById('ovpn_wizard_type_label'); + if (typeLabel) { + typeLabel.style.setProperty('color', 'var(--success-color-high, #00ac59)', 'important'); + typeLabel.style.setProperty('transition', 'color 0.5s ease'); + setTimeout(function () { + typeLabel.style.setProperty('color', ''); + }, 2000); + } + elements.inputs.connectionType.focus(); + if (elements.info && elements.info.messageBox) { + elements.info.messageBox.style.setProperty('display', 'none', 'important'); + } + buttons.create.disabled = false; + } + }, TXT.MSG.switch_to_ddns), + E('button', { + 'class': 'cbi-button cbi-button-neutral', + 'click': function (clickEv) { + clickEv.preventDefault(); + if (!elements.inputs.isStatic.checked) { + showMessage(ICON.INFO + TXT.MSG.ip_static_bypassed_by_admin, MESSAGE_TYPE.INFO, elements.info.messageBox); + } else { + showMessage(ICON.INFO + TXT.MSG.ip_static_now_checked_by_admin, MESSAGE_TYPE.INFO, elements.info.messageBox); + } + buttons.create.disabled = false; + } + }, TXT.MSG.keep_ip_setup) + ]); +}; + +/** + * Evaluates existing profiles and get collision-free TCP ports + */ +const getPortsTcp = function (wizardData) { + const instances = wizardData.appData.instances || []; + + // Default configuration for the very first travel-optimized TCP profile (Master Instance) + let newPort = OPENVPN.PORT.s444; + let newExterPort = OPENVPN.PORT.s443; + + if (instances && instances.length > 0) { + let tcpInstanceCount = 0; + + // 1. Just count how many TCP instances are currently registered in total + for (let i = 0; i < instances.length; i++) { + if (instances[i] && instances[i].proto === OPENVPN.PROTO.TCP) { + tcpInstanceCount++; + } + } + + // 2. Structural cascade based strictly on the index count + if (tcpInstanceCount > 0) { + // Fast mathematical block calculation starting at base 444 + const nextCalculatedPort = 444 + tcpInstanceCount; + + // Safety boundary matching your consensus to keep it safe from registered system services + if (nextCalculatedPort >= 450) { + newPort = wizardData.appData.statusClass.calcPortFromId(null, wizardData.instanceNumber); + newExterPort = newPort; + } else { + // Cascade based strictly on instance order: Inst 1 -> 444 / 443, Inst 2 -> 445 / 445, Inst 3 -> 446 / 446... + newPort = String(nextCalculatedPort); + newExterPort = newPort; + } + } + } + + return { port: newPort, portExtern: newExterPort }; +}; + +/** + * Handles the step-by-step navigation logic when clicking the Next button. + */ +const handleButtonNextClick = function (state, elements, buttons, portForwardingAlert, wizardData) { + const scenario = getSelectedScenarioValue(elements); + buttons.next.classList.remove('cbi-input-invalid'); + + switch (state.currentStep) { + case 1: { + // Name Validation filter rules matching alphanumeric structures + if (elements.inputs.displayName.value && !/^[a-zA-Z0-9_-]*$/.test(elements.inputs.displayName.value)) { + elements.inputs.displayName.classList.add('cbi-input-invalid'); + elements.inputs.displayName.focus(); + + const nameError = ICON.ERROR + ' ' + TXT.ERROR.vpn_name_validation; + showMessage(nameError, MESSAGE_TYPE.ERROR, elements.info.messageBox); + + return; + } + elements.inputs.displayName.classList.remove('cbi-input-invalid'); + + state.currentStep = 2; + updateStepVisibility(elements, state, buttons, wizardData); + syncScenarioSubNodeVisibility(elements); + } + break; + + + case 2: { + if (scenario === OPENVPN.SCENARIO.CONNECT_SERVER && !elements.subNodes.connectServer.validate()) return; + if (scenario === OPENVPN.SCENARIO.SITE_TO_SITE_SERVER && !elements.subNodes.siteServer.validate()) return; + + // Handle original import and validation branches for site client offices + if (scenario === OPENVPN.SCENARIO.SITE_TO_SITE_CLIENT) { + importClientInstance(elements, wizardData, buttons.next, buttons.prev); + return; + } + // set default tcp on mobile connect and udp on site to site + if (scenario === OPENVPN.SCENARIO.CONNECT_SERVER) { + + elements.radios.tcp.checked = true; + elements.radios.udp.checked = false; + const ports = getPortsTcp(wizardData); + elements.inputs.port.value = ports.port + elements.inputs.externPort.value = ports.portExtern; + } else { + elements.radios.tcp.checked = false; + elements.radios.udp.checked = true; + elements.inputs.port.value = String(wizardData.appData.statusClass.calcPortFromId(null, wizardData.instanceNumber)); + elements.inputs.externPort.value = elements.inputs.port.value; + } + elements.inputs.port.placeholder = TXT.INFO.placeholder_eg + ' ' + elements.inputs.port.value; + elements.inputs.externPort.placeholder = TXT.INFO.placeholder_eg + ' ' + elements.inputs.externPort.value; + + state.currentStep = 3; + updateStepVisibility(elements, state, buttons, wizardData); + updateDefaultWizardPorts(getActiveProtoValue(elements), portForwardingAlert, elements.inputs.port, elements.inputs.externPort, scenario, wizardData).then(function () { + triggerWizardNetworkCheck(elements, portForwardingAlert, wizardData, false); + }); + } + break; + + case 3: { + const connType = elements.inputs.connectionType.value; + + if (connType === OPENVPN.CONN_TYPE.IP) { + elements.inputs.ddns.classList.remove('cbi-input-invalid'); + const inputVal = sanitizeInputLine(elements.inputs.ddns.value); + + if (!inputVal) { + elements.inputs.ddns.classList.add('cbi-input-invalid'); + elements.inputs.ddns.focus(); + return; + } + + // Alphanumeric character parsing rule filter maps + const hasLetters = /[a-zA-Z]/.test(inputVal); + + // If dynamic IP is used without static check, show warning box + if (!hasLetters && !elements.inputs.isStatic.checked) { + const warningNode = renderIpValidationWarning(elements, scenario, wizardData, buttons); + showMessage(warningNode, MESSAGE_TYPE.WARNING, elements.info.messageBox); + // Blocks execution until user clicks an action button + buttons.create.disabled = true; + return; + } + } + + if (connType === OPENVPN.CONN_TYPE.DDNS) { + elements.inputs.ddnsDomain.classList.remove('cbi-input-invalid'); + elements.inputs.ddnsToken.classList.remove('cbi-input-invalid'); + elements.inputs.ddnsUsername.classList.remove('cbi-input-invalid'); + elements.inputs.ddnsCustomUrl.classList.remove('cbi-input-invalid'); + + let invalid = false; + + // 1. Check Username field only if it is strictly marked as 'visible' + const activeProviderOption = elements.inputs.ddnsProvider.options[elements.inputs.ddnsProvider.selectedIndex]; + if (activeProviderOption && activeProviderOption.getAttribute('data-username') === 'visible') { + if (!sanitizeInputLine(elements.inputs.ddnsUsername.value)) { + if (elements.inputs.ddnsUsername) { + // Mark field red + elements.inputs.ddnsUsername.classList.add('cbi-input-invalid'); + elements.inputs.ddnsUsername.focus(); + } + invalid = true; + } + } + + // 2. Validate Domain Name field + if (!invalid && !sanitizeInputLine(elements.inputs.ddnsDomain.value)) { + // Mark field red + elements.inputs.ddnsDomain.classList.add('cbi-input-invalid'); + elements.inputs.ddnsDomain.focus(); + invalid = true; + } + + // 3. Validate Token / Password field (only if not a custom URL setup) + if (!invalid && elements.inputs.ddnsProvider.value !== 'custom') { + if (!sanitizeInputLine(elements.inputs.ddnsToken.value)) { + // Mark field red + elements.inputs.ddnsToken.classList.add('cbi-input-invalid'); + elements.inputs.ddnsToken.focus(); + invalid = true; + } + } else if (!invalid) { + // 4. Validate Custom URL field + if (!sanitizeInputLine(elements.inputs.ddnsCustomUrl.value)) { + // Mark field red + elements.inputs.ddnsCustomUrl.classList.add('cbi-input-invalid'); + elements.inputs.ddnsCustomUrl.focus(); + invalid = true; + } + } + + if (invalid) { + // Stop the wizard from going to the next step or finishing + return; + } + } + + + // Final save execution path + const currentProto = { value: getActiveProtoValue(elements) }; + createServerInstance({ value: scenario }, elements, currentProto, wizardData, buttons.next, buttons.prev); + } + break; + + default: + break; + } +} + +/** + * Synchronizes the wizard UI elements dynamically when the DDNS provider changes. + */ +const handleDdnsProviderChange = function (selectedOption, elements, buttons) { + if (!selectedOption) { + return; + } + + if (!selectedOption) { + return; + } + + lockCreateButton(buttons); + + // 1. Update the domain input field placeholder text dynamically + const newPlaceholder = selectedOption.getAttribute('data-placeholder') || ''; + if (elements.inputs.ddnsDomain) { + elements.inputs.ddnsDomain.placeholder = newPlaceholder; + } + + // 2. Update the helper description line below the provider dropdown + const descriptionNode = document.getElementById('ddns_provider_description'); + if (descriptionNode) { + descriptionNode.innerHTML = ''; + const providerText = selectedOption.getAttribute('data-description') || ''; + const providerLink = selectedOption.getAttribute('data-link') || ''; + if (providerLink) { + const homeLinkAnchor = E('a', { 'href': providerLink, 'target': '_blank', 'rel': 'noopener noreferrer', 'style': 'margin-right: 4px; display: inline-flex; align-items: center; text-decoration: none !important; cursor: pointer; border: none !important;' }, [ICON.GLOBE]); + descriptionNode.appendChild(homeLinkAnchor); + } + descriptionNode.appendChild(document.createTextNode(providerText)); + } + + // 3. Update the left-side label for the token password input field + const tokenLabelNode = document.getElementById('ddns_token_label'); + if (tokenLabelNode) { + tokenLabelNode.textContent = selectedOption.getAttribute('data-token-label') || TXT.INFO.token_password; + } + + // 4. Dynamically rewrite the label of the username row + const usernameLabelNode = document.getElementById('ddns_username_label'); + if (usernameLabelNode) { + usernameLabelNode.textContent = selectedOption.getAttribute('data-username-label') || TXT.INFO.username; + } + + // 5. Dynamic Username visibility control via the custom data-username attribute + const usernameRow = document.getElementById('ddns_username_container'); + const isUsernameRequired = ((selectedOption.getAttribute('data-username') === 'visible') || (selectedOption.getAttribute('data-username') === 'optional')); + + if (usernameRow) { + if (isUsernameRequired === true) { + // show the row safely without breaking the theme layout: + usernameRow.style.removeProperty('display'); + } else { + // hide the row completely: + usernameRow.style.setProperty('display', 'none', 'important'); + if (elements.inputs.ddnsUsername) { + elements.inputs.ddnsUsername.value = ''; + } + } + } +} + +/** + * Binds all change events, click triggers and navigation logic together + */ +const setupWizardEventListeners = function (elements, buttons, portForwardingAlert, wizardData, state) { + + // Update ports and warnings when the protocol changes + const handleProtoContextChange = function () { + updateDefaultWizardPorts(getActiveProtoValue(elements), portForwardingAlert, elements.inputs.port, elements.inputs.externPort, getSelectedScenarioValue(elements), wizardData).then(function () { + triggerWizardNetworkCheck(elements, portForwardingAlert, wizardData, true); + elements.inputs.port.placeholder = TXT.INFO.placeholder_eg + ' ' + elements.inputs.port.value; + elements.inputs.externPort.placeholder = TXT.INFO.placeholder_eg + ' ' + elements.inputs.externPort.value; + }); + } + + // When the user changes a ddns provider clear error color, set placeholder and description text + // Change placeholder, description, token label, and username visibility on provider change + elements.inputs.ddnsProvider.addEventListener('change', function () { + const selectedOption = this.options[this.selectedIndex]; + handleDdnsProviderChange(selectedOption, elements, buttons) + }); + + // Run validation lock and FULL alert re-trigger when the user leaves the public IP/Domain field + elements.inputs.ddns.addEventListener('blur', function () { + this.classList.remove('cbi-input-invalid'); + lockCreateButton(buttons); + triggerWizardNetworkCheck(elements, portForwardingAlert, wizardData, false); + }); + + // Also re-trigger if the user hits "Enter" inside the IP field + elements.inputs.ddns.addEventListener('keyup', function (ev) { + if (ev.keyCode === 13) { + lockCreateButton(buttons); + } + }); + + // Re-trigger when switching between "Public IP" and "DDNS Service" + elements.inputs.connectionType.addEventListener('change', function () { + lockCreateButton(buttons); + triggerWizardNetworkCheck(elements, portForwardingAlert, wizardData, false); + }); + + // Re-trigger when the DDNS domain input changes + elements.inputs.ddnsDomain.addEventListener('blur', function () { + lockCreateButton(buttons); + }); + + elements.radios.udp.addEventListener('change', handleProtoContextChange); + elements.radios.tcp.addEventListener('change', handleProtoContextChange); + + // Listen to manual internal port modifications + elements.inputs.port.addEventListener('input', function () { + lockCreateButton(buttons); + }); + + // Listen to manual external client port modifications to update the alert live + elements.inputs.externPort.addEventListener('input', function () { + lockCreateButton(buttons); + }); + + + // Update visibility and ports when the VPN type radio changes + const handleRadioContextChange = function () { + syncScenarioSubNodeVisibility(elements); + } + + elements.radios.connectServer.addEventListener('change', handleRadioContextChange); + elements.radios.siteServer.addEventListener('change', handleRadioContextChange); + elements.radios.siteClient.addEventListener('change', handleRadioContextChange); + + // Back button click logic + buttons.prev.addEventListener('click', function () { + if (state.currentStep > 1) { + state.currentStep--; + updateStepVisibility(elements, state, buttons, wizardData); + } + }); + + buttons.next.addEventListener('click', function () { handleButtonNextClick(state, elements, buttons, portForwardingAlert, wizardData); }); + buttons.create.addEventListener('click', function () { handleButtonNextClick(state, elements, buttons, portForwardingAlert, wizardData); }); + + + // Close button hiding logic + buttons.close.addEventListener('click', L.bind(L.ui.hideModal, L.ui)); + + // Initialize the default layout states on window load + syncScenarioSubNodeVisibility(elements); + updateStepVisibility(elements, state, buttons, wizardData); +}; + +/** + * Locks the wizard creation flow by enforcing a mandatory network check. + */ +const lockCreateButton = function (buttons, focus) { + buttons.check.style.setProperty('display', 'inline-block', 'important'); + buttons.create.style.setProperty('display', 'none', 'important'); + if (focus === true) { + buttons.check.focus(); + } +}; + +/** + * Unlocks the wizard creation flow after a successful network diagnostic. + */ +const unlockCreateButton = function (buttons, focus) { + buttons.check.style.setProperty('display', 'none', 'important'); + buttons.create.style.setProperty('display', 'inline-block', 'important'); + if (focus === true) { + buttons.create.focus(); + } +}; + +/** + * Sets up the diagnostic event routing for the network address check button. + */ +const setupAddressValidator = function (elements, buttons, portForwardingAlert, wizardData) { + + buttons.check.addEventListener('click', async function (ev) { + ev.preventDefault(); + const connType = elements.inputs.connectionType.value; + let targetToTest = ''; + let targetInputField = null; + + // Check if we are using standard IP or Dynamic DNS + if (connType === OPENVPN.CONN_TYPE.IP) { + targetToTest = sanitizeInputLine(elements.inputs.ddns.value); + targetInputField = elements.inputs.ddns; + if (!targetToTest) { + if (targetInputField) { + targetInputField.classList.add('cbi-input-invalid'); + targetInputField.focus(); + } + return; + } + if (targetInputField) targetInputField.classList.remove('cbi-input-invalid'); + } else { + // --- DDNS MODE VALIDATION --- + const ddnsDomainField = elements.inputs.ddnsDomain; + const ddnsTokenField = elements.inputs.ddnsToken; + const ddnsUserField = elements.inputs.ddnsUsername; + const rawDomain = sanitizeInputLine(ddnsDomainField.value); + const rawToken = sanitizeInputLine(ddnsTokenField.value); + const rawUser = ddnsUserField ? sanitizeInputLine(ddnsUserField.value) : ''; + + ddnsDomainField.classList.remove('cbi-input-invalid'); + ddnsTokenField.classList.remove('cbi-input-invalid'); + if (ddnsUserField) ddnsUserField.classList.remove('cbi-input-invalid'); + + if (!rawDomain) { + ddnsDomainField.classList.add('cbi-input-invalid'); + ddnsDomainField.focus(); + return; + } + if (!rawToken && elements.inputs.ddnsProvider.value !== 'custom') { + ddnsTokenField.classList.add('cbi-input-invalid'); + ddnsTokenField.focus(); + return; + } + const activeProviderOption = elements.inputs.ddnsProvider.options[elements.inputs.ddnsProvider.selectedIndex]; + if (activeProviderOption && activeProviderOption.getAttribute('data-username') === 'visible') { + if (!rawUser && ddnsUserField) { + ddnsUserField.classList.add('cbi-input-invalid'); + ddnsUserField.focus(); + return; + } + } + targetToTest = rawDomain; + targetInputField = ddnsDomainField; + } + + if (elements.info.messageBox) { + elements.info.messageBox.style.setProperty('display', 'none', 'important'); + } + + buttons.check.disabled = true; + const btn_check_textContent = buttons.check.textContent; + buttons.check.textContent = ICON.LOADING + TXT.INFO.resolving_target; + + // Start the asynchronous background port scanner immediately + const activePortCheckPromise = triggerWizardNetworkCheck(elements, portForwardingAlert, wizardData, true); + + // --- START DDNS PROVIDER LOGIN AND UPDATE CHECK --- + let providerResponse = null; + let sendingInfoMsg = ''; + + try { + if (connType === OPENVPN.CONN_TYPE.DDNS) { + const provider = elements.inputs.ddnsProvider.value; + const domain = sanitizeInputLine(elements.inputs.ddnsDomain.value); + const token = sanitizeInputLine(elements.inputs.ddnsToken.value); + let ddnsUser = ''; + + if (provider === 'custom') { + ddnsUser = elements.inputs.ddnsCustomUrl ? sanitizeInputLine(elements.inputs.ddnsCustomUrl.value) : ''; + } else { + ddnsUser = elements.inputs.ddnsUsername ? sanitizeInputLine(elements.inputs.ddnsUsername.value) : ''; + } + + const updateUrl = await buildDdnsUpdateUrl(provider, domain, token, ddnsUser, wizardData, null); + if (updateUrl) { + let displayUrl = updateUrl; + const tokenField = elements.inputs.ddnsToken; + if (token && tokenField && tokenField.type === 'password') { + const maskedStars = '*'.repeat(token.length); + displayUrl = await buildDdnsUpdateUrl(provider, domain, token, ddnsUser, wizardData, maskedStars); + } + + sendingInfoMsg = ICON.INFO + ' DDNS Service: Sending update URL...
' + + '' + displayUrl + '
'; + showMessage(sendingInfoMsg, MESSAGE_TYPE.INFO, elements.info.messageBox); + providerResponse = await wizardData.networkCallbacks.updateDdnsProvider(updateUrl, domain); + } + } + + if (providerResponse && providerResponse.isError) { + buttons.check.disabled = false; + buttons.check.textContent = btn_check_textContent; + if (targetInputField) targetInputField.classList.add('cbi-input-invalid'); + const authErrorMsg = sendingInfoMsg + ICON.ERROR + ' DDNS Service Error:
' + providerResponse.raw; + showMessage(authErrorMsg, MESSAGE_TYPE.ERROR, elements.info.messageBox); + return; + } + + const providerTextHint = providerResponse ? '
' + providerResponse.raw + '' : ''; + + // Start standard DNS lookup and bundle it with the running port checker in parallel + const activeDdnsCheckPromise = wizardData.networkCallbacks.checkDdns(targetToTest); + const results = await Promise.all([activeDdnsCheckPromise, activePortCheckPromise]); + const res = results[0]; + + buttons.check.disabled = false; + buttons.check.textContent = btn_check_textContent; + + if (res && res.success === true) { + let successMsg = sendingInfoMsg + ICON.CHECK + TXT.MSG.ip_valid_and_resolvable + providerTextHint + '
' + ICON.GLOBE; + const hasLetters = /[a-zA-Z]/.test(targetToTest); + if ((connType === OPENVPN.CONN_TYPE.DDNS) || (hasLetters)) { + successMsg += targetToTest + ' '; + } + successMsg += '(' + res.response + ')'; + showMessage(successMsg, MESSAGE_TYPE.OK, elements.info.messageBox); + unlockCreateButton(buttons); + } else { + if (targetInputField) targetInputField.classList.add('cbi-input-invalid'); + const errorBox = E('div', {}, [ + E('p', {}, TXT.WARNING.ip_cannot_be_resolved), + E('button', { + 'class': 'cbi-button cbi-button-remove', + 'style': 'margin-top:8px; font-weight:bold;', + 'click': function (clickEv) { + clickEv.preventDefault(); + unlockCreateButton(buttons); + showMessage(ICON.INFO + TXT.MSG.ip_validation_bypassed_by_admin, MESSAGE_TYPE.INFO, elements.info.messageBox); + } + }, TXT.MSG.skip_check_unlock_create) + ]); + showMessage(errorBox, MESSAGE_TYPE.ERROR, elements.info.messageBox); + } + } catch { + buttons.check.disabled = false; + buttons.check.textContent = btn_check_textContent; + if (targetInputField) targetInputField.classList.add('cbi-input-invalid'); + const nativeErrorBox = E('div', {}, [ + E('p', {}, TXT.ERROR.dns_ip_check_failed), + E('button', { + 'class': 'cbi-button cbi-button-remove', + 'style': 'margin-top:8px; font-weight:bold;', + 'click': function (clickEv) { + clickEv.preventDefault(); + unlockCreateButton(buttons); + showMessage(ICON.INFO + TXT.MSG.ip_validation_bypassed_after_net_failure, MESSAGE_TYPE.INFO, elements.info.messageBox); + } + }, TXT.MSG.skip_check_unlock_create) + ]); + showMessage(nativeErrorBox, MESSAGE_TYPE.ERROR, elements.info.messageBox); + } + }); +}; + + +/** + * MAIN WIZARD + */ +const openWizardModal = function (wizardData, optionalHideClient) { + + // Instantiate the new independent port forwarding warning component + const portForwardingAlert = renderPortForwardingAlert(wizardData.networkCallbacks); + const selectedScenario = wizardData.forcedScenario || OPENVPN.SCENARIO.CONNECT_SERVER; + + // Create all text boxes, buttons, and radio controls + const buttons = createWizardNavigationButtons(); + const elements = createWizardInputElements(buttons, wizardData, selectedScenario, portForwardingAlert, optionalHideClient); + + // Create the description text line that changes on every step + const descrNode = E('div', { + 'class': 'cbi-section-descr', + 'style': 'margin-bottom:15px; border-bottom:1px solid var(--border-color-medium, #cbd5e1); padding-bottom:8px; font-weight:bold;color:color-mix(in srgb, var(--text-color-medium, #71717a) 85%, #0055ff);' + }, [TXT.MSG.create_vpn_few_clicks]); + + // Put all steps and rows into a single state map + const state = { + currentStep: wizardData.forcedScenario ? 2 : 1, + nodes: { descr: descrNode }, + rows: { + step1: buildWizardStep1Row(elements.inputs.displayName, elements.radios.connectServer, elements.radios.siteServer, elements.radios.siteClient, optionalHideClient), + step2: buildWizardStep2Row(elements.subNodes.connectServer.node, elements.subNodes.siteServer.node, elements.subNodes.siteClient.node), + step3: buildWizardStep3Row(elements.inputs, elements.radios.udp, elements.radios.tcp, buttons, portForwardingAlert.node) + } + }; + + // Execute the decoupled external address check validator logic routine + setupAddressValidator(elements, buttons, portForwardingAlert, wizardData); + + // Connect core multi-step page transition handler + setupWizardEventListeners(elements, buttons, portForwardingAlert, wizardData, state); + + // Try to find your public IP address automatically in the background + if (typeof wizardData.networkCallbacks.getDdnsOrPublicIp === 'function') { + elements.inputs.ddns.value = TXT.MSG.finding_your_public_ip; + wizardData.networkCallbacks.getDdnsOrPublicIp().then(function (detectedAddress) { + elements.inputs.ddns.value = detectedAddress; + }); + } else { + elements.inputs.ddns.value = window.location.hostname; + } + + // Inject custom modal styles into the document head if not already present + if (!document.getElementById('ovpn-plus-wizard-styles')) { + const styleNode = E('style', { 'id': 'ovpn-plus-wizard-styles' }, + '.modal.ovpn-plus-wizard-modal { width: 90%; min-width: 270px; max-width: 900px; }' + ); + document.head.appendChild(styleNode); + } + + // Show the modal window on the screen with a simple layout structure + L.ui.showModal(ICON.ROCKET + TXT.INFO.setup_wizard, [ + E('div', { + 'class': 'cbi-map ovpn-plus-setup-wizard', + 'style': 'width: 100%; margin: 0 auto;' + }, [ + E('div', { 'class': 'cbi-section' }, [ + state.nodes.descr, + + state.rows.step1, + state.rows.step2, + state.rows.step3, + E('div', { 'style': 'text-align:right; margin-top:20px; border-top:1px solid var(--border-color-medium, #cbd5e1); padding-top:12px;' }, [ + elements.info.messageBox, + buttons.prev, + buttons.next, + buttons.check, + buttons.create, + buttons.close + ]) + ]) + ]) + ], 'ovpn-plus-wizard-modal'); +}; + +/** + * Export the open wizard modal function to LuCI + */ +return L.Class.extend( + { + WIZARD_DATA_TEMPLATE: WIZARD_DATA_TEMPLATE, + WIZARD_PARAMS_TEMPLATE: WIZARD_PARAMS_TEMPLATE, + renderSubnetInputs: renderSubnetInputs, + renderPortForwardingAlert: renderPortForwardingAlert, + getValidCommonName: getValidCommonName, + openWizardModal: openWizardModal + }); diff --git a/luci-app-openvpn-plus/root/www/luci-static/resources/view/vpn/openvpn.js b/luci-app-openvpn-plus/root/www/luci-static/resources/view/vpn/openvpn.js new file mode 100755 index 000000000..faf5cbce4 --- /dev/null +++ b/luci-app-openvpn-plus/root/www/luci-static/resources/view/vpn/openvpn.js @@ -0,0 +1,5482 @@ +/* + * Copyright (C) 2008-2026 The OpenWrt Project + * Copyright (C) 2026 Manfred Jaider + * + * This is free software, licensed under the Apache License, Version 2.0. + * See /LICENSE for more information. + * + * luci-app-openvpn-plus : user interface for easy OpenVPN server and client configuration + * /www/luci-static/resources/view/vpn/openvpn.js + * + * 1. --- TEXT & DEFINITIONS --- ....Global translations and system definitions + * 2. --- HELPER & INIT --- ........ Router connections and file setup + * 3. --- SAVE AND RESTART --- ..... UCI saving and instance restart logic + * 4. --- OVPN PROFILES --- ........ Import and export .ovpn profiles + * 5. --- STATUS VIEW --- .......... Main OpenVPN dashboard + * 6. --- OPENVPN INSTANCES --- .... Instance settings and creation buttons + * 7. --- FIREWALL & LOG VIEW --- .. Active ports info and system log box + * 8. --- LOCK SCREEN --- .......... Startup loading overlay and pollers + * 9. --- MAIN VIEW --- ............ Main entry where the page is generated + */ + +/* global E, URL, FileReader, Blob, sessionStorage, uqr, network */ +'use strict'; +'require uqr'; +'require network'; + +const view = L.view; + +/* + * --- TEXT & DEFINITIONS --- + */ +const TXT = { + INFO: { + active: _('Active'), + clearing: _('Clearing...'), + creating: _('Creating...'), + disable: _('Disable'), + disabled: _('Disabled'), + export_profile: _('Export Profile'), + enable: _('Enable'), + error: _('Error'), + instance_x: _('Instance #'), + log_clear: _('Clear Log'), + log_cleared: _('Log Cleared'), + no: _('No'), + no_changes_detected: _('No changes detected'), + openvpn: _('OpenVPN'), + pending: _('Pending...'), + starting: _('Starting...'), + status: _('Status'), + title_instance: _('Instance Management'), + title_log: _('LOG'), + title_main: _('OpenVPN Server/Client'), + netmask: _('Netmask'), + remote_lan_subnet: _('Remote LAN Subnet'), + remote_name: _('Remote Name'), + wizard: _('Wizard'), + yes: _('Yes') + }, + BTN: { + add_client: _('Add Client Instance'), + add_server: _('Add Server Instance'), + cancel: _('Cancel'), + change: _('Change'), + click_save_apply: _('Please click «Save & Apply».'), + close: _('Close'), + del_instance: _('Delete Instance'), + del_ready: _('Deleted - Save & Apply'), + download: _('Download'), + download_ovpn: _('Download (.ovpn)'), + enabled: _('Enabled'), + generating: _('Generating...'), + next: _('Next'), + ok: _('OK'), + processing: _('Processing...'), + save_config: _('Save Config'), + saving: _('Saving...'), + saved: _('Saved'), + show: _('Show'), + upload: _('Upload'), + use_fallback: _('Use Fallback') + }, + MSG: { + client_export: _('OpenVPN Client Export'), + config_changed_reload: _('Configuration changed! Applying will temporarily restart the OpenVPN instance.'), + confirm_del: _('Are you sure you want to delete '), + download_only_available_window_open: _('Download and QR code link are only available while this window is open.'), + edit_config: _('Edit Config file'), + export_connection_address_qr_code: _('This is the address your VPN clients will use to connect from the internet. Scan the QR code with your phone camera or use the link below to download your connection profile.'), + export_ovpn: _('Export (.ovpn)'), + export_openvpn_connect_client_profile: _('Export OpenVPN Connect Client Profile'), + import_ovpn: _('Import (.ovpn)'), + import_openvpn_connect_client_profile: _('Import OpenVPN Connect Client Profile'), + import_profile: _('Import Profile'), + lan_to_lan_profile_selection: _('LAN-to-LAN Router Profile Selection'), + manage_instance: _('Here you can manage multiple OpenVPN Server and Client instances dynamically.'), + mobile_export: _('OpenVPN Connect Mobile Export'), + no_active_log_entries: _('No active OpenVPN log entries found.'), + no_vpn_configured: _('No OpenVPN instances configured yet. Use the Wizard to create new OpenVPN instances.'), + no_vpn_log: _('No active OpenVPN log entries found.'), + office_profile: _('Office Profile:'), + placeholder_cn_mobile: _('e.g. my-smartphone'), + please_assign_cn_name: _('Please assign a unique device name (Common Name) for this mobile profile:'), + process_take_few_minutes: _('This automated initialization process can take a few minutes on your device...'), + scan_qr_code_with_camera: _('Scan QR Code with Mobile Phone Camera:'), + secure_temporary_profile_url: _('Secure Temporary Profile URL Field:'), + select_remote_office_to_export: _('This server handles network rules for multiple remote offices. Please choose an existing office name or write a name to make a new connection profile.'), + system_logs: _('System logs ...'), + uploaded_file_invalid: _('Uploaded file is invalid or corrupt!'), + vpn_client_address: _('VPN Client Connection Domain or Address:') + }, + FIREWALL: { + ACTIVE: _('ACTIVE'), + auto_open_secure_connection: _(' are opened automatically for secure OpenVPN connections.'), + automated_zone_setup: _('Automated Zone Setup: '), + check_traffic_rules: _('Check Traffic Rules: '), + devices_autocreated: _(' devices is created automatically.'), + filtered: _('Filtered'), + firewall: _('Firewall '), + firewall_info: _('Firewall & Routing Information'), + inbound_access: _('Inbound Access: '), + network: _('Network '), + openvpn_tunnel_interface: _('OpenVPN tunnel interfaces (tun0, tun1, tun3, etc.)'), + packets: _('packets'), + secure_firewall_for_all: _('A secure firewall zone for all '), + smart_filewall: _('Smart Firewall'), + smart_filewall_tooltip: _('Automatic port scans are a constant threat on the internet. The smart firewall detects, bans and drops these requests. This hides your system, prevents spying, saves CPU power and guarantees maximum VPN performance.'), + protected_since: _('Protected since'), + traffic_rules: _('Traffic Rules'), + wan_ports: _('WAN ports '), + }, + KEY: { + ca: _('Certification Authority'), + client_connection_needs_server_key_and_config: _('A client connection needs the server keys and config. Select an .ovpn profile to import them automatically, or click Cancel to use the default settings.'), + create_key_new_office: _('Create keys for a new office...'), + dh: _('Diffie - Hellman Parameters'), + key_verification_failed: _('Key verification failed'), + keygen: _('KeyGen'), + keygen_in_progress: _('Key generation in progress...'), + keygen_wait: _('Please wait while secure cryptographic, router-unique default assets are being generated.'), + keyfile_not_exist: _('Key file is empty or does not exist on disk yet.'), + openvpn_keys: _('OpenVPN Keys'), + server_crt: _('Server Certificate'), + server_key: _('Private Server Key'), + client_crt: _('Client Certificate'), + client_key: _('Private Client Key'), + server_tls: _('TLS Crypt v2 Server Master Key'), + client_tls: _('TLS Crypt v2 Client Key') + }, + WARNING: { + key_upload_nomatch: _('Warning: The uploaded file structure does not match the expected key type.'), + key_upload_processing: _('Proceeding with invalid cryptographic keys will cause total connection failure and may lead to service instability or infinite daemon crash loops.'), + key_upload_save_anyway: _('Do you want to proceed and save this file anyway?'), + key_upload_title: _('Cryptographic Key Warning'), + profile_link_without_qr_code: _('Profile link active (Native uqr framework (QR-Code) not loaded).'), + security_notice: _('Security Notice:'), + tls_missing: _('Import cancelled due to missing TLS-Crypt v2 protection.'), + tls_security_warning: _('Security Warning!'), + tls_not_in_profile: _('This profile does not use TLS-Crypt v2. It is highly recommended to use TLS-Crypt v2 for maximum security, better performance, and protection against unauthorized scanning.'), + tls_proced_import_anyway: _('Do you want to proceed with the import anyway?') + }, + ERROR: { + build_profile: _('Failed to build profile: '), + config_key_missing: _('Error: Configuration or cryptographic keys are missing on disk.'), + invalid_file_parts_missing: _('Invalid file. Important parts are missing (CA, Cert, or Private Key).'), + key_check_failed: _('Key check failed. One security key is broken or invalid.'), + keygen_failed: _('Key generation failed.'), + key_type_mismatch: _('Key type mismatch'), + ip_used_on_other_office: _('Error: This IP address is already used by another office. Every office needs a different IP address!'), + office_name_already_exist: _('Error: An office with this name is already in the list.'), + use_local_ipnetwork: _('Error: You cannot use this IP network! It is the local network of this router and will block all traffic.'), + upload_key_empty: _('The uploaded file is empty.'), + wrong_key_type: _('Wrong key type! Please upload the correct cryptographic file.') + } +} + +const CFG = Object.freeze({ + FILE: Object.freeze({ + dir_cfg: '/etc/openvpn/luci/', + dir_keys: '/etc/openvpn/keys/', + client_def_conf: 'client.default.conf', + server_def_conf: 'server.default.conf', + ca_def_crt: 'ca_default.crt', + dh_def_pem: 'dh_default.pem', + server_def_crt: 'server_default.crt', + server_def_key: 'server_default.key', + client_def_crt: 'client_default.crt', + client_def_key: 'client_default.key', + server_def_tls2: 'server_default.tls2.key', + client_def_tls2: 'client_default.tls2.key', + openvpn_keygen_lock: '/var/run/openvpn.keygen.lock', + default_nft: 'default.nft', + loading_img: '/luci-static/resources/icons/loading.svg', + vpn_disabled_img: '/luci-static/resources/icons/tunnel_disabled.svg', + vpn_enabled_img: '/luci-static/resources/icons/tunnel.svg' + }), + LIBEXEC: Object.freeze({ + luci_app_openvpn_plus: '/usr/libexec/luci-app-openvpn-plus', + lanip: 'lanip', + bestcrypto: 'bestcrypto', + ovpnservice: 'ovpnservice', + firewallservice: 'firewallservice', + firewallnft: 'firewallnft', + openssldgst: 'openssldgst', + symlink: 'symlink', + iroute: 'iroute', + cleanipdns: 'cleanipdns', + checkport: 'checkport', + checksroute: 'checksroute', + checkddns: 'checkddns', + publicip: 'publicip', + wgetddns: 'wgetddns', + ucisortovpn: 'ucisortovpn', + cleanup: 'cleanup', + keymeta: 'keymeta', + initkeys: 'initkeys' + }), + CMD: Object.freeze({ + openvpn: 'openvpn', + firewall: 'firewall', + restart: 'restart', + start: 'start', + stop: 'stop', + cidr: 'cidr' + }), + ID: Object.freeze({ + instance: 'instance', + openvpn_logread: 'openvpn', + openvpn_log_stamp: 'openvpn_log_stamp', + fwPort: 'fwPort', + fwProto: 'fwProto', + fw_openvpn_rule: 'openvpn_rule_', + fw_openvpn_inc: 'openvpn_inc_', + fw_openvpn_s2s_inc: 'openvpn_s2s_inc_', + fw_openvpn_zone: 'openvpn_zone_', + fw_openvpn_fwd_lan: 'openvpn_fwd_lan_', + fw_openvpn_fwd_vpn: 'openvpn_fwd_vpn_', + fw_openvpn_server: 'openvpn_server_', + }), + CONF: Object.freeze({ + modern_vpn_client: '# Modern OpenVPN Client Configuration Instance', + modern_vpn_server: '# Modern OpenVPN Server Configuration Instance', + openvpn_instance_status: 'openvpn.instance.status', + certificate_and_keys_comment: '# --- Certificates & Keys ---', + data_ciphers_aes: 'data-ciphers AES-256-GCM:AES-128-GCM:CHACHA20-POLY1305', + data_ciphers_chacha: 'data-ciphers CHACHA20-POLY1305:AES-256-GCM:AES-128-GCM', + data_ciphers_comment: '# Prioritize CHACHA20 on devices without hardware AES acceleration', + explicit_exit_notify: 'explicit-exit-notify 1', + sitetosite_routing_comment: '# Client Site-to-Site routing', + sitetosite_add_routes_comment: '# 1. Add routes to the router system', + sitetosite_enable_script_comment: '# 2. Enable script security to run shell commands', + sitetosite_setenv_cname_comment: '# 3. Save names and networks of remote offices in variables', + sitetosite_client_connect_comment: '# 4. luci-app-openvpn-plus iroute checks client name and sends the correct route', + sitetosite_push_route_comment: '# 5. Send local network of this router to all clients', + setenv_sitetosite_on: 'setenv sitetosite on', + client_push_options_comment: '# --- Client Push Options ---', + push_block_outside_dns: 'push "block-outside-dns"', + push_redirect_gateway: 'push "redirect-gateway def1 bypass-dhcp"', + push_redirect_gateway_ipv6: 'push "redirect-gateway ipv6"', + setenv_client_cname_: 'setenv CLIENT_CNAME_', + setenv_client_route_: 'setenv CLIENT_ROUTE_', + dev_tun: 'dev tun', + tls_timeout_5: 'tls-timeout 5', + mssfix_1360: 'mssfix 1360', + tun_mtu_1420: 'tun-mtu 1420' + }) +}) + +const ICON = Object.freeze({ + ARROW: '➔ ', + CHECK: '✓ ', + CHANGE: '✏️ ', + OFFICE: '🏢 ', + ERROR: '❌ ', + PLUS: '➕ ', + MINUS: '➖ ', + HINT: '💡 ', + INFO: 'ℹ️ ', + LOADING: '⏳ ', + FORWARD: '➡️ ', + SAVE: '💾 ', + SHIELD: '🛡️ ', + SUCCESS: '✅ ', + WARNING: '⚠️ ', + DOWNLOAD: '🔽 ', + ROCKET: '🚀 ', + POINT: '▪ ', + REMOVE: '🗑 ', + MOBILE: '📱 ', + IMPORT: '📥 ', + ROUTING: '🔗 ' +}); + +const OPENVPN = Object.freeze({ + ROLE: Object.freeze({ + SERVER: 'server', + CLIENT: 'client', + }), + STRATEGY: Object.freeze({ + REDIRECT: 'redirect', + SITETOSITE: 'sitetosite' + }), + PROTO: Object.freeze({ + UDP: 'udp', + TCP: 'tcp' + }), + PORT: Object.freeze({ + s1194: '1194', + n1194: 1194, + }), + IPv4: Object.freeze({ + ZERO: '0.0.0.0', + LOOPBACK: '127.0.0.1', + SUBNET_SERVER: '10.8.0.0', + MASK24: '255.255.255.0' + }), + CONN_TYPE: Object.freeze({ + DDNS: 'ddns' + }), + STATE: Object.freeze({ + disabled: 'disabled', + pending: 'pending', + active: 'active', + error: 'error' + }), + SCENARIO: Object.freeze({ + SITE_TO_SITE_CLIENT: 'sitetosite_client' + }) +}); + +/** + * Main data storage for the OpenVPN page + */ +const APP_DATA_TEMPLATE = Object.freeze({ + statusClass: null, + wizardClass: null, + keygenClass: null, + uptime: 0, + serverTemplate: '', + clientTemplate: '', + logread: '', + keysReady: false, + ovpnUciSections: null, + instances: null +}); + + +/** + * --- HELPER & INIT --- + */ + + +/** + * Strips all accidental Windows or Mac line breaks and trims spaces. + */ +const sanitizeInputLine = function (value) { + return String(value || '').trim().replace(/[\r\n]/g, ''); +}; + +/** + * Normalizes all Windows and Mac line breaks into clean UNIX line breaks for textareas. + */ +const sanitizeInputText = function (value) { + const rawText = value ? String(value).trim() : ''; + return rawText.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); +}; + +/** + * Returns an empty list using a promise when there is no data to load. + */ +const initEmptyUciView = function () { + return Promise.resolve([]); +}; + +/** + * Declare the native file read RPC once globally (LuCI caches this definition) + */ +const nativeFileReadRpc = L.rpc.declare({ + object: 'file', + method: 'read', + params: ['path'] +}); + +/** + * Native RPC wrapper to read a file as a plain string (replaces L.fs.read cleanly) + */ +const rpcReadFile = async function (path) { + const res = await L.resolveDefault(nativeFileReadRpc(path), null); + if (res && typeof res === 'object' && typeof res.data === 'string') { + return res.data; + } + return null; +}; + +/** + * Global single registration for file writing and directory creation + */ +const nativeFileWriteRpc = L.rpc.declare({ + object: 'file', + method: 'write', + params: ['path', 'data'] +}); + +/** + * Native RPC wrapper to write data (replaces L.fs.write cleanly) + */ +const rpcWriteFile = async function (filepath, content) { + const textData = (typeof content === 'string') ? content : ''; + const res = await L.resolveDefault(nativeFileWriteRpc(filepath, textData), -1); + if (res === 0 || (res && typeof res === 'object' && res.code === 0)) { + return 0; + } + return -1; +}; + +/** + * Native RPC wrapper to ensure a directory exists (replaces mkdir -p via L.fs.write) + */ +const rpcMakeDir = async function (filepath) { + const res = await L.resolveDefault(nativeFileWriteRpc(filepath, ''), -1); + if (res === 0 || (res && typeof res === 'object' && res.code === 0)) { + return 0; + } + return -1; +}; + +/** + * Global single registration for checking file statistics + */ +const nativeFileRemoveRpc = L.rpc.declare({ + object: 'file', + method: 'remove', + params: ['path'] +}); + +/** + * Native RPC wrapper to delete a file using async/await (replaces L.fs.remove cleanly) + */ +const rpcRemoveFile = async function (filepath) { + const res = await L.resolveDefault(nativeFileRemoveRpc(filepath), -1); + if (res === 0 || (res && typeof res === 'object' && res.code === 0)) { + return true; + } + return false; +}; + +/** + * Global single registration for file statistics (captures the full stat object) + */ +const nativeFileStatRpc = L.rpc.declare({ + object: 'file', + method: 'stat', + params: ['path'] +}); + +/** + * Native RPC wrapper to check file statistics (replaces L.fs.stat cleanly) + */ +const rpcStatFile = async function (filepath) { + const res = await L.resolveDefault(nativeFileStatRpc(filepath), {}); + if (res && typeof res === 'object' && (typeof res.type === 'string' || typeof res.mtime === 'number')) { + return res; + } + return null; +}; + +/** + * Declare the native file exec RPC once globally (LuCI caches this definition) + */ +const nativeFileExecRpc = L.rpc.declare({ + object: 'file', + method: 'exec', + params: ['command', 'params'] +}); + +/** + * Wrapper for /usr/libexec/luci-app-openvpn-plus using the native ubus RPC directly + */ +const luci_app_openvpn_plus = async function (args) { + const extraArgs = (typeof args === 'string') ? [args] : (Array.isArray(args) ? args : []); + const res = await L.resolveDefault(nativeFileExecRpc(CFG.LIBEXEC.luci_app_openvpn_plus, extraArgs), {}); + if (res && typeof res === 'object' && typeof res.stdout === 'string') { + return res.stdout.trim(); + } + return ''; +}; + +/** + * Container for native ubus RPC file and execution helper functions + */ +const rpcFileCallbacks = Object.freeze({ + rpcReadFile: rpcReadFile, + rpcWriteFile: rpcWriteFile, + luci_app_openvpn_plus: luci_app_openvpn_plus +}); + +/** + * Declare the native service list RPC once globally (LuCI caches this definition) + */ +const nativeServiceListRpc = L.rpc.declare({ + object: 'service', + method: 'list', + params: ['name'], + expect: { 'openvpn': {} } +}); + +/** + * Gets the active status of the OpenVPN service from ubus (replaces 'sh -c ubus' shell calls). + */ +const rpcGetServiceStatus = async function () { + const res = await L.resolveDefault(nativeServiceListRpc(CFG.CMD.openvpn), {}); + if (res && typeof res === 'object') { + return res; + } + return {}; +}; + +// Checks if a specific OpenVPN instance is enabled directly in the live UCI buffer +const isInstanceEnabled = function (instance_id) { + return L.uci.get(CFG.CMD.openvpn, instance_id, 'enabled') === '1'; +}; + +// Checks if at least one OpenVPN instance is active inside the provided configuration array +const isAnyInstanceEnabled = function (ovpnUciSections) { + const ovpnSections = Array.isArray(ovpnUciSections) ? ovpnUciSections : []; + + // .some() returns true immediately if any item matches the condition + return ovpnSections.some(function (section) { + return section && section['.name'] && isInstanceEnabled(section['.name']); + }); +}; + +/** + * Calculates a SHA256 hash using the privileged backend helper + */ +const sha256sum = async function (textToHash) { + const res = await luci_app_openvpn_plus([CFG.LIBEXEC.openssldgst, '-sha256', String(textToHash || '')]); + if (res.length > 0) { + const parts = res.trim().split(/\s+/); + return (parts && parts[0]) ? parts[0] : ''; + } else { + return ''; + } +}; + +/** + * Global helper function to determine the fallback IP and mask from browser or UCI config. + */ +const getHostnameFallback = function (returnMask) { + // Basic regex patterns to check for valid IPv4 or IPv6 layouts + const ipv4Pattern = /^([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$/; + const ipv6Pattern = /^(([0-9a-fA-F]{1,4}:){1,7}|:)(:[0-9a-fA-F]{1,4}){1,7}$|^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$/; + + // Get the address string from the browser URL bar and remove IPv6 brackets + let ip = (window.location.hostname || "").trim().replace(/^\[|\]$/g, ''); + let mask = '255.255.255.0'; + + // If browser URL is text (like openwrt.lan), fallback to configured UCI IPs + if (!ipv4Pattern.test(ip) && !ipv6Pattern.test(ip)) { + ip = L.uci.get('network', 'lan', 'ipaddr') || L.uci.get('network', 'lan', 'ip6addr') || '192.168.1.1'; + } + + // Set the matching subnet mask format based on the detected IP family + if (ipv4Pattern.test(ip)) { + mask = L.uci.get('network', 'lan', 'netmask') || OPENVPN.IPv4.MASK24; + } else if (ipv6Pattern.test(ip)) { + mask = L.uci.get('network', 'lan', 'ip6assign') || '64'; + } + + if (returnMask === true) { + return { ip: ip, mask: mask }; + } else { + return ip; + } +}; + +/** + * Memory buffer and timestamp to cache the land and public IP string + */ +const netCache = { + LanIp: null, + LanIpTime: 0, + LanIpMask: null, + LanIpMaskTime: 0, + PublicIp: null, + PublicIpTime: 0, + TIMEOUT: 60000 +}; + +/** + * Get LAN IP with local cache + */ +const getLanIp = async function (force) { + const now = new Date().getTime(); + if (force !== true && netCache.LanIp !== null && Math.abs(now - netCache.LanIpTime) < netCache.TIMEOUT) { + return netCache.LanIp; + } + const lanIp = await luci_app_openvpn_plus([CFG.LIBEXEC.lanip]); + if (lanIp.length > 0) { + netCache.LanIp = lanIp; + netCache.LanIpTime = new Date().getTime(); + return lanIp; + } + return getHostnameFallback(false); +}; +/** + * Get LAN IP and Mask with local cache + */ +const getLanIpMask = async function (force) { + const now = new Date().getTime(); + if (force !== true && netCache.LanIpMask !== null && Math.abs(now - netCache.LanIpMaskTime) < netCache.TIMEOUT) { + return netCache.LanIpMask; + } + const res = await luci_app_openvpn_plus([CFG.LIBEXEC.lanip, CFG.CMD.cidr]); + if (res.length > 0) { + const parts = res.split('/'); + if (parts.length === 2) { + const inputIp = parts[0]; + let inputMask = parts[1]; + + // Only run the bit-shifting calculation if it is a standard IPv4 address + if (inputIp.indexOf('.') !== -1) { + const inlinePrefix = parseInt(parts[1], 10) || 24; + const maskArray = [0, 0, 0, 0]; + + // Rebuild explicit netmask format string from the prefix bits + for (let bit4 = 0; bit4 < 32; bit4++) { + if (bit4 < inlinePrefix) { + maskArray[Math.floor(bit4 / 8)] |= (1 << (7 - (bit4 % 8))); + } + } + inputMask = maskArray.join('.'); + } + netCache.LanIpMask = { ip: inputIp, mask: inputMask }; + netCache.LanIpMaskTime = new Date().getTime(); + return netCache.LanIpMask; + } + } + return getHostnameFallback(true); +}; + +/** + * Get public WAN IP with local cache. + */ +const queryPublicIp = async function (force) { + const currentHost = window.location.hostname; + const currentTime = Date.now(); + if (force !== true && netCache.PublicIp !== null && Math.abs(currentTime - netCache.PublicIpTime) < netCache.TIMEOUT) { + return netCache.PublicIp; + } + const cleanIp = await luci_app_openvpn_plus([CFG.LIBEXEC.publicip]); + if (cleanIp.length > 0) { + netCache.PublicIp = cleanIp; + netCache.PublicIpTime = currentTime; + return cleanIp; + } + return currentHost; +}; + +/** + * Combined DDNS resolution function (checks your custom target or the public OpenWrt DDNS system) + */ +const getDdnsOrPublicIp = async function (instObj) { + let foundDomain = ''; + + // 1. Get the dynamic domain name from the .conf file data object + if (instObj && instObj.ddns) { + foundDomain = instObj.ddns.trim(); + } + + // If no domain was passed, try to look up the host via OpenWrt's UCI system + if (!foundDomain) { + try { + // Load the ddns configuration package asynchronously + await L.uci.load('ddns'); + const ddnsSections = L.uci.sections('ddns', 'service') || []; + + // Modern, clean loop to find the first valid lookup_host + for (const section of ddnsSections) { + const host = L.uci.get('ddns', section['.name'], 'lookup_host'); + if (host && host.trim() !== '') { + foundDomain = host.trim(); + // Found a valid host target, stop searching immediately + break; + } + } + } catch { + // Secure fallback if the ddns plugin package is missing on the system + foundDomain = ''; + } + } + + // 2. Resolve the domain we found or run the public IP fallback + if (foundDomain) { + const resolvedIp = await luci_app_openvpn_plus([CFG.LIBEXEC.checkddns, foundDomain]); + if (resolvedIp.length > 0) { + return resolvedIp; + } + } + + // 3. fallback if domain fails or is completely empty + return await queryPublicIp(false); +}; + +/** + * Clean any string from spaces, tabs, newlines, http(s)://, paths, ports and URL brackets + */ +const cleanIpOrDomain = async function (rawString) { + if (!rawString) { + return ''; + } + const cleaned = await luci_app_openvpn_plus([CFG.LIBEXEC.cleanipdns, rawString]); + if (cleaned.length > 0) { + return cleaned; + } + return rawString; +}; + +/** + * Check a DDNS domain name or validate a raw public WAN IP address, and verify if it is online + */ +const checkDdns = async function (targetHost) { + if (!targetHost) { + return { success: false, response: '' }; + } + try { + const res = await luci_app_openvpn_plus([CFG.LIBEXEC.checkddns, targetHost]); + if (res.length > 0) { + return { + success: true, + response: res + }; + } + } catch { + return { success: false, response: '' }; + } +}; + +/** + * Validate if a UDP or TCP port is open from the outside. Returns true or false. + */ +const checkPort = async function (targetHost, externalPort, internalPort, protocol) { + if (!targetHost || !externalPort || !internalPort || !protocol) { + return false; + } + const protoStr = protocol.toLowerCase(); + const res = await luci_app_openvpn_plus([CFG.LIBEXEC.checkport, targetHost, externalPort, internalPort, protoStr]); + if (res === 'PORT_OPEN') { + return true; + } + return false; +}; + +/** + * Check if the main WAN router has a working static route to a remote net pointing to this VPN router. + */ +const checkStaticRoute = async function (wanIp, remoteNet) { + if (!wanIp || !remoteNet) { + return false; + } + const res = await luci_app_openvpn_plus([CFG.LIBEXEC.checksroute, wanIp, remoteNet]); + if (res === 'ROUTE_OPEN') { + return true; + } + return false; +}; + +/** + * Run the background system task to update your dynamic DNS registration. + */ +const updateDdnsProvider = async function (updateUrl, domain) { + if (!updateUrl) { + return null; + } + const responseText = await luci_app_openvpn_plus([CFG.LIBEXEC.wgetddns, updateUrl, domain]); + if ((responseText.length > 0) && (responseText.toLowerCase().indexOf('success') !== -1)) { + return { + raw: responseText, + isError: false + }; + } + return { + raw: responseText, + isError: true + }; +}; + +/** + * checkNetworkStructure cached data storage + */ +const networkStructure = { + cachePromise: null, + cacheTime: null, + doubleNat: false, + apMode: false, + gateway: null, + localSubnets: null +} + +/** + * Checks the router network structure to find double NAT, AP mode, real gateway IP and collects all active subnets + */ +const checkNetworkStructure = function (appData) { + const privateIpRegex = /^(10\.|172\.(1[6-9]|2[0-9]|3[0-1])\.|192\.168\.|fd|fc)/i; + // Cache is valid for 10 seconds (10000 milliseconds) + const CACHE_TTL = 10000; + const now = Date.now(); + + // If cache exists and time difference is small, return the saved promise (Math.abs protects against system time changes - NTP syncs) + if (networkStructure.cachePromise && (Math.abs(now - networkStructure.cacheTime) < CACHE_TTL)) { + return networkStructure.cachePromise; + } + + // Save the current time and create a new promise + networkStructure.cacheTime = now; + networkStructure.cachePromise = new Promise(function (resolve) { + + // We create an internal async function to do the heavy work safely + const fetchNetworkData = async function () { + let localSubnets = []; + + try { + // 1. Get all local network devices + const devices = await network.getDevices(); + if (Array.isArray(devices)) { + for (const dev of devices) { + if (!dev) continue; + + // Extract the live IP address strings array from the network objects + const ipaddrs = dev.getIPAddrs ? dev.getIPAddrs() : []; + let deviceMask = (dev.getNetmask && dev.getNetmask()) ? dev.getNetmask() : (dev.data && dev.data.netmask) ? dev.data.netmask : null; + if (deviceMask === null || deviceMask === undefined || deviceMask === '') { + deviceMask = OPENVPN.IPv4.MASK24; + } + for (const rawIp of ipaddrs) { + if (!rawIp) continue; + const ipStr = String(rawIp); + // Use netIPCIDR to map the exact LAN network boundary + const calculatedNet = appData.statusClass.netIPCIDR(ipStr, deviceMask); + + if (calculatedNet && !localSubnets.includes(calculatedNet)) { + localSubnets.push(calculatedNet); + } + } + } + } + + // 2. Get the WAN (Internet) networks + const wanNetworks = await network.getWANNetworks(); + let isDoubleNat = false; + let isApMode = false; + let gatewayIp = ''; + + // If there is no WAN network, the router is probably in AP mode + if (!wanNetworks || wanNetworks.length === 0) { + isApMode = true; + + // Set values to global object before early exit + networkStructure.doubleNat = isDoubleNat; + networkStructure.apMode = isApMode; + networkStructure.gateway = OPENVPN.IPv4.ZERO; + networkStructure.localSubnets = localSubnets; + + resolve({ doubleNat: isDoubleNat, apMode: isApMode, gateway: OPENVPN.IPv4.ZERO, localSubnets: localSubnets }); + return; + } + + // Check all found WAN networks + for (const net of wanNetworks) { + if (!net) continue; + + // Get the gateway IP address + const gw4 = net.getGatewayAddr ? net.getGatewayAddr() : (net.data ? net.data.gateway : null); + if (gw4 && gw4 !== OPENVPN.IPv4.ZERO) { + gatewayIp = gw4; + } + + // Check WAN IPv4 addresses for Double NAT and extract WAN net-boundaries securely [sh] + const ipaddrs = net.getIPAddrs ? net.getIPAddrs() : []; + let deviceMask = (net.getNetmask && net.getNetmask()) ? net.getNetmask() : (net.data && net.data.netmask) ? net.data.netmask : null; + if (deviceMask === null || deviceMask === undefined || deviceMask === '') { + deviceMask = OPENVPN.IPv4.MASK24; + } + for (const rawWanIp of ipaddrs) { + if (!rawWanIp) continue; + + const wanIpStr = String(rawWanIp); + const ip = wanIpStr.split('/')[0]; + + // If the WAN IP is private, we have a Double NAT [sh] + if (privateIpRegex.test(ip)) { + isDoubleNat = true; + } + + // Use netIPCIDR to map the exact WAN network boundary + const calculatedWanNet = appData.statusClass.netIPCIDR(wanIpStr, deviceMask); + if (calculatedWanNet && !localSubnets.includes(calculatedWanNet)) { + localSubnets.push(calculatedWanNet); + } + } + + // Check WAN IPv6 addresses for Double NAT + const ip6addrs = net.getIP6Addrs ? net.getIP6Addrs() : []; + for (const rawWanIp6 of ip6addrs) { + if (!rawWanIp6) continue; + const ip6 = String(rawWanIp6).split('/')[0]; + if (privateIpRegex.test(ip6)) { + isDoubleNat = true; + } + } + } + + if (!gatewayIp) { + gatewayIp = OPENVPN.IPv4.ZERO; + } + + // Set values to global object (Fixed: changed 'gateway' to 'gatewayIp') + networkStructure.doubleNat = isDoubleNat; + networkStructure.apMode = isApMode; + networkStructure.gateway = gatewayIp; + networkStructure.localSubnets = localSubnets; + + // Return the finished network information object + resolve({ + doubleNat: isDoubleNat, + apMode: isApMode, + gateway: gatewayIp, + localSubnets: localSubnets + }); + + } catch (err) { + // If there is an error, delete the cache so the next call tries again freshly + console.error('Network structure background scan crashed:', err); + networkStructure.cachePromise = null; + resolve({ doubleNat: false, apMode: false, gateway: OPENVPN.IPv4.ZERO, localSubnets: localSubnets }); + } + }; + + // Start the async work instantly + fetchNetworkData(); + }); + + return networkStructure.cachePromise; +}; + +/** + * network callbacks container + */ +const networkCallbacks = ({ + sha256sum: sha256sum, + getLanIp: getLanIp, + queryPublicIp: queryPublicIp, + getDdnsOrPublicIp: getDdnsOrPublicIp, + cleanIpOrDomain: cleanIpOrDomain, + checkDdns: checkDdns, + checkPort: checkPort, + checkStaticRoute: checkStaticRoute, + updateDdnsProvider: updateDdnsProvider, + checkNetworkStructure: checkNetworkStructure +}); + +/** + * Asynchronously loads system telemetry, network metrics, logs, and configuration templates + */ +const loadSystemTelemetry = async function (appData) { + try { + + // Execute all filesystem reads in parallel for maximum speed + const results = await Promise.all([ + // 1. Ensure keys directory exists + rpcMakeDir(CFG.FILE.dir_keys), + + // 2. Check if specific files exist + rpcStatFile(CFG.FILE.dir_keys + CFG.FILE.client_def_tls2), + rpcStatFile(CFG.FILE.openvpn_keygen_lock), + + // 3. Read system and config files directly as strings + rpcReadFile(CFG.FILE.dir_cfg + CFG.FILE.server_def_conf), + rpcReadFile(CFG.FILE.dir_cfg + CFG.FILE.client_def_conf), + + // 4. Get the cleaned application log + callLogRead({ pattern: CFG.ID.openvpn_logread }) + ]); + + // Unpack the results array into meaningful variables while skipping the first index cleanly + const [, tlsStat, lockStat, serverTpl, clientTpl, logData] = results; + + // Unlock user interface only if keys exist on disk and background lock file is removed + appData.keysReady = (tlsStat && tlsStat.size > 0 && lockStat === null) ? true : false; + + appData.serverTemplate = serverTpl || ''; + appData.clientTemplate = clientTpl || ''; + appData.logread = logData || ''; + + } catch (err) { + // Safe fallback block to log unexpected low-level operational failures + console.error('Failed to load system telemetry data:', err); + } +}; + +/** + * Calculates a unique IPv4 server subnet string from the instance placement number + */ +const getServerSubnetFromInstNum = function (instNum) { + const ipSegments = OPENVPN.IPv4.SUBNET_SERVER.split('.'); + // Increment the second segment (the 8 inside 10.8.0.0) correctly + const calculatedOctet = parseInt(ipSegments[1], 10) - 1 + parseInt(instNum, 10); + if (calculatedOctet > 0 && calculatedOctet <= 254) { + ipSegments[1] = String(calculatedOctet); + return ipSegments.join('.'); + } + return OPENVPN.IPv4.SUBNET_SERVER; +}; + +/** + * Calculates a unique IPv6 server subnet prefix offset from the instance placement number + */ +const getServerIpv6SubnetFromInstNum = function (instNum) { + const parsedNum = parseInt(instNum, 10) || 1; + const subnetOffset = parsedNum - 1; + return 'fd00:db8:0:' + subnetOffset + '::/64'; +}; + +/** + * Executes a hardware cpu check to determine and return if the optimal cipher is AES + */ +const checkOptimalDataCipherAES = async function () { + + const res = await luci_app_openvpn_plus([CFG.LIBEXEC.bestcrypto]); + if (res.length > 0) { + if (res === 'AES') { + return true + } else { + return false; + } + } + return true; +}; + +/** + * Compresses and formats the configuration text. Optionally removes dynamic blocks defined by start and end identifiers. + */ +const formatOpenVpnConfig = function (configText, removelines) { + if (!configText || typeof configText !== 'string') { + return ''; + } + const filters = Array.isArray(removelines) ? removelines : []; + const lines = configText.split('\n'); + let formattedLines = []; + let skipBlock = false; + let currentLastId = ''; + + for (let i = 0; i < lines.length; i++) { + let currentLine = lines[i].trim(); + + // SECTION REMOVAL LOGIC + { + // If we are currently skipping lines, check if this line contains the end marker + if (skipBlock) { + // Check if the current line starts with the active lastid prefix + if (currentLine.indexOf(currentLastId) === 0) { + // check if the NEXT line is also part of the removal block prefix, if not stop skipping after this + let nextLine = (i + 1 < lines.length) ? lines[i + 1].trim() : ''; + if (nextLine.indexOf(currentLastId) !== 0) { + skipBlock = false; + currentLastId = ''; + } + } + // Skip this line + continue; + } + // Check if a new removal block starts on this line + let matchedFilter = false; + for (let j = 0; j < filters.length; j++) { + if (filters[j] && currentLine.indexOf(filters[j].startid) === 0) { + skipBlock = true; + currentLastId = filters[j].lastid; + matchedFilter = true; + break; + } + } + if (matchedFilter) { + // Skip the start line + continue; + } + } + // Skip entirely empty lines to reduce all double newlines first + if (currentLine === '') { + continue; + } + // Condition: From line 2 onwards, inject a clean empty line above comments (#) + if (currentLine.indexOf('#') === 0 && formattedLines.length > 0) { + formattedLines.push(''); + } + formattedLines.push(currentLine); + } + let cleanConfig = formattedLines.join('\n'); + return cleanConfig.trim() + '\n'; +}; + +/** + * Escapes dot characters to safely rewrite default crypto filenames into unique instance paths + */ +const escapeRegExp = function (str) { + return str.replace(/\./g, '\\.'); +}; + +/** + * Generate configuration file text for an OpenVPN server profile instance + */ +const generateServerConfig = async function (appData, newInstanceItem, wizardParams) { + if (!appData.serverTemplate) { + return ''; + } + + const id = newInstanceItem.id; + const instNum = newInstanceItem.instNum; + + const chosenPort = (wizardParams && wizardParams.port) ? wizardParams.port : newInstanceItem.port; + const chosenProto = (wizardParams && wizardParams.proto) ? wizardParams.proto : OPENVPN.PROTO.UDP; + const displayName = (wizardParams && wizardParams.displayName) ? wizardParams.displayName.trim() : ''; + + // Calculate the external client port target safely + let externPortValue = chosenPort; + if (wizardParams && wizardParams.portExtern) { + externPortValue = wizardParams.portExtern; + } + + // Save the correct port back into the shared object property + newInstanceItem.port = chosenPort; + + // Calculate unique server subnets using helper functions + const targetIpv4Subnet = getServerSubnetFromInstNum(instNum); + const targetIpv6Subnet = getServerIpv6SubnetFromInstNum(instNum); + const optimalDataCipherAES = await checkOptimalDataCipherAES(); + + let config = appData.serverTemplate + .replace(new RegExp(escapeRegExp(CFG.FILE.ca_def_crt), 'g'), 'ca_' + id + '.crt') + .replace(new RegExp(escapeRegExp(CFG.FILE.server_def_crt), 'g'), 'server_' + id + '.crt') + .replace(new RegExp(escapeRegExp(CFG.FILE.server_def_key), 'g'), 'server_' + id + '.key') + .replace(new RegExp(escapeRegExp(CFG.FILE.dh_def_pem), 'g'), 'dh_' + id + '.pem') + .replace(new RegExp(escapeRegExp(CFG.FILE.server_def_tls2), 'g'), 'server_' + id + '.tls2.key') + .replace(CFG.CONF.dev_tun, CFG.CONF.dev_tun + String(instNum - 1)) + .replace(/^port\s+\d+/m, 'port ' + chosenPort) + .replace(/^setenv\s+portextern\s+\d+/m, 'setenv portextern ' + externPortValue) + .replace(/^proto\s+\S+/m, 'proto ' + chosenProto) + .replace(/^server\s+10\.8\.0\.0/m, 'server ' + targetIpv4Subnet) + .replace(/^server-ipv6\s+fd00:db8:0:1::\/64/m, 'server-ipv6 ' + targetIpv6Subnet) + .replace(CFG.CONF.openvpn_instance_status, 'openvpn.instance' + instNum + '.status'); + + if (displayName) { + config = config.replace(CFG.CONF.modern_vpn_server, CFG.CONF.modern_vpn_server + ' #' + instNum + ' (' + displayName + ')'); + } else { + config = config.replace(CFG.CONF.modern_vpn_server, CFG.CONF.modern_vpn_server + ' #' + instNum); + } + + if (!optimalDataCipherAES) { + config = config.replace(CFG.CONF.data_ciphers_aes, CFG.CONF.data_ciphers_comment + "\n" + CFG.CONF.data_ciphers_chacha); + } + + let blocksToRemove = []; + if (chosenProto === OPENVPN.PROTO.UDP) { + // remove tls_timeout_5 if runing on UDP + config = config.replace(CFG.CONF.tls_timeout_5, ''); + } else { + // remove mssfix 1360 if running on TCP + config = config.replace(CFG.CONF.mssfix_1360, ''); + config = config.replace(CFG.CONF.tun_mtu_1420, ''); + } + + // Option A: Mobile clients profile settings (Route all traffic over VPN) + if (wizardParams && wizardParams.strategy === OPENVPN.STRATEGY.REDIRECT) { + config += '\n# Mobile Devices Routing\n\n'; + if (config.indexOf(CFG.CONF.push_redirect_gateway) === -1) { + config += CFG.CONF.push_redirect_gateway + '\n'; + } + if (config.indexOf(CFG.CONF.push_redirect_gateway_ipv6) === -1) { + config += CFG.CONF.push_redirect_gateway_ipv6 + '\n'; + } + if (config.indexOf(CFG.CONF.push_block_outside_dns) === -1) { + config += CFG.CONF.push_block_outside_dns + '\n'; + } + + // Fallback if missing in defalt config file + if (config.indexOf('dhcp-option DNS') === -1) { + const localLanIp = await getLanIp(true); + if (localLanIp && localLanIp.trim()) { + config += 'push "dhcp-option DNS ' + localLanIp.trim() + '"\n'; + config += 'push "dhcp-option DNS 9.9.9.9"\n'; + config += 'push "dhcp-option DNS 149.112.112.112"\n'; + } else { + config += 'push "dhcp-option DNS 9.9.9.9"\n'; + config += 'push "dhcp-option DNS 149.112.112.112"\n'; + } + } + if (config.indexOf('dhcp-option DNS6') === -1) { + config += 'push "dhcp-option DNS6 2620:fe::fe"\n'; + config += 'push "dhcp-option DNS6 2620:fe::9"\n'; + } + } + + // Option B: Office network settings (Universal Multi-Client Site-to-Site LAN-LAN Setup) + if (wizardParams && wizardParams.strategy === OPENVPN.STRATEGY.SITETOSITE) { + + // Remove push dhcp-option DNS for sito-to-site connection + config = config.replace(CFG.CONF.client_push_options_comment, ''); + config = config.replace(/push "dhcp-option DNS.*\n/g, ''); + config = config.replace(CFG.CONF.push_block_outside_dns, ''); + + config += '\n' + CFG.CONF.sitetosite_routing_comment + '\n'; + config += CFG.CONF.setenv_sitetosite_on + '\n\n'; + + // Load all registered client offices from the wizard array list + const targetClients = Array.isArray(wizardParams.clients) ? wizardParams.clients : []; + + config += CFG.CONF.sitetosite_add_routes_comment + '\n'; + targetClients.forEach(function (client) { + const isClientIpv6 = (client.subnet.indexOf(':') !== -1); + if (isClientIpv6 === true) { + // Use dynamic standard CIDR or prefix notation format for IPv6 + config += 'route-ipv6 ' + client.subnet + (client.mask ? '/' + client.mask : '') + '\n'; + } else { + config += 'route ' + client.subnet + ' ' + client.mask + '\n'; + } + }); + config += '\n'; + + config += CFG.CONF.sitetosite_enable_script_comment + '\n'; + config += 'script-security 2\n\n'; + + config += CFG.CONF.sitetosite_setenv_cname_comment + '\n'; + targetClients.forEach(function (client, index) { + const num = index + 1; + const isClientIpv6 = (client.subnet.indexOf(':') !== -1); + + config += CFG.CONF.setenv_client_cname_ + num + ' "' + client.commonName.trim() + '"\n'; + if (isClientIpv6 === true) { + config += CFG.CONF.setenv_client_route_ + num + ' "iroute-ipv6 ' + client.subnet + (client.mask ? '/' + client.mask : '') + '"\n'; + } else { + config += CFG.CONF.setenv_client_route_ + num + ' "iroute ' + client.subnet + ' ' + client.mask + '"\n'; + } + }); + config += '\n'; + + if (targetClients.length > 0) { + config += CFG.CONF.sitetosite_client_connect_comment + '\n'; + config += 'client-connect "' + CFG.LIBEXEC.luci_app_openvpn_plus + ' ' + CFG.LIBEXEC.iroute + '"' + '\n\n'; + } + + // Find local LAN network details of this server to push them back to clients + const localLan = await getLanIpMask(true); + if (localLan.ip) { + + config += CFG.CONF.sitetosite_push_route_comment + '\n'; + + const isLocalIpv6 = (localLan.ip.indexOf(':') !== -1); + if (isLocalIpv6 === true) { + // ipv6: get subnet with CIRD (e.g fd00:db8::/64) + const localSubnet = appData.statusClass.netIPCIDR(localLan.ip, localLan.mask); + config += 'push "route-ipv6 ' + localSubnet + '"\n'; + } else { + // ipv4: get subnet without CIRD (e.g 192.168.10.0) + const localSubnet = appData.statusClass.netIPCIDR(localLan.ip, null); + config += 'push "route ' + localSubnet + ' ' + localLan.mask + '"\n'; + } + } + } + + if (wizardParams && wizardParams.ddnsOrPublicIp) { + const targetHost = wizardParams.ddnsOrPublicIp.trim(); + const hasLetters = /[a-zA-Z]/.test(targetHost); + + if (wizardParams.connectionType === OPENVPN.CONN_TYPE.DDNS) { + // Scenario A: Active internal dynamic DNS updater script mode + config += '\n# Public Dynamic DNS (Autonomous background update routine)\n'; + config += 'setenv DDNS "' + targetHost + '"\n'; + config += 'setenv DDNS_PROVIDER "' + wizardParams.ddnsProvider + '"\n'; + config += 'setenv DDNS_URL "' + wizardParams.ddnsUrl + '"\n'; + } else if (hasLetters === true) { + // Fallback: User provided a domain name but uses an external DDNS client + config += '\n# Public Domain (Managed by an external DDNS client or provider)\n'; + config += 'setenv PUBLIC_DOMAIN ' + targetHost + '\n'; + } else if (wizardParams.isStaticIp === true) { + // Line connection with permanent static public IP + config += '\n# Public Static IP\n'; + config += 'setenv PUBLIC_STATIC_IP ' + targetHost + '\n'; + } else { + // Temporary dynamic public IP address with standard connection warnings + config += '\n# Public Dynamic IP\n'; + config += '# [WARNING] Value will change on ISP reconnection and your VPN connection will disconnect!\n'; + config += '# Please use a DDNS domain name to prevent this.\n'; + config += 'setenv PUBLIC_DYNAMIC_IP ' + targetHost + '\n'; + } + } + + // BACKUP RULE: If for some reason the template didn't have the line, add it as fallback + if (config.indexOf('setenv portextern') === -1) { + config += '\nsetenv portextern ' + externPortValue + '\n'; + } + + return formatOpenVpnConfig(config, blocksToRemove); +}; + +/** + * Finds the last active server ID, its configuration port number, and its protocol (udp/tcp) + */ +const getLastServerIdAndPort = function (currentId, defaultPort, appData) { + const result = { id: currentId, port: defaultPort, proto: OPENVPN.PROTO.UDP }; + + if (!appData || !Array.isArray(appData.instances)) { + return result; + } + + const instances = appData.instances; + + // Loop backwards directly through the structural instances array in RAM + for (let i = instances.length - 1; i >= 0; i--) { + const inst = instances[i]; + + // Find the nearest previous server instance that is not the current one + if (inst && inst.role === OPENVPN.ROLE.SERVER && inst.id !== currentId) { + result.id = inst.id; + if (inst.port && !isNaN(inst.port)) { + result.port = parseInt(inst.port, 10); + } + if (inst.proto) { + result.proto = inst.proto.toLowerCase(); + } + break; + } + } + return result; +}; + + +/** + * Calculates a unique loopback client IP address from the instance number + */ +const getClientIpFromInstNum = function (instNum) { + const ipSegments = OPENVPN.IPv4.LOOPBACK.split('.'); + + // Increment the last digit using the unique instance placement number + const lastSegment = parseInt(ipSegments[3], 10) - 1 + parseInt(instNum, 10); + + if (lastSegment > 0 && lastSegment <= 254) { + ipSegments[3] = String(lastSegment); + return ipSegments.join('.'); + } + return OPENVPN.IPv4.LOOPBACK; +}; + +/** + * Generate configuration file text for an OpenVPN client profile instance + */ +const generateClientConfig = async function (appData, newInstanceItem, wizardParams) { + if (!appData.clientTemplate) { + return ''; + } + + const id = newInstanceItem.id; + const instNum = newInstanceItem.instNum; + + const chosenPort = (wizardParams && wizardParams.port) ? wizardParams.port : newInstanceItem.port; + const chosenProto = (wizardParams && wizardParams.proto) ? wizardParams.proto : newInstanceItem.proto; + const remoteServer = (wizardParams && wizardParams.remoteServer) ? wizardParams.remoteServer.trim() : getClientIpFromInstNum(instNum); + const remoteServerLan = (wizardParams && wizardParams.remoteServerLan) ? wizardParams.remoteServerLan.trim() : ''; + const displayName = (wizardParams && wizardParams.displayName) ? wizardParams.displayName.trim() : ''; + const optimalDataCipherAES = await checkOptimalDataCipherAES(); + + // 1. Prepare base replacement variables for crypto paths + let config = appData.clientTemplate + .replace(new RegExp(escapeRegExp(CFG.FILE.ca_def_crt), 'g'), 'ca_' + id + '.crt') + .replace(new RegExp(escapeRegExp(CFG.FILE.client_def_crt), 'g'), 'client_' + id + '.crt') + .replace(new RegExp(escapeRegExp(CFG.FILE.client_def_key), 'g'), 'client_' + id + '.key') + .replace(new RegExp(escapeRegExp(CFG.FILE.client_def_tls2), 'g'), 'client_' + id + '.tls2.key') + .replace(CFG.CONF.dev_tun, CFG.CONF.dev_tun + String(instNum - 1)) + .replace(CFG.CONF.openvpn_instance_status, 'openvpn.instance' + instNum + '.status'); + + if (displayName) { + config = config.replace(CFG.CONF.modern_vpn_client, CFG.CONF.modern_vpn_client + ' #' + instNum + ' (' + displayName + ')'); + } else { + config = config.replace(CFG.CONF.modern_vpn_client, CFG.CONF.modern_vpn_client + ' #' + instNum); + } + + if (!optimalDataCipherAES) { + config = config.replace(CFG.CONF.data_ciphers_aes, CFG.CONF.data_ciphers_comment + "\n" + CFG.CONF.data_ciphers_chacha); + } + + if (chosenProto === OPENVPN.PROTO.TCP) { + // remove explicit-exit-notify 1, mssfix 1360 and tun-mtu 1420 if running on TCP + config = config.replace(CFG.CONF.explicit_exit_notify, ''); + config = config.replace(CFG.CONF.mssfix_1360, ''); + config = config.replace(CFG.CONF.tun_mtu_1420, ''); + } + + // 2. Inject remote server connection paths and transport protocols + let newRemote = 'remote ' + remoteServer + ' ' + chosenPort; + if (remoteServerLan !== '') { + newRemote = newRemote + '\nsetenv remotelan ' + remoteServerLan; + } + + config = config.replace(/^remote\s+\S+\s+\d+/m, newRemote); + config = config.replace(/^proto\s+\S+/m, 'proto ' + chosenProto); + + return formatOpenVpnConfig(config); +}; + +/** + * Compiles the final standalone .ovpn profile text with embedded keys + */ +const compileOvpnProfileText = async function (cname, targetHost, targetPort, proto, cryptoAssets, appData, instNum, isSiteToSite) { + + const optimalDataCipherAES = await checkOptimalDataCipherAES(); + + let new_remote = 'remote ' + targetHost + ' ' + targetPort; + if (isSiteToSite) { + const localLan = await getLanIpMask(true); + const localSubnet = appData.statusClass.netIPCIDR(localLan.ip, localLan.mask); + new_remote = new_remote + '\nsetenv remotelan ' + localSubnet; + } + + // Remove old crypto key references - ovpn profile comes with embedded keys + let ovpn = appData.clientTemplate + .replace(CFG.CONF.modern_vpn_client, CFG.CONF.modern_vpn_client + ' #' + instNum + ' (' + cname + ')') + .replace(CFG.CONF.certificate_and_keys_comment, '') + .replace(/^ca\s+\S+\r?\n/m, '') + .replace(/^cert\s+\S+\r?\n/m, '') + .replace(/^key\s+\S+\r?\n/m, '') + .replace(/^tls-crypt-v2\s+\S+\r?\n/m, '') + .replace(/^remote\s+\S+\s+\d+/m, new_remote) + .replace(/^proto\s+\S+/m, 'proto ' + proto); + + if (isSiteToSite !== true) { + ovpn = ovpn.replace(/^.*openvpn\.instance\.status.*\n/m, '') + ovpn = ovpn.replace(/^.*status-version.*\n/m, '') + } + + if (!optimalDataCipherAES) { + ovpn = ovpn.replace(CFG.CONF.data_ciphers_aes, CFG.CONF.data_ciphers_comment + '\n' + CFG.CONF.data_ciphers_chacha); + } + + if (proto === OPENVPN.PROTO.TCP) { + ovpn = ovpn.replace(CFG.CONF.mssfix_1360, ''); + ovpn = ovpn.replace(CFG.CONF.tun_mtu_1420, ''); + } + + // Collapse three or more consecutive newlines down to exactly one empty line + ovpn = ovpn.replace(/\n{3,}/g, '\n\n'); + ovpn += 'setenv client-cname ' + cname + '\n\n'; + + if (cryptoAssets.ca && typeof cryptoAssets.ca.trim === 'function') { + const rawCa = cryptoAssets.ca.trim(); + const cleanCa = rawCa.replace(/-----BEGIN[^\n]*PRIVATE KEY-----[\s\S]*?-----END[^\n]*PRIVATE KEY-----\n*/g, ''); + ovpn += '\n' + cleanCa.trim() + '\n\n\n'; + } + + if (cryptoAssets.cert && typeof cryptoAssets.cert.trim === 'function') { + ovpn += '\n' + cryptoAssets.cert.trim() + '\n\n\n'; + } + + if (cryptoAssets.key && typeof cryptoAssets.key.trim === 'function') { + ovpn += '\n' + cryptoAssets.key.trim() + '\n\n\n'; + } + + // Embedded client asset delivery wrapped into modern asymmetric tls-crypt-v2 XML layout tags + if (cryptoAssets.tlsCrypt && typeof cryptoAssets.tlsCrypt.trim === 'function' && cryptoAssets.tlsCrypt.trim()) { + ovpn += '\n' + cryptoAssets.tlsCrypt.trim() + '\n\n'; + } + + return ovpn; +}; + +/** + * Checks and creates all configuration and key files for an instance + */ +const syncInstanceFiles = async function (newInstanceItem, appData, wizardParams) { + const id = newInstanceItem.id; + const calculatedPort = appData.statusClass.calcPortFromId(id, newInstanceItem.instNum); + const rolePrefix = newInstanceItem.role + '_'; + const isServer = (newInstanceItem.role === OPENVPN.ROLE.SERVER); + + if (!wizardParams && newInstanceItem.role === OPENVPN.ROLE.CLIENT) { + // Get loopback server data if simple new client instance + const serverData = getLastServerIdAndPort(id, calculatedPort, appData); + newInstanceItem.loopbackServerId = serverData.id; + newInstanceItem.port = serverData.port; + newInstanceItem.proto = serverData.proto; + } else if (!wizardParams && isServer) { + newInstanceItem.port = calculatedPort; + } else if (wizardParams && wizardParams.port) { + // Ensure the wizard port is also mirrored inside the object property instantly + newInstanceItem.port = parseInt(wizardParams.port, 10); + if (wizardParams.proto) { + newInstanceItem.proto = wizardParams.proto; + } + } + + const def_crt = isServer ? CFG.FILE.server_def_crt : CFG.FILE.client_def_crt; + const def_key = isServer ? CFG.FILE.server_def_key : CFG.FILE.client_def_key; + const def_tls = isServer ? CFG.FILE.server_def_tls2 : CFG.FILE.client_def_tls2; + + // Push all tasks into an array to fire them simultaneously + const filePromises = [ + initFile(CFG.FILE.dir_cfg + id + '.conf', null, newInstanceItem, appData, wizardParams), + initFile(CFG.FILE.dir_keys + 'ca_' + id + '.crt', CFG.FILE.dir_keys + CFG.FILE.ca_def_crt, newInstanceItem, appData, wizardParams), + initFile(CFG.FILE.dir_keys + rolePrefix + id + '.crt', CFG.FILE.dir_keys + def_crt, newInstanceItem, appData, wizardParams), + initFile(CFG.FILE.dir_keys + rolePrefix + id + '.key', CFG.FILE.dir_keys + def_key, newInstanceItem, appData, wizardParams), + initFile(CFG.FILE.dir_keys + rolePrefix + id + '.tls2.key', CFG.FILE.dir_keys + def_tls, newInstanceItem, appData, wizardParams) + ]; + + if (isServer) { + filePromises.push( + initFile(CFG.FILE.dir_keys + 'dh_' + id + '.pem', CFG.FILE.dir_keys + CFG.FILE.dh_def_pem, newInstanceItem, appData, wizardParams) + ); + filePromises.push( + initFile(CFG.FILE.dir_cfg + id + '.nft', CFG.FILE.dir_cfg + CFG.FILE.default_nft, newInstanceItem, appData, wizardParams) + ); + } + + // We wait for all parallel file transmissions + return await Promise.all(filePromises); +}; + +/** + * Reads a file or creates it with default text if missing + */ +const initFile = async function (customPath, defaultPath, newInstanceItem, appData, wizardParams) { + // Try to read the file from the disk + const existingContent = await rpcReadFile(customPath); + if (existingContent) { + return existingContent; + } else { + + // STEP 1: If the missing file is a configuration profile (.conf), compile it now + if (customPath.indexOf('.conf') !== -1) { + let configContent = ''; + + if (newInstanceItem.role === OPENVPN.ROLE.CLIENT) { + configContent = await generateClientConfig(appData, newInstanceItem, wizardParams); + } else { + configContent = await generateServerConfig(appData, newInstanceItem, wizardParams); + } + newInstanceItem.confContent = configContent; + + // Write the new config file and return its content inline + await rpcWriteFile(customPath, configContent); + return configContent; + } + + // If no default fallback path is given, stop here safely + if (!defaultPath) { + return ''; + } + + // STEP 2: For local loopback tests, copy the server's keys so the client certificates match perfectly. + let sourcePath = defaultPath; + if (!wizardParams && newInstanceItem.role === OPENVPN.ROLE.CLIENT && newInstanceItem.loopbackServerId && newInstanceItem.loopbackServerId !== newInstanceItem.id) { + // Example: Change "ca_instance2.crt" to search for "ca_instance1.crt" on the disk + const fileName = customPath.substring(customPath.lastIndexOf('/') + 1); + const serverFileName = fileName.replace(newInstanceItem.id, newInstanceItem.loopbackServerId); + sourcePath = customPath.substring(0, customPath.lastIndexOf('/') + 1) + serverFileName; + } + + // STEP 3: Read the selected source file with a safe fallback to an empty string + let sourceContent = ''; + const rawSource = await rpcReadFile(sourcePath); + if (rawSource) { + sourceContent = String(rawSource).trim(); + } + + let content = ''; + if (sourceContent.length === 0 && sourcePath !== defaultPath) { + // SAFETY FALLBACK: If the local file was empty or missing, fall back to default files + let fallbackContent = ''; + const rawFallback = await rpcReadFile(defaultPath); + if (rawFallback) { + fallbackContent = String(rawFallback || '').trim(); + } + if (fallbackContent.length > 0) { + content = fallbackContent; + } + } else { + if (sourceContent.length > 0) { + content = sourceContent; + } + } + if (content.length > 0) { + // Write the verified clean content to the destination folder + await rpcWriteFile(customPath, content); + } + return content; + } +}; + +/** + * Asynchronously loads the settings and running state for all profiles + */ +const loadInstanceData = async function (appData) { + const ovpnUciSections = appData.ovpnUciSections || []; + + try { + // Get openvpn service data and system stats at the same time in parallel + const results = await Promise.all([ + rpcGetServiceStatus(), + appData.statusClass.getSystemUptime() + ]); + + const serviceData = results[0]; + const instancesObj = serviceData.instances || {}; + const systemUptime = results[1]; + + const syncPromises = []; + ovpnUciSections.forEach(function (s, idx) { + const id = s['.name']; + const instNum = appData.statusClass.getInstanceNumber(id, idx + 1); + const role = L.uci.get(CFG.CMD.openvpn, id, 'role') || OPENVPN.ROLE.SERVER; + const devName = appData.statusClass.getDevName(id, instNum, false); + + // Initialize a clean newInstanceItem template for the synchronization loop + const newInstanceItem = Object.assign({}, appData.statusClass.INSTANCE_TEMPLATE, { + id: id, + instNum: instNum, + devName: devName, + role: role, + connectedClients: [] + }); + + syncPromises.push(syncInstanceFiles(newInstanceItem, appData, null)); + }); + + // Await all files to be synchronized in parallel before reading status structures + await Promise.all(syncPromises); + + const updatedInstances = await appData.statusClass.readInstanceStatus(ovpnUciSections, instancesObj, systemUptime); + await appData.statusClass.refreshNetStat(updatedInstances); + appData.instances = updatedInstances; + + } catch (err) { + console.error('Failed to load instance configuration data engine:', err); + appData.instances = await appData.statusClass.readInstanceStatus(ovpnUciSections, {}, 0); + } +}; + + + +/** + * --- SAVE AND RESTART --- + */ + + +/** + * Define OpenVPN service start / stop with luci_app_openvpn_plus ovpnservice + */ +const ovpnServiceStartStop = { + Request: false, + uciSort: false, + Action: CFG.CMD.start, + Instance: '', + checkRequest: function () { + if (this.Request === true) { + this.Request = false; + + // Freeze the current state into local variables before entering the async timeout closure + const targetAction = this.Action; + const targetInstance = this.Instance; + this.Instance = ''; + setTimeout(async function () { + try { + await luci_app_openvpn_plus([CFG.LIBEXEC.ovpnservice, targetAction, targetInstance]); + } catch { + console.error('Asynchronous background OpenVPN service call failed: ' + CFG.LIBEXEC.luci_app_openvpn_plus + ' ' + CFG.LIBEXEC.ovpnservice + ' ' + targetAction + ' ' + targetInstance); + } + }, 0); + if (this.uciSort === true) { + this.uciSort = false; + setTimeout(async function () { + try { + await luci_app_openvpn_plus([CFG.LIBEXEC.ucisortovpn]); + } catch { + console.error('Asynchronous background call "uci reorder openvpn.instanceX=Y" failed: ' + CFG.LIBEXEC.luci_app_openvpn_plus + ' ' + CFG.LIBEXEC.ucisortovpn); + } + }, 3000); + } + } + return; + } +}; + +/** + * Trigger ovpnPending state for 5 seconds + */ +const ovpnPending = { + _t: null, + init: function (appData) { + if (appData) { + this._t = this._t = appData.statusClass.ovpnPending.trigger; + } + }, + trigger: function () { + if (typeof this._t === 'function') { + this._t(); + } + } +}; + +/** + * Intercepts the native LuCI apply event to trigger background OpenVPN and firewall lifecycle updates smoothly + */ +if (typeof L.ui.changes.apply === 'function') { + + const nativeLuCiApply = L.ui.changes.apply; + + L.ui.changes.apply = function () { + const hookArgs = arguments; + + // Trigger all background workers right before the changes are applied to the system + ovpnPending.trigger(); + reloadFirewall.checkRequest(); + ovpnServiceStartStop.checkRequest(); + + // UNBLOCKED MAIN PATH: Preserves strict 'this' binding contexts for UI overlays + return nativeLuCiApply.apply(this, hookArgs); + }; +} + +/** + * Saves all pending UCI modifications and refreshes the LuCI changes modal view safely + */ +const uciSaveApply = function (initCallback, savedCallback, finallyCallback, errorCallback) { + + // 1. Write current modifications to the active memory staging area + L.uci.save(); + + // 2. Initialize the LuCI changes manager framework + L.ui.changes.init().then(function () { + if (typeof initCallback === 'function') { + initCallback(); + } + // 3. Re-initialize and update the modal layout view with the new changes + if (L.ui && L.ui.changes && typeof L.ui.changes.displayChanges === 'function') { + L.ui.changes.displayChanges(); + } + }).then(function () { + // Triggers after the staging process finished without errors + if (typeof savedCallback === 'function') { + savedCallback(); + } + }).catch(function (err) { + // Catches any broken promises or core framework execution errors + if (typeof errorCallback === 'function') { + errorCallback(err); + } + }).finally(function () { + // Always runs at the very end of the execution chain + if (typeof finallyCallback === 'function') { + finallyCallback(); + } + }); +}; + +/** + * Shows the LuCI changes modal and restarts the OpenVPN instance safely + */ +const uciSaveApplyRestart = async function (instance_id) { + + const needsRestart = isInstanceEnabled(instance_id); + + if (needsRestart == true) { + const universalDate = new Date().toISOString().slice(0, 19) + 'Z'; + L.uci.set(CFG.CMD.openvpn, instance_id, 'restart', universalDate); + } + + ovpnServiceStartStop.Request = true; + ovpnServiceStartStop.Instance = instance_id + ovpnServiceStartStop.Action = needsRestart ? CFG.CMD.restart : CFG.CMD.stop; + + + // Save all current modifications to the uci memory buffer + L.uci.save(); + + // Open the standard LuCI review and apply changes window + try { + // Wait for the modal engine initialization + await L.ui.changes.init(); + + if (L.ui.changes.displayChanges && typeof L.ui.changes.displayChanges === 'function') { + L.ui.changes.displayChanges(); + + // requestAnimationFrame guarantees the DOM node is fully accessible before injection runs. + window.requestAnimationFrame(function () { + if (needsRestart == true) { + const modalNode = document.querySelector('.modal.uci-dialog') || document.querySelector('.modal'); + if (modalNode) { + const infoNotice = E('div', { + 'class': 'alert-message info', + 'style': 'margin:15px 0 15px 0; padding:12px; font-weight:bold; font-size:12px; line-height:1.5; ' + + 'border-left:4px solid var(--primary-color-high, #1976d2); ' + + 'background: color-mix(in srgb, var(--primary-color-high, #1976d2) 6%, transparent); ' + + 'color: color-mix(in srgb, var(--text-color-medium, #71717a) 85%, #0055ff); ' + + 'border-radius:4px;' + }, ICON.WARNING + TXT.MSG.config_changed_reload); + + const titleHeader = modalNode.querySelector('h4'); + if (titleHeader && titleHeader.nextSibling) { + modalNode.insertBefore(infoNotice, titleHeader.nextSibling); + } else { + modalNode.appendChild(infoNotice); + } + } + } + }); + } + } catch { + // Silent fallback on failure + } +}; + + +/** + * --- OVPN PROFILES --- + */ + + +/** + * Reads a profile file, tests keys with keymeta, and saves them to the router. + */ +const importOvpnClientProfile = async function (ovpnContent, instanceId) { + if (!ovpnContent || !ovpnContent.trim()) { + throw new Error(ICON.ERROR + TXT.ERROR.upload_key_empty); + } + + // Fix line endings for Windows, Mac, and Linux instantly + const content = sanitizeInputText(ovpnContent); + + // Setup clean variables for the router files + let caContent = ''; + let certContent = ''; + let keyContent = ''; + let tlsCryptContent = ''; + + // Try to find native OpenVPN XML tags in the text profile + const caXml = content.match(/([\s\S]*?)<\/ca>/); + const certXml = content.match(/([\s\S]*?)<\/cert>/); + const keyXml = content.match(/([\s\S]*?)<\/key>/); + const tlsCryptXml = content.match(/([\s\S]*?)<\/tls-crypt-v2>/); + + const blocksToScan = []; + let isXmlSource = false; + + // CHECK PATH: Test if we have XML tags or if we must scan raw PEM blocks + const hasValidCaXml = (caXml && caXml[1] && caXml[1].trim()); + const hasValidCertXml = (certXml && certXml[1] && certXml[1].trim()); + const hasValidKeyXml = (keyXml && keyXml[1] && keyXml[1].trim()); + + if (hasValidCaXml || hasValidCertXml || hasValidKeyXml) { + // This file has valid XML tags + isXmlSource = true; + + if (hasValidCaXml) { blocksToScan.push({ type: 'ca', text: caXml[1].trim() }); } + if (hasValidCertXml) { blocksToScan.push({ type: 'cert', text: certXml[1].trim() }); } + if (hasValidKeyXml) { blocksToScan.push({ type: 'key', text: keyXml[1].trim() }); } + if (tlsCryptXml && tlsCryptXml[1] && tlsCryptXml[1].trim()) { + blocksToScan.push({ type: 'tlscrypt-v2', text: tlsCryptXml[1].trim() }); + } + } else { + // Fallback: This is a pure keys.crt bundle. Get PEM text blocks using regex. + const certArray = content.match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g) || []; + const keyArray = content.match(/-----BEGIN[^\n]*?PRIVATE KEY-----[\s\S]*?-----END[^\n]*?PRIVATE KEY-----/g) || []; + const tlsCryptArray = content.match(/-----BEGIN OpenVPN tls-crypt-v2 client key-----[\s\S]*?-----END OpenVPN tls-crypt-v2 client key-----/g) || []; + + const combinedPem = [].concat(certArray, keyArray, tlsCryptArray); + for (let i = 0; i < combinedPem.length; i++) { + if (combinedPem[i]) { + blocksToScan.push({ type: 'unknown', text: combinedPem[i].trim() }); + } + } + } + + // SCAN LOOP: Test every single key block with the keymeta tool in the backend + for (let i = 0; i < blocksToScan.length; i++) { + const currentItem = blocksToScan[i]; + const currentBlock = currentItem ? currentItem.text : ''; + if (!currentBlock) { + continue; + } + + // Save the current block to a quick temporary file + const tmpFile = 'tmp_import_scan_' + i + '.pem'; + await rpcWriteFile(CFG.FILE.dir_keys + tmpFile, currentBlock + '\n'); + + // Run keymeta to check if this key is good or bad + const res = await luci_app_openvpn_plus([CFG.LIBEXEC.keymeta, tmpFile]); + await rpcRemoveFile(CFG.FILE.dir_keys + tmpFile); + const metaReport = res.toUpperCase(); + + // Stop instantly if keymeta finds an error or trash text inside the file + if (metaReport.length == 0 || metaReport.indexOf('ERROR') !== -1) { + throw new Error(ICON.ERROR + TXT.ERROR.key_check_failed); + } + + // SORTING: Place keys into correct variables using their real crypto type from keymeta + if (isXmlSource === true && currentItem.type !== 'unknown') { + // Trust the XML tags because keymeta verified that the content is good! + if (currentItem.type === 'ca') { caContent = currentBlock; } + else if (currentItem.type === 'cert') { certContent = currentBlock; } + else if (currentItem.type === 'key') { keyContent = currentBlock; } + else if (currentItem.type === 'tlscrypt-v2') { tlsCryptContent = currentBlock; } + } else { + // Sorting path for pure keys.crt files based on actual crypto traits + if (metaReport.indexOf('AUTHORITY') !== -1 || metaReport.indexOf('CA:TRUE') !== -1) { + caContent = currentBlock; + } + else if (metaReport.indexOf('STANDARD CERTIFICATE') !== -1 || metaReport.indexOf('PUBLIC-KEY') !== -1) { + certContent = currentBlock; + } + else if ((metaReport.indexOf('PRIVATE-KEY') !== -1) || metaReport.indexOf('Private-Key') !== -1) { + keyContent = currentBlock; + } + else if (metaReport.indexOf('TLSv2-Client-Key') !== -1) { + tlsCryptContent = currentBlock; + } + } + } + + // Safety check: Every profile needs at least a CA, a Cert, and a Private Key + if (!caContent || !certContent || !keyContent) { + throw new Error(ICON.ERROR + TXT.ERROR.invalid_file_parts_missing); + } + + // tls check: warning if missing - stop if requested + if (!tlsCryptContent) { + if (!window.confirm(ICON.WARNING + TXT.WARNING.tls_security_warning + '\n\n' + TXT.WARNING.tls_not_in_profile + '\n\n' + TXT.WARNING.tls_proced_import_anyway)) { + throw new Error(ICON.WARNING + TXT.WARNING.tls_missing); + } + } + + // Helper function to remove XML text tags before saving to disk + const stripXmlTags = function (str) { + return str.replace(/<\/?(ca|cert|key|tls-crypt)>/g, '').trim(); + }; + + // Save clean, verified keys to the router folders in parallel + await Promise.all([ + rpcWriteFile(CFG.FILE.dir_keys + 'ca_' + instanceId + '.crt', stripXmlTags(caContent) + '\n'), + rpcWriteFile(CFG.FILE.dir_keys + 'client_' + instanceId + '.crt', stripXmlTags(certContent) + '\n'), + rpcWriteFile(CFG.FILE.dir_keys + 'client_' + instanceId + '.key', stripXmlTags(keyContent) + '\n'), + tlsCryptContent ? rpcWriteFile(CFG.FILE.dir_keys + 'client_' + instanceId + '.tls2.key', stripXmlTags(tlsCryptContent) + '\n') : Promise.resolve() + ]); + + // Look for connection data to choose between a full setup or key rotation + const remoteServerMatch = content.match(/^remote\s+(\S+)\s+(\d+)/m); + const remoteServerLanMatch = content.match(/^setenv\s+remotelan\s+([^\n]+)/m); + const protoMatch = content.match(/^proto\s+(\S+)/m); + const cnameMatch = content.match(/^setenv\s+client-cname\s+([^\n]+)/m); + + if (!remoteServerMatch) { + return { + isCryptoUpdateOnly: true, + remoteServer: '', + remoteServerLan: '', + port: '', + proto: '', + cname: '' + }; + } + + return { + isCryptoUpdateOnly: false, + remoteServer: remoteServerMatch[1].trim(), + remoteServerLan: remoteServerLanMatch ? remoteServerLanMatch[1].trim() : '', + port: parseInt(remoteServerMatch[2], 10) || OPENVPN.PORT.n1194, + proto: protoMatch ? protoMatch[1].trim().toLowerCase() : OPENVPN.PROTO.UDP, + cname: cnameMatch ? cnameMatch[1].trim() : '' + }; +}; + +/** + * Displays the mobile QR code for OpenVPN profiles + */ +const renderClientOvpnProfileQr = function (containerNode, downloadUrl) { + containerNode.innerHTML = ''; + + if (typeof uqr !== 'undefined' && typeof uqr.renderSVG === 'function') { + try { + // Clean standard pixel size. Generates perfect native grid coordinates. + const rawSvgString = uqr.renderSVG(downloadUrl, { + ecc: 'M', + pixelSize: 4, + whiteColor: 'white', + blackColor: 'black' + }); + + // Inject the raw SVG string straight into the elastic box + containerNode.innerHTML = rawSvgString; + + // Remove any forced 100% stretching so the SVG stays at its true natural size + const svgElement = containerNode.querySelector('svg'); + if (svgElement) { + svgElement.style.display = 'block'; + svgElement.style.margin = '0 auto'; + svgElement.style.width = ''; + svgElement.style.height = ''; + svgElement.style.shapeRendering = 'crispEdges'; + } + } catch (e) { + console.error('Native uqr framework (QR-Code) execution failed:', e); + } + } else { + containerNode.appendChild(E('div', { 'style': 'color:color-mix(in srgb, var(--text-color-medium, #71717a) 85%, #0055ff); font-size:12px; padding:20px;' }, [ + E('strong', {}, ICON.WARNING + TXT.WARNING.profile_link_without_qr_code) + ])); + } +}; + +/** + * Displays the mobile layout container for OpenVPN profiles + */ +const renderClientOvpnProfileModal = function (ipFieldWrapper, urlContainer, qrContainer, dlBtn, closeBtn) { + return E('div', { 'class': 'cbi-map' }, [ + E('div', { 'class': 'cbi-section' }, [ + E('div', { 'class': 'cbi-section-descr', 'style': 'margin-bottom:15px; border-bottom:1px solid var(--border-color-medium, #cbd5e1); padding-bottom:8px;' }, + TXT.MSG.export_connection_address_qr_code + ), + E('div', { 'style': 'display:flex; flex-direction:column; align-items:center; width:100%; text-align:center; margin-bottom:15px; background: var(--background-color-medium, #f4f4f5); padding:12px; border:1px solid var(--border-color-medium, #cbd5e1); border-radius:4px;' }, [ + E('strong', { 'style': 'display:block; font-size:12px; color:color-mix(in srgb, var(--text-color-medium, #71717a) 85%, #0055ff); text-transform:uppercase; letter-spacing:0.5px;' }, TXT.MSG.vpn_client_address), + ipFieldWrapper + ]), + E('div', { 'style': 'display:flex; flex-direction:column; align-items:center; gap:15px; background: var(--background-color-medium, #f4f4f5); padding:20px; border-radius:6px; border:1px solid var(--border-color-medium, #cbd5e1); margin-bottom:15px;' }, [ + E('div', { 'style': 'width:100%; text-align:center;' }, [ + E('strong', { 'style': 'display:block; margin-bottom:6px; font-size:12px; color:color-mix(in srgb, var(--text-color-medium, #71717a) 85%, #0055ff); text-transform:uppercase; letter-spacing:0.5px;' }, TXT.MSG.secure_temporary_profile_url), + urlContainer + ]), + E('div', { 'style': 'width:100%; text-align:center; margin-top:5px;' }, [ + E('strong', { 'style': 'display:block; margin-bottom:2px; font-size:12px; color:color-mix(in srgb, var(--text-color-medium, #71717a) 85%, #0055ff); text-transform:uppercase; letter-spacing:0.5px;' }, TXT.MSG.scan_qr_code_with_camera), + qrContainer + ]), + E('div', { 'style': 'width:100%; text-align:center; margin-top:8px; font-size:12px; color:color-mix(in srgb, var(--text-color-medium, #71717a) 85%, #0055ff); padding-top:10px; border-top:1px dashed var(--border-color-medium, #cbd5e1); font-weight:500;' }, [ + E('span', { 'style': 'var(--success-color-high, #00ac59); font-weight:bold; margin-right:4px;' }, ICON.WARNING + TXT.WARNING.security_notice + ' '), + TXT.MSG.download_only_available_window_open + ]) + ]), + E('div', { 'style': 'text-align:right; border-top:1px solid var(--border-color-medium, #cbd5e1); padding-top:12px; margin-top:15px;' }, [ + dlBtn, E('span', { 'style': 'margin-right:10px;' }), closeBtn + ]) + ]) + ]); +}; + +/** + * Creates the basic screen elements for the mobile QR window + */ +const createQrBoxElements = function (initialHost) { + // Create the link field that users can click to open or download files + const urlOutput = E('a', { + 'target': '_blank', 'download': '', 'onclick': 'event.stopPropagation();', + 'style': 'display:block; width:100%; font-family:var(--font-monospace, monospace); font-size:13px; color:var(--primary-color-high, #1976d2); font-weight:bold; text-align:center; text-decoration:underline; padding:8px; background:var(--background-color-medium, #f4f4f5); border:1px dashed var(--border-color-medium, #cbd5e1); border-radius:4px; word-break:break-all;' + }, ['']); + + // Create a clean white card box that changes its size automatically + const qrContainer = E('div', { + 'style': 'text-align:center; padding:12px; background:var(--background-color-high); border:1px solid var(--border-color-medium, #cbd5e1); border-radius:6px; display:inline-block; box-sizing:border-box; width:auto; height:auto; min-width:140px; min-height:140px; box-shadow:0 1px 3px rgba(0,0,0,0.05); margin-top:5px;' + }, [E('em', {}, TXT.BTN.generating)]); + + const dlBtn = E('button', { 'class': 'cbi-button cbi-button-action important' }, ICON.SAVE + TXT.BTN.download_ovpn); + const closeBtn = E('button', { 'class': 'cbi-button cbi-button-neutral' }, TXT.BTN.close); + + const labelNode = E('span', { 'style': 'font-weight:bold; font-size:15px; margin-right:15px;' }, [initialHost]); + const inputNode = E('input', { 'type': 'text', 'class': 'cbi-input-text', 'value': initialHost, 'style': 'display:none; width:180px; font-weight:bold; font-size:13px; text-align:center; padding:4px;' }); + const editBtn = E('button', { 'class': 'cbi-button cbi-button-neutral', 'style': 'padding:2px 10px; font-size:11px;' }, [ICON.CHANGE + TXT.BTN.change]); + + const ipWrapper = E('div', { 'style': 'display:flex; flex-direction:row; align-items:center; justify-content:center; width:100%; margin-top:5px;' }, [labelNode, inputNode, editBtn]); + + return { + nodes: { url: urlOutput, qr: qrContainer, label: labelNode, input: inputNode, edit: editBtn, wrapper: ipWrapper }, + buttons: { download: dlBtn, close: closeBtn } + }; +}; + +/** + * Setup all click actions and update the view when things change + */ +const setupQrBoxEvents = function (elements, ovpnParams, cryptoAssets, appData, isSiteToSite) { + + // Update data, links and the QR code asynchronously when the host changes + const refreshModalState = async function () { + try { + const activeHost = elements.nodes.input.value ? sanitizeInputLine(elements.nodes.input.value) : window.location.hostname; + const instNumber = appData.statusClass.getInstanceNumber(ovpnParams.nextId); + + // 1. Await the dynamic profile compilation text block + const fullProfileText = await compileOvpnProfileText(ovpnParams.displayId, activeHost, ovpnParams.port, ovpnParams.proto, cryptoAssets, appData, instNumber, isSiteToSite); + const sanitizedFileName = ovpnParams.displayId.replace(/\s+/g, '_'); + const downloadUrl = window.location.protocol + '//' + window.location.host + '/' + sanitizedFileName + '_client.ovpn'; + + elements.nodes.url.href = downloadUrl; + elements.nodes.url.textContent = downloadUrl; + + // 2. Write the compiled text payload directly into the RAM configuration folder + await rpcWriteFile(ovpnParams.exportPath, fullProfileText); + + // 3. Trigger the backend symlink engine execution pipeline + await luci_app_openvpn_plus([CFG.LIBEXEC.symlink, instNumber.toString(), 'create', ovpnParams.displayId]); + + // 4. Render the vector graphic directly into the visible workspace panel + renderClientOvpnProfileQr(elements.nodes.qr, downloadUrl); + + } catch (e) { + console.error('Failed to process and link profile export assets:', e); + } + }; + + elements.nodes.input.addEventListener('input', refreshModalState); + + // Show or hide the text input box when clicking the change button + elements.nodes.edit.addEventListener('click', function (ev) { + ev.preventDefault(); + if (elements.nodes.input.style.display === 'none') { + elements.nodes.label.style.display = 'none'; + elements.nodes.input.style.display = 'inline-block'; + elements.nodes.input.focus(); + elements.nodes.edit.textContent = TXT.BTN.ok; + elements.nodes.edit.className = 'cbi-button cbi-button-action important'; + } else { + const typedVal = sanitizeInputLine(elements.nodes.input.value); + const activeMutationHost = typedVal || window.location.hostname; + elements.nodes.label.textContent = activeMutationHost; + + elements.nodes.input.style.display = 'none'; + elements.nodes.label.style.display = 'inline-block'; + elements.nodes.edit.textContent = ICON.CHANGE + TXT.BTN.change; + elements.nodes.edit.className = 'cbi-button cbi-button-neutral'; + + refreshModalState(); + } + }); + + // Download the profile file directly to a computer + elements.buttons.download.addEventListener('click', function () { + const activeHost = elements.nodes.input.value ? sanitizeInputLine(elements.nodes.input.value) : window.location.hostname; + const instNumber = appData.statusClass.getInstanceNumber(ovpnParams.nextId); + compileOvpnProfileText(ovpnParams.displayId, activeHost, ovpnParams.port, ovpnParams.proto, cryptoAssets, appData, instNumber, isSiteToSite).then(function (fullProfileText) { + const blob = new Blob([fullProfileText], { type: 'application/x-openvpn-profile' }); + const link = document.createElement('a'); + link.href = URL.createObjectURL(blob); + link.download = ovpnParams.displayId.replace(/\s+/g, '_') + '_client.ovpn'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }); + }); + + // Delete temporary files and close the window when clicking the close button + elements.buttons.close.addEventListener('click', async function () { + const instNumber = appData.statusClass.getInstanceNumber(ovpnParams.nextId); + try { + // 1. Remove the temporary export file + await rpcRemoveFile(ovpnParams.exportPath); + // 2. Call the universal script to delete the symlink + await luci_app_openvpn_plus([CFG.LIBEXEC.symlink, instNumber.toString(), 'delete', ovpnParams.displayId]); + } catch (err) { + // Log the error but do not block the UI from closing + console.error("Error during cleanup:", err); + } finally { + // 3. Always hide the modal window and trigger save/apply if needed + L.ui.hideModal(); + if (ovpnParams.saveApplyOpenVPN === true) { + uciSaveApplyRestart(ovpnParams.instance_id); + } + } + }); + + // Run the first update when the view loads + refreshModalState(); +}; + +/** + * Universal data structure template for packing generated OpenVPN profile assets + */ +const SERVER_FILES_TEMPLATE = { + conf: '', + ca: '', + cert: '', + key: '', + tlsCrypt: '' +}; + +/** + * Opens a simplified modal for mobile apps clients with a dynamic targetCnName input field and triggers asynchronous client cryptographic generation. + */ +const openMobileExportModal = function (instance_id, nextId, files, finalHost, triggerStandardExportFlow, appData) { + const cnInput = E('input', { + 'type': 'text', + 'class': 'cbi-input-text', + 'style': 'width:100%; font-weight:bold;', + 'placeholder': TXT.MSG.placeholder_cn_mobile + }); + cnInput.addEventListener('input', function () { + cnInput.classList.remove('cbi-input-invalid'); + }); + const modalConfirmBtn = E('button', { 'class': 'cbi-button cbi-button-action important' }, [TXT.BTN.next + ' ' + ICON.ARROW]); + const modalCancelBtn = E('button', { 'class': 'cbi-button cbi-button-neutral', 'style': 'margin-right:10px;' }, [TXT.BTN.cancel]); + const statusFeedbackNode = E('textarea', { + 'class': 'cbi-input-textarea', + 'style': 'width:100%; max-width:100%; resize:none; font-family:monospace; font-size:11px; display:none; background:#18181b; color:#4ade80; padding:10px; margin-top:12px;', + 'rows': '8', 'readonly': 'readonly' + }); + let forgedAssetsBundle = null; + let finalizedCnName = ''; + let saveApplyOpenVPN = false; + + L.ui.showModal(ICON.MOBILE + ' ' + TXT.MSG.export_openvpn_connect_client_profile, [ + E('div', { 'class': 'cbi-map' }, [ + E('div', { 'class': 'cbi-section' }, [ + E('div', { 'id': 'mobile-description-node', 'class': 'cbi-section-descr', 'style': 'margin-bottom:12px; font-size:12px; color:color-mix(in srgb, var(--text-color-medium, #71717a) 85%, #0055ff);' }, [ + TXT.MSG.please_assign_cn_name + ]), + E('div', { 'class': 'cbi-value', 'style': 'border:none; padding:0;' }, [ + E('div', { 'class': 'cbi-value-field', 'style': 'width:100%; margin:0; padding:0;' }, [ + cnInput, + statusFeedbackNode + ]) + ]), + E('div', { 'style': 'text-align:right; margin-top:15px; border-top:1px solid var(--border-color-medium, #cbd5e1); padding-top:12px;' }, [ + modalCancelBtn, modalConfirmBtn + ]) + ]) + ]) + ]); + modalCancelBtn.addEventListener('click', L.ui.hideModal); + + modalConfirmBtn.addEventListener('click', function (ev) { + ev.preventDefault(); + if (forgedAssetsBundle && finalizedCnName) { + L.ui.hideModal(); + triggerStandardExportFlow(forgedAssetsBundle, finalizedCnName, saveApplyOpenVPN); + return; + } + cnInput.classList.remove('cbi-input-invalid'); + const rawCn = sanitizeInputLine(cnInput.value); + const cleanCn = appData.wizardClass.getValidCommonName(rawCn); + if (!rawCn || !cleanCn) { + cnInput.classList.add('cbi-input-invalid'); + return; + } + finalizedCnName = cleanCn; + const descNode = document.getElementById('mobile-description-node'); + if (descNode) { descNode.style.display = 'none'; } + cnInput.style.display = 'none'; + modalConfirmBtn.disabled = true; + modalConfirmBtn.textContent = ICON.LOADING + ' ' + TXT.BTN.processing; + modalCancelBtn.style.display = 'none'; + statusFeedbackNode.style.display = 'block'; + + // Step 1: Run the standard Asynchronous Client Certificate generation pipeline + appData.keygenClass.executeAsynchronousKeyGen(nextId, appData.keygenClass.KEYTYPE.client_pki, appData.keygenClass.KEYTYPE.rsa2048_ec, appData.keygenClass.KEYTYPE.y100, finalizedCnName, '', statusFeedbackNode, rpcFileCallbacks, function (keygenSuccess, pkiPayload) { + if (!keygenSuccess || !pkiPayload) { + modalCancelBtn.style.display = 'inline-block'; + modalConfirmBtn.disabled = false; + modalConfirmBtn.textContent = TXT.BTN.next + ' ' + ICON.ARROW; + statusFeedbackNode.value += '\n' + ICON.ERROR + ' ' + TXT.ERROR.keygen_failed; + return; + } + statusFeedbackNode.value += '\nLOG: Certificate pipeline completed. Generating TLS crypt v2 client key...'; + + // Step 2: Directly chain the asymmetric client tls generation + appData.keygenClass.executeAsynchronousKeyGen(nextId, appData.keygenClass.KEYTYPE.client_tls2, 'none', '0', finalizedCnName, '', statusFeedbackNode, rpcFileCallbacks, function (tlsSuccess, nullData, tlsPayload) { + if (!tlsSuccess || !tlsPayload) { + modalCancelBtn.style.display = 'inline-block'; + modalConfirmBtn.disabled = false; + modalConfirmBtn.textContent = TXT.BTN.next + ' ' + ICON.ARROW; + statusFeedbackNode.value += '\n' + ICON.ERROR + ' TLS crypt v2 client key generation failed.'; + return; + } + + // Pack all 5 crypto components cleanly into the finalized delivery bundle + forgedAssetsBundle = Object.assign({}, SERVER_FILES_TEMPLATE, { + conf: files.conf, + ca: pkiPayload.ca, + cert: pkiPayload.cert, + key: pkiPayload.key, + tlsCrypt: tlsPayload + }); + modalConfirmBtn.disabled = false; + modalConfirmBtn.className = 'cbi-button cbi-button-action important'; + modalConfirmBtn.textContent = TXT.INFO.export_profile + '\u00A0\u00A0\u00A0' + ICON.DOWNLOAD; + + }, 'append'); + }, 'fresh'); + }); +}; + +/** + * Update or delete the clients on a site-to-site export config file + */ +const siteToSiteExportUpdateClients = async function (instance_id, nextId, targetCnName, targetSub, targetMask) { + // Read the current configuration file from disk memory + const currentConfigText = await rpcReadFile(CFG.FILE.dir_cfg + nextId + '.conf'); + const lines = currentConfigText ? currentConfigText.split('\n') : []; + + // Create an empty array to collect all clean existing offices + let collectedOffices = []; + + // Regular expressions to find office names and real networks inside the file lines + const cnameRegex = /setenv\s+CLIENT_CNAME_(\d+)\s+"([^"]+)"/; + const realNetRegex = /setenv\s+CLIENT_ROUTE_\d+\s+"i?route(?:-ipv6)?\s+([^\s"]+)(?:\s+([^\s"]+))?"/; + + const isArrayInput = Array.isArray(targetCnName); + + // STEP A: Loop through all lines to find CLIENT_CNAME and their index numbers + for (let l = 0; l < lines.length; l++) { + const line = lines[l].trim(); + const cnameMatch = line.match(cnameRegex); + if (cnameMatch) { + const currentNum = cnameMatch[1]; // The X inside CLIENT_CNAME_X + const currentName = cnameMatch[2].trim(); // The actual office name string + if (currentName) { + collectedOffices.push({ + num: currentNum, + commonName: currentName, + fullRealNet: '' + }); + } + } + } + + // STEP B: Loop through our found offices and find the matching CLIENT_ROUTE anywhere in the text + collectedOffices.forEach(function (office) { + for (let i = 0; i < lines.length; i++) { + if (lines[i].indexOf('CLIENT_ROUTE_' + office.num) !== -1) { + const netMatch = lines[i].match(realNetRegex); + if (netMatch) { + // Reconstruct the network identifier string safely for both IPv4 and IPv6 + office.fullRealNet = netMatch[1] + (netMatch[2] ? ' ' + netMatch[2] : ''); + break; + } + } + } + }); + + // STEP C: Filter out all offices that do not have both a name and a network string + collectedOffices = collectedOffices.filter(function (office) { + return office.commonName && office.fullRealNet; + }); + + // STEP D: Handle Array Input (DELETE Mode) or String Input (ADD Mode) + if (isArrayInput === true) { + // Convert all target names to lowercase for safe matching + const lowercaseTargets = targetCnName.map(function (name) { + return name.toLowerCase(); + }); + + // Filter out the offices that match any name inside our deletion array + collectedOffices = collectedOffices.filter(function (office) { + return lowercaseTargets.indexOf(office.commonName.toLowerCase()) === -1; + }); + } else { + // Standard path: Push the new office straight into our collection array + collectedOffices.push({ + num: '', + commonName: targetCnName, + fullRealNet: (targetSub.indexOf(':') !== -1) ? (targetSub + '/' + (targetMask || '64')) : (targetSub + ' ' + targetMask) + }); + } + + // Create an empty array for the clean base server configuration lines + const cleanedLines = []; + + // Setup state flags for our placeholder positioning markers + let systemRouteFound = false; + let clientEnvFound = false; + + const system_route_marker = 'system_route_marker'; + const client_env_marker = 'client_env_marker'; + + // Loop through all lines, strip old dynamic entries and place strategic markers + for (let l = 0; l < lines.length; l++) { + const currentLineTrimmed = lines[l].trim(); + + // Check for the very first naked system route line to insert our marker + if ((currentLineTrimmed.indexOf('route ') === 0 || currentLineTrimmed.indexOf('route-ipv6 ') === 0) && currentLineTrimmed.indexOf('push') === -1 && !systemRouteFound) { + systemRouteFound = true; + cleanedLines.push(system_route_marker); + continue; + } + + // Check for the very first client environment line to insert our marker + if (currentLineTrimmed.indexOf(CFG.CONF.setenv_client_cname_) === 0 && !clientEnvFound) { + clientEnvFound = true; + cleanedLines.push(client_env_marker); + continue; + } + + // ONLY skip the explicit dynamic blocks to clean up old data entries + if ((currentLineTrimmed.indexOf('route ') === 0 && currentLineTrimmed.indexOf('push') === -1) || + currentLineTrimmed.indexOf('route-ipv6 ') === 0 || + currentLineTrimmed.indexOf(CFG.CONF.setenv_client_cname_) === 0 || + currentLineTrimmed.indexOf(CFG.CONF.setenv_client_route_) === 0) { + continue; + } + + // Fallback Section: If no active code line was found yet, check for comment headers as anchors + if (!systemRouteFound && currentLineTrimmed.indexOf(CFG.CONF.sitetosite_add_routes_comment) === 0) { + systemRouteFound = true; + cleanedLines.push(lines[l]); + cleanedLines.push(system_route_marker); + continue; + } + + if (!clientEnvFound && currentLineTrimmed.indexOf(CFG.CONF.sitetosite_setenv_cname_comment) === 0) { + clientEnvFound = true; + cleanedLines.push(lines[l]); + cleanedLines.push(client_env_marker); + continue; + } + + // Keep all other code lines, comments and empty spaces completely untouched + cleanedLines.push(lines[l]); + } + + let dynamicRouteBlock = ''; + let dynamicEnvBlock = ''; + + // Loop through our clean array and build the new text lines sequentially from 1 to N + collectedOffices.forEach(function (office, index) { + const num = index + 1; + const isIpv6 = (office.fullRealNet.indexOf(':') !== -1); + + if (isIpv6 === true) { + // IPv6 Dual-Stack Configuration Commands + dynamicRouteBlock += 'route-ipv6 ' + office.fullRealNet + '\n'; + dynamicEnvBlock += CFG.CONF.setenv_client_cname_ + num + ' "' + office.commonName + '"\n' + + CFG.CONF.setenv_client_route_ + num + ' "iroute-ipv6 ' + office.fullRealNet + '"\n'; + } else { + // Standard IPv4 Configuration Commands + dynamicRouteBlock += 'route ' + office.fullRealNet + '\n'; + dynamicEnvBlock += CFG.CONF.setenv_client_cname_ + num + ' "' + office.commonName + '"\n' + + CFG.CONF.setenv_client_route_ + num + ' "iroute ' + office.fullRealNet + '"\n'; + } + }); + + let configOutput = cleanedLines.join('\n'); + + // Inject the dynamic blocks into the markers + if (systemRouteFound === true) { + configOutput = configOutput.replace(system_route_marker, dynamicRouteBlock.trim()); + } else if (dynamicRouteBlock) { + configOutput = dynamicRouteBlock + '\n' + configOutput; + } + + if (clientEnvFound === true) { + configOutput = configOutput.replace(client_env_marker, dynamicEnvBlock.trim()); + } else if (dynamicEnvBlock) { + configOutput = dynamicEnvBlock + '\n' + dynamicEnvBlock; + } + + // Clean up any remaining hanging system markers safely + configOutput = configOutput.replace(system_route_marker, '').replace(client_env_marker, ''); + + // Write the new configuration file back to disk storage memory + await rpcWriteFile(CFG.FILE.dir_cfg + instance_id + '.conf', formatOpenVpnConfig(configOutput)); +}; + + +/** + * Opens the selection modal for site-to-site clients and handles configuration updates + */ +const openSiteToSiteExportModal = function (instance_id, nextId, files, activeBranchNames, initialHost, triggerStandardExportFlow, appData) { + + const selectDropdown = E('select', { 'class': 'cbi-input-select', 'style': 'width:100%; font-weight:bold;' }); + activeBranchNames.forEach(function (name) { + selectDropdown.appendChild(E('option', { 'value': name }, [ICON.OFFICE + TXT.MSG.office_profile + ' ' + name])); + }); + selectDropdown.appendChild(E('option', { 'value': 'create_new_client' }, [ICON.PLUS + TXT.KEY.create_key_new_office])); + selectDropdown.appendChild(E('option', { 'value': 'delete_existing_clients' }, [ICON.MINUS + _('Delete Client Profile...')])); + + const subnetInputs = appData.wizardClass.renderSubnetInputs(); + + // Creates a hidden yellowish warning message box for network conflicts or duplicate names + const errorDisplayNode = E('div', { + 'style': 'display:none; margin-top:10px; margin-bottom:10px; padding:5px; background:color-mix(in srgb, var(--warn-color-high, #efbd0b) 18%, transparent); border-left:4px solid var(--warn-color-high, #efbd0b); font-family:var(--font-monospace, monospace); line-height:1.6; font-weight:bold; font-size:13px; text-align:center; border-radius:4px;' + }); + + // Create Add and Delete buttons + const addOfficeBtn = E('button', { 'class': 'cbi-button cbi-button-add', 'style': 'margin-top:8px; font-weight:bold;' }, [ICON.PLUS + _('Add')]); + const delOfficeBtn = E('button', { 'class': 'cbi-button cbi-button-remove disabled', 'style': 'margin-top:8px; font-weight:bold; text-align: right; display: none;', 'disabled': true }, [ICON.REMOVE + _('Delete')]); + + // Creates a small preview table to show the validated network data + const networkPreviewTable = E('table', { + 'style': 'display:none; width:auto; border-spacing:10px 2px; font-size:11px; margin-top:4px; text-align:left; border:0; background:transparent;' + }, [ + E('tr', {}, [ + E('th', { 'style': 'padding:0 5px; color:var(--text-color-medium, #71717a); font-weight:bold;' }, [TXT.INFO.remote_name]), + E('th', { 'style': 'padding:0 5px; color:var(--text-color-medium, #71717a); font-weight:bold;' }, [TXT.INFO.remote_lan_subnet]), + E('th', { 'style': 'padding:0 5px; color:var(--text-color-medium, #71717a); font-weight:bold;' }, [TXT.INFO.netmask]) + ]), + E('tr', { 'style': 'font-family:monospace; font-weight:bold;' }, [ + E('td', { 'id': 'preview_name', 'style': 'padding:0 5px;' }, ['-']), + E('td', { 'id': 'preview_subnet', 'style': 'padding:0 5px;' }, ['-']), + E('td', { 'id': 'preview_mask', 'style': 'padding:0 5px;' }, ['-']) + ]) + ]); + + // Combines the preview table and buttons into a flexible space-between row + const flexBtnContainer = E('div', { 'style': 'display:flex; justify-content: space-between; align-items: flex-end; width:100%;' }, [ + addOfficeBtn, + networkPreviewTable, + delOfficeBtn + ]); + + + // Combine input fields, buttons and error box into one container + const inputRowContainer = E('div', { 'style': 'margin-top:4px; display:none;' }, [ + subnetInputs.node, + flexBtnContainer, + errorDisplayNode + ]); + + // Create an independent checklist container for picking branches to delete + const deleteChecklistContainer = E('div', { + 'style': 'margin-top:12px; padding:15px; background:var(--background-color-medium, #f4f4f5); border:1px dashed var(--border-color-medium, #f4f4f5); border-radius:4px; width:100%; display: none;' + }, [ + E('strong', { 'style': 'display:block; margin-bottom:10px; font-size:12px; color:var(--error-color-high, #f62b12); text-transform:uppercase; font-family:var(--font-sans);' }, [_('Select clients to remove:')]) + ]); + + // Generate dynamic matrix layout checklist row objects inside the memory tree + const checkboxElementsArray = []; + activeBranchNames.forEach(function (branchName) { + const uniqueCheckboxId = 'del_target_' + branchName.replace(/[^a-zA-Z0-9]/g, '_'); + const checkboxInput = E('input', { + 'type': 'checkbox', + 'id': uniqueCheckboxId, + 'value': branchName, + 'style': 'width:18px; height:18px; margin:0; cursor:pointer;' + }); + + // Monitor checkboxes live to block or unlock the master confirm execution tracking button + checkboxInput.addEventListener('change', function () { + const hasSelection = checkboxElementsArray.some(function (cb) { + return cb.checked; + }); + modalConfirmBtn.disabled = !hasSelection; + }); + + checkboxElementsArray.push(checkboxInput); + + deleteChecklistContainer.appendChild(E('label', { + 'for': uniqueCheckboxId, + 'style': 'display:flex; align-items:center; gap:10px; margin-bottom:8px; padding:6px; background:var(--background-color-medium, #f4f4f5); border-radius:3px; cursor:pointer; font-weight:bold;' + }, [ + checkboxInput, + E('span', { 'style': 'font-family:monospace;' }, [ICON.OFFICE + branchName]) + ])); + }); + + if (activeBranchNames.length === 0) { + selectDropdown.value = 'create_new_client'; + inputRowContainer.style.display = 'block'; + } + + // Handle dropdown visibility and button state changes + selectDropdown.addEventListener('change', function (e) { + if (e.target.value === 'create_new_client') { + inputRowContainer.style.display = 'block'; + deleteChecklistContainer.style.display = 'none'; + // Use delete click logic to trigger an initial data reset + delOfficeBtnClick(); + modalConfirmBtn.disabled = true; + } else if (e.target.value === 'delete_existing_clients') { + inputRowContainer.style.display = 'none'; + errorDisplayNode.style.display = 'none'; + deleteChecklistContainer.style.display = 'block'; + // Check if any deletion check item is already selected + const hasAnySelection = checkboxElementsArray.some(function (cb) { + return cb.checked; + }); + modalConfirmBtn.disabled = !hasAnySelection; + } else { + inputRowContainer.style.display = 'none'; + deleteChecklistContainer.style.display = 'none'; + errorDisplayNode.style.display = 'none'; + modalConfirmBtn.disabled = false; + } + }); + + const modalConfirmBtn = E('button', { 'class': 'cbi-button cbi-button-action important' }, [TXT.BTN.next + ' ' + ICON.ARROW]); + const modalCancelBtn = E('button', { 'class': 'cbi-button cbi-button-neutral', 'style': 'margin-right:10px;' }, [TXT.BTN.cancel]); + const statusFeedbackNode = E('textarea', { + 'class': 'cbi-input-textarea', + 'style': 'width:100%; max-width:100%; resize:none; font-family:monospace; font-size:11px; display:none; background:#18181b; color:#4ade80; padding:10px; margin-top:12px;', + 'rows': '8', 'readonly': 'readonly' + }); + + if (selectDropdown.value === 'create_new_client' || selectDropdown.value === 'delete_existing_clients') { + modalConfirmBtn.disabled = true; + } + + let localCachedValidatedNetwork = null; + let forgedAssetsBundle = null; + let finalizedCnName = ''; + let saveApplyOpenVPN = false; + + // Action for the Add button - validates inputs, checks for duplicates, and checks router subnets + addOfficeBtn.addEventListener('click', function (ev) { + ev.preventDefault(); + errorDisplayNode.style.display = 'none'; + + const validatedNetworkData = subnetInputs.validateAndFetchData(); + if (!validatedNetworkData) { + return; + } + + // Check if the office name already exists in activeBranchNames + const isDuplicateCN = activeBranchNames.some(function (name) { + return name.toLowerCase() === validatedNetworkData.commonName.toLowerCase(); + }); + + let isDuplicateSubnet = false; + appData.instances.forEach(function (inst) { + if (inst.id == instance_id) { + inst.remoteSubnets.forEach(function (subnet) { + if (subnet.indexOf(validatedNetworkData.subnet) !== -1) { + isDuplicateSubnet = true; + return; + } + }); + return; + } + }); + + if (isDuplicateCN || isDuplicateSubnet) { + errorDisplayNode.innerHTML = ICON.WARNING + ' ' + (isDuplicateCN ? TXT.ERROR.office_name_already_exist : TXT.ERROR.ip_used_on_other_office) + errorDisplayNode.style.display = 'block'; + subnetInputs.fields.cn.classList.add('cbi-input-invalid'); + subnetInputs.fields.cn.focus(); + return; + } + + // Call the system network check to prevent local subnet collisions + checkNetworkStructure(appData).then(function (networkState) { + const blockedSubnets = networkState.localSubnets || []; + + // Check if input matches any local LAN or WAN network range + let invalidSubNet = false; + blockedSubnets.forEach(function (subnet) { + if (subnet.indexOf(validatedNetworkData.subnet) !== -1) { + errorDisplayNode.innerHTML = ICON.WARNING + ' ' + TXT.ERROR.use_local_ipnetwork; + errorDisplayNode.style.display = 'block'; + subnetInputs.fields.sub.classList.add('cbi-input-invalid'); + subnetInputs.fields.sub.focus(); + invalidSubNet = true; + return; + } + }); + if (invalidSubNet == true) { + return; + } + + // Save validated data to variable and freeze inputs + localCachedValidatedNetwork = validatedNetworkData; + + // Write the validated values into our table preview cells + flexBtnContainer.querySelector('#preview_name').textContent = validatedNetworkData.commonName; + flexBtnContainer.querySelector('#preview_subnet').textContent = validatedNetworkData.subnet; + flexBtnContainer.querySelector('#preview_mask').textContent = validatedNetworkData.mask; + + subnetInputs.fields.cn.disabled = true; + subnetInputs.fields.sub.disabled = true; + subnetInputs.fields.mask.disabled = true; + + // Show the preview table, hide Add button, and display Delete button on the right + addOfficeBtn.style.display = 'none'; + networkPreviewTable.style.display = 'table'; + delOfficeBtn.disabled = false; + delOfficeBtn.classList.remove('disabled'); + delOfficeBtn.style.display = 'block'; + + // Unlock the main Next button + modalConfirmBtn.disabled = false; + }).catch(function () { + errorDisplayNode.innerHTML = ICON.WARNING + ' ' + _('Error: Network status check failed.'); + errorDisplayNode.style.display = 'block'; + }); + }); + + // Master helper function to clean errors, clear text fields, and reset button styles + const delOfficeBtnClick = function () { + localCachedValidatedNetwork = null; + errorDisplayNode.style.display = 'none'; + subnetInputs.clearErrors(); + + // Unlock and clear input text boxes + subnetInputs.fields.cn.disabled = false; + subnetInputs.fields.sub.disabled = false; + subnetInputs.fields.mask.disabled = false; + subnetInputs.fields.cn.value = ''; + subnetInputs.fields.sub.value = ''; + subnetInputs.fields.mask.value = ''; + + // Reset button container layout and visibilities + networkPreviewTable.style.display = 'none'; + addOfficeBtn.style.display = 'block'; + addOfficeBtn.disabled = false; + addOfficeBtn.classList.remove('disabled'); + delOfficeBtn.disabled = true; + delOfficeBtn.classList.add('disabled'); + delOfficeBtn.style.display = 'none'; + + // Lock the main Next button again + modalConfirmBtn.disabled = true; + subnetInputs.fields.cn.focus(); + }; + + // Action for the Delete button - clears data, unlocks inputs and resets button states + delOfficeBtn.addEventListener('click', function (ev) { + ev.preventDefault(); + delOfficeBtnClick(); + }); + + L.ui.showModal(ICON.ROUTING + TXT.MSG.lan_to_lan_profile_selection, [ + E('div', { 'class': 'cbi-map' }, [ + E('div', { 'class': 'cbi-section' }, [ + E('div', { 'id': 's2s-description-node', 'class': 'cbi-section-descr', 'style': 'margin-bottom:12px; font-size:12px; color:color-mix(in srgb, var(--text-color-medium, #71717a) 85%, #0055ff);' }, [ + TXT.MSG.select_remote_office_to_export + ]), + E('div', { 'class': 'cbi-value' }, [ + E('div', { 'class': 'cbi-value-field', 'style': 'width:100%; margin:0; padding:0;' }, [ + selectDropdown, + inputRowContainer, + deleteChecklistContainer, + statusFeedbackNode + ]) + ]), + E('div', { 'style': 'text-align:right; margin-top:15px; border-top:1px solid var(--border-color-medium, #cbd5e1); padding-top:12px;' }, [ + modalCancelBtn, modalConfirmBtn + ]) + ]) + ]) + ]); + + modalCancelBtn.addEventListener('click', L.ui.hideModal); + + modalConfirmBtn.addEventListener('click', function (ev) { + ev.preventDefault(); + if (forgedAssetsBundle && finalizedCnName) { + L.ui.hideModal(); + triggerStandardExportFlow(forgedAssetsBundle, finalizedCnName, saveApplyOpenVPN); + return; + } + + const targetMode = selectDropdown.value; + let targetCnName = ''; + let targetSub = ''; + let targetMask = ''; + let isNewClient = false; + const isDeleteRoute = (targetMode === 'delete_existing_clients'); + + // PATH A: DIRECT RE-ROUTING IF DELETE CLIENT IS SELECTED + if (isDeleteRoute === true) { + const selectedDeleteTargetsArray = []; + checkboxElementsArray.forEach(function (cb) { + if (cb.checked && cb.value) { + selectedDeleteTargetsArray.push(cb.value); + } + }); + + // Extra safety guard: abort if no checkbox items are marked + if (selectedDeleteTargetsArray.length === 0) { + return; + } + + // Call update clients script directly using the collected deletion array list + L.ui.hideModal(); + siteToSiteExportUpdateClients(instance_id, nextId, selectedDeleteTargetsArray, '', ''); + uciSaveApplyRestart(instance_id); + return; + } + + // PATH B: STANDARD CODE TRACK FOR CREATION AND EXPORT + if (targetMode === 'create_new_client') { + if (!localCachedValidatedNetwork) { + return; + } + targetCnName = localCachedValidatedNetwork.commonName; + targetSub = localCachedValidatedNetwork.subnet; + targetMask = localCachedValidatedNetwork.mask; + isNewClient = true; + } else { + targetCnName = targetMode; + } + + finalizedCnName = targetCnName; + const descNode = document.getElementById('s2s-description-node'); + if (descNode) { + descNode.style.display = 'none'; + } + selectDropdown.style.display = 'none'; + inputRowContainer.style.display = 'none'; + modalConfirmBtn.disabled = true; + modalConfirmBtn.textContent = ICON.LOADING + ' ' + TXT.BTN.processing; + modalCancelBtn.style.display = 'none'; + statusFeedbackNode.style.display = 'block'; + + // Step 1: Execute standard client certificate generation pipeline + appData.keygenClass.executeAsynchronousKeyGen(nextId, appData.keygenClass.KEYTYPE.client_pki, appData.keygenClass.KEYTYPE.rsa2048_ec, appData.keygenClass.KEYTYPE.y100, targetCnName, '', statusFeedbackNode, rpcFileCallbacks, async function (keygenSuccess, pkiPayload, nullData) { + if (!keygenSuccess || !pkiPayload) { + modalCancelBtn.style.display = 'inline-block'; + modalConfirmBtn.disabled = false; + modalConfirmBtn.textContent = TXT.BTN.next + ' ' + ICON.ARROW; + statusFeedbackNode.value += '\n' + ICON.ERROR + ' ' + TXT.ERROR.keygen_failed; + return; + } + statusFeedbackNode.value += '\nLOG: Certificate pipeline completed. Generating TLS crypt v2 client key...'; + + // Step 2: Directly chain the asymmetric client tls generation + appData.keygenClass.executeAsynchronousKeyGen(nextId, appData.keygenClass.KEYTYPE.client_tls2, 'none', '0', targetCnName, '', statusFeedbackNode, rpcFileCallbacks, async function (tlsSuccess, nullData, tlsPayload) { + if (!tlsSuccess || !tlsPayload) { + modalCancelBtn.style.display = 'inline-block'; + modalConfirmBtn.disabled = false; + modalConfirmBtn.textContent = TXT.BTN.next + ' ' + ICON.ARROW; + statusFeedbackNode.value += '\n' + ICON.ERROR + ' TLS crypt v2 client key generation failed.'; + return; + } + const activeConfigContentText = files; + if (isNewClient === true) { + await siteToSiteExportUpdateClients(instance_id, nextId, targetCnName, targetSub, targetMask); + saveApplyOpenVPN = true; + } + forgedAssetsBundle = Object.assign({}, SERVER_FILES_TEMPLATE, { + conf: activeConfigContentText.conf, + ca: pkiPayload.ca, + cert: pkiPayload.cert, + key: pkiPayload.key, + tlsCrypt: tlsPayload + }); + modalConfirmBtn.disabled = false; + modalConfirmBtn.className = 'cbi-button cbi-button-action important'; + modalConfirmBtn.textContent = TXT.INFO.export_profile + '\u00A0\u00A0\u00A0' + ICON.DOWNLOAD; + }, 'append'); + }, 'fresh'); + }); +}; + +/** + * Exporter for OpenVPN profiles with embedded client crypto data + */ +const downloadClientOvpnProfile = async function (instance_id, instObj, customUciName, appData) { + const nextId = instance_id; + const displayId = customUciName || instance_id; + const currentRole = instObj.role || 'client'; + const rolePrefix = currentRole + '_'; + + // Note: Profile will be compiled in: triggerStandardExportFlow -> setupQrBoxEvents -> compileOvpnProfileText + + try { + // Read all 5 crypto files simultaneously in parallel + const readSafe = async function (path) { + const content = await rpcReadFile(path); + if (content) { + return content; + } else { + return ''; + } + }; + + // Read all 5 crypto files simultaneously in parallel + const rawFilesArray = await Promise.all([ + readSafe(CFG.FILE.dir_cfg + nextId + '.conf'), + readSafe(CFG.FILE.dir_keys + 'ca_' + nextId + '.crt'), + readSafe(CFG.FILE.dir_keys + rolePrefix + nextId + '.crt'), + readSafe(CFG.FILE.dir_keys + rolePrefix + nextId + '.key'), + readSafe(CFG.FILE.dir_keys + rolePrefix + nextId + '.tls2.key') + ]); + + // Validate that we actually have the configuration and the required keys + if (!rawFilesArray || rawFilesArray.length < 5 || !rawFilesArray[0]) { + L.ui.addNotification(null, E('p', {}, TXT.ERROR.config_key_missing), 'error'); + return; + } + + const serverAssets = Object.assign({}, SERVER_FILES_TEMPLATE, { + conf: rawFilesArray[0], + ca: rawFilesArray[1], + cert: rawFilesArray[2], + key: rawFilesArray[3], + tlsCrypt: rawFilesArray[4] + });; + + + // Check if this server configuration is Site-To-Site + const isSiteToSite = appData.statusClass.parseSiteToSite(serverAssets.conf); + + // Read connection parameters instantly from your clean instObj RAM cache! + const serverProto = instObj.proto || OPENVPN.PROTO.UDP; + const internalPort = instObj.port || OPENVPN.PORT.s1194; + + // Read the custom client port range directly from the template schema + const currentPort = instObj.portExtern || internalPort; + + // Match both the variable type [1] and the target value [2] using capturing groups + const ddnsMetaMatch = serverAssets.conf.match(/^setenv\s+(DDNS|PUBLIC_DOMAIN|PUBLIC_STATIC_IP|PUBLIC_DYNAMIC_IP)\s+"?(\S+?)"?$/m); + + const configIpType = ddnsMetaMatch ? ddnsMetaMatch[1] : ''; + const savedHost = ddnsMetaMatch ? ddnsMetaMatch[2].trim() : ''; + let finalHost = ''; + + // If it is a permanent domain name or verified static IP, never overwrite it + if (configIpType === 'DDNS' || configIpType === 'PUBLIC_DOMAIN' || configIpType === 'PUBLIC_STATIC_IP') { + finalHost = savedHost; + } else { + // For unverified dynamic IPs, try to get the public IP via our flat cache engine + try { + const liveDetectedIp = await getDdnsOrPublicIp(instObj, false); + finalHost = liveDetectedIp || savedHost || window.location.hostname; + } catch { + finalHost = savedHost || window.location.hostname; + } + } + + const exportStaticPath = CFG.FILE.dir_cfg + nextId + '.ovpn'; + + const ovpnDownloadParams = { + nextId: nextId, + displayId: displayId, + port: currentPort, + proto: serverProto, + exportPath: exportStaticPath, + instance_id: instance_id, + saveApplyOpenVPN: false + }; + + // --- TYPE A: Flow for standard devices (Mobile Apps) --- + const triggerStandardExportFlow = function (finalCryptoBundle, overrideCn, saveApplyOpenVPN) { + const ovpnParamsUpdated = Object.assign({}, ovpnDownloadParams); + let title; + if (overrideCn) { + title = ICON.OFFICE + TXT.MSG.client_export; + ovpnParamsUpdated.displayId = overrideCn; + } else { + title = ICON.MOBILE + TXT.MSG.mobile_export; + } + if (saveApplyOpenVPN) { + ovpnParamsUpdated.saveApplyOpenVPN = saveApplyOpenVPN; + } + const ui = createQrBoxElements(finalHost); + setupQrBoxEvents(ui, ovpnParamsUpdated, finalCryptoBundle, appData, isSiteToSite); + L.ui.showModal(title + ': ' + ovpnParamsUpdated.displayId, [ + renderClientOvpnProfileModal(ui.nodes.wrapper, ui.nodes.url, ui.nodes.qr, ui.buttons.download, ui.buttons.close) + ]); + }; + + // If it is a standard mobile server, open the Mobile Export modal to generate unique client_pki keys first + if (!isSiteToSite) { + openMobileExportModal(instance_id, nextId, serverAssets, finalHost, triggerStandardExportFlow, appData); + return; + } + + // Parse existing office names safely from the configuration content string rows + const activeBranchNames = []; + const nameRegex = /setenv\s+CLIENT_CNAME_\d+\s+"([^"]+)"/g; + let regexMatch = nameRegex.exec(serverAssets.conf); + + while (regexMatch !== null) { + if (regexMatch && regexMatch[1]) { + const cleanName = regexMatch[1].trim(); + if (cleanName && activeBranchNames.indexOf(cleanName) === -1) { + activeBranchNames.push(cleanName); + } + } + regexMatch = nameRegex.exec(serverAssets.conf); + } + + // --- TYPE B: Open the clean isolated Site-to-Site Selection Modal UI Sub-Routine --- + openSiteToSiteExportModal(instance_id, nextId, serverAssets, activeBranchNames, finalHost, triggerStandardExportFlow, appData); + + } catch (err) { + // Central error interception handler cleanly logs all file system crashes + L.ui.addNotification(null, E('p', {}, TXT.ERROR.build_profile + ' ' + err.message), 'error'); + } +}; + + +/** + * --- STATUS VIEW --- + */ + + +/** + * Updates the visual styles, backgrounds, and action buttons based on the operational three-way state. + */ +const updateStatusBoxVisuals = function (ovpnState, badgeLabelNode, badgeImgNode, boxHeadNode, btnEnableOpenVPN) { + + if (ovpnState === OPENVPN.STATE.active) { + badgeLabelNode.textContent = TXT.INFO.openvpn + ' (' + TXT.INFO.active + ')'; + badgeImgNode.src = CFG.FILE.vpn_enabled_img; + if (boxHeadNode) { + boxHeadNode.style.setProperty('background', 'var(--success-color-high, #00ac59)', 'important'); + boxHeadNode.style.setProperty('color', 'var(--on-success-color, white)', 'important'); + } + if (badgeLabelNode) { + badgeLabelNode.style.setProperty('color', 'var(--on-success-color, white)', 'important'); + } + + if (btnEnableOpenVPN) { + btnEnableOpenVPN.className = 'cbi-button cbi-button-negative important'; + btnEnableOpenVPN.textContent = TXT.INFO.disable + ' ' + TXT.INFO.openvpn; + } + } else if (ovpnState === OPENVPN.STATE.pending) { + badgeLabelNode.textContent = TXT.INFO.openvpn + ' (' + TXT.INFO.pending + ')'; + badgeImgNode.src = CFG.FILE.vpn_disabled_img; + if (boxHeadNode) { + boxHeadNode.style.setProperty('background', 'var(--warn-color-high, #efbd0b)', 'important'); + boxHeadNode.style.setProperty('color', 'var(--on-warn-color, #000000)', 'important'); + } + if (badgeLabelNode) { + badgeLabelNode.style.setProperty('color', 'var(--on-warn-color, #000000)', 'important'); + } + + if (btnEnableOpenVPN) { + btnEnableOpenVPN.className = 'cbi-button cbi-button-negative important'; + btnEnableOpenVPN.textContent = TXT.INFO.disable + ' ' + TXT.INFO.openvpn; + } + } else if (ovpnState === OPENVPN.STATE.error) { + badgeLabelNode.textContent = TXT.INFO.openvpn + ' (' + TXT.INFO.error + ')'; + badgeImgNode.src = CFG.FILE.vpn_disabled_img; + if (boxHeadNode) { + boxHeadNode.style.setProperty('background', 'var(--error-color-high, #f62b12)', 'important'); + boxHeadNode.style.setProperty('color', 'var(--on-error-color, white)', 'important'); + } + if (badgeLabelNode) { + badgeLabelNode.style.setProperty('color', 'var(--on-error-color, white)', 'important'); + } + + if (btnEnableOpenVPN) { + btnEnableOpenVPN.className = 'cbi-button cbi-button-negative important'; + btnEnableOpenVPN.textContent = TXT.INFO.disable + ' ' + TXT.INFO.openvpn; + } + } else { + badgeLabelNode.textContent = TXT.INFO.openvpn + ' (' + TXT.INFO.disabled + ')'; + badgeImgNode.src = CFG.FILE.vpn_disabled_img; + if (boxHeadNode) { + boxHeadNode.style.setProperty('background', 'var(--background-color-medium, #f4f4f5)', 'important'); + boxHeadNode.style.setProperty('color', 'var(--text-color-high, #333333)', 'important'); + } + if (badgeLabelNode) { + badgeLabelNode.style.setProperty('color', 'var(--text-color-high, #333333)', 'important'); + } + + if (btnEnableOpenVPN) { + btnEnableOpenVPN.className = 'cbi-button cbi-button-positive important'; + btnEnableOpenVPN.textContent = TXT.INFO.enable + ' ' + TXT.INFO.openvpn; + } + } +}; + +/** + * Status Control Box Containser + */ +let statusControlBox = null; +let statusTooltipNode = null; + +/** + * Handles the main toggle button click event to cycle all instances. + */ +const handelEnableOpenVPN = function (ovpnUciSections, ifaceBoxMasterNode, addServerBtn, addClientBtn, badgeLabelNode, badgeImgNode, boxHeadNode, applyNotice, btnEnableOpenVPN) { + const ovpnSections = ovpnUciSections || []; + + if (ovpnSections.length === 0) return; + + ifaceBoxMasterNode.style.opacity = '0.4'; + btnEnableOpenVPN.disabled = true; + if (addServerBtn) addServerBtn.disabled = true; + if (addClientBtn) addClientBtn.disabled = true; + + const unlockUI = function () { + ifaceBoxMasterNode.style.opacity = '1'; + btnEnableOpenVPN.disabled = false; + if (addServerBtn) addServerBtn.disabled = false; + if (addClientBtn) addClientBtn.disabled = false; + }; + + const nextState = isAnyInstanceEnabled(ovpnSections) ? '0' : '1'; + + for (let k = 0; k < ovpnSections.length; k++) { + if (ovpnSections[k] && ovpnSections[k]['.name']) { + L.uci.set(CFG.CMD.openvpn, ovpnSections[k]['.name'], 'enabled', nextState); + } + } + + updateStatusBoxVisuals(nextState, badgeLabelNode, badgeImgNode, boxHeadNode, btnEnableOpenVPN); + + if (statusControlBox) { + statusControlBox.setAttribute('data-current-state', nextState); + } + applyNotice.style.display = 'inline-block'; + + uciSaveApply( + function () { + ovpnServiceStartStop.Request = true; + ovpnServiceStartStop.Instance = "" + ovpnServiceStartStop.Action = (nextState === '0') ? CFG.CMD.stop : CFG.CMD.start; + }, + null, + unlockUI, + null + ); +}; + +/** + * Open Wizard normally + */ +const openWizardBtnClick = function (appData, newInstNumber, hideClient) { + if (!newInstNumber) { + newInstNumber = getNextInstanceNumber(appData); + } + const wizardData = Object.assign({}, appData.wizardClass.WIZARD_DATA_TEMPLATE, { + appData: appData, + addNewInstanceCallback: addNewInstance, + networkCallbacks: networkCallbacks, + uciSaveApplyRestartCallback: uciSaveApplyRestart, + importOvpnClientProfileCallback: importOvpnClientProfile, + instanceNumber: newInstNumber, + forcedScenario: null, + }); + appData.wizardClass.openWizardModal(wizardData, hideClient); +} + +/** + * Renders the status control and setup wizard box for OpenVPN + */ +const renderStatusControlBox = function (ovpnInitialState, addServerBtn, addClientBtn, appData) { + const applyNotice = E('span', { 'class': 'text-danger', 'style': 'font-weight:bold; margin-left:15px; display:none;' }, ICON.WARNING + TXT.BTN.click_save_apply); + const newInstNumber = getNextInstanceNumber(appData); + + const btnEnableOpenVPN = E('button', { + 'style': 'text-shadow:none !important; box-shadow:none !important; white-space:nowrap;' + }, ''); + + // Show Wizard button + const openWizardBtn = E('button', { + 'class': 'cbi-button cbi-button-apply important', + 'style': 'text-shadow: none !important; ' + + 'box-shadow: 0 4px 6px -1px color-mix(in srgb, var(--primary-color-high, #1976d2) 20%, transparent) !important; ' + + 'white-space: nowrap; ' + + 'padding: 6px 16px; ' + + 'font-weight: bold; ' + + // FLEXBOX: keep icon in the middle + 'display: inline-flex; ' + + 'align-items: center; ' + + 'justify-content: center; ' + + 'gap: 8px;' + }, [ + E('span', { 'style': 'font-size: 16px; line-height: 1;' }, ICON.ROCKET), + E('span', {}, TXT.INFO.wizard + ' ...') + ]); + + // Right control container with a crisp vertical layout to stack buttons + const rightControlContainer = E('div', { + 'style': 'display:flex; flex-direction:column; gap:25px; align-items:stretch; margin-left:auto;' + }, [ + openWizardBtn, + btnEnableOpenVPN + ]); + + // Set dynamic badge label based on the calculated three-way status + let labelText = TXT.INFO.openvpn + ' (' + TXT.INFO.disabled + ')'; + if (ovpnInitialState === OPENVPN.STATE.active) { + labelText = TXT.INFO.openvpn + ' (' + TXT.INFO.active + ')'; + } else if (ovpnInitialState === OPENVPN.STATE.error) { + labelText = TXT.INFO.openvpn + ' (' + TXT.INFO.error + ')'; + } else if (ovpnInitialState === OPENVPN.STATE.pending) { + labelText = TXT.INFO.openvpn + ' (' + TXT.INFO.starting + ')'; + } + + const badgeLabelNode = E('strong', { style: 'color:white !important;' }, labelText); + const badgeImgNode = E('img', { 'class': 'middle', 'style': 'width:48px; height:48px; vertical-align:middle;' }); + + const boxHeadNode = E('div', { + 'class': 'ifacebox-head', + 'style': 'padding:3px 8px; font-size:12px; text-shadow:none !important;' + }, [badgeLabelNode]); + + statusTooltipNode = appData.statusClass.getTooltipNode(ovpnInitialState, appData); + + const tooltipContainer = E('span', { 'class': 'cbi-tooltip-container' }, [ + badgeImgNode, + statusTooltipNode + ]); + + const boxBodyNode = E('div', { + 'class': 'ifacebox-body', + 'style': 'padding:12px; text-align:center; min-height:0; background:transparent !important;' + }, [ + tooltipContainer + ]); + + // Apply corporate styles, action labels, and colors directly + updateStatusBoxVisuals(ovpnInitialState, badgeLabelNode, badgeImgNode, boxHeadNode, btnEnableOpenVPN); + + const ifaceBoxMasterNode = E('div', { + 'class': 'ifacebox', + 'style': 'display:inline-block; width:160px; vertical-align:middle; margin:0; transition:opacity 0.15s ease-in-out; border:1px solid var(--border-color-medium, #cbd5e1); border-radius:4px; overflow:hidden;' + }, [boxHeadNode, boxBodyNode]); + + + btnEnableOpenVPN.addEventListener('click', function (ev) { + ev.preventDefault(); + handelEnableOpenVPN(appData.ovpnUciSections, ifaceBoxMasterNode, addServerBtn, addClientBtn, badgeLabelNode, badgeImgNode, boxHeadNode, applyNotice, btnEnableOpenVPN); + }); + + // Bind click handler to bridge execution flow into the separate wizard module class + openWizardBtn.addEventListener('click', function (ev) { + ev.preventDefault(); + openWizardBtnClick(appData, newInstNumber, false) + }); + + + return E('div', { 'style': 'margin-bottom:25px; width:100%;' }, [ + E('h2', { 'style': 'font-weight:bold; margin:0 0 10px 0; padding:0;' }, TXT.INFO.title_main), + E('p', { 'style': 'font-style:normal; margin-bottom:20px; color:color-mix(in srgb, var(--text-color-medium, #71717a) 85%, #0055ff);' }, TXT.MSG.manage_instance), + E('fieldset', { 'class': 'class_fieldset', 'style': 'margin-bottom:5px; padding:0; border:0; background:transparent;' }, [ + E('div', { 'style': 'display:flex; align-items:flex-start; justify-content:space-between; padding:3px 0; margin:0; min-height:0; width:100%;' }, [ + E('div', { 'style': 'display:inline-flex; align-items:center;' }, [ + ifaceBoxMasterNode, applyNotice + ]), + rightControlContainer + ]) + ]) + ]); +}; + +/** + * Refreshes the status control box and tooltip + */ +const refreshStatusBoxCallback = function (ovpnNewState, tooltip, appData) { + + statusTooltipNode = tooltip; + + if (statusControlBox && statusControlBox.firstChild) { + const currentVpnState = statusControlBox.getAttribute('data-current-state'); + + if (currentVpnState !== ovpnNewState) { + const mainControlBoxNode = renderStatusControlBox(ovpnNewState, null, null, appData); + statusControlBox.replaceChild(mainControlBoxNode, statusControlBox.firstChild); + statusControlBox.setAttribute('data-current-state', ovpnNewState); + } + } +}; + + +/** + * --- OPENVPN INSTANCES --- + */ + + +/** + * Finds the lowest available instance number starting from 1 + */ +const getNextInstanceNumber = function (appData) { + // 1. Extract all existing numbers into an array + const existingNumbers = []; + + if (appData.ovpnUciSections && appData.ovpnUciSections.length > 0) { + appData.ovpnUciSections.forEach(function (section) { + const numMatch = section['.name'].match(/\d+$/); + if (numMatch) { + existingNumbers.push(parseInt(numMatch[0], 10)); + } + }); + } + + // 2. Loop from 1 upwards and return the first number that is not in the array + let checkNum = 1; + while (existingNumbers.indexOf(checkNum) !== -1) { + checkNum++; + } + + return checkNum; +}; + +/** + * Renders the control row with Show, Download, and Upload buttons for the key files + */ +const renderKeyButtons = function (label, filename, instance_id, displayId, default_key, role, appData, last_button) { + const randId = 'file_' + filename.replace(/\./g, '_'); + const fileInput = E('input', { 'type': 'file', 'id': randId, 'style': 'display:none;' }); + + const showBtn = E('button', { + 'class': 'cbi-button cbi-button-apply important', + 'style': 'margin: 0 0 0 4px; text-shadow:none !important; box-shadow:none !important;' + }, TXT.BTN.show); + + const downloadBtn = E('button', { + 'class': 'cbi-button cbi-button-save', + 'style': 'margin: 0 0 0 4px;' + }, TXT.BTN.download); + + const uploadBtn = E('label', { + 'for': randId, + 'class': 'cbi-button cbi-button-neutral', + 'style': 'margin: 0 0 0 4px;' + }, TXT.BTN.upload); + + const statusMsg = E('span', { 'style': 'font-weight:bold; margin-left:10px; font-size:11px;' }, ''); + + // upload key file + fileInput.addEventListener('change', function (ev) { + const files = ev.target.files; + if (!files || files.length === 0) return; + + uploadBtn.classList.add('disabled'); + statusMsg.textContent = TXT.BTN.saving; + statusMsg.className = 'text-warning'; + const realPath = CFG.FILE.dir_keys + filename; + const tmpFilename = filename + '.tmp'; + const tmpPath = CFG.FILE.dir_keys + tmpFilename; + + const reader = new FileReader(); + reader.onload = async function (e) { + try { + // Standardize line endings instantly (Supports UNIX \n, Windows \r\n, and Mac \r) + const sanitizedResult = sanitizeInputText(e.target.result); + + // Step 1: Upload to temporary file + await rpcWriteFile(tmpPath, sanitizedResult); + + // Step 2: Validate cryptographic metadata + const rawMeta = await luci_app_openvpn_plus([CFG.LIBEXEC.keymeta, tmpFilename]); + + // Check for syntax errors or corrupted format + if (rawMeta.length === 0 || rawMeta.indexOf('ERROR') !== -1) { + await rpcRemoveFile(tmpPath); + statusMsg.className = 'text-danger'; + statusMsg.textContent = ICON.ERROR + ' ' + TXT.MSG.uploaded_file_invalid; + uploadBtn.classList.remove('disabled'); + throw new Error(TXT.KEY.key_verification_failed); + } + + let typeMismatch = false; + + if (default_key === CFG.FILE.ca_def_crt || default_key === CFG.FILE.server_def_crt || default_key === CFG.FILE.client_def_crt) { + if (rawMeta.indexOf('Public-Key') === -1) typeMismatch = true; + } else if (default_key === CFG.FILE.server_def_key || default_key === CFG.FILE.client_def_key) { + if (rawMeta.indexOf('Private-Key') === -1) typeMismatch = true; + } else if (default_key === CFG.FILE.dh_def_pem) { + if (rawMeta.indexOf('DH Parameters') === -1) typeMismatch = true; + } else if (default_key === CFG.FILE.server_def_tls2) { + // ToDo check LIBEXEC.keymeta + if (rawMeta.indexOf('TLSv-Server-Key') === -1) typeMismatch = true; + } else if (default_key === CFG.FILE.client_def_tls2) { + // ToDo check LIBEXEC.keymeta + if (rawMeta.indexOf('TLSv2-Client-Key') === -1) typeMismatch = true; + } + + // Step 3: Show warning modal if key type mismatch occurs + if (typeMismatch) { + await new Promise(function (resolve, reject) { + L.showModal(ICON.WARNING + TXT.WARNING.key_upload_title, E('div', { 'class': 'cbi-modal' }, [ + E('p', { 'style': 'margin-bottom: 12px; font-size: 13px; line-height: 1.4;' }, + TXT.WARNING.key_upload_nomatch + ), + E('p', { 'style': 'margin-bottom: 16px; font-size: 13px; color:color-mix(in srgb, var(--text-color-medium, #71717a) 85%, #0055ff); line-height: 1.4;' }, + TXT.WARNING.key_upload_processing + ), + E('p', { + 'style': 'font-weight: bold; color: var(--error-color-high, #f62b12); margin-bottom: 20px; font-size: 13px;' + }, + TXT.WARNING.key_upload_save_anyway + ), + + E('div', { 'class': 'right' }, [ + E('button', { + 'class': 'btn cbi-button-action important', + 'style': 'margin-right: 10px;', + 'click': async function () { + L.hideModal(); + await rpcRemoveFile(tmpPath); + statusMsg.className = 'text-danger'; + statusMsg.textContent = ICON.ERROR + ' ' + TXT.ERROR.wrong_key_type; + uploadBtn.classList.remove('disabled'); + reject(new Error(TXT.ERROR.key_type_mismatch)); + } + }, TXT.INFO.no), + E('button', { + 'class': 'btn cbi-button-neutral', + 'click': function () { + L.hideModal(); + resolve(); + } + }, TXT.INFO.yes) + ]) + ])); + }); + } + + // Step 4: Move validated temporary file to real path + await rpcWriteFile(realPath, sanitizedResult); + await rpcRemoveFile(tmpPath); + + statusMsg.className = 'text-success'; + statusMsg.textContent = ICON.SUCCESS + TXT.BTN.saved + ' ' + ICON.WARNING + TXT.BTN.click_save_apply; + + uciSaveApplyRestart(instance_id); + + } catch (err) { + if (err.message !== TXT.KEY.key_verification_failed && err.message !== TXT.ERROR.key_type_mismatch) { + statusMsg.className = 'text-danger'; + statusMsg.textContent = ICON.ERROR + ' ' + TXT.INFO.error + ': ' + err.message; + } + } finally { + uploadBtn.classList.remove('disabled'); + } + }; + + reader.readAsText(files[0]); + }); + + // show key file + showBtn.addEventListener('click', function (ev) { + ev.preventDefault(); + appData.keygenClass.openKeyEditorModal(filename, instance_id, displayId, role, appData, uciSaveApplyRestart, rpcFileCallbacks); + }); + + // download key file + downloadBtn.addEventListener('click', async function (ev) { + ev.preventDefault(); + + // 1. Fetch the file content natively via ubus RPC + const content = await rpcReadFile(CFG.FILE.dir_keys + filename); + + // 2. If the file is missing or empty (returns null or empty string) + if (!content) { + if (L.ui && typeof L.ui.addNotification === 'function') { + L.ui.addNotification(null, E('p', TXT.KEY.keyfile_not_exist), 'warning'); + } + return; + } + + // 3. Create a secure browser download blob from the string data + const blob = new Blob([content], { type: 'text/plain' }); + const link = document.createElement('a'); + + link.href = URL.createObjectURL(blob); + link.download = filename; + + // 4. Trigger the hidden download mechanism in the browser DOM + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }); + + let borderStyle; + if (last_button) { + borderStyle = "border:0;" + } else { + borderStyle = "border-bottom:1px dashed var(--border-color-medium, #cbd5e1);" + } + + return E('div', { + 'style': 'display:flex; align-items:center; justify-content:space-between; padding:3px 0; margin:0; ' + borderStyle + ' min-height:0; width:100%;' + }, [ + E('span', { 'style': 'font-size:13px; font-weight:normal; text-align:left; margin:0; padding:0;' }, label), + E('div', { 'style': 'display:inline-flex; align-items:center; margin:0; padding:0;' }, [ + showBtn, downloadBtn, uploadBtn, fileInput, statusMsg + ]) + ]); +}; + +/** + * Update port and proto in the smart firewall rules file 'instance*.nft' + */ +const updateSmartFirewallFile = async function (instance_id, port, proto) { + const nftFilePath = CFG.FILE.dir_cfg + instance_id + '.nft'; + const nftFileRaw = await rpcReadFile(nftFilePath); + let nftFile = String(nftFileRaw || '').trim(); + if (nftFile.length > 0) { + nftFile = nftFile.replace(new RegExp(CFG.ID.instance + '0', 'g'), instance_id); + nftFile = nftFile.replace(new RegExp(CFG.ID.fwPort + '\\s*=\\s*\\d+', 'g'), CFG.ID.fwPort + '=' + port); + nftFile = nftFile.replace(new RegExp(CFG.ID.fwProto + '\\s*=\\s*"[^"]*"', 'g'), CFG.ID.fwProto + '="' + proto + '"'); + } + await rpcWriteFile(nftFilePath, nftFile); +} + +/** + * Saves the modified configuration text and handles the instance restart logic + */ +const handleInstanceSave = async function (instance_id, instObj, role, txtArea, saveBtn, sNotice, originalConfContent, modificationBoxNode, appData, clientUpdateOnly) { + // Clean and standardize all line endings instantly (Supports UNIX \n, Windows \r\n, and Mac \r) + const newConfigContent = sanitizeInputText(txtArea.value) + '\n'; + const cleanOriginal = String(originalConfContent || '').trim() + '\n'; + const originalButtonText = saveBtn.textContent; + + // Abort if no changes occurred inside the text area field + if (newConfigContent === cleanOriginal) { + saveBtn.disabled = true; + saveBtn.textContent = ICON.INFO + TXT.INFO.no_changes_detected; + + setTimeout(function () { + saveBtn.disabled = false; + saveBtn.textContent = originalButtonText; + }, 1500); + return; + } + + saveBtn.disabled = true; + saveBtn.textContent = ICON.LOADING + TXT.INFO.creating; + + let serverSiteToSite = false; + let smartFirewallEnabled = false; + + let currentPort = appData.statusClass.parsePortFromConfig(role, originalConfContent); + if (!currentPort || isNaN(currentPort)) { + currentPort = appData.statusClass.calcPortFromId(instance_id); + } + const newPort = appData.statusClass.parsePortFromConfig(role, newConfigContent); + let newProto = appData.statusClass.parseProtoFromConfig(newConfigContent); + + const detectedProto = appData.statusClass.parseProtoFromConfig(newConfigContent); + + if (role === OPENVPN.ROLE.SERVER) { + + // check if 'setenv sitetosite on' + serverSiteToSite = appData.statusClass.parseSiteToSite(newConfigContent); + + // check if 'setenv ovpnfirewall on' + smartFirewallEnabled = appData.statusClass.parseSiteToSite(newConfigContent); + } + + const isCurrentlyEnabled = isInstanceEnabled(instance_id); + if (isCurrentlyEnabled) { + if (sNotice) { + sNotice.style.display = 'inline-block'; + } + } + + try { + + + // Write the finalized configuration file directly to the disk memory + await rpcWriteFile(CFG.FILE.dir_cfg + instance_id + '.conf', newConfigContent); + + // Update the local instance port property inside the RAM cache instantly + if (Array.isArray(appData.instances)) { + for (const instance of appData.instances) { + if (instance && instance.id === instance_id) { + instance.confContent = newConfigContent; + instance.port = newPort || currentPort; + break; + } + } + } + + if (role === OPENVPN.ROLE.SERVER) { + await updateSmartFirewallFile(instance_id, newPort, newProto); + } + await syncInstanceFirewallRule(instance_id, instObj, role, newPort, detectedProto, serverSiteToSite, smartFirewallEnabled, appData, newConfigContent); + + // Trigger changes system banner and restart workflow if needed + if (isCurrentlyEnabled) { + uciSaveApplyRestart(instance_id); + } else { + reloadFirewall.checkRequest(); + if (modificationBoxNode && typeof modificationBoxNode.setAttribute === 'function') { + modificationBoxNode.setAttribute('data-original-content', newConfigContent); + } + } + + // Compilation finished successfully + saveBtn.textContent = ICON.SUCCESS + TXT.BTN.saved; + + } catch (err) { + // Universal catch block intercepts all disk or firewall RPC failures + console.error('Failed to write OpenVPN configuration for ' + instance_id + ':', err); + saveBtn.textContent = ICON.ERROR + TXT.INFO.error; + + } finally { + setTimeout(function () { + saveBtn.disabled = false; + saveBtn.textContent = originalButtonText; + if (sNotice) { + sNotice.style.display = 'none'; + } + }, 1500); + } +}; + +/** + * Deletes an OpenVPN instance and its firewall rules + */ +const handleInstanceDeletion = function (instance_id, displayId, delBtn, dNotice, sectionRootNode) { + if (window.confirm(TXT.MSG.confirm_del + displayId + '?')) { + // Lock the delete button and show the loading notice immediately + delBtn.disabled = true; + dNotice.style.display = 'inline-block'; + + L.uci.remove(CFG.CMD.openvpn, instance_id); + removeInstanceFirewallRule(instance_id); + + uciSaveApply( + null, + function () { + // saved + if (sectionRootNode) { + sectionRootNode.style.opacity = '0.4'; + sectionRootNode.style.pointerEvents = 'none'; + } + delBtn.textContent = ICON.SUCCESS + TXT.BTN.del_ready; + }, + function () { + // finally + dNotice.style.display = 'none'; + delBtn.disabled = false; + }, + function (err) { + console.error('Failed to delete OpenVPN instance ' + instance_id + ':', err); + } + ); + } +}; + + +/** + * Shows the collapsible box containing all cryptographic key files, keeping control buttons always visible. + */ +const renderKeysBox = function (instance_id, displayId, role, ovpnProfileBtn, keygenBtn, appData) { + const isServer = (role === OPENVPN.ROLE.SERVER); + const certLabel = isServer ? TXT.KEY.server_crt : TXT.KEY.client_crt; + const keyLabel = isServer ? TXT.KEY.server_key : TXT.KEY.client_key; + const def_crt = isServer ? CFG.FILE.server_def_crt : CFG.FILE.client_def_crt; + const def_key = isServer ? CFG.FILE.server_def_key : CFG.FILE.client_def_key; + const tlsLabel = isServer ? TXT.KEY.server_tls : TXT.KEY.client_tls; + const def_tls = isServer ? CFG.FILE.server_def_tls2 : CFG.FILE.client_def_tls2; + const rolePrefix = role + '_'; + + // Create the detailed content wrapper for the key lines (Hidden by default) + const keysContentContainer = E('div', { + 'style': 'margin-bottom:10px; display:none;' + }, [ + renderKeyButtons(ICON.POINT + TXT.KEY.ca + ' (ca_' + instance_id + '.crt)', 'ca_' + instance_id + '.crt', instance_id, displayId, CFG.FILE.ca_def_crt, role, appData, false), + renderKeyButtons(ICON.POINT + certLabel + ' (' + rolePrefix + instance_id + '.crt)', rolePrefix + instance_id + '.crt', instance_id, displayId, def_crt, role, appData, false), + renderKeyButtons(ICON.POINT + keyLabel + ' (' + rolePrefix + instance_id + '.key)', rolePrefix + instance_id + '.key', instance_id, displayId, def_key, role, appData, false), + (role === OPENVPN.ROLE.SERVER) ? renderKeyButtons(ICON.POINT + TXT.KEY.dh + ' (dh_' + instance_id + '.pem)', 'dh_' + instance_id + '.pem', instance_id, displayId, CFG.FILE.dh_def_pem, role, appData, false) : '', + renderKeyButtons(ICON.POINT + tlsLabel + ' (' + rolePrefix + instance_id + '.tls2.key)', rolePrefix + instance_id + '.tls2.key', instance_id, displayId, def_tls, role, appData, true), + ]); + + // Generate the compact inline summary string for the collapsed state + let summaryTextString = ICON.POINT + TXT.KEY.ca + ' (ca_' + instance_id + '.crt) | ' + + ICON.POINT + certLabel + ' (' + rolePrefix + instance_id + '.crt) | ' + + ICON.POINT + keyLabel + ' (' + rolePrefix + instance_id + '.key) | '; + + if (role === OPENVPN.ROLE.SERVER) { + summaryTextString += ICON.POINT + TXT.KEY.dh + ' (dh_' + instance_id + '.pem) | '; + } + summaryTextString += ICON.POINT + tlsLabel + ' (' + rolePrefix + instance_id + '.tls2.key)'; + + // Create the element node for the inline text (Visible by default) + const inlineSummaryLine = E('div', { + 'style': 'width:100%; display:block; font-size:11px; font-family:var(--font-monospace, monospace); color:color-mix(in srgb, var(--text-color-medium, #71717a) 85%, #0055ff); padding:4px 5px; margin-bottom:8px; white-space:normal; word-break:break-all;' + }, summaryTextString); + + // Create the arrow to show the toggle state + const toggleArrow = E('span', { + 'style': 'margin-right:8px; font-size:11px; cursor:pointer; user-select:none;' + }, '▶ '); + + // Assemble the interactive clickable title bar header + const clickableTitle = E('legend', { + 'style': 'font-weight:bold; font-size:13px; padding:0 8px; cursor:pointer; user-select:none;', + 'click': function () { + const isHidden = (keysContentContainer.style.display === 'none'); + + if (isHidden) { + keysContentContainer.style.display = 'block'; + keygenBtn.style.display = 'block'; + inlineSummaryLine.style.display = 'none'; // Hide text row when box expands + toggleArrow.textContent = '▼ '; + } else { + keysContentContainer.style.display = 'none'; + keygenBtn.style.display = 'none'; + inlineSummaryLine.style.display = 'block'; // Show text row when box collapses + toggleArrow.textContent = '▶ '; + } + } + }, [ + toggleArrow, + TXT.KEY.openvpn_keys + ]); + + // Return the finalized component + return E('fieldset', { + 'class': 'cbi-section-fieldset', + 'style': 'margin-bottom:20px; padding:15px; border:1px solid var(--border-color-medium, #cbd5e1); border-radius:4px; background:transparent;' + }, [ + clickableTitle, + E('div', { 'class': 'cbi-section-node', 'style': 'padding:0 5px;' }, [ + // Structural layout sorting slots + keysContentContainer, + inlineSummaryLine, + + // The action buttons container remains outside the collapsible block (always visible) + E('div', { 'style': 'width:100%; display:block; overflow:hidden; border-top:1px dashed var(--border-color-medium, #cbd5e1); padding-top:5px; margin-top:0px;' }, [ + ovpnProfileBtn, + ((role === OPENVPN.ROLE.SERVER) ? keygenBtn : '') + ]) + ]) + ]); +}; + +/** + * Shows the collapsible fieldset text box for editing the configuration file + */ +const renderConfigEditor = function (instance_id, txtArea, saveBtn) { + + // Create the content wrapper that will be hidden or shown + const contentContainer = E('div', { + 'style': 'padding:0 2px; display:none;' + }, [txtArea]); + const configPathLine = E('div', { + 'style': 'width:100%; display:block; font-size:11px; font-family:var(--font-monospace, monospace); color:color-mix(in srgb, var(--text-color-medium, #71717a) 85%, #0055ff); padding:4px 2px; margin-bottom:10px; white-space:normal; word-break:break-all; font-style:italic;' + }, ICON.POINT + CFG.FILE.dir_cfg + instance_id + '.conf'); + + // Createthe arrow to show the state + const toggleArrow = E('span', { + 'style': 'margin-right:8px; font-size:11px; cursor:pointer; user-select:none; transition:transform 0.2s;' + }, '▶ '); + + // Assemble the interactive clickable title bar header + const clickableTitle = E('legend', { + 'style': 'font-weight:bold; font-size:13px; padding:0 8px; cursor:pointer; user-select:none;', + 'click': function () { + const isHidden = (contentContainer.style.display === 'none'); + + if (isHidden) { + contentContainer.style.display = 'block'; + toggleArrow.textContent = '▼ '; + saveBtn.style.display = 'block' + } else { + contentContainer.style.display = 'none'; + toggleArrow.textContent = '▶ '; + saveBtn.style.display = 'none' + } + } + }, [ + toggleArrow, + TXT.MSG.edit_config + ]); + + // Return the finalized component + return E('fieldset', { + 'class': 'cbi-section-fieldset', + 'style': 'margin-bottom:20px; padding:15px; border:1px solid var(--border-color-medium, #cbd5e1); border-radius:4px; background:transparent;' + }, [ + clickableTitle, + E('div', { 'class': 'cbi-section-node', 'style': 'padding:0 5px;' }, [ + configPathLine, + contentContainer + ]) + ]); +}; + +/** + * Shows the collapsible fieldset for port and static routing info + */ +const renderPortAlertBox = function (instObj, role, appData) { + + // Init the alert component + const portForwardingAlert = appData.wizardClass.renderPortForwardingAlert(networkCallbacks); + + // Create the content wrapper that will be hidden or shown + const contentContainer = E('div', { + 'style': 'padding:0 2px; display:none;' + }, [portForwardingAlert.node]); + + const alertPathLine = E('div', { + 'style': 'width:100%; display:block; font-size:11px; font-family:var(--font-monospace, monospace); color:color-mix(in srgb, var(--text-color-medium, #71717a) 85%, #0055ff); padding:4px 2px; margin-bottom:10px; white-space:normal; word-break:break-all; font-style:normal;' + }, '. . .'); + + // Create the arrow to show the state + const toggleArrow = E('span', { + 'style': 'margin-right:8px; font-size:11px; cursor:pointer; user-select:none; transition:transform 0.2s;' + }, '▶ '); + const toggleText = E('span', {}, 'Port Info'); + + const showAlertContainer = function (show) { + if (show === true) { + contentContainer.style.display = 'block'; + alertPathLine.style.display = 'none'; + toggleArrow.textContent = '▼ '; + } else { + contentContainer.style.display = 'none'; + alertPathLine.style.display = 'block'; + toggleArrow.textContent = '▶ '; + } + } + + // Assemble the interactive clickable title bar header + const clickableTitle = E('legend', { + 'style': 'font-weight:bold; font-size:13px; padding:0 8px; cursor:pointer; user-select:none;', + 'click': function () { + const isHidden = (contentContainer.style.display === 'none'); + showAlertContainer(isHidden); + } + }, [ + toggleArrow, + toggleText + ]); + + const alertBox_displayStyle = networkStructure.doubleNat ? 'display:block;' : 'display: none;'; + + const alertBox = E('fieldset', { + 'class': 'cbi-section-fieldset', + 'style': 'margin-bottom:20px; padding:15px; border:1px solid var(--border-color-medium, #cbd5e1); border-radius:4px; background:transparent;' + alertBox_displayStyle + }, [ + clickableTitle, + E('div', { 'class': 'cbi-section-node', 'style': 'padding:0 5px;' }, [ + alertPathLine, + contentContainer + ]) + ]); + + const showAlertBoxCallback = function (show, alertData) { + + let enabled = alertData.isPortValid || alertData.sRouteValid || alertData.portStatusText !== '' || alertData.sRouteStatusText !== '' + + if (enabled === true) { + // get Title + let portTitle = ''; + let srouteTitle = ''; + let title = ''; + if (role === OPENVPN.ROLE.SERVER) { + portTitle = 'Port Info'; + } + if ((instObj.serverSiteToSite == true) || (role === OPENVPN.ROLE.CLIENT)) { + srouteTitle = 'Static Routing'; + } + if ((portTitle.length > 0) && (srouteTitle.length > 0)) { + title = portTitle + " / " + srouteTitle; + } else { + title = portTitle + srouteTitle; + } + toggleText.textContent = title; + + + let nodes = []; + if (alertData.portStatusText.length > 0) { + nodes.push(E('span', { 'style': 'filter: grayscale(100%); opacity: 0.8; display: inline-block;' }, alertData.portStatusIcon)); + nodes.push(' ' + alertData.portStatusText); + } + if (alertData.sRouteStatusText.length > 0) { + if (nodes.length > 0) { + nodes.push(' | '); + } + nodes.push(E('span', { 'style': 'filter: grayscale(100%); opacity: 0.8; display: inline-block;' }, alertData.sRouteStatusIcon)); + nodes.push(' ' + alertData.sRouteStatusText); + } + L.dom.content(alertPathLine, nodes); + + showAlertContainer(show); + + alertBox.style.display = 'block'; + } else { + alertBox.style.display = 'none'; + } + } + + // Setup the alert component + const setupPortForwardingAlert = async function () { + // Trigger the port warning on first refresh + let runBackgroundPortCheckExtern = {}; + runBackgroundPortCheckExtern.callback = showAlertBoxCallback; + initialRefreshQueue.push(runBackgroundPortCheckExtern); + await portForwardingAlert.check( + instObj.proto || OPENVPN.PROTO.UDP, + role, + instObj.port || OPENVPN.PORT.s1194, + instObj.portExtern || OPENVPN.PORT.s1194, + instObj.remoteSubnets, + appData, + false, + runBackgroundPortCheckExtern + ); + } + setupPortForwardingAlert(); + + return alertBox; +}; + +/** + * Renders the configuration box for a single OpenVPN instance + */ +const renderInstanceBox = function (s, idx, appData) { + const instance_id = s['.name']; + let instObj = {}; + if (Array.isArray(appData.instances)) { + for (let i = 0; i < appData.instances.length; i++) { + if (appData.instances[i].id === instance_id) { + instObj = appData.instances[i]; + break; + } + } + } + const role = instObj.role || OPENVPN.ROLE.SERVER; + const confContent = instObj.confContent || ''; + const roleLabel = role.charAt(0).toUpperCase() + role.slice(1); + const instNum = appData.statusClass.getInstanceNumber(instance_id, idx + 1); + const customDisplayName = L.uci.get(CFG.CMD.openvpn, instance_id, 'displayname') || ''; + const displayId = customDisplayName || (TXT.INFO.instance_x + instNum); + instObj.displayName = displayId; + + const sectionHeadingText = customDisplayName + ? customDisplayName + ' (' + roleLabel + ')' + : displayId + ' - ' + roleLabel; + + // Create the name text input field + const nameInput = E('input', { + 'type': 'text', + 'class': 'cbi-input-text', + 'placeholder': TXT.INFO.instance_x + instNum, + 'value': customDisplayName, + 'style': 'width: 140px; margin-right: 15px; padding: 2px 6px; font-size: 12px; border-radius: 3px; border: 1px solid var(--border-color-medium, #cbd5e1);' + }); + + // Updates the instance display name when the user changes the input field value + nameInput.addEventListener('change', function (ev) { + const cleanName = sanitizeInputLine(ev.target.value); + L.uci.set(CFG.CMD.openvpn, instance_id, 'displayname', cleanName ? cleanName : null); + instObj.displayName = cleanName || (TXT.INFO.instance_x + instNum); + uciSaveApply(); + }); + + // Create the activation checkbox flag + const isEnabled = isInstanceEnabled(instance_id); + const instanceCheckbox = E('input', { + 'type': 'checkbox', + 'id': 'cb_enabled_' + instance_id, + 'style': 'margin-right: 6px; cursor: pointer; width: 16px; height: 16px; vertical-align: middle;', + 'checked': isEnabled ? 'checked' : null + }); + + // Safely updates the instance enable state and registers service background controls on checkbox change + instanceCheckbox.addEventListener('change', function (ev) { + L.uci.set(CFG.CMD.openvpn, instance_id, 'enabled', ev.target.checked ? '1' : '0'); + uciSaveApply(function () { + // init + ovpnServiceStartStop.Request = true; + ovpnServiceStartStop.Instance = instance_id; + ovpnServiceStartStop.Action = ev.target.checked ? CFG.CMD.start : CFG.CMD.stop; + }); + }); + + const checkboxContainer = E('label', { + 'for': 'cb_enabled_' + instance_id, + 'style': 'display: inline-flex; align-items: center; cursor: pointer; font-size: 13px; font-weight: bold;' + }, [instanceCheckbox, E('span', {}, TXT.BTN.enabled)]); + + // Create the key generator button + const keygenBtn = E('button', { + 'class': 'cbi-button cbi-button-apply important', + 'style': 'float: right; margin: 10px 10px 0 0; display: none' + }, TXT.KEY.keygen); + + keygenBtn.addEventListener('click', function (ev) { + ev.preventDefault(); + appData.keygenClass.openKeyGenModal(instance_id, instObj.displayName, role, appData, uciSaveApplyRestart, rpcFileCallbacks); + }); + + // Create the main configuration text box field + const txtArea = E('textarea', { + 'class': 'cbi-input-textarea', + 'style': 'width:100%; font-family:var(--font-monospace, monospace); font-size:12px; padding:12px; border-radius:4px; background:var(--background-color-low); border:1px solid var(--border-color-medium, #cbd5e1); text-shadow:none !important;', + 'rows': '15', + 'wrap': 'off' + }, confContent); + + const saveBtn = E('button', { 'class': 'btn cbi-button cbi-button-save; display:none;' }, TXT.BTN.save_config + ': ' + displayId); + const cNotice = E('span', { 'class': 'text-danger', 'style': 'font-weight:bold; margin-left:15px; display:none;' }, ICON.WARNING + ' ' + TXT.BTN.click_save_apply); + const dNotice = E('span', { 'class': 'text-danger', 'style': 'font-weight:bold; font-size:12px; display:none;' }, ICON.WARNING + ' ' + TXT.BTN.click_save_apply); + const delBtn = E('button', { 'class': 'btn cbi-button cbi-button-remove', 'style': 'float:right;' }, TXT.BTN.del_instance); + + let ovpnProfileBtn = ''; + if (role === OPENVPN.ROLE.SERVER) { + ovpnProfileBtn = E('button', { + 'class': 'btn cbi-button cbi-button-positive important', + 'style': 'float: right; margin: 10px 10px 0 0;' + }, TXT.MSG.export_ovpn); + ovpnProfileBtn.addEventListener('click', function (ev) { + ev.preventDefault(); + downloadClientOvpnProfile(instance_id, instObj, customDisplayName, appData); + }); + } else { + ovpnProfileBtn = E('button', { + 'class': 'btn cbi-button cbi-button-positive important', + 'style': 'float: right; margin: 10px 10px 0 0;' + }, TXT.MSG.import_ovpn); + ovpnProfileBtn.addEventListener('click', function (ev) { + ev.preventDefault(); + const wizardData = Object.assign({}, appData.wizardClass.WIZARD_DATA_TEMPLATE, { + appData: appData, + addNewInstanceCallback: addNewInstance, + networkCallbacks: networkCallbacks, + uciSaveApplyRestartCallback: uciSaveApplyRestart, + importOvpnClientProfileCallback: importOvpnClientProfile, + instanceNumber: instNum, + forcedScenario: OPENVPN.SCENARIO.SITE_TO_SITE_CLIENT + }); + appData.wizardClass.openWizardModal(wizardData); + }); + } + + // Build the main frame section layout + const sectionRootNode = E('div', { + 'class': 'cbi-section', + 'id': 'modification_section_' + instance_id, + 'style': 'margin:40px 0; border:1px solid var(--border-color-medium, #cbd5e1); border-radius:6px; background:transparent; overflow:hidden; position:relative;' + }, [ + E('div', { + 'style': 'display:flex; align-items:center; justify-content:space-between; background:var(--background-color-medium, #f4f4f5); border-bottom:1px solid var(--border-color-medium, #cbd5e1); padding:10px 20px; margin:0;' + }, [ + E('h3', { 'style': 'margin: 0; font-weight: bold; font-size: 16px; border: none; padding: 0;' }, sectionHeadingText), + E('div', { 'style': 'display: inline-flex; align-items: center;' }, [nameInput, checkboxContainer]) + ]), + + // BODY WRAPPER: Implements a clean unified padding area exclusively for the lower configuration elements + E('div', { 'style': 'padding: 20px;' }, [ + + renderPortAlertBox(instObj, role, appData), + + renderKeysBox(instance_id, displayId, role, ovpnProfileBtn, keygenBtn, appData), + + renderConfigEditor(instance_id, txtArea, saveBtn), + + E('div', { 'style': 'width:100%; display:block; margin-top:20px; overflow:hidden;' }, [saveBtn, cNotice, dNotice, delBtn]) + ]) + ]); + + sectionRootNode.setAttribute('data-original-content', confContent); + + saveBtn.addEventListener('click', function (ev) { + ev.preventDefault(); + const freshOriginalText = sectionRootNode.getAttribute('data-original-content') || ''; + handleInstanceSave(instance_id, instObj, role, txtArea, saveBtn, cNotice, freshOriginalText, sectionRootNode, appData); + }); + saveBtn.style.display = 'none'; + + delBtn.addEventListener('click', function (ev) { + ev.preventDefault(); + handleInstanceDeletion(instance_id, displayId, delBtn, dNotice, sectionRootNode); + }); + + return sectionRootNode; +}; + +/** + * Subroutine of addNewInstance to create a new OpenVPN instance and its firewall rules safely. + */ +const createInstance = async function (newInstanceItem, appData, wizardParams) { + + // Use await to generate all initial configuration and key assets lineary + await syncInstanceFiles(newInstanceItem, appData, wizardParams); + + // Add the new section row identity block into the local UCI cache matrix + L.uci.add(CFG.CMD.openvpn, CFG.CMD.openvpn, newInstanceItem.id); + + let targetEnabledState = '0'; + if (wizardParams) { + targetEnabledState = '1'; + } else if (appData.ovpnUciSections) { + if (appData.ovpnUciSections.length === 0) { + targetEnabledState = '1'; + } else { + if (isAnyInstanceEnabled(appData.ovpnUciSections)) { + targetEnabledState = '1'; + } + } + } + + L.uci.set(CFG.CMD.openvpn, newInstanceItem.id, 'enabled', targetEnabledState); + L.uci.set(CFG.CMD.openvpn, newInstanceItem.id, 'role', newInstanceItem.role); + L.uci.set(CFG.CMD.openvpn, newInstanceItem.id, 'config', CFG.FILE.dir_cfg + newInstanceItem.id + '.conf'); + + if ((wizardParams) && (wizardParams.displayName)) { + L.uci.set(CFG.CMD.openvpn, newInstanceItem.id, 'displayname', wizardParams.displayName); + } + + // Push the item into appData.instances right before saving to keep browser RAM synchronous + if (Array.isArray(appData.instances)) { + appData.instances.push(newInstanceItem); + } + let serverSiteToSite = false; + let smartFirewallEnabled = false; + if (newInstanceItem.role === OPENVPN.ROLE.SERVER) { + // check if 'setenv sitetosite on' + serverSiteToSite = appData.statusClass.parseSiteToSite(newInstanceItem.confContent); + + // check if 'setenv ovpnfirewall on' + smartFirewallEnabled = appData.statusClass.parseSmartFirewall(newInstanceItem.confContent); + await updateSmartFirewallFile(newInstanceItem.id, newInstanceItem.port, newInstanceItem.proto); + + } + + // create firewall rules of new isntance + await syncInstanceFirewallRule(newInstanceItem.id, newInstanceItem, newInstanceItem.role, newInstanceItem.port, newInstanceItem.proto, serverSiteToSite, smartFirewallEnabled, appData, newInstanceItem.confContent); + + // if necessary sort uci openvpn instance enties (e.g. openvpn.instance2 openvpn.instance1 -> openvpn.instance1 openvpn.instance2) + ovpnServiceStartStop.uciSort = true; + + L.uci.save(); +} + +/** + * Core routine to create a new OpenVPN instance and its firewall rules safely. + */ +const addNewInstance = async function (roleType, appData, wizardParams, optionalShowBtnCancel) { + + const nextNum = getNextInstanceNumber(appData); + const nextId = CFG.ID.instance + nextNum; + const devName = appData.statusClass.getDevName(nextId, nextNum, true); + + const newInstanceItem = Object.assign({}, appData.statusClass.INSTANCE_TEMPLATE, { + id: nextId, + instNum: nextNum, + devName: devName, + role: roleType, + connectedClients: [] + }); + + // Trigger the automated key allocation modal for ALL server installations + if ((newInstanceItem.role === OPENVPN.ROLE.SERVER) || ((wizardParams) && (wizardParams.role === OPENVPN.ROLE.SERVER))) { + + const callbacks = ({ + openWizardBtnClick: openWizardBtnClick, + rpcFileCallbacks: rpcFileCallbacks, + uciSaveApplyRestart: uciSaveApplyRestart, + createInstance: createInstance, + }); + + appData.keygenClass.openAutomatedPostKeyGenModal(newInstanceItem, appData, wizardParams, callbacks, optionalShowBtnCancel); + + } else { + await createInstance(newInstanceItem, appData, wizardParams); + uciSaveApplyRestart(newInstanceItem.id); + } + + return newInstanceItem.id; +}; + +/** + * Opens a window to import a client profile or use default files + */ +const openManualClientImportModal = function (appData) { + + const infoText = E('div', { 'class': 'cbi-section-descr', 'style': 'margin-bottom:15px; line-height:1.5;' }, + TXT.KEY.client_connection_needs_server_key_and_config + ); + + const importBtn = E('button', { + 'class': 'cbi-button cbi-button-action important', + 'style': 'margin-right:10px;' + }, TXT.MSG.import_profile); + + const fallbackBtn = E('button', { + 'class': 'cbi-button cbi-button-neutral', + 'style': 'margin-right:10px;' + }, TXT.BTN.use_fallback); + + const cancelBtn = E('button', { + 'class': 'cbi-button cbi-button-neutral' + }, TXT.BTN.cancel); + + const nextNum = getNextInstanceNumber(appData); + + // Reverts to the default standard loopback configuration + const loadDefaultFallbackKeys = function () { + L.ui.hideModal(); + addNewInstance(OPENVPN.ROLE.CLIENT, appData, null); + }; + + // Pure cancel event: just closes the modal layout without creating any instance + const handlePureCancel = function () { + L.ui.hideModal(); + }; + + // Attach the asynchronous click handler to manage file processing safely + importBtn.addEventListener('click', function () { + const wizardData = Object.assign({}, appData.wizardClass.WIZARD_DATA_TEMPLATE, { + appData: appData, + addNewInstanceCallback: addNewInstance, + networkCallbacks: networkCallbacks, + uciSaveApplyRestartCallback: uciSaveApplyRestart, + importOvpnClientProfileCallback: importOvpnClientProfile, + instanceNumber: nextNum, + forcedScenario: OPENVPN.SCENARIO.SITE_TO_SITE_CLIENT + }); + appData.wizardClass.openWizardModal(wizardData, false); + }); + + // Connect the buttons to their independent functions + fallbackBtn.addEventListener('click', loadDefaultFallbackKeys); + cancelBtn.addEventListener('click', handlePureCancel); + + // Render the complete client import window layout + L.ui.showModal(ICON.IMPORT + TXT.MSG.import_openvpn_connect_client_profile, [ + E('div', { 'class': 'cbi-map' }, [ + E('div', { 'class': 'cbi-section' }, [ + infoText, + E('div', { 'style': 'text-align:right; margin-top:20px; border-top:1px solid var(--border-color-medium, #cbd5e1); padding-top:12px;' }, [ + importBtn, + fallbackBtn, + cancelBtn + ]) + ]) + ]) + ]); +}; + +/** + * Renders the creation box containing buttons to add new profiles + */ +const renderInstanceCreationBox = function (appData) { + const addServerBtn = E('button', { 'class': 'btn cbi-button cbi-button-positive important' }, TXT.BTN.add_server); + const addClientBtn = E('button', { 'class': 'btn cbi-button cbi-button-action important' }, TXT.BTN.add_client); + + const addServerNotice = E('span', { 'class': 'text-danger', 'style': 'font-weight:bold; margin-left:10px; display:none;' }, ICON.WARNING + ' ' + TXT.BTN.click_save_apply); + const addClientNotice = E('span', { 'class': 'text-danger', 'style': 'font-weight:bold; margin-left:10px; display:none;' }, ICON.WARNING + ' ' + TXT.BTN.click_save_apply); + + // Start the regular server creation flow when clicking the server button + addServerBtn.addEventListener('click', function (ev) { + ev.preventDefault(); + addNewInstance(OPENVPN.ROLE.SERVER, appData, null, true); + }); + + // Start the import modal flow directly when clicking the client button + addClientBtn.addEventListener('click', function (ev) { + ev.preventDefault(); + openManualClientImportModal(appData); + }); + + return E('div', { 'class': 'cbi-map' }, [ + E('div', { 'class': 'cbi-section' }, [ + E('h3', { 'style': 'font-weight:bold;' }, TXT.INFO.title_instance), + E('div', { 'style': 'margin-top:10px; display:flex; align-items:center; flex-wrap:wrap; gap:10px;' }, [ + E('div', { 'style': 'display:inline-flex; align-items:center;' }, [addServerBtn, addServerNotice]), + E('div', { 'style': 'display:inline-flex; align-items:center;' }, [addClientBtn, addClientNotice]) + ]) + ]) + ]); +}; + + +/** + * --- FIREWALL & LOG VIEW --- + */ + + +/** + * Define reloadFirewall with luci_app_openvpn_plus firewallservice + */ +const reloadFirewall = { + Request: false, + checkRequest: function () { + if (this.Request === true) { + this.Request = false; + // DETACHED PRIVILEGED TRIGGER: Fires parallel to the main LuCI execution thread + setTimeout(async function () { + const res = await luci_app_openvpn_plus([CFG.LIBEXEC.firewallservice, CFG.CMD.restart]); + if (res.length === 0) { + console.error('Asynchronous background firewall service call failed: ' + CFG.LIBEXEC.luci_app_openvpn_plus + ' ' + CFG.LIBEXEC.firewallservice + ' ' + CFG.CMD.restart); + } + }, 0); + } + return; + } +}; + +/** + * Configures firewall zones, forwardings, and static site-to-site forwarding loops without wiping sections + */ +const syncInstanceFirewallRule = async function (instance_id, instObj, role, customPort, customProto, serverSiteToSite, smartFirewallEnabled, appData, conf) { + + // Inbound WAN rule blocks (Server only) + const fwRuleSection = CFG.ID.fw_openvpn_rule + instance_id; + const fwIncludeSection = CFG.ID.fw_openvpn_inc + instance_id; + + // Dedicated block for the custom static site-to-site script injection + const fwS2SIncludeSection = CFG.ID.fw_openvpn_s2s_inc + instance_id; + + // Site-to-site zone and forwarding blocks + const fwZoneSection = CFG.ID.fw_openvpn_zone + instance_id; + const fwForwardLanSection = CFG.ID.fw_openvpn_fwd_lan + instance_id; + const fwForwardVpnSection = CFG.ID.fw_openvpn_fwd_vpn + instance_id; + + // Track if any change was actually made to the configuration + let hasChanges = false; + + // Helper function to safely set UCI values only if they are different + const safeUciSet = function (config, section, option, value) { + const currentValue = L.uci.get(config, section, option); + if (String(currentValue) !== String(value)) { + L.uci.set(config, section, option, value); + hasChanges = true; + } + }; + + // Helper function to safely ensure a section exists with the correct type + const safeUciAdd = function (config, type, section) { + const exists = L.uci.get(config, section); + if (!exists) { + L.uci.add(config, type, section); + hasChanges = true; + } + }; + + // Helper function to safely remove a section only if it exists + const safeUciRemove = function (config, section) { + const exists = L.uci.get(config, section); + if (exists) { + L.uci.remove(config, section); + hasChanges = true; + } + }; + + try { + const deviceName = instObj.devName; + const zoneName = 'vpn_zone_' + instance_id; + + // --- SECTION 1: SERVER SPECIFIC INBOUND WAN ROLES --- + if (role === OPENVPN.ROLE.SERVER) { + let targetPort = customPort; + if (!targetPort || isNaN(targetPort)) { + targetPort = appData.statusClass.calcPortFromId(instance_id); + } + + let targetProto = customProto; + if (!targetProto || (targetProto !== OPENVPN.PROTO.UDP && targetProto !== OPENVPN.PROTO.TCP)) { + targetProto = OPENVPN.PROTO.UDP; + } + + // Ensure the inbound WAN port rule exists and matches target metrics + safeUciAdd(CFG.CMD.firewall, 'rule', fwRuleSection); + safeUciSet(CFG.CMD.firewall, fwRuleSection, 'name', CFG.ID.fw_openvpn_server + instance_id); + safeUciSet(CFG.CMD.firewall, fwRuleSection, 'src', 'wan'); + safeUciSet(CFG.CMD.firewall, fwRuleSection, 'dest_port', String(targetPort)); + safeUciSet(CFG.CMD.firewall, fwRuleSection, 'proto', targetProto); + safeUciSet(CFG.CMD.firewall, fwRuleSection, 'target', 'ACCEPT'); + + // Manage the custom Smart Firewall script include block + if (smartFirewallEnabled === true) { + safeUciAdd(CFG.CMD.firewall, 'include', fwIncludeSection); + safeUciSet(CFG.CMD.firewall, fwIncludeSection, 'type', 'script'); + safeUciSet(CFG.CMD.firewall, fwIncludeSection, 'fw4_compatible', '1'); + safeUciSet(CFG.CMD.firewall, fwIncludeSection, 'enabled', '1'); + safeUciSet(CFG.CMD.firewall, fwIncludeSection, 'path', CFG.FILE.dir_cfg + instance_id + '.nft'); + } else { + safeUciRemove(CFG.CMD.firewall, fwIncludeSection); + } + } else { + // Remove server specific rules if the instance is now a client + safeUciRemove(CFG.CMD.firewall, fwRuleSection); + safeUciRemove(CFG.CMD.firewall, fwIncludeSection); + } + + // --- SECTION 2: STATIC SITE-TO-SITE ROUTING --- + let s2sScript = false; + if (serverSiteToSite === true || role === OPENVPN.ROLE.CLIENT) { + const remoteSubnets = appData.statusClass.parseRemoteSubnets(conf, role, false); + + if (remoteSubnets && Array.isArray(remoteSubnets) && remoteSubnets.length > 0) { + const localLan = await getLanIpMask(true); + const localSubnet = appData.statusClass.netIPCIDR(localLan.ip, localLan.mask); + + let s2sNftContent = '#!/bin/sh\n\n'; + s2sNftContent += '# Automatically generated by luci-app-openvpn-plus - OpenVPN Site-to-Site Firewall Rules\n\n'; + + for (let i = 0; i < remoteSubnets.length; i++) { + const remoteSubnet = remoteSubnets[i]; + if (!remoteSubnet) { + continue; + } + s2sNftContent += 'nft "insert rule inet fw4 forward index 0 iifname br-lan ip daddr ' + remoteSubnet + ' counter accept comment \\"luci-app-openvpn-plus: static s2s outbound forward for ' + instance_id + '\\""\n'; + } + + s2sNftContent += '\n'; + s2sNftContent += 'nft "insert rule inet fw4 forward index 0 iifname ' + deviceName + ' ip daddr ' + localSubnet + ' counter accept comment \\"luci-app-openvpn-plus: static s2s inbound forward for ' + instance_id + '\\""\n'; + s2sNftContent += '\n'; + + const s2sScriptPath = CFG.FILE.dir_cfg + instance_id + '.s2s.nft'; + + // Try to read the old file content to see if the script actually changed + let oldScriptContent = ''; + try { + oldScriptContent = await rpcReadFile(s2sScriptPath); + } catch { + oldScriptContent = ''; + } + + // Only write the file to the flash storage if the content is different + if (String(oldScriptContent).trim() !== s2sNftContent.trim()) { + await rpcWriteFile(s2sScriptPath, s2sNftContent); + hasChanges = true; + } + + // Register the script file inside the firewall configuration tree safely + safeUciAdd(CFG.CMD.firewall, 'include', fwS2SIncludeSection); + safeUciSet(CFG.CMD.firewall, fwS2SIncludeSection, 'type', 'script'); + safeUciSet(CFG.CMD.firewall, fwS2SIncludeSection, 'fw4_compatible', '1'); + safeUciSet(CFG.CMD.firewall, fwS2SIncludeSection, 'enabled', '1'); + safeUciSet(CFG.CMD.firewall, fwS2SIncludeSection, 'path', s2sScriptPath); + s2sScript = true; + } + } + + // Remove the site-to-site block if it was not triggered or has no valid subnets + if (s2sScript === false) { + safeUciRemove(CFG.CMD.firewall, fwS2SIncludeSection); + } + + // --- SECTION 3: SYMMETRICAL VPN ZONE & LAN FORWARDING --- + const networkState = await checkNetworkStructure(appData); + + // Manage the secure firewall zone for the VPN tunnel network device safely + safeUciAdd(CFG.CMD.firewall, 'zone', fwZoneSection); + safeUciSet(CFG.CMD.firewall, fwZoneSection, 'name', zoneName); + safeUciSet(CFG.CMD.firewall, fwZoneSection, 'device', deviceName); + safeUciSet(CFG.CMD.firewall, fwZoneSection, 'input', 'ACCEPT'); + safeUciSet(CFG.CMD.firewall, fwZoneSection, 'output', 'ACCEPT'); + safeUciSet(CFG.CMD.firewall, fwZoneSection, 'forward', 'ACCEPT'); + safeUciSet(CFG.CMD.firewall, fwZoneSection, 'mtu_fix', '1'); + + // Resolve the main local LAN zone safely + const fwSections = L.uci.sections(CFG.CMD.firewall, 'zone') || []; + let lanSectionId = null; + for (let z = 0; z < fwSections.length; z++) { + if (fwSections[z] && fwSections[z].name === 'lan') { + lanSectionId = fwSections[z]['.name']; + break; + } + } + + // Set safe LAN masquerading states depending on network boundaries + if (lanSectionId) { + if (networkState.apMode === true || networkState.doubleNat === true) { + safeUciSet(CFG.CMD.firewall, lanSectionId, 'masq', '1'); + } else { + safeUciSet(CFG.CMD.firewall, lanSectionId, 'masq', '0'); + } + } + + // Configure the forwarding from LAN to VPN zone safely + safeUciAdd(CFG.CMD.firewall, 'forwarding', fwForwardLanSection); + safeUciSet(CFG.CMD.firewall, fwForwardLanSection, 'src', 'lan'); + safeUciSet(CFG.CMD.firewall, fwForwardLanSection, 'dest', zoneName); + + // Configure the forwarding from VPN zone back to LAN safely + safeUciAdd(CFG.CMD.firewall, 'forwarding', fwForwardVpnSection); + safeUciSet(CFG.CMD.firewall, fwForwardVpnSection, 'src', zoneName); + safeUciSet(CFG.CMD.firewall, fwForwardVpnSection, 'dest', 'lan'); + + // Only save and request a firewall reload if changes were actually detected + if (hasChanges === true) { + L.uci.save(); + reloadFirewall.Request = true; + } + + return true; + + } catch (err) { + console.error('Fatal firewall configuration synchronization failed:', err); + if (hasChanges === true) { + L.uci.save(); + reloadFirewall.Request = true; + } + return false; + } +}; + +/** + * Removes all custom firewall rules and zones for a specific OpenVPN profile safely + */ +const removeInstanceFirewallRule = function (instance_id) { + let hasChanges = false; + + // Helper function to safely remove a section only if it exists in the tree + const safeUciRemove = function (config, section) { + const exists = L.uci.get(config, section); + if (exists) { + L.uci.remove(config, section); + hasChanges = true; + } + }; + + // Delete the inbound WAN rule blocks if they exist + safeUciRemove(CFG.CMD.firewall, CFG.ID.fw_openvpn_rule + instance_id); + safeUciRemove(CFG.CMD.firewall, CFG.ID.fw_openvpn_inc + instance_id); + safeUciRemove(CFG.CMD.firewall, CFG.ID.fw_openvpn_s2s_inc + instance_id); + + // Delete the site-to-site zone and forwarding blocks completely if they exist + safeUciRemove(CFG.CMD.firewall, CFG.ID.fw_openvpn_zone + instance_id); + safeUciRemove(CFG.CMD.firewall, CFG.ID.fw_openvpn_fwd_lan + instance_id); + safeUciRemove(CFG.CMD.firewall, CFG.ID.fw_openvpn_fwd_vpn + instance_id); + + // Only commit to storage and request a reload if sections were actually deleted + if (hasChanges === true) { + L.uci.save(); + reloadFirewall.Request = true; + } +}; + +/** + * Reads and aggregates live packet and byte counters from the custom nftables sub-chain + */ +const getSubChainMetrics = async function (subChainName) { + // Runs your specialized firewall nft list backend utility safely + const output = await luci_app_openvpn_plus([CFG.LIBEXEC.firewallnft, 'list', 'chain', 'inet', 'fw4', subChainName]); + if (output.length > 0) { + + // Matches any counter line ending with either 'reject' or 'drop' (supports both TCP & UDP) + const counterLines = output.match(/counter packets \d+ bytes \d+ (update|reject|drop)/g) || []; + + let totalPackets = 0; + let totalBytes = 0; + + counterLines.forEach(function (line) { + // Captures both numeric groups cleanly using digits (\d+) + const metrics = line.match(/packets (\d+) bytes (\d+)/); + if (metrics && metrics[1] && metrics[2]) { + totalPackets += parseInt(metrics[1], 10); + totalBytes += parseInt(metrics[2], 10); + } + }); + + return { + active: true, + packets: totalPackets, + bytes: totalBytes + }; + } + return { active: false, packets: 0, bytes: 0 }; +}; + +/** + * Smart Firewall Filter + */ +const smartFilterContainer = E('div', {}, []); + +/** + * Helper function to calculate the firewall uptime + */ +const getFirewallUptime = async function () { + try { + // Read file status of the firewall state file + const stat = await rpcStatFile('/var/run/fw4.state'); + + if (stat && stat.mtime) { + // Get current time in seconds and calculate difference + const currentTime = Math.floor(Date.now() / 1000); + + // Protect against time shifts (NTP updates) using Math.abs + const uptimeSeconds = Math.abs(currentTime - stat.mtime); + + // Calculate days, hours, minutes and seconds + const days = Math.floor(uptimeSeconds / 86400); + const hours = Math.floor((uptimeSeconds % 86400) / 3600); + const minutes = Math.floor((uptimeSeconds % 3600) / 60); + const seconds = uptimeSeconds % 60; + + if (days > 0) { + return days + 'd ' + hours + 'h ' + minutes + 'm ' + seconds + 's'; + } + return hours + 'h ' + minutes + 'm ' + seconds + 's'; + } + } catch (e) { + console.error('Failed to read firewall uptime:', e); + } + return ''; +}; + +/** + * Refresh Smart Firewall Filter view + */ +const refreshFirewall = async function (appData) { + + const noSmartFilterElement = E('span', {}, [ + E('strong', {}, ICON.HINT + TXT.FIREWALL.check_traffic_rules), TXT.FIREWALL.network, ICON.ARROW, TXT.FIREWALL.firewall, + E('a', { 'href': L.url('admin/network/firewall/rules'), 'style': 'font-weight:bold; color:var(--primary-color-high, #1976d2); text-decoration:none;' }, ICON.FORWARD + TXT.FIREWALL.traffic_rules) + ]); + + // Check if at least one running server instance has the smart firewall flag set to true + let isSmartFirewallInUse = false; + const metricPromises = []; + + appData.instances.forEach(function (inst) { + if (inst && inst.isRunning === true && inst.smartFirewall === true) { + isSmartFirewallInUse = true; + // Push active query promise into parallel execution queue + metricPromises.push(getSubChainMetrics(`ovpn_filter_${inst.id}`)); + } + }); + + if (isSmartFirewallInUse) { + try { + // Await all parallel sub-chain kernel reads and the uptime file at the same time + const [metricsArray, uptimeString] = await Promise.all([ + Promise.all(metricPromises), + getFirewallUptime() + ]); + + let aggregatedPackets = 0; + let aggregatedBytes = 0; + + // Sum up packets and bytes from all active instances + metricsArray.forEach(function (metric) { + if (metric) { + aggregatedPackets += metric.packets || 0; + aggregatedBytes += metric.bytes || 0; + } + }); + + const filtered = (aggregatedPackets > 0) ? TXT.FIREWALL.filtered + ': ' + aggregatedPackets + ' ' + TXT.FIREWALL.packets + ' (' + appData.statusClass.formatStatusBytes(aggregatedBytes) + ')' + '\n' : ''; + const uptime = uptimeString ? TXT.FIREWALL.protected_since + ': ' + uptimeString + '\n' : ''; + const tooltipText = + ICON.SHIELD + TXT.FIREWALL.smart_filewall + ':' + '\n' + + filtered + + uptime + '\n' + + ICON.INFO + TXT.FIREWALL.smart_filewall_tooltip; + + // Create the active status element with the full tooltip + const smartFilterElement = E('span', { + 'title': tooltipText, + 'style': + 'display:inline-block; font-size:12px; padding:4px 10px; border-radius:4px; cursor:help; ' + + 'background:color-mix(in srgb, var(--primary-color-high, #1976d2) 10%, transparent); ' + + 'border:1px solid color-mix(in srgb, var(--primary-color-high, #1976d2) 30%, transparent); ' + + 'color:color-mix(in srgb, var(--text-color-medium, #71717a) 85%, #0055ff);' + }, [ + E('strong', { 'style': 'font-weight:bold' }, [ICON.SHIELD + TXT.FIREWALL.smart_filewall + ': ']), + E('strong', { 'style': 'color:var(--primary-color-high, #1976d2); font-weight:bold' }, [TXT.FIREWALL.ACTIVE]), + ]); + + // Update the container DOM element + smartFilterContainer.innerHTML = ''; + smartFilterContainer.appendChild(smartFilterElement); + return; + + } catch (err) { + console.error('Firewall live statistics core update cycle dropped:', err); + } + } + + // If no instance uses the smart firewall, show standard LuCI firewall links + smartFilterContainer.innerHTML = ''; + smartFilterContainer.appendChild(noSmartFilterElement); + return; +}; + +/** + * Renders the firewall information box displaying active ports. + */ +const renderFirewallInfoBox = function (appData) { + const customPortsMap = {}; + const allRules = L.uci.sections(CFG.CMD.firewall, 'rule') || []; + + // Scan all active openvpn firewall sections dynamically + allRules.forEach(function (r) { + const sectionName = r['.name'] || ''; + if (sectionName.indexOf('openvpn_') === 0 || sectionName.indexOf('openvpn_rule_') === 0) { + const pVal = L.uci.get(CFG.CMD.firewall, sectionName, 'dest_port'); + let protoVal = L.uci.get(CFG.CMD.firewall, sectionName, 'proto') || OPENVPN.PROTO.UDP; + + if (pVal) { + const pNum = parseInt(pVal, 10); + if (!isNaN(pNum)) { + protoVal = String(protoVal).toUpperCase(); + // Store combined proto + port token to handle dual-stacks seamlessly + customPortsMap[protoVal + ' ' + pNum] = true; + } + } + } + }); + + const activeRulesArray = Object.keys(customPortsMap).sort(); + const firewallContainer = E('fieldset', { + 'class': 'class_fieldset', + 'style': 'margin-top:5px; padding:15px; border:1px solid var(--border-color-medium, #cbd5e1); border-radius:4px; background:var(--background-color-medium, #f4f4f5);' + }, [ + E('legend', { 'style': 'font-weight:bold; font-size:13px; padding:0 8px;' }, TXT.FIREWALL.firewall_info), + E('p', { 'style': 'margin:0; font-size:12px; line-height:1.6;' }, [ + E('strong', {}, ICON.CHECK + TXT.FIREWALL.automated_zone_setup), TXT.FIREWALL.secure_firewall_for_all, + E('code', { 'title': TXT.FIREWALL.openvpn_tunnel_interface, 'style': 'cursor:help; border-bottom:1px dashed var(--border-color-medium, #cbd5e1);' }, 'tun+'), TXT.FIREWALL.devices_autocreated, + E('div', { 'style': 'margin-top: 8px;' }), + E('strong', {}, ICON.CHECK + TXT.FIREWALL.inbound_access), TXT.FIREWALL.wan_ports, activeRulesArray.length > 0 ? E('code', {}, activeRulesArray.join(', ')) : E('code', {}, 'None'), TXT.FIREWALL.auto_open_secure_connection, + E('div', { 'style': 'margin-top: 8px;' }), + smartFilterContainer + ]) + ]); + return firewallContainer; +}; + +/** + * Create the RPC call to run system commands. + */ +const logreadNativeRpc = L.rpc.declare({ + object: 'file', + method: 'exec', + params: ['command', 'params'] +}); + +/** + * Read OpenVPN system logs. + */ +const callLogRead = async function (options) { + const pattern = (options && options.pattern) ? options.pattern : CFG.ID.openvpn_logread; + const execRes = await L.resolveDefault(logreadNativeRpc('/sbin/logread', ['-e', pattern]), {}); + if (execRes && typeof execRes === 'object' && typeof execRes.stdout === 'string') { + return execRes.stdout.trim(); + } + return ''; +}; + +/** + * Filter for OpenVPN system logs + */ +const logFilter = { + filters: [], + showSmartFirewall: false, + logCache: null, + setup: function (appData) { + // 1. Always push the standard basic filters + this.filters.push('--script-security'); + this.filters.push('Using AF_INET'); + this.filters.push('pool size limits'); + + let enableFilter = false; + + // 2. Check if at least one running instance wants the log filter + if (appData && Array.isArray(appData.instances)) { + appData.instances.forEach(function (inst) { + if (inst && inst.isRunning === true && inst.useLogFilter === true) { + enableFilter = true; + } + }); + } + + // 3. If enabled, push the custom cosmetic warning filters + if (enableFilter === true) { + // Mobile clients + this.filters.push('Bad encapsulated packet length from peer'); + // DCO filter + this.filters.push('ovpn_handle_peer: received data for a non-existing peer'); + this.filters.push('dco_read_and_process: netlink reports blocking read'); + this.filters.push('sitnl_send: rtnl: generic error (-17): File exists'); + } + }, + check: function (line) { + let shouldDrop = false; + + this.filters.forEach(function (filter) { + if (line.indexOf(filter) !== -1) { + shouldDrop = true; + return; + } + }); + + return shouldDrop; + } +}; + +/** + * Applies syntax highlighting to individual log entries using strict regular expressions. + */ +const colorizeLogLines = function (text) { + if (!text) { + return ''; + } + + return text.split('\n').map(function (line) { + let cleanLine = line.replace(/&/g, '&').replace(//g, '>'); + + if (logFilter.check(cleanLine) == true) { + return null; + } + + // 0. Show firewall log entries in a subtle light gray/white + if (/openvpn.*\[firewall\]/i.test(cleanLine)) { + return '' + cleanLine + ''; + } + + // 1. Show openvpn-luci script messages in purple color (highest priority) + if (/openvpn-luci/i.test(cleanLine)) { + return '' + cleanLine + ''; + } + + // 2. Show critical errors and bad statuses in red color + if (/error|failed|auth_failed|rejected/i.test(cleanLine)) { + return '' + cleanLine + ''; + } + + // 3. Show system warnings and notes in orange color + if (/warning|warn|note/i.test(cleanLine)) { + return '' + cleanLine + ''; + } + + // 4. Show connection attempts and handshakes in green color + if (/attempting/i.test(cleanLine)) { + return '' + cleanLine + ''; + } + + // 5. Show successful connections and status updates in blue color + if (/initiated|established|completed|success/i.test(cleanLine)) { + return '' + cleanLine + ''; + } + + return cleanLine; + }).filter(function (line) { + // Remove empty null rows safely to keep ESLint happy + return line !== null; + }).join('\n'); +}; + +/** + * Filters the visible logs by saving a timestamp cutoff in session storage. + */ +const handleLogFilter = async function (clearLogBtn, logTextArea) { + clearLogBtn.disabled = true; + clearLogBtn.textContent = ICON.LOADING + TXT.INFO.clearing; + + // 1. Wait for the log data from the server + const plainText = await callLogRead({ pattern: CFG.ID.openvpn_logread }); + + if (plainText) { + const lines = String(plainText).trim().split('\n'); + if (lines.length > 0) { + const lastEntry = lines[lines.length - 1]; + if (lastEntry) { + sessionStorage.setItem(CFG.ID.openvpn_log_stamp, lastEntry.substring(0, 24)); + } + } + } + + // Reset the global RAM cache variable instantly when log is cleared + logFilter.logCache = null; + + logTextArea.innerHTML = '' + TXT.MSG.no_vpn_log + ''; + clearLogBtn.textContent = ICON.SUCCESS + TXT.INFO.log_cleared; + + // 2. Wait 1.5 seconds to reset the button text + setTimeout(function () { + clearLogBtn.disabled = false; + clearLogBtn.textContent = TXT.INFO.log_clear; + }, 1500); +}; + +/** + * Renders the terminal box for the OpenVPN protocol log output. + */ +const renderLogBox = function (logLines, appData) { + + // Scrollable terminal div with dynamic HTML coloring + const logTextArea = E('div', { + 'id': 'openvpn_terminal_box', + 'class': 'cbi-input-textarea', + 'style': 'display:block; width:100%; height:240px; font-family:var(--font-monospace, monospace); font-size:12px; background:#222 !important; color:#fff !important; padding:15px; border-radius:4px; border:1px solid var(--border-color-medium, #cbd5e1); overflow:auto; white-space:pre; text-shadow:none !important; box-sizing:border-box;' + }); + + logFilter.setup(appData); + + // Colorize the initial log stream + logTextArea.innerHTML = logLines ? colorizeLogLines(logLines) : '' + TXT.MSG.no_vpn_log + ''; + + const clearLogBtn = E('button', { + 'id': 'openvpn_clear_log_btn', + 'class': 'btn cbi-button cbi-button-remove', + 'style': 'margin: 0;' // Margin cleared since container handles spacing + }, TXT.INFO.log_clear); + + clearLogBtn.addEventListener('click', function (ev) { + ev.preventDefault(); + handleLogFilter(clearLogBtn, logTextArea); + }); + + const fwCheckboxInput = E('input', { + 'type': 'checkbox', + 'id': 'cb_show_fw_logs', + 'style': 'margin-right: 6px; cursor: pointer; width: 16px; height: 16px; vertical-align: middle;' + }); + + fwCheckboxInput.addEventListener('change', function (ev) { + logFilter.showSmartFirewall = ev.target.checked; + refreshLog(appData); + }); + + const fwCheckboxLabel = E('label', { + 'for': 'cb_show_fw_logs', + 'style': 'display: inline-flex; align-items: center; cursor: pointer; font-size: 13px; font-weight: bold; user-select: none;' + }, [fwCheckboxInput, E('span', {}, ['Show Smart Firewall drops'])]); + + // Stretches across the full width, aligning button left and checkbox right on the exact same height lane + const logActionRow = E('div', { + 'style': 'display: flex; align-items: center; justify-content: space-between; width: 100%; margin-top: 10px; box-sizing: border-box;' + }, [ + clearLogBtn, + fwCheckboxLabel + ]); + + // 1. Create the content wrapper for logs (Hidden by default) + const logContentContainer = E('div', { + 'id': 'openvpn_log_container', + 'style': 'padding:0 2px; display:none; margin-top:5px; width:100%; box-sizing:border-box;' + }, [ + logTextArea, + logActionRow // Injected the flex-row instead of single items + ]); + + // 2. Create a dynamic text arrow indicator to show the open/close state + const toggleArrow = E('span', { + 'style': 'margin-right:8px; font-size:11px; cursor:pointer; user-select:none;' + }, '▶ '); + + const inlineSummaryLine = E('div', { + 'style': 'width:100%; display:block; font-size:11px; font-family:var(--font-monospace, monospace); color:color-mix(in srgb, var(--text-color-medium, #71717a) 85%, #0055ff); padding:4px 2px; margin-bottom:10px; white-space:normal; word-break:break-all; font-style:italic;' + }, '▪ ' + TXT.MSG.system_logs); + + // 3. Assemble the interactive clickable title bar header + const clickableTitle = E('legend', { + 'style': 'font-weight:bold; font-size:13px; padding:0 8px; cursor:pointer; user-select:none;', + 'click': function () { + const isHidden = (logContentContainer.style.display === 'none'); + if (isHidden) { + logContentContainer.style.display = 'block'; + inlineSummaryLine.style.display = 'none'; + toggleArrow.textContent = '▼ '; + + // Dynamically calculate the parent width to force precise horizontal scrollbars + const parentNode = document.getElementById('system_log_section_node'); + if (parentNode) { + // Subtract 40px to account for the fieldset padding (15px left/right) and margins safely + const availableWidth = parentNode.offsetWidth - 40; + if (availableWidth > 0) { + logTextArea.style.maxWidth = availableWidth + 'px'; + } + } + + refreshLog(appData); + + // Automatically scroll down to reveal the newest logs when opening + setTimeout(function () { + const obj = document.getElementById('openvpn_terminal_box'); + if (obj) obj.scrollTop = obj.scrollHeight; + }, 100); + } else { + logContentContainer.style.display = 'none'; + inlineSummaryLine.style.display = 'block'; + toggleArrow.textContent = '▶ '; + } + } + }, [ + toggleArrow, + TXT.INFO.title_log + ]); + + // 4. Return the finalized structural fieldset layout (Matches renderConfigEditor style) + return E('div', { 'class': 'cbi-map', 'id': 'system_log_section_node', 'style': 'margin-bottom:25px; width:100%; box-sizing:border-box;' }, [ + E('div', { 'class': 'cbi-section' }, [ + E('fieldset', { + 'class': 'cbi-section-fieldset', + 'style': 'margin-bottom:0px; padding:15px; border:1px solid var(--border-color-medium, #cbd5e1); border-radius:4px; background:transparent; width:100%; box-sizing:border-box;' + }, [ + clickableTitle, + E('div', { 'class': 'cbi-section-node', 'style': 'padding:0 5px; width:100%; box-sizing:border-box;' }, [ + logContentContainer, + inlineSummaryLine // Stays visible until expanded + ]) + ]) + ]) + ]); +}; + +/** + * Filters log lines based on the session storage timestamp, hide entries from IPs that were already blocked by the firewall. + */ +const parseLogLines = function (appData) { + if (!appData || !appData.logread) { + return ''; + } + + let allLines = String(appData.logread).trim().split('\n'); + const targetStamp = sessionStorage.getItem(CFG.ID.openvpn_log_stamp); + + // Step A: Find the correct timestamp in the log lines + if (targetStamp) { + let allowedIdx = -1; + for (let j = allLines.length - 1; j >= 0; j--) { + if (allLines[j].indexOf(targetStamp) === 0) { + allowedIdx = j; + break; + } + } + if (allowedIdx !== -1) { + allLines = allLines.slice(allowedIdx + 1); + } + } + + // Debug: show all logs of blocked IP + // return allLines.join('\n'); + + // Step B: Search all lines to find blocked IP addresses + const blockedIPs = []; + allLines.forEach(function (line) { + // Check if the line comes from the firewall + if (/openvpn.*\[firewall\]/i.test(line)) { + // Find the IP address after SRC= + const match = line.match(/SRC=([0-9a-fA-F.:]+)/); + if (match && match[1]) { + const ip = match[1]; + // Add the IP to our list if it is not there yet + if (blockedIPs.indexOf(ip) === -1) { + blockedIPs.push(ip); + } + } + } + }); + + // Step C: Filter the log lines and remove bad entries + return allLines.filter(function (line) { + const isFirewallLog = /openvpn.*\[firewall\]/i.test(line); + + // If it is a firewall log, look at the hideLogsFirewall option + if (isFirewallLog) { + return logFilter.showSmartFirewall; + } + + // If the line contains a blocked IP, delete the line + for (let i = 0; i < blockedIPs.length; i++) { + if (line.indexOf(blockedIPs[i]) !== -1) { + return false; + } + } + + // Keep all other good log lines + return true; + }).join('\n'); + +}; + +/** + * Asynchronously requests, processes, and renders the colorized OpenVPN system log stream. + */ +const refreshLog = async function (appData) { + const terminal = document.getElementById('openvpn_terminal_box'); + const clearBtn = document.getElementById('openvpn_clear_log_btn'); + const logContainer = document.getElementById('openvpn_log_container'); + + // Stop if the elements are missing or if the clear button is busy + if (!terminal || (clearBtn && clearBtn.disabled) || (logContainer && logContainer.style.display === 'none')) { + return; + } + + // 1. Wait for the log data from the server + const plainText = await callLogRead({ pattern: CFG.ID.openvpn_logread }); + + // Save the text inside the appData object + appData.logread = plainText || ''; + + // Filter and parse the new log lines + const updatedLines = parseLogLines(appData); + const cleanLines = (updatedLines || '').trim(); + + if (cleanLines !== '') { + // Only update the HTML if the logs actually changed + if (cleanLines !== logFilter.logCache) { + logFilter.logCache = cleanLines; + terminal.innerHTML = colorizeLogLines(updatedLines); + } + } else { + // Show an empty log message if there are no lines + if (logFilter.logCache !== null) { + logFilter.logCache = null; + terminal.innerHTML = '' + TXT.MSG.no_active_log_entries + ''; + } + } +}; + + +/** + * --- LOCK SCREEN --- + */ + +/** + * Key Loading Overlay Container + */ +let startupLockOverlay = null; + +/** + * Array mapping configuration files to their respective DOM IDs and translation texts. + */ +const KEY_FILE_MAP = [ + { id: 'startup_lock_key_ca', label: TXT.KEY.ca, filename: CFG.FILE.ca_def_crt }, + { id: 'startup_lock_key_server_crt', label: TXT.KEY.server_crt, filename: CFG.FILE.server_def_crt }, + { id: 'startup_lock_key_server_key', label: TXT.KEY.server_key, filename: CFG.FILE.server_def_key }, + { id: 'startup_lock_key_client_crt', label: TXT.KEY.client_crt, filename: CFG.FILE.client_def_crt }, + { id: 'startup_lock_key_client_key', label: TXT.KEY.client_key, filename: CFG.FILE.client_def_key }, + { id: 'startup_lock_key_dh', label: TXT.KEY.dh, filename: CFG.FILE.dh_def_pem }, + { id: 'startup_lock_key_server_tls', label: TXT.KEY.server_tls, filename: CFG.FILE.server_def_tls2 }, + { id: 'startup_lock_key_client_tls', label: TXT.KEY.client_tls, filename: CFG.FILE.client_def_tls2 } +]; + +/** + * Renders the loading overlay for the initial default keys generation. + */ +const renderDefaultKeysOverlay = function (keysReady) { + const content = [ + TXT.KEY.keygen_wait, E('br'), + TXT.MSG.process_take_few_minutes, E('br'), E('br') + ]; + KEY_FILE_MAP.forEach(function (item) { + content.push( + E('div', { + 'id': item.id, + 'style': 'font-weight:normal; transition: all 0.3s ease;' + }, [ + item.label + ' (' + item.filename + ')... ' + ]) + ); + }); + return E('div', { + 'id': 'startup_lock_overlay_panel', + 'style': 'position:absolute; top:35px; left:0; width:100%; height:100%; padding:30px 15px; background: color-mix(in srgb, var(--background-color-high, #ffffff) 90%, transparent); z-index:9999; display:' + (keysReady ? 'none' : 'flex') + '; flex-direction:column; align-items:center; justify-content:flex-start; border-radius:4px;' + }, [ + E('img', { + 'src': CFG.FILE.loading_img, + 'style': 'width:64px; height:32px; margin-bottom:15px; vertical-align:middle;' + }), + E('h3', { 'style': 'margin:0 0 10px 0; font-weight:bold; text-shadow:none !important;' }, TXT.KEY.keygen_in_progress), + + E('p', { 'style': 'margin:0; font-style:normal; font-size:13px; text-align:center; line-height:1.6;' }, + content + ) + ]); +}; + +/** + * Checks the existence of each key file and updates the overlay UI items dynamically. + */ +const checkIndividualKeysProgress = async function () { + const statPromises = []; + + // Queue parallel file system checks for maximum efficiency + KEY_FILE_MAP.forEach(function (item) { + statPromises.push(rpcStatFile(CFG.FILE.dir_keys + item.filename)); + }); + + try { + const stats = await Promise.all(statPromises); + + stats.forEach(function (fileStat, index) { + const configItem = KEY_FILE_MAP[index]; + const element = document.getElementById(configItem.id); + + // If DOM element exists and file is present in the filesystem + if (element && fileStat && fileStat.size > 0) { + element.innerHTML = configItem.label + ' (' + configItem.filename + ')... ok'; + // Apply the bold blue color styling + element.style.color = 'var(--primary-color-high, #1976d2)'; + element.style.fontWeight = 'bold'; + } + }); + } catch (err) { + console.error('Failed to update live key generation progress items:', err); + } +}; + +/** + * Checks the default key generation lock state: 'CFG.LIBEXPERIMENT.luci_app_openvpn_plus -> generate_default_keys()' + */ +const checkDefaultKeysState = function (appData) { + return rpcStatFile(CFG.FILE.openvpn_keygen_lock).then(function (lockStat) { + // If the lock file is gone, keys are guaranteed to be fully written and ready + appData.keysReady = !lockStat; + }); +}; + +/** + * Polls the system startup state until default crypto keys are fully ready. + */ +const pollDefaultKeysReady = async function (appData) { + + try { + // Wait for the background key state check to resolve + await checkDefaultKeysState(appData); + + // 1. If background generator lock still exists, maintain the lock screen + if (!appData.keysReady) { + if (startupLockOverlay) { + startupLockOverlay.style.display = 'flex'; + // Trigger live filesystem progress tracking check during polling loop + await checkIndividualKeysProgress(); + } + return; + } + + // 2. Keys are ready - check if a reload is actually required + if (startupLockOverlay) { + if (startupLockOverlay.style.display === 'flex') { + startupLockOverlay.style.display = 'none'; + L.Poll.stop(); + // window.location.reload(); + return; + } + startupLockOverlay.style.display = 'none'; + } + + // 3. If keys were already present on page load, quietly stop the startup poll + L.Poll.stop(); + + } catch { + // Fallback-Handling if the API check fails (ESLint compliant error block) + if (startupLockOverlay) { + startupLockOverlay.style.display = 'none'; + } + L.Poll.stop(); + } +}; + +/** + * Setup default key ready refresh background pollers + */ +const setupPollDefaultKeysReady = function (appData) { + let pollKeyReadyLocked = false; + const pollKeyReady = async function () { + if (pollKeyReadyLocked === true) { + return; + } + pollKeyReadyLocked = true; + try { + await pollDefaultKeysReady(appData); + } catch { + // Silent fallback on failure + } + pollKeyReadyLocked = false; + return; + }; + + if (!appData.keysReady) { + L.Poll.add(L.bind(function () { + pollKeyReady(); + }, this), 2); + } +}; + + +/** + * --- MAIN VIEW --- + */ + + +/** + * Create an empty array to hold all initial refreshes + */ +let initialRefreshQueue = []; + +/** + * Setup main refresh background pollers + */ +const setupMainRefresh = function (appData, statusTable) { + let pollRefreshLocked = false; + const pollRefresh = async function () { + if (pollRefreshLocked === true) { + return; + } + pollRefreshLocked = true; + try { + const openvpnStatus = appData.statusClass; + if (openvpnStatus) { + await openvpnStatus.refreshLiveDashboard(appData, statusTable, refreshStatusBoxCallback); + } + await refreshLog(appData); + await refreshFirewall(appData); + + // initial refresh (once at startup) + if (initialRefreshQueue.length > 0) { + // We create a temporary array for items that are not ready yet + var nextQueue = []; + for (var i = 0; i < initialRefreshQueue.length; i++) { + var item = initialRefreshQueue[i]; + if (item && typeof item.execute === 'function') { + try { + item.execute(); + } catch (queueError) { + console.error("Error in initial refresh queue task:", queueError); + } + } else { + // If the item is not ready yet (.execute is missing), keep it for the next poll tick! + nextQueue.push(item); + } + } + // Save only the unexecuted items back to the global queue + initialRefreshQueue = nextQueue; + } + } catch { + // Silent fallback on failure + } + pollRefreshLocked = false; + } + if (appData.ovpnUciSections.length > 0) { + L.Poll.add(L.bind(function () { + pollRefresh(); + }, this), 5); + } +} + +/** + * Main entry point where the page starts loading + */ +return view.extend({ + + // Create main data storage + APP_DATA: Object.assign({}, APP_DATA_TEMPLATE, { + ovpnUciSections: [], + instances: [] + }), + + load: function () { + L.uci.unload(CFG.CMD.openvpn); + L.uci.unload(CFG.CMD.firewall); + + // 1. Trigger the background key initialization + luci_app_openvpn_plus([CFG.LIBEXEC.initkeys]).catch(function (e) { + console.log(CFG.LIBEXEC.luci_app_openvpn_plus + " " + CFG.LIBEXEC.initkeys + " -> ERROR: " + e.message); + }); + // 2. Trigger the cleanup process + luci_app_openvpn_plus([CFG.LIBEXEC.cleanup]).catch(function (e) { + console.log(CFG.LIBEXEC.luci_app_openvpn_plus + " " + CFG.LIBEXEC.cleanup + " -> ERROR: " + e.message); + }); + + const appData = this.APP_DATA; + + // Load all required classes + return Promise.all([ + L.require('view.vpn.openvpn-status'), + L.require('view.vpn.openvpn-wizard'), + L.require('view.vpn.openvpn-keygen'), + L.uci.load(CFG.CMD.openvpn), + L.uci.load(CFG.CMD.firewall) + + ]).then(async function (results) { + try { + appData.statusClass = results[0]; + appData.wizardClass = results[1]; + appData.keygenClass = results[2]; + + await appData.statusClass.onLoad(); + ovpnPending.init(appData); + + // Gets the openvon sections from LuCI + const ovpnSections = L.uci.sections(CFG.CMD.openvpn, CFG.CMD.openvpn) || []; + // Sorts the array alphabetically based on the internal '.name' (instance1, instance2, ...) + ovpnSections.sort(function (a, b) { + return a['.name'].localeCompare(b['.name'], undefined, { numeric: true, sensitivity: 'base' }); + }); + + // if no section return empty views + if (ovpnSections.length === 0) { + await loadSystemTelemetry(appData); + return initEmptyUciView(); + } + + appData.ovpnUciSections = Array.isArray(ovpnSections) ? ovpnSections : []; + + // Await the telemetry and instance assets data loads linearly + await loadSystemTelemetry(appData); + + // Await the loads of settings and running state for all profiles + await loadInstanceData(appData); + + } catch (err) { + console.error('Error loading main OpenVPN dashboard data:', err); + } + }); + }, + + /** + * Renders the OpenVPN dashboard view + */ + render: function () { + try { + + + const appData = this.APP_DATA; + const openvpnState = isAnyInstanceEnabled(appData.ovpnUciSections) ? OPENVPN.STATE.active : OPENVPN.STATE.disabled; + const openvpnStatus = appData.statusClass; + const logLines = parseLogLines(appData); + startupLockOverlay = renderDefaultKeysOverlay(appData.keysReady); + + const masterServerBtn = E('button', { 'class': 'btn cbi-button cbi-button-positive important' }, TXT.BTN.add_server); + const masterClientBtn = E('button', { 'class': 'btn cbi-button cbi-button-action important' }, TXT.BTN.add_client); + + let instancesNode; + let statusTable = null; + + if (appData.ovpnUciSections.length === 0) { + instancesNode = E('div', { 'class': 'cbi-map' }, [ + E('div', { 'class': 'cbi-section' }, [ + E('h3', { 'style': 'font-weight:bold;' }, TXT.INFO.status), + E('div', { 'class': 'cbi-section-node' }, [ + E('div', { 'class': 'alert-message info', 'style': 'margin:5px 0;' }, TXT.MSG.no_vpn_configured) + ]) + ]) + ]); + } else { + const instanceSections = []; + appData.ovpnUciSections.forEach(function (s, idx) { + // Render individual instance layout blocks using global memory arrays + instanceSections.push(renderInstanceBox(s, idx, appData)); + }); + + statusTable = E('div', { 'id': 'openvpn_live_table_wrapper' }, [ + openvpnStatus ? openvpnStatus.getStatusTable(appData.instances, parseFloat(appData.uptime) || 0, false, false) : '' + ]); + + instancesNode = E('div', {}, [ + E('div', { 'class': 'cbi-map' }, [ + E('div', { 'class': 'cbi-section' }, [ + E('h3', { 'style': 'font-weight:bold;' }, TXT.INFO.status), + E('div', { 'class': 'cbi-section-node' }, [statusTable]) + ]) + ]), + E('div', { 'class': 'cbi-section' }, instanceSections) + ]); + } + + statusControlBox = E('div', {}, [ + renderStatusControlBox(openvpnState, masterServerBtn, masterClientBtn, appData) + ]); + + const renderOpenVPNView = E('div', { 'class': 'cbi-map', 'style': 'position:relative; min-height:300px;' }, [ + startupLockOverlay, + + statusControlBox, + + instancesNode, + + E('hr', { 'style': 'margin:10px 0; border:0;;' }), + renderInstanceCreationBox(appData), + + E('hr', { 'style': 'margin:25px 0 35px 0; border:0;' }), + renderFirewallInfoBox(appData), + + E('hr', { 'style': 'margin:15px 0; border:0;' }), + renderLogBox(logLines, appData), + ]); + + window.requestAnimationFrame(function () { + setupPollDefaultKeysReady(appData); + setupMainRefresh(appData, statusTable); + }); + + return renderOpenVPNView; + + } catch (err) { + console.error('Error rendering main OpenVPN dashboard: ', err); + } + } +}); + +/** + * DEBUG: OpenWRT / LuCI developer shell debug commands + * + * # Show active live openvpn processes + * ps | grep openvpn + * + * # Show active openvpn configuration + * uci show openvpn + * + * # Force OpenVPN reload: + * /etc/init.d/openvpn restart + * + * # Force fast LuCI interface layout cache refresh: + * rm -rf /tmp/luci-* && /etc/init.d/rpcd restart + * + * # Simple process monitor for OpenVPN + * while true; do printf "\033[H\033[J"; echo "=== OPENVPN LIVE MONITOR (Exit with CTRL+C) ==="; echo ""; ps -ww | grep openvpn | grep -v grep | awk '{ gsub(/.{120}/, "&\n "); print }'; sleep 1; done + * + */ +