From 338a6a440b456b162121ec8b97cc50445db18e95 Mon Sep 17 00:00:00 2001 From: Paul Donald Date: Wed, 4 Feb 2026 21:15:32 +0100 Subject: [PATCH 1/2] luci-mod-system: implement plugin UI architecture include some example plugins also. JS files provide UI to configure behaviour of plugins which typically live in /usr/share/ucode/luci/plugins// Signed-off-by: Paul Donald --- luci.mk | 5 +- .../resources/view/system/plugins.js | 148 ++++++++++++++++++ .../root/etc/config/luci_plugins | 2 + .../share/luci/menu.d/luci-mod-system.json | 12 ++ .../usr/share/rpcd/acl.d/luci-mod-system.json | 9 ++ plugins/plugins-example/Makefile | 19 +++ .../0aef1fa8f9a045bdaf51a35ce99eb5c5.js | 44 ++++++ .../263fe72d7e834fa99a82639ed0d9e3bd.js | 44 ++++++ .../3ed2ee077c4941f8ab394106fd95ad9d.js | 44 ++++++ .../6c4b5551b62b4bc8a3053fb519d71d5f.js | 44 ++++++ .../0aef1fa8f9a045bdaf51a35ce99eb5c5.uc | 31 ++++ .../263fe72d7e834fa99a82639ed0d9e3bd.uc | 31 ++++ 12 files changed, 431 insertions(+), 2 deletions(-) create mode 100644 modules/luci-mod-system/htdocs/luci-static/resources/view/system/plugins.js create mode 100644 modules/luci-mod-system/root/etc/config/luci_plugins create mode 100644 plugins/plugins-example/Makefile create mode 100644 plugins/plugins-example/htdocs/luci-static/resources/view/plugins/0aef1fa8f9a045bdaf51a35ce99eb5c5.js create mode 100644 plugins/plugins-example/htdocs/luci-static/resources/view/plugins/263fe72d7e834fa99a82639ed0d9e3bd.js create mode 100644 plugins/plugins-example/htdocs/luci-static/resources/view/plugins/3ed2ee077c4941f8ab394106fd95ad9d.js create mode 100644 plugins/plugins-example/htdocs/luci-static/resources/view/plugins/6c4b5551b62b4bc8a3053fb519d71d5f.js create mode 100644 plugins/plugins-example/ucode/plugins/http/headers/0aef1fa8f9a045bdaf51a35ce99eb5c5.uc create mode 100644 plugins/plugins-example/ucode/plugins/http/headers/263fe72d7e834fa99a82639ed0d9e3bd.uc diff --git a/luci.mk b/luci.mk index 27e5302cbad5..27f9a8bf5567 100644 --- a/luci.mk +++ b/luci.mk @@ -65,8 +65,9 @@ LUCI_MENU.col=1. Collections LUCI_MENU.mod=2. Modules LUCI_MENU.app=3. Applications LUCI_MENU.theme=4. Themes -LUCI_MENU.proto=5. Protocols -LUCI_MENU.lib=6. Libraries +LUCI_MENU.plugin=5. Plugins +LUCI_MENU.proto=6. Protocols +LUCI_MENU.lib=7. Libraries # Language aliases LUCI_LC_ALIAS.bn_BD=bn diff --git a/modules/luci-mod-system/htdocs/luci-static/resources/view/system/plugins.js b/modules/luci-mod-system/htdocs/luci-static/resources/view/system/plugins.js new file mode 100644 index 000000000000..424b3d79c425 --- /dev/null +++ b/modules/luci-mod-system/htdocs/luci-static/resources/view/system/plugins.js @@ -0,0 +1,148 @@ +'use strict'; +'require dom'; +'require form'; +'require fs'; +'require uci'; +'require view'; + +// const plugins_path = '/usr/share/ucode/luci/plugins'; +const view_plugins = `/www/${L.resource('view/plugins')}`; + +const luci_plugins = 'luci_plugins'; + +return view.extend({ + load() { + return Promise.all([ + L.resolveDefault(fs.list(`/www/${L.resource('view/plugins')}`), []).then((entries) => { + return Promise.all(entries.filter((e) => { + return (e.type == 'file' && e.name.match(/\.js$/)); + }).map((e) => { + return 'view.plugins.' + e.name.replace(/\.js$/, ''); + }).sort().map((n) => { + return L.require(n); + })); + }), + uci.load(luci_plugins), + ]) + }, + + render([plugins]) { + let m, s, o, p_enabled; + const groups = new Set(); + + // Set global uci config if absent + if (!uci.get(luci_plugins, 'global')) { + uci.add(luci_plugins, 'global', 'global'); + } + + for (let plugin of plugins) { + const name = plugin.id; + const class_type = `${plugin.class}_${plugin.type}`; + const class_type_i18n = `${plugin.class_i18n} ${plugin.type_i18n}` + groups.add(class_type); + groups[class_type] = class_type_i18n; + plugins[plugin.id] = plugin; + + // Set basic uci config for each plugin if absent + if (!uci.get(luci_plugins, plugin.id)) { + // add the plugin via its uuid under its class+type for filtering + uci.add(luci_plugins, class_type, plugin.id); + uci.set(luci_plugins, plugin.id, 'name', plugin.name); + } + } + + m = new form.Map(luci_plugins, _('Plugins')); + m.tabbed = true; + + s = m.section(form.NamedSection, 'global', 'global', _('Global Settings')); + + o = s.option(form.Flag, 'enabled', _('Enabled')); + o.default = o.disabled; + o.optional = true; + + for (const group of new Set([...groups].sort())) { + o = s.option(form.Flag, group + '_enabled', groups[group] + ' ' + _('Enabled')); + o.default = o.disabled; + o.optional = true; + } + + for (const group of new Set([...groups].sort())) { + + s = m.section(form.GridSection, group, groups[group]); + + s.sectiontitle = function(section_id) { + const plugin = plugins[section_id]; + + return plugin.title; + }; + + p_enabled = s.option(form.Flag, 'enabled', _('Enabled')); + p_enabled.editable = true; + p_enabled.modalonly = false; + p_enabled.renderWidget = function(section_id, option_index, cfgvalue) { + const widget = form.Flag.prototype.renderWidget.apply(this, [section_id, option_index, cfgvalue]); + + widget.querySelector('input[type="checkbox"]').addEventListener('click', L.bind(function(section_id, plugin, ev) { + if (ev.target.checked && plugin && plugin.addFormOptions) + this.section.renderMoreOptionsModal(section_id); + }, this, section_id, plugins[section_id])); + + return widget; + }; + + o = s.option(form.DummyValue, '_dummy', _('Status')); + o.width = '50%'; + o.modalonly = false; + o.textvalue = function(section_id) { + const section = uci.get(luci_plugins, section_id); + const plugin = plugins[section_id]; + + if (section.enabled != '1') + return E('em', {}, [_('Plugin is disabled')]); + + const summary = plugin ? plugin.configSummary(section) : null; + return summary || E('em', _('none')); + }; + + s.modaltitle = function(section_id) { + const plugin = plugins[section_id]; + + return plugin ? plugin.title : null; + }; + + s.addModalOptions = function(s) { + const name = s.section; + const plugin = plugins[name]; + + if (!plugin) + return; + + s.description = plugin.description; + + plugin.addFormOptions(s); + + const opt = s.children.filter(function(o) { return o.option == 'enabled' })[0]; + if (opt) + opt.cfgvalue = function(section_id, set_value) { + if (arguments.length == 2) + return form.Flag.prototype.cfgvalue.apply(this, [section_id, p_enabled.formvalue(section_id)]); + else + return form.Flag.prototype.cfgvalue.apply(this, [section_id]); + }; + }; + + s.renderRowActions = function(section_id) { + const plugin = plugins[section_id]; + + const trEl = this.super('renderRowActions', [ section_id, _('Configure…') ]); + + if (!plugin || !plugin.addFormOptions) + dom.content(trEl, null); + + return trEl; + }; + } + + return m.render(); + } +}); diff --git a/modules/luci-mod-system/root/etc/config/luci_plugins b/modules/luci-mod-system/root/etc/config/luci_plugins new file mode 100644 index 000000000000..2271b8eb55b3 --- /dev/null +++ b/modules/luci-mod-system/root/etc/config/luci_plugins @@ -0,0 +1,2 @@ + +config global 'global' diff --git a/modules/luci-mod-system/root/usr/share/luci/menu.d/luci-mod-system.json b/modules/luci-mod-system/root/usr/share/luci/menu.d/luci-mod-system.json index 0822d44845c6..488a36b83555 100644 --- a/modules/luci-mod-system/root/usr/share/luci/menu.d/luci-mod-system.json +++ b/modules/luci-mod-system/root/usr/share/luci/menu.d/luci-mod-system.json @@ -85,6 +85,18 @@ } }, + "admin/system/plugins": { + "title": "Plugins", + "order": 3, + "action": { + "type": "view", + "path": "system/plugins" + }, + "depends": { + "acl": [ "luci-mod-system-plugins" ] + } + }, + "admin/system/startup": { "title": "Startup", "order": 45, diff --git a/modules/luci-mod-system/root/usr/share/rpcd/acl.d/luci-mod-system.json b/modules/luci-mod-system/root/usr/share/rpcd/acl.d/luci-mod-system.json index 379d89fc6fcb..45c92b1747dd 100644 --- a/modules/luci-mod-system/root/usr/share/rpcd/acl.d/luci-mod-system.json +++ b/modules/luci-mod-system/root/usr/share/rpcd/acl.d/luci-mod-system.json @@ -56,6 +56,15 @@ } }, + "luci-mod-system-plugins": { + "description": "Grant access to Plugin management", + "read": { + "file": { + "/usr/share/ucode/luci/*": [ "read" ] + } + } + }, + "luci-mod-system-uhttpd": { "description": "Grant access to uHTTPd configuration", "read": { diff --git a/plugins/plugins-example/Makefile b/plugins/plugins-example/Makefile new file mode 100644 index 000000000000..1cb955585570 --- /dev/null +++ b/plugins/plugins-example/Makefile @@ -0,0 +1,19 @@ +# +# Copyright (C) 2026 +# +# SPDX-License-Identifier: Apache-2.0 +# + +include $(TOPDIR)/rules.mk + +LUCI_TITLE:=LuCI Plugins - HTTP Headers examples and HTTP 2FA UI example +LUCI_DEPENDS:=+luci-base +luci-mod-system + +LUCI_TYPE:=plugin + +PKG_LICENSE:=Apache-2.0 + +include ../../luci.mk + +# call BuildPackage - OpenWrt buildroot signature + diff --git a/plugins/plugins-example/htdocs/luci-static/resources/view/plugins/0aef1fa8f9a045bdaf51a35ce99eb5c5.js b/plugins/plugins-example/htdocs/luci-static/resources/view/plugins/0aef1fa8f9a045bdaf51a35ce99eb5c5.js new file mode 100644 index 000000000000..3f5cbfd4eb09 --- /dev/null +++ b/plugins/plugins-example/htdocs/luci-static/resources/view/plugins/0aef1fa8f9a045bdaf51a35ce99eb5c5.js @@ -0,0 +1,44 @@ +'use strict'; +'require baseclass'; +'require form'; + +/* +class, type, name and id are used to build a reference for the uci config. E.g. + +config http_headers '0aef1fa8f9a045bdaf51a35ce99eb5c5' + option name 'X-Foobar' + ... + +*/ + +return baseclass.extend({ + + class: 'http', + class_i18n: _('HTTP'), + + type: 'headers', + type_i18n: _('Headers'), + + name: 'X-Foobar', // to make visual ID in UCI config easy + id: '0aef1fa8f9a045bdaf51a35ce99eb5c5', // cat /proc/sys/kernel/random/uuid | tr -d - + title: _('X-Foobar Example Plugin'), + description: _('This plugin sets an X-Foobar HTTP header.'), + + addFormOptions(s) { + let o; + + o = s.option(form.Flag, 'enabled', _('Enabled')); + + o = s.option(form.Value, 'foo', _('Foo')); + o.default = 'foo'; + o.depends('enabled', '1'); + + o = s.option(form.Value, 'bar', _('Bar')); + o.default = '4000'; + o.depends('enabled', '1'); + }, + + configSummary(section) { + return _('I am class %s, type %s, name %s, bar: %d').format(this.class_i18n, this.type_i18n, this.name, section.bar || 1000); + } +}); diff --git a/plugins/plugins-example/htdocs/luci-static/resources/view/plugins/263fe72d7e834fa99a82639ed0d9e3bd.js b/plugins/plugins-example/htdocs/luci-static/resources/view/plugins/263fe72d7e834fa99a82639ed0d9e3bd.js new file mode 100644 index 000000000000..81829ef7b880 --- /dev/null +++ b/plugins/plugins-example/htdocs/luci-static/resources/view/plugins/263fe72d7e834fa99a82639ed0d9e3bd.js @@ -0,0 +1,44 @@ +'use strict'; +'require baseclass'; +'require form'; + +/* +class, type, name and id are used to build a reference for the uci config. E.g. + +config http_headers '263fe72d7e834fa99a82639ed0d9e3bd' + option name 'X-Example' + ... + +*/ + +return baseclass.extend({ + + class: 'http', + class_i18n: _('HTTP'), + + type: 'headers', + type_i18n: _('Headers'), + + name: 'X-Example', // to make visual ID in UCI config easy + id: '263fe72d7e834fa99a82639ed0d9e3bd', // cat /proc/sys/kernel/random/uuid | tr -d - + title: _('X-Example Example Plugin'), + description: _('This plugin sets an X-Example HTTP header.'), + + addFormOptions(s) { + let o; + + o = s.option(form.Flag, 'enabled', _('Enabled')); + + o = s.option(form.Value, 'foo', _('Foo')); + o.default = 'foo'; + o.depends('enabled', '1'); + + o = s.option(form.Value, 'bar', _('Bar')); + o.default = '3000'; + o.depends('enabled', '1'); + }, + + configSummary(section) { + return _('I am class %s, type %s, name %s, bar: %d').format(this.class_i18n, this.type_i18n, this.name, section.bar || 1000); + } +}); diff --git a/plugins/plugins-example/htdocs/luci-static/resources/view/plugins/3ed2ee077c4941f8ab394106fd95ad9d.js b/plugins/plugins-example/htdocs/luci-static/resources/view/plugins/3ed2ee077c4941f8ab394106fd95ad9d.js new file mode 100644 index 000000000000..300c37466d3f --- /dev/null +++ b/plugins/plugins-example/htdocs/luci-static/resources/view/plugins/3ed2ee077c4941f8ab394106fd95ad9d.js @@ -0,0 +1,44 @@ +'use strict'; +'require baseclass'; +'require form'; + +/* +class, type, name and id are used to build a reference for the uci config. E.g. + +config foo_bar '3ed2ee077c4941f8ab394106fd95ad9d' + option name 'Chonki Boi' + ... + +*/ + +return baseclass.extend({ + + class: 'foo', + class_i18n: _('FOO'), + + type: 'bar', + type_i18n: _('Bar'), + + name: 'Chonki Boi', // to make visual ID in UCI config easy + id: '3ed2ee077c4941f8ab394106fd95ad9d', // cat /proc/sys/kernel/random/uuid | tr -d - + title: _('Chonki Boi Example Plugin'), + description: _('This plugin does nothing. It is just a UI example.'), + + addFormOptions(s) { + let o; + + o = s.option(form.Flag, 'enabled', _('Enabled')); + + o = s.option(form.Value, 'foo', _('Foo')); + o.default = 'chonkk value'; + o.depends('enabled', '1'); + + o = s.option(form.Value, 'bar', _('Bar')); + o.default = '1000'; + o.depends('enabled', '1'); + }, + + configSummary(section) { + return _('I am class %s, type %s, name %s, bar: %d').format(this.class_i18n, this.type_i18n, this.name, section.bar || 1000); + } +}); diff --git a/plugins/plugins-example/htdocs/luci-static/resources/view/plugins/6c4b5551b62b4bc8a3053fb519d71d5f.js b/plugins/plugins-example/htdocs/luci-static/resources/view/plugins/6c4b5551b62b4bc8a3053fb519d71d5f.js new file mode 100644 index 000000000000..832395ca1bd0 --- /dev/null +++ b/plugins/plugins-example/htdocs/luci-static/resources/view/plugins/6c4b5551b62b4bc8a3053fb519d71d5f.js @@ -0,0 +1,44 @@ +'use strict'; +'require baseclass'; +'require form'; + +/* +class, type, name and id are used to build a reference for the uci config. E.g. + +config http_auth '6c4b5551b62b4bc8a3053fb519d71d5f' + option name '2FA' + ... + +*/ + +return baseclass.extend({ + + class: 'http', + class_i18n: _('HTTP'), + + type: 'auth', + type_i18n: _('Auth'), + + name: '2FA', // to make visual ID in UCI config easy + id: '6c4b5551b62b4bc8a3053fb519d71d5f', // cat /proc/sys/kernel/random/uuid | tr -d - + title: _('2FA Example Plugin'), + description: _('This plugin does nothing. It is just a UI example.'), + + addFormOptions(s) { + let o; + + o = s.option(form.Flag, 'enabled', _('Enabled')); + + o = s.option(form.Value, 'foo', _('Foo')); + o.default = '2FA value'; + o.depends('enabled', '1'); + + o = s.option(form.Value, 'bar', _('Bar')); + o.default = '2000'; + o.depends('enabled', '1'); + }, + + configSummary(section) { + return _('I am class %s, type %s, name %s, bar: %d').format(this.class_i18n, this.type_i18n, this.name, section.bar || 1000); + } +}); diff --git a/plugins/plugins-example/ucode/plugins/http/headers/0aef1fa8f9a045bdaf51a35ce99eb5c5.uc b/plugins/plugins-example/ucode/plugins/http/headers/0aef1fa8f9a045bdaf51a35ce99eb5c5.uc new file mode 100644 index 000000000000..24aed34d107c --- /dev/null +++ b/plugins/plugins-example/ucode/plugins/http/headers/0aef1fa8f9a045bdaf51a35ce99eb5c5.uc @@ -0,0 +1,31 @@ +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 + +/* +The plugin filename shall be the 32 character uuid in its JS config front-end. +This allows parsing plugins against user-defined configuration. User retains +all control over whether a plugin is active or not. +*/ + +'use strict'; + +import { cursor } from 'uci'; + +/* +The ucode plugin portion shall return a default action which returns a value +and type of value appropriate for its usage class and type. For http.headers, +it shall return a string array[] with header_name, header_value, without any +\r or \n. +*/ + +function default_action(...args) { + const uci = cursor(); + const str = uci.get('luci_plugins', args[0], 'bar') || '4000'; + const value = sprintf('%s; %s', str, ...args); + // do stuff + // should produce: x-foobar: 4000; 0aef1fa8f9a045bdaf51a35ce99eb5c5 + return ['X-Foobar', value]; +}; + + +return default_action; diff --git a/plugins/plugins-example/ucode/plugins/http/headers/263fe72d7e834fa99a82639ed0d9e3bd.uc b/plugins/plugins-example/ucode/plugins/http/headers/263fe72d7e834fa99a82639ed0d9e3bd.uc new file mode 100644 index 000000000000..06cfb44e4a5e --- /dev/null +++ b/plugins/plugins-example/ucode/plugins/http/headers/263fe72d7e834fa99a82639ed0d9e3bd.uc @@ -0,0 +1,31 @@ +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 + +/* +The plugin filename shall be the 32 character uuid in its JS config front-end. +This allows parsing plugins against user-defined configuration. User retains +all control over whether a plugin is active or not. +*/ + +'use strict'; + +import { cursor } from 'uci'; + +/* +The ucode plugin portion shall return a default action which returns a value +and type of value appropriate for its usage class and type. For http.headers, +it shall return a string array[] with header_name, header_value, without any +\r or \n. +*/ + +function default_action(...args) { + const uci = cursor(); + const str = uci.get('luci_plugins', args[0], 'foo') || 'foo'; + const value = sprintf('%s; %s', str, ...args); + // do stuff + // should produce: x-example: foo; 263fe72d7e834fa99a82639ed0d9e3bd + return ['X-Example', value]; +}; + + +return default_action; From a0af8b1e50816a07c7d2fe0dcd746a3ee8d94889 Mon Sep 17 00:00:00 2001 From: Paul Donald Date: Sun, 15 Mar 2026 19:18:33 +0100 Subject: [PATCH 2/2] luci-base: implement http header plugins This implements the injection of custom http headers via the new plugin architecture. Signed-off-by: Paul Donald --- modules/luci-base/ucode/http.uc | 37 +++++++++++++++++++ modules/luci-base/ucode/luciplugins.uc | 49 ++++++++++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 modules/luci-base/ucode/luciplugins.uc diff --git a/modules/luci-base/ucode/http.uc b/modules/luci-base/ucode/http.uc index e7f64ae6e9fe..350bcc5eefc3 100644 --- a/modules/luci-base/ucode/http.uc +++ b/modules/luci-base/ucode/http.uc @@ -13,6 +13,12 @@ import { stdin, stdout, mkstemp } from 'fs'; +import { + openlog, syslog, closelog, LOG_NOTICE, LOG_LOCAL0 +} from 'log'; + +import { run_plugins } from 'luciplugins'; + // luci.http module scope export let HTTP_MAX_CONTENT = 1024*100; // 100 kB maximum content size @@ -504,6 +510,37 @@ const Class = { if (!this.headers?.['x-content-type-options']) this.header('X-Content-Type-Options', 'nosniff'); + /* http header plugins */ + let log_class = 'http.uc'; + openlog(log_class); + for (let plugin_id, p_output in run_plugins('/luci/plugins/http/headers', 'http_headers_enabled')) { + + /* header plugins shall return e.g.: ['X-Header', 'foo'] */ + if (type(p_output) !== 'array' || length(p_output) !== 2) + continue; + + if (type(p_output[0]) !== 'string' || type(p_output[1]) !== 'string') + continue; + + if (!match(p_output[0], /^[A-Za-z0-9-]+$/)) { + syslog(LOG_NOTICE|LOG_LOCAL0, + sprintf("Invalid header name from plugin %s output: %s", plugin_id, p_output[0])); + continue; + } + + /* header plugin values shall not contain line-feeds */ + if (match(p_output[1], /[\r\n]/)) { + syslog(LOG_NOTICE|LOG_LOCAL0, + sprintf("\\r and/or \\n in plugin %s output", plugin_id)); + continue; + } + + if(!this.headers?.[p_output[0]]) + this.header(p_output[0], p_output[1]); + + } + closelog(); + this.output('Status: '); this.output(this.status_code); this.output(' '); diff --git a/modules/luci-base/ucode/luciplugins.uc b/modules/luci-base/ucode/luciplugins.uc new file mode 100644 index 000000000000..364c839b0425 --- /dev/null +++ b/modules/luci-base/ucode/luciplugins.uc @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { + lsdir +} from 'fs'; + +import { + syslog, LOG_NOTICE, LOG_LOCAL0 +} from 'log'; + +import { cursor } from 'uci'; + + +/* generic plugin handler */ +export function run_plugins(plugin_class_path, plugin_class_enable) { + let uci = cursor(); + const require_path = replace(plugin_class_path, '/', '.'); + + if (uci.get('luci_plugins', 'global', 'enabled') == 1 && + uci.get('luci_plugins', 'global', plugin_class_enable) == 1) { + const PLUGINS_PATH = '/usr/share/ucode' + plugin_class_path; + const results = {}; + + for (let fn in lsdir(PLUGINS_PATH)) { + const plugin_id = replace(fn, /.uc$/, ''); + /* plugins shall have a <32_char_UUID_no_hyphens>.uc filename */ + if (!match(plugin_id, /^[a-f0-9]+$/) || length(plugin_id) !== 32) { + syslog(LOG_NOTICE|LOG_LOCAL0, + sprintf("Invalid plugin name: %s", plugin_id)); + continue; + } + + if (uci.get('luci_plugins', plugin_id, 'enabled')) { + const mod = require(require_path + `.${plugin_id}`); + if (type(mod) === 'function') { + try { + results[plugin_id] = mod(plugin_id); + } catch (e) { + syslog(LOG_NOTICE|LOG_LOCAL0, + sprintf("Could not execute plugin %s: %s", + join('/', [PLUGINS_PATH, plugin_id]), e)); + }; + } + } + } + + return results; + } +};