Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions luci.mk
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 37 additions & 0 deletions modules/luci-base/ucode/http.uc
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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(' ');
Expand Down
49 changes: 49 additions & 0 deletions modules/luci-base/ucode/luciplugins.uc
Original file line number Diff line number Diff line change
@@ -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;
}
};
Original file line number Diff line number Diff line change
@@ -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();
}
});
2 changes: 2 additions & 0 deletions modules/luci-mod-system/root/etc/config/luci_plugins
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@

config global 'global'
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
19 changes: 19 additions & 0 deletions plugins/plugins-example/Makefile
Original file line number Diff line number Diff line change
@@ -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

Original file line number Diff line number Diff line change
@@ -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);
}
});
Loading
Loading