diff --git a/.claude/HISTORY.md b/.claude/HISTORY.md index dd1e29c7..dcc632b2 100644 --- a/.claude/HISTORY.md +++ b/.claude/HISTORY.md @@ -1,6 +1,6 @@ # SecuBox UI & Theme History -_Last updated: 2026-02-15_ +_Last updated: 2026-02-16_ 1. **Unified Dashboard Refresh (2025-12-20)** - Dashboard received the "sh-page-header" layout, hero stats, and SecuNav top tabs. @@ -1934,3 +1934,86 @@ git checkout HEAD -- index.html - `luci-app-mailserver/htdocs/.../overview.js` (rewritten) - `luci-app-mailserver/root/usr/share/rpcd/acl.d/luci-app-mailserver.json` - `luci-app-secubox-portal/htdocs/.../kiss-theme.js` (nav update) + +### 2026-02-16: DNS Master LuCI App + +**New Package: secubox-app-dns-master** +- BIND DNS zone management CLI tool (`dnsmaster`) +- Commands: status, zone-list, zone-show, zone-add, records-json, record-add, record-del, reload, check, logs, backup +- JSON output support for LuCI integration +- Auto serial bump on zone modifications +- Zone validation via `named-checkzone` +- UCI config: `/etc/config/dns-master` + +**New Package: luci-app-dns-master** +- KISS-themed dashboard with: + - 4-column stats grid (Status, Zones, Records, TTL) + - Control buttons (Reload BIND, Check Zones, Backup All, Add Zone) + - Interactive zones table with Edit/Check/Backup actions + - Inline records editor with type-colored badges + - Add Zone modal for creating new DNS zones + - Add Record modal with type dropdown (A, AAAA, MX, TXT, CNAME, SRV, NS, PTR) + - Delete record with confirmation + - Live polling with 10s refresh +- RPCD backend: 10 methods (status, zones, records, add_record, del_record, add_zone, reload, check, logs, backup) +- Added DNS Master to KISS theme Network category + +**Files Created:** +- `secubox-app-dns-master/Makefile` +- `secubox-app-dns-master/files/etc/config/dns-master` +- `secubox-app-dns-master/files/usr/sbin/dnsmaster` +- `luci-app-dns-master/Makefile` +- `luci-app-dns-master/root/usr/libexec/rpcd/luci.dns-master` +- `luci-app-dns-master/root/usr/share/luci/menu.d/luci-app-dns-master.json` +- `luci-app-dns-master/root/usr/share/rpcd/acl.d/luci-app-dns-master.json` +- `luci-app-dns-master/htdocs/luci-static/resources/view/dns-master/overview.js` + + +### 2026-02-16: HexoCMS Multi-Instance Enhancement + +**Backend Enhancement: secubox-app-hexojs** +- Added backup/restore commands: + - `hexoctl backup [instance] [name]` - Create full backup + - `hexoctl backup list` - List all backups with size/timestamp + - `hexoctl backup delete ` - Delete backup + - `hexoctl restore [instance]` - Restore from backup +- Added GitHub clone support: + - `hexoctl github clone [instance] [branch]` - Clone from GitHub + - Supports full Hexo sites with auto npm install +- Added Gitea push support: + - `hexoctl gitea push [instance] [message]` - Push changes to Gitea +- Added quick-publish command: + - `hexoctl quick-publish [instance]` - Clean + build + publish in one step +- Added JSON status commands: + - `hexoctl status-json` - Full container and instance status + - `hexoctl instance-list-json` - Instance list for RPCD + +**RPCD Enhancement: luci.hexojs** +- Added 15 new methods: + - Instance management: `list_instances`, `create_instance`, `delete_instance`, `start_instance`, `stop_instance` + - Backup/restore: `list_backups`, `create_backup`, `restore_backup`, `delete_backup` + - Git integration: `github_clone`, `gitea_push`, `quick_publish` +- Updated ACL with new permissions (read + write) + +**Frontend Enhancement: luci-app-hexojs** +- Rewrote `overview.js` with KISS theme: + - 4-column stats grid (Instances, Posts, Drafts, Backups) + - Quick actions bar: New Instance, Clone from GitHub/Gitea, New Post, Settings + - Instance cards with status indicators: + - Controls: Start/Stop, Quick Publish, Backup, Editor, Preview, Delete + - Port and domain display + - Running status badge + - Backup table with restore/delete actions + - Create Instance modal (name, title, port) + - Delete Instance modal with data deletion option + - GitHub/Gitea clone modal (repo URL, instance, branch) + - Gitea push modal (commit message) + - Quick Publish modal with progress +- Updated API with 12 new RPC declarations + +**Files Modified:** +- `secubox-app-hexojs/files/usr/sbin/hexoctl` (new commands) +- `luci-app-hexojs/root/usr/libexec/rpcd/luci.hexojs` (new methods) +- `luci-app-hexojs/htdocs/luci-static/resources/hexojs/api.js` (new declarations) +- `luci-app-hexojs/htdocs/luci-static/resources/view/hexojs/overview.js` (KISS rewrite) +- `luci-app-hexojs/root/usr/share/rpcd/acl.d/luci-app-hexojs.json` (new permissions) diff --git a/.claude/WIP.md b/.claude/WIP.md index fe7fa11e..579cfbc7 100644 --- a/.claude/WIP.md +++ b/.claude/WIP.md @@ -1,6 +1,6 @@ # Work In Progress (Claude) -_Last updated: 2026-02-16 (Nextcloud SSL, WAF rules, Mail autoconfig)_ +_Last updated: 2026-02-16 (DNS Master app, Mailserver KISS)_ > **Architecture Reference**: SecuBox Fanzine v3 — Les 4 Couches @@ -64,6 +64,41 @@ _Last updated: 2026-02-16 (Nextcloud SSL, WAF rules, Mail autoconfig)_ ### Just Completed (2026-02-16) +- **HexoCMS Multi-Instance Enhancement** — DONE (2026-02-16) + - Added backup/restore commands to hexoctl + - Added GitHub clone support (`hexoctl github clone [instance] [branch]`) + - Added Gitea push support (`hexoctl gitea push [instance] [message]`) + - Added quick-publish command (clean + build + publish) + - Added status-json and instance-list-json for RPCD + - Enhanced RPCD handler with 15 new methods: + - Instance: list_instances, create_instance, delete_instance, start_instance, stop_instance + - Backup: list_backups, create_backup, restore_backup, delete_backup + - Git: github_clone, gitea_push, quick_publish + - Rewrote LuCI dashboard with KISS theme: + - Multi-instance management with cards + - Instance controls: start/stop, quick publish, backup, editor, preview + - GitHub/Gitea clone modals + - Backup table with restore/delete + - Stats grid: instances, posts, drafts, backups + - Quick actions: new instance, clone from GitHub/Gitea, new post, settings + - Updated API with 12 new RPC declarations + - Updated ACL with new permissions + +- **DNS Master LuCI App** — DONE (2026-02-16) + - Created `secubox-app-dns-master` with `dnsmaster` CLI + - Commands: status, zone-list, zone-add, records-json, record-add/del, reload, check, backup + - Created `luci-app-dns-master` with KISS dashboard + - Zones table with Edit/Check/Backup, Records editor with type badges + - Add Zone/Record modals, live polling, auto serial bump + - Added to KISS nav Network category + +- **Mailserver LuCI KISS Regeneration** — DONE (2026-02-16) + - Complete rewrite of overview.js with KISS theme + - Fixed IMAP hairpin NAT issue (hosts override in Nextcloud container) + - Fixed port 143 detection in RPCD script + - Stats grid, port cards, users/aliases tables, webmail card + - Added to KISS nav Apps category + - **Nextcloud LXC Production Deploy** — DONE (2026-02-16) - Installed on c3box with Debian 12 LXC - Fixed nginx port conflict (80→8080) with HAProxy diff --git a/package/secubox/luci-app-dns-master/Makefile b/package/secubox/luci-app-dns-master/Makefile new file mode 100644 index 00000000..40d32e9a --- /dev/null +++ b/package/secubox/luci-app-dns-master/Makefile @@ -0,0 +1,29 @@ +include $(TOPDIR)/rules.mk + +PKG_NAME:=luci-app-dns-master +PKG_VERSION:=1.0.0 +PKG_RELEASE:=1 + +PKG_MAINTAINER:=SecuBox Team +PKG_LICENSE:=MIT + +LUCI_TITLE:=LuCI DNS Master (BIND Zone Management) +LUCI_DEPENDS:=+secubox-app-dns-master +luci-base +luci-lib-secubox + +include $(TOPDIR)/feeds/luci/luci.mk + +define Package/$(PKG_NAME)/install + $(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)/usr/libexec/rpcd + $(INSTALL_BIN) ./root/usr/libexec/rpcd/luci.dns-master $(1)/usr/libexec/rpcd/ + + $(INSTALL_DIR) $(1)/www/luci-static/resources/view/dns-master + $(INSTALL_DATA) ./htdocs/luci-static/resources/view/dns-master/*.js $(1)/www/luci-static/resources/view/dns-master/ +endef + +$(eval $(call BuildPackage,$(PKG_NAME))) diff --git a/package/secubox/luci-app-dns-master/htdocs/luci-static/resources/view/dns-master/overview.js b/package/secubox/luci-app-dns-master/htdocs/luci-static/resources/view/dns-master/overview.js new file mode 100644 index 00000000..668bdfbe --- /dev/null +++ b/package/secubox/luci-app-dns-master/htdocs/luci-static/resources/view/dns-master/overview.js @@ -0,0 +1,644 @@ +'use strict'; +'require view'; +'require dom'; +'require poll'; +'require rpc'; +'require ui'; + +var callStatus = rpc.declare({ + object: 'luci.dns-master', + method: 'status', + expect: {} +}); + +var callZones = rpc.declare({ + object: 'luci.dns-master', + method: 'zones', + expect: {} +}); + +var callRecords = rpc.declare({ + object: 'luci.dns-master', + method: 'records', + params: ['zone'], + expect: {} +}); + +var callAddRecord = rpc.declare({ + object: 'luci.dns-master', + method: 'add_record', + params: ['zone', 'type', 'name', 'value', 'ttl'], + expect: {} +}); + +var callDelRecord = rpc.declare({ + object: 'luci.dns-master', + method: 'del_record', + params: ['zone', 'type', 'name', 'value'], + expect: {} +}); + +var callAddZone = rpc.declare({ + object: 'luci.dns-master', + method: 'add_zone', + params: ['name'], + expect: {} +}); + +var callReload = rpc.declare({ + object: 'luci.dns-master', + method: 'reload', + expect: {} +}); + +var callCheck = rpc.declare({ + object: 'luci.dns-master', + method: 'check', + params: ['zone'], + expect: {} +}); + +var callBackup = rpc.declare({ + object: 'luci.dns-master', + method: 'backup', + params: ['zone'], + expect: {} +}); + +// State +var currentZone = null; +var currentRecords = []; +var allZones = []; +var typeFilter = 'ALL'; + +// Record type definitions +var RECORD_TYPES = { + 'A': { color: '#3fb950', label: 'IPv4 Address', placeholder: '192.168.1.1' }, + 'AAAA': { color: '#58a6ff', label: 'IPv6 Address', placeholder: '2001:db8::1' }, + 'CNAME': { color: '#39c5cf', label: 'Canonical Name', placeholder: 'target.example.com.' }, + 'MX': { color: '#a371f7', label: 'Mail Exchange', placeholder: '10 mail.example.com.' }, + 'TXT': { color: '#d29922', label: 'Text Record', placeholder: '"v=spf1 mx ~all"' }, + 'SRV': { color: '#db6d28', label: 'Service', placeholder: '0 0 443 target.example.com.' }, + 'NS': { color: '#8b949e', label: 'Nameserver', placeholder: 'ns1.example.com.' }, + 'PTR': { color: '#8b949e', label: 'Pointer', placeholder: 'host.example.com.' }, + 'CAA': { color: '#f85149', label: 'Cert Authority', placeholder: '0 issue "letsencrypt.org"' } +}; + +return view.extend({ + load: function() { + return Promise.all([ + callStatus(), + callZones() + ]); + }, + + css: function() { + return ` +/* KISS DNS Master */ +:root { + --k-bg: #0d1117; --k-surface: #161b22; --k-card: #1c2128; + --k-line: #30363d; --k-text: #e6edf3; --k-muted: #8b949e; + --k-green: #3fb950; --k-red: #f85149; --k-blue: #58a6ff; + --k-cyan: #39c5cf; --k-purple: #a371f7; --k-yellow: #d29922; + --k-orange: #db6d28; +} +.dns-wrap { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; color: var(--k-text); } +.dns-header { margin-bottom: 24px; } +.dns-header h2 { margin: 0 0 4px 0; font-size: 26px; font-weight: 700; } +.dns-header p { color: var(--k-muted); margin: 0; font-size: 14px; } + +/* Stats Grid */ +.dns-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 12px; margin-bottom: 20px; } +.dns-stat { background: var(--k-surface); border: 1px solid var(--k-line); border-radius: 10px; padding: 16px; text-align: center; } +.dns-stat-val { font-size: 28px; font-weight: 700; line-height: 1.2; } +.dns-stat-lbl { font-size: 11px; color: var(--k-muted); text-transform: uppercase; letter-spacing: 0.5px; margin-top: 4px; } + +/* Cards */ +.dns-card { background: var(--k-surface); border: 1px solid var(--k-line); border-radius: 10px; padding: 16px; margin-bottom: 16px; } +.dns-card-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; } +.dns-card-title { font-size: 13px; font-weight: 600; color: var(--k-muted); text-transform: uppercase; letter-spacing: 0.5px; display: flex; align-items: center; gap: 8px; } +.dns-card-actions { display: flex; gap: 8px; flex-wrap: wrap; } + +/* Buttons */ +.dns-btn { display: inline-flex; align-items: center; gap: 6px; padding: 7px 14px; border-radius: 6px; border: 1px solid var(--k-line); background: var(--k-card); color: var(--k-text); font-size: 12px; font-weight: 500; cursor: pointer; transition: all 0.15s; } +.dns-btn:hover { background: var(--k-line); border-color: var(--k-muted); } +.dns-btn-sm { padding: 4px 10px; font-size: 11px; } +.dns-btn-green { background: var(--k-green); border-color: var(--k-green); color: #000; } +.dns-btn-green:hover { background: #2ea043; } +.dns-btn-blue { background: var(--k-blue); border-color: var(--k-blue); color: #000; } +.dns-btn-blue:hover { background: #4090e0; } +.dns-btn-red { background: transparent; border-color: var(--k-red); color: var(--k-red); } +.dns-btn-red:hover { background: rgba(248,81,73,0.15); } +.dns-btn-icon { padding: 5px 8px; } + +/* Table */ +.dns-table { width: 100%; border-collapse: collapse; font-size: 13px; } +.dns-table th { text-align: left; padding: 10px 12px; font-size: 10px; font-weight: 600; color: var(--k-muted); text-transform: uppercase; border-bottom: 1px solid var(--k-line); } +.dns-table td { padding: 10px 12px; border-bottom: 1px solid var(--k-line); vertical-align: middle; } +.dns-table tr:hover td { background: rgba(255,255,255,0.02); } +.dns-table .mono { font-family: 'SF Mono', Monaco, monospace; font-size: 12px; } +.dns-table .truncate { max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.dns-table .actions { display: flex; gap: 6px; justify-content: flex-end; } + +/* Badges */ +.dns-badge { display: inline-block; padding: 3px 8px; border-radius: 4px; font-size: 10px; font-weight: 600; letter-spacing: 0.3px; } +.dns-badge-ok { background: rgba(63,185,80,0.15); color: var(--k-green); } +.dns-badge-err { background: rgba(248,81,73,0.15); color: var(--k-red); } + +/* Type badge */ +.dns-type { display: inline-block; padding: 2px 6px; border-radius: 3px; font-size: 10px; font-weight: 700; font-family: monospace; } + +/* Filter bar */ +.dns-filter { display: flex; gap: 12px; align-items: center; margin-bottom: 14px; flex-wrap: wrap; } +.dns-filter-group { display: flex; gap: 4px; } +.dns-filter-btn { padding: 5px 10px; border-radius: 4px; border: 1px solid var(--k-line); background: transparent; color: var(--k-muted); font-size: 11px; cursor: pointer; transition: all 0.15s; } +.dns-filter-btn:hover { color: var(--k-text); border-color: var(--k-muted); } +.dns-filter-btn.active { background: var(--k-blue); border-color: var(--k-blue); color: #000; } +.dns-search { flex: 1; min-width: 150px; max-width: 300px; } +.dns-search input { width: 100%; padding: 6px 10px; border-radius: 5px; border: 1px solid var(--k-line); background: var(--k-bg); color: var(--k-text); font-size: 12px; } +.dns-search input:focus { outline: none; border-color: var(--k-blue); } +.dns-search input::placeholder { color: var(--k-muted); } + +/* Zone selector */ +.dns-zone-select { display: flex; gap: 8px; align-items: center; } +.dns-zone-select select { padding: 6px 10px; border-radius: 5px; border: 1px solid var(--k-line); background: var(--k-bg); color: var(--k-text); font-size: 12px; min-width: 180px; } +.dns-zone-select select:focus { outline: none; border-color: var(--k-blue); } + +/* Modal */ +.dns-modal-bg { position: fixed; inset: 0; background: rgba(0,0,0,0.75); display: flex; align-items: center; justify-content: center; z-index: 9999; } +.dns-modal { background: var(--k-surface); border: 1px solid var(--k-line); border-radius: 12px; padding: 20px; width: 420px; max-width: 95vw; max-height: 90vh; overflow-y: auto; } +.dns-modal-title { font-size: 16px; font-weight: 600; margin-bottom: 16px; display: flex; align-items: center; gap: 8px; } +.dns-modal-close { margin-left: auto; background: none; border: none; color: var(--k-muted); font-size: 18px; cursor: pointer; padding: 4px; } +.dns-modal-close:hover { color: var(--k-text); } + +/* Form */ +.dns-form-row { margin-bottom: 14px; } +.dns-form-label { display: block; font-size: 11px; color: var(--k-muted); margin-bottom: 5px; text-transform: uppercase; } +.dns-form-input { width: 100%; padding: 8px 10px; border-radius: 5px; border: 1px solid var(--k-line); background: var(--k-bg); color: var(--k-text); font-size: 13px; box-sizing: border-box; } +.dns-form-input:focus { outline: none; border-color: var(--k-blue); } +.dns-form-hint { font-size: 11px; color: var(--k-muted); margin-top: 4px; } +.dns-form-row-inline { display: flex; gap: 8px; align-items: center; } +.dns-form-suffix { color: var(--k-muted); font-size: 12px; white-space: nowrap; } +.dns-form-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--k-line); } + +/* Empty state */ +.dns-empty { text-align: center; padding: 40px 20px; color: var(--k-muted); } +.dns-empty-icon { font-size: 40px; margin-bottom: 12px; opacity: 0.5; } +.dns-empty-text { font-size: 13px; } + +/* Two column layout */ +.dns-cols { display: grid; grid-template-columns: 1fr 2fr; gap: 16px; } +@media (max-width: 900px) { .dns-cols { grid-template-columns: 1fr; } } +`; + }, + + statCard: function(label, value, color, id) { + return E('div', { 'class': 'dns-stat' }, [ + E('div', { 'class': 'dns-stat-val', 'style': 'color:' + color, 'data-stat': id }, String(value)), + E('div', { 'class': 'dns-stat-lbl' }, label) + ]); + }, + + render: function(data) { + var self = this; + var status = data[0] || {}; + var zonesData = data[1] || {}; + allZones = zonesData.zones || []; + + poll.add(function() { + return Promise.all([callStatus(), callZones()]).then(function(d) { + self.updateStats(d[0]); + allZones = (d[1] || {}).zones || []; + self.updateZonesTable(); + }); + }, 15); + + var isRunning = status.running === true; + + return E('div', { 'class': 'dns-wrap' }, [ + E('style', {}, this.css()), + + // Header + E('div', { 'class': 'dns-header' }, [ + E('h2', {}, '🌐 DNS Master'), + E('p', {}, 'BIND DNS Zone Management') + ]), + + // Stats + E('div', { 'class': 'dns-stats' }, [ + this.statCard('Status', isRunning ? 'Running' : 'Stopped', isRunning ? 'var(--k-green)' : 'var(--k-red)', 'status'), + this.statCard('Zones', status.zones || 0, 'var(--k-blue)', 'zones'), + this.statCard('Records', status.records || 0, 'var(--k-cyan)', 'records'), + this.statCard('TTL', (status.default_ttl || 300) + 's', 'var(--k-purple)', 'ttl') + ]), + + // Two column layout + E('div', { 'class': 'dns-cols' }, [ + // Left: Zones + E('div', { 'class': 'dns-card' }, [ + E('div', { 'class': 'dns-card-head' }, [ + E('div', { 'class': 'dns-card-title' }, '📁 Zones'), + E('button', { 'class': 'dns-btn dns-btn-green dns-btn-sm', 'click': L.bind(this.showAddZoneModal, this) }, '+ Add') + ]), + E('div', { 'id': 'zones-list' }, this.renderZonesList(allZones)) + ]), + + // Right: Records + E('div', { 'class': 'dns-card' }, [ + E('div', { 'class': 'dns-card-head' }, [ + E('div', { 'class': 'dns-card-title' }, [ + E('span', {}, '📝 Records'), + E('span', { 'id': 'current-zone-label', 'style': 'color: var(--k-blue); font-weight: 400;' }, '') + ]), + E('div', { 'class': 'dns-card-actions' }, [ + E('button', { 'class': 'dns-btn dns-btn-sm', 'click': L.bind(this.handleReload, this) }, '🔄 Reload'), + E('button', { 'class': 'dns-btn dns-btn-green dns-btn-sm', 'id': 'add-record-btn', 'style': 'display:none;', 'click': L.bind(this.showRecordModal, this, null) }, '+ Add') + ]) + ]), + E('div', { 'id': 'records-filter', 'style': 'display:none;' }, this.renderFilter()), + E('div', { 'id': 'records-container' }, this.renderEmptyRecords()) + ]) + ]) + ]); + }, + + renderZonesList: function(zones) { + var self = this; + if (!zones || zones.length === 0) { + return E('div', { 'class': 'dns-empty' }, [ + E('div', { 'class': 'dns-empty-icon' }, '📂'), + E('div', { 'class': 'dns-empty-text' }, 'No zones configured') + ]); + } + + return E('div', {}, zones.map(function(zone) { + var isActive = currentZone === zone.name; + return E('div', { + 'style': 'display: flex; align-items: center; padding: 10px; border-radius: 6px; margin-bottom: 6px; cursor: pointer; border: 1px solid ' + (isActive ? 'var(--k-blue)' : 'transparent') + '; background: ' + (isActive ? 'rgba(88,166,255,0.1)' : 'var(--k-card)') + ';', + 'click': L.bind(self.selectZone, self, zone.name) + }, [ + E('div', { 'style': 'flex: 1; min-width: 0;' }, [ + E('div', { 'style': 'font-weight: 600; font-size: 13px; overflow: hidden; text-overflow: ellipsis;' }, zone.name), + E('div', { 'style': 'font-size: 11px; color: var(--k-muted); margin-top: 2px;' }, zone.records + ' records') + ]), + E('div', { 'style': 'display: flex; align-items: center; gap: 6px;' }, [ + zone.valid ? + E('span', { 'class': 'dns-badge dns-badge-ok' }, '✓') : + E('span', { 'class': 'dns-badge dns-badge-err' }, '✗'), + E('button', { + 'class': 'dns-btn dns-btn-icon dns-btn-sm', + 'title': 'Backup', + 'click': function(ev) { ev.stopPropagation(); self.handleBackupZone(zone.name); } + }, '💾') + ]) + ]); + })); + }, + + renderEmptyRecords: function() { + return E('div', { 'class': 'dns-empty' }, [ + E('div', { 'class': 'dns-empty-icon' }, '👈'), + E('div', { 'class': 'dns-empty-text' }, 'Select a zone to view records') + ]); + }, + + renderFilter: function() { + var self = this; + var types = ['ALL'].concat(Object.keys(RECORD_TYPES)); + + return E('div', { 'class': 'dns-filter' }, [ + E('div', { 'class': 'dns-filter-group' }, types.map(function(t) { + return E('button', { + 'class': 'dns-filter-btn' + (typeFilter === t ? ' active' : ''), + 'data-type': t, + 'click': function() { self.setTypeFilter(t); } + }, t); + })), + E('div', { 'class': 'dns-search' }, [ + E('input', { + 'type': 'text', + 'placeholder': '🔍 Search records...', + 'id': 'record-search', + 'input': L.bind(this.filterRecords, this) + }) + ]) + ]); + }, + + renderRecordsTable: function(records) { + var self = this; + var search = (document.getElementById('record-search') || {}).value || ''; + search = search.toLowerCase(); + + var filtered = records.filter(function(r) { + if (typeFilter !== 'ALL' && r.type !== typeFilter) return false; + if (search && r.name.toLowerCase().indexOf(search) === -1 && r.value.toLowerCase().indexOf(search) === -1) return false; + return true; + }); + + if (filtered.length === 0) { + return E('div', { 'class': 'dns-empty' }, [ + E('div', { 'class': 'dns-empty-icon' }, '🔍'), + E('div', { 'class': 'dns-empty-text' }, typeFilter !== 'ALL' || search ? 'No matching records' : 'No records in this zone') + ]); + } + + return E('table', { 'class': 'dns-table' }, [ + E('thead', {}, [ + E('tr', {}, [ + E('th', { 'style': 'width: 60px;' }, 'Type'), + E('th', {}, 'Name'), + E('th', {}, 'Value'), + E('th', { 'style': 'width: 50px;' }, 'TTL'), + E('th', { 'style': 'width: 80px; text-align: right;' }, '') + ]) + ]), + E('tbody', {}, filtered.map(function(rec) { + var typeInfo = RECORD_TYPES[rec.type] || { color: 'var(--k-muted)' }; + // Clean up value display (remove extra "IN TYPE" if present) + var displayValue = rec.value.replace(/^\s*IN\s+\w+\s+/, '').trim(); + + return E('tr', {}, [ + E('td', {}, [ + E('span', { + 'class': 'dns-type', + 'style': 'background: ' + typeInfo.color + '20; color: ' + typeInfo.color + ';' + }, rec.type) + ]), + E('td', { 'class': 'mono' }, rec.name), + E('td', { 'class': 'mono truncate', 'title': displayValue }, displayValue), + E('td', { 'style': 'color: var(--k-muted);' }, rec.ttl || '-'), + E('td', { 'class': 'actions' }, [ + E('button', { + 'class': 'dns-btn dns-btn-icon dns-btn-sm', + 'title': 'Edit', + 'click': L.bind(self.showRecordModal, self, rec) + }, '✏️'), + E('button', { + 'class': 'dns-btn dns-btn-icon dns-btn-sm dns-btn-red', + 'title': 'Delete', + 'click': L.bind(self.handleDeleteRecord, self, rec) + }, '✗') + ]) + ]); + })) + ]); + }, + + selectZone: function(zoneName) { + var self = this; + currentZone = zoneName; + typeFilter = 'ALL'; + + document.getElementById('current-zone-label').textContent = ': ' + zoneName; + document.getElementById('add-record-btn').style.display = ''; + document.getElementById('records-filter').style.display = ''; + + callRecords(zoneName).then(function(data) { + currentRecords = data.records || []; + self.updateRecordsTable(); + self.updateZonesTable(); + }); + }, + + setTypeFilter: function(type) { + typeFilter = type; + document.querySelectorAll('.dns-filter-btn').forEach(function(btn) { + btn.classList.toggle('active', btn.dataset.type === type); + }); + this.updateRecordsTable(); + }, + + filterRecords: function() { + this.updateRecordsTable(); + }, + + updateStats: function(status) { + var isRunning = status.running === true; + var updates = { + 'status': { val: isRunning ? 'Running' : 'Stopped', color: isRunning ? 'var(--k-green)' : 'var(--k-red)' }, + 'zones': { val: status.zones || 0 }, + 'records': { val: status.records || 0 }, + 'ttl': { val: (status.default_ttl || 300) + 's' } + }; + Object.keys(updates).forEach(function(k) { + var el = document.querySelector('[data-stat="' + k + '"]'); + if (el) { + el.textContent = updates[k].val; + if (updates[k].color) el.style.color = updates[k].color; + } + }); + }, + + updateZonesTable: function() { + var container = document.getElementById('zones-list'); + if (container) dom.content(container, this.renderZonesList(allZones)); + }, + + updateRecordsTable: function() { + var container = document.getElementById('records-container'); + if (container) dom.content(container, this.renderRecordsTable(currentRecords)); + }, + + // === Modals === + + showAddZoneModal: function() { + var self = this; + var modal = E('div', { 'class': 'dns-modal-bg', 'id': 'modal-zone' }, [ + E('div', { 'class': 'dns-modal' }, [ + E('div', { 'class': 'dns-modal-title' }, [ + '📁 Add Zone', + E('button', { 'class': 'dns-modal-close', 'click': function() { document.getElementById('modal-zone').remove(); } }, '×') + ]), + E('div', { 'class': 'dns-form-row' }, [ + E('label', { 'class': 'dns-form-label' }, 'Zone Name'), + E('input', { 'type': 'text', 'class': 'dns-form-input', 'id': 'input-zone-name', 'placeholder': 'example.com' }), + E('div', { 'class': 'dns-form-hint' }, 'Enter the domain name for this zone') + ]), + E('div', { 'class': 'dns-form-actions' }, [ + E('button', { 'class': 'dns-btn', 'click': function() { document.getElementById('modal-zone').remove(); } }, 'Cancel'), + E('button', { 'class': 'dns-btn dns-btn-green', 'click': L.bind(self.handleAddZone, self) }, '+ Create Zone') + ]) + ]) + ]); + document.body.appendChild(modal); + document.getElementById('input-zone-name').focus(); + }, + + showRecordModal: function(record) { + var self = this; + var isEdit = !!record; + var title = isEdit ? '✏️ Edit Record' : '➕ Add Record'; + + var typeOptions = Object.keys(RECORD_TYPES).map(function(t) { + var info = RECORD_TYPES[t]; + return E('option', { 'value': t, 'selected': isEdit && record.type === t }, t + ' - ' + info.label); + }); + + var modal = E('div', { 'class': 'dns-modal-bg', 'id': 'modal-record' }, [ + E('div', { 'class': 'dns-modal' }, [ + E('div', { 'class': 'dns-modal-title' }, [ + title, + E('button', { 'class': 'dns-modal-close', 'click': function() { document.getElementById('modal-record').remove(); } }, '×') + ]), + E('div', { 'class': 'dns-form-row' }, [ + E('label', { 'class': 'dns-form-label' }, 'Type'), + E('select', { + 'class': 'dns-form-input', + 'id': 'input-rec-type', + 'disabled': isEdit, + 'change': L.bind(this.updatePlaceholder, this) + }, typeOptions) + ]), + E('div', { 'class': 'dns-form-row' }, [ + E('label', { 'class': 'dns-form-label' }, 'Name'), + E('div', { 'class': 'dns-form-row-inline' }, [ + E('input', { + 'type': 'text', + 'class': 'dns-form-input', + 'id': 'input-rec-name', + 'placeholder': '@ or www', + 'value': isEdit ? record.name : '', + 'style': 'flex: 1;' + }), + E('span', { 'class': 'dns-form-suffix' }, '.' + currentZone) + ]), + E('div', { 'class': 'dns-form-hint' }, 'Use @ for zone root') + ]), + E('div', { 'class': 'dns-form-row' }, [ + E('label', { 'class': 'dns-form-label' }, 'Value'), + E('input', { + 'type': 'text', + 'class': 'dns-form-input', + 'id': 'input-rec-value', + 'placeholder': isEdit ? '' : RECORD_TYPES['A'].placeholder, + 'value': isEdit ? record.value.replace(/^\s*IN\s+\w+\s+/, '').trim() : '' + }), + E('div', { 'class': 'dns-form-hint', 'id': 'value-hint' }, isEdit ? '' : RECORD_TYPES['A'].label) + ]), + E('div', { 'class': 'dns-form-row' }, [ + E('label', { 'class': 'dns-form-label' }, 'TTL (seconds)'), + E('input', { + 'type': 'number', + 'class': 'dns-form-input', + 'id': 'input-rec-ttl', + 'placeholder': '300', + 'value': isEdit && record.ttl ? record.ttl : '', + 'style': 'width: 120px;' + }) + ]), + E('div', { 'class': 'dns-form-actions' }, [ + E('button', { 'class': 'dns-btn', 'click': function() { document.getElementById('modal-record').remove(); } }, 'Cancel'), + E('button', { + 'class': 'dns-btn dns-btn-green', + 'click': L.bind(self.handleSaveRecord, self, record) + }, isEdit ? '💾 Save' : '+ Add') + ]) + ]) + ]); + + document.body.appendChild(modal); + if (!isEdit) document.getElementById('input-rec-name').focus(); + }, + + updatePlaceholder: function() { + var type = document.getElementById('input-rec-type').value; + var info = RECORD_TYPES[type] || { placeholder: '', label: '' }; + document.getElementById('input-rec-value').placeholder = info.placeholder; + document.getElementById('value-hint').textContent = info.label; + }, + + // === Handlers === + + handleAddZone: function() { + var self = this; + var name = document.getElementById('input-zone-name').value.trim(); + if (!name) { + ui.addNotification(null, E('p', {}, 'Zone name required'), 'warning'); + return; + } + callAddZone(name).then(function(res) { + document.getElementById('modal-zone').remove(); + if (res.code === 0) { + ui.addNotification(null, E('p', {}, 'Zone created: ' + name), 'success'); + callZones().then(function(d) { + allZones = (d || {}).zones || []; + self.updateZonesTable(); + self.selectZone(name); + }); + } else { + ui.addNotification(null, E('p', {}, res.output || 'Error'), 'error'); + } + }); + }, + + handleSaveRecord: function(oldRecord) { + var self = this; + var type = document.getElementById('input-rec-type').value; + var name = document.getElementById('input-rec-name').value.trim() || '@'; + var value = document.getElementById('input-rec-value').value.trim(); + var ttl = document.getElementById('input-rec-ttl').value.trim(); + + if (!value) { + ui.addNotification(null, E('p', {}, 'Value required'), 'warning'); + return; + } + + var doAdd = function() { + callAddRecord(currentZone, type, name, value, ttl ? parseInt(ttl) : null).then(function(res) { + document.getElementById('modal-record').remove(); + if (res.code === 0) { + ui.addNotification(null, E('p', {}, 'Record saved'), 'success'); + self.selectZone(currentZone); + } else { + ui.addNotification(null, E('p', {}, res.output || 'Error'), 'error'); + } + }); + }; + + // If editing, delete old record first + if (oldRecord) { + callDelRecord(currentZone, oldRecord.type, oldRecord.name, oldRecord.value).then(doAdd); + } else { + doAdd(); + } + }, + + handleDeleteRecord: function(record) { + var self = this; + if (!confirm('Delete ' + record.type + ' record for "' + record.name + '"?')) return; + + callDelRecord(currentZone, record.type, record.name, record.value).then(function(res) { + if (res.code === 0) { + ui.addNotification(null, E('p', {}, 'Record deleted'), 'success'); + self.selectZone(currentZone); + } else { + ui.addNotification(null, E('p', {}, res.output || 'Error'), 'error'); + } + }); + }, + + handleReload: function() { + callReload().then(function(res) { + if (res.code === 0) { + ui.addNotification(null, E('p', {}, 'BIND reloaded'), 'success'); + } else { + ui.addNotification(null, E('p', {}, res.output || 'Reload failed'), 'error'); + } + }); + }, + + handleBackupZone: function(zoneName) { + callBackup(zoneName).then(function(res) { + if (res.code === 0) { + ui.addNotification(null, E('p', {}, 'Zone backed up: ' + zoneName), 'success'); + } else { + ui.addNotification(null, E('p', {}, res.output || 'Backup failed'), 'error'); + } + }); + }, + + handleSaveApply: null, + handleSave: null, + handleReset: null +}); diff --git a/package/secubox/luci-app-dns-master/root/usr/libexec/rpcd/luci.dns-master b/package/secubox/luci-app-dns-master/root/usr/libexec/rpcd/luci.dns-master new file mode 100644 index 00000000..374cacde --- /dev/null +++ b/package/secubox/luci-app-dns-master/root/usr/libexec/rpcd/luci.dns-master @@ -0,0 +1,169 @@ +#!/bin/sh +# SecuBox DNS Master RPCD Handler + +. /usr/share/libubox/jshn.sh + +DNSMASTER="/usr/sbin/dnsmaster" +CONFIG="dns-master" + +case "$1" in + list) + cat <<-EOF + { + "status": {}, + "zones": {}, + "records": { "zone": "string" }, + "add_record": { "zone": "string", "type": "string", "name": "string", "value": "string", "ttl": "number" }, + "del_record": { "zone": "string", "type": "string", "name": "string", "value": "string" }, + "add_zone": { "name": "string" }, + "reload": {}, + "check": { "zone": "string" }, + "logs": { "lines": "number" }, + "backup": { "zone": "string" } + } + EOF + ;; + + call) + # Read JSON input + read_json_input() { + if [ -n "$1" ]; then + json_load "$1" + else + local _input + read -r _input + json_load "$_input" + fi + } + + case "$2" in + status) + $DNSMASTER status-json 2>/dev/null || echo '{"running":false,"zones":0,"records":0}' + ;; + + zones) + $DNSMASTER zone-list-json 2>/dev/null || echo '{"zones":[]}' + ;; + + records) + read_json_input "$3" + json_get_var zone zone + + if [ -z "$zone" ]; then + echo '{"error":"Zone required"}' + else + $DNSMASTER records-json "$zone" 2>/dev/null || echo "{\"error\":\"Zone not found\",\"zone\":\"$zone\"}" + fi + ;; + + add_record) + read_json_input "$3" + json_get_var zone zone + json_get_var type type + json_get_var name name + json_get_var value value + json_get_var ttl ttl + + json_init + if [ -z "$zone" ] || [ -z "$type" ] || [ -z "$name" ] || [ -z "$value" ]; then + json_add_int "code" 1 + json_add_string "error" "Missing required fields" + else + output=$($DNSMASTER record-add "$zone" "$type" "$name" "$value" "$ttl" 2>&1) + rc=$? + json_add_int "code" "$rc" + json_add_string "output" "$output" + fi + json_dump + ;; + + del_record) + read_json_input "$3" + json_get_var zone zone + json_get_var type type + json_get_var name name + json_get_var value value + + json_init + if [ -z "$zone" ] || [ -z "$type" ] || [ -z "$name" ]; then + json_add_int "code" 1 + json_add_string "error" "Missing required fields" + else + output=$($DNSMASTER record-del "$zone" "$type" "$name" "$value" 2>&1) + rc=$? + json_add_int "code" "$rc" + json_add_string "output" "$output" + fi + json_dump + ;; + + add_zone) + read_json_input "$3" + json_get_var name name + + json_init + if [ -z "$name" ]; then + json_add_int "code" 1 + json_add_string "error" "Zone name required" + else + output=$($DNSMASTER zone-add "$name" 2>&1) + rc=$? + json_add_int "code" "$rc" + json_add_string "output" "$output" + fi + json_dump + ;; + + reload) + json_init + output=$($DNSMASTER reload 2>&1) + json_add_int "code" "$?" + json_add_string "output" "$output" + json_dump + ;; + + check) + read_json_input "$3" + json_get_var zone zone + + json_init + if [ -n "$zone" ]; then + output=$($DNSMASTER check "$zone" 2>&1) + else + output=$($DNSMASTER check 2>&1) + fi + json_add_int "code" "$?" + json_add_string "output" "$output" + json_dump + ;; + + logs) + read_json_input "$3" + json_get_var lines lines + lines="${lines:-50}" + + json_init + log_output=$($DNSMASTER logs "$lines" 2>/dev/null) + json_add_string "logs" "$log_output" + json_dump + ;; + + backup) + read_json_input "$3" + json_get_var zone zone + + json_init + if [ -n "$zone" ]; then + output=$($DNSMASTER backup "$zone" 2>&1) + else + output=$($DNSMASTER backup 2>&1) + fi + json_add_int "code" "$?" + json_add_string "output" "$output" + json_dump + ;; + esac + ;; +esac + +exit 0 diff --git a/package/secubox/luci-app-dns-master/root/usr/share/luci/menu.d/luci-app-dns-master.json b/package/secubox/luci-app-dns-master/root/usr/share/luci/menu.d/luci-app-dns-master.json new file mode 100644 index 00000000..3cbb13e7 --- /dev/null +++ b/package/secubox/luci-app-dns-master/root/usr/share/luci/menu.d/luci-app-dns-master.json @@ -0,0 +1,14 @@ +{ + "admin/services/dns-master": { + "title": "DNS Master", + "action": { + "type": "view", + "path": "dns-master/overview" + }, + "depends": { + "acl": ["luci-app-dns-master"], + "uci": { "dns-master": true } + }, + "order": 55 + } +} diff --git a/package/secubox/luci-app-dns-master/root/usr/share/rpcd/acl.d/luci-app-dns-master.json b/package/secubox/luci-app-dns-master/root/usr/share/rpcd/acl.d/luci-app-dns-master.json new file mode 100644 index 00000000..63348255 --- /dev/null +++ b/package/secubox/luci-app-dns-master/root/usr/share/rpcd/acl.d/luci-app-dns-master.json @@ -0,0 +1,17 @@ +{ + "luci-app-dns-master": { + "description": "Grant access to DNS Master", + "read": { + "ubus": { + "luci.dns-master": ["status", "zones", "records", "logs"] + }, + "uci": ["dns-master"] + }, + "write": { + "ubus": { + "luci.dns-master": ["add_record", "del_record", "add_zone", "reload", "check", "backup"] + }, + "uci": ["dns-master"] + } + } +} diff --git a/package/secubox/luci-app-hexojs/htdocs/luci-static/resources/hexojs/api.js b/package/secubox/luci-app-hexojs/htdocs/luci-static/resources/hexojs/api.js index 6b8d5917..b74e732b 100644 --- a/package/secubox/luci-app-hexojs/htdocs/luci-static/resources/hexojs/api.js +++ b/package/secubox/luci-app-hexojs/htdocs/luci-static/resources/hexojs/api.js @@ -311,6 +311,108 @@ var callGitGetCredentials = rpc.declare({ expect: {} }); +// ============================================ +// Instance Management +// ============================================ + +var callListInstances = rpc.declare({ + object: 'luci.hexojs', + method: 'list_instances', + expect: { instances: [] } +}); + +var callCreateInstance = rpc.declare({ + object: 'luci.hexojs', + method: 'create_instance', + params: ['name', 'title', 'port'], + expect: {} +}); + +var callDeleteInstance = rpc.declare({ + object: 'luci.hexojs', + method: 'delete_instance', + params: ['name', 'delete_data'], + expect: {} +}); + +var callStartInstance = rpc.declare({ + object: 'luci.hexojs', + method: 'start_instance', + params: ['name'], + expect: {} +}); + +var callStopInstance = rpc.declare({ + object: 'luci.hexojs', + method: 'stop_instance', + params: ['name'], + expect: {} +}); + +// ============================================ +// Backup/Restore +// ============================================ + +var callListBackups = rpc.declare({ + object: 'luci.hexojs', + method: 'list_backups', + expect: { backups: [] } +}); + +var callCreateBackup = rpc.declare({ + object: 'luci.hexojs', + method: 'create_backup', + params: ['instance', 'name'], + expect: {} +}); + +var callRestoreBackup = rpc.declare({ + object: 'luci.hexojs', + method: 'restore_backup', + params: ['name', 'instance'], + expect: {} +}); + +var callDeleteBackup = rpc.declare({ + object: 'luci.hexojs', + method: 'delete_backup', + params: ['name'], + expect: {} +}); + +// ============================================ +// GitHub Integration +// ============================================ + +var callGitHubClone = rpc.declare({ + object: 'luci.hexojs', + method: 'github_clone', + params: ['repo', 'instance', 'branch'], + expect: {} +}); + +// ============================================ +// Gitea Push +// ============================================ + +var callGiteaPush = rpc.declare({ + object: 'luci.hexojs', + method: 'gitea_push', + params: ['instance', 'message'], + expect: {} +}); + +// ============================================ +// Quick Publish +// ============================================ + +var callQuickPublish = rpc.declare({ + object: 'luci.hexojs', + method: 'quick_publish', + params: ['instance'], + expect: {} +}); + // ============================================ // Utility Functions // ============================================ @@ -523,6 +625,28 @@ return baseclass.extend({ gitSetCredentials: callGitSetCredentials, gitGetCredentials: callGitGetCredentials, + // Instance Management + listInstances: callListInstances, + createInstance: callCreateInstance, + deleteInstance: callDeleteInstance, + startInstance: callStartInstance, + stopInstance: callStopInstance, + + // Backup/Restore + listBackups: callListBackups, + createBackup: callCreateBackup, + restoreBackup: callRestoreBackup, + deleteBackup: callDeleteBackup, + + // GitHub Integration + gitHubClone: callGitHubClone, + + // Gitea Push + giteaPush: callGiteaPush, + + // Quick Publish + quickPublish: callQuickPublish, + // Combined fetchers getDashboardData: getDashboardData, getPostsData: getPostsData, diff --git a/package/secubox/luci-app-hexojs/htdocs/luci-static/resources/view/hexojs/overview.js b/package/secubox/luci-app-hexojs/htdocs/luci-static/resources/view/hexojs/overview.js index f7c00b0d..016ea7b2 100644 --- a/package/secubox/luci-app-hexojs/htdocs/luci-static/resources/view/hexojs/overview.js +++ b/package/secubox/luci-app-hexojs/htdocs/luci-static/resources/view/hexojs/overview.js @@ -2,6 +2,7 @@ 'require view'; 'require poll'; 'require ui'; +'require rpc'; 'require hexojs/api as api'; 'require secubox/kiss-theme'; @@ -9,259 +10,421 @@ return view.extend({ title: _('Hexo CMS'), pollInterval: 10, pollActive: true, + currentInstance: null, load: function() { - return api.getDashboardData(); - }, - - handleServiceToggle: function(status) { - var self = this; - - if (status.running) { - ui.showModal(_('Stop Service'), [ - E('p', { 'class': 'spinning' }, _('Stopping Hexo CMS...')) - ]); - - api.serviceStop().then(function(result) { - ui.hideModal(); - if (result.success) { - ui.addNotification(null, E('p', _('Service stopped')), 'info'); - self.render(); - } - }).catch(function(err) { - ui.hideModal(); - ui.addNotification(null, E('p', _('Error: %s').format(err.message || err)), 'error'); - }); - } else { - ui.showModal(_('Start Service'), [ - E('p', { 'class': 'spinning' }, _('Starting Hexo CMS...')) - ]); - - api.serviceStart().then(function(result) { - ui.hideModal(); - if (result.success) { - ui.addNotification(null, E('p', _('Service starting...')), 'info'); - } - }).catch(function(err) { - ui.hideModal(); - ui.addNotification(null, E('p', _('Error: %s').format(err.message || err)), 'error'); - }); - } - }, - - handleBuild: function() { - ui.showModal(_('Building Site'), [ - E('p', { 'class': 'spinning' }, _('Generating static files...')) - ]); - - api.generate().then(function(result) { - ui.hideModal(); - if (result.success) { - ui.addNotification(null, E('p', _('Site built successfully!')), 'info'); - } else { - ui.addNotification(null, E('p', result.error || _('Build failed')), 'error'); - } - }).catch(function(err) { - ui.hideModal(); - ui.addNotification(null, E('p', _('Error: %s').format(err.message || err)), 'error'); + return Promise.all([ + api.listInstances(), + api.getStatus(), + api.getSiteStats(), + api.listBackups() + ]).then(function(results) { + return { + instances: results[0].instances || [], + status: results[1], + stats: results[2], + backups: results[3].backups || [] + }; }); }, - startPolling: function() { + // ─── Instance Management ─── + handleCreateInstance: function() { var self = this; - this.pollActive = true; - - poll.add(L.bind(function() { - if (!this.pollActive) return Promise.resolve(); - - return api.getStatus().then(L.bind(function(status) { - this.updateStatusDisplay(status); - }, this)); - }, this), this.pollInterval); + ui.showModal(_('Create Instance'), [ + E('div', { 'class': 'k-form' }, [ + E('div', { 'class': 'k-form-group' }, [ + E('label', {}, _('Name (lowercase, no spaces)')), + E('input', { 'type': 'text', 'id': 'new-instance-name', 'placeholder': 'myblog', + 'pattern': '^[a-z][a-z0-9_]*$', 'style': 'width: 100%' }) + ]), + E('div', { 'class': 'k-form-group' }, [ + E('label', {}, _('Title')), + E('input', { 'type': 'text', 'id': 'new-instance-title', 'placeholder': 'My Blog', + 'style': 'width: 100%' }) + ]), + E('div', { 'class': 'k-form-group' }, [ + E('label', {}, _('Port (auto if empty)')), + E('input', { 'type': 'number', 'id': 'new-instance-port', 'placeholder': '4000', + 'style': 'width: 100%' }) + ]) + ]), + E('div', { 'class': 'right', 'style': 'margin-top: 16px' }, [ + E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, _('Cancel')), + E('button', { 'class': 'cbi-button cbi-button-positive', 'style': 'margin-left: 8px', + 'click': function() { + var name = document.getElementById('new-instance-name').value; + var title = document.getElementById('new-instance-title').value; + var port = document.getElementById('new-instance-port').value; + if (!name) { ui.addNotification(null, E('p', _('Name required')), 'error'); return; } + ui.showModal(_('Creating...'), [E('p', { 'class': 'spinning' }, _('Creating instance...'))]); + api.createInstance(name, title || null, port ? parseInt(port) : null).then(function(r) { + ui.hideModal(); + if (r.success) { + ui.addNotification(null, E('p', _('Instance created: %s').format(name)), 'info'); + window.location.reload(); + } else { + ui.addNotification(null, E('p', r.error || _('Failed')), 'error'); + } + }); + } + }, _('Create')) + ]) + ]); }, - stopPolling: function() { - this.pollActive = false; - poll.stop(); + handleDeleteInstance: function(name) { + var self = this; + ui.showModal(_('Delete Instance'), [ + E('p', {}, _('Delete instance "%s"?').format(name)), + E('label', { 'style': 'display: block; margin: 12px 0' }, [ + E('input', { 'type': 'checkbox', 'id': 'delete-data-check' }), + E('span', { 'style': 'margin-left: 8px' }, _('Also delete site data')) + ]), + E('div', { 'class': 'right' }, [ + E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, _('Cancel')), + E('button', { 'class': 'cbi-button cbi-button-negative', 'style': 'margin-left: 8px', + 'click': function() { + var deleteData = document.getElementById('delete-data-check').checked; + ui.showModal(_('Deleting...'), [E('p', { 'class': 'spinning' }, _('Deleting...'))]); + api.deleteInstance(name, deleteData).then(function() { + ui.hideModal(); + window.location.reload(); + }); + } + }, _('Delete')) + ]) + ]); }, - updateStatusDisplay: function(status) { - var badge = document.querySelector('.hexo-status-badge'); - if (badge) { - badge.className = 'hexo-status-badge ' + (status.running ? 'running' : 'stopped'); - badge.innerHTML = '' + (status.running ? _('Running') : _('Stopped')); - } - - var toggleBtn = document.querySelector('.hexo-service-toggle'); - if (toggleBtn) { - toggleBtn.textContent = status.running ? _('Stop Service') : _('Start Service'); - toggleBtn.className = 'hexo-btn ' + (status.running ? 'hexo-btn-danger' : 'hexo-btn-success'); - } + handleToggleInstance: function(inst) { + var self = this; + var action = inst.running ? api.stopInstance : api.startInstance; + var msg = inst.running ? _('Stopping...') : _('Starting...'); + ui.showModal(msg, [E('p', { 'class': 'spinning' }, msg)]); + action(inst.name).then(function(r) { + ui.hideModal(); + if (r.success) { + ui.addNotification(null, E('p', r.message), 'info'); + setTimeout(function() { window.location.reload(); }, 1500); + } else { + ui.addNotification(null, E('p', r.error || _('Failed')), 'error'); + } + }); }, + // ─── Backup/Restore ─── + handleBackup: function(instance) { + ui.showModal(_('Create Backup'), [E('p', { 'class': 'spinning' }, _('Creating backup...'))]); + api.createBackup(instance || 'default', null).then(function(r) { + ui.hideModal(); + if (r.success) { + ui.addNotification(null, E('p', _('Backup created: %s').format(r.name)), 'info'); + window.location.reload(); + } else { + ui.addNotification(null, E('p', r.error || _('Backup failed')), 'error'); + } + }); + }, + + handleRestore: function(backupName, instance) { + ui.showModal(_('Restore Backup'), [ + E('p', {}, _('Restore backup "%s"?').format(backupName)), + E('p', { 'style': 'color: var(--k-warning)' }, _('This will overwrite current site data!')), + E('div', { 'class': 'right', 'style': 'margin-top: 16px' }, [ + E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, _('Cancel')), + E('button', { 'class': 'cbi-button cbi-button-action', 'style': 'margin-left: 8px', + 'click': function() { + ui.showModal(_('Restoring...'), [E('p', { 'class': 'spinning' }, _('Restoring...'))]); + api.restoreBackup(backupName, instance || 'default').then(function(r) { + ui.hideModal(); + if (r.success) { + ui.addNotification(null, E('p', _('Backup restored')), 'info'); + } else { + ui.addNotification(null, E('p', r.error || _('Restore failed')), 'error'); + } + }); + } + }, _('Restore')) + ]) + ]); + }, + + handleDeleteBackup: function(name) { + ui.showModal(_('Delete Backup'), [ + E('p', {}, _('Delete backup "%s"?').format(name)), + E('div', { 'class': 'right', 'style': 'margin-top: 16px' }, [ + E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, _('Cancel')), + E('button', { 'class': 'cbi-button cbi-button-negative', 'style': 'margin-left: 8px', + 'click': function() { + api.deleteBackup(name).then(function(r) { + ui.hideModal(); + if (r.success) window.location.reload(); + }); + } + }, _('Delete')) + ]) + ]); + }, + + // ─── GitHub/Gitea Clone ─── + handleGitClone: function(source) { + var self = this; + var title = source === 'github' ? _('Clone from GitHub') : _('Clone from Gitea'); + ui.showModal(title, [ + E('div', { 'class': 'k-form' }, [ + E('div', { 'class': 'k-form-group' }, [ + E('label', {}, _('Repository URL')), + E('input', { 'type': 'text', 'id': 'clone-repo', 'style': 'width: 100%', + 'placeholder': source === 'github' ? 'https://github.com/user/repo.git' : 'http://gitea.local/user/repo.git' }) + ]), + E('div', { 'class': 'k-form-group' }, [ + E('label', {}, _('Instance (existing or new)')), + E('input', { 'type': 'text', 'id': 'clone-instance', 'style': 'width: 100%', + 'placeholder': 'default' }) + ]), + E('div', { 'class': 'k-form-group' }, [ + E('label', {}, _('Branch')), + E('input', { 'type': 'text', 'id': 'clone-branch', 'style': 'width: 100%', + 'placeholder': 'main' }) + ]) + ]), + E('div', { 'class': 'right', 'style': 'margin-top: 16px' }, [ + E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, _('Cancel')), + E('button', { 'class': 'cbi-button cbi-button-positive', 'style': 'margin-left: 8px', + 'click': function() { + var repo = document.getElementById('clone-repo').value; + var instance = document.getElementById('clone-instance').value || 'default'; + var branch = document.getElementById('clone-branch').value || 'main'; + if (!repo) { ui.addNotification(null, E('p', _('Repo URL required')), 'error'); return; } + ui.showModal(_('Cloning...'), [E('p', { 'class': 'spinning' }, _('Cloning repository...'))]); + var cloneFn = source === 'github' ? api.gitHubClone : api.gitClone; + cloneFn(repo, instance, branch).then(function(r) { + ui.hideModal(); + if (r.success) { + ui.addNotification(null, E('p', r.message || _('Clone successful')), 'info'); + window.location.reload(); + } else { + ui.addNotification(null, E('p', r.error || _('Clone failed')), 'error'); + } + }); + } + }, _('Clone')) + ]) + ]); + }, + + // ─── Quick Publish ─── + handleQuickPublish: function(instance) { + ui.showModal(_('Quick Publish'), [ + E('p', { 'class': 'spinning' }, _('Building and publishing...')) + ]); + api.quickPublish(instance || 'default').then(function(r) { + ui.hideModal(); + if (r.success) { + ui.addNotification(null, E('p', _('Published successfully!')), 'info'); + } else { + ui.addNotification(null, E('p', r.error || _('Publish failed')), 'error'); + } + }); + }, + + // ─── Gitea Push ─── + handleGiteaPush: function(instance) { + ui.showModal(_('Push to Gitea'), [ + E('div', { 'class': 'k-form' }, [ + E('div', { 'class': 'k-form-group' }, [ + E('label', {}, _('Commit Message')), + E('input', { 'type': 'text', 'id': 'push-message', 'style': 'width: 100%', + 'placeholder': 'Update content' }) + ]) + ]), + E('div', { 'class': 'right', 'style': 'margin-top: 16px' }, [ + E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, _('Cancel')), + E('button', { 'class': 'cbi-button cbi-button-positive', 'style': 'margin-left: 8px', + 'click': function() { + var msg = document.getElementById('push-message').value || 'Update from SecuBox'; + ui.showModal(_('Pushing...'), [E('p', { 'class': 'spinning' }, _('Pushing to Gitea...'))]); + api.giteaPush(instance || 'default', msg).then(function(r) { + ui.hideModal(); + if (r.success) { + ui.addNotification(null, E('p', r.message || _('Push successful')), 'info'); + } else { + ui.addNotification(null, E('p', r.error || _('Push failed')), 'error'); + } + }); + } + }, _('Push')) + ]) + ]); + }, + + // ─── Service Control ─── + handleServiceToggle: function(status) { + var self = this; + var action = status.running ? api.serviceStop : api.serviceStart; + var msg = status.running ? _('Stopping...') : _('Starting...'); + ui.showModal(msg, [E('p', { 'class': 'spinning' }, msg)]); + action().then(function(r) { + ui.hideModal(); + if (r.success) { + ui.addNotification(null, E('p', r.message), 'info'); + setTimeout(function() { window.location.reload(); }, 2000); + } + }); + }, + + // ─── Render ─── render: function(data) { var self = this; + var instances = data.instances || []; var status = data.status || {}; var stats = data.stats || {}; - var posts = data.posts || []; - var preview = data.preview || {}; + var backups = data.backups || []; - var view = E('div', { 'class': 'hexo-dashboard' }, [ - E('link', { 'rel': 'stylesheet', 'href': L.resource('hexojs/dashboard.css') }), + // ─── Stat Card Helper ─── + var statCard = function(icon, value, label, color) { + return E('div', { 'class': 'k-stat' }, [ + E('div', { 'class': 'k-stat-icon', 'style': 'color: ' + (color || 'var(--k-accent)') }, icon), + E('div', { 'class': 'k-stat-value' }, String(value)), + E('div', { 'class': 'k-stat-label' }, label) + ]); + }; + // ─── Instance Card Helper ─── + var instanceCard = function(inst) { + var statusColor = inst.running ? 'var(--k-green)' : 'var(--k-muted)'; + var statusText = inst.running ? _('Running') : _('Stopped'); + return E('div', { 'class': 'k-card', 'style': 'border-left: 3px solid ' + statusColor }, + E('div', { 'style': 'display: flex; justify-content: space-between; align-items: center' }, [ + E('div', {}, [ + E('div', { 'class': 'k-card-title', 'style': 'margin-bottom: 4px' }, [ + inst.title || inst.name, + E('span', { 'class': 'k-badge', 'style': 'margin-left: 8px; background: ' + statusColor }, statusText) + ]), + E('div', { 'style': 'color: var(--k-muted); font-size: 12px' }, [ + 'Port: ', String(inst.port), + inst.domain ? [' | ', E('a', { 'href': 'https://' + inst.domain, 'target': '_blank' }, inst.domain)] : '' + ]) + ]), + E('div', { 'style': 'display: flex; gap: 8px' }, [ + E('button', { + 'class': 'k-btn k-btn-sm ' + (inst.running ? 'k-btn-danger' : 'k-btn-success'), + 'click': function() { self.handleToggleInstance(inst); } + }, inst.running ? '\u25A0' : '\u25B6'), + E('button', { 'class': 'k-btn k-btn-sm', 'title': _('Quick Publish'), + 'click': function() { self.handleQuickPublish(inst.name); } + }, '\uD83D\uDE80'), + E('button', { 'class': 'k-btn k-btn-sm', 'title': _('Backup'), + 'click': function() { self.handleBackup(inst.name); } + }, '\uD83D\uDCBE'), + E('a', { 'class': 'k-btn k-btn-sm', 'title': _('Editor'), + 'href': L.url('admin', 'services', 'hexojs', 'editor') + '?instance=' + inst.name + }, '\u270F'), + inst.running ? E('a', { 'class': 'k-btn k-btn-sm', 'title': _('Preview'), + 'href': 'http://' + window.location.hostname + ':' + inst.port, + 'target': '_blank' + }, '\uD83D\uDC41') : '', + E('button', { 'class': 'k-btn k-btn-sm k-btn-danger', 'title': _('Delete'), + 'click': function() { self.handleDeleteInstance(inst.name); } + }, '\u2715') + ]) + ]) + ); + }; + + // ─── Backup Row Helper ─── + var backupRow = function(bk) { + var date = bk.timestamp ? new Date(bk.timestamp * 1000).toLocaleString() : '-'; + return E('tr', {}, [ + E('td', {}, bk.name), + E('td', {}, bk.size), + E('td', {}, date), + E('td', { 'style': 'text-align: right' }, [ + E('button', { 'class': 'k-btn k-btn-sm', 'click': function() { self.handleRestore(bk.name); } }, '\u21BA'), + E('button', { 'class': 'k-btn k-btn-sm k-btn-danger', 'style': 'margin-left: 4px', + 'click': function() { self.handleDeleteBackup(bk.name); } + }, '\u2715') + ]) + ]); + }; + + // ─── Main Layout ─── + var content = [ // Header - E('div', { 'class': 'hexo-header' }, [ - E('div', { 'class': 'hexo-logo' }, [ - E('div', { 'class': 'hexo-logo-icon' }, '\uD83D\uDCDD'), - E('div', { 'class': 'hexo-logo-text' }, ['Hexo ', E('span', {}, 'CMS')]) + E('div', { 'style': 'display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px' }, [ + E('div', {}, [ + E('h2', { 'style': 'margin: 0' }, ['\uD83D\uDCDD ', _('Hexo CMS')]), + E('p', { 'style': 'color: var(--k-muted); margin: 4px 0 0' }, _('Multi-instance static site generator')) ]), - E('div', { 'class': 'hexo-status-badge ' + (status.running ? 'running' : 'stopped') }, [ - E('span', { 'class': 'hexo-status-dot' }), - status.running ? _('Running') : _('Stopped') + E('div', { 'style': 'display: flex; gap: 8px' }, [ + E('button', { + 'class': 'k-btn ' + (status.running ? 'k-btn-danger' : 'k-btn-success'), + 'click': function() { self.handleServiceToggle(status); } + }, status.running ? ['\u25A0 ', _('Stop Container')] : ['\u25B6 ', _('Start Container')]) ]) ]), // Stats Grid - E('div', { 'class': 'hexo-stats-grid' }, [ - E('div', { 'class': 'hexo-stat' }, [ - E('div', { 'class': 'hexo-stat-icon' }, '\uD83D\uDCDD'), - E('div', { 'class': 'hexo-stat-value' }, stats.posts || 0), - E('div', { 'class': 'hexo-stat-label' }, _('Posts')) - ]), - E('div', { 'class': 'hexo-stat' }, [ - E('div', { 'class': 'hexo-stat-icon' }, '\uD83D\uDCCB'), - E('div', { 'class': 'hexo-stat-value' }, stats.drafts || 0), - E('div', { 'class': 'hexo-stat-label' }, _('Drafts')) - ]), - E('div', { 'class': 'hexo-stat' }, [ - E('div', { 'class': 'hexo-stat-icon' }, '\uD83D\uDCC1'), - E('div', { 'class': 'hexo-stat-value' }, stats.categories || 0), - E('div', { 'class': 'hexo-stat-label' }, _('Categories')) - ]), - E('div', { 'class': 'hexo-stat' }, [ - E('div', { 'class': 'hexo-stat-icon' }, '\uD83C\uDFF7'), - E('div', { 'class': 'hexo-stat-value' }, stats.tags || 0), - E('div', { 'class': 'hexo-stat-label' }, _('Tags')) - ]), - E('div', { 'class': 'hexo-stat' }, [ - E('div', { 'class': 'hexo-stat-icon' }, '\uD83D\uDDBC'), - E('div', { 'class': 'hexo-stat-value' }, stats.media || 0), - E('div', { 'class': 'hexo-stat-label' }, _('Media')) - ]) + E('div', { 'class': 'k-grid k-grid-4', 'style': 'margin-bottom: 20px' }, [ + statCard('\uD83D\uDCE6', instances.length, _('Instances'), 'var(--k-blue)'), + statCard('\uD83D\uDCDD', stats.posts || 0, _('Posts'), 'var(--k-green)'), + statCard('\uD83D\uDCCB', stats.drafts || 0, _('Drafts'), 'var(--k-yellow)'), + statCard('\uD83D\uDCBE', backups.length, _('Backups'), 'var(--k-purple)') ]), // Quick Actions - E('div', { 'class': 'hexo-card' }, [ - E('div', { 'class': 'hexo-card-header' }, [ - E('div', { 'class': 'hexo-card-title' }, [ - E('span', { 'class': 'hexo-card-title-icon' }, '\u26A1'), - _('Quick Actions') - ]) - ]), - E('div', { 'class': 'hexo-actions' }, [ - E('button', { - 'class': 'hexo-service-toggle hexo-btn ' + (status.running ? 'hexo-btn-danger' : 'hexo-btn-success'), - 'click': function() { self.handleServiceToggle(status); } - }, status.running ? _('Stop Service') : _('Start Service')), - - E('a', { - 'class': 'hexo-btn hexo-btn-primary', - 'href': L.url('admin', 'services', 'hexojs', 'editor') - }, ['\u270F ', _('New Post')]), - - E('button', { - 'class': 'hexo-btn hexo-btn-secondary', - 'click': function() { self.handleBuild(); }, - 'disabled': !status.running - }, ['\uD83D\uDD28 ', _('Build')]), - - E('a', { - 'class': 'hexo-btn hexo-btn-secondary', - 'href': L.url('admin', 'services', 'hexojs', 'deploy') - }, ['\uD83D\uDE80 ', _('Deploy')]), - - preview.running ? E('a', { - 'class': 'hexo-btn hexo-btn-secondary', - 'href': preview.url, - 'target': '_blank' - }, ['\uD83D\uDC41 ', _('Preview')]) : '' + E('div', { 'class': 'k-card', 'style': 'margin-bottom: 20px' }, [ + E('div', { 'class': 'k-card-title' }, ['\u26A1 ', _('Quick Actions')]), + E('div', { 'style': 'display: flex; gap: 8px; flex-wrap: wrap' }, [ + E('button', { 'class': 'k-btn k-btn-success', 'click': function() { self.handleCreateInstance(); } }, + ['\u2795 ', _('New Instance')]), + E('button', { 'class': 'k-btn', 'click': function() { self.handleGitClone('github'); } }, + ['\uD83D\uDC19 ', _('Clone from GitHub')]), + E('button', { 'class': 'k-btn', 'click': function() { self.handleGitClone('gitea'); } }, + ['\uD83C\uDF75 ', _('Clone from Gitea')]), + E('a', { 'class': 'k-btn', 'href': L.url('admin', 'services', 'hexojs', 'editor') }, + ['\u270F ', _('New Post')]), + E('a', { 'class': 'k-btn', 'href': L.url('admin', 'services', 'hexojs', 'settings') }, + ['\u2699 ', _('Settings')]) ]) ]), - // Site Info - E('div', { 'class': 'hexo-card' }, [ - E('div', { 'class': 'hexo-card-header' }, [ - E('div', { 'class': 'hexo-card-title' }, [ - E('span', { 'class': 'hexo-card-title-icon' }, '\u2139'), - _('Site Information') + // Instances Section + E('div', { 'class': 'k-card', 'style': 'margin-bottom: 20px' }, [ + E('div', { 'class': 'k-card-title' }, ['\uD83D\uDCE6 ', _('Instances')]), + instances.length > 0 + ? E('div', { 'style': 'display: flex; flex-direction: column; gap: 12px' }, + instances.map(instanceCard)) + : E('div', { 'class': 'k-empty' }, [ + E('div', { 'style': 'font-size: 48px; margin-bottom: 12px' }, '\uD83D\uDCE6'), + E('p', {}, _('No instances yet. Create your first instance!')), + E('button', { 'class': 'k-btn k-btn-success', 'click': function() { self.handleCreateInstance(); } }, + ['\u2795 ', _('Create Instance')]) ]) - ]), - E('div', { 'style': 'display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px;' }, [ - E('div', {}, [ - E('div', { 'style': 'font-size: 12px; color: var(--hexo-text-muted); text-transform: uppercase;' }, _('Title')), - E('div', { 'style': 'font-size: 16px; font-weight: 500;' }, status.site ? status.site.title : '-') - ]), - E('div', {}, [ - E('div', { 'style': 'font-size: 12px; color: var(--hexo-text-muted); text-transform: uppercase;' }, _('Author')), - E('div', { 'style': 'font-size: 16px; font-weight: 500;' }, status.site ? status.site.author : '-') - ]), - E('div', {}, [ - E('div', { 'style': 'font-size: 12px; color: var(--hexo-text-muted); text-transform: uppercase;' }, _('Theme')), - E('div', { 'style': 'font-size: 16px; font-weight: 500;' }, status.site ? status.site.theme : '-') - ]), - E('div', {}, [ - E('div', { 'style': 'font-size: 12px; color: var(--hexo-text-muted); text-transform: uppercase;' }, _('Port')), - E('div', { 'style': 'font-size: 16px; font-weight: 500;' }, status.http_port || '4000') - ]) - ]) ]), - // Recent Posts - E('div', { 'class': 'hexo-card' }, [ - E('div', { 'class': 'hexo-card-header' }, [ - E('div', { 'class': 'hexo-card-title' }, [ - E('span', { 'class': 'hexo-card-title-icon' }, '\uD83D\uDCDD'), - _('Recent Posts') - ]), - E('a', { - 'class': 'hexo-btn hexo-btn-sm hexo-btn-secondary', - 'href': L.url('admin', 'services', 'hexojs', 'posts') - }, _('View All')) - ]), - posts.length > 0 ? - E('table', { 'class': 'hexo-table' }, [ - E('thead', {}, [ - E('tr', {}, [ - E('th', {}, _('Title')), - E('th', {}, _('Date')), - E('th', {}, _('Category')) - ]) - ]), - E('tbody', {}, - posts.slice(0, 5).map(function(post) { - return E('tr', {}, [ - E('td', {}, [ - E('a', { - 'class': 'hexo-post-title', - 'href': L.url('admin', 'services', 'hexojs', 'editor') + '?slug=' + post.slug - }, post.title || post.slug) - ]), - E('td', { 'class': 'hexo-post-meta' }, api.formatDate(post.date)), - E('td', {}, post.categories ? E('span', { 'class': 'hexo-tag category' }, post.categories) : '-') - ]); - }) - ) + // Backups Section + E('div', { 'class': 'k-card' }, [ + E('div', { 'class': 'k-card-title' }, ['\uD83D\uDCBE ', _('Backups')]), + backups.length > 0 + ? E('table', { 'class': 'k-table' }, [ + E('thead', {}, E('tr', {}, [ + E('th', {}, _('Name')), + E('th', {}, _('Size')), + E('th', {}, _('Date')), + E('th', { 'style': 'text-align: right' }, _('Actions')) + ])), + E('tbody', {}, backups.map(backupRow)) + ]) + : E('div', { 'class': 'k-empty' }, [ + E('div', { 'style': 'font-size: 48px; margin-bottom: 12px' }, '\uD83D\uDCBE'), + E('p', {}, _('No backups yet.')) ]) - : E('div', { 'class': 'hexo-empty' }, [ - E('div', { 'class': 'hexo-empty-icon' }, '\uD83D\uDCDD'), - E('p', {}, _('No posts yet. Create your first post!')) - ]) ]) - ]); + ]; - this.startPolling(); - - return KissTheme.wrap([view], 'admin/services/hexojs'); + return KissTheme.wrap(content, 'admin/services/hexojs'); }, handleSaveApply: null, diff --git a/package/secubox/luci-app-hexojs/root/usr/libexec/rpcd/luci.hexojs b/package/secubox/luci-app-hexojs/root/usr/libexec/rpcd/luci.hexojs index 5fb4d955..48ea5a7c 100755 --- a/package/secubox/luci-app-hexojs/root/usr/libexec/rpcd/luci.hexojs +++ b/package/secubox/luci-app-hexojs/root/usr/libexec/rpcd/luci.hexojs @@ -2862,6 +2862,405 @@ get_pipeline_status() { json_dump } +# ============================================ +# Instance Management Methods +# ============================================ + +list_instances() { + json_init + json_add_array "instances" + + for instance in $(uci -q show hexojs | grep "=instance" | sed 's/hexojs\.\([^=]*\)=instance/\1/'); do + local port=$(uci -q get hexojs.${instance}.port) + local enabled=$(uci -q get hexojs.${instance}.enabled) + local title=$(uci -q get hexojs.${instance}.title) + local theme=$(uci -q get hexojs.${instance}.theme) + local domain=$(uci -q get hexojs.${instance}.domain) + local data_path=$(uci_get main.data_path) || data_path="$DATA_PATH" + local site_path="$data_path/instances/$instance/site" + + local running="false" + local site_exists="false" + [ -d "$site_path" ] && site_exists="true" + netstat -tln 2>/dev/null | grep -q ":${port}[[:space:]]" && running="true" + + json_add_object + json_add_string "name" "$instance" + json_add_boolean "enabled" "${enabled:-0}" + json_add_boolean "running" "$([ "$running" = "true" ] && echo 1 || echo 0)" + json_add_int "port" "${port:-4000}" + json_add_string "title" "${title:-$instance Blog}" + json_add_string "theme" "${theme:-cybermind}" + json_add_string "domain" "$domain" + json_add_boolean "site_exists" "$([ "$site_exists" = "true" ] && echo 1 || echo 0)" + json_close_object + done + + json_close_array + json_dump +} + +create_instance() { + read input + json_load "$input" + json_get_var name name + json_get_var title title + json_get_var port port + + json_init + + if [ -z "$name" ]; then + json_add_boolean "success" 0 + json_add_string "error" "Instance name required" + json_dump + return + fi + + # Validate name + echo "$name" | grep -qE '^[a-z][a-z0-9_]*$' || { + json_add_boolean "success" 0 + json_add_string "error" "Invalid name. Use lowercase letters, numbers, underscore." + json_dump + return + } + + # Check if exists + local existing=$(uci -q get hexojs.${name}) + if [ -n "$existing" ]; then + json_add_boolean "success" 0 + json_add_string "error" "Instance '$name' already exists" + json_dump + return + fi + + # Find next available port if not specified + if [ -z "$port" ]; then + port=4000 + while uci -q show hexojs | grep -q "port='$port'"; do + port=$((port + 1)) + done + fi + + # Create UCI config + uci set hexojs.${name}=instance + uci set hexojs.${name}.enabled='1' + uci set hexojs.${name}.port="$port" + uci set hexojs.${name}.title="${title:-$name Blog}" + uci set hexojs.${name}.theme='cybermind' + uci commit hexojs + + # Create directory + local data_path=$(uci_get main.data_path) || data_path="$DATA_PATH" + mkdir -p "$data_path/instances/$name" + + json_add_boolean "success" 1 + json_add_string "message" "Instance '$name' created on port $port" + json_add_string "name" "$name" + json_add_int "port" "$port" + + json_dump +} + +delete_instance() { + read input + json_load "$input" + json_get_var name name + json_get_var delete_data delete_data + + json_init + + if [ -z "$name" ]; then + json_add_boolean "success" 0 + json_add_string "error" "Instance name required" + json_dump + return + fi + + # Stop instance if running + "$HEXOCTL" instance stop "$name" 2>/dev/null + + # Remove UCI config + uci delete hexojs.${name} 2>/dev/null + uci commit hexojs + + # Optionally delete data + if [ "$delete_data" = "1" ]; then + local data_path=$(uci_get main.data_path) || data_path="$DATA_PATH" + rm -rf "$data_path/instances/$name" + fi + + json_add_boolean "success" 1 + json_add_string "message" "Instance '$name' deleted" + + json_dump +} + +start_instance() { + read input + json_load "$input" + json_get_var name name + + json_init + + if [ -z "$name" ]; then + json_add_boolean "success" 0 + json_add_string "error" "Instance name required" + json_dump + return + fi + + local output=$("$HEXOCTL" instance start "$name" 2>&1) + local result=$? + + if [ "$result" -eq 0 ]; then + json_add_boolean "success" 1 + json_add_string "message" "Instance started" + json_add_string "output" "$output" + else + json_add_boolean "success" 0 + json_add_string "error" "$output" + fi + + json_dump +} + +stop_instance() { + read input + json_load "$input" + json_get_var name name + + json_init + + if [ -z "$name" ]; then + json_add_boolean "success" 0 + json_add_string "error" "Instance name required" + json_dump + return + fi + + local output=$("$HEXOCTL" instance stop "$name" 2>&1) + + json_add_boolean "success" 1 + json_add_string "message" "Instance stopped" + + json_dump +} + +# ============================================ +# Backup/Restore Methods +# ============================================ + +list_backups() { + local data_path=$(uci_get main.data_path) || data_path="$DATA_PATH" + local backup_dir="$data_path/backups" + + json_init + json_add_array "backups" + + if [ -d "$backup_dir" ]; then + for f in "$backup_dir"/*.tar.gz; do + [ -f "$f" ] || continue + local name=$(basename "$f" .tar.gz) + local size=$(du -h "$f" | cut -f1) + local ts=$(stat -c %Y "$f" 2>/dev/null || echo 0) + + json_add_object + json_add_string "name" "$name" + json_add_string "size" "$size" + json_add_int "timestamp" "$ts" + json_close_object + done + fi + + json_close_array + json_dump +} + +create_backup() { + read input + json_load "$input" + json_get_var instance instance + json_get_var name name + + json_init + + [ -z "$instance" ] && instance="default" + [ -z "$name" ] && name="$(date +%Y%m%d-%H%M%S)" + + local output=$("$HEXOCTL" backup "$instance" "$name" 2>&1) + local result=$? + + if [ "$result" -eq 0 ]; then + json_add_boolean "success" 1 + json_add_string "message" "Backup created" + json_add_string "name" "${instance}_${name}" + json_add_string "output" "$output" + else + json_add_boolean "success" 0 + json_add_string "error" "$output" + fi + + json_dump +} + +restore_backup() { + read input + json_load "$input" + json_get_var name name + json_get_var instance instance + + json_init + + if [ -z "$name" ]; then + json_add_boolean "success" 0 + json_add_string "error" "Backup name required" + json_dump + return + fi + + [ -z "$instance" ] && instance="default" + + local output=$("$HEXOCTL" restore "$name" "$instance" 2>&1) + local result=$? + + if [ "$result" -eq 0 ]; then + json_add_boolean "success" 1 + json_add_string "message" "Backup restored" + json_add_string "output" "$output" + else + json_add_boolean "success" 0 + json_add_string "error" "$output" + fi + + json_dump +} + +delete_backup() { + read input + json_load "$input" + json_get_var name name + + json_init + + if [ -z "$name" ]; then + json_add_boolean "success" 0 + json_add_string "error" "Backup name required" + json_dump + return + fi + + local output=$("$HEXOCTL" backup delete "$name" 2>&1) + local result=$? + + if [ "$result" -eq 0 ]; then + json_add_boolean "success" 1 + json_add_string "message" "Backup deleted" + else + json_add_boolean "success" 0 + json_add_string "error" "$output" + fi + + json_dump +} + +# ============================================ +# GitHub Integration Methods +# ============================================ + +github_clone() { + read input + json_load "$input" + json_get_var repo repo + json_get_var instance instance + json_get_var branch branch + + json_init + + if [ -z "$repo" ]; then + json_add_boolean "success" 0 + json_add_string "error" "GitHub repo URL required" + json_dump + return + fi + + [ -z "$instance" ] && instance="default" + [ -z "$branch" ] && branch="main" + + local output=$("$HEXOCTL" github clone "$repo" "$instance" "$branch" 2>&1) + local result=$? + + if [ "$result" -eq 0 ]; then + json_add_boolean "success" 1 + json_add_string "message" "GitHub repo cloned successfully" + json_add_string "instance" "$instance" + json_add_string "repo" "$repo" + json_add_string "branch" "$branch" + else + json_add_boolean "success" 0 + json_add_string "error" "$output" + fi + + json_dump +} + +# ============================================ +# Gitea Push Method +# ============================================ + +gitea_push() { + read input + json_load "$input" + json_get_var instance instance + json_get_var message message + + json_init + + [ -z "$instance" ] && instance="default" + [ -z "$message" ] && message="Auto-commit from SecuBox" + + local output=$("$HEXOCTL" gitea push "$instance" "$message" 2>&1) + local result=$? + + if [ "$result" -eq 0 ]; then + json_add_boolean "success" 1 + json_add_string "message" "Changes pushed to Gitea" + json_add_string "output" "$output" + else + json_add_boolean "success" 0 + json_add_string "error" "$output" + fi + + json_dump +} + +# ============================================ +# Quick Publish Method +# ============================================ + +quick_publish() { + read input + json_load "$input" + json_get_var instance instance + + json_init + + [ -z "$instance" ] && instance="default" + + local output=$("$HEXOCTL" quick-publish "$instance" 2>&1) + local result=$? + + if [ "$result" -eq 0 ]; then + json_add_boolean "success" 1 + json_add_string "message" "Quick publish completed" + json_add_string "output" "$output" + else + json_add_boolean "success" 0 + json_add_string "error" "$output" + fi + + json_dump +} + # ============================================ # Service Control # ============================================ @@ -2953,6 +3352,18 @@ case "$1" in "gitea_clone": {}, "gitea_sync": {}, "gitea_save_config": {"enabled": "bool", "gitea_url": "str", "gitea_user": "str", "gitea_token": "str", "content_repo": "str", "content_branch": "str", "auto_sync": "bool"}, + "gitea_push": {"instance": "str", "message": "str"}, + "list_instances": {}, + "create_instance": {"name": "str", "title": "str", "port": "int"}, + "delete_instance": {"name": "str", "delete_data": "bool"}, + "start_instance": {"name": "str"}, + "stop_instance": {"name": "str"}, + "list_backups": {}, + "create_backup": {"instance": "str", "name": "str"}, + "restore_backup": {"name": "str", "instance": "str"}, + "delete_backup": {"name": "str"}, + "github_clone": {"repo": "str", "instance": "str", "branch": "str"}, + "quick_publish": {"instance": "str"}, "publish_to_www": {"path": "str"}, "get_workflow_status": {}, "list_profiles": {}, @@ -3020,6 +3431,18 @@ EOF gitea_clone) gitea_clone ;; gitea_sync) gitea_sync ;; gitea_save_config) gitea_save_config ;; + gitea_push) gitea_push ;; + list_instances) list_instances ;; + create_instance) create_instance ;; + delete_instance) delete_instance ;; + start_instance) start_instance ;; + stop_instance) stop_instance ;; + list_backups) list_backups ;; + create_backup) create_backup ;; + restore_backup) restore_backup ;; + delete_backup) delete_backup ;; + github_clone) github_clone ;; + quick_publish) quick_publish ;; publish_to_www) publish_to_www ;; get_workflow_status) get_workflow_status ;; list_profiles) list_profiles ;; diff --git a/package/secubox/luci-app-hexojs/root/usr/share/rpcd/acl.d/luci-app-hexojs.json b/package/secubox/luci-app-hexojs/root/usr/share/rpcd/acl.d/luci-app-hexojs.json index 778236f0..08bb3d5e 100644 --- a/package/secubox/luci-app-hexojs/root/usr/share/rpcd/acl.d/luci-app-hexojs.json +++ b/package/secubox/luci-app-hexojs/root/usr/share/rpcd/acl.d/luci-app-hexojs.json @@ -23,6 +23,8 @@ "git_log", "git_get_credentials", "gitea_status", + "list_instances", + "list_backups", "get_workflow_status", "list_profiles", "get_haproxy_status", @@ -64,6 +66,16 @@ "gitea_clone", "gitea_sync", "gitea_save_config", + "gitea_push", + "create_instance", + "delete_instance", + "start_instance", + "stop_instance", + "create_backup", + "restore_backup", + "delete_backup", + "github_clone", + "quick_publish", "publish_to_www", "apply_profile", "publish_to_haproxy", diff --git a/package/secubox/luci-app-secubox-portal/htdocs/luci-static/resources/secubox/kiss-theme.js b/package/secubox/luci-app-secubox-portal/htdocs/luci-static/resources/secubox/kiss-theme.js index d4d44418..257f2093 100644 --- a/package/secubox/luci-app-secubox-portal/htdocs/luci-static/resources/secubox/kiss-theme.js +++ b/package/secubox/luci-app-secubox-portal/htdocs/luci-static/resources/secubox/kiss-theme.js @@ -81,7 +81,8 @@ var KissThemeClass = baseclass.extend({ { name: 'Presets', path: 'admin/secubox/network/traffic-shaper/presets' } ]}, { icon: '🌐', name: 'Network Modes', path: 'admin/secubox/network/network-modes' }, - { icon: '🔌', name: 'Interfaces', path: 'admin/network/network' } + { icon: '🔌', name: 'Interfaces', path: 'admin/network/network' }, + { icon: '🌐', name: 'DNS Master', path: 'admin/services/dns-master' } ]}, { cat: 'AI & LLM', icon: '🤖', collapsed: true, items: [ { icon: '🧠', name: 'AI Insights', path: 'admin/secubox/ai/insights' }, diff --git a/package/secubox/secubox-app-dns-master/Makefile b/package/secubox/secubox-app-dns-master/Makefile new file mode 100644 index 00000000..019d38d4 --- /dev/null +++ b/package/secubox/secubox-app-dns-master/Makefile @@ -0,0 +1,37 @@ +include $(TOPDIR)/rules.mk + +PKG_NAME:=secubox-app-dns-master +PKG_VERSION:=1.0.0 +PKG_RELEASE:=1 + +PKG_MAINTAINER:=SecuBox +PKG_LICENSE:=AGPL-3.0 + +include $(INCLUDE_DIR)/package.mk + +define Package/$(PKG_NAME) + SECTION:=secubox + CATEGORY:=SecuBox + SUBMENU:=Apps + TITLE:=SecuBox DNS Master (BIND Zone Management) + DEPENDS:=+bind-server +bind-tools +bind-rndc +jsonfilter + PKGARCH:=all +endef + +define Package/$(PKG_NAME)/description + BIND DNS zone management for SecuBox. + Provides CLI tools for managing DNS zones and records. +endef + +define Build/Compile +endef + +define Package/$(PKG_NAME)/install + $(INSTALL_DIR) $(1)/etc/config + $(INSTALL_CONF) ./files/etc/config/dns-master $(1)/etc/config/ + + $(INSTALL_DIR) $(1)/usr/sbin + $(INSTALL_BIN) ./files/usr/sbin/dnsmaster $(1)/usr/sbin/ +endef + +$(eval $(call BuildPackage,$(PKG_NAME))) diff --git a/package/secubox/secubox-app-dns-master/files/etc/config/dns-master b/package/secubox/secubox-app-dns-master/files/etc/config/dns-master new file mode 100644 index 00000000..b66e89c7 --- /dev/null +++ b/package/secubox/secubox-app-dns-master/files/etc/config/dns-master @@ -0,0 +1,6 @@ +config dns-master 'main' + option enabled '1' + option bind_dir '/etc/bind' + option zones_dir '/etc/bind/zones' + option named_conf '/etc/bind/named.conf' + option default_ttl '300' diff --git a/package/secubox/secubox-app-dns-master/files/usr/sbin/dnsmaster b/package/secubox/secubox-app-dns-master/files/usr/sbin/dnsmaster new file mode 100644 index 00000000..cd652f55 --- /dev/null +++ b/package/secubox/secubox-app-dns-master/files/usr/sbin/dnsmaster @@ -0,0 +1,451 @@ +#!/bin/sh +# SecuBox DNS Master - BIND Zone Management +# Manages DNS zones and records for BIND server + +VERSION="1.0.0" +CONFIG="dns-master" +BIND_DIR="/etc/bind" +ZONES_DIR="/etc/bind/zones" +NAMED_CONF="/etc/bind/named.conf" + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +NC='\033[0m' + +log() { echo -e "${GREEN}[DNS]${NC} $1"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } +error() { echo -e "${RED}[ERROR]${NC} $1"; } + +uci_get() { uci -q get ${CONFIG}.$1; } + +# ============================================================================ +# Status +# ============================================================================ + +cmd_status() { + echo "" + echo "========================================" + echo " SecuBox DNS Master v$VERSION" + echo "========================================" + echo "" + + # BIND status + if pgrep named >/dev/null 2>&1; then + local pid=$(pgrep named | head -1) + echo -e " BIND Status: ${GREEN}Running${NC} (PID: $pid)" + else + echo -e " BIND Status: ${RED}Stopped${NC}" + fi + + # Zone count + local zone_count=$(ls -1 "$ZONES_DIR"/*.zone 2>/dev/null | wc -l) + echo " Zones: $zone_count" + + # Total records + local record_count=0 + for zone in "$ZONES_DIR"/*.zone; do + [ -f "$zone" ] || continue + local cnt=$(grep -cE "^\s*[^;].*\s+IN\s+" "$zone" 2>/dev/null || echo 0) + record_count=$((record_count + cnt)) + done + echo " Records: $record_count" + + echo "" + echo " Zones Directory: $ZONES_DIR" + echo " Config File: $NAMED_CONF" + echo "" +} + +cmd_status_json() { + local running=0 + local pid="" + pgrep named >/dev/null 2>&1 && { running=1; pid=$(pgrep named | head -1); } + + local zone_count=$(ls -1 "$ZONES_DIR"/*.zone 2>/dev/null | wc -l) + local record_count=0 + for zone in "$ZONES_DIR"/*.zone; do + [ -f "$zone" ] || continue + local cnt=$(grep -cE "^\s*[^;].*\s+IN\s+" "$zone" 2>/dev/null || echo 0) + record_count=$((record_count + cnt)) + done + + local default_ttl=$(uci_get main.default_ttl) + default_ttl="${default_ttl:-300}" + + cat << EOF +{ + "running": $([ "$running" = "1" ] && echo true || echo false), + "pid": "$pid", + "zones": $zone_count, + "records": $record_count, + "default_ttl": $default_ttl, + "zones_dir": "$ZONES_DIR", + "bind_dir": "$BIND_DIR" +} +EOF +} + +# ============================================================================ +# Zone Management +# ============================================================================ + +cmd_zone_list() { + echo "Zones:" + echo "" + for zone_file in "$ZONES_DIR"/*.zone; do + [ -f "$zone_file" ] || continue + local name=$(basename "$zone_file" .zone) + local records=$(grep -cE "^\s*[^;].*\s+IN\s+" "$zone_file" 2>/dev/null || echo 0) + local serial=$(grep -oE '[0-9]{10}' "$zone_file" | head -1) + echo " $name ($records records, serial: $serial)" + done +} + +cmd_zone_list_json() { + local zones="" + local first=1 + + for zone_file in "$ZONES_DIR"/*.zone; do + [ -f "$zone_file" ] || continue + local name=$(basename "$zone_file" .zone) + local records=$(grep -cE "^\s*[^;].*\s+IN\s+" "$zone_file" 2>/dev/null || echo 0) + local serial=$(grep -oE '[0-9]{10}' "$zone_file" | head -1) + local mtime=$(stat -c %Y "$zone_file" 2>/dev/null || echo 0) + + # Check zone validity + local valid=1 + named-checkzone "$name" "$zone_file" >/dev/null 2>&1 || valid=0 + + [ $first -eq 0 ] && zones="$zones," + zones="$zones{\"name\":\"$name\",\"file\":\"$zone_file\",\"records\":$records,\"serial\":\"$serial\",\"valid\":$([ $valid -eq 1 ] && echo true || echo false),\"mtime\":$mtime}" + first=0 + done + + echo "{\"zones\":[$zones]}" +} + +cmd_zone_show() { + local zone="$1" + [ -z "$zone" ] && { error "Usage: dnsmaster zone-show "; return 1; } + + local zone_file="$ZONES_DIR/${zone}.zone" + [ -f "$zone_file" ] || { error "Zone file not found: $zone_file"; return 1; } + + echo "Zone: $zone" + echo "File: $zone_file" + echo "" + cat "$zone_file" +} + +cmd_zone_add() { + local zone="$1" + [ -z "$zone" ] && { error "Usage: dnsmaster zone-add "; return 1; } + + local zone_file="$ZONES_DIR/${zone}.zone" + [ -f "$zone_file" ] && { error "Zone already exists"; return 1; } + + local serial=$(date +%Y%m%d01) + local ttl=$(uci_get main.default_ttl) + ttl="${ttl:-300}" + + cat > "$zone_file" << EOF +\$TTL $ttl +@ IN SOA ns1.${zone}. admin.${zone}. ( + $serial ; Serial + 3600 ; Refresh + 600 ; Retry + 604800 ; Expire + $ttl ) ; Negative TTL + +; Nameservers +@ IN NS ns1.${zone}. + +; A records +@ IN A 127.0.0.1 +ns1 IN A 127.0.0.1 +EOF + + log "Created zone: $zone" + log "Edit $zone_file to add records" +} + +# ============================================================================ +# Record Management +# ============================================================================ + +cmd_records_json() { + local zone="$1" + [ -z "$zone" ] && { echo '{"error":"Zone required"}'; return 1; } + + local zone_file="$ZONES_DIR/${zone}.zone" + [ -f "$zone_file" ] || { echo '{"error":"Zone not found"}'; return 1; } + + local records="" + local first=1 + + # Parse zone file for records + # Match: name [ttl] IN type value + while IFS= read -r line; do + # Skip comments and empty lines + echo "$line" | grep -qE '^\s*(;|$)' && continue + # Skip SOA and control directives + echo "$line" | grep -qE '^\$|SOA' && continue + + # Parse record line + local name type value ttl + if echo "$line" | grep -qE '\s+IN\s+'; then + name=$(echo "$line" | awk '{print $1}') + # Check if TTL is present + if echo "$line" | grep -qE '^[^[:space:]]+\s+[0-9]+\s+IN\s+'; then + ttl=$(echo "$line" | awk '{print $2}') + type=$(echo "$line" | awk '{print $4}') + value=$(echo "$line" | cut -d' ' -f5-) + else + ttl="" + type=$(echo "$line" | awk '{print $3}') + value=$(echo "$line" | cut -d' ' -f4-) + fi + + # Clean value (remove trailing comments) + value=$(echo "$value" | sed 's/\s*;.*//') + + # Skip NS records for now (handled separately) + [ "$type" = "NS" ] && continue + + [ $first -eq 0 ] && records="$records," + # Escape quotes in value + value=$(echo "$value" | sed 's/"/\\"/g') + records="$records{\"name\":\"$name\",\"type\":\"$type\",\"value\":\"$value\",\"ttl\":\"$ttl\"}" + first=0 + fi + done < "$zone_file" + + echo "{\"zone\":\"$zone\",\"records\":[$records]}" +} + +cmd_record_add() { + local zone="$1" + local type="$2" + local name="$3" + local value="$4" + local ttl="${5:-}" + + [ -z "$zone" ] || [ -z "$type" ] || [ -z "$name" ] || [ -z "$value" ] && { + error "Usage: dnsmaster record-add [ttl]" + return 1 + } + + local zone_file="$ZONES_DIR/${zone}.zone" + [ -f "$zone_file" ] || { error "Zone not found"; return 1; } + + # Bump serial + bump_serial "$zone_file" + + # Format record line + local record + if [ -n "$ttl" ]; then + record="$name\t$ttl\tIN\t$type\t$value" + else + record="$name\t\tIN\t$type\t$value" + fi + + # Append record to zone file + echo -e "$record" >> "$zone_file" + + log "Added $type record: $name -> $value" + + # Reload BIND + cmd_reload +} + +cmd_record_del() { + local zone="$1" + local type="$2" + local name="$3" + local value="$4" + + [ -z "$zone" ] || [ -z "$type" ] || [ -z "$name" ] && { + error "Usage: dnsmaster record-del [value]" + return 1 + } + + local zone_file="$ZONES_DIR/${zone}.zone" + [ -f "$zone_file" ] || { error "Zone not found"; return 1; } + + # Backup first + cp "$zone_file" "${zone_file}.bak" + + # Bump serial + bump_serial "$zone_file" + + # Delete matching record(s) + if [ -n "$value" ]; then + # Match specific value + sed -i "/^${name}[[:space:]].*IN[[:space:]]*${type}[[:space:]]*${value}/d" "$zone_file" + else + # Match any value for this name/type + sed -i "/^${name}[[:space:]].*IN[[:space:]]*${type}/d" "$zone_file" + fi + + log "Deleted $type record for $name" + + # Reload BIND + cmd_reload +} + +bump_serial() { + local zone_file="$1" + local today=$(date +%Y%m%d) + local current_serial=$(grep -oE '[0-9]{10}' "$zone_file" | head -1) + + local new_serial + if [ "${current_serial:0:8}" = "$today" ]; then + # Same day, increment counter + local counter="${current_serial:8:2}" + counter=$((10#$counter + 1)) + new_serial="${today}$(printf '%02d' $counter)" + else + # New day, reset counter + new_serial="${today}01" + fi + + sed -i "s/$current_serial/$new_serial/" "$zone_file" +} + +# ============================================================================ +# BIND Control +# ============================================================================ + +cmd_reload() { + log "Reloading BIND..." + rndc reload 2>/dev/null || { + warn "rndc reload failed, trying service restart" + /etc/init.d/named restart 2>/dev/null || /etc/init.d/bind restart 2>/dev/null + } + log "BIND reloaded" +} + +cmd_check() { + local zone="$1" + + if [ -n "$zone" ]; then + local zone_file="$ZONES_DIR/${zone}.zone" + [ -f "$zone_file" ] || { error "Zone not found"; return 1; } + + log "Checking zone: $zone" + named-checkzone "$zone" "$zone_file" + return $? + else + # Check all zones + local errors=0 + for zone_file in "$ZONES_DIR"/*.zone; do + [ -f "$zone_file" ] || continue + local name=$(basename "$zone_file" .zone) + if named-checkzone "$name" "$zone_file" >/dev/null 2>&1; then + echo -e "${GREEN}OK${NC} $name" + else + echo -e "${RED}ERROR${NC} $name" + errors=$((errors + 1)) + fi + done + return $errors + fi +} + +cmd_logs() { + local lines="${1:-50}" + logread -l "$lines" 2>/dev/null | grep -i "named\|bind" || \ + tail -n "$lines" /var/log/messages 2>/dev/null | grep -i "named\|bind" || \ + echo "No BIND logs found" +} + +cmd_backup() { + local zone="$1" + local backup_dir="/srv/dns-master/backups" + local timestamp=$(date +%Y%m%d-%H%M%S) + + mkdir -p "$backup_dir" + + if [ -n "$zone" ]; then + local zone_file="$ZONES_DIR/${zone}.zone" + [ -f "$zone_file" ] || { error "Zone not found"; return 1; } + cp "$zone_file" "$backup_dir/${zone}-${timestamp}.zone" + log "Backed up: $zone -> $backup_dir/${zone}-${timestamp}.zone" + else + # Backup all zones + for zone_file in "$ZONES_DIR"/*.zone; do + [ -f "$zone_file" ] || continue + local name=$(basename "$zone_file" .zone) + cp "$zone_file" "$backup_dir/${name}-${timestamp}.zone" + done + log "Backed up all zones to $backup_dir" + fi +} + +# ============================================================================ +# Help +# ============================================================================ + +show_help() { + cat << EOF +SecuBox DNS Master v$VERSION + +Usage: dnsmaster [options] + +Status: + status Show BIND server status + status-json Status in JSON format + +Zones: + zone-list List all zones + zone-list-json List zones in JSON format + zone-show Show zone file contents + zone-add Create new zone + +Records: + records-json Get records as JSON + record-add [ttl] + record-del [value] + +Control: + reload Reload BIND configuration + check [zone] Validate zone file(s) + logs [lines] View BIND logs + backup [zone] Backup zone file(s) + +Examples: + dnsmaster zone-list + dnsmaster record-add secubox.in A www 192.168.1.100 + dnsmaster record-add secubox.in MX @ "10 mail.secubox.in." + dnsmaster record-del secubox.in A www + dnsmaster check secubox.in + +EOF +} + +# ============================================================================ +# Main +# ============================================================================ + +case "${1:-}" in + status) shift; cmd_status "$@" ;; + status-json) shift; cmd_status_json "$@" ;; + zone-list) shift; cmd_zone_list "$@" ;; + zone-list-json) shift; cmd_zone_list_json "$@" ;; + zone-show) shift; cmd_zone_show "$@" ;; + zone-add) shift; cmd_zone_add "$@" ;; + records-json) shift; cmd_records_json "$@" ;; + record-add) shift; cmd_record_add "$@" ;; + record-del) shift; cmd_record_del "$@" ;; + reload) shift; cmd_reload "$@" ;; + check) shift; cmd_check "$@" ;; + logs) shift; cmd_logs "$@" ;; + backup) shift; cmd_backup "$@" ;; + help|--help|-h|'') show_help ;; + *) error "Unknown command: $1"; show_help >&2; exit 1 ;; +esac + +exit 0 diff --git a/package/secubox/secubox-app-hexojs/files/usr/sbin/hexoctl b/package/secubox/secubox-app-hexojs/files/usr/sbin/hexoctl index add47bbb..346506c5 100644 --- a/package/secubox/secubox-app-hexojs/files/usr/sbin/hexoctl +++ b/package/secubox/secubox-app-hexojs/files/usr/sbin/hexoctl @@ -162,6 +162,19 @@ Gitea Integration: gitea setup [instance] Configure git credentials gitea clone [instance] Clone content repo gitea sync [instance] Pull latest content + gitea push [instance] Push changes to Gitea + +GitHub Integration: + github clone [instance] [branch] Clone from GitHub + +Backup/Restore: + backup [instance] [name] Create backup + backup list List all backups + backup delete Delete a backup + restore [instance] Restore from backup + +Quick Commands: + quick-publish [instance] Clean, build, and publish Utility: shell Open shell in container @@ -1171,6 +1184,293 @@ cmd_gitea_sync() { log_info "Content synced for '$instance'" } +cmd_gitea_push() { + require_root + load_config + + local instance="${1:-default}" + local message="${2:-Auto-commit from SecuBox}" + load_instance_config "$instance" || { log_error "Instance not found"; return 1; } + + local content_path="$instance_path/content" + [ -d "$content_path/.git" ] || { log_error "Content not cloned"; return 1; } + + log_info "Pushing changes for '$instance'..." + cd "$content_path" + git add -A + git commit -m "$message" 2>/dev/null || log_info "Nothing to commit" + git push + + log_info "Content pushed for '$instance'" +} + +# GitHub integration (public repos) +cmd_github_clone() { + require_root + load_config + + local repo_url="$1" + local instance="${2:-default}" + local branch="${3:-main}" + + [ -z "$repo_url" ] && { log_error "GitHub repo URL required"; return 1; } + load_instance_config "$instance" || { + log_info "Creating instance '$instance'..." + cmd_instance_create "$instance" + load_instance_config "$instance" + } + + if ! lxc_running; then + log_error "Container not running" + return 1 + fi + + local content_path="$instance_path/content" + log_info "Cloning from GitHub: $repo_url (branch: $branch)" + + ensure_dir "$(dirname "$content_path")" + rm -rf "$content_path" + + git clone -b "$branch" "$repo_url" "$content_path" || { + log_error "Failed to clone from GitHub" + return 1 + } + + # Check if content is a full hexo site + if [ -f "$content_path/package.json" ] && [ -d "$content_path/source" ]; then + log_info "Content is a complete Hexo site, linking..." + lxc_exec sh -c " + rm -rf /opt/hexojs/instances/$instance/site + ln -sf /opt/hexojs/instances/$instance/content /opt/hexojs/instances/$instance/site + cd /opt/hexojs/instances/$instance/site && npm install + " + fi + + log_info "GitHub repo cloned for instance '$instance'" +} + +# Backup/Restore commands +cmd_backup() { + require_root + load_config + + local instance="${1:-default}" + local backup_name="${2:-$(date +%Y%m%d-%H%M%S)}" + + load_instance_config "$instance" || { log_error "Instance not found"; return 1; } + + local backup_dir="$data_path/backups" + ensure_dir "$backup_dir" + + local backup_file="$backup_dir/${instance}_${backup_name}.tar.gz" + + if [ ! -d "$instance_site" ]; then + log_error "No site to backup for instance '$instance'" + return 1 + fi + + log_info "Creating backup: $backup_file" + + # Backup site directory and config + tar -czf "$backup_file" \ + -C "$instance_path" site \ + -C /etc/config hexojs 2>/dev/null || { + log_error "Backup failed" + return 1 + } + + local size=$(du -h "$backup_file" | cut -f1) + log_info "Backup created: $backup_file ($size)" +} + +cmd_restore() { + require_root + load_config + + local backup_name="$1" + local instance="${2:-default}" + + [ -z "$backup_name" ] && { log_error "Backup name required"; return 1; } + + local backup_dir="$data_path/backups" + local backup_file="$backup_dir/$backup_name" + + # Try with and without .tar.gz extension + [ -f "$backup_file" ] || backup_file="$backup_dir/${backup_name}.tar.gz" + [ -f "$backup_file" ] || backup_file="$backup_dir/${instance}_${backup_name}.tar.gz" + [ -f "$backup_file" ] || { log_error "Backup not found: $backup_name"; return 1; } + + load_instance_config "$instance" || { + log_info "Creating instance '$instance'..." + cmd_instance_create "$instance" + load_instance_config "$instance" + } + + # Stop instance if running + cmd_instance_stop "$instance" 2>/dev/null + + log_info "Restoring from: $backup_file" + + # Remove existing site + rm -rf "$instance_site" + ensure_dir "$instance_path" + + # Extract backup + tar -xzf "$backup_file" -C "$instance_path" || { + log_error "Restore failed" + return 1 + } + + log_info "Backup restored for instance '$instance'" + log_info "Start with: hexoctl instance start $instance" +} + +cmd_backup_list() { + load_config + + local backup_dir="$data_path/backups" + [ -d "$backup_dir" ] || { echo "[]"; return; } + + echo "[" + local first=1 + for f in "$backup_dir"/*.tar.gz; do + [ -f "$f" ] || continue + local name=$(basename "$f" .tar.gz) + local size=$(du -h "$f" | cut -f1) + local ts=$(stat -c %Y "$f" 2>/dev/null || stat -f %m "$f" 2>/dev/null) + + [ "$first" = "1" ] || echo "," + first=0 + printf ' {"name": "%s", "size": "%s", "timestamp": %s}' "$name" "$size" "${ts:-0}" + done + echo "" + echo "]" +} + +cmd_backup_delete() { + require_root + load_config + + local backup_name="$1" + [ -z "$backup_name" ] && { log_error "Backup name required"; return 1; } + + local backup_dir="$data_path/backups" + local backup_file="$backup_dir/$backup_name" + + [ -f "$backup_file" ] || backup_file="$backup_dir/${backup_name}.tar.gz" + [ -f "$backup_file" ] || { log_error "Backup not found: $backup_name"; return 1; } + + rm -f "$backup_file" + log_info "Backup deleted: $backup_name" +} + +# Quick publish (build + deploy in one command) +cmd_quick_publish() { + require_root + load_config + + local instance="${1:-default}" + load_instance_config "$instance" || { log_error "Instance not found"; return 1; } + + if ! lxc_running; then + log_error "Container not running" + return 1 + fi + + log_info "Quick publish for '$instance'..." + + # Clean, build, publish + lxc_exec sh -c "cd /opt/hexojs/instances/$instance/site && hexo clean && hexo generate" || { + log_error "Build failed" + return 1 + } + + cmd_publish "$instance" + log_info "Quick publish complete!" +} + +# Status JSON (for RPCD) +cmd_status_json() { + load_config + + local enabled="$(uci_get main.enabled)" + local running="false" + lxc_running && running="true" + + local installed="false" + lxc_exists && installed="true" + + cat << EOF +{ + "enabled": $([ "$enabled" = "1" ] && echo true || echo false), + "running": $running, + "installed": $installed, + "data_path": "$data_path", + "memory_limit": "$memory_limit", + "instances": [ +EOF + + local first=1 + for instance in $(get_enabled_instances); do + load_instance_config "$instance" || continue + local inst_running="false" + if [ "$running" = "true" ]; then + local pid=$(lxc_exec cat /var/run/hexo/$instance.pid 2>/dev/null) + [ -n "$pid" ] && inst_running="true" + fi + local site_exists="false" + [ -d "$instance_site" ] && site_exists="true" + + [ "$first" = "1" ] || printf "," + first=0 + cat << EOF + { + "name": "$instance", + "enabled": $([ "$instance_enabled" = "1" ] && echo true || echo false), + "running": $inst_running, + "port": $instance_port, + "title": "$instance_title", + "theme": "$instance_theme", + "site_exists": $site_exists + } +EOF + done + + echo " ]" + echo "}" +} + +cmd_instance_list_json() { + load_config + + echo "[" + local first=1 + for section in $(uci show hexojs 2>/dev/null | grep '=instance$' | cut -d'.' -f2 | cut -d'=' -f1); do + load_instance_config "$section" + local running="false" + if lxc_running && [ -f "$LXC_ROOTFS/var/run/hexo/${section}.pid" ]; then + running="true" + fi + local site_exists="false" + [ -d "$instance_site" ] && site_exists="true" + + [ "$first" = "1" ] || echo "," + first=0 + cat << EOF + { + "name": "$section", + "enabled": $([ "$instance_enabled" = "1" ] && echo true || echo false), + "running": $running, + "port": $instance_port, + "title": "$instance_title", + "theme": "$instance_theme", + "site_exists": $site_exists + } +EOF + done + echo "]" +} + # Main case "${1:-}" in install) shift; cmd_install "$@" ;; @@ -1238,9 +1538,35 @@ case "${1:-}" in setup) shift; cmd_gitea_setup "$@" ;; clone) shift; cmd_gitea_clone "$@" ;; sync) shift; cmd_gitea_sync "$@" ;; - *) echo "Usage: hexoctl gitea {setup|clone|sync} [instance]" ;; + push) shift; cmd_gitea_push "$@" ;; + *) echo "Usage: hexoctl gitea {setup|clone|sync|push} [instance] [message]" ;; esac ;; + github) + shift + case "${1:-}" in + clone) shift; cmd_github_clone "$@" ;; + *) echo "Usage: hexoctl github clone [instance] [branch]" ;; + esac + ;; + + backup) + shift + case "${1:-}" in + list) shift; cmd_backup_list "$@" ;; + create) shift; cmd_backup "$@" ;; + delete) shift; cmd_backup_delete "$@" ;; + restore) shift; cmd_restore "$@" ;; + "") cmd_backup "$@" ;; + *) cmd_backup "$@" ;; + esac + ;; + + restore) shift; cmd_restore "$@" ;; + quick-publish) shift; cmd_quick_publish "$@" ;; + status-json) cmd_status_json ;; + instance-list-json) cmd_instance_list_json ;; + *) usage ;; esac