luci-app-rustdesk-server: add new application
authorGuilherme Cardoso <luminoso+github@gmail.com>
Sun, 4 Jan 2026 12:32:33 +0000 (12:32 +0000)
committerPaul Donald <newtwen+github@gmail.com>
Wed, 21 Jan 2026 22:21:08 +0000 (23:21 +0100)
Add LuCI web interface for managing RustDesk Server on OpenWrt.

RustDesk is an open-source remote desktop application, providing a
self-hosted alternative to proprietary solutions like TeamViewer and
AnyDesk. It enables secure remote access without relying on third-party
servers.

This package provides configuration for the RustDesk server components:
- hbbs: ID/Rendezvous server for connection brokering
- hbbr: Relay server for NAT traversal

Features:
- Modern JavaScript UI with live status polling
- Service management (start/stop/restart/enable/disable)
- Public key display with copy button and regeneration
- Firewall rule auto-management based on port settings
- Tabbed configuration for ID Server (hbbs) and Relay Server (hbbr)
- Log viewer with auto-refresh
- Input validation (paths, ports, CIDR notation)
- i18n ready with POT template

Signed-off-by: Guilherme Cardoso <luminoso+github@gmail.com>
13 files changed:
applications/luci-app-rustdesk-server/Makefile [new file with mode: 0644]
applications/luci-app-rustdesk-server/README.md [new file with mode: 0644]
applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js [new file with mode: 0644]
applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/logs.js [new file with mode: 0644]
applications/luci-app-rustdesk-server/po/templates/rustdesk-server.pot [new file with mode: 0644]
applications/luci-app-rustdesk-server/root/etc/config/rustdesk-server [new file with mode: 0644]
applications/luci-app-rustdesk-server/root/etc/init.d/rustdesk-server [new file with mode: 0755]
applications/luci-app-rustdesk-server/root/etc/uci-defaults/50-luci-rustdesk-server [new file with mode: 0644]
applications/luci-app-rustdesk-server/root/usr/libexec/rustdesk-hbbr [new file with mode: 0755]
applications/luci-app-rustdesk-server/root/usr/libexec/rustdesk-hbbs [new file with mode: 0755]
applications/luci-app-rustdesk-server/root/usr/share/luci/menu.d/luci-app-rustdesk-server.json [new file with mode: 0644]
applications/luci-app-rustdesk-server/root/usr/share/rpcd/acl.d/luci-app-rustdesk-server.json [new file with mode: 0644]
applications/luci-app-rustdesk-server/root/usr/share/rpcd/ucode/rustdesk-server.uc [new file with mode: 0644]

