mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-29 23:55:13 +08:00
🐤 Sync 2026-09-07 02:18:00
This commit is contained in:
1 parent
65e584daaa
commit
4b545cbffa
30 files changed
+1911
-1238
No files matched your search
@@ -5,8 +5,8 @@
|
||||
include $(TOPDIR)/rules.mk
|
||||
|
||||
PKG_NAME:=luci-app-daede
|
||||
PKG_VERSION:=1.14.7
|
||||
PKG_RELEASE:=42
|
||||
PKG_VERSION:=1.15
|
||||
PKG_RELEASE:=43
|
||||
PKG_MAINTAINER:=kenzok8
|
||||
PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_NAME)
|
||||
|
||||
@@ -78,6 +78,7 @@ define Package/$(PKG_NAME)/install
|
||||
$(INSTALL_BIN) ./root/usr/share/luci-app-daede/proxy-check.sh $(1)/usr/share/luci-app-daede/proxy-check.sh
|
||||
$(INSTALL_BIN) ./root/usr/share/luci-app-daede/fetch-clash-yaml.sh $(1)/usr/share/luci-app-daede/fetch-clash-yaml.sh
|
||||
$(INSTALL_BIN) ./root/usr/share/luci-app-daede/config-backup.sh $(1)/usr/share/luci-app-daede/config-backup.sh
|
||||
$(INSTALL_BIN) ./root/usr/share/luci-app-daede/snapshot-file.sh $(1)/usr/share/luci-app-daede/snapshot-file.sh
|
||||
$(INSTALL_BIN) ./root/usr/share/luci-app-daede/config-defaults.sh $(1)/usr/share/luci-app-daede/config-defaults.sh
|
||||
$(INSTALL_DIR) $(1)/usr/share/luci-app-daede/defaults
|
||||
$(INSTALL_DATA) $(CURDIR)/../dae/files/dae.config $(1)/usr/share/luci-app-daede/defaults/dae
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
const FETCHER = '/usr/share/luci-app-daede/fetch-clash-yaml.sh';
|
||||
const GENERATOR = '/usr/share/luci-app-daede/gen-dae-config.sh';
|
||||
const SNAPSHOT_HELPER = '/usr/share/luci-app-daede/snapshot-file.sh';
|
||||
const SUB_STAGE = '/tmp/daede-sub.txt';
|
||||
const FETCH_CHUNK_BYTES = 16384;
|
||||
|
||||
@@ -651,11 +652,19 @@ return view.extend({
|
||||
const name = airportName.value.trim();
|
||||
const groupName = airportSync.backendGroupName(name, 'daed', airportId);
|
||||
const endpoint = daedEndpoint();
|
||||
// daed can't read file:// — it HTTP-fetches a subscription link. Stage
|
||||
// the converted links (base64) and let daed pull them from the
|
||||
// loopback-only CGI, so the import lands as ONE subscription.
|
||||
const subToken = airportSync.backendId(airportId) + Date.now().toString(36);
|
||||
const stageFile = '/tmp/daede-daedsub-' + subToken;
|
||||
// daed can't read file:// — it HTTP-fetches a subscription link. Persist
|
||||
// the converted snapshot and let daed pull it from the loopback-only CGI,
|
||||
// so later daed refreshes and configuration backups keep working.
|
||||
// Keep the CGI token opaque, bounded, and unique across imports started in
|
||||
// the same millisecond. The final sanitization also protects the filename
|
||||
// and the CGI's allow-list if backendId ever changes.
|
||||
const tokenTime = Date.now().toString(36);
|
||||
const tokenRandom = Math.random().toString(36).slice(2, 12);
|
||||
const tokenPrefix = airportSync.backendId(airportId).replace(/[^A-Za-z0-9]/g, '')
|
||||
.slice(0, Math.max(0, 64 - tokenTime.length - tokenRandom.length));
|
||||
const subToken = (tokenPrefix + tokenTime + tokenRandom).slice(0, 64);
|
||||
const snapshotFile = '/etc/daed/daede-sub-' + subToken;
|
||||
const stageFile = '/etc/daed/.daede-sub-stage-' + subToken;
|
||||
const subUrl = 'http://127.0.0.1/cgi-bin/daede-sub?t=' + subToken;
|
||||
const b64 = btoa(items.map(function(item) { return item.link; }).join('\n') + '\n');
|
||||
|
||||
@@ -664,15 +673,156 @@ return view.extend({
|
||||
let createdGroupId = '';
|
||||
const createdNodeIds = [];
|
||||
let groupReady = false;
|
||||
let snapshotCommitted = false;
|
||||
let snapshotFinalToken = '';
|
||||
let reuseExistingSnapshot = false;
|
||||
const oldSubId = existingAirport ? existingAirport.subscription_id : '';
|
||||
const oldNodeIds = existingAirport ? existingAirport.node_ids : [];
|
||||
|
||||
const dropStage = function() { return fs.exec('/bin/rm', [ '-f', stageFile ]).catch(function() {}); };
|
||||
const dropSnapshot = function(path) {
|
||||
if (!path)
|
||||
return Promise.resolve();
|
||||
return fs.remove(path).catch(function(error) {
|
||||
const message = String(error && (error.message || error) || '');
|
||||
if (/not found|no such file|enoent/i.test(message))
|
||||
return;
|
||||
throw error;
|
||||
});
|
||||
};
|
||||
const appendWarning = function(error, warning) {
|
||||
const result = error instanceof Error ? error : new Error(String(error && (error.message || error) || error));
|
||||
result.message = result.message + '; ' + warning;
|
||||
return result;
|
||||
};
|
||||
const snapshotAction = function(args) {
|
||||
return fs.exec(SNAPSHOT_HELPER, args).then(function(res) {
|
||||
if (!res || res.code !== 0)
|
||||
throw new Error((res && (res.stderr || res.stdout)) || _('Snapshot operation failed'));
|
||||
return res;
|
||||
});
|
||||
};
|
||||
const cleanupStage = function() {
|
||||
return snapshotAction([ 'discard', subToken ]);
|
||||
};
|
||||
const querySubscriptions = function() {
|
||||
if (!token)
|
||||
return Promise.reject(new Error(_('daed subscription cleanup could not be verified')));
|
||||
return graphQL(endpoint, 'query SubscriptionLinks{subscriptions{id link}}', {}, token).then(function(value) {
|
||||
if (!value || !Array.isArray(value.subscriptions))
|
||||
throw new Error(_('daed returned an invalid subscription list'));
|
||||
return value.subscriptions;
|
||||
});
|
||||
};
|
||||
const dropSnapshotIfUnreferenced = function() {
|
||||
if (snapshotFinalToken !== subToken)
|
||||
return Promise.resolve();
|
||||
return querySubscriptions().then(function(subscriptions) {
|
||||
if (subscriptions.some(function(sub) { return String(sub.link || '') === subUrl; }))
|
||||
throw new Error(_('new daed subscription snapshot is still referenced; it was kept'));
|
||||
return dropSnapshot(snapshotFile);
|
||||
});
|
||||
};
|
||||
const finishWithSnapshotCleanup = function(result) {
|
||||
return dropSnapshotIfUnreferenced().then(function() {
|
||||
return result;
|
||||
}).catch(function(error) {
|
||||
const warning = String(error && (error.message || error) || error);
|
||||
result.warning = result.warning
|
||||
? result.warning + '; ' + warning
|
||||
: warning;
|
||||
return result;
|
||||
});
|
||||
};
|
||||
const updateExistingSubscription = function() {
|
||||
const old = (((before || {}).subscriptions) || []).find(function(sub) { return sub.id === oldSubId; });
|
||||
if (!old)
|
||||
return Promise.reject(new Error(_('Managed subscription no longer exists in daed')));
|
||||
|
||||
const oldLink = String(old.link || '');
|
||||
const localToken = oldLink.match(/^http:\/\/127\.0\.0\.1\/cgi-bin\/daede-sub\?t=([A-Za-z0-9]{1,64})$/);
|
||||
const refresh = function() {
|
||||
return graphQL(endpoint, 'mutation UpdateSub($id:ID!){updateSubscription(id:$id){id}}', { id: oldSubId }, token);
|
||||
};
|
||||
const currentLink = function() {
|
||||
return querySubscriptions().then(function(subscriptions) {
|
||||
const current = subscriptions.find(function(sub) { return sub.id === oldSubId; });
|
||||
return current ? String(current.link || '') : '';
|
||||
});
|
||||
};
|
||||
const restoreOldLink = function(originalError) {
|
||||
return graphQL(endpoint, 'mutation RestoreLink($id:ID!,$link:String!){updateSubscriptionLink(id:$id,link:$link)}', {
|
||||
id: oldSubId,
|
||||
link: oldLink
|
||||
}, token).then(refresh).then(currentLink).then(function(link) {
|
||||
if (link !== oldLink) {
|
||||
snapshotCommitted = true;
|
||||
throw appendWarning(originalError, _('rollback was not confirmed; both subscription snapshots were kept'));
|
||||
}
|
||||
throw originalError;
|
||||
}, function(rollbackError) {
|
||||
snapshotCommitted = true;
|
||||
throw appendWarning(originalError, _('rollback could not be confirmed; both subscription snapshots were kept: %s').format(String(rollbackError && (rollbackError.message || rollbackError) || rollbackError)));
|
||||
});
|
||||
};
|
||||
const finish = function() {
|
||||
const tag = old.tag !== groupName
|
||||
? graphQL(endpoint, 'mutation TagSub($id:ID!,$tag:String!){tagSubscription(id:$id,tag:$tag)}', {
|
||||
id: oldSubId,
|
||||
tag: groupName
|
||||
}, token).catch(function(error) {
|
||||
return String(error && (error.message || error) || error);
|
||||
})
|
||||
: Promise.resolve('');
|
||||
return tag.then(function(tagWarning) {
|
||||
writeAirportRecord(existingAirport, {
|
||||
id: airportId,
|
||||
backend: 'daed',
|
||||
name: name,
|
||||
sourceHash: state.sourceHash,
|
||||
groupId: existingAirport.group_id,
|
||||
subscriptionId: oldSubId,
|
||||
nodeIds: []
|
||||
});
|
||||
return applyUciChanges().then(function() {
|
||||
items.forEach(function(item) { item.duplicate = true; item.selected = false; });
|
||||
return {
|
||||
added: items.length,
|
||||
duplicates: 0,
|
||||
failed: 0,
|
||||
warning: tagWarning
|
||||
};
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
if (localToken && reuseExistingSnapshot) {
|
||||
return graphQL(endpoint, 'mutation UpdateSub($id:ID!){updateSubscription(id:$id){id}}', { id: oldSubId }, token)
|
||||
.then(currentLink).then(function(link) {
|
||||
if (link !== oldLink)
|
||||
throw new Error(_('daed did not confirm the existing subscription link after refresh'));
|
||||
}).catch(function(error) {
|
||||
throw appendWarning(error, _('daed subscription refresh failed; the new snapshot remains at the existing link and existing nodes were kept'));
|
||||
}).then(finish);
|
||||
}
|
||||
|
||||
return graphQL(endpoint, 'mutation SetLink($id:ID!,$link:String!){updateSubscriptionLink(id:$id,link:$link)}', {
|
||||
id: oldSubId,
|
||||
link: subUrl
|
||||
}, token).then(refresh).then(function() {
|
||||
return currentLink().then(function(link) {
|
||||
if (link !== subUrl)
|
||||
throw new Error(_('daed did not confirm the new subscription link'));
|
||||
});
|
||||
}).catch(restoreOldLink).then(function() {
|
||||
snapshotCommitted = true;
|
||||
return finish();
|
||||
});
|
||||
};
|
||||
const loadState = function(forceLogin) {
|
||||
return requestDaedToken(endpoint, forceLogin).then(function(auth) {
|
||||
token = auth.token;
|
||||
usedCachedToken = auth.cached;
|
||||
return graphQL(endpoint, 'query State{nodes(first:10000){edges{id link tag}} groups{id name nodes{id}}}', {}, token);
|
||||
return graphQL(endpoint, 'query State{nodes(first:10000){edges{id link tag}} groups{id name nodes{id}} subscriptions{id link tag}}', {}, token);
|
||||
});
|
||||
};
|
||||
const importDirectNodes = function() {
|
||||
@@ -797,7 +947,7 @@ return view.extend({
|
||||
});
|
||||
};
|
||||
|
||||
return fs.write(stageFile, b64).then(function() {
|
||||
return fs.write(stageFile, b64, 384).then(function() {
|
||||
return loadState(false).catch(function(error) {
|
||||
if (!usedCachedToken || !daedSession.isAccessDenied(error))
|
||||
throw error;
|
||||
@@ -806,42 +956,63 @@ return view.extend({
|
||||
});
|
||||
}).then(function(dataValue) {
|
||||
before = dataValue;
|
||||
// drop this airport's previous subscription first: the new one
|
||||
// reuses the same tag (group name) and daed enforces unique tags.
|
||||
if (!oldSubId)
|
||||
return null;
|
||||
return graphQL(endpoint, 'mutation RmSub($ids:[ID!]!){removeSubscriptions(ids:$ids)}', { ids: [ oldSubId ] }, token).catch(function() {});
|
||||
}).then(function() {
|
||||
// import the whole converted batch as one subscription
|
||||
return graphQL(endpoint,
|
||||
'mutation Import($a:ImportArgument!){importSubscription(rollbackError:false,arg:$a){sub{id} nodeImportResult{error}}}',
|
||||
{ a: { link: subUrl, tag: groupName } }, token).then(function(result) {
|
||||
const sub = result.importSubscription && result.importSubscription.sub;
|
||||
const rows = (result.importSubscription && result.importSubscription.nodeImportResult) || [];
|
||||
const usable = rows.length
|
||||
? rows.some(function(r) { return !r.error || r.error === 'node already exists'; })
|
||||
: !!(sub && sub.id);
|
||||
if (!sub || !sub.id || !usable) {
|
||||
const details = rows.map(function(r) { return r.error; }).filter(Boolean).join('; ');
|
||||
if (sub && sub.id)
|
||||
newSubId = sub.id;
|
||||
throw new Error(details || _('No usable nodes were imported'));
|
||||
}
|
||||
return result;
|
||||
}).catch(function(error) {
|
||||
if (daedSession.isAccessDenied(error))
|
||||
throw error;
|
||||
const cleanupSub = newSubId
|
||||
? graphQL(endpoint, 'mutation Rm($ids:[ID!]!){removeSubscriptions(ids:$ids)}', { ids: [ newSubId ] }, token).catch(function() {})
|
||||
: Promise.resolve();
|
||||
newSubId = '';
|
||||
return cleanupSub.then(importDirectNodes).then(function(result) {
|
||||
return { directResult: result };
|
||||
});
|
||||
const old = (before.subscriptions || []).find(function(sub) { return sub.id === oldSubId; });
|
||||
const localToken = old && String(old.link || '').match(/^http:\/\/127\.0\.0\.1\/cgi-bin\/daede-sub\?t=([A-Za-z0-9]{1,64})$/);
|
||||
const oldLinkReferences = localToken
|
||||
? (before.subscriptions || []).filter(function(sub) { return String(sub.link || '') === String(old.link || ''); })
|
||||
: [];
|
||||
reuseExistingSnapshot = !!(localToken && oldLinkReferences.length === 1 && String(oldLinkReferences[0].id) === String(oldSubId));
|
||||
const publish = reuseExistingSnapshot
|
||||
? snapshotAction([ 'replace', localToken[1], subToken ]).then(function() {
|
||||
snapshotFinalToken = localToken[1];
|
||||
snapshotCommitted = true;
|
||||
})
|
||||
: snapshotAction([ 'publish', subToken ]).then(function() {
|
||||
snapshotFinalToken = subToken;
|
||||
});
|
||||
return publish.then(function() {
|
||||
if (oldSubId)
|
||||
return updateExistingSubscription().then(function(result) { return { existingResult: result }; });
|
||||
// import the whole converted batch as one subscription
|
||||
return graphQL(endpoint,
|
||||
'mutation Import($a:ImportArgument!){importSubscription(rollbackError:false,arg:$a){sub{id} nodeImportResult{error}}}',
|
||||
{ a: { link: subUrl, tag: groupName } }, token).then(function(result) {
|
||||
const sub = result.importSubscription && result.importSubscription.sub;
|
||||
const rows = (result.importSubscription && result.importSubscription.nodeImportResult) || [];
|
||||
const usable = rows.length
|
||||
? rows.some(function(r) { return !r.error || r.error === 'node already exists'; })
|
||||
: !!(sub && sub.id);
|
||||
if (!sub || !sub.id || !usable) {
|
||||
const details = rows.map(function(r) { return r.error; }).filter(Boolean).join('; ');
|
||||
if (sub && sub.id)
|
||||
newSubId = sub.id;
|
||||
throw new Error(details || _('No usable nodes were imported'));
|
||||
}
|
||||
return result;
|
||||
}).catch(function(error) {
|
||||
if (daedSession.isAccessDenied(error))
|
||||
throw error;
|
||||
const cleanupSub = newSubId
|
||||
? graphQL(endpoint, 'mutation Rm($ids:[ID!]!){removeSubscriptions(ids:$ids)}', { ids: [ newSubId ] }, token).catch(function(cleanupError) {
|
||||
snapshotCommitted = true;
|
||||
throw appendWarning(error, _('rollback could not be confirmed; the new subscription snapshot was kept: %s').format(String(cleanupError && (cleanupError.message || cleanupError) || cleanupError)));
|
||||
})
|
||||
: Promise.resolve();
|
||||
newSubId = '';
|
||||
return cleanupSub.then(function() {
|
||||
if (oldSubId)
|
||||
throw error;
|
||||
return importDirectNodes();
|
||||
}).then(function(result) {
|
||||
return { directResult: result };
|
||||
});
|
||||
});
|
||||
});
|
||||
}).then(function(result) {
|
||||
if (result.existingResult)
|
||||
return result.existingResult;
|
||||
if (result.directResult)
|
||||
return result.directResult;
|
||||
return finishWithSnapshotCleanup(result.directResult);
|
||||
|
||||
const sub = result.importSubscription && result.importSubscription.sub;
|
||||
newSubId = sub.id;
|
||||
@@ -861,6 +1032,7 @@ return view.extend({
|
||||
return ensureGroup.then(function(groupId) {
|
||||
return graphQL(endpoint, 'mutation AddSubs($id:ID!,$ids:[ID!]!){groupAddSubscriptions(id:$id,subscriptionIDs:$ids)}', { id: groupId, ids: [ newSubId ] }, token).then(function() {
|
||||
groupReady = true;
|
||||
snapshotCommitted = true;
|
||||
const cleanup = [];
|
||||
// Migrate converter-managed airport groups to proxy, but never
|
||||
// disturb proxy's existing nodes or other subscriptions.
|
||||
@@ -880,7 +1052,12 @@ return view.extend({
|
||||
});
|
||||
return applyUciChanges().then(function() {
|
||||
items.forEach(function(item) { item.duplicate = true; item.selected = false; });
|
||||
return { added: items.length, duplicates: 0, failed: failed };
|
||||
return {
|
||||
added: items.length,
|
||||
duplicates: 0,
|
||||
failed: failed,
|
||||
warning: ''
|
||||
};
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -892,15 +1069,47 @@ return view.extend({
|
||||
throw error;
|
||||
const cleanup = [];
|
||||
if (newSubId)
|
||||
cleanup.push(graphQL(endpoint, 'mutation Rm($ids:[ID!]!){removeSubscriptions(ids:$ids)}', { ids: [ newSubId ] }, token));
|
||||
cleanup.push(graphQL(endpoint, 'mutation Rm($ids:[ID!]!){removeSubscriptions(ids:$ids)}', { ids: [ newSubId ] }, token).catch(function(cleanupError) {
|
||||
snapshotCommitted = true;
|
||||
throw cleanupError;
|
||||
}));
|
||||
if (createdNodeIds.length)
|
||||
cleanup.push(graphQL(endpoint, 'mutation Rm($ids:[ID!]!){removeNodes(ids:$ids)}', { ids: createdNodeIds }, token));
|
||||
if (createdGroupId)
|
||||
cleanup.push(graphQL(endpoint, 'mutation RmG($id:ID!){removeGroup(id:$id)}', { id: createdGroupId }, token));
|
||||
return Promise.all(cleanup).catch(function() {}).then(function() { throw error; });
|
||||
}).finally(function() {
|
||||
token = '';
|
||||
return dropStage();
|
||||
return Promise.all(cleanup).then(function() {
|
||||
throw error;
|
||||
}, function(cleanupError) {
|
||||
snapshotCommitted = true;
|
||||
throw appendWarning(error, _('rollback could not be confirmed; the new subscription snapshot was kept: %s').format(String(cleanupError && (cleanupError.message || cleanupError) || cleanupError)));
|
||||
});
|
||||
}).then(function(result) {
|
||||
return cleanupStage().then(function() {
|
||||
token = '';
|
||||
return result;
|
||||
}, function(cleanupError) {
|
||||
token = '';
|
||||
result.warning = result.warning
|
||||
? result.warning + '; ' + String(cleanupError && (cleanupError.message || cleanupError) || cleanupError)
|
||||
: String(cleanupError && (cleanupError.message || cleanupError) || cleanupError);
|
||||
return result;
|
||||
});
|
||||
}, function(error) {
|
||||
let cleanupWarning = '';
|
||||
const removeSnapshot = snapshotCommitted
|
||||
? Promise.resolve()
|
||||
: dropSnapshotIfUnreferenced().catch(function(cleanupError) {
|
||||
cleanupWarning = _('rollback could not be confirmed; the new subscription snapshot was kept: %s').format(String(cleanupError && (cleanupError.message || cleanupError) || cleanupError));
|
||||
});
|
||||
return removeSnapshot.then(function() {
|
||||
return cleanupStage().catch(function(stageError) {
|
||||
const warning = _('snapshot stage cleanup failed: %s').format(String(stageError && (stageError.message || stageError) || stageError));
|
||||
cleanupWarning = cleanupWarning ? cleanupWarning + '; ' + warning : warning;
|
||||
});
|
||||
}).then(function() {
|
||||
token = '';
|
||||
throw cleanupWarning ? appendWarning(error, cleanupWarning) : error;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
@@ -924,8 +1133,11 @@ return view.extend({
|
||||
setImportStatus(_('Importing node group…'));
|
||||
const action = state.target === 'dae' ? importDae(items) : importDaed(items);
|
||||
action.then(function(result) {
|
||||
setImportStatus(_('Node group imported: added %d, reused %d, failed %d')
|
||||
.format(result.added, result.duplicates, result.failed), result.failed ? 'err' : 'ok');
|
||||
let message = _('Node group imported: added %d, reused %d, failed %d')
|
||||
.format(result.added, result.duplicates, result.failed);
|
||||
if (result.warning)
|
||||
message += ' ' + _('Completed with warning: %s').format(result.warning);
|
||||
setImportStatus(message, result.failed || result.warning ? 'err' : 'ok');
|
||||
renderResults();
|
||||
}).catch(function(e) {
|
||||
setImportStatus(_('Node group import failed: %s').format(e.message || e), 'err');
|
||||
|
||||
@@ -812,6 +812,12 @@ msgstr ""
|
||||
msgid "Node group imported: added %d, reused %d, failed %d"
|
||||
msgstr ""
|
||||
|
||||
msgid "Completed with warning: %s"
|
||||
msgstr ""
|
||||
|
||||
msgid "Managed subscription no longer exists in daed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Node group import failed: %s"
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -1004,6 +1004,12 @@ msgstr "正在导入节点组…"
|
||||
msgid "Node group imported: added %d, reused %d, failed %d"
|
||||
msgstr "节点组导入完成:新增 %d,复用 %d,失败 %d"
|
||||
|
||||
msgid "Completed with warning: %s"
|
||||
msgstr "已完成,但有警告:%s"
|
||||
|
||||
msgid "Managed subscription no longer exists in daed"
|
||||
msgstr "daed 中已不存在该托管订阅"
|
||||
|
||||
msgid "Node group import failed: %s"
|
||||
msgstr "节点组导入失败:%s"
|
||||
|
||||
|
||||
@@ -1004,6 +1004,12 @@ msgstr "正在导入节点组…"
|
||||
msgid "Node group imported: added %d, reused %d, failed %d"
|
||||
msgstr "节点组导入完成:新增 %d,复用 %d,失败 %d"
|
||||
|
||||
msgid "Completed with warning: %s"
|
||||
msgstr "已完成,但有警告:%s"
|
||||
|
||||
msgid "Managed subscription no longer exists in daed"
|
||||
msgstr "daed 中已不存在该托管订阅"
|
||||
|
||||
msgid "Node group import failed: %s"
|
||||
msgstr "节点组导入失败:%s"
|
||||
|
||||
|
||||
@@ -31,6 +31,12 @@ paths() {
|
||||
case "${name%.sub}" in ''|*[!A-Za-z0-9_]*) fail 'unexpected subscription filename' ;; esac
|
||||
printf 'etc/dae/subscriptions/%s\n' "$name"
|
||||
done
|
||||
for sub in "$1"/etc/daed/daede-sub-*; do
|
||||
[ -e "$sub" ] || [ -L "$sub" ] || continue
|
||||
name="${sub##*/daede-sub-}"
|
||||
case "$name" in ''|*[!A-Za-z0-9]*) fail 'unexpected daed subscription filename' ;; esac
|
||||
printf 'etc/daed/daede-sub-%s\n' "$name"
|
||||
done
|
||||
}
|
||||
check_paths() {
|
||||
for dir in /etc/config /etc/dae /etc/daed /etc/dae/subscriptions; do
|
||||
@@ -42,6 +48,14 @@ check_paths() {
|
||||
[ ! -e "/$p" ] || [ -f "/$p" ] || fail "not a regular file: /$p"
|
||||
done < "$WORK/paths"
|
||||
}
|
||||
cleanup_stages() {
|
||||
for stage in /etc/daed/.daede-sub-stage-*; do
|
||||
[ -e "$stage" ] || [ -L "$stage" ] || continue
|
||||
[ ! -L "$stage" ] || fail "refusing symlink: $stage"
|
||||
[ -f "$stage" ] || fail "not a regular file: $stage"
|
||||
rm -f "$stage" || fail "failed to remove stale snapshot stage: $stage"
|
||||
done
|
||||
}
|
||||
snapshot() {
|
||||
mkdir -p "$WORK/before"
|
||||
while IFS= read -r p; do
|
||||
@@ -139,6 +153,9 @@ validate_archive() {
|
||||
etc/dae/subscriptions/*.sub)
|
||||
name="${p#etc/dae/subscriptions/}"
|
||||
case "${name%.sub}" in ''|*[!A-Za-z0-9_]*) fail 'invalid subscription path' ;; esac ;;
|
||||
etc/daed/daede-sub-*)
|
||||
name="${p#etc/daed/daede-sub-}"
|
||||
case "$name" in ''|*[!A-Za-z0-9]*) fail 'invalid daed subscription path' ;; esac ;;
|
||||
*) fail 'unexpected archive entry' ;;
|
||||
esac
|
||||
done < "$WORK/entries"
|
||||
@@ -175,6 +192,7 @@ run_action() {
|
||||
fi
|
||||
fi
|
||||
stop_backends
|
||||
[ "$ACTION" = reset ] && cleanup_stages
|
||||
snapshot
|
||||
if [ "$ACTION" = export ]; then
|
||||
paths "$WORK/before" > "$WORK/candidates"
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
#!/bin/sh
|
||||
# Publish converter snapshots without exposing a partially written final file.
|
||||
|
||||
set -eu
|
||||
|
||||
DIR=/etc/daed
|
||||
PREFIX=daede-sub
|
||||
STAGE_PREFIX=.daede-sub-stage
|
||||
|
||||
fail() {
|
||||
echo "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
valid_token() {
|
||||
[ "$#" -eq 1 ] || return 1
|
||||
case "$1" in
|
||||
''|*[!A-Za-z0-9]*) return 1 ;;
|
||||
esac
|
||||
[ "${#1}" -le 64 ]
|
||||
}
|
||||
|
||||
stage_path() { printf '%s/%s-%s\n' "$DIR" "$STAGE_PREFIX" "$1"; }
|
||||
final_path() { printf '%s/%s-%s\n' "$DIR" "$PREFIX" "$1"; }
|
||||
|
||||
regular_file() {
|
||||
[ -f "$1" ] && [ ! -L "$1" ]
|
||||
}
|
||||
|
||||
safe_dir() {
|
||||
[ -d "$DIR" ] && [ ! -L "$DIR" ] || fail "invalid snapshot directory"
|
||||
}
|
||||
|
||||
publish() {
|
||||
[ "$#" -eq 1 ] || fail 'publish expects one token'
|
||||
valid_token "$1" || fail 'invalid snapshot token'
|
||||
safe_dir
|
||||
stage=$(stage_path "$1")
|
||||
final=$(final_path "$1")
|
||||
regular_file "$stage" || fail 'snapshot stage is not a regular file'
|
||||
[ ! -e "$final" ] && [ ! -L "$final" ] || fail 'snapshot already exists'
|
||||
chmod 600 "$stage" || fail 'cannot secure snapshot stage'
|
||||
mv -f "$stage" "$final" || fail 'cannot publish snapshot'
|
||||
}
|
||||
|
||||
replace() {
|
||||
[ "$#" -eq 2 ] || fail 'replace expects existing and stage tokens'
|
||||
valid_token "$1" || fail 'invalid existing snapshot token'
|
||||
valid_token "$2" || fail 'invalid stage snapshot token'
|
||||
safe_dir
|
||||
[ "$1" != "$2" ] || fail 'existing and stage tokens must differ'
|
||||
stage=$(stage_path "$2")
|
||||
final=$(final_path "$1")
|
||||
regular_file "$stage" || fail 'snapshot stage is not a regular file'
|
||||
if [ -e "$final" ] || [ -L "$final" ]; then
|
||||
regular_file "$final" || fail 'existing snapshot is not a regular file'
|
||||
fi
|
||||
chmod 600 "$stage" || fail 'cannot secure snapshot stage'
|
||||
mv -f "$stage" "$final" || fail 'cannot replace snapshot'
|
||||
}
|
||||
|
||||
discard() {
|
||||
[ "$#" -eq 1 ] || fail 'discard expects one token'
|
||||
valid_token "$1" || fail 'invalid snapshot token'
|
||||
safe_dir
|
||||
stage=$(stage_path "$1")
|
||||
if [ -L "$stage" ]; then
|
||||
fail 'refusing symlink snapshot stage'
|
||||
fi
|
||||
if [ -e "$stage" ]; then
|
||||
[ -f "$stage" ] || fail 'snapshot stage is not a regular file'
|
||||
rm -f "$stage" || fail 'cannot discard snapshot stage'
|
||||
fi
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
publish) shift; publish "$@" ;;
|
||||
replace) shift; replace "$@" ;;
|
||||
discard) shift; discard "$@" ;;
|
||||
*) fail 'unknown snapshot action' ;;
|
||||
esac
|
||||
@@ -43,9 +43,12 @@
|
||||
"/tmp/dae-validate.dae": [ "write" ],
|
||||
"/tmp/daede-import.b64": [ "write" ],
|
||||
"/tmp/daede-sub.txt": [ "write" ],
|
||||
"/tmp/daede-daedsub-*": [ "write" ],
|
||||
"/bin/rm -f /tmp/daede-daedsub-*": [ "exec" ],
|
||||
"/etc/daed/.daede-sub-stage-*": [ "write" ],
|
||||
"/etc/daed/daede-sub-*": [ "write" ],
|
||||
"/usr/share/luci-app-daede/config-backup.sh import": [ "exec" ],
|
||||
"/usr/share/luci-app-daede/snapshot-file.sh publish *": [ "exec" ],
|
||||
"/usr/share/luci-app-daede/snapshot-file.sh replace * *": [ "exec" ],
|
||||
"/usr/share/luci-app-daede/snapshot-file.sh discard *": [ "exec" ],
|
||||
"/usr/share/luci-app-daede/gen-dae-config.sh write-sub *": [ "exec" ],
|
||||
"/usr/share/luci-app-daede/gen-dae-config.sh delete-sub *": [ "exec" ],
|
||||
"/etc/config/daede": [ "write" ],
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
# daede-sub - serve a converter's staged node list to the local daed only.
|
||||
#
|
||||
# daed (dae-wing) can only build a subscription by HTTP-fetching a link, and it
|
||||
# does not read file:// like dae core. So the converter stages the base64 share
|
||||
# links in /tmp/daede-daedsub-<token> and points daed's importSubscription at
|
||||
# does not read file:// like dae core. So the converter stores the base64 share
|
||||
# links in /etc/daed/daede-sub-<token> and points daed's importSubscription at
|
||||
# http://127.0.0.1/cgi-bin/daede-sub?t=<token>. This CGI is the read side.
|
||||
#
|
||||
# Locked to loopback (REMOTE_ADDR), so even though uhttpd listens on the LAN no
|
||||
# remote client can pull the links. The frontend deletes the staged file after
|
||||
# the import (success or failure), so the exposure is one local fetch.
|
||||
# remote client can pull the links. The snapshot remains available for daed's
|
||||
# own refresh action and is included in daede configuration backups.
|
||||
|
||||
deny() { printf 'Status: 403 Forbidden\r\nContent-Type: text/plain\r\n\r\nforbidden\n'; exit 0; }
|
||||
|
||||
@@ -18,7 +18,7 @@ deny() { printf 'Status: 403 Forbidden\r\nContent-Type: text/plain\r\n\r\nforbid
|
||||
t=$(printf '%s' "$QUERY_STRING" | sed -n 's/^.*\bt=\([A-Za-z0-9]\{1,64\}\).*$/\1/p')
|
||||
[ -n "$t" ] || deny
|
||||
|
||||
f="/tmp/daede-daedsub-$t"
|
||||
f="/etc/daed/daede-sub-$t"
|
||||
[ -f "$f" ] || deny
|
||||
|
||||
printf 'Content-Type: text/plain\r\n\r\n'
|
||||
|
||||
@@ -12,7 +12,7 @@ LUCI_DEPENDS:=+luci-base +logd +jsonfilter
|
||||
LUCI_PKGARCH:=all
|
||||
|
||||
PKG_VERSION:=0.1.38
|
||||
PKG_RELEASE:=8
|
||||
PKG_RELEASE:=9
|
||||
|
||||
# Reproducible build: honor SOURCE_DATE_EPOCH from the build environment (set by docker-sdk.sh / CI).
|
||||
ifdef SOURCE_DATE_EPOCH
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
'use strict';
|
||||
/* SPDX-License-Identifier: Apache-2.0 */
|
||||
/* Copyright 2025-2026 Lucas Albers <lucas.b.albers@gmail.com> */
|
||||
'require baseclass';
|
||||
|
||||
/**
|
||||
@@ -16,26 +18,24 @@ function ingestCap(paused, rowLimit, fetchLinesMax) {
|
||||
|
||||
function mergeById(entries, normalized, cap) {
|
||||
if (!normalized || !normalized.length) {
|
||||
if (!entries || !entries.length)
|
||||
return [];
|
||||
if (!entries || !entries.length) return [];
|
||||
return entries.slice(-cap);
|
||||
}
|
||||
|
||||
const byId = {};
|
||||
let i;
|
||||
if (entries) {
|
||||
for (i = 0; i < entries.length; i++)
|
||||
byId[entries[i].id] = entries[i];
|
||||
for (i = 0; i < entries.length; i++) byId[entries[i].id] = entries[i];
|
||||
}
|
||||
for (i = 0; i < normalized.length; i++)
|
||||
byId[normalized[i].id] = normalized[i];
|
||||
for (i = 0; i < normalized.length; i++) byId[normalized[i].id] = normalized[i];
|
||||
|
||||
const merged = Object.keys(byId).map(function(id) { return byId[id]; });
|
||||
merged.sort(function(a, b) {
|
||||
const merged = Object.keys(byId).map(function (id) {
|
||||
return byId[id];
|
||||
});
|
||||
merged.sort(function (a, b) {
|
||||
const ta = a.timestamp || 0;
|
||||
const tb = b.timestamp || 0;
|
||||
if (ta !== tb)
|
||||
return ta - tb;
|
||||
if (ta !== tb) return ta - tb;
|
||||
return (a.log_id || 0) - (b.log_id || 0);
|
||||
});
|
||||
return merged.slice(-cap);
|
||||
@@ -58,13 +58,10 @@ function applyFetchedEntries(entries, normalized, opts) {
|
||||
const merge = paused || resumeMerge;
|
||||
|
||||
let next;
|
||||
if (merge)
|
||||
next = mergeById(entries, normalized, cap);
|
||||
else
|
||||
next = (normalized || []).slice(-cap);
|
||||
if (merge) next = mergeById(entries, normalized, cap);
|
||||
else next = (normalized || []).slice(-cap);
|
||||
|
||||
if (!paused && next.length > rowLimit)
|
||||
next = next.slice(-rowLimit);
|
||||
if (!paused && next.length > rowLimit) next = next.slice(-rowLimit);
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
'use strict';
|
||||
/* SPDX-License-Identifier: Apache-2.0 */
|
||||
/* Copyright 2025-2026 Lucas Albers <lucas.b.albers@gmail.com> */
|
||||
'require baseclass'; /* LuCI require() needs Class.isSubclass — plain return {} fails */
|
||||
'require fwlive.log as log';
|
||||
|
||||
@@ -16,16 +18,13 @@
|
||||
|
||||
function chipValueNodes(field, val) {
|
||||
const p = log.parseFilterValue(val);
|
||||
if (!p.value)
|
||||
return [ '' ];
|
||||
if (!p.value) return [''];
|
||||
|
||||
const valueNode = p.negate
|
||||
? E('span', { 'class': 'fwlive-chip-strike' }, [ p.value ])
|
||||
: p.value;
|
||||
const valueNode = p.negate ? E('span', { 'class': 'fwlive-chip-strike' }, [p.value]) : p.value;
|
||||
|
||||
if (!p.negate) {
|
||||
return [
|
||||
E('span', { 'class': 'fwlive-chip-polarity' }, [ _('is') ]),
|
||||
E('span', { 'class': 'fwlive-chip-polarity' }, [_('is')]),
|
||||
' ',
|
||||
log.formatFilterChipLabel(field, val)
|
||||
];
|
||||
@@ -34,26 +33,24 @@ function chipValueNodes(field, val) {
|
||||
if (field === 'q' || field === 'src' || field === 'dst')
|
||||
return [
|
||||
field + ': ',
|
||||
E('strong', { 'class': 'fwlive-chip-not' }, [ _('not') ]),
|
||||
E('strong', { 'class': 'fwlive-chip-not' }, [_('not')]),
|
||||
' contains ',
|
||||
valueNode
|
||||
];
|
||||
|
||||
return [
|
||||
field + ': ',
|
||||
E('strong', { 'class': 'fwlive-chip-not' }, [ _('not') ]),
|
||||
' ',
|
||||
valueNode
|
||||
];
|
||||
return [field + ': ', E('strong', { 'class': 'fwlive-chip-not' }, [_('not')]), ' ', valueNode];
|
||||
}
|
||||
|
||||
function chipLeadingSym(negated) {
|
||||
if (!negated)
|
||||
return null;
|
||||
return E('span', {
|
||||
'class': 'fwlive-chip-sym fwlive-chip-sym-light',
|
||||
'aria-hidden': 'true'
|
||||
}, [ '≠' ]);
|
||||
if (!negated) return null;
|
||||
return E(
|
||||
'span',
|
||||
{
|
||||
'class': 'fwlive-chip-sym fwlive-chip-sym-light',
|
||||
'aria-hidden': 'true'
|
||||
},
|
||||
['≠']
|
||||
);
|
||||
}
|
||||
|
||||
function renderFilterChips(host, state, callbacks) {
|
||||
@@ -64,38 +61,62 @@ function renderFilterChips(host, state, callbacks) {
|
||||
for (let i = 0; i < chipFields.length; i++) {
|
||||
const spec = chipFields[i];
|
||||
const val = filters[spec.key];
|
||||
if (!val)
|
||||
continue;
|
||||
if (!val) continue;
|
||||
|
||||
const parsed = log.parseFilterValue(val);
|
||||
const negated = parsed.negate;
|
||||
const kids = [];
|
||||
const lead = chipLeadingSym(negated);
|
||||
if (lead)
|
||||
kids.push(lead);
|
||||
if (lead) kids.push(lead);
|
||||
|
||||
kids.push(E('span', { 'class': 'fwlive-chip-label' }, chipValueNodes(spec.label, val)));
|
||||
kids.push(E('span', {
|
||||
'class': 'fwlive-chip-invert-wrap',
|
||||
'data-tip': negated ? _('Include instead') : _('Exclude instead')
|
||||
}, [
|
||||
E('button', {
|
||||
'type': 'button',
|
||||
'class': 'fwlive-chip-invert',
|
||||
'click': function(ev) { callbacks.onInvert(spec.key, ev); }
|
||||
}, [ '≠' ])
|
||||
]));
|
||||
kids.push(E('a', {
|
||||
'href': '#',
|
||||
'class': 'fwlive-chip-remove',
|
||||
'title': _('Remove filter'),
|
||||
'click': function(ev) { callbacks.onClear(spec.key, ev); }
|
||||
}, [ '×' ]));
|
||||
kids.push(
|
||||
E(
|
||||
'span',
|
||||
{
|
||||
'class': 'fwlive-chip-invert-wrap',
|
||||
'data-tip': negated ? _('Include instead') : _('Exclude instead')
|
||||
},
|
||||
[
|
||||
E(
|
||||
'button',
|
||||
{
|
||||
'type': 'button',
|
||||
'class': 'fwlive-chip-invert',
|
||||
'click': function (ev) {
|
||||
callbacks.onInvert(spec.key, ev);
|
||||
}
|
||||
},
|
||||
['≠']
|
||||
)
|
||||
]
|
||||
)
|
||||
);
|
||||
kids.push(
|
||||
E(
|
||||
'a',
|
||||
{
|
||||
'href': '#',
|
||||
'class': 'fwlive-chip-remove',
|
||||
'title': _('Remove filter'),
|
||||
'click': function (ev) {
|
||||
callbacks.onClear(spec.key, ev);
|
||||
}
|
||||
},
|
||||
['×']
|
||||
)
|
||||
);
|
||||
|
||||
chips.push(E('span', {
|
||||
'class': 'fwlive-chip'
|
||||
+ (negated ? ' fwlive-chip-negated' : ' fwlive-chip-include')
|
||||
}, kids));
|
||||
chips.push(
|
||||
E(
|
||||
'span',
|
||||
{
|
||||
'class':
|
||||
'fwlive-chip' + (negated ? ' fwlive-chip-negated' : ' fwlive-chip-include')
|
||||
},
|
||||
kids
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
host.className = 'fwlive-chips fwlive-chips-labels';
|
||||
@@ -106,14 +127,21 @@ function renderFilterChips(host, state, callbacks) {
|
||||
}
|
||||
|
||||
host.style.display = 'flex';
|
||||
for (let i = 0; i < chips.length; i++)
|
||||
host.appendChild(chips[i]);
|
||||
for (let i = 0; i < chips.length; i++) host.appendChild(chips[i]);
|
||||
|
||||
host.appendChild(E('a', {
|
||||
'href': '#',
|
||||
'class': 'fwlive-chip-clear',
|
||||
'click': function(ev) { callbacks.onClearAll(ev); }
|
||||
}, [ _('Clear all') ]));
|
||||
host.appendChild(
|
||||
E(
|
||||
'a',
|
||||
{
|
||||
'href': '#',
|
||||
'class': 'fwlive-chip-clear',
|
||||
'click': function (ev) {
|
||||
callbacks.onClearAll(ev);
|
||||
}
|
||||
},
|
||||
[_('Clear all')]
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return baseclass.extend({
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
'use strict';
|
||||
/* SPDX-License-Identifier: Apache-2.0 */
|
||||
/* Copyright 2025-2026 Lucas Albers <lucas.b.albers@gmail.com> */
|
||||
'require baseclass';
|
||||
|
||||
/**
|
||||
@@ -8,17 +10,32 @@
|
||||
return baseclass.extend({
|
||||
/* Keep in sync with openwrt-feed/luci-app-fwlive/Makefile PKG_VERSION. */
|
||||
APP_VERSION: '0.1.38',
|
||||
ROW_LIMIT_OPTIONS: [ 25, 50, 100, 250, 500, 1000, 2000 ],
|
||||
ROW_LIMIT_OPTIONS: [25, 50, 100, 250, 500, 1000, 2000],
|
||||
DEFAULT_ROW_LIMIT: 100,
|
||||
/* Row pass/deny tint (#40): classic green/red default; accessible teal/orange */
|
||||
ROW_TINT_OPTIONS: [ 'off', 'classic', 'accessible' ],
|
||||
ROW_TINT_OPTIONS: ['off', 'classic', 'accessible'],
|
||||
DEFAULT_ROW_TINT: 'classic',
|
||||
FETCH_LINES_MAX: 2000, /* ubus poll / logd ring cap (~2000 lines ≈ typical ring) */
|
||||
FETCH_LINES_MAX: 2000 /* ubus poll / logd ring cap (~2000 lines ≈ typical ring) */,
|
||||
/* DOM budget: ~250 new/updated rows painted per second on typical LuCI routers */
|
||||
RENDER_CAP_PER_SEC: 250,
|
||||
VIEW_MODES: [ 'simple', 'detailed' ],
|
||||
VIEW_MODES: ['simple', 'detailed'],
|
||||
COLUMN_SETS: {
|
||||
simple: [ 'action', 'time', 'iface', 'flow', 'proto', 'rule' ],
|
||||
detailed: [ 'time', 'action', 'rule', 'iface_in', 'iface_out', 'dir', 'proto', 'src', 'sport', 'dst', 'dport', 'flags', 'len', 'message' ]
|
||||
simple: ['action', 'time', 'iface', 'flow', 'proto', 'rule'],
|
||||
detailed: [
|
||||
'time',
|
||||
'action',
|
||||
'rule',
|
||||
'iface_in',
|
||||
'iface_out',
|
||||
'dir',
|
||||
'proto',
|
||||
'src',
|
||||
'sport',
|
||||
'dst',
|
||||
'dport',
|
||||
'flags',
|
||||
'len',
|
||||
'message'
|
||||
]
|
||||
}
|
||||
});
|
||||
@@ -1,4 +1,6 @@
|
||||
'use strict';
|
||||
/* SPDX-License-Identifier: Apache-2.0 */
|
||||
/* Copyright 2025-2026 Lucas Albers <lucas.b.albers@gmail.com> */
|
||||
'require baseclass';
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
'use strict';
|
||||
/* SPDX-License-Identifier: Apache-2.0 */
|
||||
/* Copyright 2025-2026 Lucas Albers <lucas.b.albers@gmail.com> */
|
||||
'require baseclass';
|
||||
|
||||
/**
|
||||
@@ -11,10 +13,9 @@ return baseclass.extend({
|
||||
FAIL_MAX: 1000,
|
||||
|
||||
/* Touch-on-write LRU: re-insert moves key to newest; evict oldest when over max. */
|
||||
lruSet: function(map, key, value, max) {
|
||||
lruSet: function (map, key, value, max) {
|
||||
const cap = max || this.CACHE_MAX;
|
||||
if (map.has(key))
|
||||
map.delete(key);
|
||||
if (map.has(key)) map.delete(key);
|
||||
map.set(key, value);
|
||||
while (map.size > cap) {
|
||||
const oldest = map.keys().next().value;
|
||||
@@ -23,33 +24,30 @@ return baseclass.extend({
|
||||
return map;
|
||||
},
|
||||
|
||||
lruGet: function(map, key) {
|
||||
if (!map.has(key))
|
||||
return undefined;
|
||||
lruGet: function (map, key) {
|
||||
if (!map.has(key)) return undefined;
|
||||
const value = map.get(key);
|
||||
map.delete(key);
|
||||
map.set(key, value);
|
||||
return value;
|
||||
},
|
||||
|
||||
failIsHot: function(failedMap, ip, nowMs, ttlMs) {
|
||||
if (!failedMap || !failedMap.has(ip))
|
||||
return false;
|
||||
failIsHot: function (failedMap, ip, nowMs, ttlMs) {
|
||||
if (!failedMap || !failedMap.has(ip)) return false;
|
||||
const at = failedMap.get(ip);
|
||||
const ttl = ttlMs == null ? this.FAIL_TTL_MS : ttlMs;
|
||||
const now = nowMs == null ? Date.now() : nowMs;
|
||||
if ((now - at) >= ttl) {
|
||||
if (now - at >= ttl) {
|
||||
failedMap.delete(ip);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
failMark: function(failedMap, ip, nowMs, max) {
|
||||
failMark: function (failedMap, ip, nowMs, max) {
|
||||
const cap = max || this.FAIL_MAX;
|
||||
const now = nowMs == null ? Date.now() : nowMs;
|
||||
if (failedMap.has(ip))
|
||||
failedMap.delete(ip);
|
||||
if (failedMap.has(ip)) failedMap.delete(ip);
|
||||
failedMap.set(ip, now);
|
||||
while (failedMap.size > cap) {
|
||||
const oldest = failedMap.keys().next().value;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
'use strict';
|
||||
/* SPDX-License-Identifier: Apache-2.0 */
|
||||
/* Copyright 2025-2026 Lucas Albers <lucas.b.albers@gmail.com> */
|
||||
'require baseclass'; /* LuCI require() needs Class.isSubclass — plain return {} fails */
|
||||
'require fwlive.log as log';
|
||||
|
||||
@@ -16,8 +18,7 @@
|
||||
*/
|
||||
|
||||
function luciUrl(path) {
|
||||
if (typeof L !== 'undefined' && L.url)
|
||||
return L.url(path);
|
||||
if (typeof L !== 'undefined' && L.url) return L.url(path);
|
||||
|
||||
return '/cgi-bin/luci/' + path;
|
||||
}
|
||||
@@ -31,10 +32,14 @@ function firewallZonesUrl() {
|
||||
}
|
||||
|
||||
function firewallZonesLink(label) {
|
||||
return E('a', {
|
||||
'href': firewallZonesUrl(),
|
||||
'class': 'fwlive-filter-link'
|
||||
}, [ label || _('Network → Firewall') ]);
|
||||
return E(
|
||||
'a',
|
||||
{
|
||||
'href': firewallZonesUrl(),
|
||||
'class': 'fwlive-filter-link'
|
||||
},
|
||||
[label || _('Network → Firewall')]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -44,15 +49,20 @@ function firewallZonesLink(label) {
|
||||
* @param {function} onFilterClick - callback(field, value, ev)
|
||||
*/
|
||||
function filterLink(field, value, label, onFilterClick) {
|
||||
if (!value)
|
||||
return log.formatCell(value);
|
||||
if (!value) return log.formatCell(value);
|
||||
|
||||
return E('a', {
|
||||
'href': '#',
|
||||
'class': 'fwlive-filter-link',
|
||||
'title': _('Filter by %s').format(field),
|
||||
'click': function(ev) { onFilterClick(field, value, ev); }
|
||||
}, [ label || value ]);
|
||||
return E(
|
||||
'a',
|
||||
{
|
||||
'href': '#',
|
||||
'class': 'fwlive-filter-link',
|
||||
'title': _('Filter by %s').format(field),
|
||||
'click': function (ev) {
|
||||
onFilterClick(field, value, ev);
|
||||
}
|
||||
},
|
||||
[label || value]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -63,19 +73,24 @@ function filterLink(field, value, label, onFilterClick) {
|
||||
* @param {function} onFilterClick - callback(field, value, ev)
|
||||
*/
|
||||
function addrFilterLink(field, ip, showHostnames, hostnameCache, onFilterClick) {
|
||||
if (!ip)
|
||||
return log.formatCell(ip);
|
||||
if (!ip) return log.formatCell(ip);
|
||||
|
||||
const name = showHostnames && hostnameCache ? hostnameCache.get(ip) : null;
|
||||
const display = name || ip;
|
||||
const title = name ? ip : _('Filter by %s').format(field);
|
||||
|
||||
return E('a', {
|
||||
'href': '#',
|
||||
'class': 'fwlive-filter-link',
|
||||
'title': title,
|
||||
'click': function(ev) { onFilterClick(field, ip, ev); }
|
||||
}, [ display ]);
|
||||
return E(
|
||||
'a',
|
||||
{
|
||||
'href': '#',
|
||||
'class': 'fwlive-filter-link',
|
||||
'title': title,
|
||||
'click': function (ev) {
|
||||
onFilterClick(field, ip, ev);
|
||||
}
|
||||
},
|
||||
[display]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -83,11 +98,9 @@ function addrFilterLink(field, ip, showHostnames, hostnameCache, onFilterClick)
|
||||
* @param {string} firewallBackend - 'nft' or 'iptables'
|
||||
*/
|
||||
function ruleAdminPath(hint, firewallBackend) {
|
||||
if (hint === 'fw4')
|
||||
return 'admin/network/firewall/rules';
|
||||
if (hint === 'fw4') return 'admin/network/firewall/rules';
|
||||
|
||||
if (firewallBackend === 'iptables')
|
||||
return 'admin/status/iptables';
|
||||
if (firewallBackend === 'iptables') return 'admin/status/iptables';
|
||||
|
||||
return 'admin/status/nftables';
|
||||
}
|
||||
@@ -99,27 +112,31 @@ function ruleAdminPath(hint, firewallBackend) {
|
||||
* @param {function} onFilterClick - callback(field, value, ev)
|
||||
*/
|
||||
function ruleAdminLink(hint, label, firewallBackend, onFilterClick) {
|
||||
if (!hint)
|
||||
return log.formatCell(hint);
|
||||
if (!hint) return log.formatCell(hint);
|
||||
|
||||
const path = ruleAdminPath(hint, firewallBackend);
|
||||
const url = '%s#%s'.format(luciUrl(path), encodeURIComponent(hint));
|
||||
const text = label || hint;
|
||||
|
||||
return E('a', {
|
||||
'href': '#',
|
||||
'class': 'fwlive-filter-link fwlive-rule-link',
|
||||
'title': _('Filter logs by rule (hint: %s). Ctrl+click to open firewall settings.').format(hint),
|
||||
'click': function(ev) {
|
||||
if (ev && (ev.ctrlKey || ev.metaKey)) {
|
||||
if (ev.preventDefault)
|
||||
ev.preventDefault();
|
||||
window.location = url;
|
||||
return;
|
||||
return E(
|
||||
'a',
|
||||
{
|
||||
'href': '#',
|
||||
'class': 'fwlive-filter-link fwlive-rule-link',
|
||||
'title': _(
|
||||
'Filter logs by rule (hint: %s). Ctrl+click to open firewall settings.'
|
||||
).format(hint),
|
||||
'click': function (ev) {
|
||||
if (ev && (ev.ctrlKey || ev.metaKey)) {
|
||||
if (ev.preventDefault) ev.preventDefault();
|
||||
window.location = url;
|
||||
return;
|
||||
}
|
||||
onFilterClick('q', hint, ev);
|
||||
}
|
||||
onFilterClick('q', hint, ev);
|
||||
}
|
||||
}, [ text ]);
|
||||
},
|
||||
[text]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -127,15 +144,20 @@ function ruleAdminLink(hint, label, firewallBackend, onFilterClick) {
|
||||
* @param {function} onFilterClick - callback(field, value, ev)
|
||||
*/
|
||||
function ifaceLink(value, onFilterClick) {
|
||||
if (!value)
|
||||
return log.formatCell(value);
|
||||
if (!value) return log.formatCell(value);
|
||||
|
||||
return E('a', {
|
||||
'href': '#',
|
||||
'class': 'fwlive-filter-link fwlive-iface-badge',
|
||||
'title': _('Filter by interface'),
|
||||
'click': function(ev) { onFilterClick('interface', value, ev); }
|
||||
}, [ value ]);
|
||||
return E(
|
||||
'a',
|
||||
{
|
||||
'href': '#',
|
||||
'class': 'fwlive-filter-link fwlive-iface-badge',
|
||||
'title': _('Filter by interface'),
|
||||
'click': function (ev) {
|
||||
onFilterClick('interface', value, ev);
|
||||
}
|
||||
},
|
||||
[value]
|
||||
);
|
||||
}
|
||||
|
||||
return baseclass.extend({
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
'use strict';
|
||||
/* SPDX-License-Identifier: Apache-2.0 */
|
||||
/* Copyright 2025-2026 Lucas Albers <lucas.b.albers@gmail.com> */
|
||||
'require baseclass';
|
||||
|
||||
/**
|
||||
@@ -8,236 +10,253 @@
|
||||
*/
|
||||
return baseclass.extend({
|
||||
CLASSIFY_SPEC: {
|
||||
glueKeys: ['IN', 'OUT', 'SRC', 'DST', 'PROTO', 'SPT', 'DPT', 'LEN', 'MAC', 'TYPE', 'CODE', 'TTL', 'TOS', 'PREC', 'DF'],
|
||||
nonFirewallPrefixes: ['dnsmasq', 'procd', 'ubusd', 'netifd', 'odhcpd', 'logd', 'dropbear', 'uhttpd', 'hostapd', 'wpad'],
|
||||
glueKeys: [
|
||||
'IN',
|
||||
'OUT',
|
||||
'SRC',
|
||||
'DST',
|
||||
'PROTO',
|
||||
'SPT',
|
||||
'DPT',
|
||||
'LEN',
|
||||
'MAC',
|
||||
'TYPE',
|
||||
'CODE',
|
||||
'TTL',
|
||||
'TOS',
|
||||
'PREC',
|
||||
'DF'
|
||||
],
|
||||
nonFirewallPrefixes: [
|
||||
'dnsmasq',
|
||||
'procd',
|
||||
'ubusd',
|
||||
'netifd',
|
||||
'odhcpd',
|
||||
'logd',
|
||||
'dropbear',
|
||||
'uhttpd',
|
||||
'hostapd',
|
||||
'wpad'
|
||||
],
|
||||
firewallHints: ['fw4', 'nft', 'iptables', 'kernel', 'firewall'],
|
||||
actionWords: ['ACCEPT', 'ALLOW', 'PASS', 'DROP', 'REJECT', 'DENY', 'BLOCK'],
|
||||
rules: [
|
||||
{ or: [
|
||||
{ and: [ { kv: ['SRC'] }, { kv: ['DST'] } ] },
|
||||
{ and: [ { kvAny: ['IN', 'OUT'] }, { kvAny: ['SRC', 'DST', 'PROTO', 'SPT', 'DPT'] } ] },
|
||||
{ and: [ { action: 'known' }, { kvAny: ['IN', 'OUT', 'PROTO', 'SRC', 'DST'] } ] }
|
||||
]},
|
||||
{ and: [ { hint: true }, { action: 'known' } ] },
|
||||
{ and: [ { hint: true }, { kvAny: ['IN', 'OUT', 'SRC', 'DST', 'PROTO'] } ] }
|
||||
{
|
||||
or: [
|
||||
{ and: [{ kv: ['SRC'] }, { kv: ['DST'] }] },
|
||||
{
|
||||
and: [
|
||||
{ kvAny: ['IN', 'OUT'] },
|
||||
{ kvAny: ['SRC', 'DST', 'PROTO', 'SPT', 'DPT'] }
|
||||
]
|
||||
},
|
||||
{ and: [{ action: 'known' }, { kvAny: ['IN', 'OUT', 'PROTO', 'SRC', 'DST'] }] }
|
||||
]
|
||||
},
|
||||
{ and: [{ hint: true }, { action: 'known' }] },
|
||||
{ and: [{ hint: true }, { kvAny: ['IN', 'OUT', 'SRC', 'DST', 'PROTO'] }] }
|
||||
]
|
||||
},
|
||||
|
||||
TCP_FLAG_TAIL: /\b(SYN|ACK|FIN|RST|PSH|URG)(?:\s+(?:SYN|ACK|FIN|RST|PSH|URG))*\s*$/i,
|
||||
NETFILTER_KV_GLUE: /([^\s])(?=(IN|OUT|SRC|DST|PROTO|SPT|DPT|LEN|MAC|TYPE|CODE|TTL|TOS|PREC|DF)=)/g,
|
||||
NETFILTER_KV_GLUE:
|
||||
/([^\s])(?=(IN|OUT|SRC|DST|PROTO|SPT|DPT|LEN|MAC|TYPE|CODE|TTL|TOS|PREC|DF)=)/g,
|
||||
|
||||
wordPattern: function(words) {
|
||||
wordPattern: function (words) {
|
||||
const alt = words.join('|');
|
||||
return new RegExp('(^|[^A-Za-z0-9_])(' + alt + ')([^A-Za-z0-9_]|$)', 'i');
|
||||
},
|
||||
|
||||
kvHas: function(msg, key) {
|
||||
kvHas: function (msg, key) {
|
||||
return new RegExp('(^|[^A-Za-z0-9_])' + key + '=').test(msg);
|
||||
},
|
||||
|
||||
NON_FIREWALL_PREFIX: /^(dnsmasq|procd|ubusd|netifd|odhcpd|logd|dropbear|uhttpd|hostapd|wpad)([^A-Za-z0-9_]|$)/i,
|
||||
NON_FIREWALL_PREFIX:
|
||||
/^(dnsmasq|procd|ubusd|netifd|odhcpd|logd|dropbear|uhttpd|hostapd|wpad)([^A-Za-z0-9_]|$)/i,
|
||||
FIREWALL_HINT: /(^|[^A-Za-z0-9_])(fw4|nft|iptables|kernel|firewall)([^A-Za-z0-9_]|$)/i,
|
||||
ACTION_RE: /(^|[^A-Za-z0-9_])(ACCEPT|ALLOW|PASS|DROP|REJECT|DENY|BLOCK)([^A-Za-z0-9_]|$)/i,
|
||||
DENY_ACTION: /(^|[^A-Za-z0-9_])(DROP|REJECT|DENY|BLOCK)([^A-Za-z0-9_]|$)/i,
|
||||
|
||||
normalizeNetfilterMessage: function(message) {
|
||||
normalizeNetfilterMessage: function (message) {
|
||||
return (message || '').replace(this.NETFILTER_KV_GLUE, '$1 ');
|
||||
},
|
||||
|
||||
parseKeyValueLog: function(message) {
|
||||
parseKeyValueLog: function (message) {
|
||||
const out = {};
|
||||
const re = /\b([A-Z]+)=([^\s]+)/g;
|
||||
const normalized = this.normalizeNetfilterMessage(message);
|
||||
let match;
|
||||
|
||||
while ((match = re.exec(normalized)) !== null)
|
||||
out[match[1]] = match[2];
|
||||
while ((match = re.exec(normalized)) !== null) out[match[1]] = match[2];
|
||||
|
||||
return out;
|
||||
},
|
||||
|
||||
detectAction: function(message) {
|
||||
detectAction: function (message) {
|
||||
const m = message.match(this.ACTION_RE);
|
||||
return m ? m[2].toUpperCase() : 'UNKNOWN';
|
||||
},
|
||||
|
||||
evaluateClassifySpec: function(message, actionRaw) {
|
||||
evaluateClassifySpec: function (message, actionRaw) {
|
||||
const msg = this.normalizeNetfilterMessage(message || '');
|
||||
const action = actionRaw === undefined ? this.detectAction(msg) : actionRaw;
|
||||
const self = this;
|
||||
const has = function(key) { return self.kvHas(msg, key); };
|
||||
const hasAny = function(keys) {
|
||||
const has = function (key) {
|
||||
return self.kvHas(msg, key);
|
||||
};
|
||||
const hasAny = function (keys) {
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
if (has(keys[i]))
|
||||
return true;
|
||||
if (has(keys[i])) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const pred = {
|
||||
kv: function(c) {
|
||||
kv: function (c) {
|
||||
for (let i = 0; i < c.kv.length; i++) {
|
||||
if (!has(c.kv[i]))
|
||||
return false;
|
||||
if (!has(c.kv[i])) return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
kvAny: function(c) { return hasAny(c.kvAny); },
|
||||
action: function(c) { return (c.action === 'known') ? action !== 'UNKNOWN' : true; },
|
||||
hint: function() { return self.FIREWALL_HINT.test(msg); }
|
||||
kvAny: function (c) {
|
||||
return hasAny(c.kvAny);
|
||||
},
|
||||
action: function (c) {
|
||||
return c.action === 'known' ? action !== 'UNKNOWN' : true;
|
||||
},
|
||||
hint: function () {
|
||||
return self.FIREWALL_HINT.test(msg);
|
||||
}
|
||||
};
|
||||
|
||||
const evalNode = function(node) {
|
||||
const evalNode = function (node) {
|
||||
if (node.and) {
|
||||
for (let i = 0; i < node.and.length; i++) {
|
||||
if (!evalNode(node.and[i]))
|
||||
return false;
|
||||
if (!evalNode(node.and[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (node.or) {
|
||||
for (let i = 0; i < node.or.length; i++) {
|
||||
if (evalNode(node.or[i]))
|
||||
return true;
|
||||
if (evalNode(node.or[i])) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
const keys = Object.keys(node);
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const k = keys[i];
|
||||
if (pred[k])
|
||||
return pred[k](node);
|
||||
if (pred[k]) return pred[k](node);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
for (let i = 0; i < this.CLASSIFY_SPEC.rules.length; i++) {
|
||||
if (evalNode(this.CLASSIFY_SPEC.rules[i]))
|
||||
return true;
|
||||
if (evalNode(this.CLASSIFY_SPEC.rules[i])) return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
normalizeAction: function(raw) {
|
||||
normalizeAction: function (raw) {
|
||||
const a = (raw || '').toUpperCase();
|
||||
const words = this.CLASSIFY_SPEC.actionWords;
|
||||
const pass = words.slice(0, 3); /* ACCEPT|ALLOW|PASS */
|
||||
const denyClass = words.slice(3); /* DROP|REJECT|DENY|BLOCK — positional */
|
||||
if (pass.indexOf(a) >= 0)
|
||||
return 'pass';
|
||||
if (a === words[3]) /* DROP */
|
||||
return 'drop';
|
||||
if (a === words[4]) /* REJECT */
|
||||
return 'reject';
|
||||
if (denyClass.indexOf(a) >= 0) /* DENY|BLOCK */
|
||||
return 'block';
|
||||
if (pass.indexOf(a) >= 0) return 'pass';
|
||||
if (a === words[3]) /* DROP */ return 'drop';
|
||||
if (a === words[4]) /* REJECT */ return 'reject';
|
||||
if (denyClass.indexOf(a) >= 0) /* DENY|BLOCK */ return 'block';
|
||||
return 'unknown';
|
||||
},
|
||||
|
||||
parseRuleHint: function(message) {
|
||||
parseRuleHint: function (message) {
|
||||
let msg = this.normalizeNetfilterMessage(message || '').trim();
|
||||
msg = msg.replace(/^\[\s*[\d.]+\]\s*/, '');
|
||||
|
||||
if (/^fw4:\s*/i.test(msg))
|
||||
return 'fw4';
|
||||
if (/^fw4:\s*/i.test(msg)) return 'fw4';
|
||||
|
||||
const beforeKv = msg.match(/^([A-Za-z0-9_.-]+)(?::|\s+)(?=IN=|OUT=|SRC=|DST=|PROTO=)/);
|
||||
if (beforeKv)
|
||||
return beforeKv[1];
|
||||
if (beforeKv) return beforeKv[1];
|
||||
|
||||
const colon = msg.match(/^([A-Za-z0-9_.-]+):/);
|
||||
if (colon) {
|
||||
const tag = colon[1].toLowerCase();
|
||||
if (tag !== 'kernel' && tag !== 'iptables')
|
||||
return colon[1];
|
||||
if (tag !== 'kernel' && tag !== 'iptables') return colon[1];
|
||||
}
|
||||
|
||||
return '';
|
||||
},
|
||||
|
||||
formatRuleLabel: function(hint) {
|
||||
if (!hint)
|
||||
return '';
|
||||
formatRuleLabel: function (hint) {
|
||||
if (!hint) return '';
|
||||
|
||||
if (hint === 'fw4')
|
||||
return 'Firewall4';
|
||||
if (hint === 'fw4') return 'Firewall4';
|
||||
|
||||
return hint.replace(/-/g, ' ');
|
||||
},
|
||||
|
||||
inferActionRaw: function(message, kv, actionRaw) {
|
||||
if (actionRaw && actionRaw !== 'UNKNOWN')
|
||||
return actionRaw;
|
||||
inferActionRaw: function (message, kv, actionRaw) {
|
||||
if (actionRaw && actionRaw !== 'UNKNOWN') return actionRaw;
|
||||
|
||||
const msg = this.normalizeNetfilterMessage(message || '');
|
||||
const withoutKv = msg.replace(/\b[A-Z]+=[^\s]*/g, ' ');
|
||||
if (this.DENY_ACTION.test(withoutKv))
|
||||
return 'UNKNOWN';
|
||||
if (this.DENY_ACTION.test(withoutKv)) return 'UNKNOWN';
|
||||
|
||||
if (/^kernel:/i.test(msg.trim()))
|
||||
return 'UNKNOWN';
|
||||
if (/^kernel:/i.test(msg.trim())) return 'UNKNOWN';
|
||||
|
||||
const hasTuple = !!(kv.IN || kv.OUT) && !!(kv.SRC || kv.DST || kv.PROTO);
|
||||
if (hasTuple)
|
||||
return 'PASS';
|
||||
if (hasTuple) return 'PASS';
|
||||
|
||||
return 'UNKNOWN';
|
||||
},
|
||||
|
||||
parseFlags: function(message, kv) {
|
||||
if (kv.TCPFLAGS)
|
||||
return kv.TCPFLAGS;
|
||||
if (kv.FLAGS)
|
||||
return kv.FLAGS;
|
||||
parseFlags: function (message, kv) {
|
||||
if (kv.TCPFLAGS) return kv.TCPFLAGS;
|
||||
if (kv.FLAGS) return kv.FLAGS;
|
||||
|
||||
const m = message.match(this.TCP_FLAG_TAIL);
|
||||
if (!m)
|
||||
return '';
|
||||
if (!m) return '';
|
||||
|
||||
return m[0].trim().toUpperCase().replace(/\s+/g, ',');
|
||||
},
|
||||
|
||||
parseLength: function(kv) {
|
||||
parseLength: function (kv) {
|
||||
const len = kv.LEN || kv.LENGTH || '';
|
||||
if (!len)
|
||||
return null;
|
||||
if (!len) return null;
|
||||
|
||||
const n = parseInt(len, 10);
|
||||
return isFinite(n) ? n : null;
|
||||
},
|
||||
|
||||
timestampUnix: function(entry) {
|
||||
if (!entry || entry.time == null || entry.time === '')
|
||||
return null;
|
||||
timestampUnix: function (entry) {
|
||||
if (!entry || entry.time == null || entry.time === '') return null;
|
||||
|
||||
if (typeof entry.time === 'string' && /^\d{4}-\d{2}-\d{2}[T ]/.test(entry.time)) {
|
||||
const ms = new Date(entry.time).getTime();
|
||||
if (isFinite(ms))
|
||||
return Math.floor(ms / 1000);
|
||||
if (isFinite(ms)) return Math.floor(ms / 1000);
|
||||
}
|
||||
|
||||
const n = Number(entry.time);
|
||||
if (!isFinite(n))
|
||||
return null;
|
||||
if (!isFinite(n)) return null;
|
||||
|
||||
return n > 1e12 ? Math.floor(n / 1000) : Math.floor(n);
|
||||
},
|
||||
|
||||
formatTimestampDisplay: function(entry) {
|
||||
formatTimestampDisplay: function (entry) {
|
||||
const unix = this.timestampUnix(entry);
|
||||
if (unix == null)
|
||||
return '';
|
||||
if (unix == null) return '';
|
||||
|
||||
return new Date(unix * 1000).toISOString();
|
||||
},
|
||||
|
||||
/* @fwlive-codegen:luci-preserve-begin */
|
||||
formatTimestampLocal: function(unix) {
|
||||
if (unix == null || !isFinite(unix))
|
||||
return '';
|
||||
formatTimestampLocal: function (unix) {
|
||||
if (unix == null || !isFinite(unix)) return '';
|
||||
|
||||
const d = new Date(unix * 1000);
|
||||
const pad = function(n) { return (n < 10 ? '0' : '') + n; };
|
||||
const pad = function (n) {
|
||||
return (n < 10 ? '0' : '') + n;
|
||||
};
|
||||
|
||||
return '%d-%s-%s %s:%s:%s'.format(
|
||||
d.getFullYear(),
|
||||
@@ -249,17 +268,18 @@ return baseclass.extend({
|
||||
);
|
||||
},
|
||||
|
||||
formatTimestampCompact: function(unix) {
|
||||
if (unix == null || !isFinite(unix))
|
||||
return '';
|
||||
formatTimestampCompact: function (unix) {
|
||||
if (unix == null || !isFinite(unix)) return '';
|
||||
|
||||
const d = new Date(unix * 1000);
|
||||
const pad = function(n) { return (n < 10 ? '0' : '') + n; };
|
||||
const pad = function (n) {
|
||||
return (n < 10 ? '0' : '') + n;
|
||||
};
|
||||
|
||||
return '%s:%s:%s'.format(pad(d.getHours()), pad(d.getMinutes()), pad(d.getSeconds()));
|
||||
},
|
||||
|
||||
formatFlowDisplay: function(row) {
|
||||
formatFlowDisplay: function (row) {
|
||||
const src = row && row.src ? String(row.src) : '';
|
||||
const dst = row && row.dst ? String(row.dst) : '';
|
||||
const sport = row && row.sport ? String(row.sport) : '';
|
||||
@@ -267,75 +287,89 @@ return baseclass.extend({
|
||||
let left = src;
|
||||
let right = dst;
|
||||
|
||||
if (sport)
|
||||
left = left ? (left + ':' + sport) : (':' + sport);
|
||||
if (dport)
|
||||
right = right ? (right + ':' + dport) : (':' + dport);
|
||||
if (sport) left = left ? left + ':' + sport : ':' + sport;
|
||||
if (dport) right = right ? right + ':' + dport : ':' + dport;
|
||||
|
||||
if (!left && !right)
|
||||
return '—';
|
||||
if (!right)
|
||||
return left;
|
||||
if (!left)
|
||||
return '→ ' + right;
|
||||
if (!left && !right) return '—';
|
||||
if (!right) return left;
|
||||
if (!left) return '→ ' + right;
|
||||
|
||||
return left + ' → ' + right;
|
||||
},
|
||||
|
||||
formatCell: function(value) {
|
||||
if (value == null || value === '')
|
||||
return '';
|
||||
formatCell: function (value) {
|
||||
if (value == null || value === '') return '';
|
||||
|
||||
return String(value);
|
||||
},
|
||||
|
||||
formatActionLabel: function(action) {
|
||||
formatActionLabel: function (action) {
|
||||
const a = (action || '').toLowerCase();
|
||||
if (!a || a === 'unknown')
|
||||
return '—';
|
||||
if (!a || a === 'unknown') return '—';
|
||||
return a;
|
||||
},
|
||||
|
||||
formatMessageDisplay: function(message, layout) {
|
||||
formatMessageDisplay: function (message, layout) {
|
||||
let m = this.normalizeNetfilterMessage(message || '');
|
||||
m = m.replace(/^\[\s*[\d.]+\]\s*/, '');
|
||||
m = m.replace(/\bMAC=[^\s]+/g, '');
|
||||
m = m.replace(/\s+/g, ' ').trim();
|
||||
|
||||
if (layout === 'oneline')
|
||||
return m;
|
||||
if (layout === 'oneline') return m;
|
||||
|
||||
if (m.length > 240)
|
||||
return m.substring(0, 237) + '…';
|
||||
if (m.length > 240) return m.substring(0, 237) + '…';
|
||||
|
||||
return m;
|
||||
},
|
||||
/* @fwlive-codegen:luci-preserve-end */
|
||||
|
||||
isFirewallEvent: function(entry) {
|
||||
isFirewallEvent: function (entry) {
|
||||
const msg = this.normalizeNetfilterMessage((entry && entry.msg) || '');
|
||||
if (!msg.trim())
|
||||
return false;
|
||||
if (!msg.trim()) return false;
|
||||
|
||||
if (this.NON_FIREWALL_PREFIX.test(msg))
|
||||
return false;
|
||||
if (this.NON_FIREWALL_PREFIX.test(msg)) return false;
|
||||
|
||||
return this.evaluateClassifySpec(msg);
|
||||
},
|
||||
|
||||
makeEntryId: function(entry, tsUnix, action, src, dst, sport, dport, proto, ifaceIn, ifaceOut) {
|
||||
if (entry && entry.id != null && entry.id !== '')
|
||||
return 'log:' + entry.id;
|
||||
makeEntryId: function (
|
||||
entry,
|
||||
tsUnix,
|
||||
action,
|
||||
src,
|
||||
dst,
|
||||
sport,
|
||||
dport,
|
||||
proto,
|
||||
ifaceIn,
|
||||
ifaceOut
|
||||
) {
|
||||
if (entry && entry.id != null && entry.id !== '') return 'log:' + entry.id;
|
||||
|
||||
return [tsUnix, action, src, dst, sport, dport, proto, ifaceIn, ifaceOut, entry.msg || ''].join('|');
|
||||
return [
|
||||
tsUnix,
|
||||
action,
|
||||
src,
|
||||
dst,
|
||||
sport,
|
||||
dport,
|
||||
proto,
|
||||
ifaceIn,
|
||||
ifaceOut,
|
||||
entry.msg || ''
|
||||
].join('|');
|
||||
},
|
||||
|
||||
normalizeEntry: function(entry) {
|
||||
normalizeEntry: function (entry) {
|
||||
const kv = this.parseKeyValueLog(entry.msg || '');
|
||||
const tsUnix = this.timestampUnix(entry);
|
||||
const tsDisplay = this.formatTimestampDisplay(entry);
|
||||
const proto = (kv.PROTO || '').toUpperCase();
|
||||
const actionRaw = this.inferActionRaw(entry.msg || '', kv, this.detectAction(entry.msg || ''));
|
||||
const actionRaw = this.inferActionRaw(
|
||||
entry.msg || '',
|
||||
kv,
|
||||
this.detectAction(entry.msg || '')
|
||||
);
|
||||
const action = this.normalizeAction(actionRaw);
|
||||
const src = kv.SRC || '';
|
||||
const dst = kv.DST || '';
|
||||
@@ -344,14 +378,25 @@ return baseclass.extend({
|
||||
const ifaceIn = kv.IN || '';
|
||||
const ifaceOut = kv.OUT || '';
|
||||
const iface = ifaceIn || ifaceOut || '';
|
||||
const dir = ifaceIn && ifaceOut ? 'forward' : (ifaceIn ? 'in' : (ifaceOut ? 'out' : 'unknown'));
|
||||
const dir = ifaceIn && ifaceOut ? 'forward' : ifaceIn ? 'in' : ifaceOut ? 'out' : 'unknown';
|
||||
const flags = this.parseFlags(entry.msg || '', kv);
|
||||
const length = this.parseLength(kv);
|
||||
const ruleHint = this.parseRuleHint(entry.msg || '');
|
||||
const ruleLabel = this.formatRuleLabel(ruleHint);
|
||||
|
||||
return {
|
||||
id: this.makeEntryId(entry, tsUnix, action, src, dst, sport, dport, proto, ifaceIn, ifaceOut),
|
||||
id: this.makeEntryId(
|
||||
entry,
|
||||
tsUnix,
|
||||
action,
|
||||
src,
|
||||
dst,
|
||||
sport,
|
||||
dport,
|
||||
proto,
|
||||
ifaceIn,
|
||||
ifaceOut
|
||||
),
|
||||
log_id: entry && entry.id != null ? Number(entry.id) : null,
|
||||
timestamp: tsUnix,
|
||||
timestamp_display: tsDisplay,
|
||||
@@ -374,29 +419,25 @@ return baseclass.extend({
|
||||
};
|
||||
},
|
||||
|
||||
parseFilterValue: function(val) {
|
||||
parseFilterValue: function (val) {
|
||||
const s = (val || '').trim();
|
||||
if (!s)
|
||||
return { negate: false, value: '' };
|
||||
if (!s) return { negate: false, value: '' };
|
||||
|
||||
if (s.charAt(0) === '!')
|
||||
return { negate: true, value: s.slice(1).trim() };
|
||||
if (s.charAt(0) === '!') return { negate: true, value: s.slice(1).trim() };
|
||||
|
||||
return { negate: false, value: s };
|
||||
},
|
||||
|
||||
toggleFilterNegation: function(val) {
|
||||
toggleFilterNegation: function (val) {
|
||||
const p = this.parseFilterValue(val);
|
||||
if (!p.value)
|
||||
return val;
|
||||
if (!p.value) return val;
|
||||
|
||||
return p.negate ? p.value : '!' + p.value;
|
||||
},
|
||||
|
||||
formatFilterChipLabel: function(field, val) {
|
||||
formatFilterChipLabel: function (field, val) {
|
||||
const p = this.parseFilterValue(val);
|
||||
if (!p.value)
|
||||
return '';
|
||||
if (!p.value) return '';
|
||||
|
||||
if (p.negate) {
|
||||
if (field === 'q' || field === 'src' || field === 'dst')
|
||||
@@ -408,19 +449,17 @@ return baseclass.extend({
|
||||
return '%s: %s'.format(field, val);
|
||||
},
|
||||
|
||||
matchesTextField: function(haystack, spec) {
|
||||
matchesTextField: function (haystack, spec) {
|
||||
const p = this.parseFilterValue(spec);
|
||||
if (!p.value)
|
||||
return true;
|
||||
if (!p.value) return true;
|
||||
|
||||
const hit = (haystack || '').indexOf(p.value) !== -1;
|
||||
return p.negate ? !hit : hit;
|
||||
},
|
||||
|
||||
matchesExactField: function(haystack, spec) {
|
||||
matchesExactField: function (haystack, spec) {
|
||||
const p = this.parseFilterValue(spec);
|
||||
if (!p.value)
|
||||
return true;
|
||||
if (!p.value) return true;
|
||||
|
||||
const want = p.value.toUpperCase();
|
||||
const got = (haystack || '').toUpperCase();
|
||||
@@ -428,18 +467,16 @@ return baseclass.extend({
|
||||
return p.negate ? !hit : hit;
|
||||
},
|
||||
|
||||
matchesFilter: function(row, filters) {
|
||||
matchesFilter: function (row, filters) {
|
||||
if (filters.q) {
|
||||
const p = this.parseFilterValue(filters.q);
|
||||
if (p.value) {
|
||||
const keys = Object.keys(row);
|
||||
const parts = [];
|
||||
for (let i = 0; i < keys.length; i++)
|
||||
parts.push(row[keys[i]]);
|
||||
for (let i = 0; i < keys.length; i++) parts.push(row[keys[i]]);
|
||||
const blob = parts.join(' ').toLowerCase();
|
||||
const hit = blob.indexOf(p.value.toLowerCase()) !== -1;
|
||||
if (p.negate ? hit : !hit)
|
||||
return false;
|
||||
if (p.negate ? hit : !hit) return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -447,10 +484,10 @@ return baseclass.extend({
|
||||
const p = this.parseFilterValue(filters.action);
|
||||
if (p.value) {
|
||||
const want = p.value.toLowerCase();
|
||||
const hit = row.action === want
|
||||
|| (row.action_raw || '').toUpperCase() === p.value.toUpperCase();
|
||||
if (p.negate ? hit : !hit)
|
||||
return false;
|
||||
const hit =
|
||||
row.action === want ||
|
||||
(row.action_raw || '').toUpperCase() === p.value.toUpperCase();
|
||||
if (p.negate ? hit : !hit) return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -458,33 +495,26 @@ return baseclass.extend({
|
||||
const p = this.parseFilterValue(filters.interface);
|
||||
if (p.value) {
|
||||
const iface = p.value;
|
||||
const hit = row.interface === iface
|
||||
|| row.interface_in === iface
|
||||
|| row.interface_out === iface;
|
||||
if (p.negate ? hit : !hit)
|
||||
return false;
|
||||
const hit =
|
||||
row.interface === iface ||
|
||||
row.interface_in === iface ||
|
||||
row.interface_out === iface;
|
||||
if (p.negate ? hit : !hit) return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (filters.proto && !this.matchesExactField(row.proto, filters.proto))
|
||||
return false;
|
||||
if (filters.src && !this.matchesTextField(row.src, filters.src))
|
||||
return false;
|
||||
if (filters.dst && !this.matchesTextField(row.dst, filters.dst))
|
||||
return false;
|
||||
if (filters.sport && !this.matchesExactField(row.sport, filters.sport))
|
||||
return false;
|
||||
if (filters.dport && !this.matchesExactField(row.dport, filters.dport))
|
||||
return false;
|
||||
if (filters.proto && !this.matchesExactField(row.proto, filters.proto)) return false;
|
||||
if (filters.src && !this.matchesTextField(row.src, filters.src)) return false;
|
||||
if (filters.dst && !this.matchesTextField(row.dst, filters.dst)) return false;
|
||||
if (filters.sport && !this.matchesExactField(row.sport, filters.sport)) return false;
|
||||
if (filters.dport && !this.matchesExactField(row.dport, filters.dport)) return false;
|
||||
return true;
|
||||
},
|
||||
|
||||
actionRowClass: function(action) {
|
||||
actionRowClass: function (action) {
|
||||
const a = (action || '').toLowerCase();
|
||||
if (a === 'drop' || a === 'reject' || a === 'block')
|
||||
return 'fwlive-action fwlive-deny';
|
||||
if (a === 'pass')
|
||||
return 'fwlive-action fwlive-pass';
|
||||
if (a === 'drop' || a === 'reject' || a === 'block') return 'fwlive-action fwlive-deny';
|
||||
if (a === 'pass') return 'fwlive-action fwlive-pass';
|
||||
return 'fwlive-action fwlive-unknown';
|
||||
}
|
||||
});
|
||||
@@ -1,4 +1,6 @@
|
||||
'use strict';
|
||||
/* SPDX-License-Identifier: Apache-2.0 */
|
||||
/* Copyright 2025-2026 Lucas Albers <lucas.b.albers@gmail.com> */
|
||||
'require baseclass';
|
||||
'require fwlive.links as links';
|
||||
|
||||
@@ -46,10 +48,11 @@ function persistConsentDismissed() {
|
||||
|
||||
function blockerCode(state) {
|
||||
const blockers = (state.loggingStatus && state.loggingStatus.blockers) || [];
|
||||
if (blockers.indexOf('no_wan_zone') >= 0)
|
||||
return 'no_wan_zone';
|
||||
if (blockers.indexOf('nf_log_ipv4_missing') >= 0 ||
|
||||
blockers.indexOf('nf_log_ipv6_missing') >= 0)
|
||||
if (blockers.indexOf('no_wan_zone') >= 0) return 'no_wan_zone';
|
||||
if (
|
||||
blockers.indexOf('nf_log_ipv4_missing') >= 0 ||
|
||||
blockers.indexOf('nf_log_ipv6_missing') >= 0
|
||||
)
|
||||
return 'nf_log_missing';
|
||||
return '';
|
||||
}
|
||||
@@ -66,15 +69,21 @@ function renderToolbar(host, state, callbacks) {
|
||||
const blocker = blockerCode(state);
|
||||
|
||||
if (blocker === 'no_wan_zone') {
|
||||
host.appendChild(E('span', { 'class': 'fwlive-logging-status' },
|
||||
[ _('WAN logging unavailable: no WAN zone') ]));
|
||||
host.appendChild(
|
||||
E('span', { 'class': 'fwlive-logging-status' }, [
|
||||
_('WAN logging unavailable: no WAN zone')
|
||||
])
|
||||
);
|
||||
host.appendChild(links.firewallZonesLink());
|
||||
return;
|
||||
}
|
||||
|
||||
if (blocker === 'nf_log_missing') {
|
||||
host.appendChild(E('span', { 'class': 'fwlive-logging-status' },
|
||||
[ _('WAN logging unavailable: missing kernel log modules') ]));
|
||||
host.appendChild(
|
||||
E('span', { 'class': 'fwlive-logging-status' }, [
|
||||
_('WAN logging unavailable: missing kernel log modules')
|
||||
])
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -82,48 +91,64 @@ function renderToolbar(host, state, callbacks) {
|
||||
if (st.wan_log) {
|
||||
const busy = !!state.loggingBusy;
|
||||
const children = busy
|
||||
? [ _('Disabling…') ]
|
||||
? [_('Disabling…')]
|
||||
: [
|
||||
E('span', { 'class': 'fwlive-log-on-dot', 'aria-hidden': 'true' }, [ '' ]),
|
||||
E('span', { 'class': 'fwlive-log-label' }, [ _('WAN logging on') ]),
|
||||
E('span', { 'class': 'fwlive-log-rate' }, [ _('· %s').format(limit) ])
|
||||
];
|
||||
host.appendChild(E('button', {
|
||||
'class': 'cbi-button fwlive-log-merged',
|
||||
'type': 'button',
|
||||
'title': _('WAN logging on (%s). Click to disable.').format(limit),
|
||||
'disabled': busy ? '' : null,
|
||||
'click': function() { callbacks.onDisable(); }
|
||||
}, children));
|
||||
E('span', { 'class': 'fwlive-log-on-dot', 'aria-hidden': 'true' }, ['']),
|
||||
E('span', { 'class': 'fwlive-log-label' }, [_('WAN logging on')]),
|
||||
E('span', { 'class': 'fwlive-log-rate' }, [_('· %s').format(limit)])
|
||||
];
|
||||
host.appendChild(
|
||||
E(
|
||||
'button',
|
||||
{
|
||||
'class': 'cbi-button fwlive-log-merged',
|
||||
'type': 'button',
|
||||
'title': _('WAN logging on (%s). Click to disable.').format(limit),
|
||||
'disabled': busy ? '' : null,
|
||||
'click': function () {
|
||||
callbacks.onDisable();
|
||||
}
|
||||
},
|
||||
children
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
host.appendChild(E('button', {
|
||||
'class': 'cbi-button cbi-button-action',
|
||||
'type': 'button',
|
||||
'title': _('Enable WAN zone drop/reject logging (same as Network → Firewall).'),
|
||||
'disabled': state.loggingBusy ? '' : null,
|
||||
'click': function() { callbacks.onEnable(); }
|
||||
}, [ state.loggingBusy ? _('Enabling…') : _('Enable logging') ]));
|
||||
host.appendChild(
|
||||
E(
|
||||
'button',
|
||||
{
|
||||
'class': 'cbi-button cbi-button-action',
|
||||
'type': 'button',
|
||||
'title': _('Enable WAN zone drop/reject logging (same as Network → Firewall).'),
|
||||
'disabled': state.loggingBusy ? '' : null,
|
||||
'click': function () {
|
||||
callbacks.onEnable();
|
||||
}
|
||||
},
|
||||
[state.loggingBusy ? _('Enabling…') : _('Enable logging')]
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function buildConsentPanel(state, callbacks) {
|
||||
const dontShowId = 'fwlive-consent-dont-show';
|
||||
const panel = E('div', { 'class': 'fwlive-consent', 'id': 'fwlive-consent' }, [
|
||||
E('p', { 'class': 'fwlive-empty-title' }, [ _('Before you enable logging') ]),
|
||||
E('p', { 'class': 'fwlive-empty-title' }, [_('Before you enable logging')]),
|
||||
E('ul', { 'class': 'fwlive-consent-list' }, [
|
||||
E('li', {}, [
|
||||
E('strong', {}, [ _('Changes:') ]),
|
||||
E('strong', {}, [_('Changes:')]),
|
||||
' ',
|
||||
_('sets log on the WAN firewall zone and reloads the firewall.')
|
||||
]),
|
||||
E('li', {}, [
|
||||
E('strong', {}, [ _('Does not change:') ]),
|
||||
E('strong', {}, [_('Does not change:')]),
|
||||
' ',
|
||||
_('allow/deny rules, LAN logging, or anything else.')
|
||||
]),
|
||||
E('li', {}, [
|
||||
E('strong', {}, [ _('Undo:') ]),
|
||||
E('strong', {}, [_('Undo:')]),
|
||||
' ',
|
||||
_('turn it back off with the WAN logging on control on the watch strip.')
|
||||
])
|
||||
@@ -139,28 +164,34 @@ function buildConsentPanel(state, callbacks) {
|
||||
])
|
||||
]),
|
||||
E('p', { 'class': 'fwlive-consent-actions' }, [
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-action',
|
||||
'type': 'button',
|
||||
'disabled': state.loggingBusy ? '' : null,
|
||||
'click': function() {
|
||||
persistConsentDismissed();
|
||||
callbacks.onEnable();
|
||||
}
|
||||
}, [ state.loggingBusy ? _('Enabling…') : _('Enable WAN drop/reject logging') ]),
|
||||
' ',
|
||||
E('button', {
|
||||
'class': 'cbi-button',
|
||||
'type': 'button',
|
||||
'click': function() {
|
||||
const box = document.getElementById(dontShowId);
|
||||
const persist = !!(box && box.checked);
|
||||
if (persist)
|
||||
E(
|
||||
'button',
|
||||
{
|
||||
'class': 'cbi-button cbi-button-action',
|
||||
'type': 'button',
|
||||
'disabled': state.loggingBusy ? '' : null,
|
||||
'click': function () {
|
||||
persistConsentDismissed();
|
||||
if (callbacks.onDismissConsent)
|
||||
callbacks.onDismissConsent(persist);
|
||||
}
|
||||
}, [ _('Not now') ]),
|
||||
callbacks.onEnable();
|
||||
}
|
||||
},
|
||||
[state.loggingBusy ? _('Enabling…') : _('Enable WAN drop/reject logging')]
|
||||
),
|
||||
' ',
|
||||
E(
|
||||
'button',
|
||||
{
|
||||
'class': 'cbi-button',
|
||||
'type': 'button',
|
||||
'click': function () {
|
||||
const box = document.getElementById(dontShowId);
|
||||
const persist = !!(box && box.checked);
|
||||
if (persist) persistConsentDismissed();
|
||||
if (callbacks.onDismissConsent) callbacks.onDismissConsent(persist);
|
||||
}
|
||||
},
|
||||
[_('Not now')]
|
||||
),
|
||||
' ',
|
||||
links.firewallZonesLink(_('I’ll configure this under Network → Firewall'))
|
||||
])
|
||||
@@ -174,41 +205,71 @@ function buildEmptyStateNodes(state, callbacks) {
|
||||
const blocker = blockerCode(state);
|
||||
|
||||
if (state.loggingNotice) {
|
||||
nodes.push(E('p', { 'class': 'fwlive-logging-notice' }, [
|
||||
state.loggingNotice,
|
||||
' ',
|
||||
links.firewallZonesLink()
|
||||
]));
|
||||
nodes.push(
|
||||
E('p', { 'class': 'fwlive-logging-notice' }, [
|
||||
state.loggingNotice,
|
||||
' ',
|
||||
links.firewallZonesLink()
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
if (blocker === 'no_wan_zone') {
|
||||
nodes.push(E('p', { 'class': 'fwlive-empty-title' }, [ _('No WAN zone found') ]));
|
||||
nodes.push(E('p', {}, [
|
||||
_('No WAN firewall zone found in /etc/config/firewall. Configure zones under '),
|
||||
links.firewallZonesLink()
|
||||
]));
|
||||
nodes.push(E('p', { 'class': 'fwlive-empty-title' }, [_('No WAN zone found')]));
|
||||
nodes.push(
|
||||
E('p', {}, [
|
||||
_('No WAN firewall zone found in /etc/config/firewall. Configure zones under '),
|
||||
links.firewallZonesLink()
|
||||
])
|
||||
);
|
||||
return nodes;
|
||||
}
|
||||
|
||||
if (blocker === 'nf_log_missing') {
|
||||
nodes.push(E('p', { 'class': 'fwlive-empty-title' }, [ _('Kernel log modules missing') ]));
|
||||
nodes.push(E('p', {}, [ _('Kernel netfilter log modules are missing. Install kmod-nf-log-ipv4 and kmod-nf-log-ipv6 (or kmod-nf-log / kmod-nf-log6), then reload the firewall.') ]));
|
||||
nodes.push(E('p', {}, [
|
||||
E('code', {}, [ 'opkg update && opkg install kmod-nf-log-ipv4 kmod-nf-log-ipv6' ])
|
||||
]));
|
||||
nodes.push(E('p', { 'class': 'fwlive-empty-title' }, [_('Kernel log modules missing')]));
|
||||
nodes.push(
|
||||
E('p', {}, [
|
||||
_(
|
||||
'Kernel netfilter log modules are missing. Install kmod-nf-log-ipv4 and kmod-nf-log-ipv6 (or kmod-nf-log / kmod-nf-log6), then reload the firewall.'
|
||||
)
|
||||
])
|
||||
);
|
||||
nodes.push(
|
||||
E('p', {}, [
|
||||
E('code', {}, ['opkg update && opkg install kmod-nf-log-ipv4 kmod-nf-log-ipv6'])
|
||||
])
|
||||
);
|
||||
return nodes;
|
||||
}
|
||||
|
||||
if (st && st.wan_log) {
|
||||
nodes.push(E('p', { 'class': 'fwlive-empty-title' }, [ _('Waiting for firewall events') ]));
|
||||
nodes.push(E('p', {}, [ _('WAN drop/reject logging is on. Blocked inbound WAN traffic will show up here. Normal LAN browsing will not.') ]));
|
||||
nodes.push(E('p', { 'class': 'fwlive-empty-muted' }, [ _('If the WAN is quiet, wait for probes or use the optional ping check in Help / the enabling-logs guide.') ]));
|
||||
nodes.push(E('p', { 'class': 'fwlive-empty-title' }, [_('Waiting for firewall events')]));
|
||||
nodes.push(
|
||||
E('p', {}, [
|
||||
_(
|
||||
'WAN drop/reject logging is on. Blocked inbound WAN traffic will show up here. Normal LAN browsing will not.'
|
||||
)
|
||||
])
|
||||
);
|
||||
nodes.push(
|
||||
E('p', { 'class': 'fwlive-empty-muted' }, [
|
||||
_(
|
||||
'If the WAN is quiet, wait for probes or use the optional ping check in Help / the enabling-logs guide.'
|
||||
)
|
||||
])
|
||||
);
|
||||
nodes.push(E('p', {}, links.firewallZonesLink(_('Open firewall zone settings'))));
|
||||
return nodes;
|
||||
}
|
||||
|
||||
nodes.push(E('p', { 'class': 'fwlive-empty-title' }, [ _('Logging is off on this router') ]));
|
||||
nodes.push(E('p', {}, [ _('OpenWrt does not write firewall events to the log until you turn logging on. Live View only shows what the firewall already logs — it does not add allow/deny rules.') ]));
|
||||
nodes.push(E('p', { 'class': 'fwlive-empty-title' }, [_('Logging is off on this router')]));
|
||||
nodes.push(
|
||||
E('p', {}, [
|
||||
_(
|
||||
'OpenWrt does not write firewall events to the log until you turn logging on. Live View only shows what the firewall already logs — it does not add allow/deny rules.'
|
||||
)
|
||||
])
|
||||
);
|
||||
|
||||
/* Consent bullets already spell out the effect — do not repeat it or the CTA. */
|
||||
if (state.showConsent) {
|
||||
@@ -216,29 +277,42 @@ function buildEmptyStateNodes(state, callbacks) {
|
||||
return nodes;
|
||||
}
|
||||
|
||||
nodes.push(E('p', {}, [ _('Turns on WAN zone drop/reject logging (same as Network → Firewall → wan → Log). Rate-limited by the zone log_limit (OpenWrt default 10/minute). Normal LAN browsing is not logged.') ]));
|
||||
nodes.push(E('p', { 'class': 'fwlive-empty-muted' }, [ _('Nothing changes until you click Enable.') ]));
|
||||
nodes.push(E('p', {}, [
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-action',
|
||||
'type': 'button',
|
||||
'disabled': state.loggingBusy ? '' : null,
|
||||
'click': function() {
|
||||
persistConsentDismissed();
|
||||
callbacks.onEnable();
|
||||
}
|
||||
}, [ state.loggingBusy ? _('Enabling…') : _('Enable WAN drop/reject logging') ]),
|
||||
' ',
|
||||
links.firewallZonesLink(_('I’ll configure this under Network → Firewall'))
|
||||
]));
|
||||
nodes.push(
|
||||
E('p', {}, [
|
||||
_(
|
||||
'Turns on WAN zone drop/reject logging (same as Network → Firewall → wan → Log). Rate-limited by the zone log_limit (OpenWrt default 10/minute). Normal LAN browsing is not logged.'
|
||||
)
|
||||
])
|
||||
);
|
||||
nodes.push(
|
||||
E('p', { 'class': 'fwlive-empty-muted' }, [_('Nothing changes until you click Enable.')])
|
||||
);
|
||||
nodes.push(
|
||||
E('p', {}, [
|
||||
E(
|
||||
'button',
|
||||
{
|
||||
'class': 'cbi-button cbi-button-action',
|
||||
'type': 'button',
|
||||
'disabled': state.loggingBusy ? '' : null,
|
||||
'click': function () {
|
||||
persistConsentDismissed();
|
||||
callbacks.onEnable();
|
||||
}
|
||||
},
|
||||
[state.loggingBusy ? _('Enabling…') : _('Enable WAN drop/reject logging')]
|
||||
),
|
||||
' ',
|
||||
links.firewallZonesLink(_('I’ll configure this under Network → Firewall'))
|
||||
])
|
||||
);
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function renderEmptyState(host, state, callbacks) {
|
||||
const nodes = buildEmptyStateNodes(state, callbacks);
|
||||
host.innerHTML = '';
|
||||
for (let i = 0; i < nodes.length; i++)
|
||||
host.appendChild(nodes[i]);
|
||||
for (let i = 0; i < nodes.length; i++) host.appendChild(nodes[i]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -250,11 +324,19 @@ function renderManualTestNodes(host, state, _callbacks) {
|
||||
host.innerHTML = '';
|
||||
if (state.firewallBackend === 'iptables') {
|
||||
host.appendChild(document.createTextNode(_('Manual test (System → Terminal): ')));
|
||||
host.appendChild(E('code', {}, [ 'iptables -I INPUT -p icmp --icmp-type echo-request -j LOG --log-prefix "fwlive-ping: "' ]));
|
||||
host.appendChild(
|
||||
E('code', {}, [
|
||||
'iptables -I INPUT -p icmp --icmp-type echo-request -j LOG --log-prefix "fwlive-ping: "'
|
||||
])
|
||||
);
|
||||
host.appendChild(document.createTextNode(_(' then ping the router.')));
|
||||
} else {
|
||||
host.appendChild(document.createTextNode(_('Manual test (System → Terminal): ')));
|
||||
host.appendChild(E('code', {}, [ 'nft insert rule inet fw4 input ip protocol icmp icmp type echo-request log prefix "fwlive-ping " accept' ]));
|
||||
host.appendChild(
|
||||
E('code', {}, [
|
||||
'nft insert rule inet fw4 input ip protocol icmp icmp type echo-request log prefix "fwlive-ping " accept'
|
||||
])
|
||||
);
|
||||
host.appendChild(document.createTextNode(_(' then ping the router.')));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
'use strict';
|
||||
/* SPDX-License-Identifier: Apache-2.0 */
|
||||
/* Copyright 2025-2026 Lucas Albers <lucas.b.albers@gmail.com> */
|
||||
'require baseclass'; /* LuCI require() needs Class.isSubclass — plain return {} fails */
|
||||
|
||||
/**
|
||||
@@ -7,25 +9,23 @@
|
||||
*/
|
||||
|
||||
return baseclass.extend({
|
||||
readProtoFilter: function() {
|
||||
readProtoFilter: function () {
|
||||
const custom = document.getElementById('fwlive-proto-custom');
|
||||
if (custom) {
|
||||
const typed = (custom.value || '').trim();
|
||||
if (typed)
|
||||
return typed;
|
||||
if (typed) return typed;
|
||||
}
|
||||
const sel = document.getElementById('fwlive-proto');
|
||||
return sel ? (sel.value || '') : '';
|
||||
return sel ? sel.value || '' : '';
|
||||
},
|
||||
|
||||
setProtoFilterValue: function(value) {
|
||||
setProtoFilterValue: function (value) {
|
||||
const sel = document.getElementById('fwlive-proto');
|
||||
const custom = document.getElementById('fwlive-proto-custom');
|
||||
if (!sel)
|
||||
return false;
|
||||
if (!sel) return false;
|
||||
|
||||
value = value || '';
|
||||
let inMenu = (value === '');
|
||||
let inMenu = value === '';
|
||||
if (!inMenu) {
|
||||
for (let i = 0; i < sel.options.length; i++) {
|
||||
if (sel.options[i].value === value) {
|
||||
@@ -37,12 +37,10 @@ return baseclass.extend({
|
||||
|
||||
if (inMenu) {
|
||||
sel.value = value;
|
||||
if (custom)
|
||||
custom.value = '';
|
||||
if (custom) custom.value = '';
|
||||
} else {
|
||||
sel.value = '';
|
||||
if (custom)
|
||||
custom.value = value;
|
||||
if (custom) custom.value = value;
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
'use strict';
|
||||
/* SPDX-License-Identifier: Apache-2.0 */
|
||||
/* Copyright 2025-2026 Lucas Albers <lucas.b.albers@gmail.com> */
|
||||
'require baseclass';
|
||||
'require fwlive.log as log';
|
||||
'require fwlive.links as links';
|
||||
@@ -51,23 +53,36 @@ function columnLabel(col) {
|
||||
|
||||
function columnCellClass(col) {
|
||||
switch (col) {
|
||||
case 'time': return 'fwlive-time';
|
||||
case 'action': return 'fwlive-action';
|
||||
case 'rule': return 'fwlive-rule';
|
||||
case 'iface':
|
||||
case 'iface_in':
|
||||
case 'iface_out': return 'fwlive-iface';
|
||||
case 'dir': return 'fwlive-dir';
|
||||
case 'proto': return 'fwlive-proto';
|
||||
case 'src':
|
||||
case 'dst': return 'fwlive-addr';
|
||||
case 'sport':
|
||||
case 'dport': return 'fwlive-port';
|
||||
case 'flags': return 'fwlive-flags';
|
||||
case 'len': return 'fwlive-len';
|
||||
case 'flow': return 'fwlive-flow-cell';
|
||||
case 'message': return 'fwlive-message fwlive-th-message';
|
||||
default: return '';
|
||||
case 'time':
|
||||
return 'fwlive-time';
|
||||
case 'action':
|
||||
return 'fwlive-action';
|
||||
case 'rule':
|
||||
return 'fwlive-rule';
|
||||
case 'iface':
|
||||
case 'iface_in':
|
||||
case 'iface_out':
|
||||
return 'fwlive-iface';
|
||||
case 'dir':
|
||||
return 'fwlive-dir';
|
||||
case 'proto':
|
||||
return 'fwlive-proto';
|
||||
case 'src':
|
||||
case 'dst':
|
||||
return 'fwlive-addr';
|
||||
case 'sport':
|
||||
case 'dport':
|
||||
return 'fwlive-port';
|
||||
case 'flags':
|
||||
return 'fwlive-flags';
|
||||
case 'len':
|
||||
return 'fwlive-len';
|
||||
case 'flow':
|
||||
return 'fwlive-flow-cell';
|
||||
case 'message':
|
||||
return 'fwlive-message fwlive-th-message';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,26 +90,30 @@ function flowCell(row, state, callbacks) {
|
||||
const parts = [];
|
||||
const onFilterClick = callbacks.onFilterClick;
|
||||
const pushAddr = (addr, port, addrField, portField) => {
|
||||
if (!addr && !port)
|
||||
return;
|
||||
if (!addr && !port) return;
|
||||
|
||||
if (addr)
|
||||
parts.push(links.addrFilterLink(addrField, addr,
|
||||
!!state.showHostnames, state.hostnameCache, onFilterClick));
|
||||
parts.push(
|
||||
links.addrFilterLink(
|
||||
addrField,
|
||||
addr,
|
||||
!!state.showHostnames,
|
||||
state.hostnameCache,
|
||||
onFilterClick
|
||||
)
|
||||
);
|
||||
if (port) {
|
||||
if (addr)
|
||||
parts.push(':');
|
||||
if (addr) parts.push(':');
|
||||
parts.push(links.filterLink(portField, port, port, onFilterClick));
|
||||
}
|
||||
};
|
||||
|
||||
pushAddr(row.src, row.sport, 'src', 'sport');
|
||||
if (parts.length && (row.dst || row.dport))
|
||||
parts.push(E('span', { 'class': 'fwlive-flow-arrow' }, [ ' → ' ]));
|
||||
parts.push(E('span', { 'class': 'fwlive-flow-arrow' }, [' → ']));
|
||||
pushAddr(row.dst, row.dport, 'dst', 'dport');
|
||||
|
||||
if (!parts.length)
|
||||
return '—';
|
||||
if (!parts.length) return '—';
|
||||
|
||||
return E('span', { 'class': 'fwlive-flow' }, parts);
|
||||
}
|
||||
@@ -102,76 +121,120 @@ function flowCell(row, state, callbacks) {
|
||||
function buildColumnCell(col, row, state, callbacks) {
|
||||
const onFilterClick = callbacks.onFilterClick;
|
||||
const msgDisplay = log.formatMessageDisplay(row.message, state.messageLayout);
|
||||
const actionCell = row.action && row.action !== 'unknown'
|
||||
? links.filterLink('action', row.action, log.formatActionLabel(row.action), onFilterClick)
|
||||
: log.formatActionLabel(row.action);
|
||||
const actionCell =
|
||||
row.action && row.action !== 'unknown'
|
||||
? links.filterLink(
|
||||
'action',
|
||||
row.action,
|
||||
log.formatActionLabel(row.action),
|
||||
onFilterClick
|
||||
)
|
||||
: log.formatActionLabel(row.action);
|
||||
|
||||
switch (col) {
|
||||
case 'time': {
|
||||
const timeAttrs = { 'class': columnCellClass(col) };
|
||||
if (state.viewMode === 'simple')
|
||||
timeAttrs.title = _('Click a row for the full message');
|
||||
return E('td', timeAttrs,
|
||||
[ state.viewMode === 'simple'
|
||||
? log.formatTimestampCompact(row.timestamp)
|
||||
: log.formatTimestampLocal(row.timestamp) ]);
|
||||
}
|
||||
case 'action':
|
||||
return E('td', { 'class': log.actionRowClass(row.action) }, [ actionCell ]);
|
||||
case 'rule':
|
||||
return E('td', { 'class': columnCellClass(col) },
|
||||
[ links.ruleAdminLink(row.rule_hint, row.rule_label, state.firewallBackend, onFilterClick) ]);
|
||||
case 'iface':
|
||||
return E('td', { 'class': columnCellClass(col) },
|
||||
[ links.ifaceLink(row.interface_in, onFilterClick) ]);
|
||||
case 'iface_in':
|
||||
case 'iface_out':
|
||||
return E('td', { 'class': columnCellClass(col) }, [ links.ifaceLink(
|
||||
col === 'iface_in' ? row.interface_in : row.interface_out, onFilterClick) ]);
|
||||
case 'dir':
|
||||
return E('td', { 'class': columnCellClass(col) }, [ log.formatCell(row.direction) ]);
|
||||
case 'proto':
|
||||
return E('td', { 'class': columnCellClass(col) },
|
||||
[ links.filterLink('proto', row.proto, null, onFilterClick) ]);
|
||||
case 'src':
|
||||
return E('td', { 'class': columnCellClass(col) },
|
||||
[ links.addrFilterLink('src', row.src, !!state.showHostnames, state.hostnameCache, onFilterClick) ]);
|
||||
case 'sport':
|
||||
return E('td', { 'class': columnCellClass(col) },
|
||||
[ links.filterLink('sport', row.sport, null, onFilterClick) ]);
|
||||
case 'dst':
|
||||
return E('td', { 'class': columnCellClass(col) },
|
||||
[ links.addrFilterLink('dst', row.dst, !!state.showHostnames, state.hostnameCache, onFilterClick) ]);
|
||||
case 'dport':
|
||||
return E('td', { 'class': columnCellClass(col) },
|
||||
[ links.filterLink('dport', row.dport, null, onFilterClick) ]);
|
||||
case 'flags':
|
||||
return E('td', { 'class': columnCellClass(col) }, [ log.formatCell(row.flags) ]);
|
||||
case 'len':
|
||||
return E('td', { 'class': columnCellClass(col) }, [ row.length != null ? String(row.length) : '' ]);
|
||||
case 'flow':
|
||||
return E('td', { 'class': columnCellClass(col) }, [ flowCell(row, state, callbacks) ]);
|
||||
case 'message':
|
||||
if (state.messageLayout === 'wrap') {
|
||||
return E('td', {
|
||||
'class': 'fwlive-message',
|
||||
'title': msgDisplay || ''
|
||||
}, E('div', { 'class': 'fwlive-message-wrap' }, [ msgDisplay || '—' ]));
|
||||
case 'time': {
|
||||
const timeAttrs = { 'class': columnCellClass(col) };
|
||||
if (state.viewMode === 'simple')
|
||||
timeAttrs.title = _('Click a row for the full message');
|
||||
return E('td', timeAttrs, [
|
||||
state.viewMode === 'simple'
|
||||
? log.formatTimestampCompact(row.timestamp)
|
||||
: log.formatTimestampLocal(row.timestamp)
|
||||
]);
|
||||
}
|
||||
return E('td', {
|
||||
'class': 'fwlive-message',
|
||||
'title': msgDisplay || ''
|
||||
}, [ msgDisplay || '—' ]);
|
||||
default:
|
||||
return E('td', {}, [ '' ]);
|
||||
case 'action':
|
||||
return E('td', { 'class': log.actionRowClass(row.action) }, [actionCell]);
|
||||
case 'rule':
|
||||
return E('td', { 'class': columnCellClass(col) }, [
|
||||
links.ruleAdminLink(
|
||||
row.rule_hint,
|
||||
row.rule_label,
|
||||
state.firewallBackend,
|
||||
onFilterClick
|
||||
)
|
||||
]);
|
||||
case 'iface':
|
||||
return E('td', { 'class': columnCellClass(col) }, [
|
||||
links.ifaceLink(row.interface_in, onFilterClick)
|
||||
]);
|
||||
case 'iface_in':
|
||||
case 'iface_out':
|
||||
return E('td', { 'class': columnCellClass(col) }, [
|
||||
links.ifaceLink(
|
||||
col === 'iface_in' ? row.interface_in : row.interface_out,
|
||||
onFilterClick
|
||||
)
|
||||
]);
|
||||
case 'dir':
|
||||
return E('td', { 'class': columnCellClass(col) }, [log.formatCell(row.direction)]);
|
||||
case 'proto':
|
||||
return E('td', { 'class': columnCellClass(col) }, [
|
||||
links.filterLink('proto', row.proto, null, onFilterClick)
|
||||
]);
|
||||
case 'src':
|
||||
return E('td', { 'class': columnCellClass(col) }, [
|
||||
links.addrFilterLink(
|
||||
'src',
|
||||
row.src,
|
||||
!!state.showHostnames,
|
||||
state.hostnameCache,
|
||||
onFilterClick
|
||||
)
|
||||
]);
|
||||
case 'sport':
|
||||
return E('td', { 'class': columnCellClass(col) }, [
|
||||
links.filterLink('sport', row.sport, null, onFilterClick)
|
||||
]);
|
||||
case 'dst':
|
||||
return E('td', { 'class': columnCellClass(col) }, [
|
||||
links.addrFilterLink(
|
||||
'dst',
|
||||
row.dst,
|
||||
!!state.showHostnames,
|
||||
state.hostnameCache,
|
||||
onFilterClick
|
||||
)
|
||||
]);
|
||||
case 'dport':
|
||||
return E('td', { 'class': columnCellClass(col) }, [
|
||||
links.filterLink('dport', row.dport, null, onFilterClick)
|
||||
]);
|
||||
case 'flags':
|
||||
return E('td', { 'class': columnCellClass(col) }, [log.formatCell(row.flags)]);
|
||||
case 'len':
|
||||
return E('td', { 'class': columnCellClass(col) }, [
|
||||
row.length != null ? String(row.length) : ''
|
||||
]);
|
||||
case 'flow':
|
||||
return E('td', { 'class': columnCellClass(col) }, [flowCell(row, state, callbacks)]);
|
||||
case 'message':
|
||||
if (state.messageLayout === 'wrap') {
|
||||
return E(
|
||||
'td',
|
||||
{
|
||||
'class': 'fwlive-message',
|
||||
'title': msgDisplay || ''
|
||||
},
|
||||
E('div', { 'class': 'fwlive-message-wrap' }, [msgDisplay || '—'])
|
||||
);
|
||||
}
|
||||
return E(
|
||||
'td',
|
||||
{
|
||||
'class': 'fwlive-message',
|
||||
'title': msgDisplay || ''
|
||||
},
|
||||
[msgDisplay || '—']
|
||||
);
|
||||
default:
|
||||
return E('td', {}, ['']);
|
||||
}
|
||||
}
|
||||
|
||||
function renderThead(host, state, _callbacks) {
|
||||
const columns = state.columns || [];
|
||||
const tr = host.querySelector('thead tr');
|
||||
if (!tr)
|
||||
return;
|
||||
if (!tr) return;
|
||||
|
||||
let colgroup = host.querySelector('colgroup');
|
||||
|
||||
@@ -185,8 +248,10 @@ function renderThead(host, state, _callbacks) {
|
||||
|
||||
for (let i = 0; i < columns.length; i++) {
|
||||
const col = columns[i];
|
||||
colgroup.appendChild(E('col', { 'class': 'fwlive-col fwlive-col-' + col.replace(/_/g, '-') }));
|
||||
tr.appendChild(E('th', { 'class': columnCellClass(col) }, [ columnLabel(col) ]));
|
||||
colgroup.appendChild(
|
||||
E('col', { 'class': 'fwlive-col fwlive-col-' + col.replace(/_/g, '-') })
|
||||
);
|
||||
tr.appendChild(E('th', { 'class': columnCellClass(col) }, [columnLabel(col)]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,26 +268,34 @@ function renderRows(host, state, callbacks) {
|
||||
state.viewMode === 'simple' ? 'fwlive-row-clickable' : '',
|
||||
state.expandedRowId === r.id ? 'fwlive-row-expanded' : '',
|
||||
state.rowTint ? callbacks.actionRowTintClass(r.action) : ''
|
||||
].filter(Boolean).join(' ');
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
const cells = [];
|
||||
for (let c = 0; c < columns.length; c++)
|
||||
cells.push(buildColumnCell(columns[c], r, state, callbacks));
|
||||
|
||||
const tr = E('tr', {
|
||||
'class': rowClass,
|
||||
'click': state.viewMode === 'simple'
|
||||
? (ev) => callbacks.onRowClick(r.id, ev) : null
|
||||
}, cells);
|
||||
const tr = E(
|
||||
'tr',
|
||||
{
|
||||
'class': rowClass,
|
||||
'click': state.viewMode === 'simple' ? (ev) => callbacks.onRowClick(r.id, ev) : null
|
||||
},
|
||||
cells
|
||||
);
|
||||
host.appendChild(tr);
|
||||
|
||||
if (state.viewMode === 'simple' && state.expandedRowId === r.id) {
|
||||
host.appendChild(E('tr', { 'class': 'fwlive-msg-expand' }, [
|
||||
E('td', { 'colspan': String(columns.length) }, [
|
||||
E('div', { 'class': 'fwlive-msg-expand-label' }, [ _('Message') ]),
|
||||
E('pre', { 'class': 'fwlive-msg-expand-body' },
|
||||
[ log.formatMessageDisplay(r.message, 'wrap') || '—' ])
|
||||
host.appendChild(
|
||||
E('tr', { 'class': 'fwlive-msg-expand' }, [
|
||||
E('td', { 'colspan': String(columns.length) }, [
|
||||
E('div', { 'class': 'fwlive-msg-expand-label' }, [_('Message')]),
|
||||
E('pre', { 'class': 'fwlive-msg-expand-body' }, [
|
||||
log.formatMessageDisplay(r.message, 'wrap') || '—'
|
||||
])
|
||||
])
|
||||
])
|
||||
]));
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
'use strict';
|
||||
/* SPDX-License-Identifier: Apache-2.0 */
|
||||
/* Copyright 2025-2026 Lucas Albers <lucas.b.albers@gmail.com> */
|
||||
'require baseclass';
|
||||
|
||||
/**
|
||||
@@ -21,8 +23,7 @@ var PASS_HEX = CLASSIC_PASS_HEX;
|
||||
var DENY_HEX = CLASSIC_DENY_HEX;
|
||||
|
||||
function normalizeRowTint(mode) {
|
||||
if (mode === 'off' || mode === 'accessible' || mode === 'classic')
|
||||
return mode;
|
||||
if (mode === 'off' || mode === 'accessible' || mode === 'classic') return mode;
|
||||
return 'classic';
|
||||
}
|
||||
|
||||
@@ -33,16 +34,13 @@ function hexPairForMode(mode) {
|
||||
}
|
||||
|
||||
function parseCssRgbChannels(value) {
|
||||
if (!value)
|
||||
return null;
|
||||
if (!value) return null;
|
||||
|
||||
const s = String(value).trim().toLowerCase();
|
||||
if (s === 'transparent' || s === 'rgba(0, 0, 0, 0)' || s === 'rgba(0,0,0,0)')
|
||||
return null;
|
||||
if (s === 'transparent' || s === 'rgba(0, 0, 0, 0)' || s === 'rgba(0,0,0,0)') return null;
|
||||
|
||||
const rgb = s.match(/rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)/i);
|
||||
if (rgb)
|
||||
return [ parseFloat(rgb[1]), parseFloat(rgb[2]), parseFloat(rgb[3]) ];
|
||||
if (rgb) return [parseFloat(rgb[1]), parseFloat(rgb[2]), parseFloat(rgb[3])];
|
||||
|
||||
/* color-mix() often serializes as color(srgb r g b[/a]) with 0..1 channels. */
|
||||
const modern = s.match(/color\(\s*srgb\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)/i);
|
||||
@@ -60,27 +58,22 @@ function cssColorPaintDelta(a, b) {
|
||||
const ca = parseCssRgbChannels(a);
|
||||
const cb = parseCssRgbChannels(b);
|
||||
/* Transparent vs opaque color is a real paint change (common off-state). */
|
||||
if (!ca && !cb)
|
||||
return 0;
|
||||
if (!ca && cb)
|
||||
return Math.abs(cb[0]) + Math.abs(cb[1]) + Math.abs(cb[2]);
|
||||
if (ca && !cb)
|
||||
return Math.abs(ca[0]) + Math.abs(ca[1]) + Math.abs(ca[2]);
|
||||
if (!ca && !cb) return 0;
|
||||
if (!ca && cb) return Math.abs(cb[0]) + Math.abs(cb[1]) + Math.abs(cb[2]);
|
||||
if (ca && !cb) return Math.abs(ca[0]) + Math.abs(ca[1]) + Math.abs(ca[2]);
|
||||
|
||||
return Math.abs(ca[0] - cb[0]) + Math.abs(ca[1] - cb[1]) + Math.abs(ca[2] - cb[2]);
|
||||
}
|
||||
|
||||
function tintShouldEngageFallback(opts) {
|
||||
const o = opts || {};
|
||||
const minDelta = (typeof o.minDelta === 'number') ? o.minDelta : PAINT_DELTA_MIN;
|
||||
const minDelta = typeof o.minDelta === 'number' ? o.minDelta : PAINT_DELTA_MIN;
|
||||
|
||||
/* Visible paint is the success criterion; token/CSS.supports are only used when
|
||||
paint cannot be measured (no delta sample yet). */
|
||||
if (typeof o.paintDelta === 'number')
|
||||
return o.paintDelta < minDelta;
|
||||
if (typeof o.paintDelta === 'number') return o.paintDelta < minDelta;
|
||||
|
||||
if (o.tokenResolved === false)
|
||||
return true;
|
||||
if (o.tokenResolved === false) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -4,6 +4,7 @@
|
||||
# GENERATED FILE — do not edit. Run: ./scripts/gen-all.sh
|
||||
# source: core/fwlive-log.js CLASSIFY_SPEC
|
||||
# Shared isFirewallEvent parity logic (shell). Sourced by fwlive-log-filter.sh and tests.
|
||||
# Sourced library: do not add set -euo here (callers own strict mode, #291 C3).
|
||||
# One awk process classifies a batch (MODE=json) or one message (default).
|
||||
|
||||
_fwlive_run_classify() {
|
||||
|
||||
@@ -8,6 +8,14 @@
|
||||
#
|
||||
# Perf (#219): one jsonfilter for @.log[*] plus one awk classify. Process
|
||||
# count is constant per poll, not O(entries).
|
||||
#
|
||||
# Entry point (pipeline). The classifier sibling is sourced and must not
|
||||
# set -euo itself (#291 C3).
|
||||
set -eu
|
||||
# pipefail: BusyBox ash supports it; Debian dash (host `sh`) rejects a
|
||||
# bare `set -o pipefail`. Probe in a subshell (same class as #244).
|
||||
# shellcheck disable=SC3040 # pipefail is ash/bash; dash probe is a subshell
|
||||
(set -o pipefail) 2>/dev/null && set -o pipefail
|
||||
|
||||
if ! command -v jsonfilter >/dev/null 2>&1; then
|
||||
command -v logger >/dev/null 2>&1 && logger -t fwlive "jsonfilter not found; cannot filter firewall logs"
|
||||
@@ -26,5 +34,7 @@ printf '%s' '{"log":['
|
||||
# Prefer stdin over -s: Linux MAX_ARG_STRLEN is 128KiB; a raised logd ring
|
||||
# (or paused FETCH_LINES_MAX poll) can exceed that and make jsonfilter fail
|
||||
# while this script still printed {"log":[]} and exited 0 (#234).
|
||||
printf '%s' "$input" | jsonfilter -e '@.log[*]' 2>/dev/null | _fwlive_filter_json_entries
|
||||
# jsonfilter miss / empty @.log is not fatal; must still close JSON (#220).
|
||||
# set -e + pipefail cannot apply to this pipeline (#291 C3).
|
||||
printf '%s' "$input" | jsonfilter -e '@.log[*]' 2>/dev/null | _fwlive_filter_json_entries || true
|
||||
printf '%s' ']}'
|
||||
@@ -3,6 +3,11 @@
|
||||
# Copyright 2025-2026 Lucas Albers <lucas.b.albers@gmail.com>
|
||||
#
|
||||
# WAN zone logging helpers for ubus fwlive (logging_status / enable / disable).
|
||||
#
|
||||
# Sourced library (rpcd plugin, package prerm). Do not `set -euo pipefail`
|
||||
# here: prerm is best-effort (`restore ... || logger`; exit 0) and callers
|
||||
# expect soft failures. The rpcd entry point enables strict mode; critical
|
||||
# paths use explicit `|| return 1` / `|| true` (#244, #291 C3).
|
||||
|
||||
NF_LOG_IPV4='/proc/sys/net/netfilter/nf_log/2'
|
||||
NF_LOG_IPV6='/proc/sys/net/netfilter/nf_log/10'
|
||||
@@ -121,11 +126,16 @@ find_wan_zone_section() {
|
||||
# Match anonymous (@zone[N]) and named (e.g. wan) sections whose name option
|
||||
# is 'wan'. Prefer the first section whose type is zone (issue #168); skip
|
||||
# non-zone sections that happen to share name='wan'.
|
||||
# uci missing / no wan zone is empty, not fatal. pipefail + set -e
|
||||
# cannot apply to this pipeline (#291 C3).
|
||||
_zones=$(uci -q show firewall 2>/dev/null \
|
||||
| sed -n "s/^firewall\.\([^.]*\)\.name='wan'$/\1/p")
|
||||
| sed -n "s/^firewall\.\([^.]*\)\.name='wan'$/\1/p") || true
|
||||
for zone in $_zones; do
|
||||
[ -n "$zone" ] || continue
|
||||
[ "$(uci -q get "firewall.${zone}" 2>/dev/null)" = "zone" ] || continue
|
||||
# uci -q get exits 1 on a missing section. Capture with || true so
|
||||
# set -e cannot abort inside "$(…)" before || continue (#291 C3).
|
||||
_type=$(uci -q get "firewall.${zone}" 2>/dev/null || true)
|
||||
[ "$_type" = "zone" ] || continue
|
||||
printf '%s' "$zone"
|
||||
return 0
|
||||
done
|
||||
@@ -133,14 +143,16 @@ find_wan_zone_section() {
|
||||
}
|
||||
|
||||
firewall_changes_pending() {
|
||||
pending="$(uci -q changes firewall 2>/dev/null)"
|
||||
# uci miss is "no pending changes". set -e cannot apply (#291 C3).
|
||||
pending="$(uci -q changes firewall 2>/dev/null || true)"
|
||||
[ -n "$pending" ]
|
||||
}
|
||||
|
||||
wan_zone_log_value() {
|
||||
zone="$1"
|
||||
[ -n "$zone" ] || return 1
|
||||
uci -q get "firewall.${zone}.log" 2>/dev/null
|
||||
# Unset option is a valid empty value; uci -q get exits 1 (#291 C3).
|
||||
uci -q get "firewall.${zone}.log" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# Resolve a firewall section id to its canonical cfgXXXX form (issue B-1 /
|
||||
@@ -284,7 +296,8 @@ maybe_snapshot_wan_log_baseline() {
|
||||
restore_wan_log_baseline() {
|
||||
path="$(wan_log_baseline_path)"
|
||||
[ -f "$path" ] || return 0
|
||||
baseline=$(cat "$path" 2>/dev/null)
|
||||
# Empty file is a valid "option was unset" baseline (#291 C3).
|
||||
baseline=$(cat "$path" 2>/dev/null || true)
|
||||
zone=$(find_wan_zone_section)
|
||||
if [ -z "$zone" ]; then
|
||||
logger -t fwlive "WAN log baseline restore skipped: no WAN zone" 2>/dev/null || true
|
||||
@@ -363,7 +376,7 @@ wan_filter_log_clear_value() {
|
||||
read_nf_log_backend() {
|
||||
path="$1"
|
||||
[ -f "$path" ] || return 1
|
||||
val=$(cat "$path" 2>/dev/null)
|
||||
val=$(cat "$path" 2>/dev/null) || return 1
|
||||
[ -n "$val" ] && [ "$val" != 'none' ]
|
||||
}
|
||||
|
||||
@@ -403,8 +416,9 @@ collect_logging_blockers() {
|
||||
check_nf_log_ipv4 || logging_blockers_append 'nf_log_ipv4_missing'
|
||||
check_nf_log_ipv6 || logging_blockers_append 'nf_log_ipv6_missing'
|
||||
|
||||
[ -n "$LOGGING_BLOCKERS" ] || return 0
|
||||
return 1
|
||||
# Report via LOGGING_BLOCKERS, not exit status: return 1 would abort
|
||||
# build_logging_status_json under set -e (#291 C3).
|
||||
return 0
|
||||
}
|
||||
|
||||
collect_logging_warnings() {
|
||||
@@ -414,8 +428,8 @@ collect_logging_warnings() {
|
||||
# Warnings are diagnostics only — do not gate the enable-logging CTA.
|
||||
command -v timeout >/dev/null 2>&1 || logging_warnings_append 'timeout_missing'
|
||||
|
||||
[ -n "$LOGGING_WARNINGS" ] || return 0
|
||||
return 1
|
||||
# Report via LOGGING_WARNINGS, not exit status (#291 C3).
|
||||
return 0
|
||||
}
|
||||
|
||||
json_null_or_string() {
|
||||
@@ -430,8 +444,13 @@ json_null_or_string() {
|
||||
|
||||
build_logging_status_json() {
|
||||
zone=$(find_wan_zone_section)
|
||||
log_val=$(wan_zone_log_value "$zone")
|
||||
limit_val=$( [ -n "$zone" ] && uci -q get "firewall.${zone}.log_limit" 2>/dev/null )
|
||||
# Empty zone / unset log bit are valid; set -e cannot apply (#291 C3).
|
||||
log_val=$(wan_zone_log_value "$zone") || log_val=
|
||||
# Unset log_limit is a valid empty value; uci -q get exits 1 (#291 C3).
|
||||
limit_val=
|
||||
if [ -n "$zone" ]; then
|
||||
limit_val=$(uci -q get "firewall.${zone}.log_limit" 2>/dev/null || true)
|
||||
fi
|
||||
wan_log=false
|
||||
if wan_filter_log_enabled "$log_val"; then
|
||||
wan_log=true
|
||||
|
||||
@@ -8,6 +8,17 @@
|
||||
# resolve — reverse DNS for IP addresses (BusyBox nslookup)
|
||||
# logging_status — WAN zone logging readiness (no args)
|
||||
# enable_wan_logging / disable_wan_logging — opt-in WAN zone log=1 (no args)
|
||||
#
|
||||
# Entry point (rpcd plugin). Sourced helpers inherit these options. Do not
|
||||
# copy set -euo into fwlive-logging.sh — prerm sources that file alone (#222)
|
||||
# and keeps soft-fail / || return 1 contracts (#244, #291 C3).
|
||||
set -eu
|
||||
# pipefail: BusyBox ash (device) supports it; Debian dash (host `sh` in
|
||||
# CI) rejects `set -o pipefail` as a startup abort. Probe in a subshell —
|
||||
# a failed `set` in-process is fatal even under `if` (same class as #244
|
||||
# failed exec).
|
||||
# shellcheck disable=SC3040 # pipefail is ash/bash; dash probe is a subshell
|
||||
(set -o pipefail) 2>/dev/null && set -o pipefail
|
||||
|
||||
FW4_TAG='!fw4: '
|
||||
LIBEXEC_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
@@ -83,7 +94,9 @@ map_add() {
|
||||
}
|
||||
|
||||
uci_rule_names() {
|
||||
uci -q show firewall 2>/dev/null | sed -n "s/^firewall\.@rule\[[0-9]*\]\.name='\(.*\)'$/\1/p"
|
||||
# uci missing / empty firewall config is "no names", not fatal.
|
||||
# pipefail + set -e cannot apply to this pipeline (#291 C3).
|
||||
uci -q show firewall 2>/dev/null | sed -n "s/^firewall\.@rule\[[0-9]*\]\.name='\(.*\)'$/\1/p" || true
|
||||
}
|
||||
|
||||
is_uci_style_name() {
|
||||
@@ -396,7 +409,8 @@ poll_lines_from_input() {
|
||||
json_get_var first 1
|
||||
json_select ..
|
||||
# shellcheck disable=SC2154 # set by json_get_var above
|
||||
case "$first" in
|
||||
# Missing addresses[1] leaves first unset; set -u (#291 C3).
|
||||
case "${first:-}" in
|
||||
''|*[!0-9]*) ;;
|
||||
*) lines="$first" ;;
|
||||
esac
|
||||
@@ -462,9 +476,10 @@ resolve_hostname() {
|
||||
[ -n "$ip" ] || return 1
|
||||
is_resolvable_address "$ip" || return 1
|
||||
command -v nslookup >/dev/null 2>&1 || return 1
|
||||
out=$(run_with_timeout "$RESOLVE_TIMEOUT" nslookup "$ip")
|
||||
# timeout/nslookup miss is "no PTR", not a plugin abort (#291 C3).
|
||||
out=$(run_with_timeout "$RESOLVE_TIMEOUT" nslookup "$ip") || return 1
|
||||
[ -n "$out" ] || return 1
|
||||
name=$(parse_nslookup_name "$out")
|
||||
name=$(parse_nslookup_name "$out") || return 1
|
||||
[ -n "$name" ] || return 1
|
||||
printf '%s' "$name"
|
||||
}
|
||||
@@ -598,11 +613,14 @@ resolve_addresses() {
|
||||
now=$(date +%s)
|
||||
[ $((now - start)) -ge "$RESOLVE_BUDGET" ] && break
|
||||
json_get_var ip "$idx"
|
||||
if ! is_resolvable_address "$ip"; then
|
||||
# Missing/empty element: set -u cannot read bare $ip (#291 C3).
|
||||
if ! is_resolvable_address "${ip:-}"; then
|
||||
idx=$((idx + 1))
|
||||
continue
|
||||
fi
|
||||
name=$(resolve_hostname "$ip")
|
||||
# NXDOMAIN / timeout: resolve_hostname returns 1. set -e
|
||||
# cannot apply to this assignment (#291 C3).
|
||||
name=$(resolve_hostname "$ip") || name=
|
||||
if [ -n "$name" ]; then
|
||||
map_add "$ip" "$name"
|
||||
count=$((count + 1))
|
||||
@@ -789,6 +807,17 @@ Address: 127.0.0.1:53")
|
||||
|
||||
run_logging_selftest || return 1
|
||||
|
||||
# Strict-mode smoke (#291 C3): status JSON uses soft-fail helpers
|
||||
# (uci miss, empty WAN zone). Must not abort under dash/ash set -e.
|
||||
_status=$(build_logging_status_json) || {
|
||||
echo "build_logging_status_json: aborted under set -e" >&2
|
||||
return 1
|
||||
}
|
||||
case "$_status" in
|
||||
*'"blockers":'*) ;;
|
||||
*) echo "build_logging_status_json: missing blockers: $_status" >&2; return 1 ;;
|
||||
esac
|
||||
|
||||
if ! command -v jshn >/dev/null 2>&1; then
|
||||
echo "skip: jshn not available (poll cap via jshn not tested)" >&2
|
||||
return 0
|
||||
@@ -834,7 +863,7 @@ Address: 127.0.0.1:53")
|
||||
return 0
|
||||
}
|
||||
|
||||
case "$1" in
|
||||
case "${1:-}" in
|
||||
__selftest)
|
||||
run_selftest
|
||||
exit $?
|
||||
@@ -845,22 +874,22 @@ case "$1" in
|
||||
exit $?
|
||||
;;
|
||||
__poll_clamp)
|
||||
poll_clamp_lines "$2"
|
||||
poll_clamp_lines "${2:-}"
|
||||
exit $?
|
||||
;;
|
||||
__tmp_dir_ok)
|
||||
_fwlive_tmp_dir_ok "$2"
|
||||
_fwlive_tmp_dir_ok "${2:-}"
|
||||
exit $?
|
||||
;;
|
||||
__resolve_one)
|
||||
if name=$(resolve_hostname "$2"); then
|
||||
if name=$(resolve_hostname "${2:-}"); then
|
||||
printf '%s\n' "$name"
|
||||
exit 0
|
||||
fi
|
||||
exit 1
|
||||
;;
|
||||
__parse_nslookup)
|
||||
name=$(parse_nslookup_name "$2")
|
||||
name=$(parse_nslookup_name "${2:-}")
|
||||
if [ -n "$name" ]; then
|
||||
printf '%s\n' "$name"
|
||||
exit 0
|
||||
@@ -874,15 +903,16 @@ case "$1" in
|
||||
echo '{"rules":{},"poll":{"addresses":[]},"resolve":{"addresses":[]},"logging_status":{},"enable_wan_logging":{},"disable_wan_logging":{}}'
|
||||
;;
|
||||
call)
|
||||
case "$2" in
|
||||
case "${2:-}" in
|
||||
rules)
|
||||
build_rules_map
|
||||
;;
|
||||
poll)
|
||||
poll_logs "$3"
|
||||
# rpcd may omit argv $3 and pass JSON on stdin (#291 C3).
|
||||
poll_logs "${3:-}"
|
||||
;;
|
||||
resolve)
|
||||
resolve_addresses "$3"
|
||||
resolve_addresses "${3:-}"
|
||||
;;
|
||||
logging_status)
|
||||
build_logging_status_json
|
||||
|
||||
@@ -15,12 +15,12 @@
|
||||
include $(TOPDIR)/rules.mk
|
||||
|
||||
PKG_NAME:=luci-app-wwand
|
||||
PKG_RELEASE:=10
|
||||
PKG_RELEASE:=11
|
||||
|
||||
PKG_SOURCE_PROTO:=git
|
||||
PKG_SOURCE_URL:=https://github.com/ddimension/luci-app-wwand.git
|
||||
PKG_SOURCE_VERSION:=bd773d76198d2a92063b349ed4f1e1f75475857c
|
||||
PKG_SOURCE_DATE:=2026-09-05
|
||||
PKG_SOURCE_VERSION:=6fd28614e5e8d40010119d52b83cf78908e2a7d3
|
||||
PKG_SOURCE_DATE:=2026-09-06
|
||||
PKG_MIRROR_HASH:=skip
|
||||
|
||||
PKG_LICENSE:=GPL-2.0-only
|
||||
|
||||
@@ -154,6 +154,7 @@
|
||||
"/dev/mtdblock[0-9]*": [ "read" ],
|
||||
"/etc/sysupgrade.conf": [ "read" ],
|
||||
"/lib/upgrade/platform.sh": [ "list" ],
|
||||
"/proc/[0-9]*/mounts": [ "read" ],
|
||||
"/proc/mounts": [ "read" ],
|
||||
"/proc/mtd": [ "read" ],
|
||||
"/proc/partitions": [ "read" ],
|
||||
|
||||
@@ -18,12 +18,12 @@
|
||||
include $(TOPDIR)/rules.mk
|
||||
|
||||
PKG_NAME:=luci-proto-wwand
|
||||
PKG_RELEASE:=6
|
||||
PKG_RELEASE:=7
|
||||
|
||||
PKG_SOURCE_PROTO:=git
|
||||
PKG_SOURCE_URL:=https://github.com/ddimension/luci-proto-wwand.git
|
||||
PKG_SOURCE_VERSION:=cc0bbfe522c7bc941a25f1ad14dfb2874660311b
|
||||
PKG_SOURCE_DATE:=2026-09-02
|
||||
PKG_SOURCE_VERSION:=b2cbfa2150034e7037a966e4676f301fcea6ed9e
|
||||
PKG_SOURCE_DATE:=2026-09-06
|
||||
PKG_MIRROR_HASH:=skip
|
||||
|
||||
PKG_LICENSE:=GPL-2.0-only
|
||||
|
||||
+2
-2
@@ -43,11 +43,11 @@
|
||||
include $(TOPDIR)/rules.mk
|
||||
|
||||
PKG_NAME:=wwand
|
||||
PKG_RELEASE:=23
|
||||
PKG_RELEASE:=24
|
||||
|
||||
PKG_SOURCE_PROTO:=git
|
||||
PKG_SOURCE_URL:=https://github.com/ddimension/wwand.git
|
||||
PKG_SOURCE_VERSION:=b9708dfd3fab8b180d3f66a66f1ea8dbce63adbf
|
||||
PKG_SOURCE_VERSION:=6ddec23e3ea9c6c6bbe1cd5feef207972137d840
|
||||
PKG_SOURCE_DATE:=2026-09-06
|
||||
PKG_MIRROR_HASH:=skip
|
||||
|
||||
|
||||
Reference in new issue
Block a user