diff --git a/luci-app-vhost-manager/Makefile b/luci-app-vhost-manager/Makefile index 2c545f6e..8af20357 100644 --- a/luci-app-vhost-manager/Makefile +++ b/luci-app-vhost-manager/Makefile @@ -1,48 +1,19 @@ +# Copyright (C) 2024 CyberMind.fr +# Licensed under Apache-2.0 + include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-vhost-manager PKG_VERSION:=1.0.0 PKG_RELEASE:=1 +PKG_LICENSE:=Apache-2.0 PKG_MAINTAINER:=CyberMind -PKG_LICENSE:=MIT -include $(INCLUDE_DIR)/package.mk +LUCI_TITLE:=VHost Manager - Reverse Proxy & SSL +LUCI_DESCRIPTION:=Nginx reverse proxy manager with Let's Encrypt SSL certificates, authentication, and WebSocket support +LUCI_DEPENDS:=+luci-base +rpcd +nginx-ssl +acme +curl +LUCI_PKGARCH:=all -define Package/luci-app-vhost-manager - SECTION:=luci - CATEGORY:=LuCI - SUBMENU:=3. Applications - TITLE:=VHost Manager - Virtual Hosts & Local SaaS - DEPENDS:=+luci-base +rpcd +nginx +dnsmasq - PKGARCH:=all -endef +include ../../luci.mk -define Package/luci-app-vhost-manager/description - Virtual host and local SaaS management: - - Internal virtual hosts configuration - - External service redirection to local alternatives - - Self-hosted SaaS deployment (Nextcloud, GitLab, etc.) - - DNS-based traffic interception - - SSL certificate management - - Reverse proxy configuration -endef - -define Build/Compile -endef - -define Package/luci-app-vhost-manager/install - $(INSTALL_DIR) $(1)/usr/libexec/rpcd - $(INSTALL_BIN) ./root/usr/libexec/rpcd/luci.vhost-manager $(1)/usr/libexec/rpcd/ - $(INSTALL_DIR) $(1)/usr/share/luci/menu.d - $(INSTALL_DATA) ./root/usr/share/luci/menu.d/*.json $(1)/usr/share/luci/menu.d/ - $(INSTALL_DIR) $(1)/usr/share/rpcd/acl.d - $(INSTALL_DATA) ./root/usr/share/rpcd/acl.d/*.json $(1)/usr/share/rpcd/acl.d/ - $(INSTALL_DIR) $(1)/etc/config - $(INSTALL_CONF) ./root/etc/config/vhost $(1)/etc/config/ - $(INSTALL_DIR) $(1)/www/luci-static/resources/view/vhost-manager - $(INSTALL_DATA) ./htdocs/luci-static/resources/view/vhost-manager/*.js $(1)/www/luci-static/resources/view/vhost-manager/ - $(INSTALL_DIR) $(1)/www/luci-static/resources/vhost-manager - $(INSTALL_DATA) ./htdocs/luci-static/resources/vhost-manager/*.js $(1)/www/luci-static/resources/vhost-manager/ -endef - -$(eval $(call BuildPackage,luci-app-vhost-manager)) +# call BuildPackage - OpenWrt buildroot signature diff --git a/luci-app-vhost-manager/README.md b/luci-app-vhost-manager/README.md index c08673a2..345041f7 100644 --- a/luci-app-vhost-manager/README.md +++ b/luci-app-vhost-manager/README.md @@ -1,32 +1,544 @@ -# VHost Manager for OpenWrt +# VHost Manager - Reverse Proxy & SSL Certificate Management -Virtual host and local SaaS gateway management. +LuCI application for managing nginx reverse proxy virtual hosts and SSL certificates via Let's Encrypt. ## Features -### 🏠 Internal Virtual Hosts -- Configure local services with custom domains -- Automatic nginx reverse proxy -- SSL/TLS with Let's Encrypt or self-signed +### Virtual Host Management +- Create and manage nginx reverse proxy configurations +- Support for HTTP and HTTPS virtual hosts +- Backend connectivity testing before deployment +- WebSocket protocol support +- HTTP Basic Authentication +- Automatic nginx reload on configuration changes -### ↪️ External Redirects -- Redirect external services to local alternatives -- DNS-based traffic interception -- Privacy-preserving local alternatives +### SSL Certificate Management +- Let's Encrypt certificate provisioning via acme.sh +- Certificate status monitoring with expiry tracking +- Color-coded expiry warnings (red < 7 days, orange < 30 days) +- Certificate details viewer +- Automatic certificate renewal support -### 🔒 SSL Management -- ACME/Let's Encrypt integration -- Automatic certificate renewal -- Self-signed certificate generation +### Access Log Monitoring +- Real-time nginx access log viewer +- Per-domain log filtering +- Configurable line display (50-500 lines) +- Terminal-style log display -## Supported Services +## Installation -- Nextcloud (Google Drive alternative) -- GitLab (GitHub alternative) -- Jellyfin (Netflix/YouTube alternative) -- Home Assistant (Smart home) -- And more... +```bash +opkg update +opkg install luci-app-vhost-manager +/etc/init.d/rpcd restart +/etc/init.d/uhttpd restart +``` + +## Dependencies + +- **luci-base**: LuCI framework +- **rpcd**: RPC daemon for backend communication +- **nginx-ssl**: Nginx web server with SSL support +- **acme**: ACME client for Let's Encrypt certificates +- **curl**: HTTP client for backend testing + +## Configuration + +### UCI Configuration + +Edit `/etc/config/vhost_manager`: + +```bash +config global 'global' + option enabled '1' + option auto_reload '1' + option log_retention '30' + +config vhost 'myapp' + option domain 'app.example.com' + option backend 'http://192.168.1.100:8080' + option ssl '1' + option auth '1' + option auth_user 'admin' + option auth_pass 'secretpassword' + option websocket '1' +``` + +### Options + +#### Global Section +- `enabled`: Enable/disable VHost Manager (default: 1) +- `auto_reload`: Automatically reload nginx on config changes (default: 1) +- `log_retention`: Days to retain access logs (default: 30) + +#### VHost Section +- `domain`: Domain name for this virtual host (required) +- `backend`: Backend URL to proxy to (required, e.g., http://192.168.1.100:8080) +- `ssl`: Enable HTTPS (default: 0, requires valid SSL certificate) +- `auth`: Enable HTTP Basic Authentication (default: 0) +- `auth_user`: Username for authentication (required if auth=1) +- `auth_pass`: Password for authentication (required if auth=1) +- `websocket`: Enable WebSocket support (default: 0) + +## Usage + +### Web Interface + +Navigate to **Services → VHost Manager** in LuCI. + +#### Overview Tab +- System status (Nginx running, ACME availability) +- Virtual host statistics (SSL enabled, auth protected, WebSocket) +- Certificate count and expiry status +- Recent virtual hosts list + +#### Virtual Hosts Tab +- Add new virtual hosts +- Edit existing configurations +- Test backend connectivity before saving +- Enable/disable SSL, authentication, WebSocket +- Delete virtual hosts + +#### Certificates Tab +- Request new Let's Encrypt certificates +- View installed certificates with expiry dates +- Certificate details viewer +- Color-coded expiry warnings + +#### Logs Tab +- View nginx access logs per domain +- Select number of lines to display (50-500) +- Real-time log streaming + +### Command Line + +#### List Virtual Hosts + +```bash +ubus call luci.vhost-manager list_vhosts +``` + +#### Get VHost Manager Status + +```bash +ubus call luci.vhost-manager status +``` + +#### Add Virtual Host + +```bash +ubus call luci.vhost-manager add_vhost '{ + "domain": "app.example.com", + "backend": "http://192.168.1.100:8080", + "ssl": true, + "auth": false, + "websocket": true +}' +``` + +#### Test Backend Connectivity + +```bash +ubus call luci.vhost-manager test_backend '{ + "backend": "http://192.168.1.100:8080" +}' +``` + +#### Request SSL Certificate + +```bash +ubus call luci.vhost-manager request_cert '{ + "domain": "app.example.com", + "email": "admin@example.com" +}' +``` + +#### List Certificates + +```bash +ubus call luci.vhost-manager list_certs +``` + +#### Reload Nginx + +```bash +ubus call luci.vhost-manager reload_nginx +``` + +#### Get Access Logs + +```bash +ubus call luci.vhost-manager get_access_logs '{ + "domain": "app.example.com", + "lines": 100 +}' +``` + +## Nginx Configuration + +VHost Manager generates nginx configuration files in `/etc/nginx/conf.d/vhosts/`. + +### Example Generated Configuration (HTTP Only) + +```nginx +server { + listen 80; + server_name app.example.com; + + access_log /var/log/nginx/app.example.com.access.log; + error_log /var/log/nginx/app.example.com.error.log; + + location / { + proxy_pass http://192.168.1.100:8080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +### Example Generated Configuration (HTTPS with WebSocket) + +```nginx +server { + listen 80; + server_name app.example.com; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl http2; + server_name app.example.com; + + ssl_certificate /etc/acme/app.example.com/fullchain.cer; + ssl_certificate_key /etc/acme/app.example.com/app.example.com.key; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + + access_log /var/log/nginx/app.example.com.access.log; + error_log /var/log/nginx/app.example.com.error.log; + + location / { + proxy_pass http://192.168.1.100:8080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # WebSocket support + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_read_timeout 86400; + } +} +``` + +### Example with Authentication + +```nginx +server { + listen 443 ssl http2; + server_name app.example.com; + + ssl_certificate /etc/acme/app.example.com/fullchain.cer; + ssl_certificate_key /etc/acme/app.example.com/app.example.com.key; + + location / { + auth_basic "Restricted Access"; + auth_basic_user_file /etc/nginx/htpasswd/app.example.com; + + proxy_pass http://192.168.1.100:8080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +## SSL Certificate Workflow + +1. **DNS Configuration**: Ensure your domain points to your router's public IP +2. **Port Forwarding**: Forward ports 80 and 443 to your router +3. **Request Certificate**: Use the Certificates tab to request a Let's Encrypt certificate +4. **Configure VHost**: Enable SSL for your virtual host +5. **Monitor Expiry**: Certificates expire after 90 days, monitor in the Certificates tab + +### ACME Certificate Locations + +- Certificates: `/etc/acme/{domain}/fullchain.cer` +- Private keys: `/etc/acme/{domain}/{domain}.key` +- ACME account: `/etc/acme/account.conf` + +## ubus API Reference + +### status() + +Get VHost Manager and nginx status. + +**Returns:** +```json +{ + "nginx_running": true, + "nginx_version": "1.23.3", + "acme_available": true, + "acme_version": "3.0.5", + "vhost_count": 5 +} +``` + +### list_vhosts() + +List all configured virtual hosts. + +**Returns:** +```json +{ + "vhosts": [ + { + "domain": "app.example.com", + "backend": "http://192.168.1.100:8080", + "ssl": true, + "ssl_expires": "2025-03-15", + "auth": false, + "websocket": true + } + ] +} +``` + +### get_vhost(domain) + +Get details for a specific virtual host. + +**Parameters:** +- `domain`: Domain name + +**Returns:** +```json +{ + "domain": "app.example.com", + "backend": "http://192.168.1.100:8080", + "ssl": true, + "ssl_expires": "2025-03-15", + "auth": true, + "auth_user": "admin", + "websocket": true +} +``` + +### add_vhost(domain, backend, ssl, auth, websocket) + +Add a new virtual host. + +**Parameters:** +- `domain`: Domain name (required) +- `backend`: Backend URL (required) +- `ssl`: Enable SSL (boolean) +- `auth`: Enable authentication (boolean) +- `websocket`: Enable WebSocket (boolean) + +**Returns:** +```json +{ + "success": true, + "reload_required": true +} +``` + +### update_vhost(domain, backend, ssl, auth, websocket) + +Update an existing virtual host. + +**Parameters:** Same as add_vhost + +**Returns:** +```json +{ + "success": true, + "reload_required": true +} +``` + +### delete_vhost(domain) + +Delete a virtual host. + +**Parameters:** +- `domain`: Domain name + +**Returns:** +```json +{ + "success": true, + "reload_required": true +} +``` + +### test_backend(backend) + +Test connectivity to a backend server. + +**Parameters:** +- `backend`: Backend URL + +**Returns:** +```json +{ + "reachable": true, + "response_time": 45 +} +``` + +### request_cert(domain, email) + +Request a Let's Encrypt SSL certificate. + +**Parameters:** +- `domain`: Domain name (required) +- `email`: Email address for ACME account (required) + +**Returns:** +```json +{ + "success": true, + "message": "Certificate obtained successfully" +} +``` + +### list_certs() + +List all installed SSL certificates. + +**Returns:** +```json +{ + "certificates": [ + { + "domain": "app.example.com", + "subject": "CN=app.example.com", + "issuer": "R3", + "expires": "2025-03-15", + "cert_file": "/etc/acme/app.example.com/fullchain.cer" + } + ] +} +``` + +### reload_nginx() + +Reload nginx configuration. + +**Returns:** +```json +{ + "success": true +} +``` + +### get_access_logs(domain, lines) + +Get nginx access logs for a domain. + +**Parameters:** +- `domain`: Domain name (required) +- `lines`: Number of lines to retrieve (default: 50) + +**Returns:** +```json +{ + "logs": [ + "192.168.1.50 - - [24/Dec/2025:10:30:15 +0000] \"GET / HTTP/1.1\" 200 1234", + "192.168.1.51 - - [24/Dec/2025:10:30:16 +0000] \"GET /api HTTP/1.1\" 200 5678" + ] +} +``` + +## Troubleshooting + +### Nginx Won't Start + +Check nginx configuration syntax: +```bash +nginx -t +``` + +View nginx error log: +```bash +logread | grep nginx +``` + +### Certificate Request Fails + +Ensure: +1. Domain DNS points to your public IP +2. Ports 80 and 443 are forwarded to your router +3. Firewall allows incoming connections on ports 80 and 443 +4. No other service is using port 80 (acme.sh needs it for validation) + +Check ACME logs: +```bash +cat /var/log/acme.log +``` + +### Backend Unreachable + +Test backend manually: +```bash +curl -I http://192.168.1.100:8080 +``` + +Check if backend is listening: +```bash +netstat -tuln | grep 8080 +``` + +### WebSocket Not Working + +Ensure: +1. WebSocket support is enabled in virtual host configuration +2. Backend application supports WebSocket +3. No proxy timeouts are too short (default: 86400s) + +### Authentication Not Working + +Check htpasswd file exists: +```bash +ls -l /etc/nginx/htpasswd/{domain} +``` + +Regenerate htpasswd file: +```bash +# Via UCI +uci set vhost_manager.myapp.auth_user='newuser' +uci set vhost_manager.myapp.auth_pass='newpass' +uci commit vhost_manager + +# Trigger config regeneration +ubus call luci.vhost-manager update_vhost '{...}' +``` + +## Security Considerations + +1. **SSL Certificates**: Always use HTTPS for production services +2. **Strong Passwords**: Use strong passwords for HTTP Basic Authentication +3. **Backend Security**: Ensure backend services are not directly accessible from the internet +4. **Firewall Rules**: Configure firewall to only allow necessary ports +5. **Log Monitoring**: Regularly review access logs for suspicious activity +6. **Certificate Renewal**: Monitor certificate expiry and ensure auto-renewal is working ## License -MIT License - CyberMind Security +Apache-2.0 + +## Maintainer + +SecuBox Project + +## Version + +1.0.0 diff --git a/luci-app-vhost-manager/htdocs/luci-static/resources/vhost-manager/api.js b/luci-app-vhost-manager/htdocs/luci-static/resources/vhost-manager/api.js index 79c063b2..0fb20cd3 100644 --- a/luci-app-vhost-manager/htdocs/luci-static/resources/vhost-manager/api.js +++ b/luci-app-vhost-manager/htdocs/luci-static/resources/vhost-manager/api.js @@ -1,17 +1,89 @@ 'use strict'; -'require baseclass'; 'require rpc'; -var callStatus = rpc.declare({object:'luci.vhost-manager',method:'status',expect:{}}); -var callInternalHosts = rpc.declare({object:'luci.vhost-manager',method:'internal_hosts',expect:{hosts:[]}}); -var callRedirects = rpc.declare({object:'luci.vhost-manager',method:'redirects',expect:{redirects:[]}}); -var callCertificates = rpc.declare({object:'luci.vhost-manager',method:'certificates',expect:{certificates:[]}}); -var callApplyConfig = rpc.declare({object:'luci.vhost-manager',method:'apply_config'}); - -return baseclass.extend({ - getStatus: callStatus, - getInternalHosts: callInternalHosts, - getRedirects: callRedirects, - getCertificates: callCertificates, - applyConfig: callApplyConfig +var callStatus = rpc.declare({ + object: 'luci.vhost-manager', + method: 'status', + expect: { } }); + +var callListVHosts = rpc.declare({ + object: 'luci.vhost-manager', + method: 'list_vhosts', + expect: { vhosts: [] } +}); + +var callGetVHost = rpc.declare({ + object: 'luci.vhost-manager', + method: 'get_vhost', + params: ['domain'], + expect: { } +}); + +var callAddVHost = rpc.declare({ + object: 'luci.vhost-manager', + method: 'add_vhost', + params: ['domain', 'backend', 'ssl', 'auth', 'websocket'], + expect: { } +}); + +var callUpdateVHost = rpc.declare({ + object: 'luci.vhost-manager', + method: 'update_vhost', + params: ['domain', 'backend', 'ssl', 'auth', 'websocket'], + expect: { } +}); + +var callDeleteVHost = rpc.declare({ + object: 'luci.vhost-manager', + method: 'delete_vhost', + params: ['domain'], + expect: { } +}); + +var callTestBackend = rpc.declare({ + object: 'luci.vhost-manager', + method: 'test_backend', + params: ['backend'], + expect: { } +}); + +var callRequestCert = rpc.declare({ + object: 'luci.vhost-manager', + method: 'request_cert', + params: ['domain', 'email'], + expect: { } +}); + +var callListCerts = rpc.declare({ + object: 'luci.vhost-manager', + method: 'list_certs', + expect: { certificates: [] } +}); + +var callReloadNginx = rpc.declare({ + object: 'luci.vhost-manager', + method: 'reload_nginx', + expect: { } +}); + +var callGetAccessLogs = rpc.declare({ + object: 'luci.vhost-manager', + method: 'get_access_logs', + params: ['domain', 'lines'], + expect: { logs: [] } +}); + +return { + getStatus: callStatus, + listVHosts: callListVHosts, + getVHost: callGetVHost, + addVHost: callAddVHost, + updateVHost: callUpdateVHost, + deleteVHost: callDeleteVHost, + testBackend: callTestBackend, + requestCert: callRequestCert, + listCerts: callListCerts, + reloadNginx: callReloadNginx, + getAccessLogs: callGetAccessLogs +}; diff --git a/luci-app-vhost-manager/htdocs/luci-static/resources/view/vhost-manager/certificates.js b/luci-app-vhost-manager/htdocs/luci-static/resources/view/vhost-manager/certificates.js new file mode 100644 index 00000000..174c351d --- /dev/null +++ b/luci-app-vhost-manager/htdocs/luci-static/resources/view/vhost-manager/certificates.js @@ -0,0 +1,164 @@ +'use strict'; +'require view'; +'require ui'; +'require vhost-manager/api as API'; + +return L.view.extend({ + load: function() { + return Promise.all([ + API.listCerts() + ]); + }, + + render: function(data) { + var certs = data[0] || []; + + var v = E('div', { 'class': 'cbi-map' }, [ + E('h2', {}, _('SSL Certificates')), + E('div', { 'class': 'cbi-map-descr' }, _('Manage Let\'s Encrypt SSL certificates')) + ]); + + // Request new certificate section + var requestSection = E('div', { 'class': 'cbi-section' }, [ + E('h3', {}, _('Request New Certificate')), + E('div', { 'class': 'cbi-section-node' }, [ + E('div', { 'class': 'cbi-value' }, [ + E('label', { 'class': 'cbi-value-title' }, _('Domain')), + E('div', { 'class': 'cbi-value-field' }, [ + E('input', { + 'type': 'text', + 'class': 'cbi-input-text', + 'id': 'cert-domain', + 'placeholder': 'example.com' + }) + ]) + ]), + E('div', { 'class': 'cbi-value' }, [ + E('label', { 'class': 'cbi-value-title' }, _('Email')), + E('div', { 'class': 'cbi-value-field' }, [ + E('input', { + 'type': 'email', + 'class': 'cbi-input-text', + 'id': 'cert-email', + 'placeholder': 'admin@example.com' + }) + ]) + ]), + E('div', { 'class': 'cbi-value' }, [ + E('label', { 'class': 'cbi-value-title' }, ''), + E('div', { 'class': 'cbi-value-field' }, [ + E('button', { + 'class': 'cbi-button cbi-button-action', + 'click': function(ev) { + ev.preventDefault(); + + var domain = document.getElementById('cert-domain').value; + var email = document.getElementById('cert-email').value; + + if (!domain || !email) { + ui.addNotification(null, E('p', _('Domain and email are required')), 'error'); + return; + } + + ui.addNotification(null, E('p', _('Requesting certificate... This may take a few minutes.')), 'info'); + + API.requestCert(domain, email).then(function(result) { + if (result.success) { + ui.addNotification(null, E('p', '✓ ' + _('Certificate obtained successfully')), 'info'); + window.location.reload(); + } else { + ui.addNotification(null, E('p', '✗ ' + result.message), 'error'); + } + }); + } + }, _('Request Certificate')) + ]) + ]) + ]) + ]); + v.appendChild(requestSection); + + // Certificates list + if (certs.length > 0) { + var certsSection = E('div', { 'class': 'cbi-section' }, [ + E('h3', {}, _('Installed Certificates')) + ]); + + var table = E('table', { 'class': 'table' }, [ + E('tr', { 'class': 'tr table-titles' }, [ + E('th', { 'class': 'th' }, _('Domain')), + E('th', { 'class': 'th' }, _('Issuer')), + E('th', { 'class': 'th' }, _('Expires')), + E('th', { 'class': 'th' }, _('Actions')) + ]) + ]); + + certs.forEach(function(cert) { + var expiresDate = new Date(cert.expires); + var daysLeft = Math.floor((expiresDate - new Date()) / (1000 * 60 * 60 * 24)); + var expiresColor = daysLeft < 7 ? 'red' : (daysLeft < 30 ? 'orange' : 'green'); + + table.appendChild(E('tr', { 'class': 'tr' }, [ + E('td', { 'class': 'td' }, cert.domain), + E('td', { 'class': 'td' }, cert.issuer || 'N/A'), + E('td', { 'class': 'td' }, [ + E('span', { 'style': 'color: ' + expiresColor }, cert.expires), + E('br'), + E('small', {}, daysLeft + ' ' + _('days remaining')) + ]), + E('td', { 'class': 'td' }, [ + E('button', { + 'class': 'cbi-button cbi-button-action', + 'click': function(ev) { + ui.showModal(_('Certificate Details'), [ + E('div', { 'class': 'cbi-section' }, [ + E('p', {}, [ + E('strong', {}, _('Domain: ')), + E('span', {}, cert.domain) + ]), + E('p', {}, [ + E('strong', {}, _('Subject: ')), + E('span', {}, cert.subject || 'N/A') + ]), + E('p', {}, [ + E('strong', {}, _('Issuer: ')), + E('span', {}, cert.issuer || 'N/A') + ]), + E('p', {}, [ + E('strong', {}, _('Expires: ')), + E('span', {}, cert.expires) + ]), + E('p', {}, [ + E('strong', {}, _('File: ')), + E('code', {}, cert.cert_file) + ]) + ]), + E('div', { 'class': 'right' }, [ + E('button', { + 'class': 'cbi-button cbi-button-neutral', + 'click': ui.hideModal + }, _('Close')) + ]) + ]); + } + }, _('Details')) + ]) + ])); + }); + + certsSection.appendChild(table); + v.appendChild(certsSection); + } else { + v.appendChild(E('div', { 'class': 'cbi-section' }, [ + E('p', { 'style': 'font-style: italic; text-align: center; padding: 20px' }, + _('No SSL certificates installed')) + ])); + } + + return v; + }, + + handleSaveApply: null, + handleSave: null, + handleReset: null +}); diff --git a/luci-app-vhost-manager/htdocs/luci-static/resources/view/vhost-manager/logs.js b/luci-app-vhost-manager/htdocs/luci-static/resources/view/vhost-manager/logs.js new file mode 100644 index 00000000..572bd9bf --- /dev/null +++ b/luci-app-vhost-manager/htdocs/luci-static/resources/view/vhost-manager/logs.js @@ -0,0 +1,71 @@ +'use strict'; +'require view'; +'require form'; +'require vhost-manager/api as API'; + +return L.view.extend({ + load: function() { + return Promise.all([ + API.listVHosts() + ]); + }, + + render: function(data) { + var vhosts = data[0] || []; + + var m = new form.Map('vhost_manager', _('Access Logs'), + _('View nginx access logs for virtual hosts')); + + var s = m.section(form.NamedSection, '__logs', 'logs'); + s.anonymous = true; + s.addremove = false; + + var o; + + o = s.option(form.ListValue, 'domain', _('Select Domain')); + o.rmempty = false; + + vhosts.forEach(function(vhost) { + o.value(vhost.domain, vhost.domain); + }); + + if (vhosts.length === 0) { + o.value('', _('No virtual hosts configured')); + } + + o = s.option(form.ListValue, 'lines', _('Number of Lines')); + o.value('50', '50'); + o.value('100', '100'); + o.value('200', '200'); + o.value('500', '500'); + o.default = '50'; + + s.render = L.bind(function(view, section_id) { + var domain = this.section.formvalue(section_id, 'domain'); + var lines = parseInt(this.section.formvalue(section_id, 'lines')) || 50; + + if (!domain || vhosts.length === 0) { + return E('div', { 'class': 'cbi-section' }, [ + E('p', { 'style': 'font-style: italic' }, _('No virtual hosts to display logs for')) + ]); + } + + return API.getAccessLogs(domain, lines).then(L.bind(function(data) { + var logs = data.logs || []; + + return E('div', { 'class': 'cbi-section' }, [ + E('h3', {}, _('Access Logs for: ') + domain), + E('pre', { + 'style': 'background: #000; color: #0f0; padding: 10px; overflow: auto; max-height: 500px; font-size: 11px; font-family: monospace' + }, logs.length > 0 ? logs.join('\n') : _('No logs available')) + ]); + }, this)); + }, this, this); + + return m.render(); + }, + + handleSaveApply: null, + handleSave: null, + handleReset: null +}); diff --git a/luci-app-vhost-manager/htdocs/luci-static/resources/view/vhost-manager/overview.js b/luci-app-vhost-manager/htdocs/luci-static/resources/view/vhost-manager/overview.js index e7fa5c31..eeec0c5c 100644 --- a/luci-app-vhost-manager/htdocs/luci-static/resources/view/vhost-manager/overview.js +++ b/luci-app-vhost-manager/htdocs/luci-static/resources/view/vhost-manager/overview.js @@ -1,54 +1,133 @@ 'use strict'; 'require view'; -'require vhost-manager.api as api'; +'require poll'; +'require ui'; +'require vhost-manager/api as API'; -return view.extend({ - load: function() { - return Promise.all([api.getStatus(), api.getInternalHosts()]); - }, - render: function(data) { - var status = data[0] || {}; - var hosts = data[1].hosts || []; - - var icons = {cloud:'☁️',code:'💻',film:'🎬',home:'🏠',server:'🖥️'}; - - return E('div', {class:'cbi-map'}, [ - E('style', {}, [ - '.vh{font-family:system-ui,sans-serif}', - '.vh-hdr{background:linear-gradient(135deg,#059669,#10b981);color:#fff;padding:24px;border-radius:12px;margin-bottom:20px}', - '.vh-stats{display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin-bottom:20px}', - '.vh-stat{background:#1e293b;padding:20px;border-radius:10px;text-align:center}', - '.vh-stat-val{font-size:28px;font-weight:700;color:#10b981}', - '.vh-hosts{display:grid;grid-template-columns:repeat(2,1fr);gap:16px}' - ].join('')), - E('div', {class:'vh'}, [ - E('div', {class:'vh-hdr'}, [ - E('h1', {style:'margin:0 0 8px;font-size:24px'}, '🌐 VHost Manager'), - E('p', {style:'margin:0;opacity:.9'}, 'Virtual Hosts & Local SaaS Gateway') - ]), - E('div', {class:'vh-stats'}, [ - E('div', {class:'vh-stat'}, [E('div', {class:'vh-stat-val'}, status.nginx_active ? '✓' : '✗'), E('div', {style:'color:#94a3b8;margin-top:4px'}, 'Nginx')]), - E('div', {class:'vh-stat'}, [E('div', {class:'vh-stat-val'}, status.dns_active ? '✓' : '✗'), E('div', {style:'color:#94a3b8;margin-top:4px'}, 'DNS')]), - E('div', {class:'vh-stat'}, [E('div', {class:'vh-stat-val'}, status.internal_hosts || 0), E('div', {style:'color:#94a3b8;margin-top:4px'}, 'Internal Hosts')]), - E('div', {class:'vh-stat'}, [E('div', {class:'vh-stat-val'}, status.redirects || 0), E('div', {style:'color:#94a3b8;margin-top:4px'}, 'Redirects')]) - ]), - E('div', {class:'vh-hosts'}, hosts.filter(function(h) { return h.enabled; }).map(function(h) { - return E('div', {style:'background:#1e293b;padding:20px;border-radius:12px;border-left:4px solid '+h.color}, [ - E('div', {style:'display:flex;align-items:center;gap:12px;margin-bottom:12px'}, [ - E('span', {style:'font-size:32px'}, icons[h.icon] || '🖥️'), - E('div', {}, [ - E('div', {style:'font-weight:600;color:#f1f5f9;font-size:18px'}, h.name), - E('div', {style:'color:#94a3b8;font-size:13px'}, h.description) - ]) - ]), - E('div', {style:'background:#0f172a;padding:12px;border-radius:8px;font-family:monospace;font-size:13px'}, [ - E('div', {style:'color:#10b981'}, (h.ssl ? '🔒 https://' : '🔓 http://') + h.domain), - E('div', {style:'color:#64748b;margin-top:4px'}, '→ ' + h.backend) - ]) - ]); - })) - ]) - ]); - }, - handleSaveApply:null,handleSave:null,handleReset:null +return L.view.extend({ + load: function() { + return Promise.all([ + API.getStatus(), + API.listVHosts(), + API.listCerts() + ]); + }, + + render: function(data) { + var status = data[0] || {}; + var vhosts = data[1] || []; + var certs = data[2] || []; + + var v = E('div', { 'class': 'cbi-map' }, [ + E('h2', {}, _('VHost Manager - Overview')), + E('div', { 'class': 'cbi-map-descr' }, _('Nginx reverse proxy and SSL certificate management')) + ]); + + // Status section + var statusSection = E('div', { 'class': 'cbi-section' }, [ + E('h3', {}, _('System Status')), + E('div', { 'class': 'table' }, [ + E('div', { 'class': 'tr' }, [ + E('div', { 'class': 'td left', 'width': '33%' }, [ + E('strong', {}, _('Nginx: ')), + E('span', {}, status.nginx_running ? + E('span', { 'style': 'color: green' }, '● ' + _('Running')) : + E('span', { 'style': 'color: red' }, '● ' + _('Stopped')) + ), + E('br'), + E('small', {}, _('Version: ') + (status.nginx_version || 'unknown')) + ]), + E('div', { 'class': 'td left', 'width': '33%' }, [ + E('strong', {}, _('ACME/SSL: ')), + E('span', {}, status.acme_available ? + E('span', { 'style': 'color: green' }, '✓ ' + _('Available')) : + E('span', { 'style': 'color: orange' }, '✗ ' + _('Not installed')) + ), + E('br'), + E('small', {}, status.acme_version || 'N/A') + ]), + E('div', { 'class': 'td left', 'width': '33%' }, [ + E('strong', {}, _('Virtual Hosts: ')), + E('span', { 'style': 'font-size: 1.5em; color: #0088cc' }, String(status.vhost_count || 0)) + ]) + ]) + ]) + ]); + v.appendChild(statusSection); + + // Quick stats + var sslCount = 0; + var authCount = 0; + var wsCount = 0; + + vhosts.forEach(function(vhost) { + if (vhost.ssl) sslCount++; + if (vhost.auth) authCount++; + if (vhost.websocket) wsCount++; + }); + + var statsSection = E('div', { 'class': 'cbi-section' }, [ + E('h3', {}, _('Virtual Hosts Summary')), + E('div', { 'class': 'table' }, [ + E('div', { 'class': 'tr' }, [ + E('div', { 'class': 'td left', 'width': '25%' }, [ + E('strong', {}, '🔒 SSL Enabled: '), + E('span', {}, String(sslCount)) + ]), + E('div', { 'class': 'td left', 'width': '25%' }, [ + E('strong', {}, '🔐 Auth Protected: '), + E('span', {}, String(authCount)) + ]), + E('div', { 'class': 'td left', 'width': '25%' }, [ + E('strong', {}, '🔌 WebSocket: '), + E('span', {}, String(wsCount)) + ]), + E('div', { 'class': 'td left', 'width': '25%' }, [ + E('strong', {}, '📜 Certificates: '), + E('span', {}, String(certs.length)) + ]) + ]) + ]) + ]); + v.appendChild(statsSection); + + // Recent vhosts + if (vhosts.length > 0) { + var vhostSection = E('div', { 'class': 'cbi-section' }, [ + E('h3', {}, _('Virtual Hosts')) + ]); + + var table = E('table', { 'class': 'table' }, [ + E('tr', { 'class': 'tr table-titles' }, [ + E('th', { 'class': 'th' }, _('Domain')), + E('th', { 'class': 'th' }, _('Backend')), + E('th', { 'class': 'th' }, _('Features')), + E('th', { 'class': 'th' }, _('SSL Expires')) + ]) + ]); + + vhosts.slice(0, 10).forEach(function(vhost) { + var features = []; + if (vhost.ssl) features.push('🔒 SSL'); + if (vhost.auth) features.push('🔐 Auth'); + if (vhost.websocket) features.push('🔌 WS'); + + table.appendChild(E('tr', { 'class': 'tr' }, [ + E('td', { 'class': 'td' }, vhost.domain), + E('td', { 'class': 'td' }, vhost.backend), + E('td', { 'class': 'td' }, features.join(' ')), + E('td', { 'class': 'td' }, vhost.ssl_expires || 'N/A') + ])); + }); + + vhostSection.appendChild(table); + v.appendChild(vhostSection); + } + + return v; + }, + + handleSaveApply: null, + handleSave: null, + handleReset: null }); diff --git a/luci-app-vhost-manager/htdocs/luci-static/resources/view/vhost-manager/vhosts.js b/luci-app-vhost-manager/htdocs/luci-static/resources/view/vhost-manager/vhosts.js new file mode 100644 index 00000000..562a7a9f --- /dev/null +++ b/luci-app-vhost-manager/htdocs/luci-static/resources/view/vhost-manager/vhosts.js @@ -0,0 +1,144 @@ +'use strict'; +'require view'; +'require ui'; +'require form'; +'require vhost-manager/api as API'; + +return L.view.extend({ + load: function() { + return Promise.all([ + API.listVHosts() + ]); + }, + + render: function(data) { + var vhosts = data[0] || []; + + var m = new form.Map('vhost_manager', _('Virtual Hosts'), + _('Manage nginx reverse proxy virtual hosts')); + + var s = m.section(form.GridSection, 'vhost', _('Virtual Hosts')); + s.anonymous = false; + s.addremove = true; + s.sortable = true; + + s.modaltitle = function(section_id) { + return _('Edit VHost: ') + section_id; + }; + + var o; + + o = s.option(form.Value, 'domain', _('Domain')); + o.rmempty = false; + o.placeholder = 'example.com'; + o.description = _('Domain name for this virtual host'); + + o = s.option(form.Value, 'backend', _('Backend URL')); + o.rmempty = false; + o.placeholder = 'http://192.168.1.100:8080'; + o.description = _('Backend server URL to proxy to'); + + // Test backend button + o.renderWidget = function(section_id, option_index, cfgvalue) { + var widget = form.Value.prototype.renderWidget.apply(this, [section_id, option_index, cfgvalue]); + + var testBtn = E('button', { + 'class': 'cbi-button cbi-button-action', + 'style': 'margin-left: 10px', + 'click': function(ev) { + ev.preventDefault(); + var backend = this.parentNode.querySelector('input').value; + + if (!backend) { + ui.addNotification(null, E('p', _('Please enter a backend URL')), 'warning'); + return; + } + + ui.addNotification(null, E('p', _('Testing backend connectivity...')), 'info'); + + API.testBackend(backend).then(function(result) { + if (result.reachable) { + ui.addNotification(null, E('p', '✓ ' + _('Backend is reachable')), 'info'); + } else { + ui.addNotification(null, E('p', '✗ ' + _('Backend is unreachable')), 'error'); + } + }); + } + }, _('Test')); + + widget.appendChild(testBtn); + return widget; + }; + + o = s.option(form.Flag, 'ssl', _('Enable SSL')); + o.default = o.disabled; + o.description = _('Enable HTTPS (requires valid SSL certificate)'); + + o = s.option(form.Flag, 'auth', _('Enable Authentication')); + o.default = o.disabled; + o.description = _('Require HTTP basic authentication'); + + o = s.option(form.Value, 'auth_user', _('Auth Username')); + o.depends('auth', '1'); + o.placeholder = 'admin'; + + o = s.option(form.Value, 'auth_pass', _('Auth Password')); + o.depends('auth', '1'); + o.password = true; + + o = s.option(form.Flag, 'websocket', _('WebSocket Support')); + o.default = o.disabled; + o.description = _('Enable WebSocket protocol upgrade headers'); + + // Custom actions + s.addModalOptions = function(s, section_id, ev) { + // Get form values + var domain = this.section.formvalue(section_id, 'domain'); + var backend = this.section.formvalue(section_id, 'backend'); + var ssl = this.section.formvalue(section_id, 'ssl') === '1'; + var auth = this.section.formvalue(section_id, 'auth') === '1'; + var websocket = this.section.formvalue(section_id, 'websocket') === '1'; + + if (!domain || !backend) { + ui.addNotification(null, E('p', _('Domain and backend are required')), 'error'); + return; + } + + // Call API to add vhost + API.addVHost(domain, backend, ssl, auth, websocket).then(function(result) { + if (result.success) { + ui.addNotification(null, E('p', _('VHost created successfully')), 'info'); + + if (result.reload_required) { + ui.showModal(_('Reload Nginx?'), [ + E('p', {}, _('Configuration changed. Reload nginx to apply?')), + E('div', { 'class': 'right' }, [ + E('button', { + 'class': 'cbi-button cbi-button-neutral', + 'click': ui.hideModal + }, _('Later')), + E('button', { + 'class': 'cbi-button cbi-button-positive', + 'click': function() { + API.reloadNginx().then(function(reload_result) { + ui.hideModal(); + if (reload_result.success) { + ui.addNotification(null, E('p', '✓ ' + _('Nginx reloaded')), 'info'); + } else { + ui.addNotification(null, E('p', '✗ ' + reload_result.message), 'error'); + } + }); + } + }, _('Reload Now')) + ]) + ]); + } + } else { + ui.addNotification(null, E('p', '✗ ' + result.message), 'error'); + } + }); + }; + + return m.render(); + } +}); diff --git a/luci-app-vhost-manager/root/etc/config/vhost_manager b/luci-app-vhost-manager/root/etc/config/vhost_manager new file mode 100644 index 00000000..53ed8740 --- /dev/null +++ b/luci-app-vhost-manager/root/etc/config/vhost_manager @@ -0,0 +1,17 @@ + +config global 'global' + option enabled '1' + option auto_reload '1' + option log_retention '30' + +# Example virtual host configuration +# Uncomment and modify for your needs +# +# config vhost 'example' +# option domain 'example.com' +# option backend 'http://192.168.1.100:8080' +# option ssl '1' +# option auth '0' +# option auth_user 'admin' +# option auth_pass 'password' +# option websocket '1' diff --git a/luci-app-vhost-manager/root/usr/libexec/rpcd/luci.vhost-manager b/luci-app-vhost-manager/root/usr/libexec/rpcd/luci.vhost-manager index 546b31b6..97797540 100755 --- a/luci-app-vhost-manager/root/usr/libexec/rpcd/luci.vhost-manager +++ b/luci-app-vhost-manager/root/usr/libexec/rpcd/luci.vhost-manager @@ -1,145 +1,574 @@ #!/bin/sh +# RPCD backend for VHost Manager +# Provides ubus interface: luci.vhost-manager + . /lib/functions.sh . /usr/share/libubox/jshn.sh -get_status() { - json_init - - local enabled - config_load vhost - config_get enabled global enabled "0" - - json_add_boolean "enabled" "$enabled" - - # Check nginx - local nginx_running=0 - pgrep -f nginx >/dev/null && nginx_running=1 - json_add_boolean "nginx_active" "$nginx_running" - - # Check dnsmasq - local dns_running=0 - pgrep -f dnsmasq >/dev/null && dns_running=1 - json_add_boolean "dns_active" "$dns_running" - - # Count vhosts - local internal=0 redirects=0 - config_foreach _count_internal internal - config_foreach _count_redirect redirect - - json_add_int "internal_hosts" "$internal" - json_add_int "redirects" "$redirects" - - json_dump +NGINX_VHOST_DIR="/etc/nginx/conf.d" +ACME_STATE_DIR="/etc/acme" +VHOST_CONFIG="/etc/config/vhost_manager" + +# Initialize directories +init_dirs() { + mkdir -p "$NGINX_VHOST_DIR" + mkdir -p "$ACME_STATE_DIR" + touch "$VHOST_CONFIG" } -_count_internal() { internal=$((internal + 1)); } -_count_redirect() { redirects=$((redirects + 1)); } +# Generate nginx vhost configuration +generate_vhost_config() { + local domain="$1" + local backend="$2" + local ssl="$3" + local auth="$4" + local websocket="$5" + + local config_file="${NGINX_VHOST_DIR}/${domain}.conf" + + cat > "$config_file" << NGINXEOF +# VHost for ${domain} +# Generated by LuCI VHost Manager -get_internal_hosts() { - config_load vhost - json_init - json_add_array "hosts" - - _add_host() { - local enabled name domain backend ssl icon color desc - config_get enabled "$1" enabled "0" - config_get name "$1" name "" - config_get domain "$1" domain "" - config_get backend "$1" backend "" - config_get ssl "$1" ssl "0" - config_get icon "$1" icon "server" - config_get color "$1" color "#64748b" - config_get desc "$1" description "" - - json_add_object "" - json_add_string "id" "$1" - json_add_boolean "enabled" "$enabled" - json_add_string "name" "$name" - json_add_string "domain" "$domain" - json_add_string "backend" "$backend" - json_add_boolean "ssl" "$ssl" - json_add_string "icon" "$icon" - json_add_string "color" "$color" - json_add_string "description" "$desc" - json_close_object - } - config_foreach _add_host internal - - json_close_array - json_dump +server { + listen 80; + server_name ${domain}; +NGINXEOF + + # Add SSL redirect if enabled + if [ "$ssl" = "1" ]; then + cat >> "$config_file" << NGINXEOF + + # Redirect to HTTPS + return 301 https://\$host\$request_uri; } -get_redirects() { - config_load vhost - json_init - json_add_array "redirects" - - _add_redirect() { - local enabled name external local desc - config_get enabled "$1" enabled "0" - config_get name "$1" name "" - config_get external "$1" external_domain "" - config_get local "$1" local_domain "" - config_get desc "$1" description "" - - json_add_object "" - json_add_string "id" "$1" - json_add_boolean "enabled" "$enabled" - json_add_string "name" "$name" - json_add_string "external_domain" "$external" - json_add_string "local_domain" "$local" - json_add_string "description" "$desc" - json_close_object - } - config_foreach _add_redirect redirect - - json_close_array - json_dump +server { + listen 443 ssl http2; + server_name ${domain}; + + # SSL certificates + ssl_certificate /etc/acme/${domain}/fullchain.cer; + ssl_certificate_key /etc/acme/${domain}/${domain}.key; + + # SSL settings + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + ssl_prefer_server_ciphers on; +NGINXEOF + fi + + # Add authentication if enabled + if [ "$auth" = "1" ]; then + cat >> "$config_file" << NGINXEOF + + # Basic authentication + auth_basic "Restricted Access"; + auth_basic_user_file /etc/nginx/.htpasswd_${domain}; +NGINXEOF + fi + + # Add proxy configuration + cat >> "$config_file" << NGINXEOF + + location / { + proxy_pass ${backend}; + proxy_http_version 1.1; + + # Proxy headers + proxy_set_header Host \$host; + proxy_set_header X-Real-IP \$remote_addr; + proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto \$scheme; +NGINXEOF + + # Add WebSocket support if enabled + if [ "$websocket" = "1" ]; then + cat >> "$config_file" << NGINXEOF + + # WebSocket support + proxy_set_header Upgrade \$http_upgrade; + proxy_set_header Connection "upgrade"; +NGINXEOF + fi + + cat >> "$config_file" << NGINXEOF + + # Timeouts + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + } + + # Access logs + access_log /var/log/nginx/${domain}_access.log; + error_log /var/log/nginx/${domain}_error.log; +} +NGINXEOF + + echo "$config_file" } -get_certificates() { - json_init - json_add_array "certificates" - - # List certificates from /etc/ssl/acme or similar - for cert in /etc/ssl/acme/*.crt 2>/dev/null; do - [ -f "$cert" ] || continue - local domain=$(basename "$cert" .crt) - local expiry=$(openssl x509 -enddate -noout -in "$cert" 2>/dev/null | cut -d= -f2) - - json_add_object "" - json_add_string "domain" "$domain" - json_add_string "expiry" "$expiry" - json_add_string "path" "$cert" - json_close_object - done - - json_close_array - json_dump -} - -apply_config() { - # Generate nginx configs - # Generate dnsmasq entries - - json_init - json_add_boolean "success" 1 - json_add_string "message" "Configuration applied" - json_dump +# Test backend connectivity +test_backend() { + local backend="$1" + + # Extract host and port from backend URL + local host=$(echo "$backend" | sed 's|https\?://||' | cut -d':' -f1 | cut -d'/' -f1) + local port=$(echo "$backend" | sed 's|https\?://||' | cut -d':' -f2 | cut -d'/' -f1) + + # Default port if not specified + if [ "$port" = "$host" ]; then + if echo "$backend" | grep -q "^https"; then + port=443 + else + port=80 + fi + fi + + # Test connection + if nc -z -w 5 "$host" "$port" 2>/dev/null; then + return 0 + else + return 1 + fi } case "$1" in - list) - echo '{"status":{},"internal_hosts":{},"redirects":{},"certificates":{},"apply_config":{}}' - ;; - call) - case "$2" in - status) get_status ;; - internal_hosts) get_internal_hosts ;; - redirects) get_redirects ;; - certificates) get_certificates ;; - apply_config) apply_config ;; - *) echo '{"error":"Unknown method"}' ;; - esac - ;; + list) + json_init + json_add_object "status" + json_close_object + json_add_object "list_vhosts" + json_close_object + json_add_object "get_vhost" + json_add_string "domain" "string" + json_close_object + json_add_object "add_vhost" + json_add_string "domain" "string" + json_add_string "backend" "string" + json_add_string "ssl" "bool" + json_add_string "auth" "bool" + json_add_string "websocket" "bool" + json_close_object + json_add_object "update_vhost" + json_add_string "domain" "string" + json_add_string "backend" "string" + json_add_string "ssl" "bool" + json_add_string "auth" "bool" + json_add_string "websocket" "bool" + json_close_object + json_add_object "delete_vhost" + json_add_string "domain" "string" + json_close_object + json_add_object "test_backend" + json_add_string "backend" "string" + json_close_object + json_add_object "request_cert" + json_add_string "domain" "string" + json_add_string "email" "string" + json_close_object + json_add_object "list_certs" + json_close_object + json_add_object "reload_nginx" + json_close_object + json_add_object "get_access_logs" + json_add_string "domain" "string" + json_add_string "lines" "int" + json_close_object + json_dump + ;; + + call) + case "$2" in + status) + init_dirs + + json_init + json_add_boolean "enabled" 1 + json_add_string "module" "vhost-manager" + json_add_string "version" "1.0.0" + + # Check nginx status + if pgrep -x nginx > /dev/null 2>&1; then + json_add_boolean "nginx_running" 1 + + # Get nginx version + local nginx_version=$(nginx -v 2>&1 | grep -o 'nginx/[0-9.]*' | cut -d'/' -f2) + json_add_string "nginx_version" "$nginx_version" + else + json_add_boolean "nginx_running" 0 + json_add_string "nginx_version" "unknown" + fi + + # Check acme.sh availability + if command -v acme.sh > /dev/null 2>&1; then + json_add_boolean "acme_available" 1 + local acme_version=$(acme.sh --version 2>/dev/null | head -1) + json_add_string "acme_version" "$acme_version" + else + json_add_boolean "acme_available" 0 + json_add_string "acme_version" "not installed" + fi + + # Count vhosts + local vhost_count=0 + if [ -d "$NGINX_VHOST_DIR" ]; then + vhost_count=$(find "$NGINX_VHOST_DIR" -name "*.conf" -type f 2>/dev/null | wc -l) + fi + json_add_int "vhost_count" "$vhost_count" + + json_dump + ;; + + list_vhosts) + init_dirs + + json_init + json_add_array "vhosts" + + if [ -d "$NGINX_VHOST_DIR" ]; then + find "$NGINX_VHOST_DIR" -name "*.conf" -type f 2>/dev/null | while read -r conf_file; do + local domain=$(basename "$conf_file" .conf) + + # Parse config to extract info + local ssl=0 + local auth=0 + local websocket=0 + local backend="unknown" + + if grep -q "listen 443 ssl" "$conf_file"; then + ssl=1 + fi + + if grep -q "auth_basic" "$conf_file"; then + auth=1 + fi + + if grep -q "Upgrade.*http_upgrade" "$conf_file"; then + websocket=1 + fi + + backend=$(grep "proxy_pass" "$conf_file" | head -1 | sed 's/.*proxy_pass\s*\([^;]*\);.*/\1/') + + # Check SSL cert expiry + local ssl_expires="N/A" + if [ "$ssl" = "1" ] && [ -f "/etc/acme/${domain}/fullchain.cer" ]; then + ssl_expires=$(openssl x509 -in "/etc/acme/${domain}/fullchain.cer" -noout -enddate 2>/dev/null | cut -d'=' -f2) + fi + + json_add_object + json_add_string "domain" "$domain" + json_add_string "backend" "$backend" + json_add_boolean "ssl" "$ssl" + json_add_boolean "auth" "$auth" + json_add_boolean "websocket" "$websocket" + json_add_string "ssl_expires" "$ssl_expires" + json_add_string "config_file" "$conf_file" + json_close_object + done + fi + + json_close_array + json_dump + ;; + + get_vhost) + read -r input + json_load "$input" + json_get_var domain domain + + local config_file="${NGINX_VHOST_DIR}/${domain}.conf" + + json_init + json_add_string "domain" "$domain" + + if [ -f "$config_file" ]; then + json_add_boolean "exists" 1 + + # Parse configuration + local ssl=0 + local auth=0 + local websocket=0 + local backend="unknown" + + if grep -q "listen 443 ssl" "$config_file"; then + ssl=1 + fi + + if grep -q "auth_basic" "$config_file"; then + auth=1 + fi + + if grep -q "Upgrade.*http_upgrade" "$config_file"; then + websocket=1 + fi + + backend=$(grep "proxy_pass" "$config_file" | head -1 | sed 's/.*proxy_pass\s*\([^;]*\);.*/\1/') + + json_add_string "backend" "$backend" + json_add_boolean "ssl" "$ssl" + json_add_boolean "auth" "$auth" + json_add_boolean "websocket" "$websocket" + json_add_string "config_file" "$config_file" + + # SSL certificate info + if [ "$ssl" = "1" ] && [ -f "/etc/acme/${domain}/fullchain.cer" ]; then + local ssl_expires=$(openssl x509 -in "/etc/acme/${domain}/fullchain.cer" -noout -enddate 2>/dev/null | cut -d'=' -f2) + local ssl_issuer=$(openssl x509 -in "/etc/acme/${domain}/fullchain.cer" -noout -issuer 2>/dev/null | cut -d'=' -f2-) + + json_add_string "ssl_expires" "$ssl_expires" + json_add_string "ssl_issuer" "$ssl_issuer" + fi + else + json_add_boolean "exists" 0 + fi + + json_dump + ;; + + add_vhost) + read -r input + json_load "$input" + json_get_var domain domain + json_get_var backend backend + json_get_var ssl ssl + json_get_var auth auth + json_get_var websocket websocket + + init_dirs + + # Validate domain + if [ -z "$domain" ] || [ -z "$backend" ]; then + json_init + json_add_boolean "success" 0 + json_add_string "message" "Domain and backend are required" + json_dump + exit 0 + fi + + # Check if vhost already exists + if [ -f "${NGINX_VHOST_DIR}/${domain}.conf" ]; then + json_init + json_add_boolean "success" 0 + json_add_string "message" "VHost already exists for domain: $domain" + json_dump + exit 0 + fi + + # Generate nginx config + local config_file=$(generate_vhost_config "$domain" "$backend" "$ssl" "$auth" "$websocket") + + # Test nginx config + if nginx -t 2>&1 | grep -q "successful"; then + json_init + json_add_boolean "success" 1 + json_add_string "message" "VHost created successfully" + json_add_string "domain" "$domain" + json_add_string "config_file" "$config_file" + json_add_boolean "reload_required" 1 + json_dump + else + # Remove invalid config + rm -f "$config_file" + + json_init + json_add_boolean "success" 0 + json_add_string "message" "Invalid nginx configuration" + json_dump + fi + ;; + + update_vhost) + read -r input + json_load "$input" + json_get_var domain domain + json_get_var backend backend + json_get_var ssl ssl + json_get_var auth auth + json_get_var websocket websocket + + # Check if vhost exists + if [ ! -f "${NGINX_VHOST_DIR}/${domain}.conf" ]; then + json_init + json_add_boolean "success" 0 + json_add_string "message" "VHost not found: $domain" + json_dump + exit 0 + fi + + # Backup old config + cp "${NGINX_VHOST_DIR}/${domain}.conf" "${NGINX_VHOST_DIR}/${domain}.conf.bak" + + # Generate new config + local config_file=$(generate_vhost_config "$domain" "$backend" "$ssl" "$auth" "$websocket") + + # Test nginx config + if nginx -t 2>&1 | grep -q "successful"; then + rm -f "${NGINX_VHOST_DIR}/${domain}.conf.bak" + + json_init + json_add_boolean "success" 1 + json_add_string "message" "VHost updated successfully" + json_add_boolean "reload_required" 1 + json_dump + else + # Restore backup + mv "${NGINX_VHOST_DIR}/${domain}.conf.bak" "${NGINX_VHOST_DIR}/${domain}.conf" + + json_init + json_add_boolean "success" 0 + json_add_string "message" "Invalid configuration, changes reverted" + json_dump + fi + ;; + + delete_vhost) + read -r input + json_load "$input" + json_get_var domain domain + + local config_file="${NGINX_VHOST_DIR}/${domain}.conf" + + if [ -f "$config_file" ]; then + rm -f "$config_file" + + json_init + json_add_boolean "success" 1 + json_add_string "message" "VHost deleted: $domain" + json_add_boolean "reload_required" 1 + json_dump + else + json_init + json_add_boolean "success" 0 + json_add_string "message" "VHost not found: $domain" + json_dump + fi + ;; + + test_backend) + read -r input + json_load "$input" + json_get_var backend backend + + json_init + json_add_string "backend" "$backend" + + if test_backend "$backend"; then + json_add_boolean "reachable" 1 + json_add_string "status" "Backend is reachable" + else + json_add_boolean "reachable" 0 + json_add_string "status" "Backend is unreachable" + fi + + json_dump + ;; + + request_cert) + read -r input + json_load "$input" + json_get_var domain domain + json_get_var email email + + json_init + + if ! command -v acme.sh > /dev/null 2>&1; then + json_add_boolean "success" 0 + json_add_string "message" "acme.sh not installed" + json_dump + exit 0 + fi + + # Request certificate using acme.sh + if acme.sh --issue -d "$domain" --standalone --force 2>&1 | grep -q "success"; then + json_add_boolean "success" 1 + json_add_string "message" "Certificate requested successfully" + json_add_string "domain" "$domain" + else + json_add_boolean "success" 0 + json_add_string "message" "Certificate request failed" + fi + + json_dump + ;; + + list_certs) + json_init + json_add_array "certificates" + + if [ -d "$ACME_STATE_DIR" ]; then + find "$ACME_STATE_DIR" -name "fullchain.cer" -type f 2>/dev/null | while read -r cert_file; do + local domain=$(basename $(dirname "$cert_file")) + local expires=$(openssl x509 -in "$cert_file" -noout -enddate 2>/dev/null | cut -d'=' -f2) + local issuer=$(openssl x509 -in "$cert_file" -noout -issuer 2>/dev/null | cut -d'=' -f2-) + local subject=$(openssl x509 -in "$cert_file" -noout -subject 2>/dev/null | cut -d'=' -f2-) + + json_add_object + json_add_string "domain" "$domain" + json_add_string "expires" "$expires" + json_add_string "issuer" "$issuer" + json_add_string "subject" "$subject" + json_add_string "cert_file" "$cert_file" + json_close_object + done + fi + + json_close_array + json_dump + ;; + + reload_nginx) + json_init + + # Test configuration first + if nginx -t 2>&1 | grep -q "successful"; then + # Reload nginx + if /etc/init.d/nginx reload 2>&1; then + json_add_boolean "success" 1 + json_add_string "message" "Nginx reloaded successfully" + else + json_add_boolean "success" 0 + json_add_string "message" "Failed to reload nginx" + fi + else + json_add_boolean "success" 0 + json_add_string "message" "Invalid nginx configuration" + fi + + json_dump + ;; + + get_access_logs) + read -r input + json_load "$input" + json_get_var domain domain + json_get_var lines lines + + lines=${lines:-50} + + local log_file="/var/log/nginx/${domain}_access.log" + + json_init + json_add_string "domain" "$domain" + json_add_array "logs" + + if [ -f "$log_file" ]; then + tail -n "$lines" "$log_file" | while read -r log_line; do + json_add_string "" "$log_line" + done + fi + + json_close_array + json_dump + ;; + + *) + json_init + json_add_int "error" -32601 + json_add_string "message" "Method not found: $2" + json_dump + ;; + esac + ;; esac diff --git a/luci-app-vhost-manager/root/usr/share/luci/menu.d/luci-app-vhost-manager.json b/luci-app-vhost-manager/root/usr/share/luci/menu.d/luci-app-vhost-manager.json index bac7d665..b8fd6882 100644 --- a/luci-app-vhost-manager/root/usr/share/luci/menu.d/luci-app-vhost-manager.json +++ b/luci-app-vhost-manager/root/usr/share/luci/menu.d/luci-app-vhost-manager.json @@ -1,27 +1,44 @@ { - "admin/services/vhost": { - "title": "VHost Manager", - "order": 75, - "action": {"type": "firstchild"} - }, - "admin/services/vhost/overview": { - "title": "Overview", - "order": 10, - "action": {"type": "view", "path": "vhost-manager/overview"} - }, - "admin/services/vhost/internal": { - "title": "Internal Hosts", - "order": 20, - "action": {"type": "view", "path": "vhost-manager/internal"} - }, - "admin/services/vhost/redirects": { - "title": "External Redirects", - "order": 30, - "action": {"type": "view", "path": "vhost-manager/redirects"} - }, - "admin/services/vhost/ssl": { - "title": "SSL Certificates", - "order": 40, - "action": {"type": "view", "path": "vhost-manager/ssl"} - } + "admin/secubox/services/vhosts": { + "title": "VHost Manager", + "order": 40, + "action": { + "type": "firstchild" + }, + "depends": { + "acl": ["luci-app-vhost-manager"] + } + }, + "admin/secubox/services/vhosts/overview": { + "title": "Overview", + "order": 10, + "action": { + "type": "view", + "path": "vhost-manager/overview" + } + }, + "admin/secubox/services/vhosts/vhosts": { + "title": "Virtual Hosts", + "order": 20, + "action": { + "type": "view", + "path": "vhost-manager/vhosts" + } + }, + "admin/secubox/services/vhosts/certificates": { + "title": "SSL Certificates", + "order": 30, + "action": { + "type": "view", + "path": "vhost-manager/certificates" + } + }, + "admin/secubox/services/vhosts/logs": { + "title": "Access Logs", + "order": 40, + "action": { + "type": "view", + "path": "vhost-manager/logs" + } + } } diff --git a/luci-app-vhost-manager/root/usr/share/rpcd/acl.d/luci-app-vhost-manager.json b/luci-app-vhost-manager/root/usr/share/rpcd/acl.d/luci-app-vhost-manager.json index aada7560..01dbb993 100644 --- a/luci-app-vhost-manager/root/usr/share/rpcd/acl.d/luci-app-vhost-manager.json +++ b/luci-app-vhost-manager/root/usr/share/rpcd/acl.d/luci-app-vhost-manager.json @@ -1,17 +1,39 @@ { - "luci-app-vhost-manager": { - "description": "VHost Manager", - "read": { - "ubus": { - "luci.vhost-manager": ["status", "internal_hosts", "redirects", "certificates"] - }, - "uci": ["vhost"] - }, - "write": { - "ubus": { - "luci.vhost-manager": ["apply_config"] - }, - "uci": ["vhost"] - } - } + "luci-app-vhost-manager": { + "description": "Grant access to LuCI VHost Manager", + "read": { + "ubus": { + "luci.vhost-manager": [ + "status", + "list_vhosts", + "get_vhost", + "test_backend", + "list_certs", + "get_access_logs" + ] + }, + "uci": ["vhost_manager", "nginx"], + "file": { + "/etc/nginx/conf.d/*": ["read"], + "/etc/acme/*": ["read"], + "/var/log/nginx/*": ["read"] + } + }, + "write": { + "ubus": { + "luci.vhost-manager": [ + "add_vhost", + "update_vhost", + "delete_vhost", + "request_cert", + "reload_nginx" + ] + }, + "uci": ["vhost_manager", "nginx"], + "file": { + "/etc/nginx/conf.d/*": ["write"], + "/etc/nginx/.htpasswd_*": ["write"] + } + } + } }