diff --git a/applications/luci-app-rustdesk-server/Makefile b/applications/luci-app-rustdesk-server/Makefile
new file mode 100644 (file)
index 0000000..222e0c9
--- /dev/null
@@ -0,0 +1,20 @@
+include $(TOPDIR)/rules.mk
+
+PKG_VERSION:=20250610
+PKG_RELEASE:=4
+PKG_NAME:=luci-app-rustdesk-server
+PKG_MAINTAINER:=Guilherme Cardoso <luminoso@gmail.com>
+
+LUCI_TITLE:=LuCI support for RustDesk Server
+LUCI_DEPENDS:=+luci-base +rpcd +rpcd-mod-ucode
+LUCI_PKGARCH:=all
+PKG_LICENSE:=Apache-2.0
+
+define Package/$(PKG_NAME)/conffiles
+/etc/config/rustdesk-server
+endef
+
+include ../../luci.mk
+
+# call BuildPackage - OpenWrt buildroot signature
+$(eval $(call BuildPackage,$(PKG_NAME)))
diff --git a/applications/luci-app-rustdesk-server/README.md b/applications/luci-app-rustdesk-server/README.md
new file mode 100644 (file)
index 0000000..6283aac
--- /dev/null
@@ -0,0 +1,299 @@
+# luci-app-rustdesk-server
+
+LuCI web interface for managing [RustDesk Server](https://github.com/rustdesk/rustdesk-server) on OpenWrt.
+
+RustDesk is a full-featured open source remote control alternative to TeamViewer and AnyDesk. This LuCI application provides a web-based interface to configure and manage the self-hosted RustDesk server components (hbbs and hbbr) on OpenWrt routers.
+
+## Features
+
+- **Service Management** - Start/Stop/Restart services directly from the UI
+- **Boot Enable/Disable** - Toggle service startup at boot
+- **Status Monitoring** - Real-time status of HBBS and HBBR services with live polling
+- **Public Key Display** - View and copy the generated public key for client configuration
+- **Key Regeneration** - Regenerate encryption keys when needed
+- **Log Viewer** - View service logs with auto-refresh and auto-scroll features
+- **Firewall Hints** - Displays required ports for manual firewall configuration
+- **Tabbed Configuration** - Organized settings for ID Server (hbbs) and Relay Server (hbbr)
+- **Input Validation** - Validates paths, ports, and configuration values
+- **i18n Ready** - Full translation support with POT template
+
+## Architecture
+
+```
+luci-app-rustdesk-server/
+├── Makefile                      # OpenWrt package build file
+├── htdocs/luci-static/resources/view/rustdesk-server/
+│   └── general.js                # Main UI view (JavaScript)
+├── po/templates/
+│   └── rustdesk-server.pot       # Translation template
+└── root/
+    ├── etc/
+    │   ├── config/rustdesk-server        # UCI configuration
+    │   ├── init.d/rustdesk-server        # procd init script
+    │   └── uci-defaults/50-luci-rustdesk-server  # First-run setup
+    └── usr/share/
+        ├── luci/menu.d/luci-app-rustdesk-server.json  # Menu entry
+        └── rpcd/
+            ├── acl.d/luci-app-rustdesk-server.json    # ACL permissions
+            └── ucode/rustdesk-server.uc               # RPC backend
+```
+
+## Requirements
+
+### OpenWrt Dependencies
+- OpenWrt 23.05 or later with LuCI installed
+- `luci-base` - LuCI core framework
+- `rpcd` - RPC daemon
+- `rpcd-mod-ucode` - ucode support for rpcd
+
+### RustDesk Server Binaries
+The RustDesk server binaries (`hbbs`, `hbbr`) must be installed separately. They are **not included** in this package.
+
+#### Installing RustDesk Server Binaries
+
+1. **Download from GitHub Releases:**
+   ```bash
+   # Check your architecture
+   uname -m
+   
+   # Download appropriate binaries from:
+   # https://github.com/rustdesk/rustdesk-server/releases
+   
+   # Example for aarch64:
+   wget https://github.com/rustdesk/rustdesk-server/releases/download/1.1.11/rustdesk-server-linux-arm64v8.zip
+   unzip rustdesk-server-linux-arm64v8.zip
+   cp amd64/hbbs amd64/hbbr /usr/bin/
+   chmod +x /usr/bin/hbbs /usr/bin/hbbr
+   ```
+
+2. **Or build from source:**
+   ```bash
+   # See https://github.com/rustdesk/rustdesk-server for build instructions
+   ```
+
+3. **Verify installation:**
+   ```bash
+   /usr/bin/hbbs --version
+   /usr/bin/hbbr --version
+   ```
+
+## Installation
+
+### From OpenWrt Package Repository
+```bash
+opkg update
+opkg install luci-app-rustdesk-server
+```
+
+### From Source (Development)
+```bash
+# Clone the LuCI repository
+git clone https://github.com/openwrt/luci.git
+cd luci
+
+# Build the package
+make package/luci-app-rustdesk-server/compile
+```
+
+### Manual Installation
+1. Copy the application files to your OpenWrt device:
+   ```bash
+   # Copy htdocs to /www
+   cp -r htdocs/luci-static /www/luci-static/
+   
+   # Copy root files
+   cp -r root/* /
+   
+   # Set permissions
+   chmod +x /etc/init.d/rustdesk-server
+   ```
+
+2. Reload rpcd to register the new RPC methods:
+   ```bash
+   /etc/init.d/rpcd reload
+   ```
+
+3. Clear LuCI cache:
+   ```bash
+   rm -rf /tmp/luci-*
+   ```
+
+4. Access the interface at: **Services → RustDesk Server**
+
+## Configuration
+
+### Binary Location
+The application expects `hbbs` and `hbbr` binaries to be installed in `/usr/bin`.
+
+### Firewall Configuration
+Firewall rules must be configured manually in **Network → Firewall → Traffic Rules**. The application displays the required ports in the Service Status section.
+
+The standard RustDesk port layout is:
+
+| Port | Protocol | Service | Calculation |
+|------|----------|---------|-------------|
+| HBBS-1 | TCP | NAT type test | server_port - 1 |
+| HBBS | TCP/UDP | ID server / Hole punching | server_port |
+| HBBS+2 | TCP | Web client support | server_port + 2 |
+| HBBR | TCP | Relay server | relay_port |
+| HBBR+2 | TCP | Web client support | relay_port + 2 |
+
+**Example:** With default ports (`server_port=21116` and `relay_port=21117`):
+- TCP ports: 21115, 21116, 21117, 21118, 21119
+- UDP port: 21116
+
+### Logging
+Enable logging in General settings to write service output to `/var/log/rustdesk-server.log`. View logs in real-time using the Logs tab.
+
+### Database Location
+The database is stored in `/tmp/rustdesk_db_v2.sqlite3`. This is a non-persistent location and will be cleared on reboot. This is intentional for embedded systems like OpenWrt where persistent storage may be limited.
+
+## Client Configuration
+
+After starting the service:
+
+1. Go to the LuCI interface and note your router's IP address
+2. Copy the **Public Key** from the Service Status section
+3. In RustDesk client settings, configure:
+   - **ID Server**: Your router's IP:21116 (or custom port if configured)
+   - **Relay Server**: Your router's IP:21117 (or custom port if configured)
+   - **Key**: The public key from step 2
+
+## UCI Configuration Reference
+
+The configuration is stored in `/etc/config/rustdesk-server`:
+
+```uci
+config rustdesk-server
+    option enabled '1'              # Enable ID server (hbbs)
+    option enabled_relay '1'        # Enable Relay server (hbbr)
+    
+    # HBBS options
+    option server_port '21116'      # ID server port
+    option server_key ''            # Custom key (optional)
+    
+    # HBBR options  
+    option relay_port '21117'       # Relay server port
+    
+    # Environment variables
+    option server_env_rust_log 'info'
+```
+
+## Files
+
+| Path | Description |
+|------|-------------|
+| `/etc/config/rustdesk-server` | UCI configuration file |
+| `/etc/init.d/rustdesk-server` | procd init script |
+| `/etc/rustdesk/` | Key storage directory |
+| `/etc/rustdesk/id_ed25519.pub` | Public key (auto-generated) |
+| `/var/log/rustdesk-server.log` | Service log file (when enabled) |
+| `/usr/share/rpcd/ucode/rustdesk-server.uc` | RPC backend |
+| `/usr/share/luci/menu.d/luci-app-rustdesk-server.json` | Menu entry |
+| `/usr/share/rpcd/acl.d/luci-app-rustdesk-server.json` | ACL permissions |
+
+## Troubleshooting
+
+### Service won't start
+1. **Check binaries exist:**
+   ```bash
+   ls -la /usr/bin/hbbs /usr/bin/hbbr
+   ```
+2. **Verify binaries are executable:**
+   ```bash
+   chmod +x /usr/bin/hbbs /usr/bin/hbbr
+   ```
+3. **Check system log:**
+   ```bash
+   logread | grep rustdesk-server
+   ```
+4. **Verify at least one server is enabled** in the configuration
+
+### Key not generated
+The public key (`id_ed25519.pub`) is generated automatically when HBBS starts for the first time. If missing:
+1. Ensure the key directory exists: `mkdir -p /etc/rustdesk`
+2. Start the service and wait a few seconds
+3. Check if key was created: `cat /etc/rustdesk/id_ed25519.pub`
+
+### Firewall / Connection issues
+1. Verify firewall rules are configured in **Network → Firewall → Traffic Rules**
+2. Check that required ports are open (TCP: 21115-21119, UDP: 21116)
+3. Reload firewall:
+   ```bash
+   /etc/init.d/firewall reload
+   ```
+4. Verify the service is running:
+   ```bash
+   pidof hbbs hbbr
+   ```
+5. Check if ports are listening:
+   ```bash
+   netstat -tlnp | grep -E '2111[5-9]'
+   ```
+6. Test connectivity from client:
+   ```bash
+   nc -zv <router-ip> 21116
+   ```
+
+### RPC errors in browser console
+1. Reload rpcd:
+   ```bash
+   /etc/init.d/rpcd reload
+   ```
+2. Clear LuCI cache:
+   ```bash
+   rm -rf /tmp/luci-*
+   ```
+
+## Development
+
+### Building Translations
+```bash
+# Scan for translatable strings
+./build/i18n-scan.pl applications/luci-app-rustdesk-server > applications/luci-app-rustdesk-server/po/templates/rustdesk-server.pot
+
+# Update existing translations
+./build/i18n-update.pl applications/luci-app-rustdesk-server
+```
+
+### Testing Changes
+1. Make changes to files
+2. Copy to device and reload rpcd
+3. Clear browser cache and LuCI cache
+4. Refresh the page
+
+## Security Considerations
+
+This application implements multiple layers of input validation and sanitization to prevent shell injection attacks:
+
+### Frontend Validation (JavaScript)
+All user inputs are validated before being saved to UCI configuration:
+
+| Field Type | Validation |
+|------------|------------|
+| Ports | Numeric only, range 1-65535, supports ranges and comma-separated lists |
+| CIDR masks | Strict IP/prefix format validation |
+| Keys | Alphanumeric and base64 characters only (`A-Za-z0-9+/=`) |
+| URLs | Must start with `http://` or `https://`, no shell metacharacters |
+| Paths | Must start with `/`, no shell metacharacters (`;|&$\`(){}[]<>'"\\!`) |
+| Server lists | Alphanumeric, dots, colons, commas, hyphens, underscores only |
+| Numeric fields | Use LuCI's built-in `uinteger` datatype |
+
+### Backend Validation (Init Script)
+The init script (`/etc/init.d/rustdesk-server`) includes comprehensive validation functions that re-validate all configuration values before using them in shell commands:
+
+- `validate_numeric()` - Ensures values contain only digits
+- `validate_port()` - Validates port range (1-65535)
+- `validate_path()` - Checks for shell metacharacters and requires leading `/`
+- `validate_url()` - Validates URL format and rejects dangerous characters
+- `validate_key()` - Allows only base64-safe characters
+- `validate_server_list()` - Allows only hostname/IP-safe characters
+- `validate_cidr()` - Allows only digits, dots, and slash
+- `validate_log_level()` - Whitelist of valid log levels
+
+Invalid values are rejected and logged with warnings to syslog.
+
+### RPC Backend Validation (ucode)
+The RPC backend (`rustdesk-server.uc`) validates:
+- `service_action`: Whitelist of allowed actions (`start`, `stop`, `restart`, `reload`, `enable`, `disable`)
+- `get_log` lines parameter: Clamped to range 10-1000
diff --git a/applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js b/applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
new file mode 100644 (file)
index 0000000..a78fee7
--- /dev/null
@@ -0,0 +1,604 @@
+'use strict';
+'require view';
+'require form';
+'require fs';
+'require ui';
+'require uci';
+'require rpc';
+'require poll';
+
+/*
+ * Constants - only frontend-relevant values
+ */
+const CONSTANTS = {
+       // Default ports (used in placeholders)
+       HBBS_DEFAULT_PORT: '21116',
+       HBBR_DEFAULT_PORT: '21117',
+
+       // Polling interval (seconds)
+       POLL_INTERVAL: 3,
+
+       // Colors for status display
+       COLORS: {
+               SUCCESS: 'green',
+               ERROR: 'red',
+               MUTED: 'gray',
+               INFO: '#888'
+       }
+};
+
+/*
+ * RPC declarations
+ */
+const callGetStatus = rpc.declare({
+       object: 'luci.rustdesk-server',
+       method: 'get_status'
+});
+
+const callGetPublicKey = rpc.declare({
+       object: 'luci.rustdesk-server',
+       method: 'get_public_key'
+});
+
+const callGetVersion = rpc.declare({
+       object: 'luci.rustdesk-server',
+       method: 'get_version'
+});
+
+const callRegenerateKey = rpc.declare({
+       object: 'luci.rustdesk-server',
+       method: 'regenerate_key'
+});
+
+const callServiceAction = rpc.declare({
+       object: 'luci.rustdesk-server',
+       method: 'service_action',
+       params: ['action']
+});
+
+/*
+ * Helper functions
+ */
+function handleAction(action) {
+       return fs.exec_direct('/etc/init.d/rustdesk-server', [action]);
+}
+
+
+/**
+ * Shell metacharacters regex - prevents command injection
+ */
+const SHELL_METACHARS = /[;&|$`(){}[\]<>'"\\!]/;
+
+/**
+ * Validates a safe string value (no shell metacharacters)
+ * Used for URL and path validation
+ * @param {string} value - The value to check
+ * @returns {boolean|string} True if safe, error message if not
+ */
+function containsShellMetachars(value) {
+       if (SHELL_METACHARS.test(value))
+               return _('Invalid characters detected');
+       return true;
+}
+
+/**
+ * Validates a key string (alphanumeric and base64 characters only)
+ * @param {string} section_id - The section ID
+ * @param {string} value - The key value
+ * @returns {boolean|string} True if valid, error message if invalid
+ */
+function validateKey(section_id, value) {
+       if (!value || value.length === 0)
+               return true;
+       if (/[^A-Za-z0-9+/=]/.test(value))
+               return _('Invalid characters.') + ' ' + _('Only alphanumeric and base64 characters (+/=) allowed.');
+       return true;
+}
+
+/**
+ * Validates a URL (must start with http:// or https://)
+ * @param {string} section_id - The section ID
+ * @param {string} value - The URL value
+ * @returns {boolean|string} True if valid, error message if invalid
+ */
+function validateURL(section_id, value) {
+       if (!value || value.length === 0)
+               return true;
+       const shellCheck = containsShellMetachars(value);
+       if (shellCheck !== true)
+               return shellCheck;
+       if (!/^https?:\/\//.test(value))
+               return _('URL must start with http:// or https://');
+       return true;
+}
+
+
+/**
+ * Creates a status indicator HTML string
+ * @param {boolean} isActive - Whether the status is active/good
+ * @param {string} activeText - Text to show when active
+ * @param {string} inactiveText - Text to show when inactive
+ * @param {string} [suffix] - Optional suffix to append
+ * @returns {string} HTML string
+ */
+function createStatusIndicator(isActive, activeText, inactiveText, suffix) {
+       const color = isActive ? CONSTANTS.COLORS.SUCCESS : CONSTANTS.COLORS.ERROR;
+       const symbol = isActive ? '●' : '○';
+       const text = isActive ? activeText : inactiveText;
+       let html = '<span style="color:' + color + '">' + symbol + ' ' + text + '</span>';
+
+       if (suffix) {
+               html += ' <small style="color:' + CONSTANTS.COLORS.INFO + '">' + suffix + '</small>';
+       }
+
+       return html;
+}
+
+/**
+ * Creates a checkmark status indicator
+ * @param {boolean} isActive - Whether the status is active/good
+ * @param {string} activeText - Text to show when active
+ * @param {string} inactiveText - Text to show when inactive
+ * @returns {string} HTML string
+ */
+function createCheckIndicator(isActive, activeText, inactiveText) {
+       const color = isActive ? CONSTANTS.COLORS.SUCCESS : CONSTANTS.COLORS.MUTED;
+       const symbol = isActive ? '✓' : '✗';
+       const text = isActive ? activeText : inactiveText;
+       return '<span style="color:' + color + '">' + symbol + ' ' + text + '</span>';
+}
+
+// Track if key exists globally for button state
+let keyExistsGlobal = false;
+// Track if any server is enabled in config
+let anyServerEnabledGlobal = false;
+// Track boot enabled state
+let bootEnabledGlobal = false;
+
+return view.extend({
+       render() {
+               let m, s, o;
+
+               m = new form.Map('rustdesk-server', _('RustDesk Server'),
+                       _('Remote Desktop Software Server configuration.') +
+                       ' <a href="https://github.com/rustdesk/rustdesk-server" target="_blank">' + _('Server') + '</a> | ' +
+                       '<a href="https://github.com/rustdesk/rustdesk" target="_blank">' + _('Client') + '</a>');
+
+               /*
+                       Firewall Notice
+               */
+               s = m.section(form.NamedSection, 'firewall_info');
+               s.render = () => E('div', { 'class': 'alert-message notice' }, [
+                       E('h4', {}, _('Firewall Configuration Required')),
+                       E('p', {}, _('Required ports (when using default settings): TCP 21115-21119, UDP 21116.')),
+                       E('p', {}, _('Configure in Network → Firewall → Traffic Rules.'))
+               ]);
+
+               /*
+                       Status Section (custom render)
+               */
+               s = m.section(form.NamedSection, 'global');
+               s.render = L.bind((view, section_id) => {
+                       return E('div', { 'class': 'cbi-section' }, [
+                               E('h3', _('Service Status')),
+
+                               // Status Table for HBBS and HBBR
+                               E('table', { 'class': 'table cbi-section-table', 'id': 'status_table' }, [
+                                       E('tr', { 'class': 'tr table-titles' }, [
+                                               E('th', { 'class': 'th' }, _('Component')),
+                                               E('th', { 'class': 'th' }, _('Service Status')),
+                                               E('th', { 'class': 'th' }, _('Binary')),
+                                               E('th', { 'class': 'th' }, _('Enabled'))
+                                       ]),
+                                       E('tr', { 'class': 'tr', 'id': 'hbbs_row' }, [
+                                               E('td', { 'class': 'td' }, _('HBBS (ID Server)')),
+                                               E('td', { 'class': 'td', 'id': 'hbbs_status' }, '-'),
+                                               E('td', { 'class': 'td', 'id': 'hbbs_binary' }, '-'),
+                                               E('td', { 'class': 'td', 'id': 'hbbs_enabled' }, '-')
+                                       ]),
+                                       E('tr', { 'class': 'tr', 'id': 'hbbr_row' }, [
+                                               E('td', { 'class': 'td' }, _('HBBR (Relay Server)')),
+                                               E('td', { 'class': 'td', 'id': 'hbbr_status' }, '-'),
+                                               E('td', { 'class': 'td', 'id': 'hbbr_binary' }, '-'),
+                                               E('td', { 'class': 'td', 'id': 'hbbr_enabled' }, '-')
+                                       ])
+                               ]),
+
+                               // Public Key with Regenerate button inline
+                               E('div', { 'class': 'cbi-value' }, [
+                                       E('label', { 'class': 'cbi-value-title' }, _('Public Key')),
+                                       E('div', { 'class': 'cbi-value-field', 'style': 'display: flex; align-items: center; flex-wrap: wrap; gap: 8px;' }, [
+                                               E('span', { 'id': 'public_key', 'style': 'word-break: break-all; flex: 1; min-width: 200px;' }, '-'),
+                                               E('button', {
+                                                       'class': 'btn cbi-button cbi-button-negative',
+                                                       'id': 'regenerate_key_btn',
+                                                       'disabled': true,
+                                                       'title': _('Regenerate the key pair (requires existing key)'),
+                                                       'click': (ev) => {
+                                                               if (!keyExistsGlobal) {
+                                                                       ui.addTimeLimitedNotification(null, E('p', _('Cannot regenerate: No public key exists yet.') + ' ' + _('Start the service first to generate the initial key.')), 5000, 'warning');
+                                                                       return;
+                                                               }
+                                                               if (!confirm(_('This will regenerate the key pair and restart the service.') + ' ' + _('All existing clients will need to be reconfigured.') + ' ' + _('Continue?'))) {
+                                                                       return;
+                                                               }
+                                                               ev.target.disabled = true;
+                                                               ev.target.textContent = _('Regenerating...');
+
+                                                               L.resolveDefault(callRegenerateKey(), {}).then((res) => {
+                                                                       if (res && res.success) {
+                                                                               ui.addTimeLimitedNotification(null, E('p', _('Keys deleted. Starting service to generate new keys...')), 5000, 'notice');
+                                                                               // Use RPC to start service for reliable execution
+                                                                               // Add small delay to ensure service has fully stopped
+                                                                               return new Promise((resolve) => {
+                                                                                       setTimeout(resolve, 1000);
+                                                                               }).then(() => L.resolveDefault(callServiceAction('start'), {}));
+                                                                       } else {
+                                                                               ui.addTimeLimitedNotification(null, E('p', _('Key regeneration failed: ') + (res.message || 'Could not delete keys')), 5000, 'error');
+                                                                               throw new Error('Regeneration failed');
+                                                                       }
+                                                               }).then((startRes) => {
+                                                                       if (startRes && startRes.success) {
+                                                                               ui.addTimeLimitedNotification(null, E('p', _('Service started with new key')), 5000, 'notice');
+                                                                       } else if (startRes) {
+                                                                               ui.addTimeLimitedNotification(null, E('p', _('Service start may have failed. Check status above.')), 5000, 'warning');
+                                                                       }
+                                                               }).catch((err) => {
+                                                                       if (err.message !== 'Regeneration failed') {
+                                                                               ui.addTimeLimitedNotification(null, E('p', _('Error: ') + err.message), 5000, 'error');
+                                                                       }
+                                                               }).finally(() => {
+                                                                       const btn = document.getElementById('regenerate_key_btn');
+                                                                       if (btn) {
+                                                                               btn.disabled = !keyExistsGlobal;
+                                                                               btn.textContent = _('Regenerate Key');
+                                                                       }
+                                                               });
+                                                       }
+                                               }, _('Regenerate Key'))
+                                       ])
+                               ]),
+
+                               // Boot at startup toggle
+                               E('div', { 'class': 'cbi-value' }, [
+                                       E('label', { 'class': 'cbi-value-title' }, _('Start at Boot')),
+                                       E('div', { 'class': 'cbi-value-field', 'style': 'display: flex; align-items: center; gap: 12px;' }, [
+                                               E('span', { 'id': 'boot_status' }, '-'),
+                                               E('button', {
+                                                       'class': 'btn cbi-button cbi-button-action',
+                                                       'id': 'enable_boot_btn',
+                                                       'click': (ev) => {
+                                                               const action = bootEnabledGlobal ? 'disable' : 'enable';
+                                                               ev.target.disabled = true;
+                                                               ev.target.textContent = _('Processing...');
+
+                                                               L.resolveDefault(callServiceAction(action), {}).then((res) => {
+                                                                       if (res && res.success) {
+                                                                               bootEnabledGlobal = !bootEnabledGlobal;
+                                                                               ui.addTimeLimitedNotification(null, E('p',
+                                                                                       bootEnabledGlobal ? _('Service enabled at boot') : _('Service disabled at boot')
+                                                                               ), 5000, 'notice');
+                                                                       } else {
+                                                                               const errMsg = res.error || res.message || (res.exit_code !== undefined ? 'Exit code: ' + res.exit_code : JSON.stringify(res));
+                                                                               ui.addTimeLimitedNotification(null, E('p', _('Failed: ') + errMsg), 5000, 'error');
+                                                                       }
+                                                               }).catch((err) => {
+                                                                       ui.addTimeLimitedNotification(null, E('p', _('Error: ') + err.message), 5000, 'error');
+                                                               }).finally(() => {
+                                                                       const btn = document.getElementById('enable_boot_btn');
+                                                                       const statusEl = document.getElementById('boot_status');
+                                                                       if (btn) {
+                                                                               btn.disabled = false;
+                                                                               btn.textContent = bootEnabledGlobal ? _('Disable') : _('Enable');
+                                                                       }
+                                                                       if (statusEl) {
+                                                                               statusEl.innerHTML = createCheckIndicator(bootEnabledGlobal, _('Enabled'), _('Disabled'));
+                                                                       }
+                                                               });
+                                                       }
+                                               }, _('Loading...'))
+                                       ])
+                               ]),
+
+                               // Service Control section
+                               E('div', { 'class': 'cbi-value' }, [
+                                       E('label', { 'class': 'cbi-value-title' }, _('Service Control')),
+                                       E('div', { 'class': 'cbi-value-field' }, [
+                                               E('div', { 'style': 'margin-bottom: 8px;' }, [
+                                                       E('button', {
+                                                               'class': 'btn cbi-button cbi-button-apply',
+                                                               'id': 'start_btn',
+                                                               'disabled': true,
+                                                               'title': _('Enable ID Server or Relay Server first'),
+                                                               'click': (ev) => {
+                                                                       if (!anyServerEnabledGlobal) {
+                                                                               ui.addTimeLimitedNotification(null, E('p', _('Cannot start service: Enable the ID Server or Relay Server in the configuration first.') + ' ' + _('Check "Enable ID Server" or "Enable Relay Server" below and click "Save & Apply".')), 5000, 'error');
+                                                                               return;
+                                                                       }
+
+                                                                       ev.target.disabled = true;
+                                                                       handleAction('start').then(() => {
+                                                                               ev.target.disabled = !anyServerEnabledGlobal;
+                                                                       }).catch((err) => {
+                                                                               ui.addTimeLimitedNotification(null, E('p', _('Failed to start service: ') + err.message), 5000, 'error');
+                                                                               ev.target.disabled = !anyServerEnabledGlobal;
+                                                                       });
+                                                               }
+                                                       }, _('Start')),
+                                                       ' ',
+                                                       E('button', {
+                                                               'class': 'btn cbi-button cbi-button-remove',
+                                                               'click': (ev) => {
+                                                                       ev.target.disabled = true;
+                                                                       handleAction('stop').then(() => {
+                                                                               ev.target.disabled = false;
+                                                                       }).catch((err) => {
+                                                                               ui.addTimeLimitedNotification(null, E('p', _('Failed to stop service: ') + err.message), 5000, 'error');
+                                                                               ev.target.disabled = false;
+                                                                       });
+                                                               }
+                                                       }, _('Stop')),
+                                                       ' ',
+                                                       E('button', {
+                                                               'class': 'btn cbi-button cbi-button-action',
+                                                               'click': (ev) => {
+                                                                       ev.target.disabled = true;
+                                                                       handleAction('restart').then(() => {
+                                                                               ev.target.disabled = false;
+                                                                       }).catch((err) => {
+                                                                               ui.addTimeLimitedNotification(null, E('p', _('Failed to restart service: ') + err.message), 5000, 'error');
+                                                                               ev.target.disabled = false;
+                                                                       });
+                                                               }
+                                                       }, _('Restart'))
+                                               ]),
+                                               // Info message about Start requirement
+                                               E('div', {
+                                                       'class': 'cbi-value-description',
+                                                       'style': 'color: #666; font-size: 0.9em; margin-top: 4px;'
+                                               }, [
+                                                       E('em', {}, [
+                                                               _('Start will only work if at least "Enable ID Server" or "Enable Relay Server" is checked in the Configuration section below.')
+                                                       ])
+                                               ])
+                                       ])
+                               ])
+                       ]);
+               }, o, this);
+
+               /*
+                       Polling for status updates
+               */
+               poll.add(() => {
+                       return Promise.all([
+                               L.resolveDefault(callGetStatus(), {}),
+                               L.resolveDefault(callGetPublicKey(), {}),
+                               L.resolveDefault(callGetVersion(), {}),
+                               uci.load('rustdesk-server')
+                       ]).then(([status = {}, keyInfo = {}, verInfo = {}]) => {
+
+                               // Get enabled status from UCI
+                               const sections = uci.sections('rustdesk-server', 'rustdesk-server');
+                               let hbbsEnabled = false;
+                               let hbbrEnabled = false;
+                               if (sections && sections.length > 0) {
+                                       hbbsEnabled = (sections[0].enabled == '1');
+                                       hbbrEnabled = (sections[0].enabled_relay == '1');
+                               }
+
+                               // HBBS Status (Service Status column)
+                               const hbbsStatusEl = document.getElementById('hbbs_status');
+                               if (hbbsStatusEl) {
+                                       let suffix = '';
+                                       if (status.hbbs_pid) {
+                                               suffix = '(PID: ' + status.hbbs_pid + ')';
+                                               if (verInfo.hbbs_version) suffix += ' [' + verInfo.hbbs_version + ']';
+                                       }
+                                       hbbsStatusEl.innerHTML = createStatusIndicator(
+                                               !!status.hbbs_pid, _('Running'), _('Stopped'), suffix
+                                       );
+                               }
+
+                               // HBBS Binary column
+                               const hbbsBinaryEl = document.getElementById('hbbs_binary');
+                               if (hbbsBinaryEl) {
+                                       hbbsBinaryEl.innerHTML = createCheckIndicator(status.hbbs_exists, _('Found'), _('Not Found'));
+                               }
+
+                               // HBBS Enabled column
+                               const hbbsEnabledEl = document.getElementById('hbbs_enabled');
+                               if (hbbsEnabledEl) {
+                                       hbbsEnabledEl.innerHTML = createCheckIndicator(hbbsEnabled, _('Yes'), _('No'));
+                               }
+
+                               // HBBR Status (Service Status column)
+                               const hbbrStatusEl = document.getElementById('hbbr_status');
+                               if (hbbrStatusEl) {
+                                       let suffix = '';
+                                       if (status.hbbr_pid) {
+                                               suffix = '(PID: ' + status.hbbr_pid + ')';
+                                               if (verInfo.hbbr_version) suffix += ' [' + verInfo.hbbr_version + ']';
+                                       }
+                                       hbbrStatusEl.innerHTML = createStatusIndicator(
+                                               !!status.hbbr_pid, _('Running'), _('Stopped'), suffix
+                                       );
+                               }
+
+                               // HBBR Binary column
+                               const hbbrBinaryEl = document.getElementById('hbbr_binary');
+                               if (hbbrBinaryEl) {
+                                       hbbrBinaryEl.innerHTML = createCheckIndicator(status.hbbr_exists, _('Found'), _('Not Found'));
+                               }
+
+                               // HBBR Enabled column
+                               const hbbrEnabledEl = document.getElementById('hbbr_enabled');
+                               if (hbbrEnabledEl) {
+                                       hbbrEnabledEl.innerHTML = createCheckIndicator(hbbrEnabled, _('Yes'), _('No'));
+                               }
+
+                               // Public Key - update global state
+                               keyExistsGlobal = !!(keyInfo.key_exists && keyInfo.public_key);
+
+                               const keyEl = document.getElementById('public_key');
+                               const regenBtn = document.getElementById('regenerate_key_btn');
+
+                               if (keyEl) {
+                                       if (keyInfo.key_exists && keyInfo.public_key) {
+                                               keyEl.innerHTML = '<code style="font-size:0.9em">' + keyInfo.public_key + '</code>' +
+                                                       ' <button class="btn cbi-button cbi-button-action" onclick="navigator.clipboard.writeText(\'' +
+                                                       keyInfo.public_key + '\');this.textContent=\'✓\';setTimeout(()=>this.textContent=\'' + _('Copy') + '\',1000)">' + _('Copy') + '</button>';
+                                       } else {
+                                               keyEl.innerHTML = '<em style="color:' + CONSTANTS.COLORS.MUTED + '">' + _('Not generated yet - start the service') + '</em>';
+                                       }
+                               }
+
+                               // Update regenerate button state
+                               if (regenBtn) {
+                                       regenBtn.disabled = !keyExistsGlobal;
+                                       if (!keyExistsGlobal) {
+                                               regenBtn.title = _('Start the service first to generate the initial key');
+                                       } else {
+                                               regenBtn.title = _('Regenerate the key pair (will restart service)');
+                                       }
+                               }
+
+                               // Update Start button state based on config
+                               anyServerEnabledGlobal = hbbsEnabled || hbbrEnabled;
+                               const startBtn = document.getElementById('start_btn');
+                               if (startBtn) {
+                                       startBtn.disabled = !anyServerEnabledGlobal;
+                                       if (!anyServerEnabledGlobal) {
+                                               startBtn.title = _('Enable ID Server or Relay Server in Configuration first');
+                                       } else {
+                                               startBtn.title = _('Start the service');
+                                       }
+                               }
+
+                               // Update boot enabled status
+                               bootEnabledGlobal = status.boot_enabled || false;
+                               const bootStatusEl = document.getElementById('boot_status');
+                               const bootBtn = document.getElementById('enable_boot_btn');
+                               if (bootStatusEl) {
+                                       bootStatusEl.innerHTML = createCheckIndicator(bootEnabledGlobal, _('Enabled'), _('Disabled'));
+                               }
+                               if (bootBtn) {
+                                       bootBtn.textContent = bootEnabledGlobal ? _('Disable') : _('Enable');
+                               }
+                       });
+               }, CONSTANTS.POLL_INTERVAL);
+
+               /*
+                       Configuration Section
+               */
+               s = m.section(form.TypedSection, 'rustdesk-server', _('Configuration'));
+               s.anonymous = true;
+               s.addremove = false;
+
+               s.tab('hbbs', _('ID Server (hbbs)'));
+               s.tab('hbbr', _('Relay Server (hbbr)'));
+
+               /* HBBS Settings */
+               o = s.taboption('hbbs', form.Flag, 'enabled', _('Enable'));
+               o.rmempty = false;
+
+               o = s.taboption('hbbs', form.Value, 'server_port', _('Port (-p, --port)'));
+               o.datatype = 'port';
+               o.placeholder = CONSTANTS.HBBS_DEFAULT_PORT;
+               o.description = _('Sets the listening port for the ID/Rendezvous server');
+
+               o = s.taboption('hbbs', form.Value, 'server_key', _('Key (-k, --key)'));
+               o.description = _('Only allow clients with the same key. If empty, uses auto-generated key');
+               o.validate = validateKey;
+
+               o = s.taboption('hbbs', form.DynamicList, 'server_relay_servers', _('Relay Servers (-r, --relay-servers)'));
+               o.description = _('Default relay servers. Add one server per entry (hostname or hostname:port)');
+               o.datatype = 'or(host,hostport)';
+
+               o = s.taboption('hbbs', form.DynamicList, 'server_rendezvous_servers', _('Rendezvous Servers (-R, --rendezvous-servers)'));
+               o.description = _('Additional rendezvous servers. Add one server per entry (hostname or hostname:port)');
+               o.datatype = 'or(host,hostport)';
+
+               o = s.taboption('hbbs', form.Value, 'server_mask', _('LAN Mask (--mask)'));
+               o.description = _('Determine if the connection comes from LAN. Use CIDR notation.');
+               o.placeholder = '192.168.0.0/16';
+               o.datatype = 'cidr4';
+
+               o = s.taboption('hbbs', form.Value, 'server_rmem', _('UDP Recv Buffer (-M, --rmem)'));
+               o.datatype = 'uinteger';
+               o.placeholder = '0';
+               o.description = _('Sets UDP receive buffer size (0 = system default)');
+
+               o = s.taboption('hbbs', form.Value, 'server_serial', _('Serial Number (-s, --serial)'));
+               o.datatype = 'uinteger';
+               o.placeholder = '0';
+               o.description = _('Sets configure update serial number');
+
+               o = s.taboption('hbbs', form.Value, 'server_software_url', _('Software Download URL (-u, --software-url)'));
+               o.description = _('Sets the download URL of RustDesk software for clients');
+               o.validate = validateURL;
+
+               /* HBBS Settings - Environment Variables */
+               o = s.taboption('hbbs', form.Flag, 'server_env_always_use_relay', _('ALWAYS_USE_RELAY'));
+               o.description = _('Force all connections to use relay servers');
+               o.default = o.disabled;
+
+               o = s.taboption('hbbs', form.ListValue, 'server_env_rust_log', _('RUST_LOG'));
+               o.description = _('Logging level for the ID server');
+               o.value('', _('Default'));
+               o.value('error', _('Error'));
+               o.value('warn', _('Warning'));
+               o.value('info', _('Info'));
+               o.value('debug', _('Debug'));
+               o.value('trace', _('Trace'));
+               o.default = '';
+
+               /* HBBR Settings */
+               o = s.taboption('hbbr', form.Flag, 'enabled_relay', _('Enable'));
+               o.rmempty = false;
+
+               o = s.taboption('hbbr', form.Value, 'relay_port', _('Port (-p, --port)'));
+               o.datatype = 'port';
+               o.placeholder = CONSTANTS.HBBR_DEFAULT_PORT;
+               o.description = _('Sets the listening port for the relay server');
+
+               o = s.taboption('hbbr', form.Value, 'relay_key', _('Key (-k, --key)'));
+               o.description = _('Only allow clients with the same key. If empty, uses auto-generated key');
+               o.validate = validateKey;
+
+               /* HBBR Settings - Environment Variables */
+               o = s.taboption('hbbr', form.ListValue, 'relay_env_rust_log', _('RUST_LOG'));
+               o.description = _('Logging level for the relay server');
+               o.value('', _('Default'));
+               o.value('error', _('Error'));
+               o.value('warn', _('Warning'));
+               o.value('info', _('Info'));
+               o.value('debug', _('Debug'));
+               o.value('trace', _('Trace'));
+               o.default = '';
+
+               o = s.taboption('hbbr', form.Value, 'relay_env_limit_speed', _('LIMIT_SPEED'));
+               o.datatype = 'uinteger';
+               o.description = _('Speed limit per connection in Mb/s (0 = default)');
+               o.placeholder = '0';
+
+               o = s.taboption('hbbr', form.Value, 'relay_env_single_bandwidth', _('SINGLE_BANDWIDTH'));
+               o.datatype = 'uinteger';
+               o.description = _('Bandwidth limit per single connection in MB/s (0 = default)');
+               o.placeholder = '0';
+
+               o = s.taboption('hbbr', form.Value, 'relay_env_total_bandwidth', _('TOTAL_BANDWIDTH'));
+               o.datatype = 'uinteger';
+               o.description = _('Total bandwidth limit in MB/s (0 = default)');
+               o.placeholder = '0';
+
+               o = s.taboption('hbbr', form.Value, 'relay_env_downgrade_threshold', _('DOWNGRADE_THRESHOLD'));
+               o.datatype = 'uinteger';
+               o.description = _('Threshold for connection downgrade');
+
+               o = s.taboption('hbbr', form.Value, 'relay_env_downgrade_start_check', _('DOWNGRADE_START_CHECK'));
+               o.datatype = 'uinteger';
+               o.description = _('Start check time for connection downgrade');
+
+               return m.render();
+       }
+});
diff --git a/applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/logs.js b/applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/logs.js
new file mode 100644 (file)
index 0000000..f7664bb
--- /dev/null
@@ -0,0 +1,4 @@
+'use strict';
+'require tools.views as views';
+
+return views.LogreadBox('hbb', _('RustDesk Server Log'));
diff --git a/applications/luci-app-rustdesk-server/po/templates/rustdesk-server.pot b/applications/luci-app-rustdesk-server/po/templates/rustdesk-server.pot
new file mode 100644 (file)
index 0000000..82e1c58
--- /dev/null
@@ -0,0 +1,434 @@
+msgid ""
+msgstr "Content-Type: text/plain; charset=UTF-8"
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "RustDesk Server"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Remote Desktop Software Server configuration."
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Server"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Client"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Service Status"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Component"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Binary"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Enabled"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Disabled"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "HBBS (ID Server)"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "HBBR (Relay Server)"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Running"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Stopped"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Found"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Not Found"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Yes"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "No"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Firewall Ports"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Required firewall ports (configure in Network → Firewall → Traffic Rules):"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "(NAT test)"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "(ID server)"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "(Relay)"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "(Web clients)"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "(Hole punching)"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Ports are relative to configured ID Server port. If using non-default ports, adjust accordingly."
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Copy"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Public Key"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Regenerate Key"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Regenerate the key pair (requires existing key)"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Cannot regenerate: No public key exists yet. Start the service first to generate the initial key."
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "This will regenerate the key pair and restart the service. All existing clients will need to be reconfigured. Continue?"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Regenerating..."
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Keys deleted. Starting service to generate new keys..."
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Key regeneration failed: "
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Service started with new key"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Service start may have failed. Check status above."
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Start the service first to generate the initial key"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Regenerate the key pair (will restart service)"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Not generated yet - start the service"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Start at Boot"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Processing..."
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Service enabled at boot"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Service disabled at boot"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Failed: "
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Error: "
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Disable"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Enable"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Loading..."
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Service Control"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Enable ID Server or Relay Server first"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Cannot start service: The ID Server or Relay Server must be enabled in the configuration first. Please check \"Enable ID Server\" or \"Enable Relay Server\" below and click \"Save & Apply\"."
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Failed to start service: "
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Failed to stop service: "
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Failed to restart service: "
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Start"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Stop"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Restart"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Start will only work if at least \"Enable ID Server\" or \"Enable Relay Server\" is checked in the Configuration section below."
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Enable ID Server or Relay Server in Configuration first"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Start the service"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Configuration"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "General"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "ID Server (hbbs)"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Relay Server (hbbr)"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Enable ID Server"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Enable Relay Server"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Port (-p, --port)"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Sets the listening port for the ID/Rendezvous server"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Key (-k, --key)"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Only allow clients with the same key. If empty, uses auto-generated key"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Invalid characters. Only alphanumeric and base64 characters (+/=) allowed"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Relay Servers (-r, --relay-servers)"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Default relay servers. Add one server per entry (hostname or hostname:port)"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Rendezvous Servers (-R, --rendezvous-servers)"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Additional rendezvous servers. Add one server per entry (hostname or hostname:port)"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "LAN Mask (--mask)"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Determine if the connection comes from LAN. Use CIDR notation."
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "UDP Recv Buffer (-M, --rmem)"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Sets UDP receive buffer size (0 = system default)"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Serial Number (-s, --serial)"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Sets configure update serial number"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Software Download URL (-u, --software-url)"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Sets the download URL of RustDesk software for clients"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Invalid characters detected"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "URL must start with http:// or https://"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "ALWAYS_USE_RELAY"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Force all connections to use relay servers"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "RUST_LOG"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Logging level for the ID server"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Logging level for the relay server"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Default"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Error"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Warning"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Info"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Debug"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Trace"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Sets the listening port for the relay server"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "LIMIT_SPEED"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Speed limit per connection in Mb/s (0 = default)"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "SINGLE_BANDWIDTH"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Bandwidth limit per single connection in MB/s (0 = default)"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "TOTAL_BANDWIDTH"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Total bandwidth limit in MB/s (0 = default)"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "DOWNGRADE_THRESHOLD"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Threshold for connection downgrade"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "DOWNGRADE_START_CHECK"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/htdocs/luci-static/resources/view/rustdesk-server/general.js
+msgid "Start check time for connection downgrade"
+msgstr ""
+
+#: applications/luci-app-rustdesk-server/root/usr/share/rpcd/acl.d/luci-app-rustdesk-server.json
+msgid "Grant access to RustDesk Server configuration"
+msgstr ""
diff --git a/applications/luci-app-rustdesk-server/root/etc/config/rustdesk-server b/applications/luci-app-rustdesk-server/root/etc/config/rustdesk-server
new file mode 100644 (file)
index 0000000..c9b8e27
--- /dev/null
@@ -0,0 +1,3 @@
+config rustdesk-server
+       option enabled '0'
+       option enabled_relay '0'
diff --git a/applications/luci-app-rustdesk-server/root/etc/init.d/rustdesk-server b/applications/luci-app-rustdesk-server/root/etc/init.d/rustdesk-server
new file mode 100755 (executable)
index 0000000..eaaf9cf
--- /dev/null
@@ -0,0 +1,129 @@
+#!/bin/sh /etc/rc.common
+# shellcheck disable=SC2034,SC2154
+
+START=98
+STOP=10
+USE_PROCD=1
+
+NAME=rustdesk-server
+# Use wrapper scripts that set working directory for key generation
+# See /usr/libexec/rustdesk-hbbs and /usr/libexec/rustdesk-hbbr for details
+PROG_HBBS=/usr/libexec/rustdesk-hbbs
+PROG_HBBR=/usr/libexec/rustdesk-hbbr
+BIN_HBBS=/usr/bin/hbbs
+BIN_HBBR=/usr/bin/hbbr
+KEY_DIR=/etc/rustdesk
+
+get_config() {
+       config_get_bool enabled "$1" enabled 0
+       config_get_bool enabled_relay "$1" enabled_relay 0
+
+       # HBBS settings
+       config_get server_port "$1" server_port
+       config_get server_key "$1" server_key
+       config_get server_relay_servers "$1" server_relay_servers
+       config_get server_rendezvous_servers "$1" server_rendezvous_servers
+       config_get server_mask "$1" server_mask
+       config_get server_rmem "$1" server_rmem
+       config_get server_serial "$1" server_serial
+       config_get server_software_url "$1" server_software_url
+       config_get_bool server_env_always_use_relay "$1" server_env_always_use_relay 0
+       config_get server_env_rust_log "$1" server_env_rust_log
+
+       # HBBR settings
+       config_get relay_port "$1" relay_port
+       config_get relay_key "$1" relay_key
+       config_get relay_env_rust_log "$1" relay_env_rust_log
+       config_get relay_env_limit_speed "$1" relay_env_limit_speed
+       config_get relay_env_single_bandwidth "$1" relay_env_single_bandwidth
+       config_get relay_env_total_bandwidth "$1" relay_env_total_bandwidth
+       config_get relay_env_downgrade_threshold "$1" relay_env_downgrade_threshold
+       config_get relay_env_downgrade_start_check "$1" relay_env_downgrade_start_check
+}
+
+start_hbbs() {
+       [ "$enabled" = "1" ] || return 0
+       [ -x "$BIN_HBBS" ] || {
+               logger -t "$NAME" "Error: $BIN_HBBS not found or not executable"
+               return 1
+       }
+
+       # Convert UCI lists (space-separated) to comma-separated for CLI
+       local relay_servers_csv="${server_relay_servers// /,}"
+       local rendezvous_servers_csv="${server_rendezvous_servers// /,}"
+
+       # Build command arguments
+       local cmd_args="${server_port:+ -p $server_port}${relay_servers_csv:+ -r $relay_servers_csv}${server_key:+ -k $server_key}${rendezvous_servers_csv:+ -R $rendezvous_servers_csv}${server_mask:+ --mask $server_mask}${server_rmem:+ -M $server_rmem}${server_serial:+ -s $server_serial}${server_software_url:+ -u $server_software_url}"
+
+       procd_open_instance hbbs
+       procd_set_param command "$PROG_HBBS" $cmd_args
+
+       procd_set_param respawn 3600 5 5
+       procd_set_param stdout 1
+       procd_set_param stderr 1
+       procd_set_param pidfile /var/run/hbbs.pid
+
+       # Environment variables
+       procd_append_param env "DB_URL=/tmp/rustdesk_db_v2.sqlite3"
+       [ "$server_env_always_use_relay" = "1" ] && procd_append_param env "ALWAYS_USE_RELAY=Y"
+       [ -n "$server_env_rust_log" ] && procd_append_param env "RUST_LOG=$server_env_rust_log"
+
+       procd_close_instance
+}
+
+start_hbbr() {
+       [ "$enabled_relay" = "1" ] || return 0
+       [ -x "$BIN_HBBR" ] || {
+               logger -t "$NAME" "Error: $BIN_HBBR not found or not executable"
+               return 1
+       }
+
+       # Build command arguments
+       local cmd_args="${relay_port:+ -p $relay_port}${relay_key:+ -k $relay_key}"
+
+       procd_open_instance hbbr
+       procd_set_param command "$PROG_HBBR" $cmd_args
+
+       procd_set_param respawn 3600 5 5
+       procd_set_param stdout 1
+       procd_set_param stderr 1
+       procd_set_param pidfile /var/run/hbbr.pid
+
+       # Environment variables
+       [ -n "$relay_env_rust_log" ] && procd_append_param env "RUST_LOG=$relay_env_rust_log"
+       [ "${relay_env_limit_speed:-0}" != "0" ] && procd_append_param env "LIMIT_SPEED=$relay_env_limit_speed"
+       [ "${relay_env_single_bandwidth:-0}" != "0" ] && procd_append_param env "SINGLE_BANDWIDTH=$relay_env_single_bandwidth"
+       [ "${relay_env_total_bandwidth:-0}" != "0" ] && procd_append_param env "TOTAL_BANDWIDTH=$relay_env_total_bandwidth"
+       [ -n "$relay_env_downgrade_threshold" ] && procd_append_param env "DOWNGRADE_THRESHOLD=$relay_env_downgrade_threshold"
+       [ -n "$relay_env_downgrade_start_check" ] && procd_append_param env "DOWNGRADE_START_CHECK=$relay_env_downgrade_start_check"
+
+       procd_close_instance
+}
+
+start_service() {
+       mkdir -p "$KEY_DIR"
+
+       config_load "$NAME"
+       config_foreach get_config "$NAME"
+
+       [ "$enabled" != "1" ] && [ "$enabled_relay" != "1" ] && {
+               logger -t "$NAME" "Services disabled. Not starting."
+               return 0
+       }
+
+       start_hbbs
+       start_hbbr
+}
+
+stop_service() {
+       logger -t "$NAME" "Service stopping"
+}
+
+service_triggers() {
+       procd_add_reload_trigger "$NAME"
+}
+
+reload_service() {
+       stop
+       start
+}
diff --git a/applications/luci-app-rustdesk-server/root/etc/uci-defaults/50-luci-rustdesk-server b/applications/luci-app-rustdesk-server/root/etc/uci-defaults/50-luci-rustdesk-server
new file mode 100644 (file)
index 0000000..c91c840
--- /dev/null
@@ -0,0 +1,13 @@
+#!/bin/sh
+
+# luci-app-rustdesk-server UCI defaults
+# This script runs on first install to set up sensible defaults
+
+uci -q batch <<-EOF >/dev/null
+       set rustdesk-server.@rustdesk-server[0]=rustdesk-server
+       set rustdesk-server.@rustdesk-server[0].enabled='0'
+       set rustdesk-server.@rustdesk-server[0].enabled_relay='0'
+       commit rustdesk-server
+EOF
+
+exit 0
diff --git a/applications/luci-app-rustdesk-server/root/usr/libexec/rustdesk-hbbr b/applications/luci-app-rustdesk-server/root/usr/libexec/rustdesk-hbbr
new file mode 100755 (executable)
index 0000000..5a14f60
--- /dev/null
@@ -0,0 +1,10 @@
+#!/usr/bin/env sh
+# Wrapper script for hbbr (RustDesk Relay server)
+#
+# Purpose: This wrapper changes to /etc/rustdesk before exec'ing hbbr.
+# This is for consistency with hbbs (which needs the working directory
+# for key generation). Using exec ensures the process name remains
+# "hbbr" (not "sh") so syslog filtering works correctly.
+
+cd /etc/rustdesk
+exec /usr/bin/hbbr "$@"
diff --git a/applications/luci-app-rustdesk-server/root/usr/libexec/rustdesk-hbbs b/applications/luci-app-rustdesk-server/root/usr/libexec/rustdesk-hbbs
new file mode 100755 (executable)
index 0000000..ca61a47
--- /dev/null
@@ -0,0 +1,10 @@
+#!/usr/bin/env sh
+# Wrapper script for hbbs (RustDesk ID/Rendezvous server)
+#
+# Purpose: This wrapper changes to /etc/rustdesk before exec'ing hbbs.
+# hbbs generates its keypair (id_ed25519, id_ed25519.pub) in its
+# working directory. Using exec ensures the process name remains
+# "hbbs" (not "sh") so syslog filtering works correctly.
+
+cd /etc/rustdesk
+exec /usr/bin/hbbs "$@"
diff --git a/applications/luci-app-rustdesk-server/root/usr/share/luci/menu.d/luci-app-rustdesk-server.json b/applications/luci-app-rustdesk-server/root/usr/share/luci/menu.d/luci-app-rustdesk-server.json
new file mode 100644 (file)
index 0000000..4d989f8
--- /dev/null
@@ -0,0 +1,33 @@
+{
+    "admin/services/rustdesk-server": {
+        "title": "RustDesk Server",
+        "order": 60,
+        "action": {
+            "type": "firstchild"
+        },
+        "depends": {
+            "acl": [
+                "luci-app-rustdesk-server"
+            ],
+            "uci": {
+                "rustdesk-server": true
+            }
+        }
+    },
+    "admin/services/rustdesk-server/general": {
+        "title": "General",
+        "order": 10,
+        "action": {
+            "type": "view",
+            "path": "rustdesk-server/general"
+        }
+    },
+    "admin/services/rustdesk-server/logs": {
+        "title": "Log",
+        "order": 20,
+        "action": {
+            "type": "view",
+            "path": "rustdesk-server/logs"
+        }
+    }
+}
diff --git a/applications/luci-app-rustdesk-server/root/usr/share/rpcd/acl.d/luci-app-rustdesk-server.json b/applications/luci-app-rustdesk-server/root/usr/share/rpcd/acl.d/luci-app-rustdesk-server.json
new file mode 100644 (file)
index 0000000..9066cf1
--- /dev/null
@@ -0,0 +1,75 @@
+{
+    "luci-app-rustdesk-server": {
+        "description": "Grant access to RustDesk Server configuration",
+        "read": {
+            "uci": [
+                "rustdesk-server"
+            ],
+            "ubus": {
+                "luci.rustdesk-server": [
+                    "get_status",
+                    "get_public_key",
+                    "get_version"
+                ],
+                "log": [
+                    "read"
+                ],
+                "file": [
+                    "stat",
+                    "exec"
+                ]
+            },
+            "file": {
+                "/usr/bin/hbbs": [
+                    "read"
+                ],
+                "/usr/bin/hbbr": [
+                    "read"
+                ],
+                "/etc/rustdesk/id_ed25519.pub": [
+                    "read"
+                ],
+                "/etc/rustdesk/*": [
+                    "list"
+                ],
+                "/etc/rc.d/S98rustdesk-server": [
+                    "read"
+                ]
+            }
+        },
+        "write": {
+            "uci": [
+                "rustdesk-server"
+            ],
+            "ubus": {
+                "luci.rustdesk-server": [
+                    "service_action",
+                    "regenerate_key"
+                ]
+            },
+            "cgi-io": [
+                "exec"
+            ],
+            "file": {
+                "/etc/init.d/rustdesk-server start": [
+                    "exec"
+                ],
+                "/etc/init.d/rustdesk-server stop": [
+                    "exec"
+                ],
+                "/etc/init.d/rustdesk-server restart": [
+                    "exec"
+                ],
+                "/etc/init.d/rustdesk-server reload": [
+                    "exec"
+                ],
+                "/etc/init.d/rustdesk-server enable": [
+                    "exec"
+                ],
+                "/etc/init.d/rustdesk-server disable": [
+                    "exec"
+                ]
+            }
+        }
+    }
+}
diff --git a/applications/luci-app-rustdesk-server/root/usr/share/rpcd/ucode/rustdesk-server.uc b/applications/luci-app-rustdesk-server/root/usr/share/rpcd/ucode/rustdesk-server.uc
new file mode 100644 (file)
index 0000000..184bb2f
--- /dev/null
@@ -0,0 +1,163 @@
+#!/usr/bin/env ucode
+'use strict';
+
+import { popen, access, readfile, unlink } from 'fs';
+import { process_list, init_enabled, init_action } from 'luci.sys';
+
+const BIN_DIR = '/usr/bin';
+const KEY_DIR = '/etc/rustdesk';
+
+/*
+ * Helper functions to reduce code duplication
+ */
+
+// Shell escape a string to prevent command injection
+function shellquote(s) {
+       return `'${replace(s, "'", "'\\''")}'`;
+}
+
+// Get PID of a process by name using luci.sys.process_list()
+function getProcessPid(process_name) {
+       for (let proc in process_list()) {
+               if (index(proc.COMMAND, process_name) >= 0) {
+                       return proc.PID;
+               }
+       }
+       return null;
+}
+
+// Execute a command and return trimmed output (for version queries only)
+function execCommand(bin, args) {
+       let result = null;
+       let cmd = shellquote(bin) + ' ' + args;
+       let pp = popen(cmd, 'r');
+       if (pp) {
+               let output = pp.read('all');
+               pp.close();
+               if (output) {
+                       result = trim(output);
+               }
+       }
+       return result;
+}
+
+// Check if a file exists
+function fileExists(path) {
+       return !!access(path);
+}
+
+// Read file content and trim
+function readFileContent(path) {
+       let content = readfile(path);
+       return content ? trim(content) : null;
+}
+
+// Safe file deletion
+function safeUnlink(path) {
+       return unlink(path) || false;
+}
+
+const methods = {
+       get_status: {
+               call: function() {
+                       // Check if service is enabled for boot using luci.sys.init_enabled()
+                       let boot_enabled = init_enabled('rustdesk-server');
+
+                       return {
+                               hbbs_pid: getProcessPid('hbbs'),
+                               hbbr_pid: getProcessPid('hbbr'),
+                               hbbs_exists: fileExists(BIN_DIR + '/hbbs'),
+                               hbbr_exists: fileExists(BIN_DIR + '/hbbr'),
+                               boot_enabled: boot_enabled
+                       };
+               }
+       },
+
+       get_public_key: {
+               call: function() {
+                       let key_path = KEY_DIR + '/id_ed25519.pub';
+                       let key_exists = fileExists(key_path);
+                       let public_key = null;
+
+                       if (key_exists) {
+                               public_key = readFileContent(key_path);
+                       }
+
+                       return {
+                               key_exists: key_exists,
+                               public_key: public_key,
+                               key_path: key_path
+                       };
+               }
+       },
+
+       service_action: {
+               args: { action: 'action' },
+               call: function(req) {
+                       let action = '';
+
+                       if (req && req.args && req.args.action) {
+                               action = req.args.action;
+                       }
+
+                       // Validate action - whitelist approach
+                       const valid_actions = ['start', 'stop', 'restart', 'reload', 'enable', 'disable'];
+                       if (index(valid_actions, action) < 0) {
+                               return {
+                                       success: false,
+                                       error: 'Invalid action. Allowed: ' + join(', ', valid_actions)
+                               };
+                       }
+
+                       // Use luci.sys.init_action() for service control
+                       let result = init_action('rustdesk-server', action);
+
+                       return {
+                               success: (result === 0),
+                               action: action,
+                               exit_code: result
+                       };
+               }
+       },
+
+       get_version: {
+               call: function() {
+                       return {
+                               hbbs_version: fileExists(BIN_DIR + '/hbbs') ? execCommand(BIN_DIR + '/hbbs', '--version 2>&1') : null,
+                               hbbr_version: fileExists(BIN_DIR + '/hbbr') ? execCommand(BIN_DIR + '/hbbr', '--version 2>&1') : null
+                       };
+               }
+       },
+
+       regenerate_key: {
+               call: function() {
+                       let key_priv = KEY_DIR + '/id_ed25519';
+                       let key_pub = KEY_DIR + '/id_ed25519.pub';
+
+                       // Step 1: Stop the service first so keys are not in use
+                       // init_action is synchronous - waits for service to fully stop
+                       init_action('rustdesk-server', 'stop');
+
+                       // Step 2: Remove existing keys
+                       let priv_deleted = safeUnlink(key_priv);
+                       let pub_deleted = safeUnlink(key_pub);
+
+                       // Verify keys are deleted
+                       let keys_deleted = !fileExists(key_priv) && !fileExists(key_pub);
+
+                       // The UI will call restart to regenerate the keys
+                       // hbbs automatically generates new keys on startup if they don't exist
+
+                       return {
+                               success: keys_deleted,
+                               keys_deleted: keys_deleted,
+                               priv_deleted: priv_deleted,
+                               pub_deleted: pub_deleted,
+                               key_path: key_pub,
+                               message: keys_deleted ? 'Keys deleted. Restart service to generate new keys.' : 'Failed to delete keys'
+                       };
+               }
+       }
+};
+
+return { 'luci.rustdesk-server': methods };