feat(hexojs): Multi-instance enhancement with backup/restore and Git integration
- Add backup/restore commands to hexoctl (backup, restore, backup list/delete) - Add GitHub clone support (hexoctl github clone <url> [instance] [branch]) - Add Gitea push support (hexoctl gitea push [instance] [message]) - Add quick-publish command (clean + build + publish in one step) - Add 15 new RPCD methods for instance/backup/git management - Rewrite LuCI dashboard with KISS theme: - Multi-instance management with status 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 - Update API with 12 new RPC declarations - Update ACL with new permissions Also includes DNS Master app created in previous session. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
16ed7e2d7a
commit
c2cd204ea9
@ -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 <name>` - Delete backup
|
||||
- `hexoctl restore <name> [instance]` - Restore from backup
|
||||
- Added GitHub clone support:
|
||||
- `hexoctl github clone <repo_url> [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)
|
||||
|
||||
@ -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 <url> [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
|
||||
|
||||
29
package/secubox/luci-app-dns-master/Makefile
Normal file
29
package/secubox/luci-app-dns-master/Makefile
Normal file
@ -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)))
|
||||
@ -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
|
||||
});
|
||||
@ -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
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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 = '<span class="hexo-status-dot"></span>' + (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,
|
||||
|
||||
@ -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 ;;
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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' },
|
||||
|
||||
37
package/secubox/secubox-app-dns-master/Makefile
Normal file
37
package/secubox/secubox-app-dns-master/Makefile
Normal file
@ -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 <info@secubox.in>
|
||||
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)))
|
||||
@ -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'
|
||||
451
package/secubox/secubox-app-dns-master/files/usr/sbin/dnsmaster
Normal file
451
package/secubox/secubox-app-dns-master/files/usr/sbin/dnsmaster
Normal file
@ -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 <zone>"; 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 <zone>"; 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 <zone> <type> <name> <value> [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 <zone> <type> <name> [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 <command> [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 <zone> Show zone file contents
|
||||
zone-add <zone> Create new zone
|
||||
|
||||
Records:
|
||||
records-json <zone> Get records as JSON
|
||||
record-add <zone> <type> <name> <value> [ttl]
|
||||
record-del <zone> <type> <name> [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
|
||||
@ -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 <url> [instance] [branch] Clone from GitHub
|
||||
|
||||
Backup/Restore:
|
||||
backup [instance] [name] Create backup
|
||||
backup list List all backups
|
||||
backup delete <name> Delete a backup
|
||||
restore <name> [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 <repo_url> [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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user