diff --git a/luci-app-haproxy-manager/Makefile b/luci-app-haproxy-manager/Makefile new file mode 100644 index 00000000..6cc3d122 --- /dev/null +++ b/luci-app-haproxy-manager/Makefile @@ -0,0 +1,60 @@ +# +# Copyright (C) 2026 HAProxy Manager contributors +# +# This is free software, licensed under the MIT License. +# + +include $(TOPDIR)/rules.mk + +PKG_VERSION:=0.6.1 +PKG_RELEASE:=1 +PKG_LICENSE:=MIT +PKG_MAINTAINER:=Philipp Shklyaev +LUCI_MAINTAINER:=$(PKG_MAINTAINER) + +LUCI_TITLE:=LuCI HAProxy Manager +LUCI_DESCRIPTION:=Manage Web and TCP HAProxy services, firewall access, and recovery points. +LUCI_DEPENDS:=+luci-base +rpcd +rpcd-mod-file +haproxy +LUCI_PKGARCH:=all + +define Package/luci-app-haproxy-manager/conffiles +/etc/config/haproxy_manager +endef + +define Package/luci-app-haproxy-manager/postinst +#!/bin/sh +[ -n "$${IPKG_INSTROOT}" ] || { + /usr/libexec/haproxy-manager/migrate >/dev/null 2>&1 || logger -t haproxy-manager "Configuration migration failed" + rm -f /tmp/luci-indexcache.* 2>/dev/null || true + rm -rf /tmp/luci-modulecache/ 2>/dev/null || true + /etc/init.d/rpcd reload >/dev/null 2>&1 || true + /usr/libexec/haproxy-manager/recover package install >/dev/null 2>&1 & +} +exit 0 +endef + +define Package/luci-app-haproxy-manager/prerm +#!/bin/sh +case "$${PKG_UPGRADE:-0}:$${1:-}" in + 1:*|*:upgrade) exit 0 ;; +esac +[ -n "$${IPKG_INSTROOT}" ] || { + /usr/libexec/haproxy-manager/uninstall >/dev/null 2>&1 || + logger -t haproxy-manager "Package runtime cleanup failed" +} +exit 0 +endef + +define Package/luci-app-haproxy-manager/postrm +#!/bin/sh +[ -n "$${IPKG_INSTROOT}" ] || { + rm -f /tmp/luci-indexcache.* 2>/dev/null || true + rm -rf /tmp/luci-modulecache/ 2>/dev/null || true + /etc/init.d/rpcd reload >/dev/null 2>&1 || true +} +exit 0 +endef + +include $(TOPDIR)/feeds/luci/luci.mk + +# call BuildPackage - OpenWrt buildroot signature diff --git a/luci-app-haproxy-manager/htdocs/luci-static/resources/haproxy-manager/style.css b/luci-app-haproxy-manager/htdocs/luci-static/resources/haproxy-manager/style.css new file mode 100644 index 00000000..a6000915 --- /dev/null +++ b/luci-app-haproxy-manager/htdocs/luci-static/resources/haproxy-manager/style.css @@ -0,0 +1,305 @@ +.hm-toolbar { + display: flex; + align-items: center; + gap: 0.75rem; + margin: 0 0 1.25rem; +} + +.hm-filter { + appearance: none; + box-sizing: border-box; + width: min(100%, 28rem); + min-height: 2.5rem; + padding: 0.5rem 0.75rem; + border: 1px solid var(--border-color-medium, rgba(128, 128, 128, 0.45)); + border-radius: 4px; + outline: 0; + background: var(--background-color-high, rgba(128, 128, 128, 0.06)); + color: inherit; + font: inherit; +} + +.hm-filter:focus { + border-color: var(--primary-color-medium, currentColor); + box-shadow: 0 0 0 2px rgba(128, 128, 128, 0.18); +} + +.hm-route-count { + white-space: nowrap; + opacity: 0.72; +} + +.hm-endpoint { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.5rem; + min-width: 0; +} + +.hm-endpoint-value, +.hm-destination { + overflow-wrap: anywhere; +} + +.hm-port-summary { + opacity: 0.72; + white-space: nowrap; +} + +.hm-port-list { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} + +.hm-port-list code, +.hm-destination code { + white-space: nowrap; +} + +.hm-destination { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.hm-badge, +.hm-state { + display: inline-flex; + align-items: center; + width: max-content; + min-height: 1.6rem; + padding: 0 0.45rem; + border: 1px solid rgba(128, 128, 128, 0.45); + border-radius: 4px; + font-size: 0.82em; + font-weight: 600; + line-height: 1.2; + white-space: nowrap; +} + +.hm-state-off { + opacity: 0.6; +} + +.hm-state-danger { + border-color: var(--error-color, #b94a48); + color: var(--error-color, #b94a48); +} + +.hm-filter-empty { + margin-top: 1rem; +} + +.cbi-section-table-row[hidden], +.hm-filter-empty[hidden] { + display: none !important; +} + +.hm-route-name { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.5rem; + min-width: 0; +} + +.hm-service-cell { + display: flex; + align-items: flex-start; + flex-direction: column; + gap: 0.4rem; +} + +.hm-mobile-only { + display: none; +} + +.hm-actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + flex-wrap: wrap; +} + +.hm-notification { + margin: 0; + white-space: pre-wrap; + word-break: break-word; +} + +.hm-incident-report { + box-sizing: border-box; + max-height: min(60vh, 36rem); + margin: 1rem 0; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; +} + +.hm-inline-alert { + margin-bottom: 1rem; +} + +.hm-status-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 1px; + margin: 1.25rem 0 2rem; + border: 1px solid rgba(128, 128, 128, 0.35); + border-radius: 4px; + overflow: hidden; +} + +.hm-status-item { + min-width: 0; + padding: 1rem; + background: var(--background-color-low, rgba(128, 128, 128, 0.08)); +} + +.hm-status-label { + display: block; + margin-bottom: 0.35rem; + font-size: 0.85em; + opacity: 0.72; +} + +.hm-status-value { + display: block; + overflow-wrap: anywhere; + font-size: 1.05em; + font-weight: 600; +} + +.hm-listener-table code { + white-space: nowrap; +} + +.hm-section-heading { + margin-top: 2rem; +} + +.hm-section-title-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.hm-section-title-row h3 { + margin: 0; +} + +.hm-section-heading > p { + margin: 0.25rem 0 0.75rem; +} + +.hm-backup-latest { + margin-left: 0.5rem; +} + +.hm-backup-table .cbi-section-actions { + text-align: right; +} + +.hm-raw-editor { + box-sizing: border-box; + width: 100%; + min-height: 32rem; + font-family: monospace; + line-height: 1.45; + tab-size: 2; +} + +@media (max-width: 900px) { + .hm-status-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 600px) { + .hm-toolbar { + align-items: stretch; + flex-direction: column; + } + + .hm-filter { + width: 100%; + } + + .hm-status-grid { + grid-template-columns: 1fr; + } + + .hm-section-title-row { + align-items: stretch; + flex-direction: column; + } + + .hm-section-title-row .cbi-button { + width: 100%; + } + + .hm-actions > .btn, + .hm-actions > .cbi-button { + flex: 1 1 100%; + } + + .hm-mobile-only { + display: inline-flex; + } + + .cbi-section-table-row > .td[data-name="_status"] { + display: none !important; + } + + .hm-route-table .cbi-section-table-row[data-sid] { + display: grid !important; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.75rem 1rem; + padding: 1rem !important; + } + + .hm-route-table .cbi-section-table-row[data-sid] > .td { + display: block; + box-sizing: border-box; + width: auto !important; + min-width: 0 !important; + padding: 0 !important; + border: 0 !important; + } + + .hm-route-table .cbi-section-table-row[data-sid] > .td::before { + display: none !important; + content: none !important; + } + + .hm-route-table .cbi-section-table-row[data-sid] > .td[data-name="_service"], + .hm-route-table .cbi-section-table-row[data-sid] > .td[data-name="_endpoint"] { + grid-column: 1 / -1; + } + + .hm-route-table .cbi-section-table-row[data-sid] > .td[data-name="_destination"] { + align-self: center; + grid-column: 1 / -1; + } + + .hm-route-table .cbi-section-table-row[data-sid] > .cbi-section-actions { + align-self: center; + grid-column: 1 / -1; + } + + .hm-route-table .cbi-section-table-row[data-sid] > .td[data-name="_firewall"] { + grid-column: 1 / -1; + } + + .hm-route-table .cbi-section-table-row[data-sid] > .cbi-section-actions > div { + display: flex; + gap: 0.35rem; + } + + .hm-raw-editor { + min-height: 24rem; + } +} diff --git a/luci-app-haproxy-manager/htdocs/luci-static/resources/haproxy-manager/ui.js b/luci-app-haproxy-manager/htdocs/luci-static/resources/haproxy-manager/ui.js new file mode 100644 index 00000000..c0f9bbea --- /dev/null +++ b/luci-app-haproxy-manager/htdocs/luci-static/resources/haproxy-manager/ui.js @@ -0,0 +1,97 @@ +'use strict'; +'require baseclass'; +'require fs'; +'require rpc'; +'require ui'; + +var callUciCommit = rpc.declare({ + object: 'uci', + method: 'commit', + params: [ 'config' ], + reject: true +}); +var APPLY_LOCK_BUSY = 75; + +function recoveryId(output) { + var path = String(output || '').trim(); + var id = path.split('/').pop(); + + if (!/^\d{8}-\d{6}$/.test(id)) + throw new Error(_('Unable to create a recovery point.')); + + return id; +} + +return baseclass.extend({ + ensureStyles: function() { + if (document.querySelector('link[data-haproxy-manager-style]')) + return; + + document.head.appendChild(E('link', { + 'rel': 'stylesheet', + 'href': L.resource('haproxy-manager/style.css'), + 'data-haproxy-manager-style': '1' + })); + }, + + notify: function(message, level) { + ui.addNotification(null, E('pre', { 'class': 'hm-notification' }, String(message || '').trim()), level || 'info'); + }, + + notifyError: function(error) { + this.notify(error && error.message ? error.message : String(error), 'danger'); + }, + + exec: function(path, args) { + return fs.exec(path, args || []).then(function(result) { + if (!result.code) + return result; + + var message = String(result.stderr || result.stdout || + _('Command failed with code %d.').format(result.code)).trim(); + var error = new Error(message); + error.code = result.code; + error.stdout = result.stdout; + error.stderr = result.stderr; + throw error; + }); + }, + + commitAndApply: function() { + var backupId; + var committed = false; + + return this.exec('/usr/libexec/haproxy-manager/backup', []).then(function(result) { + backupId = recoveryId(result.stdout); + return callUciCommit('haproxy_manager'); + }).then(function() { + committed = true; + return this.exec('/usr/libexec/haproxy-manager/apply', [ '--backup', backupId ]); + }.bind(this)).catch(function(error) { + if (!committed || !backupId || error.code !== APPLY_LOCK_BUSY) + throw error; + + return this.exec('/usr/libexec/haproxy-manager/rollback', [ backupId ]).catch(function() { + return null; + }).then(function() { + throw error; + }); + }.bind(this)); + }, + + saveAndApply: function(map) { + return map.save(null, true).then(function() { + return this.commitAndApply(); + }.bind(this)); + }, + + notifyApplied: function() { + this.notify(_('Changes saved and applied.'), 'info'); + }, + + reloadAfterApply: function(delay) { + window.setTimeout(function() { + window.location.reload(); + }, delay || 2500); + } +}); diff --git a/luci-app-haproxy-manager/htdocs/luci-static/resources/view/haproxy-manager/raw.js b/luci-app-haproxy-manager/htdocs/luci-static/resources/view/haproxy-manager/raw.js new file mode 100644 index 00000000..8d9a6951 --- /dev/null +++ b/luci-app-haproxy-manager/htdocs/luci-static/resources/view/haproxy-manager/raw.js @@ -0,0 +1,88 @@ +'use strict'; +/* global hmUi */ +'require view'; +'require fs'; +'require ui'; +'require haproxy-manager.ui as hmUi'; + +return view.extend({ + load: function() { + return Promise.all([ + fs.read('/etc/haproxy.cfg').catch(function() { + return ''; + }) + ]); + }, + + applyRaw: function(textarea) { + ui.showModal(_('Apply raw configuration?'), [ + E('p', _('The file will be validated and backed up before HAProxy restarts.')), + E('div', { 'class': 'right' }, [ + E('button', { + 'class': 'btn cbi-button', + 'type': 'button', + 'click': ui.hideModal + }, _('Cancel')), + ' ', + E('button', { + 'class': 'btn cbi-button cbi-button-apply', + 'type': 'button', + 'click': ui.createHandlerFn(this, function() { + return fs.write('/tmp/haproxy-manager-raw.cfg', textarea.value).then(function() { + return hmUi.exec('/usr/libexec/haproxy-manager/apply-raw-file', [ '/tmp/haproxy-manager-raw.cfg' ]); + }).then(function(r) { + ui.hideModal(); + hmUi.notify(r.stdout || _('Applied'), 'info'); + }).catch(function(err) { + ui.hideModal(); + hmUi.notifyError(err); + }); + }) + }, _('Apply')) + ]) + ]); + }, + + render: function(data) { + var cfg = data[0]; + var textarea = E('textarea', { + 'id': 'haproxy-raw-config', + 'class': 'cbi-input-textarea hm-raw-editor', + 'spellcheck': 'false', + 'aria-label': _('Raw HAProxy configuration') + }, cfg || ''); + + hmUi.ensureStyles(); + + return E('div', { 'class': 'cbi-map' }, [ + E('h2', _('Raw HAProxy Config')), + E('div', { 'class': 'cbi-map-descr' }, _('Expert editor for /etc/haproxy.cfg.')), + E('div', { 'class': 'alert-message warning' }, _('Generated routes can overwrite manual changes to this file.')), + textarea, + E('div', { 'class': 'cbi-page-actions hm-actions' }, [ + E('button', { + 'class': 'btn cbi-button cbi-button-action', + 'type': 'button', + 'click': ui.createHandlerFn(this, function() { + return fs.write('/tmp/haproxy-manager-raw.cfg', textarea.value).then(function() { + return hmUi.exec('/usr/libexec/haproxy-manager/validate', [ '/tmp/haproxy-manager-raw.cfg' ]); + }).then(function(r) { + hmUi.notify(r.stdout || _('Config is valid'), 'info'); + }).catch(function(err) { + hmUi.notifyError(err); + }); + }) + }, _('Validate')), + E('button', { + 'class': 'btn cbi-button cbi-button-apply', + 'type': 'button', + 'click': ui.createHandlerFn(this, function() { this.applyRaw(textarea); }) + }, _('Apply raw config')) + ]) + ]); + }, + + handleSaveApply: null, + handleSave: null, + handleReset: null +}); diff --git a/luci-app-haproxy-manager/htdocs/luci-static/resources/view/haproxy-manager/routes.js b/luci-app-haproxy-manager/htdocs/luci-static/resources/view/haproxy-manager/routes.js new file mode 100644 index 00000000..44d0b851 --- /dev/null +++ b/luci-app-haproxy-manager/htdocs/luci-static/resources/view/haproxy-manager/routes.js @@ -0,0 +1,464 @@ +'use strict'; +/* global hmUi */ +'require view'; +'require dom'; +'require form'; +'require ui'; +'require haproxy-manager.ui as hmUi'; + +function listValue(value) { + if (Array.isArray(value)) + return value; + if (value == null || value === '') + return []; + return String(value).trim().split(/\s+/); +} + +function routeKind(map, sectionId) { + return map.data.get('haproxy_manager', sectionId, 'kind') || + map.data.get('haproxy_manager', sectionId, 'protocol') || 'web'; +} + +function kindLabel(kind) { + switch (kind) { + case 'web': return _('Web'); + case 'ssh': return _('SSH'); + case 'rdp': return _('Remote Desktop'); + case 'custom': return _('Custom TCP'); + case 'http': return _('HTTP only'); + case 'https': return _('HTTPS only'); + case 'both': return _('Legacy Web'); + default: return _('TCP'); + } +} + +function portMapIsValid(value) { + var match = String(value || '').match(/^(\d+):(\d+)$/); + return !!match && +match[1] >= 1 && +match[1] <= 65535 && +match[2] >= 1 && +match[2] <= 65535; +} + +function parseFirewall(output) { + var result = { ports: {}, conflicts: [], conflict_count: '0', enabled: '0', policy: '', managed_rule: '0' }; + + String(output || '').split(/\r?\n/).forEach(function(line) { + var fields = line.split('\t'); + if (fields[0] == 'port') + result.ports[fields[1]] = true; + else if (fields[0] == 'conflict') + result.conflicts.push({ section: fields[1], name: fields[2], ports: fields[3] }); + else if (fields[0] == 'conflicts') + result.conflict_count = fields[1] || '0'; + else if (fields[0]) + result[fields[0]] = fields.slice(1).join('\t'); + }); + + return result; +} + +function specContains(spec, port) { + return String(spec || '').split(/[\s,]+/).some(function(token) { + var range = token.split('-').map(Number); + return range.length == 1 ? range[0] == port : port >= range[0] && port <= range[1]; + }); +} + +function routePorts(map, sectionId, kind) { + var main = function(option, fallback) { + return map.data.get('haproxy_manager', 'main', option) || fallback; + }; + + switch (kind) { + case 'web': + return [ + map.data.get('haproxy_manager', sectionId, 'web_http') == '0' ? null : main('http_port', '80'), + map.data.get('haproxy_manager', sectionId, 'web_https') == '0' ? null : main('https_port', '443') + ].filter(Boolean); + case 'ssh': + return [map.data.get('haproxy_manager', sectionId, 'ssh_listen_port') || '22']; + case 'rdp': + return [map.data.get('haproxy_manager', sectionId, 'rdp_listen_port') || '3389']; + case 'custom': + return listValue(map.data.get('haproxy_manager', sectionId, 'port_map')).map(function(item) { + return item.split(':')[0]; + }); + case 'http': return [main('http_port', '80')]; + case 'https': return [main('https_port', '443')]; + case 'both': return [main('http_port', '80'), main('https_port', '443')]; + default: + return [map.data.get('haproxy_manager', sectionId, 'listen_port') || + map.data.get('haproxy_manager', sectionId, 'backend_port')].filter(Boolean); + } +} + +return view.extend({ + load: function() { + return Promise.all([ + hmUi.exec('/usr/libexec/haproxy-manager/firewall-plan', []).catch(function() { + return { stdout: '' }; + }) + ]); + }, + + render: function(data) { + var m, s, o; + var firewall = parseFirewall(data[0].stdout); + + hmUi.ensureStyles(); + + m = new form.Map('haproxy_manager', _('HAProxy Services'), + _('Publish Web, SSH, Remote Desktop, and custom TCP services from one place.')); + + s = m.section(form.GridSection, 'route', _('Services')); + s.anonymous = true; + s.addremove = true; + s.sortable = true; + s.nodescriptions = true; + s.addbtntitle = _('Add service'); + + o = s.option(form.Flag, 'enabled', _('Enabled')); + o.default = '1'; + o.modalonly = true; + + o = s.option(form.Value, 'name', _('Service name')); + o.placeholder = _('My service'); + o.rmempty = true; + o.modalonly = true; + + o = s.option(form.ListValue, 'kind', _('Service type')); + o.value('web', _('Web (HTTP + HTTPS)')); + o.value('ssh', _('SSH')); + o.value('rdp', _('Remote Desktop')); + o.value('custom', _('Custom TCP')); + o.default = 'web'; + o.rmempty = false; + o.modalonly = true; + + o = s.option(form.Value, 'host', _('Domain')); + o.placeholder = 'example.org'; + o.datatype = 'hostname'; + o.rmempty = false; + o.modalonly = true; + o.depends('kind', 'web'); + + o = s.option(form.Value, 'backend_host', _('Destination host')); + o.placeholder = '192.0.2.10'; + o.datatype = 'or(ipaddr,hostname)'; + o.rmempty = false; + o.modalonly = true; + + o = s.option(form.Flag, 'web_http', _('Publish HTTP')); + o.default = '1'; + o.modalonly = true; + o.depends('kind', 'web'); + + o = s.option(form.Value, 'backend_http_port', _('Destination HTTP port')); + o.datatype = 'port'; + o.default = '80'; + o.rmempty = false; + o.modalonly = true; + o.depends({ kind: 'web', web_http: '1' }); + + o = s.option(form.Flag, 'web_https', _('Publish HTTPS')); + o.default = '1'; + o.modalonly = true; + o.depends('kind', 'web'); + + o = s.option(form.Value, 'backend_https_port', _('Destination HTTPS port')); + o.datatype = 'port'; + o.default = '443'; + o.rmempty = false; + o.modalonly = true; + o.depends({ kind: 'web', web_https: '1' }); + + o = s.option(form.Value, 'ssh_listen_port', _('Public SSH port')); + o.datatype = 'port'; + o.default = '22'; + o.rmempty = false; + o.modalonly = true; + o.depends('kind', 'ssh'); + + o = s.option(form.Value, 'ssh_backend_port', _('Destination SSH port')); + o.datatype = 'port'; + o.default = '22'; + o.rmempty = false; + o.modalonly = true; + o.depends('kind', 'ssh'); + + o = s.option(form.Value, 'rdp_listen_port', _('Public RDP port')); + o.datatype = 'port'; + o.default = '3389'; + o.rmempty = false; + o.modalonly = true; + o.depends('kind', 'rdp'); + + o = s.option(form.Value, 'rdp_backend_port', _('Destination RDP port')); + o.datatype = 'port'; + o.default = '3389'; + o.rmempty = false; + o.modalonly = true; + o.depends('kind', 'rdp'); + + o = s.option(form.DynamicList, 'port_map', _('TCP port mappings')); + o.placeholder = '8443:443'; + o.rmempty = false; + o.modalonly = true; + o.depends('kind', 'custom'); + o.validate = function(sectionId, value) { + return portMapIsValid(value) || _('Use the public:destination format, for example 8443:443.'); + }; + + o = s.option(form.DummyValue, '_status', _('Status')); + o.modalonly = false; + o.textvalue = function(sectionId) { + var enabled = m.data.get('haproxy_manager', sectionId, 'enabled') != '0'; + return E('span', { + 'class': 'hm-state %s'.format(enabled ? 'hm-state-on' : 'hm-state-off') + }, enabled ? _('On') : _('Off')); + }; + + o = s.option(form.DummyValue, '_service', _('Service')); + o.modalonly = false; + o.textvalue = function(sectionId) { + var kind = routeKind(m, sectionId); + var name = m.data.get('haproxy_manager', sectionId, 'name') || + m.data.get('haproxy_manager', sectionId, 'host') || kindLabel(kind); + var enabled = m.data.get('haproxy_manager', sectionId, 'enabled') != '0'; + + return E('div', { 'class': 'hm-service-cell' }, [ + E('div', { 'class': 'hm-route-name' }, [ + E('strong', name), + E('span', { + 'class': 'hm-state hm-mobile-only %s'.format(enabled ? 'hm-state-on' : 'hm-state-off') + }, enabled ? _('On') : _('Off')) + ]), + E('span', { 'class': 'hm-badge' }, kindLabel(kind)) + ]); + }; + + o = s.option(form.DummyValue, '_endpoint', _('Public endpoint')); + o.modalonly = false; + o.textvalue = function(sectionId) { + var kind = routeKind(m, sectionId); + var ports = routePorts(m, sectionId, kind); + var host = m.data.get('haproxy_manager', sectionId, 'host'); + + if (kind == 'web' || kind == 'http' || kind == 'https' || kind == 'both') { + return E('div', { 'class': 'hm-endpoint' }, [ + E('span', { 'class': 'hm-endpoint-value' }, host || _('Not set')), + E('span', { 'class': 'hm-port-summary' }, ports.map(function(port) { return ':' + port; }).join(' + ')) + ]); + } + + return E('div', { 'class': 'hm-port-list' }, ports.map(function(port) { + return E('code', ':' + port); + })); + }; + + o = s.option(form.DummyValue, '_destination', _('Destination')); + o.modalonly = false; + o.textvalue = function(sectionId) { + var kind = routeKind(m, sectionId); + var host = m.data.get('haproxy_manager', sectionId, 'backend_host') || _('Not set'); + var mappings = []; + + if (kind == 'web') { + if (m.data.get('haproxy_manager', sectionId, 'web_http') != '0') + mappings.push(_('HTTP') + ' :' + (m.data.get('haproxy_manager', sectionId, 'backend_http_port') || '80')); + if (m.data.get('haproxy_manager', sectionId, 'web_https') != '0') + mappings.push(_('HTTPS') + ' :' + (m.data.get('haproxy_manager', sectionId, 'backend_https_port') || '443')); + } + else if (kind == 'ssh') + mappings.push(':' + (m.data.get('haproxy_manager', sectionId, 'ssh_backend_port') || '22')); + else if (kind == 'rdp') + mappings.push(':' + (m.data.get('haproxy_manager', sectionId, 'rdp_backend_port') || '3389')); + else if (kind == 'custom') + mappings = listValue(m.data.get('haproxy_manager', sectionId, 'port_map')).map(function(item) { return ':' + item.split(':')[1]; }); + else + mappings.push(':' + (m.data.get('haproxy_manager', sectionId, 'backend_port') || '')); + + return E('div', { 'class': 'hm-destination' }, [ + E('code', host), + E('span', mappings.join(' / ')) + ]); + }; + + o = s.option(form.DummyValue, '_firewall', _('Firewall')); + o.modalonly = false; + o.textvalue = function(sectionId) { + var kind = routeKind(m, sectionId); + var ports = routePorts(m, sectionId, kind).map(Number); + var conflict = firewall.conflicts.some(function(item) { + return ports.some(function(port) { return specContains(item.ports, port); }); + }); + var state = firewall.enabled != '1' ? _('Manual') : conflict ? _('Conflict') : + (firewall.managed_rule == '1' || firewall.policy == 'ACCEPT' ? _('Ready') : _('Pending')); + + return E('span', { + 'class': 'hm-state %s'.format(conflict ? 'hm-state-danger' : firewall.enabled == '1' ? 'hm-state-on' : 'hm-state-off') + }, state); + }; + + function applied(reload) { + hmUi.notifyApplied(); + if (reload) + hmUi.reloadAfterApply(); + } + + function applyMap(reload) { + return hmUi.saveAndApply(m).then(function() { + applied(reload); + }).catch(function(error) { + hmUi.notifyError(error); + }); + } + + var baseHandleDrop = s.handleDrop; + var baseHandleTouchEnd = s.handleTouchEnd; + + s.handleModalSave = function(modalMap, ev) { + var mapNode = this.getActiveModalMap(); + var activeMap = dom.findClassInstance(mapNode); + + return activeMap.save(null, true).then(function() { + return hmUi.commitAndApply(); + }).then(function() { + return this.handleModalCancel(modalMap, ev, true); + }.bind(this)).then(function() { + applied(true); + }).catch(function(error) { + hmUi.notifyError(error); + }); + }; + + s.handleRemove = function(sectionId) { + ui.showModal(_('Delete service?'), [ + E('p', _('The service will be removed from HAProxy and the firewall immediately.')), + E('div', { 'class': 'right' }, [ + E('button', { + 'class': 'btn cbi-button', + 'click': ui.hideModal + }, _('Cancel')), + ' ', + E('button', { + 'class': 'btn cbi-button cbi-button-negative important', + 'click': ui.createHandlerFn(this, function() { + ui.hideModal(); + m.data.remove('haproxy_manager', sectionId); + return applyMap(true); + }) + }, _('Delete and apply')) + ]) + ]); + }; + + function applyReorder() { + window.setTimeout(function() { + applyMap(true); + }, 100); + } + + s.handleDrop = function(ev) { + var shouldApply = !!(ev.currentTarget && ev.currentTarget.matches && + ev.currentTarget.matches('.drag-over-above, .drag-over-below')); + var result = baseHandleDrop.call(this, ev); + if (shouldApply) + applyReorder(); + return result; + }; + + s.handleTouchEnd = function(ev) { + var row = ev.target && ev.target.closest ? ev.target.closest('.tr') : null; + var shouldApply = !!(document.querySelector('.touchsort-element') && row && + row.parentNode.querySelector('.drag-over-above, .drag-over-below')); + var result = baseHandleTouchEnd.call(this, ev); + if (shouldApply) + applyReorder(); + return result; + }; + + return m.render().then(function(node) { + var filterInput = E('input', { + 'id': 'haproxy-route-filter', + 'class': 'cbi-input-text hm-filter', + 'type': 'search', + 'placeholder': _('Filter services'), + 'aria-label': _('Filter services') + }); + var countNode = E('span', { 'class': 'hm-route-count' }); + var emptyNode = E('p', { + 'class': 'alert-message notice hm-filter-empty', + 'hidden': '' + }, _('No matching services')); + var toolbar = E('div', { 'class': 'hm-toolbar' }, [ filterInput, countNode ]); + var section = node.querySelector('.cbi-section'); + + function updateFilter() { + var query = filterInput.value.trim().toLowerCase(); + var rows = node.querySelectorAll('.cbi-section-table-row[data-sid]'); + var visible = 0; + + for (var i = 0; i < rows.length; i++) { + var match = !query || rows[i].textContent.toLowerCase().indexOf(query) > -1; + rows[i].hidden = !match; + visible += match ? 1 : 0; + } + + countNode.textContent = _('%d services').format(visible); + emptyNode.hidden = visible > 0 || !query; + } + + filterInput.addEventListener('input', updateFilter); + + if (section) { + var table = section.querySelector('.table'); + if (table) + table.classList.add('hm-route-table'); + section.insertBefore(toolbar, table || section.firstChild); + section.appendChild(emptyNode); + } + + if (firewall.conflicts.length) { + node.insertBefore(E('div', { 'class': 'alert-message warning hm-inline-alert' }, [ + E('strong', _('Firewall conflict detected.')), + ' ', + E('a', { 'href': L.url('admin/services/haproxy-manager/settings') }, _('Review firewall settings')) + ]), node.firstChild.nextSibling); + } + + node.appendChild(E('div', { 'class': 'cbi-page-actions hm-actions' }, [ + E('button', { + 'class': 'btn cbi-button cbi-button-action', + 'type': 'button', + 'click': ui.createHandlerFn(this, function() { + return hmUi.exec('/usr/libexec/haproxy-manager/generate', [ '/tmp/haproxy-manager-preview.cfg' ]).then(function() { + return hmUi.exec('/usr/libexec/haproxy-manager/validate', [ '/tmp/haproxy-manager-preview.cfg' ]); + }).then(function() { + return hmUi.exec('/usr/libexec/haproxy-manager/firewall-plan', []); + }).then(function(r) { + var plan = parseFirewall(r.stdout); + hmUi.notify(plan.conflicts.length ? + _('Configuration is valid. Firewall conflicts: %d').format(plan.conflicts.length) : + _('Configuration and firewall are ready.'), plan.conflicts.length ? 'warning' : 'info'); + }).catch(function(err) { + hmUi.notifyError(err); + }); + }) + }, _('Check configuration')), + E('button', { + 'class': 'btn cbi-button cbi-button-apply', + 'type': 'button', + 'click': ui.createHandlerFn(this, function() { + return applyMap(true); + }) + }, _('Synchronize now')) + ])); + + updateFilter(); + return node; + }.bind(this)); + }, + + handleSaveApply: null, + handleSave: null, + handleReset: null +}); diff --git a/luci-app-haproxy-manager/htdocs/luci-static/resources/view/haproxy-manager/settings.js b/luci-app-haproxy-manager/htdocs/luci-static/resources/view/haproxy-manager/settings.js new file mode 100644 index 00000000..716cf188 --- /dev/null +++ b/luci-app-haproxy-manager/htdocs/luci-static/resources/view/haproxy-manager/settings.js @@ -0,0 +1,136 @@ +'use strict'; +/* global hmUi */ +'require view'; +'require form'; +'require ui'; +'require haproxy-manager.ui as hmUi'; + +return view.extend({ + render: function() { + var m, s, o; + + hmUi.ensureStyles(); + + m = new form.Map('haproxy_manager', _('HAProxy Settings'), + _('Configure shared Web entry ports, router access, and firewall automation.')); + + s = m.section(form.NamedSection, 'main', 'settings', _('HAProxy service')); + s.anonymous = true; + + o = s.option(form.Flag, 'enabled', _('Enable managed configuration')); + o.default = '0'; + + o = s.option(form.Value, 'wan_interface', _('WAN network interface')); + o.default = 'wan'; + o.datatype = 'uciname'; + o.rmempty = false; + o.description = _('The selected logical interface is monitored for address changes. In multi-WAN setups, select the interface that receives public connections.'); + + o = s.option(form.Value, 'wan_bind_ip', _('WAN bind address')); + o.placeholder = _('Automatic'); + o.default = 'auto'; + o.datatype = 'or(ipaddr,hostname)'; + o.rmempty = false; + + s = m.section(form.NamedSection, 'main', 'settings', _('Web entry ports')); + s.anonymous = true; + s.description = _('Every Web service shares these public ports. SSH, Remote Desktop, and Custom TCP services use the ports configured on each service.'); + + o = s.option(form.Value, 'http_port', _('Public HTTP port')); + o.datatype = 'port'; + o.default = '80'; + o.rmempty = false; + + o = s.option(form.Value, 'https_port', _('Public HTTPS port')); + o.datatype = 'port'; + o.default = '443'; + o.rmempty = false; + + s = m.section(form.NamedSection, 'main', 'settings', _('Router access')); + s.anonymous = true; + + o = s.option(form.Flag, 'manage_uhttpd_bind', _('Keep LuCI on the LAN address')); + o.default = '0'; + o.description = _('Recommended when HAProxy uses public ports 80 or 443. LuCI remains available from the local network.'); + + o = s.option(form.Value, 'lan_bind_ip', _('LuCI LAN address')); + o.placeholder = _('Automatic'); + o.default = 'auto'; + o.datatype = 'or(ipaddr,hostname)'; + o.depends('manage_uhttpd_bind', '1'); + + s = m.section(form.NamedSection, 'main', 'settings', _('Firewall automation')); + s.anonymous = true; + + o = s.option(form.Flag, 'manage_firewall', _('Open HAProxy ports on WAN automatically')); + o.default = '0'; + o.description = _('Creates and updates only firewall rules owned by HAProxy Manager.'); + + o = s.option(form.Value, 'firewall_zone', _('WAN firewall zone')); + o.default = 'wan'; + o.rmempty = false; + o.depends('manage_firewall', '1'); + + o = s.option(form.ListValue, 'firewall_conflict_mode', _('Existing port-forward conflicts')); + o.value('warn', _('Stop and show conflicts')); + o.value('disable', _('Disable conflicting forwards during apply')); + o.default = 'warn'; + o.rmempty = false; + o.depends('manage_firewall', '1'); + + s = m.section(form.NamedSection, 'main', 'settings', _('Recovery')); + s.anonymous = true; + s.description = _('A restorable snapshot is created before migration and every apply.'); + + o = s.option(form.Flag, 'auto_recover', _('Recover HAProxy after networking becomes ready')); + o.default = '1'; + o.description = _('Reconciles generated listeners after WAN address changes, retries a stopped service, and retains diagnostics for the seven latest incidents. Raw configurations are never overwritten.'); + + o = s.option(form.Flag, 'webhook_enabled', _('Send recovery webhook notifications')); + o.default = '0'; + o.description = _('Sends only the incident identifier, result, action, interface, and reason. Configuration, addresses, and diagnostic logs are never included.'); + + o = s.option(form.Value, 'webhook_url', _('Webhook URL')); + o.password = true; + o.rmempty = false; + o.depends('webhook_enabled', '1'); + o.validate = function(sectionId, value) { + return /^https?:\/\/[^/\s]+(?:\/\S*)?$/.test(value || '') || _('Enter an HTTP or HTTPS URL without spaces.'); + }; + + return m.render().then(function(node) { + var recoverySection = node.querySelectorAll('.cbi-section'); + var lastSection = recoverySection.length ? recoverySection[recoverySection.length - 1] : null; + + if (lastSection) { + lastSection.appendChild(E('p', [ + E('a', { + 'class': 'btn cbi-button', + 'href': L.url('admin/services/haproxy-manager/status') + '#recovery' + }, _('Open recovery')) + ])); + } + + node.appendChild(E('div', { 'class': 'cbi-page-actions hm-actions' }, [ + E('button', { + 'class': 'btn cbi-button cbi-button-apply', + 'type': 'button', + 'click': ui.createHandlerFn(this, function() { + return hmUi.saveAndApply(m).then(function() { + hmUi.notifyApplied(); + hmUi.reloadAfterApply(); + }).catch(function(err) { + hmUi.notifyError(err); + }); + }) + }, _('Save settings')) + ])); + + return node; + }.bind(this)); + }, + + handleSaveApply: null, + handleSave: null, + handleReset: null +}); diff --git a/luci-app-haproxy-manager/htdocs/luci-static/resources/view/haproxy-manager/status.js b/luci-app-haproxy-manager/htdocs/luci-static/resources/view/haproxy-manager/status.js new file mode 100644 index 00000000..37e591d5 --- /dev/null +++ b/luci-app-haproxy-manager/htdocs/luci-static/resources/view/haproxy-manager/status.js @@ -0,0 +1,294 @@ +'use strict'; +/* global hmUi */ +'require view'; +'require ui'; +'require haproxy-manager.ui as hmUi'; + +function parseStatus(output) { + var status = { listeners: [] }; + + String(output || '').split(/\r?\n/).forEach(function(line) { + var fields = line.split('\t'); + if (fields[0] == 'listener') { + status.listeners.push({ + protocol: fields[1] || '', + address: fields[2] || '', + process: fields.slice(3).join('\t') || '' + }); + } + else if (fields[0]) + status[fields[0]] = fields.slice(1).join('\t'); + }); + + return status; +} + +function parseBackups(output) { + return String(output || '').split(/\r?\n/).map(function(line) { + var fields = line.split('\t'); + return fields[0] == 'backup' ? { id: fields[1], latest: fields[2] == '1', files: fields[3] } : null; + }).filter(Boolean); +} + +function parseIncidents(output) { + return String(output || '').split(/\r?\n/).map(function(line) { + var fields = line.split('\t'); + return fields[0] == 'incident' ? { + id: fields[1], + latest: fields[2] == '1', + result: fields[3] || '', + action: fields[4] || '', + interface: fields[5] || '', + reason: fields[6] || '' + } : null; + }).filter(Boolean); +} + +function parseFirewall(output) { + var result = { conflicts: '0', enabled: '0', policy: '' }; + String(output || '').split(/\r?\n/).forEach(function(line) { + var fields = line.split('\t'); + if (fields[0] && fields[0] != 'conflict' && fields[0] != 'port') + result[fields[0]] = fields.slice(1).join('\t'); + }); + return result; +} + +function backupDate(id) { + var match = String(id || '').match(/^(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})$/); + return match ? '%s-%s-%s %s:%s:%s'.format(match[1], match[2], match[3], match[4], match[5], match[6]) : id; +} + +function statusItem(label, value) { + return E('div', { 'class': 'hm-status-item' }, [ + E('span', { 'class': 'hm-status-label' }, label), + E('span', { 'class': 'hm-status-value' }, value) + ]); +} + +return view.extend({ + load: function() { + return Promise.all([ + hmUi.exec('/usr/libexec/haproxy-manager/status', []).catch(function(err) { + return { stdout: '', stderr: err.message || String(err), code: 1 }; + }), + hmUi.exec('/usr/libexec/haproxy-manager/backups', []).catch(function() { return { stdout: '' }; }), + hmUi.exec('/usr/libexec/haproxy-manager/firewall-plan', []).catch(function() { return { stdout: '' }; }), + hmUi.exec('/usr/libexec/haproxy-manager/incidents', []).catch(function() { return { stdout: '' }; }) + ]); + }, + + showIncident: function(incidentId) { + return hmUi.exec('/usr/libexec/haproxy-manager/incident', [ incidentId ]).then(function(result) { + ui.showModal(_('Incident diagnostics'), [ + E('p', _('Diagnostic report for %s.').format(backupDate(incidentId))), + E('pre', { 'class': 'hm-incident-report' }, result.stdout || _('Not available')), + E('div', { 'class': 'right' }, E('button', { + 'class': 'btn cbi-button cbi-button-action', + 'type': 'button', + 'click': ui.hideModal + }, _('Close'))) + ]); + }).catch(function(err) { + hmUi.notifyError(err); + }); + }, + + showRestore: function(backupId) { + ui.showModal(_('Restore configuration?'), [ + E('p', _('HAProxy, firewall, and LuCI settings will be restored from %s.').format(backupDate(backupId))), + E('div', { 'class': 'right' }, [ + E('button', { + 'class': 'btn cbi-button', + 'type': 'button', + 'click': ui.hideModal + }, _('Cancel')), + ' ', + E('button', { + 'class': 'btn cbi-button cbi-button-negative important', + 'type': 'button', + 'click': ui.createHandlerFn(this, function() { + return hmUi.exec('/usr/libexec/haproxy-manager/rollback', [ backupId ]).then(function(r) { + ui.hideModal(); + hmUi.notify(r.stdout || _('Restored'), 'info'); + hmUi.reloadAfterApply(3500); + }).catch(function(err) { + ui.hideModal(); + hmUi.notifyError(err); + }); + }) + }, _('Restore')) + ]) + ]); + }, + + render: function(data) { + var res = data[0]; + var status = parseStatus(res.stdout); + var backups = parseBackups(data[1].stdout); + var firewall = parseFirewall(data[2].stdout); + var incidents = parseIncidents(data[3].stdout); + var running = status.service == 'running'; + var autoRecovery = status.auto_recovery != '0'; + var recoveryResults = { + 'recovered': _('Recovered'), + 'failed': _('Failed'), + 'invalid-config': _('Invalid configuration'), + 'backup-failed': _('Recovery point failed'), + 'detected': _('In progress') + }; + recoveryResults.reconciled = _('WAN address reconciled'); + recoveryResults['rolled-back'] = _('Failed, configuration restored'); + recoveryResults['rollback-failed'] = _('Failed, restore also failed'); + recoveryResults['address-unavailable'] = _('WAN address unavailable'); + recoveryResults['invalid-generated-config'] = _('Generated configuration is invalid'); + recoveryResults['install-failed'] = _('Configuration installation failed'); + var lastRecovery = status.last_incident ? '%s - %s%s'.format( + backupDate(status.last_incident), + recoveryResults[status.last_incident_result] || status.last_incident_result, + status.last_incident_interface ? ' (%s)'.format(status.last_incident_interface) : '' + ) : _('No incidents recorded'); + var firewallText = firewall.enabled != '1' ? _('Manual') : +firewall.conflicts > 0 ? + _('%d conflicts').format(+firewall.conflicts) : _('Managed'); + var modes = { + generated: _('Generated routes'), + raw: _('Raw configuration'), + none: _('Not managed') + }; + var listeners = status.listeners.map(function(listener) { + return E('tr', { 'class': 'tr' }, [ + E('td', { 'class': 'td', 'data-title': _('Protocol') }, listener.protocol), + E('td', { 'class': 'td', 'data-title': _('Address') }, E('code', listener.address)), + E('td', { 'class': 'td', 'data-title': _('Process') }, listener.process) + ]); + }); + var backupRows = backups.map(function(backup) { + return E('tr', { 'class': 'tr' }, [ + E('td', { 'class': 'td', 'data-title': _('Created') }, [ + E('span', backupDate(backup.id)), + backup.latest ? E('span', { 'class': 'hm-badge hm-backup-latest' }, _('Latest')) : '' + ]), + E('td', { 'class': 'td', 'data-title': _('Contents') }, _('%d configuration files').format(+backup.files || 0)), + E('td', { 'class': 'td cbi-section-actions' }, E('button', { + 'class': 'btn cbi-button cbi-button-action', + 'type': 'button', + 'click': ui.createHandlerFn(this, function() { this.showRestore(backup.id); }) + }, _('Restore'))) + ]); + }.bind(this)); + var incidentRows = incidents.map(function(incident) { + var result = recoveryResults[incident.result] || incident.result || _('Not available'); + var context = [ incident.action, incident.interface ].filter(Boolean).join(' / '); + return E('tr', { 'class': 'tr' }, [ + E('td', { 'class': 'td', 'data-title': _('Created') }, [ + E('span', backupDate(incident.id)), + incident.latest ? E('span', { 'class': 'hm-badge hm-backup-latest' }, _('Latest')) : '' + ]), + E('td', { 'class': 'td', 'data-title': _('Result') }, result), + E('td', { 'class': 'td', 'data-title': _('Trigger') }, context || incident.reason || '-'), + E('td', { 'class': 'td cbi-section-actions' }, E('button', { + 'class': 'btn cbi-button cbi-button-action', + 'type': 'button', + 'click': ui.createHandlerFn(this, function() { return this.showIncident(incident.id); }) + }, _('View'))) + ]); + }.bind(this)); + + hmUi.ensureStyles(); + + if (!listeners.length) { + listeners.push(E('tr', { 'class': 'tr placeholder' }, [ + E('td', { 'class': 'td', 'colspan': '3' }, _('No HAProxy or LuCI listeners detected.')) + ])); + } + + if (!backupRows.length) { + backupRows.push(E('tr', { 'class': 'tr placeholder' }, [ + E('td', { 'class': 'td', 'colspan': '3' }, _('No recovery points yet.')) + ])); + } + + if (!incidentRows.length) { + incidentRows.push(E('tr', { 'class': 'tr placeholder' }, [ + E('td', { 'class': 'td', 'colspan': '4' }, _('No incidents recorded')) + ])); + } + + return E('div', { 'class': 'cbi-map' }, [ + E('h2', _('HAProxy Status')), + E('div', { 'class': 'cbi-map-descr' }, _('Service health, active listeners, and recovery points.')), + res.code && res.stderr ? E('div', { 'class': 'alert-message error' }, res.stderr) : '', + E('div', { 'class': 'hm-status-grid' }, [ + statusItem(_('Service'), E('span', { + 'class': 'hm-state %s'.format(running ? 'hm-state-on' : 'hm-state-off') + }, running ? _('Running') : _('Stopped'))), + statusItem(_('WAN address'), status.wan_ip || _('Not set')), + statusItem(_('HAProxy version'), status.version || _('Not available')), + statusItem(_('Automatic recovery'), E('span', { + 'class': 'hm-state %s'.format(autoRecovery ? 'hm-state-on' : 'hm-state-off') + }, autoRecovery ? _('Enabled') : _('Disabled'))), + statusItem(_('Configuration mode'), modes[status.active_mode] || status.active_mode || modes.none), + statusItem(_('Last recovery'), lastRecovery), + statusItem(_('Firewall'), E('span', { + 'class': 'hm-state %s'.format(+firewall.conflicts > 0 ? 'hm-state-danger' : firewall.enabled == '1' ? 'hm-state-on' : 'hm-state-off') + }, firewallText)) + ]), + E('h3', _('Active listeners')), + E('table', { 'class': 'table hm-listener-table' }, [ + E('thead', {}, [ E('tr', { 'class': 'tr table-titles' }, [ + E('th', { 'class': 'th' }, _('Protocol')), + E('th', { 'class': 'th' }, _('Address')), + E('th', { 'class': 'th' }, _('Process')) + ]) ]), + E('tbody', {}, listeners) + ]), + E('div', { 'class': 'hm-section-heading' }, [ + E('h3', _('Recovery incidents')), + E('p', { 'class': 'cbi-section-descr' }, _('The seven latest automatic recovery reports are retained on the router.')) + ]), + E('table', { 'class': 'table hm-incident-table' }, [ + E('thead', {}, [ E('tr', { 'class': 'tr table-titles' }, [ + E('th', { 'class': 'th' }, _('Created')), + E('th', { 'class': 'th' }, _('Result')), + E('th', { 'class': 'th' }, _('Trigger')), + E('th', { 'class': 'th' }) + ]) ]), + E('tbody', {}, incidentRows) + ]), + E('div', { 'id': 'recovery', 'class': 'hm-section-heading' }, [ + E('div', { 'class': 'hm-section-title-row' }, [ + E('h3', _('Recovery points')), + E('button', { + 'class': 'btn cbi-button cbi-button-add', + 'type': 'button', + 'click': ui.createHandlerFn(this, function() { + return hmUi.exec('/usr/libexec/haproxy-manager/backup', []).then(function() { + window.location.reload(); + }).catch(function(err) { hmUi.notifyError(err); }); + }) + }, _('Create recovery point')) + ]), + E('p', { 'class': 'cbi-section-descr' }, _('Restore HAProxy, firewall, and LuCI settings from a previous snapshot.')) + ]), + E('table', { 'class': 'table hm-backup-table' }, [ + E('thead', {}, [ E('tr', { 'class': 'tr table-titles' }, [ + E('th', { 'class': 'th' }, _('Created')), + E('th', { 'class': 'th' }, _('Contents')), + E('th', { 'class': 'th' }) + ]) ]), + E('tbody', {}, backupRows) + ]), + E('div', { 'class': 'cbi-page-actions hm-actions' }, [ + E('button', { + 'class': 'btn cbi-button cbi-button-action', + 'type': 'button', + 'click': function() { window.location.reload(); } + }, _('Refresh')) + ]) + ]); + }, + + handleSaveApply: null, + handleSave: null, + handleReset: null +}); diff --git a/luci-app-haproxy-manager/po/es/haproxy-manager.po b/luci-app-haproxy-manager/po/es/haproxy-manager.po new file mode 100644 index 00000000..c298fe92 --- /dev/null +++ b/luci-app-haproxy-manager/po/es/haproxy-manager.po @@ -0,0 +1,499 @@ +msgid "" +msgstr "" +"Language: es\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" + +msgid "Command failed with code %d." +msgstr "El comando terminó con el código %d." + +msgid "%d configuration files" +msgstr "%d archivos de configuración" + +msgid "%d conflicts" +msgstr "%d conflictos" + +msgid "%d services" +msgstr "%d servicios" + +msgid "A restorable snapshot is created before migration and every apply." +msgstr "Se crea un punto de restauración antes de la migración y de cada aplicación." + +msgid "Active listeners" +msgstr "Escuchas activas" + +msgid "Add service" +msgstr "Añadir servicio" + +msgid "Address" +msgstr "Dirección" + +msgid "Applied" +msgstr "Aplicado" + +msgid "Apply" +msgstr "Aplicar" + +msgid "Apply changes" +msgstr "Aplicar cambios" + +msgid "Apply raw config" +msgstr "Aplicar configuración" + +msgid "Apply raw configuration?" +msgstr "¿Aplicar la configuración manual?" + +msgid "Automatic" +msgstr "Automático" + +msgid "Cancel" +msgstr "Cancelar" + +msgid "Check configuration" +msgstr "Comprobar configuración" + +msgid "Config is valid" +msgstr "La configuración es válida" + +msgid "Configuration and firewall are ready." +msgstr "La configuración y el cortafuegos están listos." + +msgid "Configuration is valid. Firewall conflicts: %d" +msgstr "La configuración es válida. Conflictos del cortafuegos: %d" + +msgid "Configure shared Web entry ports, router access, and firewall automation." +msgstr "Configure los puertos Web compartidos, el acceso al router y la automatización del cortafuegos." + +msgid "Conflict" +msgstr "Conflicto" + +msgid "Contents" +msgstr "Contenido" + +msgid "Create recovery point" +msgstr "Crear punto de restauración" + +msgid "Created" +msgstr "Creado" + +msgid "Creates and updates only firewall rules owned by HAProxy Manager." +msgstr "Crea y actualiza únicamente las reglas del cortafuegos gestionadas por HAProxy Manager." + +msgid "Custom TCP" +msgstr "TCP personalizado" + +msgid "Destination" +msgstr "Destino" + +msgid "Destination host" +msgstr "Host de destino" + +msgid "Destination HTTP port" +msgstr "Puerto HTTP de destino" + +msgid "Destination HTTPS port" +msgstr "Puerto HTTPS de destino" + +msgid "Destination RDP port" +msgstr "Puerto RDP de destino" + +msgid "Destination SSH port" +msgstr "Puerto SSH de destino" + +msgid "Disable conflicting forwards during apply" +msgstr "Desactivar redirecciones conflictivas al aplicar" + +msgid "Domain" +msgstr "Dominio" + +msgid "Enable managed configuration" +msgstr "Activar configuración administrada" + +msgid "Enabled" +msgstr "Activado" + +msgid "Every Web service shares these public ports. SSH, Remote Desktop, and Custom TCP services use the ports configured on each service." +msgstr "Todos los servicios Web comparten estos puertos públicos. SSH, Escritorio remoto y TCP personalizado usan los puertos configurados en cada servicio." + +msgid "Existing port-forward conflicts" +msgstr "Conflictos con redirecciones existentes" + +msgid "Expert editor for /etc/haproxy.cfg." +msgstr "Editor avanzado de /etc/haproxy.cfg." + +msgid "Filter services" +msgstr "Filtrar servicios" + +msgid "Firewall" +msgstr "Cortafuegos" + +msgid "Firewall automation" +msgstr "Automatización del cortafuegos" + +msgid "Firewall conflict detected." +msgstr "Se detectó un conflicto del cortafuegos." + +msgid "Generated routes can overwrite manual changes to this file." +msgstr "Las rutas generadas pueden sobrescribir los cambios manuales de este archivo." + +msgid "Grant access to LuCI HAProxy Manager" +msgstr "Conceder acceso a LuCI HAProxy Manager" + +msgid "HAProxy" +msgstr "HAProxy" + +msgid "HAProxy service" +msgstr "Servicio HAProxy" + +msgid "HAProxy Services" +msgstr "Servicios HAProxy" + +msgid "HAProxy Settings" +msgstr "Ajustes de HAProxy" + +msgid "HAProxy Status" +msgstr "Estado de HAProxy" + +msgid "HAProxy version" +msgstr "Versión de HAProxy" + +msgid "HAProxy, firewall, and LuCI settings will be restored from %s." +msgstr "La configuración de HAProxy, del cortafuegos y de LuCI se restaurará desde %s." + +msgid "HTTP" +msgstr "HTTP" + +msgid "HTTP only" +msgstr "Solo HTTP" + +msgid "HTTPS" +msgstr "HTTPS" + +msgid "HTTPS only" +msgstr "Solo HTTPS" + +msgid "Keep LuCI on the LAN address" +msgstr "Mantener LuCI en la dirección LAN" + +msgid "Latest" +msgstr "Más reciente" + +msgid "Legacy Web" +msgstr "Web heredado" + +msgid "LuCI LAN address" +msgstr "Dirección LAN de LuCI" + +msgid "Managed" +msgstr "Gestionado" + +msgid "Manual" +msgstr "Manual" + +msgid "My service" +msgstr "Mi servicio" + +msgid "No HAProxy or LuCI listeners detected." +msgstr "No se detectaron escuchas de HAProxy o LuCI." + +msgid "No matching services" +msgstr "No hay servicios coincidentes" + +msgid "No recovery points yet." +msgstr "Aún no hay puntos de restauración." + +msgid "Not available" +msgstr "No disponible" + +msgid "Not set" +msgstr "Sin definir" + +msgid "Off" +msgstr "Desactivado" + +msgid "On" +msgstr "Activado" + +msgid "Open HAProxy ports on WAN automatically" +msgstr "Abrir automáticamente los puertos de HAProxy en WAN" + +msgid "Open recovery" +msgstr "Abrir recuperación" + +msgid "Pending" +msgstr "Pendiente" + +msgid "Process" +msgstr "Proceso" + +msgid "Protocol" +msgstr "Protocolo" + +msgid "Public endpoint" +msgstr "Punto de entrada público" + +msgid "Public HTTP port" +msgstr "Puerto HTTP público" + +msgid "Public HTTPS port" +msgstr "Puerto HTTPS público" + +msgid "Public RDP port" +msgstr "Puerto RDP público" + +msgid "Public SSH port" +msgstr "Puerto SSH público" + +msgid "Publish HTTP" +msgstr "Publicar HTTP" + +msgid "Publish HTTPS" +msgstr "Publicar HTTPS" + +msgid "Publish Web, SSH, Remote Desktop, and custom TCP services from one place." +msgstr "Publique servicios Web, SSH, Escritorio remoto y TCP personalizado desde un solo lugar." + +msgid "Raw Config" +msgstr "Configuración" + +msgid "Raw HAProxy Config" +msgstr "Configuración de HAProxy" + +msgid "Raw HAProxy configuration" +msgstr "Configuración de HAProxy" + +msgid "Ready" +msgstr "Listo" + +msgid "Recommended when HAProxy uses public ports 80 or 443. LuCI remains available from the local network." +msgstr "Recomendado cuando HAProxy usa los puertos públicos 80 o 443. LuCI seguirá disponible desde la red local." + +msgid "Recovery" +msgstr "Recuperación" + +msgid "Recovery points" +msgstr "Puntos de restauración" + +msgid "Refresh" +msgstr "Actualizar" + +msgid "Remote Desktop" +msgstr "Escritorio remoto" + +msgid "Restore" +msgstr "Restaurar" + +msgid "Restore configuration?" +msgstr "¿Restaurar la configuración?" + +msgid "Restore HAProxy, firewall, and LuCI settings from a previous snapshot." +msgstr "Restaure la configuración de HAProxy, del cortafuegos y de LuCI desde una instantánea anterior." + +msgid "Restored" +msgstr "Restaurado" + +msgid "Review firewall settings" +msgstr "Revisar ajustes del cortafuegos" + +msgid "Router access" +msgstr "Acceso al router" + +msgid "Running" +msgstr "En ejecución" + +msgid "Save and apply" +msgstr "Guardar y aplicar" + +msgid "Save settings" +msgstr "Guardar ajustes" + +msgid "Service" +msgstr "Servicio" + +msgid "Service health, active listeners, and recovery points." +msgstr "Estado del servicio, listeners activos y puntos de restauración." + +msgid "Service name" +msgstr "Nombre del servicio" + +msgid "Service type" +msgstr "Tipo de servicio" + +msgid "Services" +msgstr "Servicios" + +msgid "Settings" +msgstr "Ajustes" + +msgid "Settings saved" +msgstr "Ajustes guardados" + +msgid "SSH" +msgstr "SSH" + +msgid "Status" +msgstr "Estado" + +msgid "Stop and show conflicts" +msgstr "Detener y mostrar conflictos" + +msgid "Stopped" +msgstr "Detenido" + +msgid "TCP" +msgstr "TCP" + +msgid "TCP port mappings" +msgstr "Asignaciones de puertos TCP" + +msgid "The file will be validated and backed up before HAProxy restarts." +msgstr "El archivo se validará y guardará antes de reiniciar HAProxy." + +msgid "Use the public:destination format, for example 8443:443." +msgstr "Use el formato público:destino, por ejemplo 8443:443." + +msgid "Validate" +msgstr "Validar" + +msgid "WAN address" +msgstr "Dirección WAN" + +msgid "WAN bind address" +msgstr "Dirección de enlace WAN" + +msgid "WAN firewall zone" +msgstr "Zona WAN del cortafuegos" + +msgid "Web" +msgstr "Web" + +msgid "Web (HTTP + HTTPS)" +msgstr "Web (HTTP + HTTPS)" + +msgid "Web entry ports" +msgstr "Puertos de entrada Web" +msgid "Changes saved and applied." +msgstr "Cambios guardados y aplicados." + +msgid "Delete and apply" +msgstr "Eliminar y aplicar" + +msgid "Delete service?" +msgstr "¿Eliminar el servicio?" + +msgid "Synchronize now" +msgstr "Sincronizar ahora" + +msgid "The service will be removed from HAProxy and the firewall immediately." +msgstr "El servicio se eliminará inmediatamente de HAProxy y del cortafuegos." + +msgid "Unable to create a recovery point." +msgstr "No se pudo crear un punto de recuperación." + +msgid "Automatic recovery" +msgstr "Recuperación automática" + +msgid "Disabled" +msgstr "Desactivada" + +msgid "Failed" +msgstr "Falló" + +msgid "If HAProxy stops before a listener address exists, retry it after a network interface comes up and retain diagnostics for the seven latest incidents." +msgstr "Si HAProxy se detiene antes de que exista una dirección de escucha, reintentar tras activarse una interfaz de red y conservar el diagnóstico de los siete incidentes más recientes." + +msgid "In progress" +msgstr "En curso" + +msgid "Invalid configuration" +msgstr "Configuración no válida" + +msgid "Last recovery" +msgstr "Última recuperación" + +msgid "No incidents recorded" +msgstr "No se registraron incidentes" + +msgid "Recover HAProxy after networking becomes ready" +msgstr "Recuperar HAProxy cuando la red esté lista" + +msgid "Recovered" +msgstr "Recuperado" + +msgid "Recovery point failed" +msgstr "Error al crear el punto de recuperación" + +msgid "WAN network interface" +msgstr "Interfaz de red WAN" + +msgid "Close" +msgstr "Cerrar" + +msgid "Configuration mode" +msgstr "Modo de configuración" + +msgid "Diagnostic report for %s." +msgstr "Informe de diagnóstico de %s." + +msgid "Enter an HTTP or HTTPS URL without spaces." +msgstr "Introduzca una URL HTTP o HTTPS sin espacios." + +msgid "Failed, configuration restored" +msgstr "Error; se restauró la configuración" + +msgid "Failed, restore also failed" +msgstr "Error; la restauración también falló" + +msgid "Generated routes" +msgstr "Rutas generadas" + +msgid "Incident diagnostics" +msgstr "Diagnóstico del incidente" + +msgid "Not managed" +msgstr "No gestionado" + +msgid "Raw configuration" +msgstr "Configuración manual" + +msgid "Reconciles generated listeners after WAN address changes, retries a stopped service, and retains diagnostics for the seven latest incidents. Raw configurations are never overwritten." +msgstr "Sincroniza los listeners generados tras cambios de la dirección WAN, vuelve a iniciar un servicio detenido y conserva el diagnóstico de los siete incidentes más recientes. Las configuraciones manuales nunca se sobrescriben." + +msgid "Recovery incidents" +msgstr "Incidentes de recuperación" + +msgid "Result" +msgstr "Resultado" + +msgid "Send recovery webhook notifications" +msgstr "Enviar notificaciones webhook de recuperación" + +msgid "Sends only the incident identifier, result, action, interface, and reason. Configuration, addresses, and diagnostic logs are never included." +msgstr "Solo envía el identificador del incidente, el resultado, la acción, la interfaz y el motivo. Nunca incluye la configuración, las direcciones ni los registros de diagnóstico." + +msgid "The selected logical interface is monitored for address changes. In multi-WAN setups, select the interface that receives public connections." +msgstr "Se supervisan los cambios de dirección de la interfaz lógica seleccionada. En configuraciones multi-WAN, seleccione la interfaz que recibe conexiones públicas." + +msgid "The seven latest automatic recovery reports are retained on the router." +msgstr "El router conserva los siete informes de recuperación automática más recientes." + +msgid "Trigger" +msgstr "Desencadenante" + +msgid "View" +msgstr "Ver" + +msgid "WAN address reconciled" +msgstr "Dirección WAN sincronizada" + +msgid "WAN address unavailable" +msgstr "Dirección WAN no disponible" + +msgid "Webhook URL" +msgstr "URL del webhook" +msgid "Generated configuration is invalid" +msgstr "La configuración generada no es válida" + +msgid "Configuration installation failed" +msgstr "No se pudo instalar la configuración" diff --git a/luci-app-haproxy-manager/po/ja/haproxy-manager.po b/luci-app-haproxy-manager/po/ja/haproxy-manager.po new file mode 100644 index 00000000..184c8734 --- /dev/null +++ b/luci-app-haproxy-manager/po/ja/haproxy-manager.po @@ -0,0 +1,499 @@ +msgid "" +msgstr "" +"Language: ja\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" + +msgid "Command failed with code %d." +msgstr "コマンドはコード %d で終了しました。" + +msgid "%d configuration files" +msgstr "%d 個の設定ファイル" + +msgid "%d conflicts" +msgstr "%d 件の競合" + +msgid "%d services" +msgstr "%d 個のサービス" + +msgid "A restorable snapshot is created before migration and every apply." +msgstr "移行前および変更を適用するたびに復元ポイントを作成します。" + +msgid "Active listeners" +msgstr "有効なリスナー" + +msgid "Add service" +msgstr "サービスを追加" + +msgid "Address" +msgstr "アドレス" + +msgid "Applied" +msgstr "適用しました" + +msgid "Apply" +msgstr "適用" + +msgid "Apply changes" +msgstr "変更を適用" + +msgid "Apply raw config" +msgstr "設定を適用" + +msgid "Apply raw configuration?" +msgstr "手動設定を適用しますか?" + +msgid "Automatic" +msgstr "自動" + +msgid "Cancel" +msgstr "キャンセル" + +msgid "Check configuration" +msgstr "設定を確認" + +msgid "Config is valid" +msgstr "設定は有効です" + +msgid "Configuration and firewall are ready." +msgstr "設定とファイアウォールの準備が完了しました。" + +msgid "Configuration is valid. Firewall conflicts: %d" +msgstr "設定は有効です。ファイアウォールの競合: %d" + +msgid "Configure shared Web entry ports, router access, and firewall automation." +msgstr "共有 Web ポート、ルーターへのアクセス、ファイアウォールの自動化を設定します。" + +msgid "Conflict" +msgstr "競合" + +msgid "Contents" +msgstr "内容" + +msgid "Create recovery point" +msgstr "復元ポイントを作成" + +msgid "Created" +msgstr "作成日時" + +msgid "Creates and updates only firewall rules owned by HAProxy Manager." +msgstr "HAProxy Manager が所有するファイアウォールルールのみを作成、更新します。" + +msgid "Custom TCP" +msgstr "カスタム TCP" + +msgid "Destination" +msgstr "転送先" + +msgid "Destination host" +msgstr "転送先ホスト" + +msgid "Destination HTTP port" +msgstr "転送先 HTTP ポート" + +msgid "Destination HTTPS port" +msgstr "転送先 HTTPS ポート" + +msgid "Destination RDP port" +msgstr "転送先 RDP ポート" + +msgid "Destination SSH port" +msgstr "転送先 SSH ポート" + +msgid "Disable conflicting forwards during apply" +msgstr "適用時に競合する転送を無効化" + +msgid "Domain" +msgstr "ドメイン" + +msgid "Enable managed configuration" +msgstr "管理対象の設定を有効化" + +msgid "Enabled" +msgstr "有効" + +msgid "Every Web service shares these public ports. SSH, Remote Desktop, and Custom TCP services use the ports configured on each service." +msgstr "すべての Web サービスはこれらの公開ポートを共有します。SSH、リモートデスクトップ、カスタム TCP は各サービスに設定したポートを使用します。" + +msgid "Existing port-forward conflicts" +msgstr "既存のポート転送との競合" + +msgid "Expert editor for /etc/haproxy.cfg." +msgstr "/etc/haproxy.cfg の上級者向けエディターです。" + +msgid "Filter services" +msgstr "サービスを絞り込み" + +msgid "Firewall" +msgstr "ファイアウォール" + +msgid "Firewall automation" +msgstr "ファイアウォールの自動化" + +msgid "Firewall conflict detected." +msgstr "ファイアウォールの競合を検出しました。" + +msgid "Generated routes can overwrite manual changes to this file." +msgstr "生成されたルートにより、このファイルの手動変更が上書きされる場合があります。" + +msgid "Grant access to LuCI HAProxy Manager" +msgstr "LuCI HAProxy Manager へのアクセスを許可" + +msgid "HAProxy" +msgstr "HAProxy" + +msgid "HAProxy service" +msgstr "HAProxy サービス" + +msgid "HAProxy Services" +msgstr "HAProxy サービス" + +msgid "HAProxy Settings" +msgstr "HAProxy 設定" + +msgid "HAProxy Status" +msgstr "HAProxy ステータス" + +msgid "HAProxy version" +msgstr "HAProxy バージョン" + +msgid "HAProxy, firewall, and LuCI settings will be restored from %s." +msgstr "HAProxy、ファイアウォール、LuCI の設定を %s から復元します。" + +msgid "HTTP" +msgstr "HTTP" + +msgid "HTTP only" +msgstr "HTTP のみ" + +msgid "HTTPS" +msgstr "HTTPS" + +msgid "HTTPS only" +msgstr "HTTPS のみ" + +msgid "Keep LuCI on the LAN address" +msgstr "LuCI を LAN アドレスに限定" + +msgid "Latest" +msgstr "最新" + +msgid "Legacy Web" +msgstr "旧形式 Web" + +msgid "LuCI LAN address" +msgstr "LuCI LAN アドレス" + +msgid "Managed" +msgstr "管理中" + +msgid "Manual" +msgstr "手動" + +msgid "My service" +msgstr "マイサービス" + +msgid "No HAProxy or LuCI listeners detected." +msgstr "HAProxy または LuCI のリスナーが見つかりません。" + +msgid "No matching services" +msgstr "一致するサービスはありません" + +msgid "No recovery points yet." +msgstr "復元ポイントはまだありません。" + +msgid "Not available" +msgstr "利用不可" + +msgid "Not set" +msgstr "未設定" + +msgid "Off" +msgstr "オフ" + +msgid "On" +msgstr "オン" + +msgid "Open HAProxy ports on WAN automatically" +msgstr "WAN の HAProxy ポートを自動的に開く" + +msgid "Open recovery" +msgstr "復元を開く" + +msgid "Pending" +msgstr "適用待ち" + +msgid "Process" +msgstr "プロセス" + +msgid "Protocol" +msgstr "プロトコル" + +msgid "Public endpoint" +msgstr "公開エンドポイント" + +msgid "Public HTTP port" +msgstr "公開 HTTP ポート" + +msgid "Public HTTPS port" +msgstr "公開 HTTPS ポート" + +msgid "Public RDP port" +msgstr "公開 RDP ポート" + +msgid "Public SSH port" +msgstr "公開 SSH ポート" + +msgid "Publish HTTP" +msgstr "HTTP を公開" + +msgid "Publish HTTPS" +msgstr "HTTPS を公開" + +msgid "Publish Web, SSH, Remote Desktop, and custom TCP services from one place." +msgstr "Web、SSH、リモートデスクトップ、カスタム TCP サービスを一か所から公開します。" + +msgid "Raw Config" +msgstr "生設定" + +msgid "Raw HAProxy Config" +msgstr "HAProxy 生設定" + +msgid "Raw HAProxy configuration" +msgstr "HAProxy 生設定" + +msgid "Ready" +msgstr "準備完了" + +msgid "Recommended when HAProxy uses public ports 80 or 443. LuCI remains available from the local network." +msgstr "HAProxy が公開ポート 80 または 443 を使用する場合に推奨します。LuCI はローカルネットワークから利用できます。" + +msgid "Recovery" +msgstr "復元" + +msgid "Recovery points" +msgstr "復元ポイント" + +msgid "Refresh" +msgstr "更新" + +msgid "Remote Desktop" +msgstr "リモートデスクトップ" + +msgid "Restore" +msgstr "復元" + +msgid "Restore configuration?" +msgstr "設定を復元しますか?" + +msgid "Restore HAProxy, firewall, and LuCI settings from a previous snapshot." +msgstr "以前のスナップショットから HAProxy、ファイアウォール、LuCI の設定を復元します。" + +msgid "Restored" +msgstr "復元しました" + +msgid "Review firewall settings" +msgstr "ファイアウォール設定を確認" + +msgid "Router access" +msgstr "ルーターへのアクセス" + +msgid "Running" +msgstr "実行中" + +msgid "Save and apply" +msgstr "保存して適用" + +msgid "Save settings" +msgstr "設定を保存" + +msgid "Service" +msgstr "サービス" + +msgid "Service health, active listeners, and recovery points." +msgstr "サービス状態、待受ポート、復元ポイントを表示します。" + +msgid "Service name" +msgstr "サービス名" + +msgid "Service type" +msgstr "サービス種別" + +msgid "Services" +msgstr "サービス" + +msgid "Settings" +msgstr "設定" + +msgid "Settings saved" +msgstr "設定を保存しました" + +msgid "SSH" +msgstr "SSH" + +msgid "Status" +msgstr "ステータス" + +msgid "Stop and show conflicts" +msgstr "停止して競合を表示" + +msgid "Stopped" +msgstr "停止中" + +msgid "TCP" +msgstr "TCP" + +msgid "TCP port mappings" +msgstr "TCP ポートマッピング" + +msgid "The file will be validated and backed up before HAProxy restarts." +msgstr "HAProxy の再起動前にファイルを検証し、バックアップします。" + +msgid "Use the public:destination format, for example 8443:443." +msgstr "公開:転送先 の形式を使用してください。例: 8443:443" + +msgid "Validate" +msgstr "検証" + +msgid "WAN address" +msgstr "WAN アドレス" + +msgid "WAN bind address" +msgstr "WAN バインドアドレス" + +msgid "WAN firewall zone" +msgstr "WAN ファイアウォールゾーン" + +msgid "Web" +msgstr "Web" + +msgid "Web (HTTP + HTTPS)" +msgstr "Web (HTTP + HTTPS)" + +msgid "Web entry ports" +msgstr "Web 公開ポート" +msgid "Changes saved and applied." +msgstr "変更を保存して適用しました。" + +msgid "Delete and apply" +msgstr "削除して適用" + +msgid "Delete service?" +msgstr "サービスを削除しますか?" + +msgid "Synchronize now" +msgstr "今すぐ同期" + +msgid "The service will be removed from HAProxy and the firewall immediately." +msgstr "サービスは HAProxy とファイアウォールから直ちに削除されます。" + +msgid "Unable to create a recovery point." +msgstr "復元ポイントを作成できません。" + +msgid "Automatic recovery" +msgstr "自動復旧" + +msgid "Disabled" +msgstr "無効" + +msgid "Failed" +msgstr "失敗" + +msgid "If HAProxy stops before a listener address exists, retry it after a network interface comes up and retain diagnostics for the seven latest incidents." +msgstr "リスナーアドレスが利用可能になる前に HAProxy が停止した場合、ネットワークインターフェースの起動後に再試行し、最新 7 件の診断を保存します。" + +msgid "In progress" +msgstr "進行中" + +msgid "Invalid configuration" +msgstr "無効な設定" + +msgid "Last recovery" +msgstr "最終復旧" + +msgid "No incidents recorded" +msgstr "インシデントは記録されていません" + +msgid "Recover HAProxy after networking becomes ready" +msgstr "ネットワーク準備完了後に HAProxy を復旧" + +msgid "Recovered" +msgstr "復旧済み" + +msgid "Recovery point failed" +msgstr "復旧ポイントの作成に失敗" + +msgid "WAN network interface" +msgstr "WAN ネットワークインターフェース" + +msgid "Close" +msgstr "閉じる" + +msgid "Configuration mode" +msgstr "設定モード" + +msgid "Diagnostic report for %s." +msgstr "%s の診断レポートです。" + +msgid "Enter an HTTP or HTTPS URL without spaces." +msgstr "空白を含まない HTTP または HTTPS URL を入力してください。" + +msgid "Failed, configuration restored" +msgstr "失敗したため設定を復元しました" + +msgid "Failed, restore also failed" +msgstr "失敗し、復元にも失敗しました" + +msgid "Generated routes" +msgstr "生成されたルート" + +msgid "Incident diagnostics" +msgstr "インシデント診断" + +msgid "Not managed" +msgstr "管理対象外" + +msgid "Raw configuration" +msgstr "手動設定" + +msgid "Reconciles generated listeners after WAN address changes, retries a stopped service, and retains diagnostics for the seven latest incidents. Raw configurations are never overwritten." +msgstr "WAN アドレスの変更後に生成済みリスナーを同期し、停止したサービスを再試行して、直近 7 件のインシデント診断を保持します。手動設定は上書きされません。" + +msgid "Recovery incidents" +msgstr "復旧インシデント" + +msgid "Result" +msgstr "結果" + +msgid "Send recovery webhook notifications" +msgstr "復旧 Webhook 通知を送信" + +msgid "Sends only the incident identifier, result, action, interface, and reason. Configuration, addresses, and diagnostic logs are never included." +msgstr "インシデント ID、結果、アクション、インターフェース、理由のみを送信します。設定、アドレス、診断ログは含まれません。" + +msgid "The selected logical interface is monitored for address changes. In multi-WAN setups, select the interface that receives public connections." +msgstr "選択した論理インターフェースのアドレス変更を監視します。マルチ WAN 構成では、外部接続を受信するインターフェースを選択してください。" + +msgid "The seven latest automatic recovery reports are retained on the router." +msgstr "直近 7 件の自動復旧レポートがルーターに保持されます。" + +msgid "Trigger" +msgstr "トリガー" + +msgid "View" +msgstr "表示" + +msgid "WAN address reconciled" +msgstr "WAN アドレスを同期しました" + +msgid "WAN address unavailable" +msgstr "WAN アドレスを利用できません" + +msgid "Webhook URL" +msgstr "Webhook URL" +msgid "Generated configuration is invalid" +msgstr "生成された設定が無効です" + +msgid "Configuration installation failed" +msgstr "設定のインストールに失敗しました" diff --git a/luci-app-haproxy-manager/po/ko/haproxy-manager.po b/luci-app-haproxy-manager/po/ko/haproxy-manager.po new file mode 100644 index 00000000..3fa7de16 --- /dev/null +++ b/luci-app-haproxy-manager/po/ko/haproxy-manager.po @@ -0,0 +1,499 @@ +msgid "" +msgstr "" +"Language: ko\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" + +msgid "Command failed with code %d." +msgstr "명령이 코드 %d(으)로 종료되었습니다." + +msgid "%d configuration files" +msgstr "설정 파일 %d개" + +msgid "%d conflicts" +msgstr "충돌 %d개" + +msgid "%d services" +msgstr "서비스 %d개" + +msgid "A restorable snapshot is created before migration and every apply." +msgstr "마이그레이션 전과 변경 사항을 적용할 때마다 복원 지점을 만듭니다." + +msgid "Active listeners" +msgstr "활성 리스너" + +msgid "Add service" +msgstr "서비스 추가" + +msgid "Address" +msgstr "주소" + +msgid "Applied" +msgstr "적용됨" + +msgid "Apply" +msgstr "적용" + +msgid "Apply changes" +msgstr "변경 사항 적용" + +msgid "Apply raw config" +msgstr "설정 적용" + +msgid "Apply raw configuration?" +msgstr "수동 설정을 적용하시겠습니까?" + +msgid "Automatic" +msgstr "자동" + +msgid "Cancel" +msgstr "취소" + +msgid "Check configuration" +msgstr "설정 확인" + +msgid "Config is valid" +msgstr "설정이 유효합니다" + +msgid "Configuration and firewall are ready." +msgstr "설정과 방화벽이 준비되었습니다." + +msgid "Configuration is valid. Firewall conflicts: %d" +msgstr "설정이 유효합니다. 방화벽 충돌: %d" + +msgid "Configure shared Web entry ports, router access, and firewall automation." +msgstr "공유 Web 포트, 라우터 접근 및 방화벽 자동화를 설정합니다." + +msgid "Conflict" +msgstr "충돌" + +msgid "Contents" +msgstr "내용" + +msgid "Create recovery point" +msgstr "복원 지점 만들기" + +msgid "Created" +msgstr "생성 시각" + +msgid "Creates and updates only firewall rules owned by HAProxy Manager." +msgstr "HAProxy Manager가 소유한 방화벽 규칙만 생성하고 업데이트합니다." + +msgid "Custom TCP" +msgstr "사용자 지정 TCP" + +msgid "Destination" +msgstr "대상" + +msgid "Destination host" +msgstr "대상 호스트" + +msgid "Destination HTTP port" +msgstr "대상 HTTP 포트" + +msgid "Destination HTTPS port" +msgstr "대상 HTTPS 포트" + +msgid "Destination RDP port" +msgstr "대상 RDP 포트" + +msgid "Destination SSH port" +msgstr "대상 SSH 포트" + +msgid "Disable conflicting forwards during apply" +msgstr "적용 시 충돌하는 포트 포워딩 비활성화" + +msgid "Domain" +msgstr "도메인" + +msgid "Enable managed configuration" +msgstr "관리 설정 활성화" + +msgid "Enabled" +msgstr "활성화" + +msgid "Every Web service shares these public ports. SSH, Remote Desktop, and Custom TCP services use the ports configured on each service." +msgstr "모든 Web 서비스는 이 공개 포트를 공유합니다. SSH, 원격 데스크톱 및 사용자 지정 TCP 서비스는 각 서비스에 설정된 포트를 사용합니다." + +msgid "Existing port-forward conflicts" +msgstr "기존 포트 포워딩 충돌" + +msgid "Expert editor for /etc/haproxy.cfg." +msgstr "/etc/haproxy.cfg 고급 편집기입니다." + +msgid "Filter services" +msgstr "서비스 필터" + +msgid "Firewall" +msgstr "방화벽" + +msgid "Firewall automation" +msgstr "방화벽 자동화" + +msgid "Firewall conflict detected." +msgstr "방화벽 충돌이 감지되었습니다." + +msgid "Generated routes can overwrite manual changes to this file." +msgstr "생성된 경로가 이 파일의 수동 변경 내용을 덮어쓸 수 있습니다." + +msgid "Grant access to LuCI HAProxy Manager" +msgstr "LuCI HAProxy Manager에 대한 접근 권한 부여" + +msgid "HAProxy" +msgstr "HAProxy" + +msgid "HAProxy service" +msgstr "HAProxy 서비스" + +msgid "HAProxy Services" +msgstr "HAProxy 서비스" + +msgid "HAProxy Settings" +msgstr "HAProxy 설정" + +msgid "HAProxy Status" +msgstr "HAProxy 상태" + +msgid "HAProxy version" +msgstr "HAProxy 버전" + +msgid "HAProxy, firewall, and LuCI settings will be restored from %s." +msgstr "HAProxy, 방화벽 및 LuCI 설정을 %s에서 복원합니다." + +msgid "HTTP" +msgstr "HTTP" + +msgid "HTTP only" +msgstr "HTTP만" + +msgid "HTTPS" +msgstr "HTTPS" + +msgid "HTTPS only" +msgstr "HTTPS만" + +msgid "Keep LuCI on the LAN address" +msgstr "LuCI를 LAN 주소에 유지" + +msgid "Latest" +msgstr "최신" + +msgid "Legacy Web" +msgstr "기존 Web" + +msgid "LuCI LAN address" +msgstr "LuCI LAN 주소" + +msgid "Managed" +msgstr "관리됨" + +msgid "Manual" +msgstr "수동" + +msgid "My service" +msgstr "내 서비스" + +msgid "No HAProxy or LuCI listeners detected." +msgstr "HAProxy 또는 LuCI 리스너가 감지되지 않았습니다." + +msgid "No matching services" +msgstr "일치하는 서비스가 없습니다" + +msgid "No recovery points yet." +msgstr "아직 복원 지점이 없습니다." + +msgid "Not available" +msgstr "사용할 수 없음" + +msgid "Not set" +msgstr "설정되지 않음" + +msgid "Off" +msgstr "꺼짐" + +msgid "On" +msgstr "켜짐" + +msgid "Open HAProxy ports on WAN automatically" +msgstr "WAN에서 HAProxy 포트를 자동으로 열기" + +msgid "Open recovery" +msgstr "복원 열기" + +msgid "Pending" +msgstr "적용 대기" + +msgid "Process" +msgstr "프로세스" + +msgid "Protocol" +msgstr "프로토콜" + +msgid "Public endpoint" +msgstr "공개 엔드포인트" + +msgid "Public HTTP port" +msgstr "공개 HTTP 포트" + +msgid "Public HTTPS port" +msgstr "공개 HTTPS 포트" + +msgid "Public RDP port" +msgstr "공개 RDP 포트" + +msgid "Public SSH port" +msgstr "공개 SSH 포트" + +msgid "Publish HTTP" +msgstr "HTTP 공개" + +msgid "Publish HTTPS" +msgstr "HTTPS 공개" + +msgid "Publish Web, SSH, Remote Desktop, and custom TCP services from one place." +msgstr "Web, SSH, 원격 데스크톱 및 사용자 지정 TCP 서비스를 한 곳에서 공개합니다." + +msgid "Raw Config" +msgstr "원시 설정" + +msgid "Raw HAProxy Config" +msgstr "HAProxy 원시 설정" + +msgid "Raw HAProxy configuration" +msgstr "HAProxy 원시 설정" + +msgid "Ready" +msgstr "준비됨" + +msgid "Recommended when HAProxy uses public ports 80 or 443. LuCI remains available from the local network." +msgstr "HAProxy가 공개 포트 80 또는 443을 사용할 때 권장합니다. LuCI는 로컬 네트워크에서 계속 사용할 수 있습니다." + +msgid "Recovery" +msgstr "복원" + +msgid "Recovery points" +msgstr "복원 지점" + +msgid "Refresh" +msgstr "새로 고침" + +msgid "Remote Desktop" +msgstr "원격 데스크톱" + +msgid "Restore" +msgstr "복원" + +msgid "Restore configuration?" +msgstr "설정을 복원하시겠습니까?" + +msgid "Restore HAProxy, firewall, and LuCI settings from a previous snapshot." +msgstr "이전 스냅샷에서 HAProxy, 방화벽 및 LuCI 설정을 복원합니다." + +msgid "Restored" +msgstr "복원됨" + +msgid "Review firewall settings" +msgstr "방화벽 설정 검토" + +msgid "Router access" +msgstr "라우터 접근" + +msgid "Running" +msgstr "실행 중" + +msgid "Save and apply" +msgstr "저장 후 적용" + +msgid "Save settings" +msgstr "설정 저장" + +msgid "Service" +msgstr "서비스" + +msgid "Service health, active listeners, and recovery points." +msgstr "서비스 상태, 활성 리스너 및 복원 지점입니다." + +msgid "Service name" +msgstr "서비스 이름" + +msgid "Service type" +msgstr "서비스 유형" + +msgid "Services" +msgstr "서비스" + +msgid "Settings" +msgstr "설정" + +msgid "Settings saved" +msgstr "설정이 저장되었습니다" + +msgid "SSH" +msgstr "SSH" + +msgid "Status" +msgstr "상태" + +msgid "Stop and show conflicts" +msgstr "중지하고 충돌 표시" + +msgid "Stopped" +msgstr "중지됨" + +msgid "TCP" +msgstr "TCP" + +msgid "TCP port mappings" +msgstr "TCP 포트 매핑" + +msgid "The file will be validated and backed up before HAProxy restarts." +msgstr "HAProxy를 다시 시작하기 전에 파일을 검증하고 백업합니다." + +msgid "Use the public:destination format, for example 8443:443." +msgstr "공개:대상 형식을 사용하십시오. 예: 8443:443" + +msgid "Validate" +msgstr "검증" + +msgid "WAN address" +msgstr "WAN 주소" + +msgid "WAN bind address" +msgstr "WAN 바인드 주소" + +msgid "WAN firewall zone" +msgstr "WAN 방화벽 영역" + +msgid "Web" +msgstr "Web" + +msgid "Web (HTTP + HTTPS)" +msgstr "Web (HTTP + HTTPS)" + +msgid "Web entry ports" +msgstr "Web 공개 포트" +msgid "Changes saved and applied." +msgstr "변경 사항이 저장되고 적용되었습니다." + +msgid "Delete and apply" +msgstr "삭제 후 적용" + +msgid "Delete service?" +msgstr "서비스를 삭제하시겠습니까?" + +msgid "Synchronize now" +msgstr "지금 동기화" + +msgid "The service will be removed from HAProxy and the firewall immediately." +msgstr "서비스가 HAProxy와 방화벽에서 즉시 제거됩니다." + +msgid "Unable to create a recovery point." +msgstr "복구 지점을 만들 수 없습니다." + +msgid "Automatic recovery" +msgstr "자동 복구" + +msgid "Disabled" +msgstr "비활성화" + +msgid "Failed" +msgstr "실패" + +msgid "If HAProxy stops before a listener address exists, retry it after a network interface comes up and retain diagnostics for the seven latest incidents." +msgstr "리스너 주소가 준비되기 전에 HAProxy가 중지되면 네트워크 인터페이스가 올라온 뒤 다시 시작하고 최근 7개 장애의 진단 정보를 보관합니다." + +msgid "In progress" +msgstr "진행 중" + +msgid "Invalid configuration" +msgstr "잘못된 구성" + +msgid "Last recovery" +msgstr "마지막 복구" + +msgid "No incidents recorded" +msgstr "기록된 장애 없음" + +msgid "Recover HAProxy after networking becomes ready" +msgstr "네트워크 준비 후 HAProxy 복구" + +msgid "Recovered" +msgstr "복구됨" + +msgid "Recovery point failed" +msgstr "복구 지점 생성 실패" + +msgid "WAN network interface" +msgstr "WAN 네트워크 인터페이스" + +msgid "Close" +msgstr "닫기" + +msgid "Configuration mode" +msgstr "구성 모드" + +msgid "Diagnostic report for %s." +msgstr "%s 진단 보고서입니다." + +msgid "Enter an HTTP or HTTPS URL without spaces." +msgstr "공백 없이 HTTP 또는 HTTPS URL을 입력하십시오." + +msgid "Failed, configuration restored" +msgstr "실패하여 구성을 복원함" + +msgid "Failed, restore also failed" +msgstr "실패했으며 복원도 실패함" + +msgid "Generated routes" +msgstr "생성된 경로" + +msgid "Incident diagnostics" +msgstr "장애 진단" + +msgid "Not managed" +msgstr "관리되지 않음" + +msgid "Raw configuration" +msgstr "수동 구성" + +msgid "Reconciles generated listeners after WAN address changes, retries a stopped service, and retains diagnostics for the seven latest incidents. Raw configurations are never overwritten." +msgstr "WAN 주소 변경 후 생성된 리스너를 동기화하고 중지된 서비스를 다시 시작하며 최근 7개 장애의 진단을 보관합니다. 수동 구성은 절대 덮어쓰지 않습니다." + +msgid "Recovery incidents" +msgstr "복구 장애" + +msgid "Result" +msgstr "결과" + +msgid "Send recovery webhook notifications" +msgstr "복구 웹훅 알림 보내기" + +msgid "Sends only the incident identifier, result, action, interface, and reason. Configuration, addresses, and diagnostic logs are never included." +msgstr "장애 식별자, 결과, 작업, 인터페이스 및 원인만 전송합니다. 구성, 주소 및 진단 로그는 포함하지 않습니다." + +msgid "The selected logical interface is monitored for address changes. In multi-WAN setups, select the interface that receives public connections." +msgstr "선택한 논리 인터페이스의 주소 변경을 감시합니다. 다중 WAN 구성에서는 외부 연결을 수신하는 인터페이스를 선택하십시오." + +msgid "The seven latest automatic recovery reports are retained on the router." +msgstr "최근 7개의 자동 복구 보고서가 라우터에 보관됩니다." + +msgid "Trigger" +msgstr "트리거" + +msgid "View" +msgstr "보기" + +msgid "WAN address reconciled" +msgstr "WAN 주소 동기화됨" + +msgid "WAN address unavailable" +msgstr "WAN 주소를 사용할 수 없음" + +msgid "Webhook URL" +msgstr "웹훅 URL" +msgid "Generated configuration is invalid" +msgstr "생성된 구성이 유효하지 않음" + +msgid "Configuration installation failed" +msgstr "구성을 설치하지 못함" diff --git a/luci-app-haproxy-manager/po/ru/haproxy-manager.po b/luci-app-haproxy-manager/po/ru/haproxy-manager.po new file mode 100644 index 00000000..5a76527a --- /dev/null +++ b/luci-app-haproxy-manager/po/ru/haproxy-manager.po @@ -0,0 +1,499 @@ +msgid "" +msgstr "" +"Language: ru\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" + +msgid "Command failed with code %d." +msgstr "Команда завершилась с кодом %d." + +msgid "%d configuration files" +msgstr "%d файлов конфигурации" + +msgid "%d conflicts" +msgstr "%d конфликтов" + +msgid "%d services" +msgstr "%d сервисов" + +msgid "A restorable snapshot is created before migration and every apply." +msgstr "Перед миграцией и каждым применением создаётся точка восстановления." + +msgid "Active listeners" +msgstr "Активные слушатели" + +msgid "Add service" +msgstr "Добавить сервис" + +msgid "Address" +msgstr "Адрес" + +msgid "Applied" +msgstr "Применено" + +msgid "Apply" +msgstr "Применить" + +msgid "Apply changes" +msgstr "Применить изменения" + +msgid "Apply raw config" +msgstr "Применить конфиг" + +msgid "Apply raw configuration?" +msgstr "Применить конфигурацию вручную?" + +msgid "Automatic" +msgstr "Автоматически" + +msgid "Cancel" +msgstr "Отмена" + +msgid "Check configuration" +msgstr "Проверить конфигурацию" + +msgid "Config is valid" +msgstr "Конфиг корректен" + +msgid "Configuration and firewall are ready." +msgstr "Конфигурация и межсетевой экран готовы." + +msgid "Configuration is valid. Firewall conflicts: %d" +msgstr "Конфигурация корректна. Конфликтов межсетевого экрана: %d" + +msgid "Configure shared Web entry ports, router access, and firewall automation." +msgstr "Настройка общих Web-портов, доступа к роутеру и автоматизации межсетевого экрана." + +msgid "Conflict" +msgstr "Конфликт" + +msgid "Contents" +msgstr "Содержимое" + +msgid "Create recovery point" +msgstr "Создать точку восстановления" + +msgid "Created" +msgstr "Создана" + +msgid "Creates and updates only firewall rules owned by HAProxy Manager." +msgstr "Создаёт и обновляет только правила межсетевого экрана, принадлежащие HAProxy Manager." + +msgid "Custom TCP" +msgstr "Пользовательский TCP" + +msgid "Destination" +msgstr "Назначение" + +msgid "Destination host" +msgstr "Внутренний хост" + +msgid "Destination HTTP port" +msgstr "Внутренний HTTP-порт" + +msgid "Destination HTTPS port" +msgstr "Внутренний HTTPS-порт" + +msgid "Destination RDP port" +msgstr "Внутренний RDP-порт" + +msgid "Destination SSH port" +msgstr "Внутренний SSH-порт" + +msgid "Disable conflicting forwards during apply" +msgstr "Отключать конфликтующие пробросы при применении" + +msgid "Domain" +msgstr "Домен" + +msgid "Enable managed configuration" +msgstr "Включить управляемую конфигурацию" + +msgid "Enabled" +msgstr "Включено" + +msgid "Every Web service shares these public ports. SSH, Remote Desktop, and Custom TCP services use the ports configured on each service." +msgstr "Все Web-сервисы используют эти общие внешние порты. SSH, удалённый рабочий стол и пользовательские TCP-сервисы используют порты из своих настроек." + +msgid "Existing port-forward conflicts" +msgstr "Конфликты с существующими пробросами" + +msgid "Expert editor for /etc/haproxy.cfg." +msgstr "Экспертный редактор /etc/haproxy.cfg." + +msgid "Filter services" +msgstr "Фильтр сервисов" + +msgid "Firewall" +msgstr "Межсетевой экран" + +msgid "Firewall automation" +msgstr "Автоматизация межсетевого экрана" + +msgid "Firewall conflict detected." +msgstr "Обнаружен конфликт межсетевого экрана." + +msgid "Generated routes can overwrite manual changes to this file." +msgstr "Сгенерированные маршруты могут перезаписать ручные изменения этого файла." + +msgid "Grant access to LuCI HAProxy Manager" +msgstr "Предоставить доступ к LuCI HAProxy Manager" + +msgid "HAProxy" +msgstr "HAProxy" + +msgid "HAProxy service" +msgstr "Служба HAProxy" + +msgid "HAProxy Services" +msgstr "Сервисы HAProxy" + +msgid "HAProxy Settings" +msgstr "Настройки HAProxy" + +msgid "HAProxy Status" +msgstr "Статус HAProxy" + +msgid "HAProxy version" +msgstr "Версия HAProxy" + +msgid "HAProxy, firewall, and LuCI settings will be restored from %s." +msgstr "Настройки HAProxy, межсетевого экрана и LuCI будут восстановлены из точки %s." + +msgid "HTTP" +msgstr "HTTP" + +msgid "HTTP only" +msgstr "Только HTTP" + +msgid "HTTPS" +msgstr "HTTPS" + +msgid "HTTPS only" +msgstr "Только HTTPS" + +msgid "Keep LuCI on the LAN address" +msgstr "Оставить LuCI на LAN-адресе" + +msgid "Latest" +msgstr "Последняя" + +msgid "Legacy Web" +msgstr "Устаревший Web" + +msgid "LuCI LAN address" +msgstr "LAN-адрес LuCI" + +msgid "Managed" +msgstr "Управляется" + +msgid "Manual" +msgstr "Вручную" + +msgid "My service" +msgstr "Мой сервис" + +msgid "No HAProxy or LuCI listeners detected." +msgstr "Слушатели HAProxy или LuCI не обнаружены." + +msgid "No matching services" +msgstr "Подходящих сервисов нет" + +msgid "No recovery points yet." +msgstr "Точек восстановления пока нет." + +msgid "Not available" +msgstr "Недоступно" + +msgid "Not set" +msgstr "Не задано" + +msgid "Off" +msgstr "Выкл" + +msgid "On" +msgstr "Вкл" + +msgid "Open HAProxy ports on WAN automatically" +msgstr "Автоматически открывать порты HAProxy на WAN" + +msgid "Open recovery" +msgstr "Открыть восстановление" + +msgid "Pending" +msgstr "Ожидает применения" + +msgid "Process" +msgstr "Процесс" + +msgid "Protocol" +msgstr "Протокол" + +msgid "Public endpoint" +msgstr "Внешний адрес" + +msgid "Public HTTP port" +msgstr "Внешний HTTP-порт" + +msgid "Public HTTPS port" +msgstr "Внешний HTTPS-порт" + +msgid "Public RDP port" +msgstr "Внешний RDP-порт" + +msgid "Public SSH port" +msgstr "Внешний SSH-порт" + +msgid "Publish HTTP" +msgstr "Публиковать HTTP" + +msgid "Publish HTTPS" +msgstr "Публиковать HTTPS" + +msgid "Publish Web, SSH, Remote Desktop, and custom TCP services from one place." +msgstr "Публикация Web, SSH, удалённого рабочего стола и пользовательских TCP-сервисов в одном месте." + +msgid "Raw Config" +msgstr "Конфиг" + +msgid "Raw HAProxy Config" +msgstr "Конфиг HAProxy" + +msgid "Raw HAProxy configuration" +msgstr "Конфигурация HAProxy" + +msgid "Ready" +msgstr "Готово" + +msgid "Recommended when HAProxy uses public ports 80 or 443. LuCI remains available from the local network." +msgstr "Рекомендуется, когда HAProxy использует внешние порты 80 или 443. LuCI останется доступен из локальной сети." + +msgid "Recovery" +msgstr "Восстановление" + +msgid "Recovery points" +msgstr "Точки восстановления" + +msgid "Refresh" +msgstr "Обновить" + +msgid "Remote Desktop" +msgstr "Удалённый рабочий стол" + +msgid "Restore" +msgstr "Восстановить" + +msgid "Restore configuration?" +msgstr "Восстановить конфигурацию?" + +msgid "Restore HAProxy, firewall, and LuCI settings from a previous snapshot." +msgstr "Восстановление настроек HAProxy, межсетевого экрана и LuCI из предыдущего снимка." + +msgid "Restored" +msgstr "Восстановлено" + +msgid "Review firewall settings" +msgstr "Проверить настройки межсетевого экрана" + +msgid "Router access" +msgstr "Доступ к роутеру" + +msgid "Running" +msgstr "Работает" + +msgid "Save and apply" +msgstr "Сохранить и применить" + +msgid "Save settings" +msgstr "Сохранить настройки" + +msgid "Service" +msgstr "Сервис" + +msgid "Service health, active listeners, and recovery points." +msgstr "Состояние службы, активные слушатели и точки восстановления." + +msgid "Service name" +msgstr "Название сервиса" + +msgid "Service type" +msgstr "Тип сервиса" + +msgid "Services" +msgstr "Сервисы" + +msgid "Settings" +msgstr "Настройки" + +msgid "Settings saved" +msgstr "Настройки сохранены" + +msgid "SSH" +msgstr "SSH" + +msgid "Status" +msgstr "Статус" + +msgid "Stop and show conflicts" +msgstr "Остановиться и показать конфликты" + +msgid "Stopped" +msgstr "Остановлен" + +msgid "TCP" +msgstr "TCP" + +msgid "TCP port mappings" +msgstr "Соответствия TCP-портов" + +msgid "The file will be validated and backed up before HAProxy restarts." +msgstr "Перед перезапуском HAProxy файл будет проверен и сохранён в бэкап." + +msgid "Use the public:destination format, for example 8443:443." +msgstr "Используйте формат внешний:внутренний, например 8443:443." + +msgid "Validate" +msgstr "Проверить" + +msgid "WAN address" +msgstr "WAN-адрес" + +msgid "WAN bind address" +msgstr "Адрес привязки WAN" + +msgid "WAN firewall zone" +msgstr "WAN-зона межсетевого экрана" + +msgid "Web" +msgstr "Web" + +msgid "Web (HTTP + HTTPS)" +msgstr "Web (HTTP + HTTPS)" + +msgid "Web entry ports" +msgstr "Внешние Web-порты" +msgid "Changes saved and applied." +msgstr "Изменения сохранены и применены." + +msgid "Delete and apply" +msgstr "Удалить и применить" + +msgid "Delete service?" +msgstr "Удалить сервис?" + +msgid "Synchronize now" +msgstr "Синхронизировать" + +msgid "The service will be removed from HAProxy and the firewall immediately." +msgstr "Сервис будет немедленно удалён из HAProxy и межсетевого экрана." + +msgid "Unable to create a recovery point." +msgstr "Не удалось создать точку восстановления." + +msgid "Automatic recovery" +msgstr "Автоматическое восстановление" + +msgid "Disabled" +msgstr "Отключено" + +msgid "Failed" +msgstr "Не удалось" + +msgid "If HAProxy stops before a listener address exists, retry it after a network interface comes up and retain diagnostics for the seven latest incidents." +msgstr "Если HAProxy остановился до появления адреса слушателя, повторить запуск после поднятия сетевого интерфейса и сохранить диагностику семи последних инцидентов." + +msgid "In progress" +msgstr "Выполняется" + +msgid "Invalid configuration" +msgstr "Некорректная конфигурация" + +msgid "Last recovery" +msgstr "Последнее восстановление" + +msgid "No incidents recorded" +msgstr "Срабатываний не зафиксировано" + +msgid "Recover HAProxy after networking becomes ready" +msgstr "Автоматически запускать HAProxy после готовности сети" + +msgid "Recovered" +msgstr "Восстановлено" + +msgid "Recovery point failed" +msgstr "Не удалось создать точку восстановления" + +msgid "WAN network interface" +msgstr "Сетевой интерфейс WAN" + +msgid "Close" +msgstr "Закрыть" + +msgid "Configuration mode" +msgstr "Режим конфигурации" + +msgid "Diagnostic report for %s." +msgstr "Диагностический отчёт для %s." + +msgid "Enter an HTTP or HTTPS URL without spaces." +msgstr "Введите HTTP- или HTTPS-адрес без пробелов." + +msgid "Failed, configuration restored" +msgstr "Ошибка, конфигурация восстановлена" + +msgid "Failed, restore also failed" +msgstr "Ошибка, восстановление также не удалось" + +msgid "Generated routes" +msgstr "Сгенерированные маршруты" + +msgid "Incident diagnostics" +msgstr "Диагностика инцидента" + +msgid "Not managed" +msgstr "Не управляется" + +msgid "Raw configuration" +msgstr "Пользовательская конфигурация" + +msgid "Reconciles generated listeners after WAN address changes, retries a stopped service, and retains diagnostics for the seven latest incidents. Raw configurations are never overwritten." +msgstr "Синхронизирует сгенерированные слушатели после смены WAN-адреса, повторно запускает остановленную службу и хранит диагностику семи последних инцидентов. Пользовательские конфигурации никогда не перезаписываются." + +msgid "Recovery incidents" +msgstr "Инциденты восстановления" + +msgid "Result" +msgstr "Результат" + +msgid "Send recovery webhook notifications" +msgstr "Отправлять webhook-уведомления о восстановлении" + +msgid "Sends only the incident identifier, result, action, interface, and reason. Configuration, addresses, and diagnostic logs are never included." +msgstr "Отправляются только идентификатор инцидента, результат, действие, интерфейс и причина. Конфигурация, адреса и диагностические журналы никогда не передаются." + +msgid "The selected logical interface is monitored for address changes. In multi-WAN setups, select the interface that receives public connections." +msgstr "Выбранный логический интерфейс отслеживается на предмет смены адреса. В конфигурациях с несколькими WAN выберите интерфейс, принимающий публичные подключения." + +msgid "The seven latest automatic recovery reports are retained on the router." +msgstr "На маршрутизаторе хранятся семь последних отчётов автоматического восстановления." + +msgid "Trigger" +msgstr "Событие" + +msgid "View" +msgstr "Просмотреть" + +msgid "WAN address reconciled" +msgstr "WAN-адрес синхронизирован" + +msgid "WAN address unavailable" +msgstr "WAN-адрес недоступен" + +msgid "Webhook URL" +msgstr "URL webhook" +msgid "Generated configuration is invalid" +msgstr "Сгенерированная конфигурация недействительна" + +msgid "Configuration installation failed" +msgstr "Не удалось установить конфигурацию" diff --git a/luci-app-haproxy-manager/po/zh-cn b/luci-app-haproxy-manager/po/zh-cn new file mode 120000 index 00000000..8d69574d --- /dev/null +++ b/luci-app-haproxy-manager/po/zh-cn @@ -0,0 +1 @@ +zh_Hans \ No newline at end of file diff --git a/luci-app-haproxy-manager/po/zh_Hans/haproxy-manager.po b/luci-app-haproxy-manager/po/zh_Hans/haproxy-manager.po new file mode 100644 index 00000000..44167317 --- /dev/null +++ b/luci-app-haproxy-manager/po/zh_Hans/haproxy-manager.po @@ -0,0 +1,499 @@ +msgid "" +msgstr "" +"Language: zh_Hans\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" + +msgid "Command failed with code %d." +msgstr "命令以代码 %d 退出。" + +msgid "%d configuration files" +msgstr "%d 个配置文件" + +msgid "%d conflicts" +msgstr "%d 个冲突" + +msgid "%d services" +msgstr "%d 个服务" + +msgid "A restorable snapshot is created before migration and every apply." +msgstr "迁移前和每次应用更改时都会创建可恢复的快照。" + +msgid "Active listeners" +msgstr "活动监听器" + +msgid "Add service" +msgstr "添加服务" + +msgid "Address" +msgstr "地址" + +msgid "Applied" +msgstr "已应用" + +msgid "Apply" +msgstr "应用" + +msgid "Apply changes" +msgstr "应用更改" + +msgid "Apply raw config" +msgstr "应用配置" + +msgid "Apply raw configuration?" +msgstr "应用手动配置?" + +msgid "Automatic" +msgstr "自动" + +msgid "Cancel" +msgstr "取消" + +msgid "Check configuration" +msgstr "检查配置" + +msgid "Config is valid" +msgstr "配置有效" + +msgid "Configuration and firewall are ready." +msgstr "配置和防火墙已就绪。" + +msgid "Configuration is valid. Firewall conflicts: %d" +msgstr "配置有效。防火墙冲突:%d" + +msgid "Configure shared Web entry ports, router access, and firewall automation." +msgstr "配置共享 Web 入口端口、路由器访问和防火墙自动化。" + +msgid "Conflict" +msgstr "冲突" + +msgid "Contents" +msgstr "内容" + +msgid "Create recovery point" +msgstr "创建恢复点" + +msgid "Created" +msgstr "创建时间" + +msgid "Creates and updates only firewall rules owned by HAProxy Manager." +msgstr "仅创建和更新由 HAProxy Manager 管理的防火墙规则。" + +msgid "Custom TCP" +msgstr "自定义 TCP" + +msgid "Destination" +msgstr "目标" + +msgid "Destination host" +msgstr "目标主机" + +msgid "Destination HTTP port" +msgstr "目标 HTTP 端口" + +msgid "Destination HTTPS port" +msgstr "目标 HTTPS 端口" + +msgid "Destination RDP port" +msgstr "目标 RDP 端口" + +msgid "Destination SSH port" +msgstr "目标 SSH 端口" + +msgid "Disable conflicting forwards during apply" +msgstr "应用时禁用冲突的端口转发" + +msgid "Domain" +msgstr "域名" + +msgid "Enable managed configuration" +msgstr "启用托管配置" + +msgid "Enabled" +msgstr "已启用" + +msgid "Every Web service shares these public ports. SSH, Remote Desktop, and Custom TCP services use the ports configured on each service." +msgstr "所有 Web 服务共享这些公网端口。SSH、远程桌面和自定义 TCP 服务使用各自配置的端口。" + +msgid "Existing port-forward conflicts" +msgstr "现有端口转发冲突" + +msgid "Expert editor for /etc/haproxy.cfg." +msgstr "/etc/haproxy.cfg 高级编辑器。" + +msgid "Filter services" +msgstr "筛选服务" + +msgid "Firewall" +msgstr "防火墙" + +msgid "Firewall automation" +msgstr "防火墙自动化" + +msgid "Firewall conflict detected." +msgstr "检测到防火墙冲突。" + +msgid "Generated routes can overwrite manual changes to this file." +msgstr "生成路由可能会覆盖对此文件的手动修改。" + +msgid "Grant access to LuCI HAProxy Manager" +msgstr "授予访问 LuCI HAProxy Manager 的权限" + +msgid "HAProxy" +msgstr "HAProxy" + +msgid "HAProxy service" +msgstr "HAProxy 服务" + +msgid "HAProxy Services" +msgstr "HAProxy 服务" + +msgid "HAProxy Settings" +msgstr "HAProxy 设置" + +msgid "HAProxy Status" +msgstr "HAProxy 状态" + +msgid "HAProxy version" +msgstr "HAProxy 版本" + +msgid "HAProxy, firewall, and LuCI settings will be restored from %s." +msgstr "将从 %s 恢复 HAProxy、防火墙和 LuCI 设置。" + +msgid "HTTP" +msgstr "HTTP" + +msgid "HTTP only" +msgstr "仅 HTTP" + +msgid "HTTPS" +msgstr "HTTPS" + +msgid "HTTPS only" +msgstr "仅 HTTPS" + +msgid "Keep LuCI on the LAN address" +msgstr "让 LuCI 仅使用 LAN 地址" + +msgid "Latest" +msgstr "最新" + +msgid "Legacy Web" +msgstr "旧版 Web" + +msgid "LuCI LAN address" +msgstr "LuCI LAN 地址" + +msgid "Managed" +msgstr "已管理" + +msgid "Manual" +msgstr "手动" + +msgid "My service" +msgstr "我的服务" + +msgid "No HAProxy or LuCI listeners detected." +msgstr "未检测到 HAProxy 或 LuCI 监听器。" + +msgid "No matching services" +msgstr "没有匹配的服务" + +msgid "No recovery points yet." +msgstr "还没有恢复点。" + +msgid "Not available" +msgstr "不可用" + +msgid "Not set" +msgstr "未设置" + +msgid "Off" +msgstr "关闭" + +msgid "On" +msgstr "开启" + +msgid "Open HAProxy ports on WAN automatically" +msgstr "自动在 WAN 上开放 HAProxy 端口" + +msgid "Open recovery" +msgstr "打开恢复" + +msgid "Pending" +msgstr "等待应用" + +msgid "Process" +msgstr "进程" + +msgid "Protocol" +msgstr "协议" + +msgid "Public endpoint" +msgstr "公共入口" + +msgid "Public HTTP port" +msgstr "公网 HTTP 端口" + +msgid "Public HTTPS port" +msgstr "公网 HTTPS 端口" + +msgid "Public RDP port" +msgstr "公网 RDP 端口" + +msgid "Public SSH port" +msgstr "公网 SSH 端口" + +msgid "Publish HTTP" +msgstr "发布 HTTP" + +msgid "Publish HTTPS" +msgstr "发布 HTTPS" + +msgid "Publish Web, SSH, Remote Desktop, and custom TCP services from one place." +msgstr "在一个界面中发布 Web、SSH、远程桌面和自定义 TCP 服务。" + +msgid "Raw Config" +msgstr "原始配置" + +msgid "Raw HAProxy Config" +msgstr "HAProxy 原始配置" + +msgid "Raw HAProxy configuration" +msgstr "HAProxy 原始配置" + +msgid "Ready" +msgstr "就绪" + +msgid "Recommended when HAProxy uses public ports 80 or 443. LuCI remains available from the local network." +msgstr "当 HAProxy 使用公网端口 80 或 443 时建议启用。LuCI 仍可从本地网络访问。" + +msgid "Recovery" +msgstr "恢复" + +msgid "Recovery points" +msgstr "恢复点" + +msgid "Refresh" +msgstr "刷新" + +msgid "Remote Desktop" +msgstr "远程桌面" + +msgid "Restore" +msgstr "恢复" + +msgid "Restore configuration?" +msgstr "恢复配置?" + +msgid "Restore HAProxy, firewall, and LuCI settings from a previous snapshot." +msgstr "从以前的快照恢复 HAProxy、防火墙和 LuCI 设置。" + +msgid "Restored" +msgstr "已恢复" + +msgid "Review firewall settings" +msgstr "检查防火墙设置" + +msgid "Router access" +msgstr "路由器访问" + +msgid "Running" +msgstr "运行中" + +msgid "Save and apply" +msgstr "保存并应用" + +msgid "Save settings" +msgstr "保存设置" + +msgid "Service" +msgstr "服务" + +msgid "Service health, active listeners, and recovery points." +msgstr "服务状态、活动监听器和恢复点。" + +msgid "Service name" +msgstr "服务名称" + +msgid "Service type" +msgstr "服务类型" + +msgid "Services" +msgstr "服务" + +msgid "Settings" +msgstr "设置" + +msgid "Settings saved" +msgstr "设置已保存" + +msgid "SSH" +msgstr "SSH" + +msgid "Status" +msgstr "状态" + +msgid "Stop and show conflicts" +msgstr "停止并显示冲突" + +msgid "Stopped" +msgstr "已停止" + +msgid "TCP" +msgstr "TCP" + +msgid "TCP port mappings" +msgstr "TCP 端口映射" + +msgid "The file will be validated and backed up before HAProxy restarts." +msgstr "HAProxy 重启前将验证并备份该文件。" + +msgid "Use the public:destination format, for example 8443:443." +msgstr "请使用 公网:目标 格式,例如 8443:443。" + +msgid "Validate" +msgstr "验证" + +msgid "WAN address" +msgstr "WAN 地址" + +msgid "WAN bind address" +msgstr "WAN 绑定地址" + +msgid "WAN firewall zone" +msgstr "WAN 防火墙区域" + +msgid "Web" +msgstr "Web" + +msgid "Web (HTTP + HTTPS)" +msgstr "Web(HTTP + HTTPS)" + +msgid "Web entry ports" +msgstr "Web 入口端口" +msgid "Changes saved and applied." +msgstr "更改已保存并应用。" + +msgid "Delete and apply" +msgstr "删除并应用" + +msgid "Delete service?" +msgstr "删除服务?" + +msgid "Synchronize now" +msgstr "立即同步" + +msgid "The service will be removed from HAProxy and the firewall immediately." +msgstr "该服务将立即从 HAProxy 和防火墙中移除。" + +msgid "Unable to create a recovery point." +msgstr "无法创建恢复点。" + +msgid "Automatic recovery" +msgstr "自动恢复" + +msgid "Disabled" +msgstr "已禁用" + +msgid "Failed" +msgstr "失败" + +msgid "If HAProxy stops before a listener address exists, retry it after a network interface comes up and retain diagnostics for the seven latest incidents." +msgstr "如果 HAProxy 在监听地址可用前停止,则在网络接口启动后重试,并保留最近七次事件的诊断信息。" + +msgid "In progress" +msgstr "进行中" + +msgid "Invalid configuration" +msgstr "配置无效" + +msgid "Last recovery" +msgstr "最近一次恢复" + +msgid "No incidents recorded" +msgstr "尚无事件记录" + +msgid "Recover HAProxy after networking becomes ready" +msgstr "网络就绪后恢复 HAProxy" + +msgid "Recovered" +msgstr "已恢复" + +msgid "Recovery point failed" +msgstr "恢复点创建失败" + +msgid "WAN network interface" +msgstr "WAN 网络接口" + +msgid "Close" +msgstr "关闭" + +msgid "Configuration mode" +msgstr "配置模式" + +msgid "Diagnostic report for %s." +msgstr "%s 的诊断报告。" + +msgid "Enter an HTTP or HTTPS URL without spaces." +msgstr "请输入不含空格的 HTTP 或 HTTPS URL。" + +msgid "Failed, configuration restored" +msgstr "失败,已恢复配置" + +msgid "Failed, restore also failed" +msgstr "失败,恢复操作也失败" + +msgid "Generated routes" +msgstr "生成的路由" + +msgid "Incident diagnostics" +msgstr "事件诊断" + +msgid "Not managed" +msgstr "未管理" + +msgid "Raw configuration" +msgstr "手动配置" + +msgid "Reconciles generated listeners after WAN address changes, retries a stopped service, and retains diagnostics for the seven latest incidents. Raw configurations are never overwritten." +msgstr "WAN 地址变化后同步生成的监听器,重试已停止的服务,并保留最近七次事件的诊断信息。手动配置绝不会被覆盖。" + +msgid "Recovery incidents" +msgstr "恢复事件" + +msgid "Result" +msgstr "结果" + +msgid "Send recovery webhook notifications" +msgstr "发送恢复 Webhook 通知" + +msgid "Sends only the incident identifier, result, action, interface, and reason. Configuration, addresses, and diagnostic logs are never included." +msgstr "仅发送事件标识符、结果、操作、接口和原因。绝不会包含配置、地址和诊断日志。" + +msgid "The selected logical interface is monitored for address changes. In multi-WAN setups, select the interface that receives public connections." +msgstr "监控所选逻辑接口的地址变化。在多 WAN 配置中,请选择接收公网连接的接口。" + +msgid "The seven latest automatic recovery reports are retained on the router." +msgstr "路由器会保留最近七份自动恢复报告。" + +msgid "Trigger" +msgstr "触发条件" + +msgid "View" +msgstr "查看" + +msgid "WAN address reconciled" +msgstr "WAN 地址已同步" + +msgid "WAN address unavailable" +msgstr "WAN 地址不可用" + +msgid "Webhook URL" +msgstr "Webhook URL" +msgid "Generated configuration is invalid" +msgstr "生成的配置无效" + +msgid "Configuration installation failed" +msgstr "配置安装失败" diff --git a/luci-app-haproxy-manager/root/etc/config/haproxy_manager b/luci-app-haproxy-manager/root/etc/config/haproxy_manager new file mode 100644 index 00000000..63a520b7 --- /dev/null +++ b/luci-app-haproxy-manager/root/etc/config/haproxy_manager @@ -0,0 +1,17 @@ +config settings 'main' + option schema_version '3' + option enabled '0' + option active_mode 'none' + option auto_recover '1' + option manage_uhttpd_bind '0' + option manage_firewall '0' + option firewall_zone 'wan' + option firewall_conflict_mode 'warn' + option lan_bind_ip 'auto' + option wan_interface 'wan' + option wan_bind_ip 'auto' + option http_port '80' + option https_port '443' + option backup_dir '/root/haproxy-manager-backups' + option webhook_enabled '0' + option webhook_url '' diff --git a/luci-app-haproxy-manager/root/etc/hotplug.d/iface/95-haproxy-manager b/luci-app-haproxy-manager/root/etc/hotplug.d/iface/95-haproxy-manager new file mode 100755 index 00000000..1823cbbe --- /dev/null +++ b/luci-app-haproxy-manager/root/etc/hotplug.d/iface/95-haproxy-manager @@ -0,0 +1,11 @@ +#!/bin/sh + +case "${ACTION:-}" in + ifup|ifupdate) ;; + *) exit 0 ;; +esac + +[ "${INTERFACE:-}" = "$(uci -q get haproxy_manager.main.wan_interface 2>/dev/null || echo wan)" ] || exit 0 +[ -x /usr/libexec/haproxy-manager/recover ] || exit 0 +/usr/libexec/haproxy-manager/recover "$ACTION" "${INTERFACE:-unknown}" >/dev/null 2>&1 & +exit 0 diff --git a/luci-app-haproxy-manager/root/usr/libexec/haproxy-manager/apply b/luci-app-haproxy-manager/root/usr/libexec/haproxy-manager/apply new file mode 100755 index 00000000..341b923e --- /dev/null +++ b/luci-app-haproxy-manager/root/usr/libexec/haproxy-manager/apply @@ -0,0 +1,180 @@ +#!/bin/sh +set -eu +. /usr/libexec/haproxy-manager/common.sh +umask 077 + +LOCK_DIR=/var/lock/haproxy-manager.apply +LOCK_BUSY_EXIT=75 +TMP_CFG="/tmp/haproxy-manager-apply.$$" +BACKUP_ID="" +APPLIED=0 +UHTTPD_CHANGED=0 + +if [ "${1:-}" = --backup ]; then + BACKUP_ID="${2:-}" + [ "$#" -eq 2 ] || { echo "Usage: apply [--backup YYYYMMDD-HHMMSS]" >&2; exit 1; } + is_valid_backup_id "$BACKUP_ID" || { echo "Invalid backup identifier" >&2; exit 1; } +elif [ "$#" -ne 0 ]; then + echo "Usage: apply [--backup YYYYMMDD-HHMMSS]" >&2 + exit 1 +fi + +operation_lock_acquire "$LOCK_DIR" || { + echo "Another HAProxy Manager operation is already running." >&2 + exit "$LOCK_BUSY_EXIT" +} + +cleanup_temp() { + rm -f "$TMP_CFG" +} + +release_lock() { + operation_lock_release "$LOCK_DIR" +} + +cleanup() { + cleanup_temp + release_lock +} + +restore_on_error() { + status=$? + trap - EXIT + if [ "$APPLIED" != 1 ] && [ -n "$BACKUP_ID" ]; then + if [ "$UHTTPD_CHANGED" = 1 ]; then + cleanup_temp + ( + sleep 2 + if /usr/libexec/haproxy-manager/rollback --sync "$BACKUP_ID" >/dev/null 2>&1; then + logger -t haproxy-manager "Apply failed; restored $BACKUP_ID" + else + logger -t haproxy-manager "Apply failed; could not restore $BACKUP_ID" + fi + release_lock + ) >/dev/null 2>&1 & + operation_lock_set_pid "$LOCK_DIR" "$!" + exit "$status" + fi + + if /usr/libexec/haproxy-manager/rollback --sync "$BACKUP_ID" >/dev/null 2>&1; then + logger -t haproxy-manager "Apply failed; restored $BACKUP_ID" + else + logger -t haproxy-manager "Apply failed; could not restore $BACKUP_ID" + fi + fi + cleanup + exit "$status" +} +trap restore_on_error EXIT + +if [ -n "$BACKUP_ID" ]; then + [ -d "$(backup_dir)/$BACKUP_ID" ] || { echo "Backup not found: $BACKUP_ID" >&2; exit 1; } +else + BACKUP_ID="$(basename "$(/usr/libexec/haproxy-manager/backup)")" +fi + +/usr/libexec/haproxy-manager/migrate >/dev/null + +defer_disabled_reload() { + APPLIED=1 + trap - EXIT + cleanup_temp + ( + sleep 2 + if /etc/init.d/uhttpd reload >/dev/null 2>&1; then + logger -t haproxy-manager "LuCI listener bindings restored" + else + if /usr/libexec/haproxy-manager/rollback --sync "$BACKUP_ID" >/dev/null 2>&1; then + logger -t haproxy-manager "LuCI reload failed; restored $BACKUP_ID" + else + logger -t haproxy-manager "LuCI reload failed; could not restore $BACKUP_ID" + fi + fi + release_lock + ) >/dev/null 2>&1 & + operation_lock_set_pid "$LOCK_DIR" "$!" +} + +if [ "$(uci_get main enabled 0)" != 1 ]; then + MODE="$(active_mode)" + restore_uhttpd_bindings + [ "$UHTTPD_CHANGED" != 1 ] || uci commit uhttpd + /usr/libexec/haproxy-manager/firewall-sync --disable >/dev/null + case "$MODE" in + generated|raw) + /etc/init.d/haproxy stop >/dev/null 2>&1 || true + /etc/init.d/haproxy disable >/dev/null 2>&1 || true + uci set "$CONFIG.main.active_mode=none" + uci commit "$CONFIG" + ;; + esac + + if [ "$UHTTPD_CHANGED" = 1 ]; then + defer_disabled_reload + echo "Managed HAProxy service disabled. LuCI bindings will be restored shortly. Backup: $BACKUP_ID" + exit 0 + fi + + APPLIED=1 + trap - EXIT + cleanup + echo "Managed HAProxy service disabled. Backup: $BACKUP_ID" + exit 0 +fi + +/usr/libexec/haproxy-manager/generate "$TMP_CFG" >/dev/null +/usr/libexec/haproxy-manager/validate "$TMP_CFG" + +if [ "$(uci_get main manage_firewall 0)" = 1 ]; then + /usr/libexec/haproxy-manager/firewall-sync --check >/dev/null +fi + +MANAGE_UHTTPD="$(uci_get main manage_uhttpd_bind 0)" +LAN_BIND_IP="$(lan_ip || true)" + +if [ "$MANAGE_UHTTPD" = 1 ]; then + [ -n "$LAN_BIND_IP" ] || { echo "Cannot detect LAN IPv4 address. Set the LuCI LAN address manually." >&2; exit 1; } + is_valid_ip_or_host "$LAN_BIND_IP" || { echo "Invalid LuCI LAN address: $LAN_BIND_IP" >&2; exit 1; } + capture_uhttpd_bindings + set_uhttpd_list listen_http "$LAN_BIND_IP:80" + set_uhttpd_list listen_https "$LAN_BIND_IP:443" +else + restore_uhttpd_bindings +fi + +[ "$UHTTPD_CHANGED" != 1 ] || uci commit uhttpd + +/usr/libexec/haproxy-manager/firewall-sync >/dev/null +atomic_install_file "$TMP_CFG" "$HAPROXY_CFG" +uci set "$CONFIG.main.active_mode=generated" +uci commit "$CONFIG" +/etc/init.d/haproxy enable >/dev/null 2>&1 || true + +if [ "$UHTTPD_CHANGED" = 1 ]; then + APPLIED=1 + trap - EXIT + cleanup_temp + ( + sleep 2 + if /etc/init.d/uhttpd reload >/dev/null 2>&1 && /etc/init.d/haproxy restart >/dev/null 2>&1; then + logger -t haproxy-manager "LuCI and HAProxy reloaded successfully" + else + if /usr/libexec/haproxy-manager/rollback --sync "$BACKUP_ID" >/dev/null 2>&1; then + logger -t haproxy-manager "Service reload failed; restored $BACKUP_ID" + else + logger -t haproxy-manager "Service reload failed; could not restore $BACKUP_ID" + fi + fi + release_lock + ) >/dev/null 2>&1 & + operation_lock_set_pid "$LOCK_DIR" "$!" + echo "Applied successfully. LuCI and HAProxy will reload shortly. Backup: $BACKUP_ID" + exit 0 +fi + +/etc/init.d/haproxy restart + +APPLIED=1 +trap - EXIT +cleanup +echo "Applied successfully. Backup: $BACKUP_ID" diff --git a/luci-app-haproxy-manager/root/usr/libexec/haproxy-manager/apply-raw-file b/luci-app-haproxy-manager/root/usr/libexec/haproxy-manager/apply-raw-file new file mode 100755 index 00000000..3a55da2e --- /dev/null +++ b/luci-app-haproxy-manager/root/usr/libexec/haproxy-manager/apply-raw-file @@ -0,0 +1,45 @@ +#!/bin/sh +set -eu +. /usr/libexec/haproxy-manager/common.sh +umask 077 + +SRC="${1:-}" +is_safe_temp_path "$SRC" || { echo "Refusing unsafe raw config path: $SRC" >&2; exit 1; } + +[ -f "$SRC" ] || { echo "Raw config file not found: $SRC" >&2; exit 1; } + +LOCK_DIR=/var/lock/haproxy-manager.apply +operation_lock_acquire "$LOCK_DIR" || { echo "Another HAProxy Manager operation is already running." >&2; exit 1; } +TMP_RAW="/tmp/haproxy-manager-raw-apply.$$" +BACKUP_ID="" +APPLIED=0 + +restore_on_error() { + status=$? + trap - EXIT + if [ "$APPLIED" != 1 ] && [ -n "$BACKUP_ID" ]; then + /usr/libexec/haproxy-manager/rollback --sync "$BACKUP_ID" >/dev/null 2>&1 || true + logger -t haproxy-manager "Raw apply failed; restored $BACKUP_ID" + fi + rm -f "$TMP_RAW" "$SRC" + operation_lock_release "$LOCK_DIR" + exit "$status" +} +trap restore_on_error EXIT + +cp "$SRC" "$TMP_RAW" +rm -f "$SRC" +/usr/libexec/haproxy-manager/validate "$TMP_RAW" +BACKUP_ID="$(basename "$(/usr/libexec/haproxy-manager/backup)")" + +atomic_install_file "$TMP_RAW" "$HAPROXY_CFG" +uci set "$CONFIG.main.active_mode=raw" +uci commit "$CONFIG" +/etc/init.d/haproxy enable >/dev/null 2>&1 || true +/etc/init.d/haproxy restart + +APPLIED=1 +trap - EXIT +rm -f "$TMP_RAW" "$SRC" +operation_lock_release "$LOCK_DIR" +echo "Raw HAProxy config applied. Backup: $BACKUP_ID" diff --git a/luci-app-haproxy-manager/root/usr/libexec/haproxy-manager/backup b/luci-app-haproxy-manager/root/usr/libexec/haproxy-manager/backup new file mode 100755 index 00000000..79c17296 --- /dev/null +++ b/luci-app-haproxy-manager/root/usr/libexec/haproxy-manager/backup @@ -0,0 +1,59 @@ +#!/bin/sh +set -eu +. /usr/libexec/haproxy-manager/common.sh + +REQUESTED_BASE="$(backup_dir)" +STAMP="$(date +%Y%m%d-%H%M%S)" + +umask 077 +mkdir -p "$REQUESTED_BASE" +BASE="$(resolve_storage_dir "$REQUESTED_BASE")" || { + echo "Recovery point directory resolves outside /root or /mnt" >&2 + exit 1 +} +chmod 700 "$BASE" 2>/dev/null || true +DIR="$BASE/$STAMP" +attempt=1 +while ! mkdir "$DIR" 2>/dev/null; do + [ "$attempt" -lt 5 ] || { + echo "Cannot create recovery point in $BASE after $attempt attempts" >&2 + exit 1 + } + sleep 1 + attempt=$((attempt + 1)) + STAMP="$(date +%Y%m%d-%H%M%S)" + DIR="$BASE/$STAMP" +done +chmod 700 "$DIR" 2>/dev/null || true +[ -f /etc/config/firewall ] && cp /etc/config/firewall "$DIR/firewall" +[ -f /etc/config/uhttpd ] && cp /etc/config/uhttpd "$DIR/uhttpd" +[ -f /etc/config/haproxy_manager ] && cp /etc/config/haproxy_manager "$DIR/haproxy_manager" +[ -f "$HAPROXY_CFG" ] && cp "$HAPROXY_CFG" "$DIR/haproxy.cfg" +{ + if /etc/init.d/haproxy enabled >/dev/null 2>&1; then + printf 'enabled=1\n' + else + printf 'enabled=0\n' + fi + if /etc/init.d/haproxy status >/dev/null 2>&1; then + printf 'running=1\n' + else + printf 'running=0\n' + fi +} > "$DIR/haproxy.state" + +printf '%s\n' "$STAMP" > "$BASE/LAST" + +count=0 +for old in $(find "$BASE" -maxdepth 1 -type d -name '[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]' 2>/dev/null | sort -r); do + count=$((count + 1)) + [ "$count" -le "$BACKUP_LIMIT" ] && continue + case "$old" in + "$BASE"/[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]) + rm -f "$old"/* + rmdir "$old" + ;; + esac +done + +printf '%s\n' "$DIR" diff --git a/luci-app-haproxy-manager/root/usr/libexec/haproxy-manager/backups b/luci-app-haproxy-manager/root/usr/libexec/haproxy-manager/backups new file mode 100755 index 00000000..7c5560b3 --- /dev/null +++ b/luci-app-haproxy-manager/root/usr/libexec/haproxy-manager/backups @@ -0,0 +1,22 @@ +#!/bin/sh +. /usr/libexec/haproxy-manager/common.sh + +BASE="$(backup_dir)" +[ ! -d "$BASE" ] || BASE="$(resolve_storage_dir "$BASE")" || exit 1 +LAST="" +[ ! -r "$BASE/LAST" ] || LAST="$(basename "$(cat "$BASE/LAST")")" + +count=0 +for dir in $(find "$BASE" -maxdepth 1 -type d -name '[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]' 2>/dev/null | sort -r); do + [ -d "$dir" ] || continue + id="$(basename "$dir")" + current=0 + [ "$id" != "$LAST" ] || current=1 + files=0 + for file in firewall uhttpd haproxy_manager haproxy.cfg; do + [ ! -f "$dir/$file" ] || files=$((files + 1)) + done + printf 'backup\t%s\t%s\t%s\n' "$id" "$current" "$files" + count=$((count + 1)) + [ "$count" -lt "$BACKUP_LIMIT" ] || break +done diff --git a/luci-app-haproxy-manager/root/usr/libexec/haproxy-manager/common.sh b/luci-app-haproxy-manager/root/usr/libexec/haproxy-manager/common.sh new file mode 100755 index 00000000..328975c9 --- /dev/null +++ b/luci-app-haproxy-manager/root/usr/libexec/haproxy-manager/common.sh @@ -0,0 +1,361 @@ +#!/bin/sh + +CONFIG=haproxy_manager +HAPROXY_CFG=/etc/haproxy.cfg +TMP_CFG=/tmp/haproxy-manager-generated.cfg +BACKUP_LIMIT=7 +INCIDENT_LIMIT=7 +FIREWALL_RULE_NAME='HAProxy Manager: WAN listeners' +FIREWALL_DISABLED_PREFIX='HAProxy Manager disabled: ' + +uci_get() { + local section="$1" + local option="$2" + local default="${3:-}" + uci -q get "$CONFIG.$section.$option" 2>/dev/null || printf '%s\n' "$default" +} + +backup_dir() { + local dir + dir="$(uci_get main backup_dir /root/haproxy-manager-backups)" + is_safe_storage_dir "$dir" || { + echo "Recovery point directory must be a plain path under /root or /mnt: $dir" >&2 + return 1 + } + printf '%s\n' "$dir" +} + +incident_dir() { + local dir + dir="$(uci_get main incident_dir /root/haproxy-manager-incidents)" + is_safe_storage_dir "$dir" || { + echo "Incident directory must be a plain path under /root or /mnt: $dir" >&2 + return 1 + } + printf '%s\n' "$dir" +} + +is_safe_storage_dir() { + case "$1" in + /root/*|/mnt/*) ;; + *) return 1 ;; + esac + case "/${1#/}/" in + */../*|*/./*|*[!A-Za-z0-9_./-]*) return 1 ;; + esac + return 0 +} + +resolve_storage_dir() { + local dir="$1" resolved + [ -d "$dir" ] || return 1 + resolved="$(cd "$dir" 2>/dev/null && pwd -P)" || return 1 + is_safe_storage_dir "$resolved" || return 1 + printf '%s\n' "$resolved" +} + +is_safe_temp_path() { + local path="$1" name + name="${path#/tmp/}" + [ "$path" = "/tmp/$name" ] || return 1 + case "$name" in + haproxy-manager-[A-Za-z0-9._-]*) return 0 ;; + *) return 1 ;; + esac +} + +operation_lock_acquire() { + local lock_dir="$1" lock_pid + if mkdir "$lock_dir" 2>/dev/null; then + printf '%s\n' "$$" > "$lock_dir/pid" + return 0 + fi + + lock_pid="$(cat "$lock_dir/pid" 2>/dev/null || true)" + case "$lock_pid" in + ''|*[!0-9]*) lock_pid=0 ;; + esac + if [ "$lock_pid" -gt 1 ] 2>/dev/null && kill -0 "$lock_pid" 2>/dev/null; then + return 1 + fi + rm -f "$lock_dir/pid" + rmdir "$lock_dir" 2>/dev/null || return 1 + mkdir "$lock_dir" 2>/dev/null || return 1 + printf '%s\n' "$$" > "$lock_dir/pid" +} + +operation_lock_set_pid() { + printf '%s\n' "$2" > "$1/pid" +} + +operation_lock_release() { + rm -f "$1/pid" + rmdir "$1" 2>/dev/null || true +} + +atomic_install_file() { + local source="$1" destination="$2" temporary mode + [ -f "$source" ] || return 1 + temporary="$destination.haproxy-manager.$$" + rm -f "$temporary" + if ! cp "$source" "$temporary"; then + rm -f "$temporary" + return 1 + fi + mode="$(stat -c '%a' "$destination" 2>/dev/null || stat -c '%a' "$source" 2>/dev/null || echo 600)" + chmod "$mode" "$temporary" || { rm -f "$temporary"; return 1; } + mv -f "$temporary" "$destination" || { rm -f "$temporary"; return 1; } +} + +active_mode() { + local mode + mode="$(uci_get main active_mode)" + case "$mode" in + generated|raw|none) printf '%s\n' "$mode" ;; + *) + if [ "$(uci_get main enabled 0)" = 1 ] && + grep -q '^# Generated by luci-app-haproxy-manager\.' "$HAPROXY_CFG" 2>/dev/null; then + printf 'generated\n' + else + printf 'none\n' + fi + ;; + esac +} + +wan_interface() { + local interface + interface="$(uci_get main wan_interface wan)" + case "$interface" in + ''|*[!A-Za-z0-9_.-]*) return 1 ;; + esac + printf '%s\n' "$interface" +} + +wan_ip() { + local configured interface + configured="$(uci_get main wan_bind_ip auto)" + if [ "$configured" != auto ] && [ -n "$configured" ]; then + printf '%s\n' "$configured" + return 0 + fi + + interface="$(wan_interface)" || return 1 + ubus call "network.interface.$interface" status 2>/dev/null \ + | jsonfilter -e '@["ipv4-address"][0].address' 2>/dev/null \ + | grep -m1 . +} + +lan_ip() { + local configured + configured="$(uci_get main lan_bind_ip auto)" + if [ "$configured" != auto ] && [ -n "$configured" ]; then + printf '%s\n' "$configured" + return 0 + fi + + ubus call network.interface.lan status 2>/dev/null \ + | jsonfilter -e '@["ipv4-address"][0].address' 2>/dev/null \ + | grep -m1 . +} + +route_name() { + printf '%s' "$1" | tr '.:-' '___' | tr -cd 'A-Za-z0-9_' +} + +is_valid_host() { + case "$1" in + ''|[!A-Za-z0-9]*|*[!A-Za-z0-9]) return 1 ;; + *[!A-Za-z0-9.-]*) return 1 ;; + esac + return 0 +} + +is_valid_ip_or_host() { + case "$1" in + ''|*[!A-Za-z0-9_.:-]*) return 1 ;; + esac + return 0 +} + +is_valid_backup_id() { + case "$1" in + [0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]) return 0 ;; + *) return 1 ;; + esac +} + +backend_address() { + local host="$1" + local port="$2" + + case "$host" in + *:*) printf '[%s]:%s\n' "$host" "$port" ;; + *) printf '%s:%s\n' "$host" "$port" ;; + esac +} + +is_valid_port() { + local port="$1" + [ "$port" -ge 1 ] 2>/dev/null && [ "$port" -le 65535 ] 2>/dev/null +} + +route_sections() { + uci -q show "$CONFIG" \ + | sed -n "s/^$CONFIG\\.\\(@route\\[[0-9][0-9]*\\]\\)=route/\\1/p" +} + +route_kind() { + local section="$1" + local kind + + kind="$(uci_get "$section" kind)" + [ -n "$kind" ] || kind="$(uci_get "$section" protocol http)" + printf '%s\n' "$kind" +} + +route_port_maps() { + local section="$1" + local kind="$2" + local public_port backend_port + + case "$kind" in + ssh) + public_port="$(uci_get "$section" ssh_listen_port 22)" + backend_port="$(uci_get "$section" ssh_backend_port 22)" + printf '%s:%s\n' "$public_port" "$backend_port" + ;; + rdp) + public_port="$(uci_get "$section" rdp_listen_port 3389)" + backend_port="$(uci_get "$section" rdp_backend_port 3389)" + printf '%s:%s\n' "$public_port" "$backend_port" + ;; + custom) + for map in $(uci_get "$section" port_map); do + printf '%s\n' "$map" + done + ;; + tcp) + public_port="$(uci_get "$section" listen_port "$(uci_get "$section" backend_port)")" + backend_port="$(uci_get "$section" backend_port)" + printf '%s:%s\n' "$public_port" "$backend_port" + ;; + esac +} + +is_valid_port_map() { + local map="$1" + local public_port backend_port + + case "$map" in + *:*:*) return 1 ;; + *:*) ;; + *) return 1 ;; + esac + + public_port="${map%%:*}" + backend_port="${map#*:}" + is_valid_port "$public_port" && is_valid_port "$backend_port" +} + +required_ports() { + local section enabled kind http_enabled https_enabled map + local http_port https_port + + http_port="$(uci_get main http_port 80)" + https_port="$(uci_get main https_port 443)" + [ "$(uci_get main enabled 0)" = 1 ] || return 0 + + for section in $(route_sections); do + enabled="$(uci_get "$section" enabled 1)" + [ "$enabled" = 1 ] || continue + kind="$(route_kind "$section")" + + case "$kind" in + web) + http_enabled="$(uci_get "$section" web_http 1)" + https_enabled="$(uci_get "$section" web_https 1)" + [ "$http_enabled" != 1 ] || printf '%s\n' "$http_port" + [ "$https_enabled" != 1 ] || printf '%s\n' "$https_port" + ;; + http) + printf '%s\n' "$http_port" + ;; + https) + printf '%s\n' "$https_port" + ;; + both) + printf '%s\n%s\n' "$http_port" "$https_port" + ;; + ssh|rdp|custom|tcp) + for map in $(route_port_maps "$section" "$kind"); do + printf '%s\n' "${map%%:*}" + done + ;; + esac + done | sort -nu +} + +port_spec_contains() { + local spec="$1" + local wanted="$2" + local token first last + + for token in $(printf '%s' "$spec" | tr ',' ' '); do + case "$token" in + *-*) + first="${token%%-*}" + last="${token#*-}" + [ "$wanted" -ge "$first" ] 2>/dev/null && [ "$wanted" -le "$last" ] 2>/dev/null && return 0 + ;; + *) + [ "$token" = "$wanted" ] && return 0 + ;; + esac + done + + return 1 +} + +set_uhttpd_list() { + local option values current address + option="$1" + values="$2" + current="$(uci -q get "uhttpd.main.$option" 2>/dev/null || true)" + [ "$current" = "$values" ] && return 0 + + uci -q delete "uhttpd.main.$option" || true + for address in $values; do + uci add_list "uhttpd.main.$option=$address" + done + UHTTPD_CHANGED=1 +} + +capture_uhttpd_bindings() { + local saved_http saved_https address + [ "$(uci_get main uhttpd_bind_owned 0)" != 1 ] || return 0 + + saved_http="$(uci -q get uhttpd.main.listen_http 2>/dev/null || true)" + saved_https="$(uci -q get uhttpd.main.listen_https 2>/dev/null || true)" + uci -q delete "$CONFIG.main.uhttpd_saved_listen_http" || true + uci -q delete "$CONFIG.main.uhttpd_saved_listen_https" || true + for address in $saved_http; do + uci add_list "$CONFIG.main.uhttpd_saved_listen_http=$address" + done + for address in $saved_https; do + uci add_list "$CONFIG.main.uhttpd_saved_listen_https=$address" + done + uci set "$CONFIG.main.uhttpd_bind_owned=1" + uci commit "$CONFIG" +} + +restore_uhttpd_bindings() { + [ "$(uci_get main uhttpd_bind_owned 0)" = 1 ] || return 0 + + set_uhttpd_list listen_http "$(uci_get main uhttpd_saved_listen_http)" + set_uhttpd_list listen_https "$(uci_get main uhttpd_saved_listen_https)" + uci -q delete "$CONFIG.main.uhttpd_saved_listen_http" || true + uci -q delete "$CONFIG.main.uhttpd_saved_listen_https" || true + uci -q delete "$CONFIG.main.uhttpd_bind_owned" || true + uci commit "$CONFIG" +} diff --git a/luci-app-haproxy-manager/root/usr/libexec/haproxy-manager/firewall-plan b/luci-app-haproxy-manager/root/usr/libexec/haproxy-manager/firewall-plan new file mode 100755 index 00000000..7f8edffc --- /dev/null +++ b/luci-app-haproxy-manager/root/usr/libexec/haproxy-manager/firewall-plan @@ -0,0 +1,65 @@ +#!/bin/sh +. /usr/libexec/haproxy-manager/common.sh + +ZONE="$(uci_get main firewall_zone wan)" +MANAGED="$(uci_get main manage_firewall 0)" +MODE="$(uci_get main firewall_conflict_mode warn)" +PORTS="$(required_ports)" +WAN_POLICY=unknown +HAS_MANAGED_RULE=0 +CONFLICTS=0 + +for zone in $(uci -q show firewall | sed -n 's/^firewall\.\(@zone\[[0-9][0-9]*\]\)=zone/\1/p'); do + [ "$(uci -q get "firewall.$zone.name")" = "$ZONE" ] || continue + WAN_POLICY="$(uci -q get "firewall.$zone.input" 2>/dev/null || echo REJECT)" + break +done + +for rule in $(uci -q show firewall | sed -n 's/^firewall\.\(@rule\[[0-9][0-9]*\]\)=rule/\1/p'); do + name="$(uci -q get "firewall.$rule.name" 2>/dev/null || true)" + [ "$name" = "$FIREWALL_RULE_NAME" ] || continue + HAS_MANAGED_RULE=1 + break +done + +printf 'enabled\t%s\n' "$MANAGED" +printf 'zone\t%s\n' "$ZONE" +printf 'policy\t%s\n' "$WAN_POLICY" +printf 'mode\t%s\n' "$MODE" +printf 'managed_rule\t%s\n' "$HAS_MANAGED_RULE" + +for port in $PORTS; do + printf 'port\t%s\n' "$port" +done + +for redirect in $(uci -q show firewall | sed -n 's/^firewall\.\(@redirect\[[0-9][0-9]*\]\)=redirect/\1/p'); do + redirect_enabled="$(uci -q get "firewall.$redirect.enabled" 2>/dev/null || echo 1)" + redirect_name="$(uci -q get "firewall.$redirect.name" 2>/dev/null || true)" + if [ "$redirect_enabled" != 1 ]; then + case "$redirect_name" in + "$FIREWALL_DISABLED_PREFIX"*) + [ "$MANAGED" = 1 ] && [ "$MODE" != disable ] || continue + ;; + *) continue ;; + esac + fi + [ "$(uci -q get "firewall.$redirect.src" 2>/dev/null || true)" = "$ZONE" ] || continue + + proto="$(uci -q get "firewall.$redirect.proto" 2>/dev/null || echo 'tcp udp')" + case " $proto " in *' tcp '*|*' tcpudp '*) ;; *) continue ;; esac + + spec="$(uci -q get "firewall.$redirect.src_dport" 2>/dev/null || true)" + [ -n "$spec" ] || continue + + for port in $PORTS; do + port_spec_contains "$spec" "$port" || continue + name="${redirect_name:-$redirect}" + dest_ip="$(uci -q get "firewall.$redirect.dest_ip" 2>/dev/null || true)" + dest_port="$(uci -q get "firewall.$redirect.dest_port" 2>/dev/null || true)" + printf 'conflict\t%s\t%s\t%s\t%s\t%s\n' "$redirect" "$name" "$spec" "$dest_ip" "$dest_port" + CONFLICTS=$((CONFLICTS + 1)) + break + done +done + +printf 'conflicts\t%s\n' "$CONFLICTS" diff --git a/luci-app-haproxy-manager/root/usr/libexec/haproxy-manager/firewall-sync b/luci-app-haproxy-manager/root/usr/libexec/haproxy-manager/firewall-sync new file mode 100755 index 00000000..f6831620 --- /dev/null +++ b/luci-app-haproxy-manager/root/usr/libexec/haproxy-manager/firewall-sync @@ -0,0 +1,106 @@ +#!/bin/sh +set -eu +. /usr/libexec/haproxy-manager/common.sh +umask 077 + +CHECK_ONLY=0 +FORCE_DISABLED=0 +case "${1:-}" in + '') ;; + --check) CHECK_ONLY=1 ;; + --disable) FORCE_DISABLED=1 ;; + *) echo "Usage: firewall-sync [--check|--disable]" >&2; exit 1 ;; +esac + +ZONE="$(uci_get main firewall_zone wan)" +MODE="$(uci_get main firewall_conflict_mode warn)" +MANAGED="$(uci_get main manage_firewall 0)" +[ "$FORCE_DISABLED" != 1 ] || MANAGED=0 +PLAN=/tmp/haproxy-manager-firewall-plan.$$ +/usr/libexec/haproxy-manager/firewall-plan > "$PLAN" +trap 'rm -f "$PLAN"' EXIT + +CONFLICTS="$(sed -n 's/^conflicts[[:space:]]*//p' "$PLAN")" +if [ "$MANAGED" = 1 ] && [ "${CONFLICTS:-0}" -gt 0 ] && [ "$MODE" != disable ]; then + echo "Enabled firewall port forwards conflict with HAProxy listeners:" >&2 + awk -F '\t' '$1 == "conflict" { printf "- %s (WAN %s -> %s:%s)\n", $3, $4, $5, $6 }' "$PLAN" >&2 + echo "Choose automatic conflict resolution in Settings or disable the redirects manually." >&2 + exit 3 +fi + +[ "$CHECK_ONLY" != 1 ] || { cat "$PLAN"; exit 0; } + +PORTS="" +[ "$MANAGED" != 1 ] || PORTS="$(sed -n 's/^port[[:space:]]*//p' "$PLAN")" +CHANGED=0 + +for redirect in $(uci -q show firewall | sed -n 's/^firewall\.\(@redirect\[[0-9][0-9]*\]\)=redirect/\1/p'); do + name="$(uci -q get "firewall.$redirect.name" 2>/dev/null || true)" + case "$name" in + "$FIREWALL_DISABLED_PREFIX"*) managed_disabled=1 ;; + *) managed_disabled=0 ;; + esac + [ "$managed_disabled" = 1 ] || continue + spec="$(uci -q get "firewall.$redirect.src_dport" 2>/dev/null || true)" + still_needed=0 + for port in $PORTS; do + port_spec_contains "$spec" "$port" || continue + still_needed=1 + break + done + [ "$still_needed" = 1 ] && continue + uci set "firewall.$redirect.enabled=1" + CHANGED=1 + name="${name#"$FIREWALL_DISABLED_PREFIX"}" + if [ -n "$name" ]; then + uci set "firewall.$redirect.name=$name" + else + uci -q delete "firewall.$redirect.name" || true + fi +done + +if [ "$MANAGED" = 1 ] && [ "$MODE" = disable ]; then + for redirect in $(awk -F '\t' '$1 == "conflict" { print $2 }' "$PLAN"); do + [ -n "$redirect" ] || continue + name="$(uci -q get "firewall.$redirect.name" 2>/dev/null || true)" + case "$name" in + "$FIREWALL_DISABLED_PREFIX"*) ;; + *) uci set "firewall.$redirect.name=$FIREWALL_DISABLED_PREFIX$name" ;; + esac + uci set "firewall.$redirect.enabled=0" + CHANGED=1 + done +fi + +for index in $(uci -q show firewall | sed -n 's/^firewall\.@rule\[\([0-9][0-9]*\)\]=rule/\1/p' | sort -rn); do + rule="@rule[$index]" + name="$(uci -q get "firewall.$rule.name" 2>/dev/null || true)" + if [ "$name" = "$FIREWALL_RULE_NAME" ]; then + uci -q delete "firewall.$rule" || true + CHANGED=1 + fi +done + +if [ -n "$PORTS" ]; then + rule="$(uci add firewall rule)" + uci set "firewall.$rule.name=$FIREWALL_RULE_NAME" + uci set "firewall.$rule.src=$ZONE" + uci set "firewall.$rule.proto=tcp" + uci set "firewall.$rule.target=ACCEPT" + uci set "firewall.$rule.family=ipv4" + for port in $PORTS; do + uci add_list "firewall.$rule.dest_port=$port" + done + CHANGED=1 +fi + +if [ "$CHANGED" = 1 ]; then + uci commit firewall + /etc/init.d/firewall reload +fi + +if [ "$MANAGED" = 1 ]; then + echo "Firewall rules synchronized" +else + echo "Firewall automation disabled; managed rules removed" +fi diff --git a/luci-app-haproxy-manager/root/usr/libexec/haproxy-manager/generate b/luci-app-haproxy-manager/root/usr/libexec/haproxy-manager/generate new file mode 100755 index 00000000..d3b86626 --- /dev/null +++ b/luci-app-haproxy-manager/root/usr/libexec/haproxy-manager/generate @@ -0,0 +1,234 @@ +#!/bin/sh +set -eu +. /usr/libexec/haproxy-manager/common.sh +umask 077 + +OUT="${1:-$TMP_CFG}" +is_safe_temp_path "$OUT" || { echo "Refusing unsafe generated config path: $OUT" >&2; exit 1; } +[ ! -d "$OUT" ] || { echo "Refusing generated config directory: $OUT" >&2; exit 1; } +WORK_OUT="$OUT.new.$$" +ENABLED="$(uci_get main enabled 0)" +HTTP_PORT="$(uci_get main http_port 80)" +HTTPS_PORT="$(uci_get main https_port 443)" +WAN_IP="$(wan_ip || true)" + +if [ "$ENABLED" != 1 ]; then + echo "Managed HAProxy configuration is disabled." >&2 + exit 2 +fi + +is_valid_port "$HTTP_PORT" || { echo "Invalid public HTTP port: $HTTP_PORT" >&2; exit 1; } +is_valid_port "$HTTPS_PORT" || { echo "Invalid public HTTPS port: $HTTPS_PORT" >&2; exit 1; } + +if [ -z "$WAN_IP" ]; then + echo "Cannot detect WAN IPv4 address. Set the WAN bind address manually." >&2 + exit 1 +fi +is_valid_ip_or_host "$WAN_IP" || { echo "Invalid WAN bind address: $WAN_IP" >&2; exit 1; } + +TMP_HTTP_RULES=/tmp/haproxy-manager-http-rules.$$ +TMP_HTTP_BACKENDS=/tmp/haproxy-manager-http-backends.$$ +TMP_TLS_RULES=/tmp/haproxy-manager-tls-rules.$$ +TMP_TLS_BACKENDS=/tmp/haproxy-manager-tls-backends.$$ +TMP_TCP_FRONTENDS=/tmp/haproxy-manager-tcp-frontends.$$ +TMP_PORTS=/tmp/haproxy-manager-listen-ports.$$ +: > "$TMP_HTTP_RULES" +: > "$TMP_HTTP_BACKENDS" +: > "$TMP_TLS_RULES" +: > "$TMP_TLS_BACKENDS" +: > "$TMP_TCP_FRONTENDS" +: > "$TMP_PORTS" +trap 'rm -f "$WORK_OUT" "$TMP_HTTP_RULES" "$TMP_HTTP_BACKENDS" "$TMP_TLS_RULES" "$TMP_TLS_BACKENDS" "$TMP_TCP_FRONTENDS" "$TMP_PORTS"' EXIT + +HAS_HTTP=0 +HAS_HTTPS=0 +HAS_TCP=0 + +claim_port() { + local port="$1" + local owner="$2" + local existing + + if grep -q "^${port}[[:space:]]" "$TMP_PORTS"; then + existing="$(sed -n "s/^${port}[[:space:]]*//p" "$TMP_PORTS" | head -1)" + echo "Public TCP port $port conflicts with ${existing:-another service}." >&2 + exit 1 + fi + + printf '%s\t%s\n' "$port" "$owner" >> "$TMP_PORTS" +} + +add_http_route() { + local name="$1" host="$2" backend_host="$3" backend_port="$4" + local address + address="$(backend_address "$backend_host" "$backend_port")" + + if [ "$HAS_HTTP" != 1 ]; then + claim_port "$HTTP_PORT" "the shared Web HTTP listener" + HAS_HTTP=1 + fi + + { + printf ' acl host_%s hdr(host),host_only -i %s\n' "$name" "$host" + printf ' use_backend be_http_%s if host_%s\n' "$name" "$name" + } >> "$TMP_HTTP_RULES" + { + printf 'backend be_http_%s\n' "$name" + printf ' mode http\n' + printf ' option httpchk GET /\n' + printf ' server srv1 %s check\n\n' "$address" + } >> "$TMP_HTTP_BACKENDS" +} + +add_https_route() { + local name="$1" host="$2" backend_host="$3" backend_port="$4" + local address + address="$(backend_address "$backend_host" "$backend_port")" + + if [ "$HAS_HTTPS" != 1 ]; then + claim_port "$HTTPS_PORT" "the shared Web HTTPS listener" + HAS_HTTPS=1 + fi + + printf ' use_backend be_tls_%s if { req.ssl_sni -i %s }\n' "$name" "$host" >> "$TMP_TLS_RULES" + { + printf 'backend be_tls_%s\n' "$name" + printf ' mode tcp\n' + printf ' server srv1 %s check\n\n' "$address" + } >> "$TMP_TLS_BACKENDS" +} + +add_tcp_route() { + local name="$1" label="$2" backend_host="$3" public_port="$4" backend_port="$5" + local address bind_address + address="$(backend_address "$backend_host" "$backend_port")" + bind_address="$(backend_address "$WAN_IP" "$public_port")" + + claim_port "$public_port" "service $label" + HAS_TCP=1 + { + printf 'frontend fe_tcp_%s\n' "$name" + printf ' bind %s\n' "$bind_address" + printf ' mode tcp\n' + printf ' default_backend be_tcp_%s\n\n' "$name" + printf 'backend be_tcp_%s\n' "$name" + printf ' mode tcp\n' + printf ' server srv1 %s check\n\n' "$address" + } >> "$TMP_TCP_FRONTENDS" +} + +for section in $(route_sections); do + enabled="$(uci_get "$section" enabled 1)" + [ "$enabled" = 1 ] || continue + + kind="$(route_kind "$section")" + label="$(uci_get "$section" name)" + host="$(uci_get "$section" host)" + backend_host="$(uci_get "$section" backend_host)" + [ -n "$label" ] || label="$host" + [ -n "$label" ] || label="$kind" + name="$(route_name "${label}_${section}")" + + is_valid_ip_or_host "$backend_host" || { echo "Invalid backend host for $label: $backend_host" >&2; exit 1; } + + case "$kind" in + web) + is_valid_host "$host" || { echo "Invalid or missing domain for Web service $label: $host" >&2; exit 1; } + http_enabled="$(uci_get "$section" web_http 1)" + https_enabled="$(uci_get "$section" web_https 1)" + [ "$http_enabled" = 1 ] || [ "$https_enabled" = 1 ] || { echo "Web service $label has no enabled protocol." >&2; exit 1; } + + if [ "$http_enabled" = 1 ]; then + backend_port="$(uci_get "$section" backend_http_port 80)" + is_valid_port "$backend_port" || { echo "Invalid HTTP backend port for $label: $backend_port" >&2; exit 1; } + add_http_route "$name" "$host" "$backend_host" "$backend_port" + fi + + if [ "$https_enabled" = 1 ]; then + backend_port="$(uci_get "$section" backend_https_port 443)" + is_valid_port "$backend_port" || { echo "Invalid HTTPS backend port for $label: $backend_port" >&2; exit 1; } + add_https_route "$name" "$host" "$backend_host" "$backend_port" + fi + ;; + http|https|both) + is_valid_host "$host" || { echo "Invalid or missing domain for legacy service $label: $host" >&2; exit 1; } + backend_port="$(uci_get "$section" backend_port)" + is_valid_port "$backend_port" || { echo "Invalid backend port for $label: $backend_port" >&2; exit 1; } + case "$kind" in http|both) add_http_route "$name" "$host" "$backend_host" "$backend_port" ;; esac + case "$kind" in https|both) add_https_route "$name" "$host" "$backend_host" "$backend_port" ;; esac + ;; + ssh|rdp|custom|tcp) + map_index=0 + maps="$(route_port_maps "$section" "$kind")" + [ -n "$maps" ] || { echo "No TCP port mappings configured for $label." >&2; exit 1; } + for map in $maps; do + is_valid_port_map "$map" || { echo "Invalid TCP port mapping for $label: $map" >&2; exit 1; } + public_port="${map%%:*}" + backend_port="${map#*:}" + map_index=$((map_index + 1)) + add_tcp_route "${name}_${map_index}" "$label" "$backend_host" "$public_port" "$backend_port" + done + ;; + *) + echo "Invalid service type for $label: $kind" >&2 + exit 1 + ;; + esac +done + +if [ "$HAS_HTTP" != 1 ] && [ "$HAS_HTTPS" != 1 ] && [ "$HAS_TCP" != 1 ]; then + echo "No enabled services define a HAProxy listener." >&2 + exit 2 +fi + +{ + printf '# Generated by luci-app-haproxy-manager. Manual changes may be overwritten.\n' + printf 'global\n' + printf ' log /dev/log local0\n' + printf ' maxconn 2048\n' + printf ' daemon\n\n' + printf 'defaults\n' + printf ' log global\n' + printf ' timeout connect 5s\n' + printf ' timeout client 60s\n' + printf ' timeout server 60s\n' + printf ' option dontlognull\n\n' + + if [ "$HAS_HTTP" = 1 ]; then + http_bind="$(backend_address "$WAN_IP" "$HTTP_PORT")" + printf 'frontend fe_web_http\n' + printf ' bind %s\n' "$http_bind" + printf ' mode http\n' + printf ' option httplog\n' + cat "$TMP_HTTP_RULES" + printf ' default_backend be_not_found\n\n' + cat "$TMP_HTTP_BACKENDS" + fi + + if [ "$HAS_HTTPS" = 1 ]; then + https_bind="$(backend_address "$WAN_IP" "$HTTPS_PORT")" + printf 'frontend fe_web_https\n' + printf ' bind %s\n' "$https_bind" + printf ' mode tcp\n' + printf ' tcp-request inspect-delay 5s\n' + printf ' tcp-request content accept if { req.ssl_hello_type 1 }\n' + cat "$TMP_TLS_RULES" + printf ' default_backend be_tls_not_found\n\n' + cat "$TMP_TLS_BACKENDS" + fi + + cat "$TMP_TCP_FRONTENDS" + + printf 'backend be_not_found\n' + printf ' mode http\n' + printf ' http-request return status 404 content-type text/plain string "No HAProxy route matched this host.\\n"\n\n' + printf 'backend be_tls_not_found\n' + printf ' mode tcp\n' + printf ' tcp-request content reject\n' +} > "$WORK_OUT" + +# Replace the path itself so a stale symlink cannot redirect this root write. +rm -f "$OUT" +mv "$WORK_OUT" "$OUT" + +printf '%s\n' "$OUT" diff --git a/luci-app-haproxy-manager/root/usr/libexec/haproxy-manager/incident b/luci-app-haproxy-manager/root/usr/libexec/haproxy-manager/incident new file mode 100755 index 00000000..26e067dc --- /dev/null +++ b/luci-app-haproxy-manager/root/usr/libexec/haproxy-manager/incident @@ -0,0 +1,13 @@ +#!/bin/sh +set -eu +. /usr/libexec/haproxy-manager/common.sh + +ID="${1:-}" +is_valid_backup_id "$ID" || { echo "Invalid incident identifier" >&2; exit 1; } +BASE="$(incident_dir)" +[ ! -d "$BASE" ] || BASE="$(resolve_storage_dir "$BASE")" || exit 1 +FILE="$BASE/$ID/diagnostics.log" +[ -f "$FILE" ] || { echo "Incident report not found: $ID" >&2; exit 1; } + +# Keep the RPC response bounded even if a platform command produced excessive output. +tail -c 65536 "$FILE" diff --git a/luci-app-haproxy-manager/root/usr/libexec/haproxy-manager/incidents b/luci-app-haproxy-manager/root/usr/libexec/haproxy-manager/incidents new file mode 100755 index 00000000..6b91c37b --- /dev/null +++ b/luci-app-haproxy-manager/root/usr/libexec/haproxy-manager/incidents @@ -0,0 +1,28 @@ +#!/bin/sh +. /usr/libexec/haproxy-manager/common.sh + +BASE="$(incident_dir)" +[ ! -d "$BASE" ] || BASE="$(resolve_storage_dir "$BASE")" || exit 1 +LAST="" +[ ! -r "$BASE/LAST" ] || LAST="$(basename "$(sed -n '1p' "$BASE/LAST")")" + +safe_field() { + printf '%s' "$1" | tr '\t\r\n' ' ' +} + +count=0 +for dir in $(find "$BASE" -maxdepth 1 -type d -name '[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]' 2>/dev/null | sort -r); do + [ -d "$dir" ] || continue + id="$(basename "$dir")" + latest=0 + [ "$id" != "$LAST" ] || latest=1 + result="$(sed -n '1p' "$dir/result" 2>/dev/null || echo detected)" + action="$(sed -n 's/^action=//p' "$dir/meta" 2>/dev/null | head -1)" + interface="$(sed -n 's/^interface=//p' "$dir/meta" 2>/dev/null | head -1)" + reason="$(sed -n 's/^reason=//p' "$dir/meta" 2>/dev/null | head -1)" + printf 'incident\t%s\t%s\t%s\t%s\t%s\t%s\n' \ + "$id" "$latest" "$(safe_field "$result")" "$(safe_field "$action")" \ + "$(safe_field "$interface")" "$(safe_field "$reason")" + count=$((count + 1)) + [ "$count" -lt "$INCIDENT_LIMIT" ] || break +done diff --git a/luci-app-haproxy-manager/root/usr/libexec/haproxy-manager/migrate b/luci-app-haproxy-manager/root/usr/libexec/haproxy-manager/migrate new file mode 100755 index 00000000..a8ab586d --- /dev/null +++ b/luci-app-haproxy-manager/root/usr/libexec/haproxy-manager/migrate @@ -0,0 +1,150 @@ +#!/bin/sh +set -eu +. /usr/libexec/haproxy-manager/common.sh + +migrate_firewall_markers() { + local section marker name changed + changed=0 + + for section in $(uci -q show firewall | sed -n 's/^firewall\.\(@redirect\[[0-9][0-9]*\]\)=redirect/\1/p'); do + marker="$(uci -q get "firewall.$section.hm_disabled_by_haproxy_manager" 2>/dev/null || true)" + [ -n "$marker" ] || continue + if [ "$marker" = 1 ]; then + name="$(uci -q get "firewall.$section.name" 2>/dev/null || true)" + case "$name" in + "$FIREWALL_DISABLED_PREFIX"*) ;; + *) uci set "firewall.$section.name=$FIREWALL_DISABLED_PREFIX$name" ;; + esac + fi + uci -q delete "firewall.$section.hm_disabled_by_haproxy_manager" || true + changed=1 + done + + for section in $(uci -q show firewall | sed -n 's/^firewall\.\(@rule\[[0-9][0-9]*\]\)=rule/\1/p'); do + marker="$(uci -q get "firewall.$section.hm_managed" 2>/dev/null || true)" + [ -n "$marker" ] || continue + [ "$marker" != 1 ] || uci set "firewall.$section.name=$FIREWALL_RULE_NAME" + uci -q delete "firewall.$section.hm_managed" || true + changed=1 + done + + [ "$changed" != 1 ] || uci commit firewall +} + +migrate_firewall_markers + +SCHEMA_VERSION="$(uci_get main schema_version 1)" +MIGRATED=0 +if [ "$SCHEMA_VERSION" -lt 2 ] 2>/dev/null; then + +SECTIONS="$(route_sections)" +[ -z "$SECTIONS" ] || /usr/libexec/haproxy-manager/backup >/dev/null + +CONSUMED=/tmp/haproxy-manager-migrate-consumed.$$ +: > "$CONSUMED" +trap 'rm -f "$CONSUMED"' EXIT + +is_consumed() { + grep -qx "$1" "$CONSUMED" +} + +to_web() { + local section="$1" http_enabled="$2" https_enabled="$3" http_backend="$4" https_backend="$5" + + uci set "$CONFIG.$section.kind=web" + uci set "$CONFIG.$section.web_http=$http_enabled" + uci set "$CONFIG.$section.web_https=$https_enabled" + [ "$http_enabled" != 1 ] || uci set "$CONFIG.$section.backend_http_port=$http_backend" + [ "$https_enabled" != 1 ] || uci set "$CONFIG.$section.backend_https_port=$https_backend" + uci -q delete "$CONFIG.$section.protocol" || true + uci -q delete "$CONFIG.$section.listen_port" || true + uci -q delete "$CONFIG.$section.backend_port" || true +} + +clean_web_name() { + local section="$1" host="$2" name clean + name="$(uci_get "$section" name)" + clean="$name" + + case "$name" in + "$host 80"|"$host:80"|"$host HTTP"|"$host http") clean="$host" ;; + *" HTTP") clean="${name% HTTP}" ;; + *" http") clean="${name% http}" ;; + esac + + [ -z "$clean" ] || [ "$clean" = "$name" ] || uci set "$CONFIG.$section.name=$clean" +} + +for section in $SECTIONS; do + protocol="$(uci_get "$section" protocol)" + case "$protocol" in + http) + host="$(uci_get "$section" host)" + backend_host="$(uci_get "$section" backend_host)" + enabled="$(uci_get "$section" enabled 1)" + http_backend="$(uci_get "$section" backend_port 80)" + match="" + + for candidate in $SECTIONS; do + is_consumed "$candidate" && continue + [ "$(uci_get "$candidate" protocol)" = https ] || continue + [ "$(uci_get "$candidate" host)" = "$host" ] || continue + [ "$(uci_get "$candidate" backend_host)" = "$backend_host" ] || continue + [ "$(uci_get "$candidate" enabled 1)" = "$enabled" ] || continue + match="$candidate" + break + done + + if [ -n "$match" ]; then + clean_web_name "$section" "$host" + to_web "$section" 1 1 "$http_backend" "$(uci_get "$match" backend_port 443)" + printf '%s\n' "$match" >> "$CONSUMED" + else + clean_web_name "$section" "$host" + to_web "$section" 1 0 "$http_backend" 443 + fi + ;; + both) + backend="$(uci_get "$section" backend_port 80)" + clean_web_name "$section" "$(uci_get "$section" host)" + to_web "$section" 1 1 "$backend" "$backend" + ;; + tcp) + public_port="$(uci_get "$section" listen_port "$(uci_get "$section" backend_port)")" + backend_port="$(uci_get "$section" backend_port)" + uci set "$CONFIG.$section.kind=custom" + uci -q delete "$CONFIG.$section.port_map" || true + uci add_list "$CONFIG.$section.port_map=$public_port:$backend_port" + uci -q delete "$CONFIG.$section.protocol" || true + uci -q delete "$CONFIG.$section.listen_port" || true + uci -q delete "$CONFIG.$section.backend_port" || true + ;; + esac +done + +for section in $SECTIONS; do + is_consumed "$section" && continue + [ "$(uci_get "$section" protocol)" = https ] || continue + to_web "$section" 0 1 80 "$(uci_get "$section" backend_port 443)" +done + +sed -n 's/^@route\[\([0-9][0-9]*\)\]$/\1/p' "$CONSUMED" | sort -rn | while read -r index; do + uci -q delete "$CONFIG.@route[$index]" || true +done + MIGRATED=1 + +fi + +if [ "$SCHEMA_VERSION" -lt 3 ] 2>/dev/null; then + if [ "$(uci_get main enabled 0)" = 1 ] && + grep -q '^# Generated by luci-app-haproxy-manager\.' "$HAPROXY_CFG" 2>/dev/null; then + uci set "$CONFIG.main.active_mode=generated" + else + uci set "$CONFIG.main.active_mode=none" + fi + uci set "$CONFIG.main.schema_version=3" + uci commit "$CONFIG" + MIGRATED=1 +fi + +[ "$MIGRATED" != 1 ] || echo "Configuration schema upgraded to version 3" diff --git a/luci-app-haproxy-manager/root/usr/libexec/haproxy-manager/notify b/luci-app-haproxy-manager/root/usr/libexec/haproxy-manager/notify new file mode 100755 index 00000000..ffb4ebf9 --- /dev/null +++ b/luci-app-haproxy-manager/root/usr/libexec/haproxy-manager/notify @@ -0,0 +1,44 @@ +#!/bin/sh +set -eu +. /usr/libexec/haproxy-manager/common.sh + +[ "$(uci_get main webhook_enabled 0)" = 1 ] || exit 0 +URL="$(uci_get main webhook_url)" +case "$URL" in + http://*) AUTHORITY="${URL#http://}" ;; + https://*) AUTHORITY="${URL#https://}" ;; + *) echo "Webhook URL must use HTTP or HTTPS" >&2; exit 1 ;; +esac +[ -n "${AUTHORITY%%/*}" ] || { echo "Webhook URL must include a host" >&2; exit 1; } +case "$URL" in + *[![:graph:]]*) echo "Webhook URL contains invalid characters" >&2; exit 1 ;; +esac +if command -v uclient-fetch >/dev/null 2>&1; then + FETCHER=uclient-fetch +elif command -v wget >/dev/null 2>&1; then + FETCHER=wget +else + echo "A compatible wget implementation is required for webhook delivery" >&2 + exit 1 +fi +[ -r /usr/share/libubox/jshn.sh ] || { echo "jshn.sh is not available" >&2; exit 1; } + +INCIDENT_ID="$(printf '%s' "${1:-unknown}" | tr -cd '0-9-')" +RESULT="$(printf '%s' "${2:-unknown}" | tr -cd 'A-Za-z0-9_.:-')" +ACTION="$(printf '%s' "${3:-unknown}" | tr -cd 'A-Za-z0-9_.:-')" +INTERFACE="$(printf '%s' "${4:-unknown}" | tr -cd 'A-Za-z0-9_.:-')" +REASON="$(printf '%s' "${5:-unknown}" | tr -cd 'A-Za-z0-9_.:-')" + +. /usr/share/libubox/jshn.sh +json_init +json_add_string event haproxy-manager-recovery +json_add_string incident "$INCIDENT_ID" +json_add_string result "$RESULT" +json_add_string action "$ACTION" +json_add_string interface "$INTERFACE" +json_add_string reason "$REASON" +PAYLOAD="$(json_dump)" + +"$FETCHER" -q -T 10 -O /dev/null \ + --header='Content-Type: application/json' \ + --post-data="$PAYLOAD" "$URL" diff --git a/luci-app-haproxy-manager/root/usr/libexec/haproxy-manager/ports b/luci-app-haproxy-manager/root/usr/libexec/haproxy-manager/ports new file mode 100755 index 00000000..c122b877 --- /dev/null +++ b/luci-app-haproxy-manager/root/usr/libexec/haproxy-manager/ports @@ -0,0 +1,4 @@ +#!/bin/sh +. /usr/libexec/haproxy-manager/common.sh + +required_ports diff --git a/luci-app-haproxy-manager/root/usr/libexec/haproxy-manager/recover b/luci-app-haproxy-manager/root/usr/libexec/haproxy-manager/recover new file mode 100755 index 00000000..1b581c70 --- /dev/null +++ b/luci-app-haproxy-manager/root/usr/libexec/haproxy-manager/recover @@ -0,0 +1,259 @@ +#!/bin/sh +set -eu +. /usr/libexec/haproxy-manager/common.sh +umask 077 + +ACTION="$(printf '%s' "${1:-network}" | tr -cd 'A-Za-z0-9_.:-')" +INTERFACE="$(printf '%s' "${2:-unknown}" | tr -cd 'A-Za-z0-9_.:-')" +RECOVER_LOCK=/var/lock/haproxy-manager.recover +APPLY_LOCK=/var/lock/haproxy-manager.apply +APPLY_LOCK_HELD=0 +TMP_CFG="/tmp/haproxy-manager-recover.$$" +INCIDENT="" +BASE="" +DIAGNOSTICS=/dev/null + +[ -n "$ACTION" ] || ACTION=network +[ -n "$INTERFACE" ] || INTERFACE=unknown +[ "$(uci_get main auto_recover 1)" = 1 ] || exit 0 +[ "$(uci_get main enabled 0)" = 1 ] || exit 0 +RECONCILE_GENERATED=0 +case "$ACTION" in + ifup|ifupdate) + [ "$INTERFACE" = "$(wan_interface)" ] || exit 0 + RECONCILE_GENERATED=1 + ;; +esac +[ -x /etc/init.d/haproxy ] || exit 0 +/etc/init.d/haproxy enabled >/dev/null 2>&1 || exit 0 + +MODE="$(active_mode)" +case "$MODE" in + generated|raw) ;; + *) exit 0 ;; +esac + +cleanup() { + rm -f "$TMP_CFG" + [ "$APPLY_LOCK_HELD" != 1 ] || operation_lock_release "$APPLY_LOCK" + operation_lock_release "$RECOVER_LOCK" +} + +trim_incidents() { + local count old + count=0 + for old in $(find "$BASE" -maxdepth 1 -type d -name '[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]' 2>/dev/null | sort -r); do + count=$((count + 1)) + [ "$count" -le "$INCIDENT_LIMIT" ] && continue + case "$old" in + "$BASE"/[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]) + rm -f "$old/meta" "$old/result" "$old/diagnostics.log" + rmdir "$old" 2>/dev/null || true + ;; + esac + done +} + +create_incident() { + local reason="$1" id attempt target_ip requested_base + requested_base="$(incident_dir)" || return 1 + umask 077 + mkdir -p "$requested_base" || { + logger -t haproxy-manager "Cannot create incident report directory: $requested_base" + return 1 + } + BASE="$(resolve_storage_dir "$requested_base")" || { + logger -t haproxy-manager "Incident directory resolves outside /root or /mnt" + return 1 + } + id="$(date +%Y%m%d-%H%M%S)" + INCIDENT="$BASE/$id" + attempt=1 + while ! mkdir "$INCIDENT" 2>/dev/null; do + [ "$attempt" -lt 5 ] || { + logger -t haproxy-manager "Cannot create incident report in $BASE" + return 1 + } + sleep 1 + attempt=$((attempt + 1)) + id="$(date +%Y%m%d-%H%M%S)" + INCIDENT="$BASE/$id" + done + chmod 700 "$BASE" "$INCIDENT" 2>/dev/null || true + target_ip="$(wan_ip 2>/dev/null || echo unknown)" + cat > "$INCIDENT/meta" < "$INCIDENT/result" || return 1 + printf '%s\n' "$id" > "$BASE/LAST" || return 1 + trim_incidents + DIAGNOSTICS="$INCIDENT/diagnostics.log" + if [ "$reason" = wan-address-change ]; then + { + printf 'HAProxy Manager WAN listener reconciliation\n' + printf 'Incident: %s\nAction: %s\nInterface: %s\nReason: %s\nTarget WAN address: %s\n' \ + "$id" "$ACTION" "$INTERFACE" "$reason" "$target_ip" + } > "$DIAGNOSTICS" 2>&1 + return 0 + fi + { + printf 'HAProxy Manager automatic recovery\n' + printf 'Incident: %s\nAction: %s\nInterface: %s\nReason: %s\nTarget WAN address: %s\n\n' \ + "$id" "$ACTION" "$INTERFACE" "$reason" "$target_ip" + printf '== Time and uptime ==\n' + date || true + uptime || true + printf '\n== Memory ==\n' + free || true + printf '\n== IPv4 addresses ==\n' + ip -4 addr show || true + printf '\n== HAProxy procd state ==\n' + ubus call service list '{"name":"haproxy","verbose":true}' || true + printf '\n== Network interfaces ==\n' + ubus call network.interface dump || true + printf '\n== Recent HAProxy, procd, and OOM messages ==\n' + logread | grep -iE 'haproxy|procd.*instance|out of memory|oom|killed process' | tail -n 160 || true + printf '\n== Kernel OOM messages ==\n' + dmesg | grep -Ei 'oom|out of memory|killed process|haproxy' | tail -n 80 || true + } > "$DIAGNOSTICS" 2>&1 +} + +set_result() { + local result="$1" id reason + if [ -z "$INCIDENT" ] || [ ! -d "$INCIDENT" ]; then + logger -t haproxy-manager "Recovery result without incident report: $result" + return 0 + fi + if ! printf '%s\n' "$result" > "$INCIDENT/result"; then + logger -t haproxy-manager "Could not update incident report with result: $result" + INCIDENT="" + DIAGNOSTICS=/dev/null + return 0 + fi + id="${INCIDENT##*/}" + reason="$(sed -n 's/^reason=//p' "$INCIDENT/meta" 2>/dev/null | head -1)" + ( + if ! /usr/libexec/haproxy-manager/notify "$id" "$result" "$ACTION" "$INTERFACE" "$reason"; then + logger -t haproxy-manager "Webhook notification failed for incident $id" + fi + ) >/dev/null 2>&1 & +} + +operation_lock_acquire "$RECOVER_LOCK" || exit 0 +trap cleanup EXIT + +# Let netifd finish installing addresses and routes before comparing listeners. +sleep 2 +operation_lock_acquire "$APPLY_LOCK" || exit 0 +APPLY_LOCK_HELD=1 + +CONFIG_CHANGED=0 +GENERATION_FAILED=0 +VALIDATION_FAILED=0 +if [ "$RECONCILE_GENERATED" = 1 ] && [ "$MODE" = generated ] && + [ "$(uci_get main wan_bind_ip auto)" = auto ]; then + if /usr/libexec/haproxy-manager/generate "$TMP_CFG" >/dev/null 2>&1; then + if /usr/libexec/haproxy-manager/validate "$TMP_CFG" >/dev/null 2>&1; then + cmp -s "$TMP_CFG" "$HAPROXY_CFG" || CONFIG_CHANGED=1 + else + VALIDATION_FAILED=1 + fi + else + GENERATION_FAILED=1 + fi +fi + +if [ "$GENERATION_FAILED" != 1 ] && [ "$VALIDATION_FAILED" != 1 ] && + [ "$CONFIG_CHANGED" != 1 ] && /etc/init.d/haproxy status >/dev/null 2>&1; then + exit 0 +fi + +if [ "$GENERATION_FAILED" = 1 ]; then + REASON=wan-address-unavailable +elif [ "$VALIDATION_FAILED" = 1 ]; then + REASON=invalid-generated-config +elif [ "$CONFIG_CHANGED" = 1 ]; then + REASON=wan-address-change +else + REASON=service-stopped +fi +if ! create_incident "$REASON"; then + logger -t haproxy-manager "Incident report could not be stored; recovery will continue" + INCIDENT="" + DIAGNOSTICS=/dev/null +fi +logger -t haproxy-manager "HAProxy recovery started after $ACTION on $INTERFACE ($REASON)" + +if [ "$GENERATION_FAILED" = 1 ]; then + set_result address-unavailable + logger -t haproxy-manager "Automatic recovery stopped: WAN address is unavailable" + exit 1 +fi + +if [ "$VALIDATION_FAILED" = 1 ]; then + set_result invalid-generated-config + logger -t haproxy-manager "Automatic recovery stopped: generated configuration is invalid" + exit 1 +fi + +if [ "$CONFIG_CHANGED" = 1 ]; then + BACKUP_PATH="" + if ! BACKUP_PATH="$(/usr/libexec/haproxy-manager/backup 2>> "$DIAGNOSTICS")"; then + set_result backup-failed + logger -t haproxy-manager "Automatic recovery stopped: snapshot creation failed" + exit 1 + fi + printf '\nRecovery point: %s\n' "${BACKUP_PATH##*/}" >> "$DIAGNOSTICS" || true + if ! atomic_install_file "$TMP_CFG" "$HAPROXY_CFG"; then + set_result install-failed + logger -t haproxy-manager "Automatic recovery stopped: HAProxy configuration could not be installed" + exit 1 + fi + if /etc/init.d/haproxy restart >> "$DIAGNOSTICS" 2>&1 && + /etc/init.d/haproxy status >/dev/null 2>&1; then + set_result reconciled + logger -t haproxy-manager "HAProxy listeners reconciled with the current WAN address" + exit 0 + fi + + if /usr/libexec/haproxy-manager/rollback --sync "${BACKUP_PATH##*/}" >> "$DIAGNOSTICS" 2>&1; then + set_result rolled-back + logger -t haproxy-manager "WAN listener reconciliation failed and was rolled back" + else + set_result rollback-failed + logger -t haproxy-manager "WAN listener reconciliation and rollback both failed" + fi + exit 1 +fi + +if ! /etc/init.d/haproxy check >> "$DIAGNOSTICS" 2>&1; then + set_result invalid-config + logger -t haproxy-manager "Automatic recovery stopped: HAProxy configuration is invalid" + exit 1 +fi + +attempt=1 +while [ "$attempt" -le 5 ]; do + printf '\n== Start attempt %s ==\n' "$attempt" >> "$DIAGNOSTICS" || true + /etc/init.d/haproxy restart >> "$DIAGNOSTICS" 2>&1 || true + sleep $((attempt + 1)) + if /etc/init.d/haproxy status >/dev/null 2>&1; then + set_result recovered + { + printf '\n== Recovered listeners ==\n' + ps w | grep '[h]aproxy' || true + netstat -lntp 2>/dev/null | grep haproxy || true + } >> "$DIAGNOSTICS" 2>&1 + logger -t haproxy-manager "HAProxy recovered after $attempt attempt(s)" + exit 0 + fi + attempt=$((attempt + 1)) +done + +set_result failed +logger -t haproxy-manager "HAProxy automatic recovery failed after 5 attempts" +exit 1 diff --git a/luci-app-haproxy-manager/root/usr/libexec/haproxy-manager/rollback b/luci-app-haproxy-manager/root/usr/libexec/haproxy-manager/rollback new file mode 100755 index 00000000..70584d26 --- /dev/null +++ b/luci-app-haproxy-manager/root/usr/libexec/haproxy-manager/rollback @@ -0,0 +1,124 @@ +#!/bin/sh +set -eu +. /usr/libexec/haproxy-manager/common.sh + +SYNC=0 +LOCK_DIR=/var/lock/haproxy-manager.apply +LOCK_HELD=0 + +if [ "${1:-}" = --sync ]; then + [ "$#" -eq 2 ] || { echo "Usage: rollback [--sync] [last|YYYYMMDD-HHMMSS]" >&2; exit 1; } + SYNC=1 + TARGET="$2" +else + [ "$#" -le 1 ] || { echo "Usage: rollback [--sync] [last|YYYYMMDD-HHMMSS]" >&2; exit 1; } + TARGET="${1:-last}" +fi + +release_lock() { + [ "$LOCK_HELD" != 1 ] || operation_lock_release "$LOCK_DIR" +} + +if [ "$SYNC" != 1 ]; then + attempt=0 + while ! operation_lock_acquire "$LOCK_DIR"; do + [ "$attempt" -lt 15 ] || { echo "Another HAProxy Manager operation is still running." >&2; exit 1; } + attempt=$((attempt + 1)) + sleep 1 + done + LOCK_HELD=1 + trap 'status=$?; release_lock; exit "$status"' EXIT +fi + +BASE="$(backup_dir)" +[ ! -d "$BASE" ] || BASE="$(resolve_storage_dir "$BASE")" || { + echo "Recovery point directory resolves outside /root or /mnt" >&2 + exit 1 +} + +if [ "$TARGET" = last ]; then + [ -f "$BASE/LAST" ] || { echo "No LAST backup marker found" >&2; exit 1; } + TARGET="$(basename "$(cat "$BASE/LAST")")" +fi + +is_valid_backup_id "$TARGET" || { echo "Invalid backup identifier" >&2; exit 1; } +TARGET_DIR="$BASE/$TARGET" +[ -d "$TARGET_DIR" ] || { echo "Backup not found: $TARGET" >&2; exit 1; } + +firewall_changed=0 +uhttpd_changed=0 +state_failed=0 + +if [ -f "$TARGET_DIR/firewall" ] && ! cmp -s "$TARGET_DIR/firewall" /etc/config/firewall; then + atomic_install_file "$TARGET_DIR/firewall" /etc/config/firewall + firewall_changed=1 +fi +if [ -f "$TARGET_DIR/uhttpd" ] && ! cmp -s "$TARGET_DIR/uhttpd" /etc/config/uhttpd; then + atomic_install_file "$TARGET_DIR/uhttpd" /etc/config/uhttpd + uhttpd_changed=1 +fi +[ -f "$TARGET_DIR/haproxy_manager" ] && atomic_install_file "$TARGET_DIR/haproxy_manager" /etc/config/haproxy_manager +[ -f "$TARGET_DIR/haproxy.cfg" ] && atomic_install_file "$TARGET_DIR/haproxy.cfg" "$HAPROXY_CFG" + +if [ "$firewall_changed" = 1 ] && ! /etc/init.d/firewall restart >/dev/null 2>&1; then + logger -t haproxy-manager "Firewall restart failed while restoring $TARGET" + state_failed=1 +fi + +haproxy_enabled=1 +haproxy_running=1 +if [ -f "$TARGET_DIR/haproxy.state" ]; then + haproxy_enabled="$(sed -n 's/^enabled=//p' "$TARGET_DIR/haproxy.state" | head -1)" + haproxy_running="$(sed -n 's/^running=//p' "$TARGET_DIR/haproxy.state" | head -1)" +fi + +if [ "$haproxy_enabled" = 1 ]; then + if ! /etc/init.d/haproxy enable >/dev/null 2>&1; then + logger -t haproxy-manager "Could not enable HAProxy while restoring $TARGET" + state_failed=1 + fi +else + if ! /etc/init.d/haproxy disable >/dev/null 2>&1; then + logger -t haproxy-manager "Could not disable HAProxy while restoring $TARGET" + state_failed=1 + fi +fi + +restore_services() { + local failed="$state_failed" + if [ "$uhttpd_changed" = 1 ] && ! /etc/init.d/uhttpd restart >/dev/null 2>&1; then + logger -t haproxy-manager "LuCI restart failed while restoring $TARGET" + failed=1 + fi + if [ "$haproxy_running" = 1 ]; then + if ! /etc/init.d/haproxy restart >/dev/null 2>&1 || + ! /etc/init.d/haproxy status >/dev/null 2>&1; then + logger -t haproxy-manager "HAProxy restart failed while restoring $TARGET" + failed=1 + fi + else + if ! /etc/init.d/haproxy stop >/dev/null 2>&1 || + /etc/init.d/haproxy status >/dev/null 2>&1; then + logger -t haproxy-manager "HAProxy stop failed while restoring $TARGET" + failed=1 + fi + fi + return "$failed" +} + +if [ "$SYNC" = 1 ]; then + restore_services || exit 1 +else + LOCK_HELD=0 + trap - EXIT + ( + sleep 2 + if ! restore_services; then + logger -t haproxy-manager "Service restoration failed for recovery point $TARGET" + fi + operation_lock_release "$LOCK_DIR" + ) >/dev/null 2>&1 & + operation_lock_set_pid "$LOCK_DIR" "$!" +fi + +echo "Restored backup $TARGET. Affected services will reload shortly." diff --git a/luci-app-haproxy-manager/root/usr/libexec/haproxy-manager/status b/luci-app-haproxy-manager/root/usr/libexec/haproxy-manager/status new file mode 100755 index 00000000..a9202ca8 --- /dev/null +++ b/luci-app-haproxy-manager/root/usr/libexec/haproxy-manager/status @@ -0,0 +1,56 @@ +#!/bin/sh +. /usr/libexec/haproxy-manager/common.sh + +enabled="$(uci_get main enabled 0)" +wan="$(wan_ip 2>/dev/null || echo unknown)" +version="not-available" +service="stopped" +backup_count=0 +auto_recovery="$(uci_get main auto_recover 1)" +mode="$(active_mode)" +last_incident="" +last_incident_result="none" +last_incident_interface="" + +if command -v haproxy >/dev/null 2>&1; then + version="$(haproxy -v 2>/dev/null | sed -n '1s/^HAProxy version \([^ ]*\).*/\1/p')" + [ -n "$version" ] || version="unknown" +fi + +if [ -x /etc/init.d/haproxy ] && /etc/init.d/haproxy status >/dev/null 2>&1; then + service="running" +fi + +backup_count="$(/usr/libexec/haproxy-manager/backups 2>/dev/null | wc -l | tr -d ' ')" + +INCIDENT_BASE="$(incident_dir)" +if [ -d "$INCIDENT_BASE" ]; then + INCIDENT_BASE="$(resolve_storage_dir "$INCIDENT_BASE")" || INCIDENT_BASE="" +fi +if [ -f "$INCIDENT_BASE/LAST" ]; then + last_incident="$(sed -n '1p' "$INCIDENT_BASE/LAST")" + if is_valid_backup_id "$last_incident" && [ -d "$INCIDENT_BASE/$last_incident" ]; then + last_incident_result="$(sed -n '1p' "$INCIDENT_BASE/$last_incident/result" 2>/dev/null || echo detected)" + last_incident_interface="$(sed -n 's/^interface=//p' "$INCIDENT_BASE/$last_incident/meta" 2>/dev/null | head -1)" + else + last_incident="" + last_incident_result="none" + fi +fi + +printf 'enabled\t%s\n' "$enabled" +printf 'wan_ip\t%s\n' "$wan" +printf 'version\t%s\n' "$version" +printf 'service\t%s\n' "$service" +printf 'backup_count\t%s\n' "$backup_count" +printf 'auto_recovery\t%s\n' "$auto_recovery" +printf 'active_mode\t%s\n' "$mode" +printf 'last_incident\t%s\n' "$last_incident" +printf 'last_incident_result\t%s\n' "$last_incident_result" +printf 'last_incident_interface\t%s\n' "$last_incident_interface" + +netstat -lntp 2>/dev/null | awk ' + NR > 2 && $6 == "LISTEN" && ($7 ~ /haproxy/ || $7 ~ /uhttpd/) { + printf "listener\t%s\t%s\t%s\n", $1, $4, $7 + } +' diff --git a/luci-app-haproxy-manager/root/usr/libexec/haproxy-manager/uninstall b/luci-app-haproxy-manager/root/usr/libexec/haproxy-manager/uninstall new file mode 100755 index 00000000..402dbec3 --- /dev/null +++ b/luci-app-haproxy-manager/root/usr/libexec/haproxy-manager/uninstall @@ -0,0 +1,70 @@ +#!/bin/sh +set -u +. /usr/libexec/haproxy-manager/common.sh + +LOCK_DIR=/var/lock/haproxy-manager.apply +LOCK_HELD=0 +UHTTPD_CHANGED=0 +MODE="$(active_mode)" +FAILED=0 + +attempt=0 +while ! operation_lock_acquire "$LOCK_DIR"; do + [ "$attempt" -lt 15 ] || { + logger -t haproxy-manager "Package removal could not acquire the operation lock" + exit 1 + } + attempt=$((attempt + 1)) + sleep 1 +done +LOCK_HELD=1 +trap '[ "$LOCK_HELD" != 1 ] || operation_lock_release "$LOCK_DIR"' EXIT + +/usr/libexec/haproxy-manager/backup >/dev/null 2>&1 || + logger -t haproxy-manager "Could not create a final recovery point before package removal" + +if ! restore_uhttpd_bindings; then + logger -t haproxy-manager "Could not restore the original LuCI listener bindings" + FAILED=1 +fi +if [ "$UHTTPD_CHANGED" = 1 ] && ! uci commit uhttpd; then + logger -t haproxy-manager "Could not commit the restored LuCI listener bindings" + FAILED=1 +fi + +if ! /usr/libexec/haproxy-manager/firewall-sync --disable >/dev/null 2>&1; then + logger -t haproxy-manager "Could not remove owned firewall state" + FAILED=1 +fi + +case "$MODE" in + generated|raw) + if ! /etc/init.d/haproxy stop >/dev/null 2>&1 || + /etc/init.d/haproxy status >/dev/null 2>&1; then + logger -t haproxy-manager "Could not stop HAProxy during package removal" + FAILED=1 + fi + if ! /etc/init.d/haproxy disable >/dev/null 2>&1; then + logger -t haproxy-manager "Could not disable HAProxy during package removal" + FAILED=1 + fi + ;; +esac + +if [ "$UHTTPD_CHANGED" = 1 ]; then + /etc/init.d/uhttpd restart >/dev/null 2>&1 || + { logger -t haproxy-manager "Could not restart LuCI after restoring its listeners"; FAILED=1; } +fi + +if [ "$FAILED" != 0 ]; then + logger -t haproxy-manager "Package runtime cleanup is incomplete; removal was stopped" + exit 1 +fi + +uci set "$CONFIG.main.active_mode=none" +uci commit "$CONFIG" + +LOCK_HELD=0 +operation_lock_release "$LOCK_DIR" +logger -t haproxy-manager "Removed package-owned HAProxy, firewall, and LuCI runtime state" +exit 0 diff --git a/luci-app-haproxy-manager/root/usr/libexec/haproxy-manager/validate b/luci-app-haproxy-manager/root/usr/libexec/haproxy-manager/validate new file mode 100755 index 00000000..3c84a386 --- /dev/null +++ b/luci-app-haproxy-manager/root/usr/libexec/haproxy-manager/validate @@ -0,0 +1,14 @@ +#!/bin/sh +set -eu +. /usr/libexec/haproxy-manager/common.sh + +CFG="${1:-/etc/haproxy.cfg}" +if [ "$CFG" != "$HAPROXY_CFG" ]; then + is_safe_temp_path "$CFG" || { echo "Refusing unsafe HAProxy config path: $CFG" >&2; exit 1; } +fi +if ! command -v haproxy >/dev/null 2>&1; then + echo "haproxy binary is not installed" >&2 + exit 1 +fi + +haproxy -c -f "$CFG" diff --git a/luci-app-haproxy-manager/root/usr/share/luci/menu.d/luci-app-haproxy-manager.json b/luci-app-haproxy-manager/root/usr/share/luci/menu.d/luci-app-haproxy-manager.json new file mode 100644 index 00000000..302160b3 --- /dev/null +++ b/luci-app-haproxy-manager/root/usr/share/luci/menu.d/luci-app-haproxy-manager.json @@ -0,0 +1,49 @@ +{ + "admin/services/haproxy-manager": { + "title": "HAProxy", + "i18n": "haproxy-manager", + "order": 60, + "action": { + "type": "firstchild" + }, + "depends": { + "acl": [ "luci-app-haproxy-manager" ] + } + }, + "admin/services/haproxy-manager/status": { + "title": "Status", + "i18n": "haproxy-manager", + "order": 20, + "action": { + "type": "view", + "path": "haproxy-manager/status" + } + }, + "admin/services/haproxy-manager/routes": { + "title": "Services", + "i18n": "haproxy-manager", + "order": 10, + "action": { + "type": "view", + "path": "haproxy-manager/routes" + } + }, + "admin/services/haproxy-manager/settings": { + "title": "Settings", + "i18n": "haproxy-manager", + "order": 30, + "action": { + "type": "view", + "path": "haproxy-manager/settings" + } + }, + "admin/services/haproxy-manager/raw": { + "title": "Raw Config", + "i18n": "haproxy-manager", + "order": 40, + "action": { + "type": "view", + "path": "haproxy-manager/raw" + } + } +} diff --git a/luci-app-haproxy-manager/root/usr/share/rpcd/acl.d/luci-app-haproxy-manager.json b/luci-app-haproxy-manager/root/usr/share/rpcd/acl.d/luci-app-haproxy-manager.json new file mode 100644 index 00000000..4a579e2a --- /dev/null +++ b/luci-app-haproxy-manager/root/usr/share/rpcd/acl.d/luci-app-haproxy-manager.json @@ -0,0 +1,36 @@ +{ + "luci-app-haproxy-manager": { + "description": "Grant access to LuCI HAProxy Manager", + "read": { + "uci": [ "haproxy_manager" ], + "file": { + "/etc/haproxy.cfg": [ "read" ], + "/tmp/haproxy-manager-*": [ "read" ] + }, + "ubus": { + "file": [ "read", "stat" ] + } + }, + "write": { + "uci": [ "haproxy_manager" ], + "file": { + "/usr/libexec/haproxy-manager/status": [ "exec" ], + "/usr/libexec/haproxy-manager/generate": [ "exec" ], + "/usr/libexec/haproxy-manager/apply": [ "exec" ], + "/usr/libexec/haproxy-manager/backup": [ "exec" ], + "/usr/libexec/haproxy-manager/backups": [ "exec" ], + "/usr/libexec/haproxy-manager/incidents": [ "exec" ], + "/usr/libexec/haproxy-manager/incident": [ "exec" ], + "/usr/libexec/haproxy-manager/firewall-plan": [ "exec" ], + "/usr/libexec/haproxy-manager/rollback": [ "exec" ], + "/usr/libexec/haproxy-manager/validate": [ "exec" ], + "/usr/libexec/haproxy-manager/apply-raw-file": [ "exec" ], + "/tmp/haproxy-manager-*": [ "write" ] + }, + "ubus": { + "file": [ "exec", "write" ], + "uci": [ "commit" ] + } + } + } +}