feat(streamlit+haproxy): Enhanced instance management and ACME cron
Streamlit Instances: - Add Publish button with HAProxy integration (uses instance port) - Add Edit dialog for modifying instance settings - Replace enable/disable buttons with checkbox - Get LAN IP dynamically from status data - Bump luci-app-streamlit to r8 HAProxy: - Add haproxy-acme-cron script for background cert processing - Cron runs every 5 minutes to issue pending ACME certificates - Prevents UI blocking during certificate issuance - Bump secubox-app-haproxy to r19 RPCD: - Fix json_error to return consistent format with json_success Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
2de769dcab
commit
d6861fe732
@ -8,7 +8,7 @@ include $(TOPDIR)/rules.mk
|
|||||||
|
|
||||||
PKG_NAME:=luci-app-streamlit
|
PKG_NAME:=luci-app-streamlit
|
||||||
PKG_VERSION:=1.0.0
|
PKG_VERSION:=1.0.0
|
||||||
PKG_RELEASE:=6
|
PKG_RELEASE:=8
|
||||||
PKG_ARCH:=all
|
PKG_ARCH:=all
|
||||||
|
|
||||||
PKG_LICENSE:=Apache-2.0
|
PKG_LICENSE:=Apache-2.0
|
||||||
|
|||||||
@ -6,22 +6,62 @@
|
|||||||
'require rpc';
|
'require rpc';
|
||||||
'require streamlit.api as api';
|
'require streamlit.api as api';
|
||||||
|
|
||||||
|
// HAProxy RPC calls for publishing
|
||||||
|
var haproxyCreateBackend = rpc.declare({
|
||||||
|
object: 'luci.haproxy',
|
||||||
|
method: 'create_backend',
|
||||||
|
params: ['name', 'mode', 'balance', 'health_check', 'enabled'],
|
||||||
|
expect: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
var haproxyCreateServer = rpc.declare({
|
||||||
|
object: 'luci.haproxy',
|
||||||
|
method: 'create_server',
|
||||||
|
params: ['backend', 'name', 'address', 'port', 'weight', 'check', 'enabled'],
|
||||||
|
expect: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
var haproxyCreateVhost = rpc.declare({
|
||||||
|
object: 'luci.haproxy',
|
||||||
|
method: 'create_vhost',
|
||||||
|
params: ['domain', 'backend', 'ssl', 'ssl_redirect', 'acme', 'enabled'],
|
||||||
|
expect: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
var haproxyReload = rpc.declare({
|
||||||
|
object: 'luci.haproxy',
|
||||||
|
method: 'reload',
|
||||||
|
expect: {}
|
||||||
|
});
|
||||||
|
|
||||||
return view.extend({
|
return view.extend({
|
||||||
instancesData: [],
|
instancesData: [],
|
||||||
appsData: [],
|
appsData: [],
|
||||||
|
statusData: {},
|
||||||
|
|
||||||
load: function() {
|
load: function() {
|
||||||
return this.refreshData();
|
return this.refreshData();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getLanIp: function() {
|
||||||
|
if (this.statusData && this.statusData.web_url) {
|
||||||
|
var match = this.statusData.web_url.match(/\/\/([^:\/]+)/);
|
||||||
|
if (match) return match[1];
|
||||||
|
}
|
||||||
|
// Fallback: get from network config
|
||||||
|
return '192.168.255.1';
|
||||||
|
},
|
||||||
|
|
||||||
refreshData: function() {
|
refreshData: function() {
|
||||||
var self = this;
|
var self = this;
|
||||||
return Promise.all([
|
return Promise.all([
|
||||||
api.listInstances(),
|
api.listInstances(),
|
||||||
api.listApps()
|
api.listApps(),
|
||||||
|
api.getStatus()
|
||||||
]).then(function(results) {
|
]).then(function(results) {
|
||||||
self.instancesData = results[0] || [];
|
self.instancesData = results[0] || [];
|
||||||
self.appsData = results[1] || {};
|
self.appsData = results[1] || {};
|
||||||
|
self.statusData = results[2] || {};
|
||||||
return results;
|
return results;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@ -107,7 +147,7 @@ return view.extend({
|
|||||||
E('th', {}, _('ID')),
|
E('th', {}, _('ID')),
|
||||||
E('th', {}, _('App')),
|
E('th', {}, _('App')),
|
||||||
E('th', {}, _('Port')),
|
E('th', {}, _('Port')),
|
||||||
E('th', {}, _('Status')),
|
E('th', { 'style': 'text-align: center;' }, _('Enabled')),
|
||||||
E('th', {}, _('Actions'))
|
E('th', {}, _('Actions'))
|
||||||
])
|
])
|
||||||
]),
|
]),
|
||||||
@ -119,9 +159,20 @@ return view.extend({
|
|||||||
|
|
||||||
renderInstanceRow: function(inst) {
|
renderInstanceRow: function(inst) {
|
||||||
var self = this;
|
var self = this;
|
||||||
var statusBadge = inst.enabled ?
|
|
||||||
E('span', { 'class': 'st-app-badge active' }, _('ENABLED')) :
|
// Enable/disable checkbox
|
||||||
E('span', { 'class': 'st-app-badge', 'style': 'background: #64748b;' }, _('DISABLED'));
|
var enableCheckbox = E('input', {
|
||||||
|
'type': 'checkbox',
|
||||||
|
'checked': inst.enabled,
|
||||||
|
'style': 'width: 18px; height: 18px; cursor: pointer;',
|
||||||
|
'change': function() {
|
||||||
|
if (this.checked) {
|
||||||
|
self.handleEnable(inst.id);
|
||||||
|
} else {
|
||||||
|
self.handleDisable(inst.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return E('tr', {}, [
|
return E('tr', {}, [
|
||||||
E('td', {}, [
|
E('td', {}, [
|
||||||
@ -132,20 +183,19 @@ return view.extend({
|
|||||||
E('td', {}, [
|
E('td', {}, [
|
||||||
E('code', { 'style': 'background: #334155; padding: 2px 6px; border-radius: 4px;' }, ':' + inst.port)
|
E('code', { 'style': 'background: #334155; padding: 2px 6px; border-radius: 4px;' }, ':' + inst.port)
|
||||||
]),
|
]),
|
||||||
E('td', {}, statusBadge),
|
E('td', { 'style': 'text-align: center;' }, enableCheckbox),
|
||||||
E('td', {}, [
|
E('td', {}, [
|
||||||
E('div', { 'class': 'st-btn-group' }, [
|
E('div', { 'class': 'st-btn-group' }, [
|
||||||
inst.enabled ?
|
E('button', {
|
||||||
E('button', {
|
'class': 'st-btn',
|
||||||
'class': 'st-btn',
|
'style': 'padding: 5px 10px; font-size: 12px; background: #7c3aed; color: #fff;',
|
||||||
'style': 'padding: 5px 10px; font-size: 12px; background: #64748b;',
|
'click': function() { self.showPublishWizard(inst); }
|
||||||
'click': function() { self.handleDisable(inst.id); }
|
}, ['\uD83C\uDF10 ', _('Publish')]),
|
||||||
}, _('Disable')) :
|
E('button', {
|
||||||
E('button', {
|
'class': 'st-btn',
|
||||||
'class': 'st-btn st-btn-success',
|
'style': 'padding: 5px 10px; font-size: 12px; background: #0ea5e9;',
|
||||||
'style': 'padding: 5px 10px; font-size: 12px;',
|
'click': function() { self.showEditDialog(inst); }
|
||||||
'click': function() { self.handleEnable(inst.id); }
|
}, ['\u270F ', _('Edit')]),
|
||||||
}, _('Enable')),
|
|
||||||
E('button', {
|
E('button', {
|
||||||
'class': 'st-btn st-btn-danger',
|
'class': 'st-btn st-btn-danger',
|
||||||
'style': 'padding: 5px 10px; font-size: 12px;',
|
'style': 'padding: 5px 10px; font-size: 12px;',
|
||||||
@ -372,5 +422,227 @@ return view.extend({
|
|||||||
ui.hideModal();
|
ui.hideModal();
|
||||||
ui.addNotification(null, E('p', {}, _('Error: ') + err.message), 'error');
|
ui.addNotification(null, E('p', {}, _('Error: ') + err.message), 'error');
|
||||||
});
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
showPublishWizard: function(inst) {
|
||||||
|
var self = this;
|
||||||
|
var lanIp = this.getLanIp();
|
||||||
|
var port = inst.port;
|
||||||
|
|
||||||
|
ui.showModal(_('Publish Instance to Web'), [
|
||||||
|
E('div', { 'style': 'margin-bottom: 16px;' }, [
|
||||||
|
E('p', { 'style': 'margin-bottom: 12px;' }, [
|
||||||
|
_('Configure HAProxy to expose '),
|
||||||
|
E('strong', {}, inst.id),
|
||||||
|
_(' (port '),
|
||||||
|
E('code', {}, port),
|
||||||
|
_(') via a custom domain.')
|
||||||
|
])
|
||||||
|
]),
|
||||||
|
E('div', { 'style': 'margin-bottom: 12px;' }, [
|
||||||
|
E('label', { 'style': 'display: block; margin-bottom: 4px; font-weight: bold;' }, _('Domain Name')),
|
||||||
|
E('input', {
|
||||||
|
'type': 'text',
|
||||||
|
'id': 'publish-domain',
|
||||||
|
'style': 'width: 100%; padding: 8px; border: 1px solid #334155; background: #1e293b; color: #fff; border-radius: 4px;',
|
||||||
|
'placeholder': inst.id + '.example.com'
|
||||||
|
}),
|
||||||
|
E('small', { 'style': 'color: #64748b;' }, _('Enter the domain that will route to this instance'))
|
||||||
|
]),
|
||||||
|
E('div', { 'style': 'margin-bottom: 12px;' }, [
|
||||||
|
E('label', { 'style': 'display: block; margin-bottom: 4px;' }, [
|
||||||
|
E('input', {
|
||||||
|
'type': 'checkbox',
|
||||||
|
'id': 'publish-ssl',
|
||||||
|
'checked': true,
|
||||||
|
'style': 'margin-right: 8px;'
|
||||||
|
}),
|
||||||
|
_('Enable SSL (HTTPS)')
|
||||||
|
])
|
||||||
|
]),
|
||||||
|
E('div', { 'style': 'margin-bottom: 12px;' }, [
|
||||||
|
E('label', { 'style': 'display: block; margin-bottom: 4px;' }, [
|
||||||
|
E('input', {
|
||||||
|
'type': 'checkbox',
|
||||||
|
'id': 'publish-acme',
|
||||||
|
'checked': true,
|
||||||
|
'style': 'margin-right: 8px;'
|
||||||
|
}),
|
||||||
|
_('Auto-request Let\'s Encrypt certificate (via cron)')
|
||||||
|
])
|
||||||
|
]),
|
||||||
|
E('div', { 'style': 'background: #334155; padding: 12px; border-radius: 4px; margin-bottom: 16px;' }, [
|
||||||
|
E('p', { 'style': 'margin: 0; font-size: 13px;' }, [
|
||||||
|
_('Backend: '),
|
||||||
|
E('code', {}, lanIp + ':' + port)
|
||||||
|
])
|
||||||
|
]),
|
||||||
|
E('div', { 'class': 'right' }, [
|
||||||
|
E('button', {
|
||||||
|
'class': 'btn',
|
||||||
|
'click': ui.hideModal
|
||||||
|
}, _('Cancel')),
|
||||||
|
E('button', {
|
||||||
|
'class': 'btn cbi-button-positive',
|
||||||
|
'style': 'margin-left: 8px;',
|
||||||
|
'click': function() {
|
||||||
|
var domain = document.getElementById('publish-domain').value.trim();
|
||||||
|
var ssl = document.getElementById('publish-ssl').checked;
|
||||||
|
var acme = document.getElementById('publish-acme').checked;
|
||||||
|
|
||||||
|
if (!domain) {
|
||||||
|
ui.addNotification(null, E('p', {}, _('Please enter a domain name')), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.publishInstance(inst, domain, lanIp, port, ssl, acme);
|
||||||
|
}
|
||||||
|
}, ['\uD83D\uDE80 ', _('Publish')])
|
||||||
|
])
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
|
||||||
|
publishInstance: function(inst, domain, backendIp, backendPort, ssl, acme) {
|
||||||
|
var self = this;
|
||||||
|
var backendName = 'streamlit_' + inst.id;
|
||||||
|
|
||||||
|
ui.hideModal();
|
||||||
|
ui.showModal(_('Publishing...'), [
|
||||||
|
E('p', { 'class': 'spinning' }, _('Creating HAProxy configuration...'))
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Step 1: Create backend
|
||||||
|
haproxyCreateBackend(backendName, 'http', 'roundrobin', 'httpchk', '1')
|
||||||
|
.then(function(result) {
|
||||||
|
if (result && result.error) {
|
||||||
|
throw new Error(result.error);
|
||||||
|
}
|
||||||
|
// Step 2: Create server
|
||||||
|
return haproxyCreateServer(backendName, inst.id, backendIp, backendPort.toString(), '100', '1', '1');
|
||||||
|
})
|
||||||
|
.then(function(result) {
|
||||||
|
if (result && result.error) {
|
||||||
|
throw new Error(result.error);
|
||||||
|
}
|
||||||
|
// Step 3: Create vhost
|
||||||
|
var sslFlag = ssl ? '1' : '0';
|
||||||
|
var acmeFlag = acme ? '1' : '0';
|
||||||
|
return haproxyCreateVhost(domain, backendName, sslFlag, sslFlag, acmeFlag, '1');
|
||||||
|
})
|
||||||
|
.then(function(result) {
|
||||||
|
if (result && result.error) {
|
||||||
|
throw new Error(result.error);
|
||||||
|
}
|
||||||
|
// Step 4: Reload HAProxy
|
||||||
|
return haproxyReload();
|
||||||
|
})
|
||||||
|
.then(function() {
|
||||||
|
ui.hideModal();
|
||||||
|
var msg = acme ?
|
||||||
|
_('Instance published! Certificate will be requested via cron.') :
|
||||||
|
_('Instance published successfully!');
|
||||||
|
ui.addNotification(null, E('p', {}, [
|
||||||
|
msg,
|
||||||
|
E('br'),
|
||||||
|
_('URL: '),
|
||||||
|
E('a', {
|
||||||
|
'href': (ssl ? 'https://' : 'http://') + domain,
|
||||||
|
'target': '_blank',
|
||||||
|
'style': 'color: #0ff;'
|
||||||
|
}, (ssl ? 'https://' : 'http://') + domain)
|
||||||
|
]), 'success');
|
||||||
|
})
|
||||||
|
.catch(function(err) {
|
||||||
|
ui.hideModal();
|
||||||
|
ui.addNotification(null, E('p', {}, _('Publish failed: ') + (err.message || err)), 'error');
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
showEditDialog: function(inst) {
|
||||||
|
var self = this;
|
||||||
|
var appsList = this.appsData.apps || [];
|
||||||
|
|
||||||
|
// Build app options
|
||||||
|
var appOptions = appsList.map(function(app) {
|
||||||
|
var selected = (inst.app === app.name + '.py') ? { 'selected': 'selected' } : {};
|
||||||
|
return E('option', Object.assign({ 'value': app.name + '.py' }, selected), app.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.showModal(_('Edit Instance: ') + inst.id, [
|
||||||
|
E('div', { 'class': 'st-form-group', 'style': 'margin-bottom: 12px;' }, [
|
||||||
|
E('label', { 'style': 'display: block; margin-bottom: 4px; font-weight: bold;' }, _('Display Name')),
|
||||||
|
E('input', {
|
||||||
|
'type': 'text',
|
||||||
|
'id': 'edit-inst-name',
|
||||||
|
'value': inst.name || inst.id,
|
||||||
|
'style': 'width: 100%; padding: 8px; border: 1px solid #334155; background: #1e293b; color: #fff; border-radius: 4px;'
|
||||||
|
})
|
||||||
|
]),
|
||||||
|
E('div', { 'class': 'st-form-group', 'style': 'margin-bottom: 12px;' }, [
|
||||||
|
E('label', { 'style': 'display: block; margin-bottom: 4px; font-weight: bold;' }, _('App File')),
|
||||||
|
E('select', {
|
||||||
|
'id': 'edit-inst-app',
|
||||||
|
'style': 'width: 100%; padding: 8px; border: 1px solid #334155; background: #1e293b; color: #fff; border-radius: 4px; height: 42px;'
|
||||||
|
}, appOptions)
|
||||||
|
]),
|
||||||
|
E('div', { 'class': 'st-form-group', 'style': 'margin-bottom: 12px;' }, [
|
||||||
|
E('label', { 'style': 'display: block; margin-bottom: 4px; font-weight: bold;' }, _('Port')),
|
||||||
|
E('input', {
|
||||||
|
'type': 'number',
|
||||||
|
'id': 'edit-inst-port',
|
||||||
|
'value': inst.port,
|
||||||
|
'min': '1024',
|
||||||
|
'max': '65535',
|
||||||
|
'style': 'width: 100%; padding: 8px; border: 1px solid #334155; background: #1e293b; color: #fff; border-radius: 4px;'
|
||||||
|
})
|
||||||
|
]),
|
||||||
|
E('div', { 'class': 'right', 'style': 'margin-top: 16px;' }, [
|
||||||
|
E('button', {
|
||||||
|
'class': 'btn',
|
||||||
|
'click': ui.hideModal
|
||||||
|
}, _('Cancel')),
|
||||||
|
E('button', {
|
||||||
|
'class': 'btn cbi-button-positive',
|
||||||
|
'style': 'margin-left: 8px;',
|
||||||
|
'click': function() {
|
||||||
|
var name = document.getElementById('edit-inst-name').value.trim();
|
||||||
|
var app = document.getElementById('edit-inst-app').value;
|
||||||
|
var port = parseInt(document.getElementById('edit-inst-port').value, 10);
|
||||||
|
|
||||||
|
if (!app) {
|
||||||
|
ui.addNotification(null, E('p', {}, _('Please select an app')), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!port || port < 1024 || port > 65535) {
|
||||||
|
ui.addNotification(null, E('p', {}, _('Please enter a valid port')), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.saveInstanceEdit(inst.id, name, app, port);
|
||||||
|
}
|
||||||
|
}, ['\uD83D\uDCBE ', _('Save')])
|
||||||
|
])
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
|
||||||
|
saveInstanceEdit: function(id, name, app, port) {
|
||||||
|
var self = this;
|
||||||
|
ui.hideModal();
|
||||||
|
|
||||||
|
// For now, we remove and re-add (since there's no update API)
|
||||||
|
// TODO: Add update_instance to the API
|
||||||
|
api.removeInstance(id).then(function() {
|
||||||
|
return api.addInstance(id, name, app, port);
|
||||||
|
}).then(function(result) {
|
||||||
|
if (result && result.success) {
|
||||||
|
ui.addNotification(null, E('p', {}, _('Instance updated: ') + id), 'success');
|
||||||
|
self.refreshData();
|
||||||
|
} else {
|
||||||
|
ui.addNotification(null, E('p', {}, result.message || _('Failed to update instance')), 'error');
|
||||||
|
}
|
||||||
|
}).catch(function(err) {
|
||||||
|
ui.addNotification(null, E('p', {}, _('Error: ') + err.message), 'error');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -16,11 +16,10 @@ json_init_obj() { json_init; json_add_object "result"; }
|
|||||||
json_close_obj() { json_close_object; json_dump; }
|
json_close_obj() { json_close_object; json_dump; }
|
||||||
|
|
||||||
json_error() {
|
json_error() {
|
||||||
json_init
|
json_init_obj
|
||||||
json_add_object "error"
|
json_add_boolean "success" 0
|
||||||
json_add_string "message" "$1"
|
json_add_string "message" "$1"
|
||||||
json_close_object
|
json_close_obj
|
||||||
json_dump
|
|
||||||
}
|
}
|
||||||
|
|
||||||
json_success() {
|
json_success() {
|
||||||
|
|||||||
@ -6,7 +6,7 @@ include $(TOPDIR)/rules.mk
|
|||||||
|
|
||||||
PKG_NAME:=secubox-app-haproxy
|
PKG_NAME:=secubox-app-haproxy
|
||||||
PKG_VERSION:=1.0.0
|
PKG_VERSION:=1.0.0
|
||||||
PKG_RELEASE:=18
|
PKG_RELEASE:=19
|
||||||
|
|
||||||
PKG_MAINTAINER:=CyberMind <contact@cybermind.fr>
|
PKG_MAINTAINER:=CyberMind <contact@cybermind.fr>
|
||||||
PKG_LICENSE:=MIT
|
PKG_LICENSE:=MIT
|
||||||
@ -51,6 +51,7 @@ define Package/secubox-app-haproxy/install
|
|||||||
$(INSTALL_DIR) $(1)/usr/sbin
|
$(INSTALL_DIR) $(1)/usr/sbin
|
||||||
$(INSTALL_BIN) ./files/usr/sbin/haproxyctl $(1)/usr/sbin/haproxyctl
|
$(INSTALL_BIN) ./files/usr/sbin/haproxyctl $(1)/usr/sbin/haproxyctl
|
||||||
$(INSTALL_BIN) ./files/usr/sbin/haproxy-sync-certs $(1)/usr/sbin/haproxy-sync-certs
|
$(INSTALL_BIN) ./files/usr/sbin/haproxy-sync-certs $(1)/usr/sbin/haproxy-sync-certs
|
||||||
|
$(INSTALL_BIN) ./files/usr/sbin/haproxy-acme-cron $(1)/usr/sbin/haproxy-acme-cron
|
||||||
|
|
||||||
$(INSTALL_DIR) $(1)/usr/lib/acme/deploy
|
$(INSTALL_DIR) $(1)/usr/lib/acme/deploy
|
||||||
$(INSTALL_BIN) ./files/usr/lib/acme/deploy/haproxy.sh $(1)/usr/lib/acme/deploy/haproxy.sh
|
$(INSTALL_BIN) ./files/usr/lib/acme/deploy/haproxy.sh $(1)/usr/lib/acme/deploy/haproxy.sh
|
||||||
@ -60,10 +61,13 @@ define Package/secubox-app-haproxy/install
|
|||||||
|
|
||||||
$(INSTALL_DIR) $(1)/usr/share/haproxy/certs
|
$(INSTALL_DIR) $(1)/usr/share/haproxy/certs
|
||||||
|
|
||||||
# Add cron job for certificate sync after ACME renewals
|
# Add cron jobs for certificate management
|
||||||
$(INSTALL_DIR) $(1)/etc/cron.d
|
$(INSTALL_DIR) $(1)/etc/cron.d
|
||||||
echo "# Sync ACME certs to HAProxy after renewals" > $(1)/etc/cron.d/haproxy-certs
|
echo "# HAProxy certificate management" > $(1)/etc/cron.d/haproxy-certs
|
||||||
|
echo "# Sync ACME certs to HAProxy after renewals" >> $(1)/etc/cron.d/haproxy-certs
|
||||||
echo "15 3 * * * root /usr/sbin/haproxy-sync-certs >/dev/null 2>&1" >> $(1)/etc/cron.d/haproxy-certs
|
echo "15 3 * * * root /usr/sbin/haproxy-sync-certs >/dev/null 2>&1" >> $(1)/etc/cron.d/haproxy-certs
|
||||||
|
echo "# Process pending ACME certificate requests (every 5 min)" >> $(1)/etc/cron.d/haproxy-certs
|
||||||
|
echo "*/5 * * * * root /usr/sbin/haproxy-acme-cron >/dev/null 2>&1" >> $(1)/etc/cron.d/haproxy-certs
|
||||||
endef
|
endef
|
||||||
|
|
||||||
define Package/secubox-app-haproxy/postinst
|
define Package/secubox-app-haproxy/postinst
|
||||||
|
|||||||
@ -0,0 +1,80 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# HAProxy ACME Certificate Background Processor
|
||||||
|
# Processes pending ACME certificate requests via cron
|
||||||
|
# Copyright (C) 2025 CyberMind.fr
|
||||||
|
|
||||||
|
LOCK_FILE="/var/run/haproxy-acme-cron.lock"
|
||||||
|
LOG_TAG="haproxy-acme-cron"
|
||||||
|
CERTS_PATH="/srv/haproxy/certs"
|
||||||
|
|
||||||
|
log_info() { logger -t "$LOG_TAG" "$*"; }
|
||||||
|
log_error() { logger -t "$LOG_TAG" -p err "$*"; }
|
||||||
|
|
||||||
|
# Prevent concurrent execution
|
||||||
|
if [ -f "$LOCK_FILE" ]; then
|
||||||
|
pid=$(cat "$LOCK_FILE" 2>/dev/null)
|
||||||
|
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
rm -f "$LOCK_FILE"
|
||||||
|
fi
|
||||||
|
echo $$ > "$LOCK_FILE"
|
||||||
|
trap "rm -f $LOCK_FILE" EXIT
|
||||||
|
|
||||||
|
# Check if haproxyctl exists
|
||||||
|
[ -x /usr/sbin/haproxyctl ] || exit 0
|
||||||
|
|
||||||
|
# Load UCI functions
|
||||||
|
. /lib/functions.sh
|
||||||
|
|
||||||
|
# Find vhosts that need ACME certificates
|
||||||
|
process_pending_certs() {
|
||||||
|
local pending_domains=""
|
||||||
|
|
||||||
|
# Callback to check each vhost
|
||||||
|
check_vhost() {
|
||||||
|
local section="$1"
|
||||||
|
local domain acme ssl enabled cert_file
|
||||||
|
|
||||||
|
config_get domain "$section" domain ""
|
||||||
|
config_get acme "$section" acme "0"
|
||||||
|
config_get ssl "$section" ssl "0"
|
||||||
|
config_get enabled "$section" enabled "1"
|
||||||
|
|
||||||
|
# Skip if not enabled, no SSL, or no ACME
|
||||||
|
[ "$enabled" != "1" ] && return
|
||||||
|
[ "$ssl" != "1" ] && return
|
||||||
|
[ "$acme" != "1" ] && return
|
||||||
|
[ -z "$domain" ] && return
|
||||||
|
|
||||||
|
# Check if certificate exists and is valid
|
||||||
|
cert_file="$CERTS_PATH/$domain.pem"
|
||||||
|
if [ ! -f "$cert_file" ]; then
|
||||||
|
log_info "Certificate missing for $domain - queuing for ACME"
|
||||||
|
pending_domains="$pending_domains $domain"
|
||||||
|
elif ! openssl x509 -checkend 604800 -noout -in "$cert_file" 2>/dev/null; then
|
||||||
|
# Certificate expires in less than 7 days
|
||||||
|
log_info "Certificate expiring soon for $domain - queuing for renewal"
|
||||||
|
pending_domains="$pending_domains $domain"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
config_load haproxy
|
||||||
|
config_foreach check_vhost vhost
|
||||||
|
|
||||||
|
# Process pending domains
|
||||||
|
for domain in $pending_domains; do
|
||||||
|
log_info "Processing ACME certificate for: $domain"
|
||||||
|
/usr/sbin/haproxyctl cert add "$domain" >/dev/null 2>&1
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
log_info "Certificate issued successfully for: $domain"
|
||||||
|
else
|
||||||
|
log_error "Failed to issue certificate for: $domain"
|
||||||
|
fi
|
||||||
|
# Small delay between certificate requests
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# Run the processor
|
||||||
|
process_pending_certs
|
||||||
Loading…
Reference in New Issue
Block a user