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:
CyberMind-FR 2026-02-16 10:26:55 +01:00
parent 16ed7e2d7a
commit c2cd204ea9
16 changed files with 2755 additions and 221 deletions

View File

@ -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)

View File

@ -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

View 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)))

View File

@ -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
});

View File

@ -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

View File

@ -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
}
}

View File

@ -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"]
}
}
}

View File

@ -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,

View File

@ -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,

View File

@ -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 ;;

View File

@ -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",

View File

@ -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' },

View 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)))

View File

@ -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'

View 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

View File

@ -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