Files
op-packages/luci-app-sysctl/root/usr/share/rpcd/ucode/luci.sysctl
T

1125 lines
29 KiB
Plaintext

// SPDX-License-Identifier: Apache-2.0
//
// luci.sysctl - rpcd ucode plugin (rpcd-mod-ucode)
//
// Provides the ubus object "luci.sysctl" used by the LuCI application
// "luci-app-sysctl" to view and manage kernel sysctl parameters.
//
// rpcd loads every regular file below /usr/share/rpcd/ucode/, executes it
// with an embedded ucode VM and uses the value returned at the top level
// as the ubus object signature:
//
// { "object-name": { "method-name": { "args": {...}, "call": fn } } }
//
// Method callbacks receive a request object exposing "request.args" (the
// ubus call arguments) and "request.info" (caller/ACL information). The
// plain object returned by the callback is sent back as ubus reply.
//
// Methods:
// status() -> list of config files, sanity info
// list() -> custom + /etc/sysctl.conf entries
// browse(prefix) -> children of /proc/sys subtree
// search(query, limit) -> substring search over all params
// set(key, value, disabled, apply) -> upsert entry in 99-luci-sysctl.conf
// remove(key) -> delete entry from 99-luci-sysctl.conf
// apply() -> run "sysctl -e -p" over all confs
//
// Online preset (wholesale-managed /etc/sysctl.d/98-online-preset.conf):
// preset_status() -> imported preset state + entries
// preset_fetch(url, prefix) -> download + parse + conflict check
// preset_import(url, prefix) -> download + write 98-online-preset.conf
// preset_check() -> re-download and diff against current
// preset_remove() -> remove the preset file
// preset_list(url, prefix) -> list .conf files in a github.com
// directory ("tree") URL
//
// Per-file viewing/editing of user-visible config sources:
// file_view(path) -> entries of one config file
// file_set(path, key, value, dis) -> rewrite a single line in place
// file_delete(path, key) -> remove matching lines
//
// Editable paths: /etc/sysctl.conf and /etc/sysctl.d/*.conf, EXCEPT the two
// plugin-managed files (98-online-preset.conf, 99-luci-sysctl.conf) which
// must be managed through their dedicated UI sections.
'use strict';
import { access, lsdir, popen, readfile, stat, unlink, writefile } from 'fs';
const MAIN_CONF = '/etc/sysctl.conf';
const SYSCTL_D = '/etc/sysctl.d';
const CUSTOM_CONF = SYSCTL_D + '/99-luci-sysctl.conf';
const PRESET_CONF = SYSCTL_D + '/98-online-preset.conf';
const PROC_SYS = '/proc/sys';
const SYSCTL_BIN = '/sbin/sysctl';
// valid sysctl key: components of [A-Za-z0-9_-], joined by dots ("/" allowed
// inside components as an alternative separator, as accepted by busybox sysctl)
const KEY_RE = /^[A-Za-z0-9_][A-Za-z0-9_.\/-]*$/;
function trim(s) {
if (s == null)
return s;
return replace(replace(s, /^[ \t\r\n]+/, ''), /[ \t\r\n]+$/, '');
}
// fs.access() returns true when the path exists and null otherwise;
// coerce the result to a real boolean for API responses
function path_exists(path) {
return (access(path) != null);
}
function shquote(s) {
return "'" + replace(s, /'/g, "'\\''") + "'";
}
// Map "net.ipv4.ip_forward" -> "/proc/sys/net/ipv4/ip_forward"
// (note: ucode's join() takes the separator first)
function proc_file(key) {
return PROC_SYS + '/' + join('/', split(key, '.'));
}
function read_current(key) {
let v = readfile(proc_file(key));
return (v == null) ? null : trim(v);
}
// Parse a sysctl configuration file into an array of entries.
// A line of the form "# key = value" is treated as a disabled entry,
// plain comments ("# ...") are skipped.
function parse_conf(path) {
let entries = [];
let data = readfile(path);
if (data == null || length(data) == 0)
return entries;
let lines = split(data, '\n');
for (let i = 0; i < length(lines); i++) {
let line = trim(lines[i]);
let disabled = false;
if (line == null || length(line) == 0)
continue;
if (substr(line, 0, 1) == '#') {
let stripped = trim(substr(line, 1));
// only treat commented-out assignments as disabled entries
if (match(stripped, /^([A-Za-z0-9_][A-Za-z0-9_.\/-]*)[ \t]*=/) == null)
continue;
disabled = true;
line = stripped;
}
let m = match(line, /^([A-Za-z0-9_][A-Za-z0-9_.\/-]*)[ \t]*=[ \t]*(.*)$/);
if (m == null)
continue;
push(entries, {
key: m[1],
value: (m[2] != null) ? trim(m[2]) : '',
line: i + 1,
disabled: disabled
});
}
return entries;
}
function render_conf(entries) {
let buf = '# Custom kernel parameters managed by luci-app-sysctl.\n' +
'# A line prefixed with "#" means the entry is disabled.\n\n';
for (let i = 0; i < length(entries); i++) {
let e = entries[i];
buf += (e.disabled ? '# ' : '') + e.key + ' = ' + e.value + '\n';
}
return buf;
}
// Merge live /proc/sys values into parsed entries
function decorate(entries) {
let out = [];
for (let i = 0; i < length(entries); i++) {
let e = entries[i];
let cur = read_current(e.key);
push(out, {
key: e.key,
value: e.value,
line: e.line,
disabled: e.disabled,
exists: path_exists(proc_file(e.key)),
current: (e.disabled || cur == null) ? null : cur,
match: (e.disabled || cur == null) ? null : (cur == e.value)
});
}
return out;
}
// Config files in application order: /etc/sysctl.conf first,
// then /etc/sysctl.d/*.conf in lexicographic order.
function collect_confs() {
let paths = [];
if (access(MAIN_CONF))
push(paths, MAIN_CONF);
if (access(SYSCTL_D)) {
let names = sort(lsdir(SYSCTL_D) || []);
for (let i = 0; i < length(names); i++)
if (match(names[i], /\.conf$/) != null)
push(paths, SYSCTL_D + '/' + names[i]);
}
return paths;
}
// Recursively read all parameters below `dir` into `out` (key -> value)
function walk(dir, prefix, out) {
let names = lsdir(dir);
if (names == null)
return;
names = sort(names);
for (let i = 0; i < length(names); i++) {
let name = names[i];
let full = dir + '/' + name;
let st = stat(full);
if (st == null)
continue;
if (st.type == 'directory') {
walk(full, prefix + name + '.', out);
}
else {
let v = readfile(full);
out[prefix + name] = (v == null) ? '' : trim(v);
}
}
}
// Write a value directly to /proc/sys, returning a status string on failure
// or a boolean on success.
function write_proc(key, value) {
let p = proc_file(key);
if (access(p) == null)
return 'missing';
if (writefile(p, value + '\n') == null)
return 'readonly';
return (read_current(key) == value);
}
// ------------------------- online preset helpers ---------------------------
// Normalize a user-supplied URL: strip whitespace and convert a github.com
// blob page URL to its raw.githubusercontent.com equivalent. Returns null
// when the input is not a usable http(s) URL.
function norm_url(url) {
url = trim(url);
if (url == null || length(url) == 0)
return null;
if (substr(url, 0, 8) != 'https://' && substr(url, 0, 7) != 'http://')
return null;
let m = match(url, /^https:\/\/github\.com\/([^\/]+)\/([^\/]+)\/blob\/(.+)$/);
if (m != null)
url = 'https://raw.githubusercontent.com/' + m[1] + '/' + m[2] + '/' + m[3];
return url;
}
// Prepend an optional mirror prefix (e.g. "gh-proxy.org"); the scheme is
// auto-completed and a trailing slash is ensured.
function apply_prefix(url, prefix) {
prefix = trim(prefix);
if (prefix == null || length(prefix) == 0)
return url;
if (substr(prefix, 0, 8) != 'https://' && substr(prefix, 0, 7) != 'http://')
prefix = 'https://' + prefix;
if (substr(prefix, length(prefix) - 1, 1) != '/')
prefix += '/';
return prefix + url;
}
// Download a URL using whichever fetcher is available on the target system.
// Tries curl, then wget (busybox/uclient), then uclient-fetch. Returns the
// response body or null when all attempts fail.
function http_get(url) {
let cmds = [
'curl -fsSL --max-time 15 ' + shquote(url) + ' 2>/dev/null',
'wget -q -T 15 -O - ' + shquote(url) + ' 2>/dev/null',
'uclient-fetch -q -T 15 -O - ' + shquote(url) + ' 2>/dev/null'
];
for (let i = 0; i < length(cmds); i++) {
let p = popen(cmds[i], 'r');
let out = (p != null) ? p.read('all') : null;
let rc = (p != null) ? p.close() : -1;
if (rc == 0 && out != null && length(out) > 0)
return out;
}
return null;
}
// Parse downloaded preset content: comments/blank lines skipped, invalid
// lines collected for reporting. Returns { params, invalid }.
function parse_preset_content(data) {
let res = { params: [], invalid: [] };
if (data == null)
return res;
let stripped = trim(data);
// guard against HTML error pages
if (length(stripped) == 0 || substr(stripped, 0, 1) == '<')
return res;
let lines = split(data, '\n');
for (let i = 0; i < length(lines); i++) {
let line = trim(lines[i]);
if (line == null || length(line) == 0 || substr(line, 0, 1) == '#')
continue;
let m = match(line, /^([A-Za-z0-9_][A-Za-z0-9_.\/-]*)[ \t]*=[ \t]*(.*)$/);
if (m == null) {
push(res.invalid, line);
continue;
}
push(res.params, { key: m[1], value: (m[2] != null) ? trim(m[2]) : '' });
}
return res;
}
// Index of keys already defined elsewhere (used for conflict reporting);
// the preset file itself is excluded.
function existing_keys() {
let idx = {};
let confs = collect_confs();
for (let i = 0; i < length(confs); i++) {
let p = confs[i];
if (p == PRESET_CONF)
continue;
let entries = parse_conf(p);
for (let j = 0; j < length(entries); j++)
if (!entries[j].disabled && idx[entries[j].key] == null)
idx[entries[j].key] = p;
}
return idx;
}
// Read preset metadata recorded in the file header comments.
function preset_meta() {
let data = readfile(PRESET_CONF);
if (data == null)
return { present: false };
let source = null;
let fetched = null;
let lines = split(data, '\n');
for (let i = 0; i < length(lines); i++) {
let line = trim(lines[i]);
if (line == null)
continue;
if (substr(line, 0, 10) == '# source: ')
source = trim(substr(line, 10));
else if (substr(line, 0, 11) == '# fetched: ')
fetched = trim(substr(line, 11));
}
let entries = parse_conf(PRESET_CONF);
let count = 0;
for (let i = 0; i < length(entries); i++)
if (!entries[i].disabled)
count++;
return { present: true, count: count, source: source, fetched: fetched };
}
function render_preset(url, params) {
let buf = '# Online preset managed by luci-app-sysctl\n' +
'# source: ' + url + '\n' +
'# fetched: ' + time() + '\n' +
'# This file is replaced wholesale on update. To override a\n' +
'# single entry, add it to 99-luci-sysctl.conf instead.\n\n';
for (let i = 0; i < length(params); i++)
buf += params[i].key + ' = ' + params[i].value + '\n';
return buf;
}
// Download + parse + conflict report, shared by fetch/import.
function preset_download(url, prefix) {
let norm = norm_url(url);
if (norm == null)
return { code: 1, error: 'Invalid URL, expected an http(s) URL' };
let final_url = apply_prefix(norm, prefix);
let data = http_get(final_url);
if (data == null)
return { code: 2, error: 'Download failed: ' + final_url, url: final_url };
let parsed = parse_preset_content(data);
if (length(parsed.params) == 0)
return { code: 3, error: 'No valid sysctl entries found at ' + final_url, url: final_url, invalid: parsed.invalid };
let idx = existing_keys();
let conflicts = [];
for (let i = 0; i < length(parsed.params); i++) {
let k = parsed.params[i].key;
if (idx[k] != null)
push(conflicts, { key: k, file: idx[k] });
}
return { code: 0, url: final_url, parsed: parsed, conflicts: conflicts };
}
// ----------------------- per-file viewing/editing ---------------------------
// Recognize a github.com directory ("tree") URL and convert it to the
// matching GitHub contents API endpoint. Returns null when the URL is not
// a tree URL (e.g. a plain file URL).
// https://github.com/u/r/tree/main/dir -> .../contents/dir?ref=main
// (note: ucode's regex engine has no non-capturing groups, so the branch
// and the sub-path are split manually)
function github_tree_to_api(url) {
let m = match(url, /^https:\/\/github\.com\/([^\/]+)\/([^\/]+)\/tree\/(.+)$/);
if (m == null)
return null;
let rest = m[3];
let slash = index(rest, '/');
let branch = (slash > 0) ? substr(rest, 0, slash) : rest;
let path = (slash > 0) ? substr(rest, slash + 1) : '';
let api = 'https://api.github.com/repos/' + m[1] + '/' + m[2] + '/contents';
if (length(path) > 0)
api += '/' + path;
return api + '?ref=' + branch;
}
// Whitelist of user-editable config sources. The plugin-managed files
// (PRESET_CONF / CUSTOM_CONF) are deliberately excluded: they are managed
// through their dedicated UI sections and rewriting them here would bypass
// the plugin's own state.
function editable_path(p) {
if (p == null || length(p) == 0)
return false;
if (p == MAIN_CONF)
return true;
if (p == CUSTOM_CONF || p == PRESET_CONF)
return false;
if (substr(p, 0, length(SYSCTL_D) + 1) == SYSCTL_D + '/') {
let base = substr(p, length(SYSCTL_D) + 1);
// plain filename only (no traversal), must end in .conf
if (match(base, /^[A-Za-z0-9_.-]+\.conf$/) != null)
return true;
}
return false;
}
// True when the raw line assigns `key` (enabled or commented-out form)
function line_matches_key(raw, key) {
let t = trim(raw);
if (t == null || length(t) == 0)
return false;
if (substr(t, 0, 1) == '#') {
let m = match(trim(substr(t, 1)), /^([A-Za-z0-9_][A-Za-z0-9_.\/-]*)[ \t]*=/);
return (m != null && m[1] == key);
}
let m = match(t, /^([A-Za-z0-9_][A-Za-z0-9_.\/-]*)[ \t]*=/);
return (m != null && m[1] == key);
}
// Replace the first line assigning `key` with a new value, keeping every
// other line (comments, ordering, formatting) untouched.
function file_rewrite(path, key, value, disabled) {
let data = readfile(path);
if (data == null)
return { code: 2, error: 'Cannot read ' + path };
let lines = split(data, '\n');
let out = [];
let replaced = false;
for (let i = 0; i < length(lines); i++) {
let raw = lines[i];
if (!replaced && line_matches_key(raw, key)) {
push(out, (disabled ? '# ' : '') + key + ' = ' + value);
replaced = true;
}
else {
push(out, raw);
}
}
if (!replaced)
return { code: 3, error: 'Key not found in ' + path };
if (writefile(path, join('\n', out)) == null)
return { code: 4, error: 'Failed to write ' + path };
return { code: 0 };
}
// Remove all lines assigning `key` from the file.
function file_delete_key(path, key) {
let data = readfile(path);
if (data == null)
return { code: 2, error: 'Cannot read ' + path };
let lines = split(data, '\n');
let out = [];
let removed = 0;
for (let i = 0; i < length(lines); i++) {
if (line_matches_key(lines[i], key))
removed++;
else
push(out, lines[i]);
}
if (removed == 0)
return { code: 3, error: 'Key not found in ' + path };
if (writefile(path, join('\n', out)) == null)
return { code: 4, error: 'Failed to write ' + path };
return { code: 0, removed: removed };
}
// Strip the normal "key = value" echo lines that sysctl -p prints to stdout
// for every successfully applied entry. What remains (if anything) is a
// genuine error message like "sysctl: permission denied on key '...'".
// NOTE: ucode regex has no non-capturing groups; plain pattern is fine here.
function strip_echo(out) {
if (out == null || length(out) == 0)
return '';
let lines = split(out, '\n');
let rest = [];
for (let i = 0; i < length(lines); i++) {
let t = trim(lines[i]);
if (t == null || length(t) == 0)
continue;
if (match(t, /^[A-Za-z0-9_][A-Za-z0-9_.\/-]*[ \t]*=[ \t]*\S/) != null)
continue;
push(rest, t);
}
return join('\n', rest);
}
const methods = {
status: {
args: {},
call: function(request) {
let confs = collect_confs();
let files = [];
for (let i = 0; i < length(confs); i++) {
push(files, {
path: confs[i],
count: length(parse_conf(confs[i])),
managed: (confs[i] == CUSTOM_CONF)
});
}
return {
files: files,
custom_path: CUSTOM_CONF,
sysctl_bin: path_exists(SYSCTL_BIN),
initd: path_exists('/etc/init.d/sysctl')
};
}
},
list: {
args: {},
call: function(request) {
return {
custom: (access(CUSTOM_CONF)) ? decorate(parse_conf(CUSTOM_CONF)) : [],
main: (access(MAIN_CONF)) ? decorate(parse_conf(MAIN_CONF)) : []
};
}
},
browse: {
args: { prefix: '' },
call: function(request) {
let a = (request.args != null) ? request.args : {};
let prefix = (a.prefix != null) ? a.prefix : '';
if (prefix != '' && match(prefix, /^[A-Za-z0-9_]+([.][A-Za-z0-9_]+)*\.$/) == null)
return { code: 1, error: 'Invalid prefix', items: [] };
let dir = PROC_SYS;
let base = '';
if (prefix != '') {
base = prefix;
dir = PROC_SYS + '/' + join('/', split(substr(prefix, 0, length(prefix) - 1), '.'));
}
let names = sort(lsdir(dir) || []);
let items = [];
for (let i = 0; i < length(names); i++) {
let name = names[i];
let full = dir + '/' + name;
let st = stat(full);
if (st == null)
continue;
if (st.type == 'directory') {
push(items, { name: name, type: 'group', prefix: base + name + '.' });
}
else {
let v = readfile(full);
push(items, {
name: name,
type: 'param',
key: base + name,
value: (v == null) ? '' : trim(v)
});
}
}
return { items: items };
}
},
search: {
args: { query: '', limit: 100 },
call: function(request) {
let a = (request.args != null) ? request.args : {};
let q = (a.query != null) ? trim(a.query) : '';
let limit = 100;
if (a.limit != null) {
let n = int(a.limit, 100);
if (n > 0 && n <= 1000)
limit = n;
}
if (q == null || length(q) < 2)
return { items: [], truncated: false };
let all = {};
walk(PROC_SYS, '', all);
let ql = lc(q);
let keylist = sort(keys(all));
let items = [];
let truncated = false;
for (let i = 0; i < length(keylist); i++) {
let key = keylist[i];
// match on key name (>= 2 chars), or on value (>= 3 chars)
if (index(lc(key), ql) < 0 &&
(length(q) < 3 || index(all[key], q) < 0))
continue;
push(items, { key: key, value: all[key] });
if (length(items) >= limit) {
truncated = true;
break;
}
}
return { items: items, truncated: truncated };
}
},
set: {
args: { key: '', value: '', disabled: false, apply: false },
call: function(request) {
let a = (request.args != null) ? request.args : {};
let key = trim(a.key);
let value = (a.value == null) ? '' : trim(a.value);
let disabled = (a.disabled == true);
let apply = (a.apply == true);
if (key == null || length(key) == 0 || match(key, KEY_RE) == null)
return { code: 1, error: 'Invalid key' };
if (match(value, /[\r\n]/) != null)
return { code: 1, error: 'Invalid value' };
let exists = path_exists(proc_file(key));
let entries = (access(CUSTOM_CONF)) ? parse_conf(CUSTOM_CONF) : [];
let found = false;
for (let i = 0; i < length(entries); i++) {
if (entries[i].key == key) {
entries[i].value = value;
entries[i].disabled = disabled;
found = true;
break;
}
}
if (!found)
push(entries, { key: key, value: value, line: 0, disabled: disabled });
if (writefile(CUSTOM_CONF, render_conf(entries)) == null)
return { code: 2, error: 'Failed to write ' + CUSTOM_CONF };
let applied = null;
if (apply && !disabled)
applied = write_proc(key, value);
return {
code: 0,
exists: exists,
applied: applied,
current: (apply && !disabled) ? read_current(key) : null
};
}
},
remove: {
args: { key: '' },
call: function(request) {
let a = (request.args != null) ? request.args : {};
let key = trim(a.key);
if (key == null || length(key) == 0)
return { code: 1, error: 'Missing key' };
let entries = (access(CUSTOM_CONF)) ? parse_conf(CUSTOM_CONF) : [];
let out = [];
for (let i = 0; i < length(entries); i++)
if (entries[i].key != key)
push(out, entries[i]);
if (length(out) == length(entries))
return { code: 3, error: 'Key not found in ' + CUSTOM_CONF };
if (writefile(CUSTOM_CONF, render_conf(out)) == null)
return { code: 2, error: 'Failed to write ' + CUSTOM_CONF };
return { code: 0 };
}
},
apply: {
args: {},
call: function(request) {
let confs = collect_confs();
let errors = [];
if (!path_exists(SYSCTL_BIN))
return { code: 4, error: 'sysctl binary not found', errors: errors };
for (let i = 0; i < length(confs); i++) {
let p = popen(SYSCTL_BIN + ' -e -p ' + shquote(confs[i]) + ' 2>&1', 'r');
let out = (p != null) ? p.read('all') : null;
let rc = (p != null) ? p.close() : -1;
out = strip_echo((out == null) ? '' : trim(out));
/* busybox `sysctl -e -p` echoes every line and silently
* swallows write failures -> echo alone proves nothing.
* Read back each enabled entry from /proc/sys instead:
* anything that differs was NOT accepted by the kernel. */
let not_applied = [];
let entries = parse_conf(confs[i]);
for (let j = 0; j < length(entries); j++) {
if (entries[j].disabled)
continue;
let cur = read_current(entries[j].key);
if (cur == null)
continue; // key unknown to this kernel, -e skips it by design
if (cur != entries[j].value)
push(not_applied, {
key: entries[j].key,
value: entries[j].value,
current: cur
});
}
if (rc != 0 || length(out) > 0 || length(not_applied) > 0)
push(errors, {
file: confs[i],
code: rc,
output: out,
not_applied: not_applied
});
}
return { code: 0, errors: errors };
}
},
preset_status: {
args: {},
call: function(request) {
let meta = preset_meta();
return {
present: meta.present,
path: PRESET_CONF,
count: (meta.present) ? meta.count : 0,
source: meta.source,
fetched: meta.fetched,
params: (meta.present) ? decorate(parse_conf(PRESET_CONF)) : []
};
}
},
preset_fetch: {
args: { url: '', prefix: '' },
call: function(request) {
let a = (request.args != null) ? request.args : {};
let dl = preset_download(a.url, a.prefix);
if (dl.code != 0)
return { code: dl.code, error: dl.error, url: dl.url, invalid: dl.invalid };
return {
code: 0,
url: dl.url,
params: dl.parsed.params,
invalid: dl.parsed.invalid,
conflicts: dl.conflicts
};
}
},
preset_import: {
args: { url: '', prefix: '' },
call: function(request) {
let a = (request.args != null) ? request.args : {};
let dl = preset_download(a.url, a.prefix);
if (dl.code != 0)
return { code: dl.code, error: dl.error, url: dl.url, invalid: dl.invalid };
if (writefile(PRESET_CONF, render_preset(dl.url, dl.parsed.params)) == null)
return { code: 4, error: 'Failed to write ' + PRESET_CONF };
return {
code: 0,
count: length(dl.parsed.params),
path: PRESET_CONF,
url: dl.url
};
}
},
preset_check: {
args: {},
call: function(request) {
let meta = preset_meta();
if (!meta.present || meta.source == null)
return { code: 5, error: 'No online preset imported yet' };
let data = http_get(meta.source);
if (data == null)
return { code: 2, error: 'Download failed: ' + meta.source, url: meta.source };
let parsed = parse_preset_content(data);
if (length(parsed.params) == 0)
return { code: 3, error: 'No valid sysctl entries found at ' + meta.source, url: meta.source, invalid: parsed.invalid };
// remote state (last definition wins, order preserved)
let remote = {};
let order = [];
for (let i = 0; i < length(parsed.params); i++) {
if (remote[parsed.params[i].key] == null)
push(order, parsed.params[i].key);
remote[parsed.params[i].key] = parsed.params[i].value;
}
// local state (preset file contains no disabled entries)
let local = {};
let entries = parse_conf(PRESET_CONF);
for (let i = 0; i < length(entries); i++)
if (!entries[i].disabled)
local[entries[i].key] = entries[i].value;
let added = [];
let changed = [];
let removed = [];
for (let i = 0; i < length(order); i++) {
let k = order[i];
if (local[k] == null)
push(added, { key: k, value: remote[k] });
else if (local[k] != remote[k])
push(changed, { key: k, from: local[k], to: remote[k] });
}
let lkeys = sort(keys(local));
for (let i = 0; i < length(lkeys); i++)
if (remote[lkeys[i]] == null)
push(removed, { key: lkeys[i], old: local[lkeys[i]] });
return {
code: 0,
url: meta.source,
remote_count: length(parsed.params),
local_count: length(local),
added: added,
changed: changed,
removed: removed
};
}
},
preset_remove: {
args: {},
call: function(request) {
if (access(PRESET_CONF) == null)
return { code: 5, error: 'No online preset imported yet' };
if (!unlink(PRESET_CONF))
return { code: 4, error: 'Failed to remove ' + PRESET_CONF };
return { code: 0 };
}
},
preset_list: {
args: { url: '', prefix: '' },
call: function(request) {
let a = (request.args != null) ? request.args : {};
let url = (a.url != null) ? trim(a.url) : '';
let api = github_tree_to_api((url == null) ? '' : url);
// not a directory URL: caller falls back to single-file fetch
if (api == null)
return { code: 10, is_dir: false, files: [] };
let final_url = apply_prefix(api, a.prefix);
let data = http_get(final_url);
if (data == null)
return { code: 2, error: 'Directory listing failed: ' + final_url, url: final_url };
let doc = json(data);
if (doc == null || type(doc) != 'array')
return { code: 3, error: 'Unexpected directory listing from ' + final_url + ' (GitHub rate limit?)', url: final_url };
let files = [];
for (let i = 0; i < length(doc); i++) {
let item = doc[i];
if (item == null || item.type != 'file' || item.name == null)
continue;
if (match(item.name, /\.conf$/) == null)
continue;
push(files, {
name: item.name,
size: (item.size != null) ? item.size : 0,
url: (item.download_url != null) ? item.download_url : ''
});
}
if (length(files) == 0)
return { code: 3, error: 'Directory contains no .conf files: ' + final_url, url: final_url };
return { code: 0, is_dir: true, files: files, url: final_url };
}
},
file_view: {
args: { path: '' },
call: function(request) {
let a = (request.args != null) ? request.args : {};
let p = trim(a.path);
if (p == null || !editable_path(p))
return { code: 1, error: 'Path not allowed: ' + ((p != null) ? p : ''), editable: false };
return {
code: 0,
path: p,
editable: true,
entries: decorate(parse_conf(p))
};
}
},
file_set: {
args: { path: '', key: '', value: '', disabled: false },
call: function(request) {
let a = (request.args != null) ? request.args : {};
let p = trim(a.path);
let key = trim(a.key);
let value = (a.value == null) ? '' : trim(a.value);
let disabled = (a.disabled == true);
if (p == null || !editable_path(p))
return { code: 1, error: 'Path not allowed' };
if (key == null || match(key, KEY_RE) == null)
return { code: 1, error: 'Invalid key' };
if (match(value, /[\r\n]/) != null)
return { code: 1, error: 'Invalid value' };
return file_rewrite(p, key, value, disabled);
}
},
file_delete: {
args: { path: '', key: '' },
call: function(request) {
let a = (request.args != null) ? request.args : {};
let p = trim(a.path);
let key = trim(a.key);
if (p == null || !editable_path(p))
return { code: 1, error: 'Path not allowed' };
if (key == null || length(key) == 0)
return { code: 1, error: 'Invalid key' };
return file_delete_key(p, key);
}
},
// Locate a sysctl key across ALL config sources, in application order.
// Without args: return only keys defined in >= 2 files (duplicate map).
// With args.key: return that key's definitions even if defined once
// (used by the add/edit form to warn about shadowing overrides).
// Entries are ordered by file load order -> the LAST enabled entry wins.
dup_check: {
args: { key: '' },
call: function(request) {
let a = (request.args != null) ? request.args : {};
let want = trim(a.key);
let confs = collect_confs();
let by_key = {};
let order = [];
for (let i = 0; i < length(confs); i++) {
let path = confs[i];
let entries = parse_conf(path);
for (let j = 0; j < length(entries); j++) {
let k = entries[j].key;
if (want != null && length(want) > 0 && k != want)
continue;
if (by_key[k] == null) {
by_key[k] = [];
push(order, k);
}
push(by_key[k], {
path: path,
value: entries[j].value,
disabled: entries[j].disabled
});
}
}
let min_defs = (want != null && length(want) > 0) ? 1 : 2;
let dups = [];
for (let i = 0; i < length(order); i++) {
let k = order[i];
if (length(by_key[k]) >= min_defs)
push(dups, { key: k, entries: by_key[k] });
}
return { code: 0, dups: dups };
}
}
};
return { 'luci.sysctl': methods };