diff --git a/package/secubox/luci-app-picobrew/Makefile b/package/secubox/luci-app-picobrew/Makefile new file mode 100644 index 00000000..a2cb580f --- /dev/null +++ b/package/secubox/luci-app-picobrew/Makefile @@ -0,0 +1,29 @@ +# SPDX-License-Identifier: Apache-2.0 +# +# Copyright (C) 2025 CyberMind.fr +# +# LuCI PicoBrew Dashboard + +include $(TOPDIR)/rules.mk + +PKG_NAME:=luci-app-picobrew +PKG_VERSION:=1.0.0 +PKG_RELEASE:=1 +PKG_ARCH:=all + +PKG_LICENSE:=Apache-2.0 +PKG_MAINTAINER:=CyberMind + +LUCI_TITLE:=LuCI PicoBrew Dashboard +LUCI_DESCRIPTION:=Modern dashboard for PicoBrew Server management on OpenWrt +LUCI_DEPENDS:=+luci-base +luci-lib-jsonc +rpcd +rpcd-mod-luci +secubox-app-picobrew + +LUCI_PKGARCH:=all + +PKG_FILE_MODES:=/usr/libexec/rpcd/luci.picobrew:root:root:755 + +include $(TOPDIR)/feeds/luci/luci.mk + +# Note: /etc/config/picobrew is in secubox-app-picobrew + +$(eval $(call BuildPackage,luci-app-picobrew)) diff --git a/package/secubox/luci-app-picobrew/htdocs/luci-static/resources/view/picobrew/dashboard.js b/package/secubox/luci-app-picobrew/htdocs/luci-static/resources/view/picobrew/dashboard.js new file mode 100644 index 00000000..5d5d140a --- /dev/null +++ b/package/secubox/luci-app-picobrew/htdocs/luci-static/resources/view/picobrew/dashboard.js @@ -0,0 +1,803 @@ +'use strict'; +'require view'; +'require ui'; +'require dom'; +'require poll'; +'require rpc'; + +// RPC declarations +var callGetStatus = rpc.declare({ + object: 'luci.picobrew', + method: 'get_status', + expect: { result: {} } +}); + +var callGetConfig = rpc.declare({ + object: 'luci.picobrew', + method: 'get_config', + expect: { result: {} } +}); + +var callStart = rpc.declare({ + object: 'luci.picobrew', + method: 'start', + expect: { result: {} } +}); + +var callStop = rpc.declare({ + object: 'luci.picobrew', + method: 'stop', + expect: { result: {} } +}); + +var callRestart = rpc.declare({ + object: 'luci.picobrew', + method: 'restart', + expect: { result: {} } +}); + +var callInstall = rpc.declare({ + object: 'luci.picobrew', + method: 'install', + expect: { result: {} } +}); + +var callUninstall = rpc.declare({ + object: 'luci.picobrew', + method: 'uninstall', + expect: { result: {} } +}); + +var callUpdate = rpc.declare({ + object: 'luci.picobrew', + method: 'update', + expect: { result: {} } +}); + +var callGetLogs = rpc.declare({ + object: 'luci.picobrew', + method: 'get_logs', + params: ['lines'], + expect: { result: {} } +}); + +var callGetInstallProgress = rpc.declare({ + object: 'luci.picobrew', + method: 'get_install_progress', + expect: { result: {} } +}); + +var callGetSessions = rpc.declare({ + object: 'luci.picobrew', + method: 'get_sessions', + expect: { result: {} } +}); + +var callGetRecipes = rpc.declare({ + object: 'luci.picobrew', + method: 'get_recipes', + expect: { result: {} } +}); + +// CSS styles +var styles = ` +.picobrew-dashboard { + padding: 20px; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; +} + +.pb-header { + background: linear-gradient(135deg, rgba(6, 182, 212, 0.1), rgba(139, 92, 246, 0.1)); + border: 1px solid rgba(6, 182, 212, 0.3); + border-radius: 12px; + padding: 24px; + margin-bottom: 24px; +} + +.pb-header-content { + display: flex; + align-items: center; + gap: 20px; +} + +.pb-logo { + font-size: 48px; +} + +.pb-title { + margin: 0; + font-size: 24px; + color: #06b6d4; +} + +.pb-subtitle { + margin: 4px 0 0 0; + color: #94a3b8; + font-size: 14px; +} + +.pb-stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 16px; + margin-bottom: 24px; +} + +.pb-stat-card { + background: rgba(15, 23, 42, 0.8); + border: 1px solid rgba(51, 65, 85, 0.5); + border-radius: 12px; + padding: 20px; + display: flex; + align-items: center; + gap: 16px; +} + +.pb-stat-card.success { border-color: rgba(16, 185, 129, 0.5); } +.pb-stat-card.warning { border-color: rgba(245, 158, 11, 0.5); } +.pb-stat-card.error { border-color: rgba(244, 63, 94, 0.5); } + +.pb-stat-icon { + font-size: 32px; + width: 48px; + height: 48px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(6, 182, 212, 0.1); + border-radius: 10px; +} + +.pb-stat-content { + flex: 1; +} + +.pb-stat-value { + font-size: 24px; + font-weight: 600; + color: #f1f5f9; +} + +.pb-stat-label { + font-size: 13px; + color: #94a3b8; + margin-top: 2px; +} + +.pb-main-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; +} + +@media (max-width: 900px) { + .pb-main-grid { + grid-template-columns: 1fr; + } +} + +.pb-card { + background: rgba(15, 23, 42, 0.8); + border: 1px solid rgba(51, 65, 85, 0.5); + border-radius: 12px; + overflow: hidden; +} + +.pb-card-header { + padding: 16px 20px; + border-bottom: 1px solid rgba(51, 65, 85, 0.5); + display: flex; + align-items: center; + justify-content: space-between; +} + +.pb-card-title { + font-size: 16px; + font-weight: 600; + color: #f1f5f9; + display: flex; + align-items: center; + gap: 8px; +} + +.pb-card-body { + padding: 20px; +} + +.pb-btn { + padding: 10px 20px; + border-radius: 8px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + border: none; + transition: all 0.2s; + display: inline-flex; + align-items: center; + gap: 8px; +} + +.pb-btn-primary { + background: linear-gradient(135deg, #06b6d4, #0891b2); + color: white; +} + +.pb-btn-primary:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(6, 182, 212, 0.4); +} + +.pb-btn-success { + background: linear-gradient(135deg, #10b981, #059669); + color: white; +} + +.pb-btn-danger { + background: linear-gradient(135deg, #f43f5e, #e11d48); + color: white; +} + +.pb-btn-warning { + background: linear-gradient(135deg, #f59e0b, #d97706); + color: white; +} + +.pb-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none !important; +} + +.pb-btn-group { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.pb-status-badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border-radius: 20px; + font-size: 13px; + font-weight: 500; +} + +.pb-status-badge.running { + background: rgba(16, 185, 129, 0.2); + color: #10b981; +} + +.pb-status-badge.stopped { + background: rgba(244, 63, 94, 0.2); + color: #f43f5e; +} + +.pb-status-badge.not-installed { + background: rgba(245, 158, 11, 0.2); + color: #f59e0b; +} + +.pb-info-list { + list-style: none; + padding: 0; + margin: 0; +} + +.pb-info-list li { + display: flex; + justify-content: space-between; + padding: 10px 0; + border-bottom: 1px solid rgba(51, 65, 85, 0.3); +} + +.pb-info-list li:last-child { + border-bottom: none; +} + +.pb-info-label { + color: #94a3b8; +} + +.pb-info-value { + color: #f1f5f9; + font-weight: 500; +} + +.pb-info-value a { + color: #06b6d4; + text-decoration: none; +} + +.pb-info-value a:hover { + text-decoration: underline; +} + +.pb-logs { + background: #0f172a; + border-radius: 8px; + padding: 12px; + font-family: "Monaco", "Consolas", monospace; + font-size: 12px; + color: #94a3b8; + max-height: 300px; + overflow-y: auto; +} + +.pb-logs-line { + margin: 4px 0; + white-space: pre-wrap; + word-break: break-all; +} + +.pb-progress { + background: rgba(51, 65, 85, 0.5); + border-radius: 8px; + height: 8px; + overflow: hidden; + margin: 16px 0; +} + +.pb-progress-bar { + height: 100%; + background: linear-gradient(90deg, #06b6d4, #8b5cf6); + border-radius: 8px; + transition: width 0.3s ease; +} + +.pb-progress-text { + text-align: center; + color: #94a3b8; + font-size: 13px; + margin-top: 8px; +} + +.pb-empty { + text-align: center; + padding: 40px 20px; + color: #64748b; +} + +.pb-empty-icon { + font-size: 48px; + margin-bottom: 12px; +} +`; + +return view.extend({ + statusData: null, + configData: null, + logsData: null, + installProgress: null, + sessionsData: null, + recipesData: null, + + load: function() { + return this.refreshData(); + }, + + refreshData: function() { + var self = this; + return Promise.all([ + callGetStatus(), + callGetConfig(), + callGetLogs(50), + callGetSessions(), + callGetRecipes() + ]).then(function(data) { + self.statusData = data[0] || {}; + self.configData = data[1] || {}; + self.logsData = data[2] || {}; + self.sessionsData = data[3] || {}; + self.recipesData = data[4] || {}; + return data; + }); + }, + + render: function() { + var self = this; + + // Inject styles + var styleEl = E('style', {}, styles); + + var container = E('div', { 'class': 'picobrew-dashboard' }, [ + styleEl, + this.renderHeader(), + this.renderStatsGrid(), + this.renderMainGrid() + ]); + + // Poll for updates + poll.add(function() { + return self.refreshData().then(function() { + self.updateDynamicContent(); + }); + }, 10); + + return container; + }, + + renderHeader: function() { + var status = this.statusData; + var statusClass = !status.installed ? 'not-installed' : (status.running ? 'running' : 'stopped'); + var statusText = !status.installed ? _('Not Installed') : (status.running ? _('Running') : _('Stopped')); + + return E('div', { 'class': 'pb-header' }, [ + E('div', { 'class': 'pb-header-content' }, [ + E('div', { 'class': 'pb-logo' }, 'đŸē'), + E('div', {}, [ + E('h1', { 'class': 'pb-title' }, _('PicoBrew Server')), + E('p', { 'class': 'pb-subtitle' }, _('Self-hosted brewing controller for PicoBrew devices')) + ]), + E('div', { 'class': 'pb-status-badge ' + statusClass, 'id': 'pb-status-badge' }, [ + E('span', {}, statusClass === 'running' ? '●' : '○'), + statusText + ]) + ]) + ]); + }, + + renderStatsGrid: function() { + var status = this.statusData; + var sessions = (this.sessionsData.sessions || []).length; + var recipes = (this.recipesData.recipes || []).length; + + var stats = [ + { + icon: '🔌', + label: _('Status'), + value: status.running ? _('Online') : _('Offline'), + id: 'stat-status', + cardClass: status.running ? 'success' : 'error' + }, + { + icon: '🌐', + label: _('Port'), + value: status.http_port || '8080', + id: 'stat-port' + }, + { + icon: '📊', + label: _('Sessions'), + value: sessions, + id: 'stat-sessions' + }, + { + icon: '📖', + label: _('Recipes'), + value: recipes, + id: 'stat-recipes' + } + ]; + + return E('div', { 'class': 'pb-stats-grid' }, + stats.map(function(stat) { + return E('div', { 'class': 'pb-stat-card ' + (stat.cardClass || '') }, [ + E('div', { 'class': 'pb-stat-icon' }, stat.icon), + E('div', { 'class': 'pb-stat-content' }, [ + E('div', { 'class': 'pb-stat-value', 'id': stat.id }, String(stat.value)), + E('div', { 'class': 'pb-stat-label' }, stat.label) + ]) + ]); + }) + ); + }, + + renderMainGrid: function() { + return E('div', { 'class': 'pb-main-grid' }, [ + this.renderControlCard(), + this.renderInfoCard(), + this.renderLogsCard() + ]); + }, + + renderControlCard: function() { + var self = this; + var status = this.statusData; + + var buttons = []; + + if (!status.installed) { + buttons.push( + E('button', { + 'class': 'pb-btn pb-btn-primary', + 'id': 'btn-install', + 'click': function() { self.handleInstall(); } + }, [E('span', {}, 'đŸ“Ĩ'), _('Install')]) + ); + } else { + if (status.running) { + buttons.push( + E('button', { + 'class': 'pb-btn pb-btn-danger', + 'id': 'btn-stop', + 'click': function() { self.handleStop(); } + }, [E('span', {}, '⏚'), _('Stop')]) + ); + buttons.push( + E('button', { + 'class': 'pb-btn pb-btn-warning', + 'id': 'btn-restart', + 'click': function() { self.handleRestart(); } + }, [E('span', {}, '🔄'), _('Restart')]) + ); + } else { + buttons.push( + E('button', { + 'class': 'pb-btn pb-btn-success', + 'id': 'btn-start', + 'click': function() { self.handleStart(); } + }, [E('span', {}, 'â–ļ'), _('Start')]) + ); + } + + buttons.push( + E('button', { + 'class': 'pb-btn pb-btn-primary', + 'id': 'btn-update', + 'click': function() { self.handleUpdate(); } + }, [E('span', {}, 'âŦ†'), _('Update')]) + ); + + buttons.push( + E('button', { + 'class': 'pb-btn pb-btn-danger', + 'id': 'btn-uninstall', + 'click': function() { self.handleUninstall(); } + }, [E('span', {}, '🗑'), _('Uninstall')]) + ); + } + + return E('div', { 'class': 'pb-card' }, [ + E('div', { 'class': 'pb-card-header' }, [ + E('div', { 'class': 'pb-card-title' }, [ + E('span', {}, '🎮'), + _('Controls') + ]) + ]), + E('div', { 'class': 'pb-card-body' }, [ + E('div', { 'class': 'pb-btn-group', 'id': 'pb-controls' }, buttons), + E('div', { 'class': 'pb-progress', 'id': 'pb-progress-container', 'style': 'display:none' }, [ + E('div', { 'class': 'pb-progress-bar', 'id': 'pb-progress-bar', 'style': 'width:0%' }) + ]), + E('div', { 'class': 'pb-progress-text', 'id': 'pb-progress-text', 'style': 'display:none' }) + ]) + ]); + }, + + renderInfoCard: function() { + var status = this.statusData; + + var infoItems = [ + { label: _('Container'), value: status.container_name || 'picobrew' }, + { label: _('Data Path'), value: status.data_path || '/srv/picobrew' }, + { label: _('Memory Limit'), value: status.memory_limit || '512M' }, + { label: _('Web Interface'), value: status.web_url, isLink: true } + ]; + + return E('div', { 'class': 'pb-card' }, [ + E('div', { 'class': 'pb-card-header' }, [ + E('div', { 'class': 'pb-card-title' }, [ + E('span', {}, 'â„šī¸'), + _('Information') + ]) + ]), + E('div', { 'class': 'pb-card-body' }, [ + E('ul', { 'class': 'pb-info-list', 'id': 'pb-info-list' }, + infoItems.map(function(item) { + var valueEl; + if (item.isLink && item.value) { + valueEl = E('a', { 'href': item.value, 'target': '_blank' }, item.value); + } else { + valueEl = item.value || '-'; + } + return E('li', {}, [ + E('span', { 'class': 'pb-info-label' }, item.label), + E('span', { 'class': 'pb-info-value' }, valueEl) + ]); + }) + ) + ]) + ]); + }, + + renderLogsCard: function() { + var logs = this.logsData.logs || []; + + return E('div', { 'class': 'pb-card', 'style': 'grid-column: span 2' }, [ + E('div', { 'class': 'pb-card-header' }, [ + E('div', { 'class': 'pb-card-title' }, [ + E('span', {}, '📜'), + _('Logs') + ]) + ]), + E('div', { 'class': 'pb-card-body' }, [ + logs.length > 0 ? + E('div', { 'class': 'pb-logs', 'id': 'pb-logs' }, + logs.map(function(line) { + return E('div', { 'class': 'pb-logs-line' }, line); + }) + ) : + E('div', { 'class': 'pb-empty' }, [ + E('div', { 'class': 'pb-empty-icon' }, '📭'), + E('div', {}, _('No logs available')) + ]) + ]) + ]); + }, + + updateDynamicContent: function() { + var status = this.statusData; + + // Update status badge + var badge = document.getElementById('pb-status-badge'); + if (badge) { + var statusClass = !status.installed ? 'not-installed' : (status.running ? 'running' : 'stopped'); + var statusText = !status.installed ? _('Not Installed') : (status.running ? _('Running') : _('Stopped')); + badge.className = 'pb-status-badge ' + statusClass; + badge.innerHTML = ''; + badge.appendChild(E('span', {}, statusClass === 'running' ? '●' : '○')); + badge.appendChild(document.createTextNode(' ' + statusText)); + } + + // Update stats + var statStatus = document.getElementById('stat-status'); + if (statStatus) { + statStatus.textContent = status.running ? _('Online') : _('Offline'); + } + + // Update logs + var logsContainer = document.getElementById('pb-logs'); + if (logsContainer && this.logsData.logs) { + logsContainer.innerHTML = ''; + this.logsData.logs.forEach(function(line) { + logsContainer.appendChild(E('div', { 'class': 'pb-logs-line' }, line)); + }); + } + }, + + handleInstall: function() { + var self = this; + var btn = document.getElementById('btn-install'); + if (btn) btn.disabled = true; + + ui.showModal(_('Installing PicoBrew Server'), [ + E('p', {}, _('This will download and install PicoBrew Server in an LXC container. This may take several minutes.')), + E('div', { 'class': 'pb-progress' }, [ + E('div', { 'class': 'pb-progress-bar', 'id': 'modal-progress', 'style': 'width:0%' }) + ]), + E('p', { 'id': 'modal-status' }, _('Starting installation...')) + ]); + + callInstall().then(function(result) { + if (result && result.started) { + self.pollInstallProgress(); + } else { + ui.hideModal(); + ui.addNotification(null, E('p', {}, result.message || _('Installation failed')), 'error'); + } + }).catch(function(err) { + ui.hideModal(); + ui.addNotification(null, E('p', {}, _('Installation failed: ') + err.message), 'error'); + }); + }, + + pollInstallProgress: function() { + var self = this; + + var checkProgress = function() { + callGetInstallProgress().then(function(result) { + var progressBar = document.getElementById('modal-progress'); + var statusText = document.getElementById('modal-status'); + + if (progressBar) { + progressBar.style.width = (result.progress || 0) + '%'; + } + if (statusText) { + statusText.textContent = result.message || ''; + } + + if (result.status === 'completed') { + ui.hideModal(); + ui.addNotification(null, E('p', {}, _('PicoBrew Server installed successfully!')), 'success'); + self.refreshData(); + } else if (result.status === 'error') { + ui.hideModal(); + ui.addNotification(null, E('p', {}, _('Installation failed: ') + result.message), 'error'); + } else if (result.status === 'running') { + setTimeout(checkProgress, 3000); + } else { + setTimeout(checkProgress, 3000); + } + }).catch(function() { + setTimeout(checkProgress, 5000); + }); + }; + + setTimeout(checkProgress, 2000); + }, + + handleStart: function() { + var self = this; + callStart().then(function(result) { + if (result && result.success) { + ui.addNotification(null, E('p', {}, _('PicoBrew Server started')), 'success'); + } else { + ui.addNotification(null, E('p', {}, result.message || _('Failed to start')), 'error'); + } + self.refreshData(); + }); + }, + + handleStop: function() { + var self = this; + callStop().then(function(result) { + if (result && result.success) { + ui.addNotification(null, E('p', {}, _('PicoBrew Server stopped')), 'info'); + } else { + ui.addNotification(null, E('p', {}, result.message || _('Failed to stop')), 'error'); + } + self.refreshData(); + }); + }, + + handleRestart: function() { + var self = this; + callRestart().then(function(result) { + if (result && result.success) { + ui.addNotification(null, E('p', {}, _('PicoBrew Server restarted')), 'success'); + } else { + ui.addNotification(null, E('p', {}, result.message || _('Failed to restart')), 'error'); + } + self.refreshData(); + }); + }, + + handleUpdate: function() { + var self = this; + + ui.showModal(_('Updating PicoBrew Server'), [ + E('p', {}, _('Updating PicoBrew Server to the latest version...')), + E('div', { 'class': 'spinner' }) + ]); + + callUpdate().then(function(result) { + ui.hideModal(); + if (result && result.started) { + ui.addNotification(null, E('p', {}, _('Update started. The server will restart automatically.')), 'info'); + } else { + ui.addNotification(null, E('p', {}, result.message || _('Update failed')), 'error'); + } + self.refreshData(); + }); + }, + + handleUninstall: function() { + var self = this; + + ui.showModal(_('Confirm Uninstall'), [ + E('p', {}, _('Are you sure you want to uninstall PicoBrew Server? Your data will be preserved.')), + E('div', { 'class': 'right' }, [ + E('button', { + 'class': 'btn', + 'click': ui.hideModal + }, _('Cancel')), + E('button', { + 'class': 'btn cbi-button-negative', + 'click': function() { + ui.hideModal(); + callUninstall().then(function(result) { + if (result && result.success) { + ui.addNotification(null, E('p', {}, _('PicoBrew Server uninstalled')), 'info'); + } else { + ui.addNotification(null, E('p', {}, result.message || _('Uninstall failed')), 'error'); + } + self.refreshData(); + }); + } + }, _('Uninstall')) + ]) + ]); + } +}); diff --git a/package/secubox/luci-app-picobrew/htdocs/luci-static/resources/view/picobrew/settings.js b/package/secubox/luci-app-picobrew/htdocs/luci-static/resources/view/picobrew/settings.js new file mode 100644 index 00000000..e639982f --- /dev/null +++ b/package/secubox/luci-app-picobrew/htdocs/luci-static/resources/view/picobrew/settings.js @@ -0,0 +1,133 @@ +'use strict'; +'require view'; +'require form'; +'require rpc'; +'require ui'; + +var callRestart = rpc.declare({ + object: 'luci.picobrew', + method: 'restart', + expect: { result: {} } +}); + +return view.extend({ + render: function() { + var m, s, o; + + m = new form.Map('picobrew', _('PicoBrew Settings'), + _('Configure PicoBrew Server settings. Changes require a service restart to take effect.')); + + // Main settings section + s = m.section(form.TypedSection, 'picobrew', _('Server Settings')); + s.anonymous = true; + s.addremove = false; + + o = s.option(form.Flag, 'enabled', _('Enable Service'), + _('Enable or disable the PicoBrew Server service')); + o.default = '0'; + o.rmempty = false; + + o = s.option(form.Value, 'http_port', _('HTTP Port'), + _('Port for the PicoBrew web interface')); + o.datatype = 'port'; + o.default = '8080'; + o.rmempty = false; + + o = s.option(form.Value, 'http_host', _('Listen Address'), + _('IP address to listen on (0.0.0.0 for all interfaces)')); + o.datatype = 'ipaddr'; + o.default = '0.0.0.0'; + o.rmempty = false; + + o = s.option(form.Value, 'data_path', _('Data Path'), + _('Path for storing recipes, sessions, and logs')); + o.default = '/srv/picobrew'; + o.rmempty = false; + + o = s.option(form.ListValue, 'memory_limit', _('Memory Limit'), + _('Maximum memory for the container')); + o.value('256M', '256 MB'); + o.value('512M', '512 MB'); + o.value('768M', '768 MB'); + o.value('1G', '1 GB'); + o.default = '512M'; + + o = s.option(form.ListValue, 'log_level', _('Log Level'), + _('Logging verbosity')); + o.value('DEBUG', 'Debug'); + o.value('INFO', 'Info'); + o.value('WARNING', 'Warning'); + o.value('ERROR', 'Error'); + o.default = 'INFO'; + + // Server section (HTTPS) + s = m.section(form.TypedSection, 'server', _('HTTPS Settings')); + s.anonymous = true; + s.addremove = false; + + o = s.option(form.Value, 'dns_name', _('DNS Name'), + _('Domain name for accessing the server (optional)')); + o.placeholder = 'picobrew.local'; + o.rmempty = true; + + o = s.option(form.Flag, 'https_enabled', _('Enable HTTPS'), + _('Enable HTTPS (requires certificates)')); + o.default = '0'; + + o = s.option(form.Value, 'cert_path', _('Certificate Path'), + _('Path to SSL certificate file')); + o.depends('https_enabled', '1'); + o.rmempty = true; + + o = s.option(form.Value, 'key_path', _('Key Path'), + _('Path to SSL private key file')); + o.depends('https_enabled', '1'); + o.rmempty = true; + + // Brewing defaults section + s = m.section(form.TypedSection, 'brewing', _('Brewing Defaults')); + s.anonymous = true; + s.addremove = false; + + o = s.option(form.ListValue, 'units', _('Units'), + _('Temperature and measurement units')); + o.value('metric', 'Metric (°C, L, kg)'); + o.value('imperial', 'Imperial (°F, gal, lb)'); + o.default = 'metric'; + + o = s.option(form.Value, 'default_boil_temp', _('Default Boil Temperature'), + _('Default boiling temperature')); + o.datatype = 'uinteger'; + o.default = '100'; + o.rmempty = false; + + o = s.option(form.Value, 'default_mash_temp', _('Default Mash Temperature'), + _('Default mashing temperature')); + o.datatype = 'uinteger'; + o.default = '67'; + o.rmempty = false; + + return m.render().then(function(mapEl) { + // Add restart button after the form + var restartBtn = E('button', { + 'class': 'cbi-button cbi-button-apply', + 'style': 'margin-top: 1em;', + 'click': function() { + ui.showModal(_('Restarting Service'), [ + E('p', { 'class': 'spinning' }, _('Restarting PicoBrew Server...')) + ]); + callRestart().then(function() { + ui.hideModal(); + ui.addNotification(null, E('p', {}, _('Service restarted successfully')), 'success'); + }).catch(function(err) { + ui.hideModal(); + ui.addNotification(null, E('p', {}, _('Failed to restart: ') + err.message), 'error'); + }); + } + }, _('Restart Service')); + + var wrapper = E('div', {}, [mapEl, restartBtn]); + return wrapper; + }); + } +}); diff --git a/package/secubox/luci-app-picobrew/root/usr/libexec/rpcd/luci.picobrew b/package/secubox/luci-app-picobrew/root/usr/libexec/rpcd/luci.picobrew new file mode 100644 index 00000000..40ac0fb6 --- /dev/null +++ b/package/secubox/luci-app-picobrew/root/usr/libexec/rpcd/luci.picobrew @@ -0,0 +1,462 @@ +#!/bin/sh +# SPDX-License-Identifier: Apache-2.0 +# LuCI RPC backend for PicoBrew Server +# Copyright (C) 2025 CyberMind.fr + +. /lib/functions.sh +. /usr/share/libubox/jshn.sh + +CONFIG="picobrew" +LXC_NAME="picobrew" +LXC_PATH="/srv/lxc" +REPO_PATH="/srv/picobrew/app" + +# JSON helpers +json_init_obj() { json_init; json_add_object "result"; } +json_close_obj() { json_close_object; json_dump; } + +json_error() { + json_init + json_add_object "error" + json_add_string "message" "$1" + json_close_object + json_dump +} + +json_success() { + json_init_obj + json_add_boolean "success" 1 + [ -n "$1" ] && json_add_string "message" "$1" + json_close_obj +} + +# Check if container is running +lxc_running() { + lxc-info -n "$LXC_NAME" -s 2>/dev/null | grep -q "RUNNING" +} + +# Check if container exists +lxc_exists() { + [ -f "$LXC_PATH/$LXC_NAME/config" ] && [ -d "$LXC_PATH/$LXC_NAME/rootfs" ] +} + +# Get service status +get_status() { + local enabled running installed uptime + local http_port data_path memory_limit + + config_load "$CONFIG" + config_get enabled main enabled "0" + config_get http_port main http_port "8080" + config_get data_path main data_path "/srv/picobrew" + config_get memory_limit main memory_limit "512M" + + running="false" + installed="false" + uptime="" + + if lxc_exists; then + installed="true" + fi + + if lxc_running; then + running="true" + uptime=$(lxc-info -n "$LXC_NAME" 2>/dev/null | grep -i "cpu use" | head -1 | awk '{print $3}') + fi + + # Check if repo exists + local repo_installed="false" + [ -d "$REPO_PATH/.git" ] && repo_installed="true" + + # Get LAN IP for URL + local lan_ip + lan_ip=$(uci -q get network.lan.ipaddr || echo "192.168.1.1") + + json_init_obj + json_add_boolean "enabled" "$( [ "$enabled" = "1" ] && echo 1 || echo 0 )" + json_add_boolean "running" "$( [ "$running" = "true" ] && echo 1 || echo 0 )" + json_add_boolean "installed" "$( [ "$installed" = "true" ] && echo 1 || echo 0 )" + json_add_boolean "repo_installed" "$( [ "$repo_installed" = "true" ] && echo 1 || echo 0 )" + json_add_string "uptime" "$uptime" + json_add_int "http_port" "$http_port" + json_add_string "data_path" "$data_path" + json_add_string "memory_limit" "$memory_limit" + json_add_string "web_url" "http://${lan_ip}:${http_port}" + json_add_string "container_name" "$LXC_NAME" + json_close_obj +} + +# Get configuration +get_config() { + local http_port http_host data_path memory_limit log_level + local dns_name https_enabled cert_path key_path + local default_boil_temp default_mash_temp units + + config_load "$CONFIG" + + # Main settings + config_get http_port main http_port "8080" + config_get http_host main http_host "0.0.0.0" + config_get data_path main data_path "/srv/picobrew" + config_get memory_limit main memory_limit "512M" + config_get log_level main log_level "INFO" + config_get enabled main enabled "0" + + # Server settings + config_get dns_name server dns_name "" + config_get https_enabled server https_enabled "0" + config_get cert_path server cert_path "" + config_get key_path server key_path "" + + # Brewing settings + config_get default_boil_temp brewing default_boil_temp "100" + config_get default_mash_temp brewing default_mash_temp "67" + config_get units brewing units "metric" + + json_init_obj + json_add_object "main" + json_add_boolean "enabled" "$( [ "$enabled" = "1" ] && echo 1 || echo 0 )" + json_add_int "http_port" "$http_port" + json_add_string "http_host" "$http_host" + json_add_string "data_path" "$data_path" + json_add_string "memory_limit" "$memory_limit" + json_add_string "log_level" "$log_level" + json_close_object + + json_add_object "server" + json_add_string "dns_name" "$dns_name" + json_add_boolean "https_enabled" "$( [ "$https_enabled" = "1" ] && echo 1 || echo 0 )" + json_add_string "cert_path" "$cert_path" + json_add_string "key_path" "$key_path" + json_close_object + + json_add_object "brewing" + json_add_int "default_boil_temp" "$default_boil_temp" + json_add_int "default_mash_temp" "$default_mash_temp" + json_add_string "units" "$units" + json_close_object + + json_close_obj +} + +# Set configuration value +set_config() { + local section="$1" + local option="$2" + local value="$3" + + if [ -z "$section" ] || [ -z "$option" ]; then + json_error "Missing section or option" + return + fi + + uci set "${CONFIG}.${section}.${option}=${value}" + uci commit "$CONFIG" + + json_success "Configuration updated" +} + +# Start service +start_service() { + if lxc_running; then + json_error "Service is already running" + return + fi + + if ! lxc_exists; then + json_error "Container not installed. Run install first." + return + fi + + /etc/init.d/picobrew start >/dev/null 2>&1 & + + sleep 2 + if lxc_running; then + json_success "Service started" + else + json_error "Failed to start service" + fi +} + +# Stop service +stop_service() { + if ! lxc_running; then + json_error "Service is not running" + return + fi + + /etc/init.d/picobrew stop >/dev/null 2>&1 + + sleep 2 + if ! lxc_running; then + json_success "Service stopped" + else + json_error "Failed to stop service" + fi +} + +# Restart service +restart_service() { + /etc/init.d/picobrew restart >/dev/null 2>&1 & + + sleep 3 + if lxc_running; then + json_success "Service restarted" + else + json_error "Service restart failed" + fi +} + +# Install PicoBrew +install() { + if lxc_exists; then + json_error "Already installed. Use update to refresh." + return + fi + + # Run install in background + /usr/sbin/picobrewctl install >/var/log/picobrew-install.log 2>&1 & + + json_init_obj + json_add_boolean "started" 1 + json_add_string "message" "Installation started in background" + json_add_string "log_file" "/var/log/picobrew-install.log" + json_close_obj +} + +# Uninstall PicoBrew +uninstall() { + /usr/sbin/picobrewctl uninstall >/dev/null 2>&1 + + if ! lxc_exists; then + json_success "Uninstalled successfully" + else + json_error "Uninstall failed" + fi +} + +# Update PicoBrew +update() { + if ! [ -d "$REPO_PATH/.git" ]; then + json_error "Not installed. Run install first." + return + fi + + # Run update in background + /usr/sbin/picobrewctl update >/var/log/picobrew-update.log 2>&1 & + + json_init_obj + json_add_boolean "started" 1 + json_add_string "message" "Update started in background" + json_add_string "log_file" "/var/log/picobrew-update.log" + json_close_obj +} + +# Get logs +get_logs() { + local lines="${1:-100}" + local data_path + + config_load "$CONFIG" + config_get data_path main data_path "/srv/picobrew" + + json_init_obj + json_add_array "logs" + + # Get container logs from data path + if [ -d "$data_path/logs" ]; then + local logfile + for logfile in "$data_path/logs"/*.log; do + [ -f "$logfile" ] || continue + tail -n "$lines" "$logfile" 2>/dev/null | while IFS= read -r line; do + json_add_string "" "$line" + done + done + fi + + # Also check install/update logs + for logfile in /var/log/picobrew-install.log /var/log/picobrew-update.log; do + [ -f "$logfile" ] || continue + tail -n 50 "$logfile" 2>/dev/null | while IFS= read -r line; do + json_add_string "" "$line" + done + done + + json_close_array + json_close_obj +} + +# Get brewing sessions (placeholder - depends on picobrew-server API) +get_sessions() { + local data_path + config_load "$CONFIG" + config_get data_path main data_path "/srv/picobrew" + + json_init_obj + json_add_array "sessions" + + # List session files if they exist + if [ -d "$data_path/sessions" ]; then + for session in "$data_path/sessions"/*.json; do + [ -f "$session" ] || continue + local name=$(basename "$session" .json) + json_add_object "" + json_add_string "id" "$name" + json_add_string "file" "$session" + json_close_object + done + fi + + json_close_array + json_close_obj +} + +# Get recipes (placeholder - depends on picobrew-server API) +get_recipes() { + local data_path + config_load "$CONFIG" + config_get data_path main data_path "/srv/picobrew" + + json_init_obj + json_add_array "recipes" + + # List recipe files if they exist + if [ -d "$data_path/recipes" ]; then + for recipe in "$data_path/recipes"/*.json; do + [ -f "$recipe" ] || continue + local name=$(basename "$recipe" .json) + json_add_object "" + json_add_string "id" "$name" + json_add_string "file" "$recipe" + json_close_object + done + fi + + json_close_array + json_close_obj +} + +# Check install progress +get_install_progress() { + local log_file="/var/log/picobrew-install.log" + local status="unknown" + local progress=0 + local message="" + + if [ -f "$log_file" ]; then + # Check for completion markers + if grep -q "Installation complete" "$log_file" 2>/dev/null; then + status="completed" + progress=100 + message="Installation completed successfully" + elif grep -q "ERROR" "$log_file" 2>/dev/null; then + status="error" + message=$(grep "ERROR" "$log_file" | tail -1) + else + status="running" + # Estimate progress based on log content + if grep -q "Extracting rootfs" "$log_file" 2>/dev/null; then + progress=60 + message="Extracting container rootfs..." + elif grep -q "Downloading Alpine" "$log_file" 2>/dev/null; then + progress=40 + message="Downloading Alpine rootfs..." + elif grep -q "Cloning" "$log_file" 2>/dev/null; then + progress=20 + message="Cloning PicoBrew repository..." + else + progress=10 + message="Starting installation..." + fi + fi + else + status="not_started" + message="Installation has not been started" + fi + + # Check if process is still running + if pgrep -f "picobrewctl install" >/dev/null 2>&1; then + status="running" + fi + + json_init_obj + json_add_string "status" "$status" + json_add_int "progress" "$progress" + json_add_string "message" "$message" + json_close_obj +} + +# Main RPC handler +case "$1" in + list) + cat <<-EOF + { + "get_status": {}, + "get_config": {}, + "set_config": {"section": "str", "option": "str", "value": "str"}, + "start": {}, + "stop": {}, + "restart": {}, + "install": {}, + "uninstall": {}, + "update": {}, + "get_logs": {"lines": 100}, + "get_sessions": {}, + "get_recipes": {}, + "get_install_progress": {} + } + EOF + ;; + call) + case "$2" in + get_status) + get_status + ;; + get_config) + get_config + ;; + set_config) + read -r input + section=$(echo "$input" | jsonfilter -e '@.section' 2>/dev/null) + option=$(echo "$input" | jsonfilter -e '@.option' 2>/dev/null) + value=$(echo "$input" | jsonfilter -e '@.value' 2>/dev/null) + set_config "$section" "$option" "$value" + ;; + start) + start_service + ;; + stop) + stop_service + ;; + restart) + restart_service + ;; + install) + install + ;; + uninstall) + uninstall + ;; + update) + update + ;; + get_logs) + read -r input + lines=$(echo "$input" | jsonfilter -e '@.lines' 2>/dev/null) + get_logs "${lines:-100}" + ;; + get_sessions) + get_sessions + ;; + get_recipes) + get_recipes + ;; + get_install_progress) + get_install_progress + ;; + *) + json_error "Unknown method: $2" + ;; + esac + ;; +esac diff --git a/package/secubox/luci-app-picobrew/root/usr/share/luci/menu.d/luci-app-picobrew.json b/package/secubox/luci-app-picobrew/root/usr/share/luci/menu.d/luci-app-picobrew.json new file mode 100644 index 00000000..315072cc --- /dev/null +++ b/package/secubox/luci-app-picobrew/root/usr/share/luci/menu.d/luci-app-picobrew.json @@ -0,0 +1,29 @@ +{ + "admin/services/picobrew": { + "title": "PicoBrew", + "order": 85, + "action": { + "type": "firstchild" + }, + "depends": { + "acl": ["luci-app-picobrew"], + "uci": {"picobrew": true} + } + }, + "admin/services/picobrew/dashboard": { + "title": "Dashboard", + "order": 10, + "action": { + "type": "view", + "path": "picobrew/dashboard" + } + }, + "admin/services/picobrew/settings": { + "title": "Settings", + "order": 20, + "action": { + "type": "view", + "path": "picobrew/settings" + } + } +} diff --git a/package/secubox/luci-app-picobrew/root/usr/share/rpcd/acl.d/luci-app-picobrew.json b/package/secubox/luci-app-picobrew/root/usr/share/rpcd/acl.d/luci-app-picobrew.json new file mode 100644 index 00000000..8004a7ee --- /dev/null +++ b/package/secubox/luci-app-picobrew/root/usr/share/rpcd/acl.d/luci-app-picobrew.json @@ -0,0 +1,17 @@ +{ + "luci-app-picobrew": { + "description": "Grant access to PicoBrew Server management", + "read": { + "ubus": { + "luci.picobrew": ["get_status", "get_config", "get_logs", "get_sessions", "get_recipes", "get_install_progress"] + }, + "uci": ["picobrew"] + }, + "write": { + "ubus": { + "luci.picobrew": ["set_config", "start", "stop", "restart", "install", "uninstall", "update"] + }, + "uci": ["picobrew"] + } + } +} diff --git a/package/secubox/secubox-app-picobrew/Makefile b/package/secubox/secubox-app-picobrew/Makefile new file mode 100644 index 00000000..ce843597 --- /dev/null +++ b/package/secubox/secubox-app-picobrew/Makefile @@ -0,0 +1,78 @@ +# SPDX-License-Identifier: MIT +# +# Copyright (C) 2025 CyberMind.fr +# +# SecuBox PicoBrew Server - Homebrew brewing controller + +include $(TOPDIR)/rules.mk + +PKG_NAME:=secubox-app-picobrew +PKG_VERSION:=1.0.0 +PKG_RELEASE:=1 +PKG_ARCH:=all + +PKG_MAINTAINER:=CyberMind Studio +PKG_LICENSE:=MIT + +include $(INCLUDE_DIR)/package.mk + +define Package/secubox-app-picobrew + SECTION:=utils + CATEGORY:=Utilities + PKGARCH:=all + SUBMENU:=SecuBox Apps + TITLE:=SecuBox PicoBrew Server + DEPENDS:=+uci +libuci +jsonfilter +wget-ssl +tar +lxc +lxc-common +git +endef + +define Package/secubox-app-picobrew/description +PicoBrew Server - Self-hosted brewing controller for PicoBrew devices + +Features: +- Control PicoBrew Zymatic, Z, Pico C, and Pico Pro devices +- Recipe management and import from BeerSmith +- Real-time brewing session monitoring +- Temperature and step control +- Session history and logging + +Runs in LXC container with Python/Flask backend. +Configure in /etc/config/picobrew. +endef + +define Package/secubox-app-picobrew/conffiles +/etc/config/picobrew +endef + +define Build/Compile +endef + +define Package/secubox-app-picobrew/install + $(INSTALL_DIR) $(1)/etc/config + $(INSTALL_CONF) ./files/etc/config/picobrew $(1)/etc/config/picobrew + + $(INSTALL_DIR) $(1)/etc/init.d + $(INSTALL_BIN) ./files/etc/init.d/picobrew $(1)/etc/init.d/picobrew + + $(INSTALL_DIR) $(1)/usr/sbin + $(INSTALL_BIN) ./files/usr/sbin/picobrewctl $(1)/usr/sbin/picobrewctl +endef + +define Package/secubox-app-picobrew/postinst +#!/bin/sh +[ -n "$${IPKG_INSTROOT}" ] || { + echo "" + echo "PicoBrew Server installed." + echo "" + echo "To install and start PicoBrew Server:" + echo " picobrewctl install" + echo " /etc/init.d/picobrew start" + echo "" + echo "Web interface: http://:8080" + echo "" + echo "Configure your PicoBrew device to connect to this server." + echo "" +} +exit 0 +endef + +$(eval $(call BuildPackage,secubox-app-picobrew)) diff --git a/package/secubox/secubox-app-picobrew/files/etc/config/picobrew b/package/secubox/secubox-app-picobrew/files/etc/config/picobrew new file mode 100644 index 00000000..eb00fe34 --- /dev/null +++ b/package/secubox/secubox-app-picobrew/files/etc/config/picobrew @@ -0,0 +1,18 @@ +config picobrew 'main' + option enabled '0' + option http_port '8080' + option http_host '0.0.0.0' + option data_path '/srv/picobrew' + option memory_limit '512M' + option log_level 'INFO' + +config server 'server' + option dns_name '' + option https_enabled '0' + option cert_path '' + option key_path '' + +config brewing 'brewing' + option default_boil_temp '100' + option default_mash_temp '67' + option units 'metric' diff --git a/package/secubox/secubox-app-picobrew/files/etc/init.d/picobrew b/package/secubox/secubox-app-picobrew/files/etc/init.d/picobrew new file mode 100644 index 00000000..595e1705 --- /dev/null +++ b/package/secubox/secubox-app-picobrew/files/etc/init.d/picobrew @@ -0,0 +1,45 @@ +#!/bin/sh /etc/rc.common +# SecuBox PicoBrew Server - Homebrew brewing controller +# Copyright (C) 2025 CyberMind.fr + +START=95 +STOP=10 +USE_PROCD=1 + +PROG=/usr/sbin/picobrewctl +CONFIG=picobrew + +start_service() { + local enabled + config_load "$CONFIG" + config_get enabled main enabled '0' + + [ "$enabled" = "1" ] || { + echo "PicoBrew is disabled. Enable with: uci set picobrew.main.enabled=1" + return 0 + } + + procd_open_instance + procd_set_param command "$PROG" service-run + procd_set_param respawn 3600 5 5 + procd_set_param stdout 1 + procd_set_param stderr 1 + procd_close_instance +} + +stop_service() { + "$PROG" service-stop +} + +service_triggers() { + procd_add_reload_trigger "$CONFIG" +} + +reload_service() { + stop + start +} + +status() { + "$PROG" status +} diff --git a/package/secubox/secubox-app-picobrew/files/usr/sbin/picobrewctl b/package/secubox/secubox-app-picobrew/files/usr/sbin/picobrewctl new file mode 100644 index 00000000..f82f7f92 --- /dev/null +++ b/package/secubox/secubox-app-picobrew/files/usr/sbin/picobrewctl @@ -0,0 +1,427 @@ +#!/bin/sh +# SecuBox PicoBrew Server Controller +# Copyright (C) 2025 CyberMind.fr +# +# Manages PicoBrew Server in LXC container + +CONFIG="picobrew" +LXC_NAME="picobrew" + +# Paths +LXC_PATH="/srv/lxc" +LXC_ROOTFS="$LXC_PATH/$LXC_NAME/rootfs" +LXC_CONFIG="$LXC_PATH/$LXC_NAME/config" +REPO_URL="https://github.com/CyberMind-FR/picobrew-server.git" +REPO_PATH="/srv/picobrew/app" + +# Logging +log_info() { echo "[INFO] $*"; logger -t picobrew "$*"; } +log_error() { echo "[ERROR] $*" >&2; logger -t picobrew -p err "$*"; } +log_debug() { [ "$DEBUG" = "1" ] && echo "[DEBUG] $*"; } + +# Helpers +require_root() { + [ "$(id -u)" -eq 0 ] || { + log_error "This command requires root privileges" + exit 1 + } +} + +has_lxc() { command -v lxc-start >/dev/null 2>&1; } +has_git() { command -v git >/dev/null 2>&1; } + +ensure_dir() { [ -d "$1" ] || mkdir -p "$1"; } + +uci_get() { uci -q get ${CONFIG}.$1; } +uci_set() { uci set ${CONFIG}.$1="$2" && uci commit ${CONFIG}; } + +# Load configuration +load_config() { + http_port="$(uci_get main.http_port)" || http_port="8080" + http_host="$(uci_get main.http_host)" || http_host="0.0.0.0" + data_path="$(uci_get main.data_path)" || data_path="/srv/picobrew" + memory_limit="$(uci_get main.memory_limit)" || memory_limit="512M" + log_level="$(uci_get main.log_level)" || log_level="INFO" + dns_name="$(uci_get server.dns_name)" || dns_name="" + https_enabled="$(uci_get server.https_enabled)" || https_enabled="0" + units="$(uci_get brewing.units)" || units="metric" + + ensure_dir "$data_path" + ensure_dir "$data_path/recipes" + ensure_dir "$data_path/sessions" + ensure_dir "$data_path/logs" +} + +# Usage +usage() { + cat < [options] + +Commands: + install Download and install PicoBrew Server + uninstall Remove PicoBrew Server container + update Update PicoBrew Server to latest version + status Show service status + logs Show container logs + shell Open shell in container + + service-run Start service (used by init) + service-stop Stop service (used by init) + +Configuration: + /etc/config/picobrew + +Data directory: + /srv/picobrew + +EOF +} + +# Check prerequisites +lxc_check_prereqs() { + if ! has_lxc; then + log_error "LXC not installed. Install with: opkg install lxc lxc-common" + return 1 + fi + if ! has_git; then + log_error "Git not installed. Install with: opkg install git" + return 1 + fi + return 0 +} + +# Clone or update repo +repo_clone() { + load_config + + if [ -d "$REPO_PATH/.git" ]; then + log_info "Updating PicoBrew Server repository..." + cd "$REPO_PATH" && git pull + else + log_info "Cloning PicoBrew Server repository..." + ensure_dir "$(dirname "$REPO_PATH")" + git clone "$REPO_URL" "$REPO_PATH" + fi +} + +# Create Python LXC rootfs from Alpine +lxc_create_rootfs() { + local rootfs="$LXC_ROOTFS" + local arch=$(uname -m) + + log_info "Creating Alpine rootfs for PicoBrew..." + + ensure_dir "$rootfs" + + # Use Alpine mini rootfs + local alpine_version="3.19" + case "$arch" in + x86_64) alpine_arch="x86_64" ;; + aarch64) alpine_arch="aarch64" ;; + armv7l) alpine_arch="armv7" ;; + *) log_error "Unsupported architecture: $arch"; return 1 ;; + esac + + local alpine_url="https://dl-cdn.alpinelinux.org/alpine/v${alpine_version}/releases/${alpine_arch}/alpine-minirootfs-${alpine_version}.0-${alpine_arch}.tar.gz" + local tmpfile="/tmp/alpine-rootfs.tar.gz" + + log_info "Downloading Alpine rootfs..." + wget -q -O "$tmpfile" "$alpine_url" || { + log_error "Failed to download Alpine rootfs" + return 1 + } + + log_info "Extracting rootfs..." + tar -xzf "$tmpfile" -C "$rootfs" || { + log_error "Failed to extract rootfs" + return 1 + } + rm -f "$tmpfile" + + # Setup resolv.conf + cp /etc/resolv.conf "$rootfs/etc/resolv.conf" 2>/dev/null || \ + echo "nameserver 1.1.1.1" > "$rootfs/etc/resolv.conf" + + # Create startup script + cat > "$rootfs/opt/start-picobrew.sh" << 'STARTUP' +#!/bin/sh +set -e + +# Install Python and dependencies on first run +if [ ! -f /opt/.installed ]; then + echo "Installing Python and dependencies..." + apk update + apk add --no-cache python3 py3-pip py3-flask py3-requests py3-pyyaml git + + # Install PicoBrew dependencies + if [ -d /app ]; then + cd /app + pip3 install --break-system-packages -r requirements.txt 2>/dev/null || \ + pip3 install -r requirements.txt 2>/dev/null || true + fi + + touch /opt/.installed +fi + +# Start PicoBrew Server +cd /app +export FLASK_APP=app.py +export FLASK_ENV=production +export PICOBREW_HOST="${PICOBREW_HOST:-0.0.0.0}" +export PICOBREW_PORT="${PICOBREW_PORT:-8080}" +export PICOBREW_LOG_LEVEL="${PICOBREW_LOG_LEVEL:-INFO}" + +echo "Starting PicoBrew Server on ${PICOBREW_HOST}:${PICOBREW_PORT}..." +exec python3 -m flask run --host="$PICOBREW_HOST" --port="$PICOBREW_PORT" +STARTUP + chmod +x "$rootfs/opt/start-picobrew.sh" + + log_info "Rootfs created successfully" + return 0 +} + +# Create LXC config +lxc_create_config() { + load_config + + ensure_dir "$(dirname "$LXC_CONFIG")" + + # Convert memory limit to bytes + local mem_bytes + case "$memory_limit" in + *G|*g) mem_bytes=$((${memory_limit%[Gg]} * 1024 * 1024 * 1024)) ;; + *M|*m) mem_bytes=$((${memory_limit%[Mm]} * 1024 * 1024)) ;; + *K|*k) mem_bytes=$((${memory_limit%[Kk]} * 1024)) ;; + *) mem_bytes="$memory_limit" ;; + esac + + cat > "$LXC_CONFIG" << EOF +# PicoBrew Server LXC Configuration +lxc.uts.name = $LXC_NAME +lxc.rootfs.path = dir:$LXC_ROOTFS +lxc.arch = $(uname -m) + +# Network: use host network for device discovery +lxc.net.0.type = none + +# Mount points +lxc.mount.auto = proc:mixed sys:ro cgroup:mixed +lxc.mount.entry = $REPO_PATH app none bind,create=dir 0 0 +lxc.mount.entry = $data_path/recipes app/recipes none bind,create=dir 0 0 +lxc.mount.entry = $data_path/sessions app/sessions none bind,create=dir 0 0 +lxc.mount.entry = $data_path/logs logs none bind,create=dir 0 0 + +# Environment +lxc.environment = PICOBREW_HOST=$http_host +lxc.environment = PICOBREW_PORT=$http_port +lxc.environment = PICOBREW_LOG_LEVEL=$log_level +lxc.environment = PICOBREW_UNITS=$units + +# Security +lxc.cap.drop = sys_admin sys_module mac_admin mac_override sys_time sys_rawio + +# Resource limits +lxc.cgroup.memory.limit_in_bytes = $mem_bytes + +# Init command +lxc.init.cmd = /opt/start-picobrew.sh +EOF + + log_info "LXC config created" +} + +# Container control +lxc_running() { + lxc-info -n "$LXC_NAME" -s 2>/dev/null | grep -q "RUNNING" +} + +lxc_exists() { + [ -f "$LXC_CONFIG" ] && [ -d "$LXC_ROOTFS" ] +} + +lxc_stop() { + if lxc_running; then + log_info "Stopping PicoBrew container..." + lxc-stop -n "$LXC_NAME" -k 2>/dev/null || true + sleep 2 + fi +} + +lxc_run() { + load_config + lxc_stop + + if ! lxc_exists; then + log_error "Container not installed. Run: picobrewctl install" + return 1 + fi + + # Regenerate config in case settings changed + lxc_create_config + + log_info "Starting PicoBrew container..." + exec lxc-start -n "$LXC_NAME" -F -f "$LXC_CONFIG" +} + +# Commands +cmd_install() { + require_root + load_config + + log_info "Installing PicoBrew Server..." + + lxc_check_prereqs || exit 1 + + # Clone repository + repo_clone || exit 1 + + # Create container + if ! lxc_exists; then + lxc_create_rootfs || exit 1 + fi + + lxc_create_config || exit 1 + + # Enable service + uci_set main.enabled '1' + /etc/init.d/picobrew enable 2>/dev/null || true + + log_info "Installation complete!" + log_info "" + log_info "Start with: /etc/init.d/picobrew start" + log_info "Web interface: http://:$http_port" +} + +cmd_uninstall() { + require_root + + log_info "Uninstalling PicoBrew Server..." + + # Stop service + /etc/init.d/picobrew stop 2>/dev/null || true + /etc/init.d/picobrew disable 2>/dev/null || true + + lxc_stop + + # Remove container (keep data) + if [ -d "$LXC_PATH/$LXC_NAME" ]; then + rm -rf "$LXC_PATH/$LXC_NAME" + log_info "Container removed" + fi + + uci_set main.enabled '0' + + log_info "PicoBrew Server uninstalled" + log_info "Data preserved in: $(uci_get main.data_path)" +} + +cmd_update() { + require_root + load_config + + log_info "Updating PicoBrew Server..." + + # Update repo + repo_clone || exit 1 + + # Recreate container to get fresh dependencies + lxc_stop + if [ -d "$LXC_ROOTFS" ]; then + rm -rf "$LXC_ROOTFS" + fi + lxc_create_rootfs || exit 1 + + # Restart if was running + if [ "$(uci_get main.enabled)" = "1" ]; then + /etc/init.d/picobrew restart + fi + + log_info "Update complete!" +} + +cmd_status() { + load_config + + local enabled="$(uci_get main.enabled)" + local running="false" + local uptime="" + + if lxc_running; then + running="true" + uptime=$(lxc-info -n "$LXC_NAME" 2>/dev/null | grep -i "cpu use" | head -1) + fi + + cat << EOF +PicoBrew Server Status +===================== +Enabled: $([ "$enabled" = "1" ] && echo "yes" || echo "no") +Running: $([ "$running" = "true" ] && echo "yes" || echo "no") +HTTP Port: $http_port +Data Path: $data_path +Memory: $memory_limit + +Container: $LXC_NAME +Rootfs: $LXC_ROOTFS +Config: $LXC_CONFIG + +EOF + + if [ "$running" = "true" ]; then + echo "Web interface: http://$(uci -q get network.lan.ipaddr || echo "localhost"):$http_port" + fi +} + +cmd_logs() { + load_config + + if [ -d "$data_path/logs" ]; then + if [ -n "$(ls -A "$data_path/logs" 2>/dev/null)" ]; then + tail -f "$data_path/logs"/*.log 2>/dev/null || \ + cat "$data_path/logs"/*.log 2>/dev/null || \ + echo "No logs found" + else + echo "No logs yet" + fi + else + echo "Log directory not found" + fi +} + +cmd_shell() { + require_root + + if ! lxc_running; then + log_error "Container not running" + exit 1 + fi + + lxc-attach -n "$LXC_NAME" -- /bin/sh +} + +cmd_service_run() { + require_root + load_config + + lxc_check_prereqs || exit 1 + lxc_run +} + +cmd_service_stop() { + require_root + lxc_stop +} + +# Main +case "${1:-}" in + install) shift; cmd_install "$@" ;; + uninstall) shift; cmd_uninstall "$@" ;; + update) shift; cmd_update "$@" ;; + status) shift; cmd_status "$@" ;; + logs) shift; cmd_logs "$@" ;; + shell) shift; cmd_shell "$@" ;; + service-run) shift; cmd_service_run "$@" ;; + service-stop) shift; cmd_service_stop "$@" ;; + *) usage ;; +esac