mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-09-14 20:34:19 +08:00
Compare commits
3
Commits
65e584daaa
...
6348b68a14
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6348b68a14 | ||
|
|
95ded25b90 | ||
|
|
4b545cbffa |
@@ -130,7 +130,7 @@ jobs:
|
|||||||
git_clone https://github.com/sirpdboy/luci-app-poweroffdevice poweroffdevice && mvdir poweroffdevice
|
git_clone https://github.com/sirpdboy/luci-app-poweroffdevice poweroffdevice && mvdir poweroffdevice
|
||||||
git_clone https://github.com/sirpdboy/luci-app-watchdog watchdog1 && mvdir watchdog1
|
git_clone https://github.com/sirpdboy/luci-app-watchdog watchdog1 && mvdir watchdog1
|
||||||
git_clone https://github.com/sirpdboy/luci-app-cupsd cupsd1 && mv -n cupsd1/{luci-app-cupsd,cups} ./ ; rm -rf cupsd1
|
git_clone https://github.com/sirpdboy/luci-app-cupsd cupsd1 && mv -n cupsd1/{luci-app-cupsd,cups} ./ ; rm -rf cupsd1
|
||||||
git_clone https://github.com/sirpdboy/luci-app-timecontrol timecontrol && mvdir timecontrol
|
# git_clone https://github.com/sirpdboy/luci-app-timecontrol timecontrol && mvdir timecontrol
|
||||||
git_clone https://github.com/sirpdboy/luci-theme-kucat
|
git_clone https://github.com/sirpdboy/luci-theme-kucat
|
||||||
git_clone https://github.com/sirpdboy/luci-app-kucat-config
|
git_clone https://github.com/sirpdboy/luci-app-kucat-config
|
||||||
git_clone https://github.com/sirpdboy/luci-app-chatgpt-web
|
git_clone https://github.com/sirpdboy/luci-app-chatgpt-web
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
include $(TOPDIR)/rules.mk
|
include $(TOPDIR)/rules.mk
|
||||||
|
|
||||||
PKG_NAME:=luci-app-daede
|
PKG_NAME:=luci-app-daede
|
||||||
PKG_VERSION:=1.14.7
|
PKG_VERSION:=1.15
|
||||||
PKG_RELEASE:=42
|
PKG_RELEASE:=43
|
||||||
PKG_MAINTAINER:=kenzok8
|
PKG_MAINTAINER:=kenzok8
|
||||||
PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_NAME)
|
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/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/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/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_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_DIR) $(1)/usr/share/luci-app-daede/defaults
|
||||||
$(INSTALL_DATA) $(CURDIR)/../dae/files/dae.config $(1)/usr/share/luci-app-daede/defaults/dae
|
$(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 FETCHER = '/usr/share/luci-app-daede/fetch-clash-yaml.sh';
|
||||||
const GENERATOR = '/usr/share/luci-app-daede/gen-dae-config.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 SUB_STAGE = '/tmp/daede-sub.txt';
|
||||||
const FETCH_CHUNK_BYTES = 16384;
|
const FETCH_CHUNK_BYTES = 16384;
|
||||||
|
|
||||||
@@ -651,11 +652,19 @@ return view.extend({
|
|||||||
const name = airportName.value.trim();
|
const name = airportName.value.trim();
|
||||||
const groupName = airportSync.backendGroupName(name, 'daed', airportId);
|
const groupName = airportSync.backendGroupName(name, 'daed', airportId);
|
||||||
const endpoint = daedEndpoint();
|
const endpoint = daedEndpoint();
|
||||||
// daed can't read file:// — it HTTP-fetches a subscription link. Stage
|
// daed can't read file:// — it HTTP-fetches a subscription link. Persist
|
||||||
// the converted links (base64) and let daed pull them from the
|
// the converted snapshot and let daed pull it from the loopback-only CGI,
|
||||||
// loopback-only CGI, so the import lands as ONE subscription.
|
// so later daed refreshes and configuration backups keep working.
|
||||||
const subToken = airportSync.backendId(airportId) + Date.now().toString(36);
|
// Keep the CGI token opaque, bounded, and unique across imports started in
|
||||||
const stageFile = '/tmp/daede-daedsub-' + subToken;
|
// 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 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');
|
const b64 = btoa(items.map(function(item) { return item.link; }).join('\n') + '\n');
|
||||||
|
|
||||||
@@ -664,15 +673,156 @@ return view.extend({
|
|||||||
let createdGroupId = '';
|
let createdGroupId = '';
|
||||||
const createdNodeIds = [];
|
const createdNodeIds = [];
|
||||||
let groupReady = false;
|
let groupReady = false;
|
||||||
|
let snapshotCommitted = false;
|
||||||
|
let snapshotFinalToken = '';
|
||||||
|
let reuseExistingSnapshot = false;
|
||||||
const oldSubId = existingAirport ? existingAirport.subscription_id : '';
|
const oldSubId = existingAirport ? existingAirport.subscription_id : '';
|
||||||
const oldNodeIds = existingAirport ? existingAirport.node_ids : [];
|
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) {
|
const loadState = function(forceLogin) {
|
||||||
return requestDaedToken(endpoint, forceLogin).then(function(auth) {
|
return requestDaedToken(endpoint, forceLogin).then(function(auth) {
|
||||||
token = auth.token;
|
token = auth.token;
|
||||||
usedCachedToken = auth.cached;
|
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() {
|
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) {
|
return loadState(false).catch(function(error) {
|
||||||
if (!usedCachedToken || !daedSession.isAccessDenied(error))
|
if (!usedCachedToken || !daedSession.isAccessDenied(error))
|
||||||
throw error;
|
throw error;
|
||||||
@@ -806,12 +956,23 @@ return view.extend({
|
|||||||
});
|
});
|
||||||
}).then(function(dataValue) {
|
}).then(function(dataValue) {
|
||||||
before = dataValue;
|
before = dataValue;
|
||||||
// drop this airport's previous subscription first: the new one
|
const old = (before.subscriptions || []).find(function(sub) { return sub.id === oldSubId; });
|
||||||
// reuses the same tag (group name) and daed enforces unique tags.
|
const localToken = old && String(old.link || '').match(/^http:\/\/127\.0\.0\.1\/cgi-bin\/daede-sub\?t=([A-Za-z0-9]{1,64})$/);
|
||||||
if (!oldSubId)
|
const oldLinkReferences = localToken
|
||||||
return null;
|
? (before.subscriptions || []).filter(function(sub) { return String(sub.link || '') === String(old.link || ''); })
|
||||||
return graphQL(endpoint, 'mutation RmSub($ids:[ID!]!){removeSubscriptions(ids:$ids)}', { ids: [ oldSubId ] }, token).catch(function() {});
|
: [];
|
||||||
}).then(function() {
|
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
|
// import the whole converted batch as one subscription
|
||||||
return graphQL(endpoint,
|
return graphQL(endpoint,
|
||||||
'mutation Import($a:ImportArgument!){importSubscription(rollbackError:false,arg:$a){sub{id} nodeImportResult{error}}}',
|
'mutation Import($a:ImportArgument!){importSubscription(rollbackError:false,arg:$a){sub{id} nodeImportResult{error}}}',
|
||||||
@@ -832,16 +993,26 @@ return view.extend({
|
|||||||
if (daedSession.isAccessDenied(error))
|
if (daedSession.isAccessDenied(error))
|
||||||
throw error;
|
throw error;
|
||||||
const cleanupSub = newSubId
|
const cleanupSub = newSubId
|
||||||
? graphQL(endpoint, 'mutation Rm($ids:[ID!]!){removeSubscriptions(ids:$ids)}', { ids: [ newSubId ] }, token).catch(function() {})
|
? 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();
|
: Promise.resolve();
|
||||||
newSubId = '';
|
newSubId = '';
|
||||||
return cleanupSub.then(importDirectNodes).then(function(result) {
|
return cleanupSub.then(function() {
|
||||||
|
if (oldSubId)
|
||||||
|
throw error;
|
||||||
|
return importDirectNodes();
|
||||||
|
}).then(function(result) {
|
||||||
return { directResult: result };
|
return { directResult: result };
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
});
|
||||||
}).then(function(result) {
|
}).then(function(result) {
|
||||||
|
if (result.existingResult)
|
||||||
|
return result.existingResult;
|
||||||
if (result.directResult)
|
if (result.directResult)
|
||||||
return result.directResult;
|
return finishWithSnapshotCleanup(result.directResult);
|
||||||
|
|
||||||
const sub = result.importSubscription && result.importSubscription.sub;
|
const sub = result.importSubscription && result.importSubscription.sub;
|
||||||
newSubId = sub.id;
|
newSubId = sub.id;
|
||||||
@@ -861,6 +1032,7 @@ return view.extend({
|
|||||||
return ensureGroup.then(function(groupId) {
|
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() {
|
return graphQL(endpoint, 'mutation AddSubs($id:ID!,$ids:[ID!]!){groupAddSubscriptions(id:$id,subscriptionIDs:$ids)}', { id: groupId, ids: [ newSubId ] }, token).then(function() {
|
||||||
groupReady = true;
|
groupReady = true;
|
||||||
|
snapshotCommitted = true;
|
||||||
const cleanup = [];
|
const cleanup = [];
|
||||||
// Migrate converter-managed airport groups to proxy, but never
|
// Migrate converter-managed airport groups to proxy, but never
|
||||||
// disturb proxy's existing nodes or other subscriptions.
|
// disturb proxy's existing nodes or other subscriptions.
|
||||||
@@ -880,7 +1052,12 @@ return view.extend({
|
|||||||
});
|
});
|
||||||
return applyUciChanges().then(function() {
|
return applyUciChanges().then(function() {
|
||||||
items.forEach(function(item) { item.duplicate = true; item.selected = false; });
|
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;
|
throw error;
|
||||||
const cleanup = [];
|
const cleanup = [];
|
||||||
if (newSubId)
|
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)
|
if (createdNodeIds.length)
|
||||||
cleanup.push(graphQL(endpoint, 'mutation Rm($ids:[ID!]!){removeNodes(ids:$ids)}', { ids: createdNodeIds }, token));
|
cleanup.push(graphQL(endpoint, 'mutation Rm($ids:[ID!]!){removeNodes(ids:$ids)}', { ids: createdNodeIds }, token));
|
||||||
if (createdGroupId)
|
if (createdGroupId)
|
||||||
cleanup.push(graphQL(endpoint, 'mutation RmG($id:ID!){removeGroup(id:$id)}', { id: createdGroupId }, token));
|
cleanup.push(graphQL(endpoint, 'mutation RmG($id:ID!){removeGroup(id:$id)}', { id: createdGroupId }, token));
|
||||||
return Promise.all(cleanup).catch(function() {}).then(function() { throw error; });
|
return Promise.all(cleanup).then(function() {
|
||||||
}).finally(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 = '';
|
token = '';
|
||||||
return dropStage();
|
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…'));
|
setImportStatus(_('Importing node group…'));
|
||||||
const action = state.target === 'dae' ? importDae(items) : importDaed(items);
|
const action = state.target === 'dae' ? importDae(items) : importDaed(items);
|
||||||
action.then(function(result) {
|
action.then(function(result) {
|
||||||
setImportStatus(_('Node group imported: added %d, reused %d, failed %d')
|
let message = _('Node group imported: added %d, reused %d, failed %d')
|
||||||
.format(result.added, result.duplicates, result.failed), result.failed ? 'err' : 'ok');
|
.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();
|
renderResults();
|
||||||
}).catch(function(e) {
|
}).catch(function(e) {
|
||||||
setImportStatus(_('Node group import failed: %s').format(e.message || e), 'err');
|
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"
|
msgid "Node group imported: added %d, reused %d, failed %d"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Completed with warning: %s"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Managed subscription no longer exists in daed"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "Node group import failed: %s"
|
msgid "Node group import failed: %s"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
|||||||
@@ -1004,6 +1004,12 @@ msgstr "正在导入节点组…"
|
|||||||
msgid "Node group imported: added %d, reused %d, failed %d"
|
msgid "Node group imported: added %d, reused %d, failed %d"
|
||||||
msgstr "节点组导入完成:新增 %d,复用 %d,失败 %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"
|
msgid "Node group import failed: %s"
|
||||||
msgstr "节点组导入失败:%s"
|
msgstr "节点组导入失败:%s"
|
||||||
|
|
||||||
|
|||||||
@@ -1004,6 +1004,12 @@ msgstr "正在导入节点组…"
|
|||||||
msgid "Node group imported: added %d, reused %d, failed %d"
|
msgid "Node group imported: added %d, reused %d, failed %d"
|
||||||
msgstr "节点组导入完成:新增 %d,复用 %d,失败 %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"
|
msgid "Node group import failed: %s"
|
||||||
msgstr "节点组导入失败:%s"
|
msgstr "节点组导入失败:%s"
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ paths() {
|
|||||||
case "${name%.sub}" in ''|*[!A-Za-z0-9_]*) fail 'unexpected subscription filename' ;; esac
|
case "${name%.sub}" in ''|*[!A-Za-z0-9_]*) fail 'unexpected subscription filename' ;; esac
|
||||||
printf 'etc/dae/subscriptions/%s\n' "$name"
|
printf 'etc/dae/subscriptions/%s\n' "$name"
|
||||||
done
|
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() {
|
check_paths() {
|
||||||
for dir in /etc/config /etc/dae /etc/daed /etc/dae/subscriptions; do
|
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"
|
[ ! -e "/$p" ] || [ -f "/$p" ] || fail "not a regular file: /$p"
|
||||||
done < "$WORK/paths"
|
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() {
|
snapshot() {
|
||||||
mkdir -p "$WORK/before"
|
mkdir -p "$WORK/before"
|
||||||
while IFS= read -r p; do
|
while IFS= read -r p; do
|
||||||
@@ -139,6 +153,9 @@ validate_archive() {
|
|||||||
etc/dae/subscriptions/*.sub)
|
etc/dae/subscriptions/*.sub)
|
||||||
name="${p#etc/dae/subscriptions/}"
|
name="${p#etc/dae/subscriptions/}"
|
||||||
case "${name%.sub}" in ''|*[!A-Za-z0-9_]*) fail 'invalid subscription path' ;; esac ;;
|
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' ;;
|
*) fail 'unexpected archive entry' ;;
|
||||||
esac
|
esac
|
||||||
done < "$WORK/entries"
|
done < "$WORK/entries"
|
||||||
@@ -175,6 +192,7 @@ run_action() {
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
stop_backends
|
stop_backends
|
||||||
|
[ "$ACTION" = reset ] && cleanup_stages
|
||||||
snapshot
|
snapshot
|
||||||
if [ "$ACTION" = export ]; then
|
if [ "$ACTION" = export ]; then
|
||||||
paths "$WORK/before" > "$WORK/candidates"
|
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/dae-validate.dae": [ "write" ],
|
||||||
"/tmp/daede-import.b64": [ "write" ],
|
"/tmp/daede-import.b64": [ "write" ],
|
||||||
"/tmp/daede-sub.txt": [ "write" ],
|
"/tmp/daede-sub.txt": [ "write" ],
|
||||||
"/tmp/daede-daedsub-*": [ "write" ],
|
"/etc/daed/.daede-sub-stage-*": [ "write" ],
|
||||||
"/bin/rm -f /tmp/daede-daedsub-*": [ "exec" ],
|
"/etc/daed/daede-sub-*": [ "write" ],
|
||||||
"/usr/share/luci-app-daede/config-backup.sh import": [ "exec" ],
|
"/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 write-sub *": [ "exec" ],
|
||||||
"/usr/share/luci-app-daede/gen-dae-config.sh delete-sub *": [ "exec" ],
|
"/usr/share/luci-app-daede/gen-dae-config.sh delete-sub *": [ "exec" ],
|
||||||
"/etc/config/daede": [ "write" ],
|
"/etc/config/daede": [ "write" ],
|
||||||
|
|||||||
@@ -2,13 +2,13 @@
|
|||||||
# daede-sub - serve a converter's staged node list to the local daed only.
|
# 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
|
# 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
|
# does not read file:// like dae core. So the converter stores the base64 share
|
||||||
# links in /tmp/daede-daedsub-<token> and points daed's importSubscription at
|
# 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.
|
# 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
|
# 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
|
# remote client can pull the links. The snapshot remains available for daed's
|
||||||
# the import (success or failure), so the exposure is one local fetch.
|
# 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; }
|
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')
|
t=$(printf '%s' "$QUERY_STRING" | sed -n 's/^.*\bt=\([A-Za-z0-9]\{1,64\}\).*$/\1/p')
|
||||||
[ -n "$t" ] || deny
|
[ -n "$t" ] || deny
|
||||||
|
|
||||||
f="/tmp/daede-daedsub-$t"
|
f="/etc/daed/daede-sub-$t"
|
||||||
[ -f "$f" ] || deny
|
[ -f "$f" ] || deny
|
||||||
|
|
||||||
printf 'Content-Type: text/plain\r\n\r\n'
|
printf 'Content-Type: text/plain\r\n\r\n'
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ LUCI_DEPENDS:=+luci-base +logd +jsonfilter
|
|||||||
LUCI_PKGARCH:=all
|
LUCI_PKGARCH:=all
|
||||||
|
|
||||||
PKG_VERSION:=0.1.38
|
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).
|
# Reproducible build: honor SOURCE_DATE_EPOCH from the build environment (set by docker-sdk.sh / CI).
|
||||||
ifdef SOURCE_DATE_EPOCH
|
ifdef SOURCE_DATE_EPOCH
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
/* SPDX-License-Identifier: Apache-2.0 */
|
||||||
|
/* Copyright 2025-2026 Lucas Albers <lucas.b.albers@gmail.com> */
|
||||||
'require baseclass';
|
'require baseclass';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -16,26 +18,24 @@ function ingestCap(paused, rowLimit, fetchLinesMax) {
|
|||||||
|
|
||||||
function mergeById(entries, normalized, cap) {
|
function mergeById(entries, normalized, cap) {
|
||||||
if (!normalized || !normalized.length) {
|
if (!normalized || !normalized.length) {
|
||||||
if (!entries || !entries.length)
|
if (!entries || !entries.length) return [];
|
||||||
return [];
|
|
||||||
return entries.slice(-cap);
|
return entries.slice(-cap);
|
||||||
}
|
}
|
||||||
|
|
||||||
const byId = {};
|
const byId = {};
|
||||||
let i;
|
let i;
|
||||||
if (entries) {
|
if (entries) {
|
||||||
for (i = 0; i < entries.length; i++)
|
for (i = 0; i < entries.length; i++) byId[entries[i].id] = entries[i];
|
||||||
byId[entries[i].id] = entries[i];
|
|
||||||
}
|
}
|
||||||
for (i = 0; i < normalized.length; i++)
|
for (i = 0; i < normalized.length; i++) byId[normalized[i].id] = normalized[i];
|
||||||
byId[normalized[i].id] = normalized[i];
|
|
||||||
|
|
||||||
const merged = Object.keys(byId).map(function(id) { return byId[id]; });
|
const merged = Object.keys(byId).map(function (id) {
|
||||||
|
return byId[id];
|
||||||
|
});
|
||||||
merged.sort(function (a, b) {
|
merged.sort(function (a, b) {
|
||||||
const ta = a.timestamp || 0;
|
const ta = a.timestamp || 0;
|
||||||
const tb = b.timestamp || 0;
|
const tb = b.timestamp || 0;
|
||||||
if (ta !== tb)
|
if (ta !== tb) return ta - tb;
|
||||||
return ta - tb;
|
|
||||||
return (a.log_id || 0) - (b.log_id || 0);
|
return (a.log_id || 0) - (b.log_id || 0);
|
||||||
});
|
});
|
||||||
return merged.slice(-cap);
|
return merged.slice(-cap);
|
||||||
@@ -58,13 +58,10 @@ function applyFetchedEntries(entries, normalized, opts) {
|
|||||||
const merge = paused || resumeMerge;
|
const merge = paused || resumeMerge;
|
||||||
|
|
||||||
let next;
|
let next;
|
||||||
if (merge)
|
if (merge) next = mergeById(entries, normalized, cap);
|
||||||
next = mergeById(entries, normalized, cap);
|
else next = (normalized || []).slice(-cap);
|
||||||
else
|
|
||||||
next = (normalized || []).slice(-cap);
|
|
||||||
|
|
||||||
if (!paused && next.length > rowLimit)
|
if (!paused && next.length > rowLimit) next = next.slice(-rowLimit);
|
||||||
next = next.slice(-rowLimit);
|
|
||||||
|
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
'use strict';
|
'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 baseclass'; /* LuCI require() needs Class.isSubclass — plain return {} fails */
|
||||||
'require fwlive.log as log';
|
'require fwlive.log as log';
|
||||||
|
|
||||||
@@ -16,12 +18,9 @@
|
|||||||
|
|
||||||
function chipValueNodes(field, val) {
|
function chipValueNodes(field, val) {
|
||||||
const p = log.parseFilterValue(val);
|
const p = log.parseFilterValue(val);
|
||||||
if (!p.value)
|
if (!p.value) return [''];
|
||||||
return [ '' ];
|
|
||||||
|
|
||||||
const valueNode = p.negate
|
const valueNode = p.negate ? E('span', { 'class': 'fwlive-chip-strike' }, [p.value]) : p.value;
|
||||||
? E('span', { 'class': 'fwlive-chip-strike' }, [ p.value ])
|
|
||||||
: p.value;
|
|
||||||
|
|
||||||
if (!p.negate) {
|
if (!p.negate) {
|
||||||
return [
|
return [
|
||||||
@@ -39,21 +38,19 @@ function chipValueNodes(field, val) {
|
|||||||
valueNode
|
valueNode
|
||||||
];
|
];
|
||||||
|
|
||||||
return [
|
return [field + ': ', E('strong', { 'class': 'fwlive-chip-not' }, [_('not')]), ' ', valueNode];
|
||||||
field + ': ',
|
|
||||||
E('strong', { 'class': 'fwlive-chip-not' }, [ _('not') ]),
|
|
||||||
' ',
|
|
||||||
valueNode
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function chipLeadingSym(negated) {
|
function chipLeadingSym(negated) {
|
||||||
if (!negated)
|
if (!negated) return null;
|
||||||
return null;
|
return E(
|
||||||
return E('span', {
|
'span',
|
||||||
|
{
|
||||||
'class': 'fwlive-chip-sym fwlive-chip-sym-light',
|
'class': 'fwlive-chip-sym fwlive-chip-sym-light',
|
||||||
'aria-hidden': 'true'
|
'aria-hidden': 'true'
|
||||||
}, [ '≠' ]);
|
},
|
||||||
|
['≠']
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderFilterChips(host, state, callbacks) {
|
function renderFilterChips(host, state, callbacks) {
|
||||||
@@ -64,38 +61,62 @@ function renderFilterChips(host, state, callbacks) {
|
|||||||
for (let i = 0; i < chipFields.length; i++) {
|
for (let i = 0; i < chipFields.length; i++) {
|
||||||
const spec = chipFields[i];
|
const spec = chipFields[i];
|
||||||
const val = filters[spec.key];
|
const val = filters[spec.key];
|
||||||
if (!val)
|
if (!val) continue;
|
||||||
continue;
|
|
||||||
|
|
||||||
const parsed = log.parseFilterValue(val);
|
const parsed = log.parseFilterValue(val);
|
||||||
const negated = parsed.negate;
|
const negated = parsed.negate;
|
||||||
const kids = [];
|
const kids = [];
|
||||||
const lead = chipLeadingSym(negated);
|
const lead = chipLeadingSym(negated);
|
||||||
if (lead)
|
if (lead) kids.push(lead);
|
||||||
kids.push(lead);
|
|
||||||
|
|
||||||
kids.push(E('span', { 'class': 'fwlive-chip-label' }, chipValueNodes(spec.label, val)));
|
kids.push(E('span', { 'class': 'fwlive-chip-label' }, chipValueNodes(spec.label, val)));
|
||||||
kids.push(E('span', {
|
kids.push(
|
||||||
|
E(
|
||||||
|
'span',
|
||||||
|
{
|
||||||
'class': 'fwlive-chip-invert-wrap',
|
'class': 'fwlive-chip-invert-wrap',
|
||||||
'data-tip': negated ? _('Include instead') : _('Exclude instead')
|
'data-tip': negated ? _('Include instead') : _('Exclude instead')
|
||||||
}, [
|
},
|
||||||
E('button', {
|
[
|
||||||
|
E(
|
||||||
|
'button',
|
||||||
|
{
|
||||||
'type': 'button',
|
'type': 'button',
|
||||||
'class': 'fwlive-chip-invert',
|
'class': 'fwlive-chip-invert',
|
||||||
'click': function(ev) { callbacks.onInvert(spec.key, ev); }
|
'click': function (ev) {
|
||||||
}, [ '≠' ])
|
callbacks.onInvert(spec.key, ev);
|
||||||
]));
|
}
|
||||||
kids.push(E('a', {
|
},
|
||||||
|
['≠']
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
kids.push(
|
||||||
|
E(
|
||||||
|
'a',
|
||||||
|
{
|
||||||
'href': '#',
|
'href': '#',
|
||||||
'class': 'fwlive-chip-remove',
|
'class': 'fwlive-chip-remove',
|
||||||
'title': _('Remove filter'),
|
'title': _('Remove filter'),
|
||||||
'click': function(ev) { callbacks.onClear(spec.key, ev); }
|
'click': function (ev) {
|
||||||
}, [ '×' ]));
|
callbacks.onClear(spec.key, ev);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
['×']
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
chips.push(E('span', {
|
chips.push(
|
||||||
'class': 'fwlive-chip'
|
E(
|
||||||
+ (negated ? ' fwlive-chip-negated' : ' fwlive-chip-include')
|
'span',
|
||||||
}, kids));
|
{
|
||||||
|
'class':
|
||||||
|
'fwlive-chip' + (negated ? ' fwlive-chip-negated' : ' fwlive-chip-include')
|
||||||
|
},
|
||||||
|
kids
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
host.className = 'fwlive-chips fwlive-chips-labels';
|
host.className = 'fwlive-chips fwlive-chips-labels';
|
||||||
@@ -106,14 +127,21 @@ function renderFilterChips(host, state, callbacks) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
host.style.display = 'flex';
|
host.style.display = 'flex';
|
||||||
for (let i = 0; i < chips.length; i++)
|
for (let i = 0; i < chips.length; i++) host.appendChild(chips[i]);
|
||||||
host.appendChild(chips[i]);
|
|
||||||
|
|
||||||
host.appendChild(E('a', {
|
host.appendChild(
|
||||||
|
E(
|
||||||
|
'a',
|
||||||
|
{
|
||||||
'href': '#',
|
'href': '#',
|
||||||
'class': 'fwlive-chip-clear',
|
'class': 'fwlive-chip-clear',
|
||||||
'click': function(ev) { callbacks.onClearAll(ev); }
|
'click': function (ev) {
|
||||||
}, [ _('Clear all') ]));
|
callbacks.onClearAll(ev);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[_('Clear all')]
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return baseclass.extend({
|
return baseclass.extend({
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
/* SPDX-License-Identifier: Apache-2.0 */
|
||||||
|
/* Copyright 2025-2026 Lucas Albers <lucas.b.albers@gmail.com> */
|
||||||
'require baseclass';
|
'require baseclass';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -13,12 +15,27 @@ return baseclass.extend({
|
|||||||
/* Row pass/deny tint (#40): classic green/red default; accessible teal/orange */
|
/* 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',
|
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 */
|
/* DOM budget: ~250 new/updated rows painted per second on typical LuCI routers */
|
||||||
RENDER_CAP_PER_SEC: 250,
|
RENDER_CAP_PER_SEC: 250,
|
||||||
VIEW_MODES: ['simple', 'detailed'],
|
VIEW_MODES: ['simple', 'detailed'],
|
||||||
COLUMN_SETS: {
|
COLUMN_SETS: {
|
||||||
simple: ['action', 'time', 'iface', 'flow', 'proto', 'rule'],
|
simple: ['action', 'time', 'iface', 'flow', 'proto', 'rule'],
|
||||||
detailed: [ 'time', 'action', 'rule', 'iface_in', 'iface_out', 'dir', 'proto', 'src', 'sport', 'dst', 'dport', 'flags', 'len', 'message' ]
|
detailed: [
|
||||||
|
'time',
|
||||||
|
'action',
|
||||||
|
'rule',
|
||||||
|
'iface_in',
|
||||||
|
'iface_out',
|
||||||
|
'dir',
|
||||||
|
'proto',
|
||||||
|
'src',
|
||||||
|
'sport',
|
||||||
|
'dst',
|
||||||
|
'dport',
|
||||||
|
'flags',
|
||||||
|
'len',
|
||||||
|
'message'
|
||||||
|
]
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
/* SPDX-License-Identifier: Apache-2.0 */
|
||||||
|
/* Copyright 2025-2026 Lucas Albers <lucas.b.albers@gmail.com> */
|
||||||
'require baseclass';
|
'require baseclass';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
/* SPDX-License-Identifier: Apache-2.0 */
|
||||||
|
/* Copyright 2025-2026 Lucas Albers <lucas.b.albers@gmail.com> */
|
||||||
'require baseclass';
|
'require baseclass';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -13,8 +15,7 @@ return baseclass.extend({
|
|||||||
/* Touch-on-write LRU: re-insert moves key to newest; evict oldest when over max. */
|
/* 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;
|
const cap = max || this.CACHE_MAX;
|
||||||
if (map.has(key))
|
if (map.has(key)) map.delete(key);
|
||||||
map.delete(key);
|
|
||||||
map.set(key, value);
|
map.set(key, value);
|
||||||
while (map.size > cap) {
|
while (map.size > cap) {
|
||||||
const oldest = map.keys().next().value;
|
const oldest = map.keys().next().value;
|
||||||
@@ -24,8 +25,7 @@ return baseclass.extend({
|
|||||||
},
|
},
|
||||||
|
|
||||||
lruGet: function (map, key) {
|
lruGet: function (map, key) {
|
||||||
if (!map.has(key))
|
if (!map.has(key)) return undefined;
|
||||||
return undefined;
|
|
||||||
const value = map.get(key);
|
const value = map.get(key);
|
||||||
map.delete(key);
|
map.delete(key);
|
||||||
map.set(key, value);
|
map.set(key, value);
|
||||||
@@ -33,12 +33,11 @@ return baseclass.extend({
|
|||||||
},
|
},
|
||||||
|
|
||||||
failIsHot: function (failedMap, ip, nowMs, ttlMs) {
|
failIsHot: function (failedMap, ip, nowMs, ttlMs) {
|
||||||
if (!failedMap || !failedMap.has(ip))
|
if (!failedMap || !failedMap.has(ip)) return false;
|
||||||
return false;
|
|
||||||
const at = failedMap.get(ip);
|
const at = failedMap.get(ip);
|
||||||
const ttl = ttlMs == null ? this.FAIL_TTL_MS : ttlMs;
|
const ttl = ttlMs == null ? this.FAIL_TTL_MS : ttlMs;
|
||||||
const now = nowMs == null ? Date.now() : nowMs;
|
const now = nowMs == null ? Date.now() : nowMs;
|
||||||
if ((now - at) >= ttl) {
|
if (now - at >= ttl) {
|
||||||
failedMap.delete(ip);
|
failedMap.delete(ip);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -48,8 +47,7 @@ return baseclass.extend({
|
|||||||
failMark: function (failedMap, ip, nowMs, max) {
|
failMark: function (failedMap, ip, nowMs, max) {
|
||||||
const cap = max || this.FAIL_MAX;
|
const cap = max || this.FAIL_MAX;
|
||||||
const now = nowMs == null ? Date.now() : nowMs;
|
const now = nowMs == null ? Date.now() : nowMs;
|
||||||
if (failedMap.has(ip))
|
if (failedMap.has(ip)) failedMap.delete(ip);
|
||||||
failedMap.delete(ip);
|
|
||||||
failedMap.set(ip, now);
|
failedMap.set(ip, now);
|
||||||
while (failedMap.size > cap) {
|
while (failedMap.size > cap) {
|
||||||
const oldest = failedMap.keys().next().value;
|
const oldest = failedMap.keys().next().value;
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
'use strict';
|
'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 baseclass'; /* LuCI require() needs Class.isSubclass — plain return {} fails */
|
||||||
'require fwlive.log as log';
|
'require fwlive.log as log';
|
||||||
|
|
||||||
@@ -16,8 +18,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
function luciUrl(path) {
|
function luciUrl(path) {
|
||||||
if (typeof L !== 'undefined' && L.url)
|
if (typeof L !== 'undefined' && L.url) return L.url(path);
|
||||||
return L.url(path);
|
|
||||||
|
|
||||||
return '/cgi-bin/luci/' + path;
|
return '/cgi-bin/luci/' + path;
|
||||||
}
|
}
|
||||||
@@ -31,10 +32,14 @@ function firewallZonesUrl() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function firewallZonesLink(label) {
|
function firewallZonesLink(label) {
|
||||||
return E('a', {
|
return E(
|
||||||
|
'a',
|
||||||
|
{
|
||||||
'href': firewallZonesUrl(),
|
'href': firewallZonesUrl(),
|
||||||
'class': 'fwlive-filter-link'
|
'class': 'fwlive-filter-link'
|
||||||
}, [ label || _('Network → Firewall') ]);
|
},
|
||||||
|
[label || _('Network → Firewall')]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -44,15 +49,20 @@ function firewallZonesLink(label) {
|
|||||||
* @param {function} onFilterClick - callback(field, value, ev)
|
* @param {function} onFilterClick - callback(field, value, ev)
|
||||||
*/
|
*/
|
||||||
function filterLink(field, value, label, onFilterClick) {
|
function filterLink(field, value, label, onFilterClick) {
|
||||||
if (!value)
|
if (!value) return log.formatCell(value);
|
||||||
return log.formatCell(value);
|
|
||||||
|
|
||||||
return E('a', {
|
return E(
|
||||||
|
'a',
|
||||||
|
{
|
||||||
'href': '#',
|
'href': '#',
|
||||||
'class': 'fwlive-filter-link',
|
'class': 'fwlive-filter-link',
|
||||||
'title': _('Filter by %s').format(field),
|
'title': _('Filter by %s').format(field),
|
||||||
'click': function(ev) { onFilterClick(field, value, ev); }
|
'click': function (ev) {
|
||||||
}, [ label || value ]);
|
onFilterClick(field, value, ev);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[label || value]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -63,19 +73,24 @@ function filterLink(field, value, label, onFilterClick) {
|
|||||||
* @param {function} onFilterClick - callback(field, value, ev)
|
* @param {function} onFilterClick - callback(field, value, ev)
|
||||||
*/
|
*/
|
||||||
function addrFilterLink(field, ip, showHostnames, hostnameCache, onFilterClick) {
|
function addrFilterLink(field, ip, showHostnames, hostnameCache, onFilterClick) {
|
||||||
if (!ip)
|
if (!ip) return log.formatCell(ip);
|
||||||
return log.formatCell(ip);
|
|
||||||
|
|
||||||
const name = showHostnames && hostnameCache ? hostnameCache.get(ip) : null;
|
const name = showHostnames && hostnameCache ? hostnameCache.get(ip) : null;
|
||||||
const display = name || ip;
|
const display = name || ip;
|
||||||
const title = name ? ip : _('Filter by %s').format(field);
|
const title = name ? ip : _('Filter by %s').format(field);
|
||||||
|
|
||||||
return E('a', {
|
return E(
|
||||||
|
'a',
|
||||||
|
{
|
||||||
'href': '#',
|
'href': '#',
|
||||||
'class': 'fwlive-filter-link',
|
'class': 'fwlive-filter-link',
|
||||||
'title': title,
|
'title': title,
|
||||||
'click': function(ev) { onFilterClick(field, ip, ev); }
|
'click': function (ev) {
|
||||||
}, [ display ]);
|
onFilterClick(field, ip, ev);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[display]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -83,11 +98,9 @@ function addrFilterLink(field, ip, showHostnames, hostnameCache, onFilterClick)
|
|||||||
* @param {string} firewallBackend - 'nft' or 'iptables'
|
* @param {string} firewallBackend - 'nft' or 'iptables'
|
||||||
*/
|
*/
|
||||||
function ruleAdminPath(hint, firewallBackend) {
|
function ruleAdminPath(hint, firewallBackend) {
|
||||||
if (hint === 'fw4')
|
if (hint === 'fw4') return 'admin/network/firewall/rules';
|
||||||
return 'admin/network/firewall/rules';
|
|
||||||
|
|
||||||
if (firewallBackend === 'iptables')
|
if (firewallBackend === 'iptables') return 'admin/status/iptables';
|
||||||
return 'admin/status/iptables';
|
|
||||||
|
|
||||||
return 'admin/status/nftables';
|
return 'admin/status/nftables';
|
||||||
}
|
}
|
||||||
@@ -99,27 +112,31 @@ function ruleAdminPath(hint, firewallBackend) {
|
|||||||
* @param {function} onFilterClick - callback(field, value, ev)
|
* @param {function} onFilterClick - callback(field, value, ev)
|
||||||
*/
|
*/
|
||||||
function ruleAdminLink(hint, label, firewallBackend, onFilterClick) {
|
function ruleAdminLink(hint, label, firewallBackend, onFilterClick) {
|
||||||
if (!hint)
|
if (!hint) return log.formatCell(hint);
|
||||||
return log.formatCell(hint);
|
|
||||||
|
|
||||||
const path = ruleAdminPath(hint, firewallBackend);
|
const path = ruleAdminPath(hint, firewallBackend);
|
||||||
const url = '%s#%s'.format(luciUrl(path), encodeURIComponent(hint));
|
const url = '%s#%s'.format(luciUrl(path), encodeURIComponent(hint));
|
||||||
const text = label || hint;
|
const text = label || hint;
|
||||||
|
|
||||||
return E('a', {
|
return E(
|
||||||
|
'a',
|
||||||
|
{
|
||||||
'href': '#',
|
'href': '#',
|
||||||
'class': 'fwlive-filter-link fwlive-rule-link',
|
'class': 'fwlive-filter-link fwlive-rule-link',
|
||||||
'title': _('Filter logs by rule (hint: %s). Ctrl+click to open firewall settings.').format(hint),
|
'title': _(
|
||||||
|
'Filter logs by rule (hint: %s). Ctrl+click to open firewall settings.'
|
||||||
|
).format(hint),
|
||||||
'click': function (ev) {
|
'click': function (ev) {
|
||||||
if (ev && (ev.ctrlKey || ev.metaKey)) {
|
if (ev && (ev.ctrlKey || ev.metaKey)) {
|
||||||
if (ev.preventDefault)
|
if (ev.preventDefault) ev.preventDefault();
|
||||||
ev.preventDefault();
|
|
||||||
window.location = url;
|
window.location = url;
|
||||||
return;
|
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)
|
* @param {function} onFilterClick - callback(field, value, ev)
|
||||||
*/
|
*/
|
||||||
function ifaceLink(value, onFilterClick) {
|
function ifaceLink(value, onFilterClick) {
|
||||||
if (!value)
|
if (!value) return log.formatCell(value);
|
||||||
return log.formatCell(value);
|
|
||||||
|
|
||||||
return E('a', {
|
return E(
|
||||||
|
'a',
|
||||||
|
{
|
||||||
'href': '#',
|
'href': '#',
|
||||||
'class': 'fwlive-filter-link fwlive-iface-badge',
|
'class': 'fwlive-filter-link fwlive-iface-badge',
|
||||||
'title': _('Filter by interface'),
|
'title': _('Filter by interface'),
|
||||||
'click': function(ev) { onFilterClick('interface', value, ev); }
|
'click': function (ev) {
|
||||||
}, [ value ]);
|
onFilterClick('interface', value, ev);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[value]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return baseclass.extend({
|
return baseclass.extend({
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
/* SPDX-License-Identifier: Apache-2.0 */
|
||||||
|
/* Copyright 2025-2026 Lucas Albers <lucas.b.albers@gmail.com> */
|
||||||
'require baseclass';
|
'require baseclass';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -8,23 +10,58 @@
|
|||||||
*/
|
*/
|
||||||
return baseclass.extend({
|
return baseclass.extend({
|
||||||
CLASSIFY_SPEC: {
|
CLASSIFY_SPEC: {
|
||||||
glueKeys: ['IN', 'OUT', 'SRC', 'DST', 'PROTO', 'SPT', 'DPT', 'LEN', 'MAC', 'TYPE', 'CODE', 'TTL', 'TOS', 'PREC', 'DF'],
|
glueKeys: [
|
||||||
nonFirewallPrefixes: ['dnsmasq', 'procd', 'ubusd', 'netifd', 'odhcpd', 'logd', 'dropbear', 'uhttpd', 'hostapd', 'wpad'],
|
'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'],
|
firewallHints: ['fw4', 'nft', 'iptables', 'kernel', 'firewall'],
|
||||||
actionWords: ['ACCEPT', 'ALLOW', 'PASS', 'DROP', 'REJECT', 'DENY', 'BLOCK'],
|
actionWords: ['ACCEPT', 'ALLOW', 'PASS', 'DROP', 'REJECT', 'DENY', 'BLOCK'],
|
||||||
rules: [
|
rules: [
|
||||||
{ or: [
|
{
|
||||||
|
or: [
|
||||||
{ and: [{ kv: ['SRC'] }, { kv: ['DST'] }] },
|
{ and: [{ kv: ['SRC'] }, { kv: ['DST'] }] },
|
||||||
{ and: [ { kvAny: ['IN', 'OUT'] }, { kvAny: ['SRC', 'DST', 'PROTO', 'SPT', 'DPT'] } ] },
|
{
|
||||||
|
and: [
|
||||||
|
{ kvAny: ['IN', 'OUT'] },
|
||||||
|
{ kvAny: ['SRC', 'DST', 'PROTO', 'SPT', 'DPT'] }
|
||||||
|
]
|
||||||
|
},
|
||||||
{ and: [{ action: 'known' }, { kvAny: ['IN', 'OUT', 'PROTO', 'SRC', 'DST'] }] }
|
{ and: [{ action: 'known' }, { kvAny: ['IN', 'OUT', 'PROTO', 'SRC', 'DST'] }] }
|
||||||
]},
|
]
|
||||||
|
},
|
||||||
{ and: [{ hint: true }, { action: 'known' }] },
|
{ and: [{ hint: true }, { action: 'known' }] },
|
||||||
{ and: [{ hint: true }, { kvAny: ['IN', 'OUT', 'SRC', 'DST', 'PROTO'] }] }
|
{ 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,
|
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('|');
|
const alt = words.join('|');
|
||||||
@@ -35,7 +72,8 @@ return baseclass.extend({
|
|||||||
return new RegExp('(^|[^A-Za-z0-9_])' + key + '=').test(msg);
|
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,
|
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,
|
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,
|
DENY_ACTION: /(^|[^A-Za-z0-9_])(DROP|REJECT|DENY|BLOCK)([^A-Za-z0-9_]|$)/i,
|
||||||
@@ -50,8 +88,7 @@ return baseclass.extend({
|
|||||||
const normalized = this.normalizeNetfilterMessage(message);
|
const normalized = this.normalizeNetfilterMessage(message);
|
||||||
let match;
|
let match;
|
||||||
|
|
||||||
while ((match = re.exec(normalized)) !== null)
|
while ((match = re.exec(normalized)) !== null) out[match[1]] = match[2];
|
||||||
out[match[1]] = match[2];
|
|
||||||
|
|
||||||
return out;
|
return out;
|
||||||
},
|
},
|
||||||
@@ -65,11 +102,12 @@ return baseclass.extend({
|
|||||||
const msg = this.normalizeNetfilterMessage(message || '');
|
const msg = this.normalizeNetfilterMessage(message || '');
|
||||||
const action = actionRaw === undefined ? this.detectAction(msg) : actionRaw;
|
const action = actionRaw === undefined ? this.detectAction(msg) : actionRaw;
|
||||||
const self = this;
|
const self = this;
|
||||||
const has = function(key) { return self.kvHas(msg, key); };
|
const has = function (key) {
|
||||||
|
return self.kvHas(msg, key);
|
||||||
|
};
|
||||||
const hasAny = function (keys) {
|
const hasAny = function (keys) {
|
||||||
for (let i = 0; i < keys.length; i++) {
|
for (let i = 0; i < keys.length; i++) {
|
||||||
if (has(keys[i]))
|
if (has(keys[i])) return true;
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
@@ -77,43 +115,44 @@ return baseclass.extend({
|
|||||||
const pred = {
|
const pred = {
|
||||||
kv: function (c) {
|
kv: function (c) {
|
||||||
for (let i = 0; i < c.kv.length; i++) {
|
for (let i = 0; i < c.kv.length; i++) {
|
||||||
if (!has(c.kv[i]))
|
if (!has(c.kv[i])) return false;
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
kvAny: function(c) { return hasAny(c.kvAny); },
|
kvAny: function (c) {
|
||||||
action: function(c) { return (c.action === 'known') ? action !== 'UNKNOWN' : true; },
|
return hasAny(c.kvAny);
|
||||||
hint: function() { return self.FIREWALL_HINT.test(msg); }
|
},
|
||||||
|
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) {
|
if (node.and) {
|
||||||
for (let i = 0; i < node.and.length; i++) {
|
for (let i = 0; i < node.and.length; i++) {
|
||||||
if (!evalNode(node.and[i]))
|
if (!evalNode(node.and[i])) return false;
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (node.or) {
|
if (node.or) {
|
||||||
for (let i = 0; i < node.or.length; i++) {
|
for (let i = 0; i < node.or.length; i++) {
|
||||||
if (evalNode(node.or[i]))
|
if (evalNode(node.or[i])) return true;
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const keys = Object.keys(node);
|
const keys = Object.keys(node);
|
||||||
for (let i = 0; i < keys.length; i++) {
|
for (let i = 0; i < keys.length; i++) {
|
||||||
const k = keys[i];
|
const k = keys[i];
|
||||||
if (pred[k])
|
if (pred[k]) return pred[k](node);
|
||||||
return pred[k](node);
|
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
for (let i = 0; i < this.CLASSIFY_SPEC.rules.length; i++) {
|
for (let i = 0; i < this.CLASSIFY_SPEC.rules.length; i++) {
|
||||||
if (evalNode(this.CLASSIFY_SPEC.rules[i]))
|
if (evalNode(this.CLASSIFY_SPEC.rules[i])) return true;
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
@@ -123,14 +162,10 @@ return baseclass.extend({
|
|||||||
const words = this.CLASSIFY_SPEC.actionWords;
|
const words = this.CLASSIFY_SPEC.actionWords;
|
||||||
const pass = words.slice(0, 3); /* ACCEPT|ALLOW|PASS */
|
const pass = words.slice(0, 3); /* ACCEPT|ALLOW|PASS */
|
||||||
const denyClass = words.slice(3); /* DROP|REJECT|DENY|BLOCK — positional */
|
const denyClass = words.slice(3); /* DROP|REJECT|DENY|BLOCK — positional */
|
||||||
if (pass.indexOf(a) >= 0)
|
if (pass.indexOf(a) >= 0) return 'pass';
|
||||||
return 'pass';
|
if (a === words[3]) /* DROP */ return 'drop';
|
||||||
if (a === words[3]) /* DROP */
|
if (a === words[4]) /* REJECT */ return 'reject';
|
||||||
return 'drop';
|
if (denyClass.indexOf(a) >= 0) /* DENY|BLOCK */ return 'block';
|
||||||
if (a === words[4]) /* REJECT */
|
|
||||||
return 'reject';
|
|
||||||
if (denyClass.indexOf(a) >= 0) /* DENY|BLOCK */
|
|
||||||
return 'block';
|
|
||||||
return 'unknown';
|
return 'unknown';
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -138,106 +173,90 @@ return baseclass.extend({
|
|||||||
let msg = this.normalizeNetfilterMessage(message || '').trim();
|
let msg = this.normalizeNetfilterMessage(message || '').trim();
|
||||||
msg = msg.replace(/^\[\s*[\d.]+\]\s*/, '');
|
msg = msg.replace(/^\[\s*[\d.]+\]\s*/, '');
|
||||||
|
|
||||||
if (/^fw4:\s*/i.test(msg))
|
if (/^fw4:\s*/i.test(msg)) return 'fw4';
|
||||||
return 'fw4';
|
|
||||||
|
|
||||||
const beforeKv = msg.match(/^([A-Za-z0-9_.-]+)(?::|\s+)(?=IN=|OUT=|SRC=|DST=|PROTO=)/);
|
const beforeKv = msg.match(/^([A-Za-z0-9_.-]+)(?::|\s+)(?=IN=|OUT=|SRC=|DST=|PROTO=)/);
|
||||||
if (beforeKv)
|
if (beforeKv) return beforeKv[1];
|
||||||
return beforeKv[1];
|
|
||||||
|
|
||||||
const colon = msg.match(/^([A-Za-z0-9_.-]+):/);
|
const colon = msg.match(/^([A-Za-z0-9_.-]+):/);
|
||||||
if (colon) {
|
if (colon) {
|
||||||
const tag = colon[1].toLowerCase();
|
const tag = colon[1].toLowerCase();
|
||||||
if (tag !== 'kernel' && tag !== 'iptables')
|
if (tag !== 'kernel' && tag !== 'iptables') return colon[1];
|
||||||
return colon[1];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return '';
|
return '';
|
||||||
},
|
},
|
||||||
|
|
||||||
formatRuleLabel: function (hint) {
|
formatRuleLabel: function (hint) {
|
||||||
if (!hint)
|
if (!hint) return '';
|
||||||
return '';
|
|
||||||
|
|
||||||
if (hint === 'fw4')
|
if (hint === 'fw4') return 'Firewall4';
|
||||||
return 'Firewall4';
|
|
||||||
|
|
||||||
return hint.replace(/-/g, ' ');
|
return hint.replace(/-/g, ' ');
|
||||||
},
|
},
|
||||||
|
|
||||||
inferActionRaw: function (message, kv, actionRaw) {
|
inferActionRaw: function (message, kv, actionRaw) {
|
||||||
if (actionRaw && actionRaw !== 'UNKNOWN')
|
if (actionRaw && actionRaw !== 'UNKNOWN') return actionRaw;
|
||||||
return actionRaw;
|
|
||||||
|
|
||||||
const msg = this.normalizeNetfilterMessage(message || '');
|
const msg = this.normalizeNetfilterMessage(message || '');
|
||||||
const withoutKv = msg.replace(/\b[A-Z]+=[^\s]*/g, ' ');
|
const withoutKv = msg.replace(/\b[A-Z]+=[^\s]*/g, ' ');
|
||||||
if (this.DENY_ACTION.test(withoutKv))
|
if (this.DENY_ACTION.test(withoutKv)) return 'UNKNOWN';
|
||||||
return 'UNKNOWN';
|
|
||||||
|
|
||||||
if (/^kernel:/i.test(msg.trim()))
|
if (/^kernel:/i.test(msg.trim())) return 'UNKNOWN';
|
||||||
return 'UNKNOWN';
|
|
||||||
|
|
||||||
const hasTuple = !!(kv.IN || kv.OUT) && !!(kv.SRC || kv.DST || kv.PROTO);
|
const hasTuple = !!(kv.IN || kv.OUT) && !!(kv.SRC || kv.DST || kv.PROTO);
|
||||||
if (hasTuple)
|
if (hasTuple) return 'PASS';
|
||||||
return 'PASS';
|
|
||||||
|
|
||||||
return 'UNKNOWN';
|
return 'UNKNOWN';
|
||||||
},
|
},
|
||||||
|
|
||||||
parseFlags: function (message, kv) {
|
parseFlags: function (message, kv) {
|
||||||
if (kv.TCPFLAGS)
|
if (kv.TCPFLAGS) return kv.TCPFLAGS;
|
||||||
return kv.TCPFLAGS;
|
if (kv.FLAGS) return kv.FLAGS;
|
||||||
if (kv.FLAGS)
|
|
||||||
return kv.FLAGS;
|
|
||||||
|
|
||||||
const m = message.match(this.TCP_FLAG_TAIL);
|
const m = message.match(this.TCP_FLAG_TAIL);
|
||||||
if (!m)
|
if (!m) return '';
|
||||||
return '';
|
|
||||||
|
|
||||||
return m[0].trim().toUpperCase().replace(/\s+/g, ',');
|
return m[0].trim().toUpperCase().replace(/\s+/g, ',');
|
||||||
},
|
},
|
||||||
|
|
||||||
parseLength: function (kv) {
|
parseLength: function (kv) {
|
||||||
const len = kv.LEN || kv.LENGTH || '';
|
const len = kv.LEN || kv.LENGTH || '';
|
||||||
if (!len)
|
if (!len) return null;
|
||||||
return null;
|
|
||||||
|
|
||||||
const n = parseInt(len, 10);
|
const n = parseInt(len, 10);
|
||||||
return isFinite(n) ? n : null;
|
return isFinite(n) ? n : null;
|
||||||
},
|
},
|
||||||
|
|
||||||
timestampUnix: function (entry) {
|
timestampUnix: function (entry) {
|
||||||
if (!entry || entry.time == null || entry.time === '')
|
if (!entry || entry.time == null || entry.time === '') return null;
|
||||||
return null;
|
|
||||||
|
|
||||||
if (typeof entry.time === 'string' && /^\d{4}-\d{2}-\d{2}[T ]/.test(entry.time)) {
|
if (typeof entry.time === 'string' && /^\d{4}-\d{2}-\d{2}[T ]/.test(entry.time)) {
|
||||||
const ms = new Date(entry.time).getTime();
|
const ms = new Date(entry.time).getTime();
|
||||||
if (isFinite(ms))
|
if (isFinite(ms)) return Math.floor(ms / 1000);
|
||||||
return Math.floor(ms / 1000);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const n = Number(entry.time);
|
const n = Number(entry.time);
|
||||||
if (!isFinite(n))
|
if (!isFinite(n)) return null;
|
||||||
return null;
|
|
||||||
|
|
||||||
return n > 1e12 ? Math.floor(n / 1000) : Math.floor(n);
|
return n > 1e12 ? Math.floor(n / 1000) : Math.floor(n);
|
||||||
},
|
},
|
||||||
|
|
||||||
formatTimestampDisplay: function (entry) {
|
formatTimestampDisplay: function (entry) {
|
||||||
const unix = this.timestampUnix(entry);
|
const unix = this.timestampUnix(entry);
|
||||||
if (unix == null)
|
if (unix == null) return '';
|
||||||
return '';
|
|
||||||
|
|
||||||
return new Date(unix * 1000).toISOString();
|
return new Date(unix * 1000).toISOString();
|
||||||
},
|
},
|
||||||
|
|
||||||
/* @fwlive-codegen:luci-preserve-begin */
|
/* @fwlive-codegen:luci-preserve-begin */
|
||||||
formatTimestampLocal: function (unix) {
|
formatTimestampLocal: function (unix) {
|
||||||
if (unix == null || !isFinite(unix))
|
if (unix == null || !isFinite(unix)) return '';
|
||||||
return '';
|
|
||||||
|
|
||||||
const d = new Date(unix * 1000);
|
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(
|
return '%d-%s-%s %s:%s:%s'.format(
|
||||||
d.getFullYear(),
|
d.getFullYear(),
|
||||||
@@ -250,11 +269,12 @@ return baseclass.extend({
|
|||||||
},
|
},
|
||||||
|
|
||||||
formatTimestampCompact: function (unix) {
|
formatTimestampCompact: function (unix) {
|
||||||
if (unix == null || !isFinite(unix))
|
if (unix == null || !isFinite(unix)) return '';
|
||||||
return '';
|
|
||||||
|
|
||||||
const d = new Date(unix * 1000);
|
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()));
|
return '%s:%s:%s'.format(pad(d.getHours()), pad(d.getMinutes()), pad(d.getSeconds()));
|
||||||
},
|
},
|
||||||
@@ -267,32 +287,25 @@ return baseclass.extend({
|
|||||||
let left = src;
|
let left = src;
|
||||||
let right = dst;
|
let right = dst;
|
||||||
|
|
||||||
if (sport)
|
if (sport) left = left ? left + ':' + sport : ':' + sport;
|
||||||
left = left ? (left + ':' + sport) : (':' + sport);
|
if (dport) right = right ? right + ':' + dport : ':' + dport;
|
||||||
if (dport)
|
|
||||||
right = right ? (right + ':' + dport) : (':' + dport);
|
|
||||||
|
|
||||||
if (!left && !right)
|
if (!left && !right) return '—';
|
||||||
return '—';
|
if (!right) return left;
|
||||||
if (!right)
|
if (!left) return '→ ' + right;
|
||||||
return left;
|
|
||||||
if (!left)
|
|
||||||
return '→ ' + right;
|
|
||||||
|
|
||||||
return left + ' → ' + right;
|
return left + ' → ' + right;
|
||||||
},
|
},
|
||||||
|
|
||||||
formatCell: function (value) {
|
formatCell: function (value) {
|
||||||
if (value == null || value === '')
|
if (value == null || value === '') return '';
|
||||||
return '';
|
|
||||||
|
|
||||||
return String(value);
|
return String(value);
|
||||||
},
|
},
|
||||||
|
|
||||||
formatActionLabel: function (action) {
|
formatActionLabel: function (action) {
|
||||||
const a = (action || '').toLowerCase();
|
const a = (action || '').toLowerCase();
|
||||||
if (!a || a === 'unknown')
|
if (!a || a === 'unknown') return '—';
|
||||||
return '—';
|
|
||||||
return a;
|
return a;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -302,11 +315,9 @@ return baseclass.extend({
|
|||||||
m = m.replace(/\bMAC=[^\s]+/g, '');
|
m = m.replace(/\bMAC=[^\s]+/g, '');
|
||||||
m = m.replace(/\s+/g, ' ').trim();
|
m = m.replace(/\s+/g, ' ').trim();
|
||||||
|
|
||||||
if (layout === 'oneline')
|
if (layout === 'oneline') return m;
|
||||||
return m;
|
|
||||||
|
|
||||||
if (m.length > 240)
|
if (m.length > 240) return m.substring(0, 237) + '…';
|
||||||
return m.substring(0, 237) + '…';
|
|
||||||
|
|
||||||
return m;
|
return m;
|
||||||
},
|
},
|
||||||
@@ -314,20 +325,39 @@ return baseclass.extend({
|
|||||||
|
|
||||||
isFirewallEvent: function (entry) {
|
isFirewallEvent: function (entry) {
|
||||||
const msg = this.normalizeNetfilterMessage((entry && entry.msg) || '');
|
const msg = this.normalizeNetfilterMessage((entry && entry.msg) || '');
|
||||||
if (!msg.trim())
|
if (!msg.trim()) return false;
|
||||||
return false;
|
|
||||||
|
|
||||||
if (this.NON_FIREWALL_PREFIX.test(msg))
|
if (this.NON_FIREWALL_PREFIX.test(msg)) return false;
|
||||||
return false;
|
|
||||||
|
|
||||||
return this.evaluateClassifySpec(msg);
|
return this.evaluateClassifySpec(msg);
|
||||||
},
|
},
|
||||||
|
|
||||||
makeEntryId: function(entry, tsUnix, action, src, dst, sport, dport, proto, ifaceIn, ifaceOut) {
|
makeEntryId: function (
|
||||||
if (entry && entry.id != null && entry.id !== '')
|
entry,
|
||||||
return 'log:' + entry.id;
|
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) {
|
||||||
@@ -335,7 +365,11 @@ return baseclass.extend({
|
|||||||
const tsUnix = this.timestampUnix(entry);
|
const tsUnix = this.timestampUnix(entry);
|
||||||
const tsDisplay = this.formatTimestampDisplay(entry);
|
const tsDisplay = this.formatTimestampDisplay(entry);
|
||||||
const proto = (kv.PROTO || '').toUpperCase();
|
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 action = this.normalizeAction(actionRaw);
|
||||||
const src = kv.SRC || '';
|
const src = kv.SRC || '';
|
||||||
const dst = kv.DST || '';
|
const dst = kv.DST || '';
|
||||||
@@ -344,14 +378,25 @@ return baseclass.extend({
|
|||||||
const ifaceIn = kv.IN || '';
|
const ifaceIn = kv.IN || '';
|
||||||
const ifaceOut = kv.OUT || '';
|
const ifaceOut = kv.OUT || '';
|
||||||
const iface = ifaceIn || ifaceOut || '';
|
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 flags = this.parseFlags(entry.msg || '', kv);
|
||||||
const length = this.parseLength(kv);
|
const length = this.parseLength(kv);
|
||||||
const ruleHint = this.parseRuleHint(entry.msg || '');
|
const ruleHint = this.parseRuleHint(entry.msg || '');
|
||||||
const ruleLabel = this.formatRuleLabel(ruleHint);
|
const ruleLabel = this.formatRuleLabel(ruleHint);
|
||||||
|
|
||||||
return {
|
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,
|
log_id: entry && entry.id != null ? Number(entry.id) : null,
|
||||||
timestamp: tsUnix,
|
timestamp: tsUnix,
|
||||||
timestamp_display: tsDisplay,
|
timestamp_display: tsDisplay,
|
||||||
@@ -376,27 +421,23 @@ return baseclass.extend({
|
|||||||
|
|
||||||
parseFilterValue: function (val) {
|
parseFilterValue: function (val) {
|
||||||
const s = (val || '').trim();
|
const s = (val || '').trim();
|
||||||
if (!s)
|
if (!s) return { negate: false, value: '' };
|
||||||
return { negate: false, value: '' };
|
|
||||||
|
|
||||||
if (s.charAt(0) === '!')
|
if (s.charAt(0) === '!') return { negate: true, value: s.slice(1).trim() };
|
||||||
return { negate: true, value: s.slice(1).trim() };
|
|
||||||
|
|
||||||
return { negate: false, value: s };
|
return { negate: false, value: s };
|
||||||
},
|
},
|
||||||
|
|
||||||
toggleFilterNegation: function (val) {
|
toggleFilterNegation: function (val) {
|
||||||
const p = this.parseFilterValue(val);
|
const p = this.parseFilterValue(val);
|
||||||
if (!p.value)
|
if (!p.value) return val;
|
||||||
return val;
|
|
||||||
|
|
||||||
return p.negate ? p.value : '!' + p.value;
|
return p.negate ? p.value : '!' + p.value;
|
||||||
},
|
},
|
||||||
|
|
||||||
formatFilterChipLabel: function (field, val) {
|
formatFilterChipLabel: function (field, val) {
|
||||||
const p = this.parseFilterValue(val);
|
const p = this.parseFilterValue(val);
|
||||||
if (!p.value)
|
if (!p.value) return '';
|
||||||
return '';
|
|
||||||
|
|
||||||
if (p.negate) {
|
if (p.negate) {
|
||||||
if (field === 'q' || field === 'src' || field === 'dst')
|
if (field === 'q' || field === 'src' || field === 'dst')
|
||||||
@@ -410,8 +451,7 @@ return baseclass.extend({
|
|||||||
|
|
||||||
matchesTextField: function (haystack, spec) {
|
matchesTextField: function (haystack, spec) {
|
||||||
const p = this.parseFilterValue(spec);
|
const p = this.parseFilterValue(spec);
|
||||||
if (!p.value)
|
if (!p.value) return true;
|
||||||
return true;
|
|
||||||
|
|
||||||
const hit = (haystack || '').indexOf(p.value) !== -1;
|
const hit = (haystack || '').indexOf(p.value) !== -1;
|
||||||
return p.negate ? !hit : hit;
|
return p.negate ? !hit : hit;
|
||||||
@@ -419,8 +459,7 @@ return baseclass.extend({
|
|||||||
|
|
||||||
matchesExactField: function (haystack, spec) {
|
matchesExactField: function (haystack, spec) {
|
||||||
const p = this.parseFilterValue(spec);
|
const p = this.parseFilterValue(spec);
|
||||||
if (!p.value)
|
if (!p.value) return true;
|
||||||
return true;
|
|
||||||
|
|
||||||
const want = p.value.toUpperCase();
|
const want = p.value.toUpperCase();
|
||||||
const got = (haystack || '').toUpperCase();
|
const got = (haystack || '').toUpperCase();
|
||||||
@@ -434,12 +473,10 @@ return baseclass.extend({
|
|||||||
if (p.value) {
|
if (p.value) {
|
||||||
const keys = Object.keys(row);
|
const keys = Object.keys(row);
|
||||||
const parts = [];
|
const parts = [];
|
||||||
for (let i = 0; i < keys.length; i++)
|
for (let i = 0; i < keys.length; i++) parts.push(row[keys[i]]);
|
||||||
parts.push(row[keys[i]]);
|
|
||||||
const blob = parts.join(' ').toLowerCase();
|
const blob = parts.join(' ').toLowerCase();
|
||||||
const hit = blob.indexOf(p.value.toLowerCase()) !== -1;
|
const hit = blob.indexOf(p.value.toLowerCase()) !== -1;
|
||||||
if (p.negate ? hit : !hit)
|
if (p.negate ? hit : !hit) return false;
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -447,10 +484,10 @@ return baseclass.extend({
|
|||||||
const p = this.parseFilterValue(filters.action);
|
const p = this.parseFilterValue(filters.action);
|
||||||
if (p.value) {
|
if (p.value) {
|
||||||
const want = p.value.toLowerCase();
|
const want = p.value.toLowerCase();
|
||||||
const hit = row.action === want
|
const hit =
|
||||||
|| (row.action_raw || '').toUpperCase() === p.value.toUpperCase();
|
row.action === want ||
|
||||||
if (p.negate ? hit : !hit)
|
(row.action_raw || '').toUpperCase() === p.value.toUpperCase();
|
||||||
return false;
|
if (p.negate ? hit : !hit) return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -458,33 +495,26 @@ return baseclass.extend({
|
|||||||
const p = this.parseFilterValue(filters.interface);
|
const p = this.parseFilterValue(filters.interface);
|
||||||
if (p.value) {
|
if (p.value) {
|
||||||
const iface = p.value;
|
const iface = p.value;
|
||||||
const hit = row.interface === iface
|
const hit =
|
||||||
|| row.interface_in === iface
|
row.interface === iface ||
|
||||||
|| row.interface_out === iface;
|
row.interface_in === iface ||
|
||||||
if (p.negate ? hit : !hit)
|
row.interface_out === iface;
|
||||||
return false;
|
if (p.negate ? hit : !hit) return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filters.proto && !this.matchesExactField(row.proto, filters.proto))
|
if (filters.proto && !this.matchesExactField(row.proto, filters.proto)) return false;
|
||||||
return false;
|
if (filters.src && !this.matchesTextField(row.src, filters.src)) return false;
|
||||||
if (filters.src && !this.matchesTextField(row.src, filters.src))
|
if (filters.dst && !this.matchesTextField(row.dst, filters.dst)) return false;
|
||||||
return false;
|
if (filters.sport && !this.matchesExactField(row.sport, filters.sport)) return false;
|
||||||
if (filters.dst && !this.matchesTextField(row.dst, filters.dst))
|
if (filters.dport && !this.matchesExactField(row.dport, filters.dport)) return false;
|
||||||
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;
|
return true;
|
||||||
},
|
},
|
||||||
|
|
||||||
actionRowClass: function (action) {
|
actionRowClass: function (action) {
|
||||||
const a = (action || '').toLowerCase();
|
const a = (action || '').toLowerCase();
|
||||||
if (a === 'drop' || a === 'reject' || a === 'block')
|
if (a === 'drop' || a === 'reject' || a === 'block') return 'fwlive-action fwlive-deny';
|
||||||
return 'fwlive-action fwlive-deny';
|
if (a === 'pass') return 'fwlive-action fwlive-pass';
|
||||||
if (a === 'pass')
|
|
||||||
return 'fwlive-action fwlive-pass';
|
|
||||||
return 'fwlive-action fwlive-unknown';
|
return 'fwlive-action fwlive-unknown';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
/* SPDX-License-Identifier: Apache-2.0 */
|
||||||
|
/* Copyright 2025-2026 Lucas Albers <lucas.b.albers@gmail.com> */
|
||||||
'require baseclass';
|
'require baseclass';
|
||||||
'require fwlive.links as links';
|
'require fwlive.links as links';
|
||||||
|
|
||||||
@@ -46,10 +48,11 @@ function persistConsentDismissed() {
|
|||||||
|
|
||||||
function blockerCode(state) {
|
function blockerCode(state) {
|
||||||
const blockers = (state.loggingStatus && state.loggingStatus.blockers) || [];
|
const blockers = (state.loggingStatus && state.loggingStatus.blockers) || [];
|
||||||
if (blockers.indexOf('no_wan_zone') >= 0)
|
if (blockers.indexOf('no_wan_zone') >= 0) return 'no_wan_zone';
|
||||||
return 'no_wan_zone';
|
if (
|
||||||
if (blockers.indexOf('nf_log_ipv4_missing') >= 0 ||
|
blockers.indexOf('nf_log_ipv4_missing') >= 0 ||
|
||||||
blockers.indexOf('nf_log_ipv6_missing') >= 0)
|
blockers.indexOf('nf_log_ipv6_missing') >= 0
|
||||||
|
)
|
||||||
return 'nf_log_missing';
|
return 'nf_log_missing';
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@@ -66,15 +69,21 @@ function renderToolbar(host, state, callbacks) {
|
|||||||
const blocker = blockerCode(state);
|
const blocker = blockerCode(state);
|
||||||
|
|
||||||
if (blocker === 'no_wan_zone') {
|
if (blocker === 'no_wan_zone') {
|
||||||
host.appendChild(E('span', { 'class': 'fwlive-logging-status' },
|
host.appendChild(
|
||||||
[ _('WAN logging unavailable: no WAN zone') ]));
|
E('span', { 'class': 'fwlive-logging-status' }, [
|
||||||
|
_('WAN logging unavailable: no WAN zone')
|
||||||
|
])
|
||||||
|
);
|
||||||
host.appendChild(links.firewallZonesLink());
|
host.appendChild(links.firewallZonesLink());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (blocker === 'nf_log_missing') {
|
if (blocker === 'nf_log_missing') {
|
||||||
host.appendChild(E('span', { 'class': 'fwlive-logging-status' },
|
host.appendChild(
|
||||||
[ _('WAN logging unavailable: missing kernel log modules') ]));
|
E('span', { 'class': 'fwlive-logging-status' }, [
|
||||||
|
_('WAN logging unavailable: missing kernel log modules')
|
||||||
|
])
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,23 +97,39 @@ function renderToolbar(host, state, callbacks) {
|
|||||||
E('span', { 'class': 'fwlive-log-label' }, [_('WAN logging on')]),
|
E('span', { 'class': 'fwlive-log-label' }, [_('WAN logging on')]),
|
||||||
E('span', { 'class': 'fwlive-log-rate' }, [_('· %s').format(limit)])
|
E('span', { 'class': 'fwlive-log-rate' }, [_('· %s').format(limit)])
|
||||||
];
|
];
|
||||||
host.appendChild(E('button', {
|
host.appendChild(
|
||||||
|
E(
|
||||||
|
'button',
|
||||||
|
{
|
||||||
'class': 'cbi-button fwlive-log-merged',
|
'class': 'cbi-button fwlive-log-merged',
|
||||||
'type': 'button',
|
'type': 'button',
|
||||||
'title': _('WAN logging on (%s). Click to disable.').format(limit),
|
'title': _('WAN logging on (%s). Click to disable.').format(limit),
|
||||||
'disabled': busy ? '' : null,
|
'disabled': busy ? '' : null,
|
||||||
'click': function() { callbacks.onDisable(); }
|
'click': function () {
|
||||||
}, children));
|
callbacks.onDisable();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
children
|
||||||
|
)
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
host.appendChild(E('button', {
|
host.appendChild(
|
||||||
|
E(
|
||||||
|
'button',
|
||||||
|
{
|
||||||
'class': 'cbi-button cbi-button-action',
|
'class': 'cbi-button cbi-button-action',
|
||||||
'type': 'button',
|
'type': 'button',
|
||||||
'title': _('Enable WAN zone drop/reject logging (same as Network → Firewall).'),
|
'title': _('Enable WAN zone drop/reject logging (same as Network → Firewall).'),
|
||||||
'disabled': state.loggingBusy ? '' : null,
|
'disabled': state.loggingBusy ? '' : null,
|
||||||
'click': function() { callbacks.onEnable(); }
|
'click': function () {
|
||||||
}, [ state.loggingBusy ? _('Enabling…') : _('Enable logging') ]));
|
callbacks.onEnable();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[state.loggingBusy ? _('Enabling…') : _('Enable logging')]
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildConsentPanel(state, callbacks) {
|
function buildConsentPanel(state, callbacks) {
|
||||||
@@ -139,7 +164,9 @@ function buildConsentPanel(state, callbacks) {
|
|||||||
])
|
])
|
||||||
]),
|
]),
|
||||||
E('p', { 'class': 'fwlive-consent-actions' }, [
|
E('p', { 'class': 'fwlive-consent-actions' }, [
|
||||||
E('button', {
|
E(
|
||||||
|
'button',
|
||||||
|
{
|
||||||
'class': 'cbi-button cbi-button-action',
|
'class': 'cbi-button cbi-button-action',
|
||||||
'type': 'button',
|
'type': 'button',
|
||||||
'disabled': state.loggingBusy ? '' : null,
|
'disabled': state.loggingBusy ? '' : null,
|
||||||
@@ -147,20 +174,24 @@ function buildConsentPanel(state, callbacks) {
|
|||||||
persistConsentDismissed();
|
persistConsentDismissed();
|
||||||
callbacks.onEnable();
|
callbacks.onEnable();
|
||||||
}
|
}
|
||||||
}, [ state.loggingBusy ? _('Enabling…') : _('Enable WAN drop/reject logging') ]),
|
},
|
||||||
|
[state.loggingBusy ? _('Enabling…') : _('Enable WAN drop/reject logging')]
|
||||||
|
),
|
||||||
' ',
|
' ',
|
||||||
E('button', {
|
E(
|
||||||
|
'button',
|
||||||
|
{
|
||||||
'class': 'cbi-button',
|
'class': 'cbi-button',
|
||||||
'type': 'button',
|
'type': 'button',
|
||||||
'click': function () {
|
'click': function () {
|
||||||
const box = document.getElementById(dontShowId);
|
const box = document.getElementById(dontShowId);
|
||||||
const persist = !!(box && box.checked);
|
const persist = !!(box && box.checked);
|
||||||
if (persist)
|
if (persist) persistConsentDismissed();
|
||||||
persistConsentDismissed();
|
if (callbacks.onDismissConsent) callbacks.onDismissConsent(persist);
|
||||||
if (callbacks.onDismissConsent)
|
|
||||||
callbacks.onDismissConsent(persist);
|
|
||||||
}
|
}
|
||||||
}, [ _('Not now') ]),
|
},
|
||||||
|
[_('Not now')]
|
||||||
|
),
|
||||||
' ',
|
' ',
|
||||||
links.firewallZonesLink(_('I’ll configure this under Network → Firewall'))
|
links.firewallZonesLink(_('I’ll configure this under Network → Firewall'))
|
||||||
])
|
])
|
||||||
@@ -174,41 +205,71 @@ function buildEmptyStateNodes(state, callbacks) {
|
|||||||
const blocker = blockerCode(state);
|
const blocker = blockerCode(state);
|
||||||
|
|
||||||
if (state.loggingNotice) {
|
if (state.loggingNotice) {
|
||||||
nodes.push(E('p', { 'class': 'fwlive-logging-notice' }, [
|
nodes.push(
|
||||||
|
E('p', { 'class': 'fwlive-logging-notice' }, [
|
||||||
state.loggingNotice,
|
state.loggingNotice,
|
||||||
' ',
|
' ',
|
||||||
links.firewallZonesLink()
|
links.firewallZonesLink()
|
||||||
]));
|
])
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (blocker === 'no_wan_zone') {
|
if (blocker === 'no_wan_zone') {
|
||||||
nodes.push(E('p', { 'class': 'fwlive-empty-title' }, [_('No WAN zone found')]));
|
nodes.push(E('p', { 'class': 'fwlive-empty-title' }, [_('No WAN zone found')]));
|
||||||
nodes.push(E('p', {}, [
|
nodes.push(
|
||||||
|
E('p', {}, [
|
||||||
_('No WAN firewall zone found in /etc/config/firewall. Configure zones under '),
|
_('No WAN firewall zone found in /etc/config/firewall. Configure zones under '),
|
||||||
links.firewallZonesLink()
|
links.firewallZonesLink()
|
||||||
]));
|
])
|
||||||
|
);
|
||||||
return nodes;
|
return nodes;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (blocker === 'nf_log_missing') {
|
if (blocker === 'nf_log_missing') {
|
||||||
nodes.push(E('p', { 'class': 'fwlive-empty-title' }, [_('Kernel log modules 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(
|
||||||
nodes.push(E('p', {}, [
|
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'])
|
E('code', {}, ['opkg update && opkg install kmod-nf-log-ipv4 kmod-nf-log-ipv6'])
|
||||||
]));
|
])
|
||||||
|
);
|
||||||
return nodes;
|
return nodes;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (st && st.wan_log) {
|
if (st && st.wan_log) {
|
||||||
nodes.push(E('p', { 'class': 'fwlive-empty-title' }, [_('Waiting for firewall events')]));
|
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(
|
||||||
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.') ]));
|
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'))));
|
nodes.push(E('p', {}, links.firewallZonesLink(_('Open firewall zone settings'))));
|
||||||
return nodes;
|
return nodes;
|
||||||
}
|
}
|
||||||
|
|
||||||
nodes.push(E('p', { 'class': 'fwlive-empty-title' }, [_('Logging is off on this router')]));
|
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', {}, [
|
||||||
|
_(
|
||||||
|
'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. */
|
/* Consent bullets already spell out the effect — do not repeat it or the CTA. */
|
||||||
if (state.showConsent) {
|
if (state.showConsent) {
|
||||||
@@ -216,10 +277,21 @@ function buildEmptyStateNodes(state, callbacks) {
|
|||||||
return nodes;
|
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(
|
||||||
nodes.push(E('p', { 'class': 'fwlive-empty-muted' }, [ _('Nothing changes until you click Enable.') ]));
|
E('p', {}, [
|
||||||
nodes.push(E('p', {}, [
|
_(
|
||||||
E('button', {
|
'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',
|
'class': 'cbi-button cbi-button-action',
|
||||||
'type': 'button',
|
'type': 'button',
|
||||||
'disabled': state.loggingBusy ? '' : null,
|
'disabled': state.loggingBusy ? '' : null,
|
||||||
@@ -227,18 +299,20 @@ function buildEmptyStateNodes(state, callbacks) {
|
|||||||
persistConsentDismissed();
|
persistConsentDismissed();
|
||||||
callbacks.onEnable();
|
callbacks.onEnable();
|
||||||
}
|
}
|
||||||
}, [ state.loggingBusy ? _('Enabling…') : _('Enable WAN drop/reject logging') ]),
|
},
|
||||||
|
[state.loggingBusy ? _('Enabling…') : _('Enable WAN drop/reject logging')]
|
||||||
|
),
|
||||||
' ',
|
' ',
|
||||||
links.firewallZonesLink(_('I’ll configure this under Network → Firewall'))
|
links.firewallZonesLink(_('I’ll configure this under Network → Firewall'))
|
||||||
]));
|
])
|
||||||
|
);
|
||||||
return nodes;
|
return nodes;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderEmptyState(host, state, callbacks) {
|
function renderEmptyState(host, state, callbacks) {
|
||||||
const nodes = buildEmptyStateNodes(state, callbacks);
|
const nodes = buildEmptyStateNodes(state, callbacks);
|
||||||
host.innerHTML = '';
|
host.innerHTML = '';
|
||||||
for (let i = 0; i < nodes.length; i++)
|
for (let i = 0; i < nodes.length; i++) host.appendChild(nodes[i]);
|
||||||
host.appendChild(nodes[i]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -250,11 +324,19 @@ function renderManualTestNodes(host, state, _callbacks) {
|
|||||||
host.innerHTML = '';
|
host.innerHTML = '';
|
||||||
if (state.firewallBackend === 'iptables') {
|
if (state.firewallBackend === 'iptables') {
|
||||||
host.appendChild(document.createTextNode(_('Manual test (System → Terminal): ')));
|
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.')));
|
host.appendChild(document.createTextNode(_(' then ping the router.')));
|
||||||
} else {
|
} else {
|
||||||
host.appendChild(document.createTextNode(_('Manual test (System → Terminal): ')));
|
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.')));
|
host.appendChild(document.createTextNode(_(' then ping the router.')));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
'use strict';
|
'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 baseclass'; /* LuCI require() needs Class.isSubclass — plain return {} fails */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -11,21 +13,19 @@ return baseclass.extend({
|
|||||||
const custom = document.getElementById('fwlive-proto-custom');
|
const custom = document.getElementById('fwlive-proto-custom');
|
||||||
if (custom) {
|
if (custom) {
|
||||||
const typed = (custom.value || '').trim();
|
const typed = (custom.value || '').trim();
|
||||||
if (typed)
|
if (typed) return typed;
|
||||||
return typed;
|
|
||||||
}
|
}
|
||||||
const sel = document.getElementById('fwlive-proto');
|
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 sel = document.getElementById('fwlive-proto');
|
||||||
const custom = document.getElementById('fwlive-proto-custom');
|
const custom = document.getElementById('fwlive-proto-custom');
|
||||||
if (!sel)
|
if (!sel) return false;
|
||||||
return false;
|
|
||||||
|
|
||||||
value = value || '';
|
value = value || '';
|
||||||
let inMenu = (value === '');
|
let inMenu = value === '';
|
||||||
if (!inMenu) {
|
if (!inMenu) {
|
||||||
for (let i = 0; i < sel.options.length; i++) {
|
for (let i = 0; i < sel.options.length; i++) {
|
||||||
if (sel.options[i].value === value) {
|
if (sel.options[i].value === value) {
|
||||||
@@ -37,12 +37,10 @@ return baseclass.extend({
|
|||||||
|
|
||||||
if (inMenu) {
|
if (inMenu) {
|
||||||
sel.value = value;
|
sel.value = value;
|
||||||
if (custom)
|
if (custom) custom.value = '';
|
||||||
custom.value = '';
|
|
||||||
} else {
|
} else {
|
||||||
sel.value = '';
|
sel.value = '';
|
||||||
if (custom)
|
if (custom) custom.value = value;
|
||||||
custom.value = value;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
/* SPDX-License-Identifier: Apache-2.0 */
|
||||||
|
/* Copyright 2025-2026 Lucas Albers <lucas.b.albers@gmail.com> */
|
||||||
'require baseclass';
|
'require baseclass';
|
||||||
'require fwlive.log as log';
|
'require fwlive.log as log';
|
||||||
'require fwlive.links as links';
|
'require fwlive.links as links';
|
||||||
@@ -51,23 +53,36 @@ function columnLabel(col) {
|
|||||||
|
|
||||||
function columnCellClass(col) {
|
function columnCellClass(col) {
|
||||||
switch (col) {
|
switch (col) {
|
||||||
case 'time': return 'fwlive-time';
|
case 'time':
|
||||||
case 'action': return 'fwlive-action';
|
return 'fwlive-time';
|
||||||
case 'rule': return 'fwlive-rule';
|
case 'action':
|
||||||
|
return 'fwlive-action';
|
||||||
|
case 'rule':
|
||||||
|
return 'fwlive-rule';
|
||||||
case 'iface':
|
case 'iface':
|
||||||
case 'iface_in':
|
case 'iface_in':
|
||||||
case 'iface_out': return 'fwlive-iface';
|
case 'iface_out':
|
||||||
case 'dir': return 'fwlive-dir';
|
return 'fwlive-iface';
|
||||||
case 'proto': return 'fwlive-proto';
|
case 'dir':
|
||||||
|
return 'fwlive-dir';
|
||||||
|
case 'proto':
|
||||||
|
return 'fwlive-proto';
|
||||||
case 'src':
|
case 'src':
|
||||||
case 'dst': return 'fwlive-addr';
|
case 'dst':
|
||||||
|
return 'fwlive-addr';
|
||||||
case 'sport':
|
case 'sport':
|
||||||
case 'dport': return 'fwlive-port';
|
case 'dport':
|
||||||
case 'flags': return 'fwlive-flags';
|
return 'fwlive-port';
|
||||||
case 'len': return 'fwlive-len';
|
case 'flags':
|
||||||
case 'flow': return 'fwlive-flow-cell';
|
return 'fwlive-flags';
|
||||||
case 'message': return 'fwlive-message fwlive-th-message';
|
case 'len':
|
||||||
default: return '';
|
return 'fwlive-len';
|
||||||
|
case 'flow':
|
||||||
|
return 'fwlive-flow-cell';
|
||||||
|
case 'message':
|
||||||
|
return 'fwlive-message fwlive-th-message';
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,15 +90,20 @@ function flowCell(row, state, callbacks) {
|
|||||||
const parts = [];
|
const parts = [];
|
||||||
const onFilterClick = callbacks.onFilterClick;
|
const onFilterClick = callbacks.onFilterClick;
|
||||||
const pushAddr = (addr, port, addrField, portField) => {
|
const pushAddr = (addr, port, addrField, portField) => {
|
||||||
if (!addr && !port)
|
if (!addr && !port) return;
|
||||||
return;
|
|
||||||
|
|
||||||
if (addr)
|
if (addr)
|
||||||
parts.push(links.addrFilterLink(addrField, addr,
|
parts.push(
|
||||||
!!state.showHostnames, state.hostnameCache, onFilterClick));
|
links.addrFilterLink(
|
||||||
|
addrField,
|
||||||
|
addr,
|
||||||
|
!!state.showHostnames,
|
||||||
|
state.hostnameCache,
|
||||||
|
onFilterClick
|
||||||
|
)
|
||||||
|
);
|
||||||
if (port) {
|
if (port) {
|
||||||
if (addr)
|
if (addr) parts.push(':');
|
||||||
parts.push(':');
|
|
||||||
parts.push(links.filterLink(portField, port, port, onFilterClick));
|
parts.push(links.filterLink(portField, port, port, onFilterClick));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -93,8 +113,7 @@ function flowCell(row, state, callbacks) {
|
|||||||
parts.push(E('span', { 'class': 'fwlive-flow-arrow' }, [' → ']));
|
parts.push(E('span', { 'class': 'fwlive-flow-arrow' }, [' → ']));
|
||||||
pushAddr(row.dst, row.dport, 'dst', 'dport');
|
pushAddr(row.dst, row.dport, 'dst', 'dport');
|
||||||
|
|
||||||
if (!parts.length)
|
if (!parts.length) return '—';
|
||||||
return '—';
|
|
||||||
|
|
||||||
return E('span', { 'class': 'fwlive-flow' }, parts);
|
return E('span', { 'class': 'fwlive-flow' }, parts);
|
||||||
}
|
}
|
||||||
@@ -102,8 +121,14 @@ function flowCell(row, state, callbacks) {
|
|||||||
function buildColumnCell(col, row, state, callbacks) {
|
function buildColumnCell(col, row, state, callbacks) {
|
||||||
const onFilterClick = callbacks.onFilterClick;
|
const onFilterClick = callbacks.onFilterClick;
|
||||||
const msgDisplay = log.formatMessageDisplay(row.message, state.messageLayout);
|
const msgDisplay = log.formatMessageDisplay(row.message, state.messageLayout);
|
||||||
const actionCell = row.action && row.action !== 'unknown'
|
const actionCell =
|
||||||
? links.filterLink('action', row.action, log.formatActionLabel(row.action), onFilterClick)
|
row.action && row.action !== 'unknown'
|
||||||
|
? links.filterLink(
|
||||||
|
'action',
|
||||||
|
row.action,
|
||||||
|
log.formatActionLabel(row.action),
|
||||||
|
onFilterClick
|
||||||
|
)
|
||||||
: log.formatActionLabel(row.action);
|
: log.formatActionLabel(row.action);
|
||||||
|
|
||||||
switch (col) {
|
switch (col) {
|
||||||
@@ -111,57 +136,96 @@ function buildColumnCell(col, row, state, callbacks) {
|
|||||||
const timeAttrs = { 'class': columnCellClass(col) };
|
const timeAttrs = { 'class': columnCellClass(col) };
|
||||||
if (state.viewMode === 'simple')
|
if (state.viewMode === 'simple')
|
||||||
timeAttrs.title = _('Click a row for the full message');
|
timeAttrs.title = _('Click a row for the full message');
|
||||||
return E('td', timeAttrs,
|
return E('td', timeAttrs, [
|
||||||
[ state.viewMode === 'simple'
|
state.viewMode === 'simple'
|
||||||
? log.formatTimestampCompact(row.timestamp)
|
? log.formatTimestampCompact(row.timestamp)
|
||||||
: log.formatTimestampLocal(row.timestamp) ]);
|
: log.formatTimestampLocal(row.timestamp)
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
case 'action':
|
case 'action':
|
||||||
return E('td', { 'class': log.actionRowClass(row.action) }, [actionCell]);
|
return E('td', { 'class': log.actionRowClass(row.action) }, [actionCell]);
|
||||||
case 'rule':
|
case 'rule':
|
||||||
return E('td', { 'class': columnCellClass(col) },
|
return E('td', { 'class': columnCellClass(col) }, [
|
||||||
[ links.ruleAdminLink(row.rule_hint, row.rule_label, state.firewallBackend, onFilterClick) ]);
|
links.ruleAdminLink(
|
||||||
|
row.rule_hint,
|
||||||
|
row.rule_label,
|
||||||
|
state.firewallBackend,
|
||||||
|
onFilterClick
|
||||||
|
)
|
||||||
|
]);
|
||||||
case 'iface':
|
case 'iface':
|
||||||
return E('td', { 'class': columnCellClass(col) },
|
return E('td', { 'class': columnCellClass(col) }, [
|
||||||
[ links.ifaceLink(row.interface_in, onFilterClick) ]);
|
links.ifaceLink(row.interface_in, onFilterClick)
|
||||||
|
]);
|
||||||
case 'iface_in':
|
case 'iface_in':
|
||||||
case 'iface_out':
|
case 'iface_out':
|
||||||
return E('td', { 'class': columnCellClass(col) }, [ links.ifaceLink(
|
return E('td', { 'class': columnCellClass(col) }, [
|
||||||
col === 'iface_in' ? row.interface_in : row.interface_out, onFilterClick) ]);
|
links.ifaceLink(
|
||||||
|
col === 'iface_in' ? row.interface_in : row.interface_out,
|
||||||
|
onFilterClick
|
||||||
|
)
|
||||||
|
]);
|
||||||
case 'dir':
|
case 'dir':
|
||||||
return E('td', { 'class': columnCellClass(col) }, [log.formatCell(row.direction)]);
|
return E('td', { 'class': columnCellClass(col) }, [log.formatCell(row.direction)]);
|
||||||
case 'proto':
|
case 'proto':
|
||||||
return E('td', { 'class': columnCellClass(col) },
|
return E('td', { 'class': columnCellClass(col) }, [
|
||||||
[ links.filterLink('proto', row.proto, null, onFilterClick) ]);
|
links.filterLink('proto', row.proto, null, onFilterClick)
|
||||||
|
]);
|
||||||
case 'src':
|
case 'src':
|
||||||
return E('td', { 'class': columnCellClass(col) },
|
return E('td', { 'class': columnCellClass(col) }, [
|
||||||
[ links.addrFilterLink('src', row.src, !!state.showHostnames, state.hostnameCache, onFilterClick) ]);
|
links.addrFilterLink(
|
||||||
|
'src',
|
||||||
|
row.src,
|
||||||
|
!!state.showHostnames,
|
||||||
|
state.hostnameCache,
|
||||||
|
onFilterClick
|
||||||
|
)
|
||||||
|
]);
|
||||||
case 'sport':
|
case 'sport':
|
||||||
return E('td', { 'class': columnCellClass(col) },
|
return E('td', { 'class': columnCellClass(col) }, [
|
||||||
[ links.filterLink('sport', row.sport, null, onFilterClick) ]);
|
links.filterLink('sport', row.sport, null, onFilterClick)
|
||||||
|
]);
|
||||||
case 'dst':
|
case 'dst':
|
||||||
return E('td', { 'class': columnCellClass(col) },
|
return E('td', { 'class': columnCellClass(col) }, [
|
||||||
[ links.addrFilterLink('dst', row.dst, !!state.showHostnames, state.hostnameCache, onFilterClick) ]);
|
links.addrFilterLink(
|
||||||
|
'dst',
|
||||||
|
row.dst,
|
||||||
|
!!state.showHostnames,
|
||||||
|
state.hostnameCache,
|
||||||
|
onFilterClick
|
||||||
|
)
|
||||||
|
]);
|
||||||
case 'dport':
|
case 'dport':
|
||||||
return E('td', { 'class': columnCellClass(col) },
|
return E('td', { 'class': columnCellClass(col) }, [
|
||||||
[ links.filterLink('dport', row.dport, null, onFilterClick) ]);
|
links.filterLink('dport', row.dport, null, onFilterClick)
|
||||||
|
]);
|
||||||
case 'flags':
|
case 'flags':
|
||||||
return E('td', { 'class': columnCellClass(col) }, [log.formatCell(row.flags)]);
|
return E('td', { 'class': columnCellClass(col) }, [log.formatCell(row.flags)]);
|
||||||
case 'len':
|
case 'len':
|
||||||
return E('td', { 'class': columnCellClass(col) }, [ row.length != null ? String(row.length) : '' ]);
|
return E('td', { 'class': columnCellClass(col) }, [
|
||||||
|
row.length != null ? String(row.length) : ''
|
||||||
|
]);
|
||||||
case 'flow':
|
case 'flow':
|
||||||
return E('td', { 'class': columnCellClass(col) }, [flowCell(row, state, callbacks)]);
|
return E('td', { 'class': columnCellClass(col) }, [flowCell(row, state, callbacks)]);
|
||||||
case 'message':
|
case 'message':
|
||||||
if (state.messageLayout === 'wrap') {
|
if (state.messageLayout === 'wrap') {
|
||||||
return E('td', {
|
return E(
|
||||||
|
'td',
|
||||||
|
{
|
||||||
'class': 'fwlive-message',
|
'class': 'fwlive-message',
|
||||||
'title': msgDisplay || ''
|
'title': msgDisplay || ''
|
||||||
}, E('div', { 'class': 'fwlive-message-wrap' }, [ msgDisplay || '—' ]));
|
},
|
||||||
|
E('div', { 'class': 'fwlive-message-wrap' }, [msgDisplay || '—'])
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return E('td', {
|
return E(
|
||||||
|
'td',
|
||||||
|
{
|
||||||
'class': 'fwlive-message',
|
'class': 'fwlive-message',
|
||||||
'title': msgDisplay || ''
|
'title': msgDisplay || ''
|
||||||
}, [ msgDisplay || '—' ]);
|
},
|
||||||
|
[msgDisplay || '—']
|
||||||
|
);
|
||||||
default:
|
default:
|
||||||
return E('td', {}, ['']);
|
return E('td', {}, ['']);
|
||||||
}
|
}
|
||||||
@@ -170,8 +234,7 @@ function buildColumnCell(col, row, state, callbacks) {
|
|||||||
function renderThead(host, state, _callbacks) {
|
function renderThead(host, state, _callbacks) {
|
||||||
const columns = state.columns || [];
|
const columns = state.columns || [];
|
||||||
const tr = host.querySelector('thead tr');
|
const tr = host.querySelector('thead tr');
|
||||||
if (!tr)
|
if (!tr) return;
|
||||||
return;
|
|
||||||
|
|
||||||
let colgroup = host.querySelector('colgroup');
|
let colgroup = host.querySelector('colgroup');
|
||||||
|
|
||||||
@@ -185,7 +248,9 @@ function renderThead(host, state, _callbacks) {
|
|||||||
|
|
||||||
for (let i = 0; i < columns.length; i++) {
|
for (let i = 0; i < columns.length; i++) {
|
||||||
const col = columns[i];
|
const col = columns[i];
|
||||||
colgroup.appendChild(E('col', { 'class': 'fwlive-col fwlive-col-' + col.replace(/_/g, '-') }));
|
colgroup.appendChild(
|
||||||
|
E('col', { 'class': 'fwlive-col fwlive-col-' + col.replace(/_/g, '-') })
|
||||||
|
);
|
||||||
tr.appendChild(E('th', { 'class': columnCellClass(col) }, [columnLabel(col)]));
|
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.viewMode === 'simple' ? 'fwlive-row-clickable' : '',
|
||||||
state.expandedRowId === r.id ? 'fwlive-row-expanded' : '',
|
state.expandedRowId === r.id ? 'fwlive-row-expanded' : '',
|
||||||
state.rowTint ? callbacks.actionRowTintClass(r.action) : ''
|
state.rowTint ? callbacks.actionRowTintClass(r.action) : ''
|
||||||
].filter(Boolean).join(' ');
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ');
|
||||||
const cells = [];
|
const cells = [];
|
||||||
for (let c = 0; c < columns.length; c++)
|
for (let c = 0; c < columns.length; c++)
|
||||||
cells.push(buildColumnCell(columns[c], r, state, callbacks));
|
cells.push(buildColumnCell(columns[c], r, state, callbacks));
|
||||||
|
|
||||||
const tr = E('tr', {
|
const tr = E(
|
||||||
|
'tr',
|
||||||
|
{
|
||||||
'class': rowClass,
|
'class': rowClass,
|
||||||
'click': state.viewMode === 'simple'
|
'click': state.viewMode === 'simple' ? (ev) => callbacks.onRowClick(r.id, ev) : null
|
||||||
? (ev) => callbacks.onRowClick(r.id, ev) : null
|
},
|
||||||
}, cells);
|
cells
|
||||||
|
);
|
||||||
host.appendChild(tr);
|
host.appendChild(tr);
|
||||||
|
|
||||||
if (state.viewMode === 'simple' && state.expandedRowId === r.id) {
|
if (state.viewMode === 'simple' && state.expandedRowId === r.id) {
|
||||||
host.appendChild(E('tr', { 'class': 'fwlive-msg-expand' }, [
|
host.appendChild(
|
||||||
|
E('tr', { 'class': 'fwlive-msg-expand' }, [
|
||||||
E('td', { 'colspan': String(columns.length) }, [
|
E('td', { 'colspan': String(columns.length) }, [
|
||||||
E('div', { 'class': 'fwlive-msg-expand-label' }, [_('Message')]),
|
E('div', { 'class': 'fwlive-msg-expand-label' }, [_('Message')]),
|
||||||
E('pre', { 'class': 'fwlive-msg-expand-body' },
|
E('pre', { 'class': 'fwlive-msg-expand-body' }, [
|
||||||
[ log.formatMessageDisplay(r.message, 'wrap') || '—' ])
|
log.formatMessageDisplay(r.message, 'wrap') || '—'
|
||||||
])
|
])
|
||||||
]));
|
])
|
||||||
|
])
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
/* SPDX-License-Identifier: Apache-2.0 */
|
||||||
|
/* Copyright 2025-2026 Lucas Albers <lucas.b.albers@gmail.com> */
|
||||||
'require baseclass';
|
'require baseclass';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -21,8 +23,7 @@ var PASS_HEX = CLASSIC_PASS_HEX;
|
|||||||
var DENY_HEX = CLASSIC_DENY_HEX;
|
var DENY_HEX = CLASSIC_DENY_HEX;
|
||||||
|
|
||||||
function normalizeRowTint(mode) {
|
function normalizeRowTint(mode) {
|
||||||
if (mode === 'off' || mode === 'accessible' || mode === 'classic')
|
if (mode === 'off' || mode === 'accessible' || mode === 'classic') return mode;
|
||||||
return mode;
|
|
||||||
return 'classic';
|
return 'classic';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,16 +34,13 @@ function hexPairForMode(mode) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parseCssRgbChannels(value) {
|
function parseCssRgbChannels(value) {
|
||||||
if (!value)
|
if (!value) return null;
|
||||||
return null;
|
|
||||||
|
|
||||||
const s = String(value).trim().toLowerCase();
|
const s = String(value).trim().toLowerCase();
|
||||||
if (s === 'transparent' || s === 'rgba(0, 0, 0, 0)' || s === 'rgba(0,0,0,0)')
|
if (s === 'transparent' || s === 'rgba(0, 0, 0, 0)' || s === 'rgba(0,0,0,0)') return null;
|
||||||
return null;
|
|
||||||
|
|
||||||
const rgb = s.match(/rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)/i);
|
const rgb = s.match(/rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)/i);
|
||||||
if (rgb)
|
if (rgb) return [parseFloat(rgb[1]), parseFloat(rgb[2]), parseFloat(rgb[3])];
|
||||||
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. */
|
/* 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);
|
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 ca = parseCssRgbChannels(a);
|
||||||
const cb = parseCssRgbChannels(b);
|
const cb = parseCssRgbChannels(b);
|
||||||
/* Transparent vs opaque color is a real paint change (common off-state). */
|
/* Transparent vs opaque color is a real paint change (common off-state). */
|
||||||
if (!ca && !cb)
|
if (!ca && !cb) return 0;
|
||||||
return 0;
|
if (!ca && cb) return Math.abs(cb[0]) + Math.abs(cb[1]) + Math.abs(cb[2]);
|
||||||
if (!ca && cb)
|
if (ca && !cb) return Math.abs(ca[0]) + Math.abs(ca[1]) + Math.abs(ca[2]);
|
||||||
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]);
|
return Math.abs(ca[0] - cb[0]) + Math.abs(ca[1] - cb[1]) + Math.abs(ca[2] - cb[2]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function tintShouldEngageFallback(opts) {
|
function tintShouldEngageFallback(opts) {
|
||||||
const o = 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
|
/* Visible paint is the success criterion; token/CSS.supports are only used when
|
||||||
paint cannot be measured (no delta sample yet). */
|
paint cannot be measured (no delta sample yet). */
|
||||||
if (typeof o.paintDelta === 'number')
|
if (typeof o.paintDelta === 'number') return o.paintDelta < minDelta;
|
||||||
return o.paintDelta < minDelta;
|
|
||||||
|
|
||||||
if (o.tokenResolved === false)
|
if (o.tokenResolved === false) return true;
|
||||||
return true;
|
|
||||||
|
|
||||||
return false;
|
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
|
# GENERATED FILE — do not edit. Run: ./scripts/gen-all.sh
|
||||||
# source: core/fwlive-log.js CLASSIFY_SPEC
|
# source: core/fwlive-log.js CLASSIFY_SPEC
|
||||||
# Shared isFirewallEvent parity logic (shell). Sourced by fwlive-log-filter.sh and tests.
|
# 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).
|
# One awk process classifies a batch (MODE=json) or one message (default).
|
||||||
|
|
||||||
_fwlive_run_classify() {
|
_fwlive_run_classify() {
|
||||||
|
|||||||
@@ -8,6 +8,14 @@
|
|||||||
#
|
#
|
||||||
# Perf (#219): one jsonfilter for @.log[*] plus one awk classify. Process
|
# Perf (#219): one jsonfilter for @.log[*] plus one awk classify. Process
|
||||||
# count is constant per poll, not O(entries).
|
# 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
|
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"
|
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
|
# 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
|
# (or paused FETCH_LINES_MAX poll) can exceed that and make jsonfilter fail
|
||||||
# while this script still printed {"log":[]} and exited 0 (#234).
|
# 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' ']}'
|
printf '%s' ']}'
|
||||||
|
|||||||
@@ -3,6 +3,11 @@
|
|||||||
# Copyright 2025-2026 Lucas Albers <lucas.b.albers@gmail.com>
|
# Copyright 2025-2026 Lucas Albers <lucas.b.albers@gmail.com>
|
||||||
#
|
#
|
||||||
# WAN zone logging helpers for ubus fwlive (logging_status / enable / disable).
|
# 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_IPV4='/proc/sys/net/netfilter/nf_log/2'
|
||||||
NF_LOG_IPV6='/proc/sys/net/netfilter/nf_log/10'
|
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
|
# 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
|
# is 'wan'. Prefer the first section whose type is zone (issue #168); skip
|
||||||
# non-zone sections that happen to share name='wan'.
|
# 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 \
|
_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
|
for zone in $_zones; do
|
||||||
[ -n "$zone" ] || continue
|
[ -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"
|
printf '%s' "$zone"
|
||||||
return 0
|
return 0
|
||||||
done
|
done
|
||||||
@@ -133,14 +143,16 @@ find_wan_zone_section() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
firewall_changes_pending() {
|
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" ]
|
[ -n "$pending" ]
|
||||||
}
|
}
|
||||||
|
|
||||||
wan_zone_log_value() {
|
wan_zone_log_value() {
|
||||||
zone="$1"
|
zone="$1"
|
||||||
[ -n "$zone" ] || return 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 /
|
# 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() {
|
restore_wan_log_baseline() {
|
||||||
path="$(wan_log_baseline_path)"
|
path="$(wan_log_baseline_path)"
|
||||||
[ -f "$path" ] || return 0
|
[ -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)
|
zone=$(find_wan_zone_section)
|
||||||
if [ -z "$zone" ]; then
|
if [ -z "$zone" ]; then
|
||||||
logger -t fwlive "WAN log baseline restore skipped: no WAN zone" 2>/dev/null || true
|
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() {
|
read_nf_log_backend() {
|
||||||
path="$1"
|
path="$1"
|
||||||
[ -f "$path" ] || return 1
|
[ -f "$path" ] || return 1
|
||||||
val=$(cat "$path" 2>/dev/null)
|
val=$(cat "$path" 2>/dev/null) || return 1
|
||||||
[ -n "$val" ] && [ "$val" != 'none' ]
|
[ -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_ipv4 || logging_blockers_append 'nf_log_ipv4_missing'
|
||||||
check_nf_log_ipv6 || logging_blockers_append 'nf_log_ipv6_missing'
|
check_nf_log_ipv6 || logging_blockers_append 'nf_log_ipv6_missing'
|
||||||
|
|
||||||
[ -n "$LOGGING_BLOCKERS" ] || return 0
|
# Report via LOGGING_BLOCKERS, not exit status: return 1 would abort
|
||||||
return 1
|
# build_logging_status_json under set -e (#291 C3).
|
||||||
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
collect_logging_warnings() {
|
collect_logging_warnings() {
|
||||||
@@ -414,8 +428,8 @@ collect_logging_warnings() {
|
|||||||
# Warnings are diagnostics only — do not gate the enable-logging CTA.
|
# Warnings are diagnostics only — do not gate the enable-logging CTA.
|
||||||
command -v timeout >/dev/null 2>&1 || logging_warnings_append 'timeout_missing'
|
command -v timeout >/dev/null 2>&1 || logging_warnings_append 'timeout_missing'
|
||||||
|
|
||||||
[ -n "$LOGGING_WARNINGS" ] || return 0
|
# Report via LOGGING_WARNINGS, not exit status (#291 C3).
|
||||||
return 1
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
json_null_or_string() {
|
json_null_or_string() {
|
||||||
@@ -430,8 +444,13 @@ json_null_or_string() {
|
|||||||
|
|
||||||
build_logging_status_json() {
|
build_logging_status_json() {
|
||||||
zone=$(find_wan_zone_section)
|
zone=$(find_wan_zone_section)
|
||||||
log_val=$(wan_zone_log_value "$zone")
|
# Empty zone / unset log bit are valid; set -e cannot apply (#291 C3).
|
||||||
limit_val=$( [ -n "$zone" ] && uci -q get "firewall.${zone}.log_limit" 2>/dev/null )
|
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
|
wan_log=false
|
||||||
if wan_filter_log_enabled "$log_val"; then
|
if wan_filter_log_enabled "$log_val"; then
|
||||||
wan_log=true
|
wan_log=true
|
||||||
|
|||||||
@@ -8,6 +8,17 @@
|
|||||||
# resolve — reverse DNS for IP addresses (BusyBox nslookup)
|
# resolve — reverse DNS for IP addresses (BusyBox nslookup)
|
||||||
# logging_status — WAN zone logging readiness (no args)
|
# logging_status — WAN zone logging readiness (no args)
|
||||||
# enable_wan_logging / disable_wan_logging — opt-in WAN zone log=1 (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: '
|
FW4_TAG='!fw4: '
|
||||||
LIBEXEC_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
LIBEXEC_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
@@ -83,7 +94,9 @@ map_add() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
uci_rule_names() {
|
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() {
|
is_uci_style_name() {
|
||||||
@@ -396,7 +409,8 @@ poll_lines_from_input() {
|
|||||||
json_get_var first 1
|
json_get_var first 1
|
||||||
json_select ..
|
json_select ..
|
||||||
# shellcheck disable=SC2154 # set by json_get_var above
|
# 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]*) ;;
|
''|*[!0-9]*) ;;
|
||||||
*) lines="$first" ;;
|
*) lines="$first" ;;
|
||||||
esac
|
esac
|
||||||
@@ -462,9 +476,10 @@ resolve_hostname() {
|
|||||||
[ -n "$ip" ] || return 1
|
[ -n "$ip" ] || return 1
|
||||||
is_resolvable_address "$ip" || return 1
|
is_resolvable_address "$ip" || return 1
|
||||||
command -v nslookup >/dev/null 2>&1 || 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
|
[ -n "$out" ] || return 1
|
||||||
name=$(parse_nslookup_name "$out")
|
name=$(parse_nslookup_name "$out") || return 1
|
||||||
[ -n "$name" ] || return 1
|
[ -n "$name" ] || return 1
|
||||||
printf '%s' "$name"
|
printf '%s' "$name"
|
||||||
}
|
}
|
||||||
@@ -598,11 +613,14 @@ resolve_addresses() {
|
|||||||
now=$(date +%s)
|
now=$(date +%s)
|
||||||
[ $((now - start)) -ge "$RESOLVE_BUDGET" ] && break
|
[ $((now - start)) -ge "$RESOLVE_BUDGET" ] && break
|
||||||
json_get_var ip "$idx"
|
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))
|
idx=$((idx + 1))
|
||||||
continue
|
continue
|
||||||
fi
|
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
|
if [ -n "$name" ]; then
|
||||||
map_add "$ip" "$name"
|
map_add "$ip" "$name"
|
||||||
count=$((count + 1))
|
count=$((count + 1))
|
||||||
@@ -789,6 +807,17 @@ Address: 127.0.0.1:53")
|
|||||||
|
|
||||||
run_logging_selftest || return 1
|
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
|
if ! command -v jshn >/dev/null 2>&1; then
|
||||||
echo "skip: jshn not available (poll cap via jshn not tested)" >&2
|
echo "skip: jshn not available (poll cap via jshn not tested)" >&2
|
||||||
return 0
|
return 0
|
||||||
@@ -834,7 +863,7 @@ Address: 127.0.0.1:53")
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
case "$1" in
|
case "${1:-}" in
|
||||||
__selftest)
|
__selftest)
|
||||||
run_selftest
|
run_selftest
|
||||||
exit $?
|
exit $?
|
||||||
@@ -845,22 +874,22 @@ case "$1" in
|
|||||||
exit $?
|
exit $?
|
||||||
;;
|
;;
|
||||||
__poll_clamp)
|
__poll_clamp)
|
||||||
poll_clamp_lines "$2"
|
poll_clamp_lines "${2:-}"
|
||||||
exit $?
|
exit $?
|
||||||
;;
|
;;
|
||||||
__tmp_dir_ok)
|
__tmp_dir_ok)
|
||||||
_fwlive_tmp_dir_ok "$2"
|
_fwlive_tmp_dir_ok "${2:-}"
|
||||||
exit $?
|
exit $?
|
||||||
;;
|
;;
|
||||||
__resolve_one)
|
__resolve_one)
|
||||||
if name=$(resolve_hostname "$2"); then
|
if name=$(resolve_hostname "${2:-}"); then
|
||||||
printf '%s\n' "$name"
|
printf '%s\n' "$name"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
exit 1
|
exit 1
|
||||||
;;
|
;;
|
||||||
__parse_nslookup)
|
__parse_nslookup)
|
||||||
name=$(parse_nslookup_name "$2")
|
name=$(parse_nslookup_name "${2:-}")
|
||||||
if [ -n "$name" ]; then
|
if [ -n "$name" ]; then
|
||||||
printf '%s\n' "$name"
|
printf '%s\n' "$name"
|
||||||
exit 0
|
exit 0
|
||||||
@@ -874,15 +903,16 @@ case "$1" in
|
|||||||
echo '{"rules":{},"poll":{"addresses":[]},"resolve":{"addresses":[]},"logging_status":{},"enable_wan_logging":{},"disable_wan_logging":{}}'
|
echo '{"rules":{},"poll":{"addresses":[]},"resolve":{"addresses":[]},"logging_status":{},"enable_wan_logging":{},"disable_wan_logging":{}}'
|
||||||
;;
|
;;
|
||||||
call)
|
call)
|
||||||
case "$2" in
|
case "${2:-}" in
|
||||||
rules)
|
rules)
|
||||||
build_rules_map
|
build_rules_map
|
||||||
;;
|
;;
|
||||||
poll)
|
poll)
|
||||||
poll_logs "$3"
|
# rpcd may omit argv $3 and pass JSON on stdin (#291 C3).
|
||||||
|
poll_logs "${3:-}"
|
||||||
;;
|
;;
|
||||||
resolve)
|
resolve)
|
||||||
resolve_addresses "$3"
|
resolve_addresses "${3:-}"
|
||||||
;;
|
;;
|
||||||
logging_status)
|
logging_status)
|
||||||
build_logging_status_json
|
build_logging_status_json
|
||||||
|
|||||||
@@ -1,39 +1,16 @@
|
|||||||
|
# Copyright (C) 2020 Lienol <lawlienol@gmail.com>
|
||||||
#
|
#
|
||||||
# Copyright (C) 2006-2017 OpenWrt.org
|
# This is free software, licensed under the GNU General Public License v3.
|
||||||
# Copyright (C) 2022-2026 sirpdboy <herboy2008@gmail.com>
|
|
||||||
# This is free software, licensed under the GNU General Public License v2.
|
|
||||||
# See /LICENSE for more information.
|
|
||||||
#
|
#
|
||||||
|
|
||||||
include $(TOPDIR)/rules.mk
|
include $(TOPDIR)/rules.mk
|
||||||
|
|
||||||
THEME_NAME:=timecontrol
|
LUCI_TITLE:=LuCI support for Time Control
|
||||||
PKG_NAME:=luci-app-$(THEME_NAME)
|
LUCI_DEPENDS:=+luci-base @(PACKAGE_firewall||PACKAGE_firewall4)
|
||||||
|
|
||||||
PKG_LICENSE:=Apache-2.0
|
|
||||||
|
|
||||||
LUCI_TITLE:=LuCI support for timecontrol for nftables
|
|
||||||
LUCI_DESCRIPTION:=LuCI support for Easy timecontrol for nftables(Internet time control).
|
|
||||||
LUCI_DEPENDS:=+bc +nftables +bash +conntrack
|
|
||||||
LUCI_PKGARCH:=all
|
LUCI_PKGARCH:=all
|
||||||
|
PKG_VERSION:=1.1
|
||||||
PKG_VERSION:=3.2.4
|
PKG_RELEASE:=5
|
||||||
PKG_RELEASE:=4
|
|
||||||
PKG_MAINTAINER:=sirpdboy <herboy2008@gmail.com>
|
|
||||||
|
|
||||||
define Build/Compile
|
|
||||||
endef
|
|
||||||
|
|
||||||
define Package/$(PKG_NAME)/postinst
|
|
||||||
#!/bin/sh
|
|
||||||
rm -f /tmp/luci-*
|
|
||||||
endef
|
|
||||||
|
|
||||||
define Package/$(PKG_NAME)/conffiles
|
|
||||||
/etc/config/timecontrol
|
|
||||||
endef
|
|
||||||
|
|
||||||
include $(TOPDIR)/feeds/luci/luci.mk
|
include $(TOPDIR)/feeds/luci/luci.mk
|
||||||
|
|
||||||
# call BuildPackage - OpenWrt buildroot signature
|
# call BuildPackage - OpenWrt buildroot signature
|
||||||
|
|
||||||
|
|||||||
@@ -1,335 +0,0 @@
|
|||||||
// SPDX-License-Identifier: Apache-2.0
|
|
||||||
/*
|
|
||||||
|
|
||||||
* Copyright (C) 2022-2026 sirpdboy <herboy2008@gmail.com>
|
|
||||||
*/
|
|
||||||
'use strict';
|
|
||||||
'require view';
|
|
||||||
'require fs';
|
|
||||||
'require ui';
|
|
||||||
'require uci';
|
|
||||||
'require form';
|
|
||||||
'require poll';
|
|
||||||
'require rpc';
|
|
||||||
'require network';
|
|
||||||
|
|
||||||
function checkTimeControlProcess() {
|
|
||||||
return fs.exec('/bin/ps', ['w']).then(function(res) {
|
|
||||||
if (res.code !== 0) {
|
|
||||||
return { running: false, pid: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
var lines = res.stdout.split('\n');
|
|
||||||
var running = false;
|
|
||||||
var pid = null;
|
|
||||||
|
|
||||||
for (var i = 0; i < lines.length; i++) {
|
|
||||||
var line = lines[i];
|
|
||||||
if (line.includes('timecontrolctrl')) {
|
|
||||||
running = true;
|
|
||||||
// 提取PID
|
|
||||||
var match = line.match(/^\s*(\d+)/);
|
|
||||||
if (match) {
|
|
||||||
pid = match[1];
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { running: running, pid: null };
|
|
||||||
}).catch(function() {
|
|
||||||
return { running: false, pid: null };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderServiceStatus(isRunning, pid) {
|
|
||||||
var statusText = isRunning ? _('RUNNING') : _('NOT RUNNING');
|
|
||||||
var color = isRunning ? 'green' : 'red';
|
|
||||||
var icon = isRunning ? '✓' : '✗';
|
|
||||||
|
|
||||||
var statusHtml = String.format(
|
|
||||||
'<em><span style="color:%s">%s <strong>%s %s</strong></span></em>',
|
|
||||||
color, icon, _('TimeControl Service'), statusText
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isRunning && pid) {
|
|
||||||
statusHtml += ' <small>(PID: ' + pid + ')</small>';
|
|
||||||
}
|
|
||||||
|
|
||||||
return statusHtml;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getHostList() {
|
|
||||||
return L.resolveDefault(network.getHostHints(), [])
|
|
||||||
.then(function(hosts) {
|
|
||||||
var hostList = [];
|
|
||||||
if (hosts && hosts.length > 0) {
|
|
||||||
hosts.forEach(function(host) {
|
|
||||||
if (host.ipv4 && host.mac) {
|
|
||||||
hostList.push({
|
|
||||||
ipv4: host.ipv4,
|
|
||||||
mac: host.mac,
|
|
||||||
name: host.name || '',
|
|
||||||
ipv6: host.ipv6 || ''
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return hostList;
|
|
||||||
})
|
|
||||||
.catch(function() {
|
|
||||||
return [];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
var cbiRichListValue = form.ListValue.extend({
|
|
||||||
renderWidget: function(section_id, option_index, cfgvalue) {
|
|
||||||
var choices = this.transformChoices();
|
|
||||||
var widget = new ui.Dropdown((cfgvalue != null) ? cfgvalue : this.default, choices, {
|
|
||||||
id: this.cbid(section_id),
|
|
||||||
sort: this.keylist,
|
|
||||||
optional: true,
|
|
||||||
select_placeholder: this.select_placeholder || this.placeholder,
|
|
||||||
custom_placeholder: this.custom_placeholder || this.placeholder,
|
|
||||||
validate: L.bind(this.validate, this, section_id),
|
|
||||||
disabled: (this.readonly != null) ? this.readonly : this.map.readonly
|
|
||||||
});
|
|
||||||
|
|
||||||
return widget.render();
|
|
||||||
},
|
|
||||||
|
|
||||||
value: function(value, title, description) {
|
|
||||||
if (description) {
|
|
||||||
form.ListValue.prototype.value.call(this, value, E([], [
|
|
||||||
E('span', { 'class': 'hide-open' }, [title]),
|
|
||||||
E('div', { 'class': 'hide-close', 'style': 'min-width:25vw' }, [
|
|
||||||
E('strong', [title]),
|
|
||||||
E('br'),
|
|
||||||
E('span', { 'style': 'white-space:normal' }, description)
|
|
||||||
])
|
|
||||||
]));
|
|
||||||
} else {
|
|
||||||
form.ListValue.prototype.value.call(this, value, title);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return view.extend({
|
|
||||||
load: function() {
|
|
||||||
return Promise.all([
|
|
||||||
uci.load('timecontrol'),
|
|
||||||
network.getHostHints()
|
|
||||||
]);
|
|
||||||
},
|
|
||||||
|
|
||||||
render: function(data) {
|
|
||||||
var m, s, o;
|
|
||||||
let hosts = data[1]?.hosts;
|
|
||||||
|
|
||||||
m = new form.Map('timecontrol', _('Internet Time Control'),
|
|
||||||
_('Users can limit their internet usage time through MAC and IP, with available IP ranges such as 192.168.110.00 to 192.168.10.200') + '<br/>' +
|
|
||||||
_('黑名单模式时间控制方式:') + '<br/>' +
|
|
||||||
_('1. 时间段控制: 指定的机器在设定时间段内可以上网,其他时间不能上网') + '<br/>' +
|
|
||||||
_('2. 允许上机时长: 指定的机器上线后可以上网指定时长,超过时长后不能上网') + '<br/>' +
|
|
||||||
_('3. 组合控制: 在时间段内+时长限制(在允许的时间段内限制上网时长)') + '<br/>' +
|
|
||||||
_('Suggested feedback:') + ' <a href="https://github.com/sirpdboy/luci-app-timecontrol.git" target="_blank">GitHub @timecontrol</a>');
|
|
||||||
|
|
||||||
s = m.section(form.TypedSection);
|
|
||||||
s.anonymous = true;
|
|
||||||
s.render = function() {
|
|
||||||
var statusView = E('p', { id: 'service_status' },
|
|
||||||
'<span class="spinning"> </span> ' + _('Checking service status...'));
|
|
||||||
|
|
||||||
checkTimeControlProcess()
|
|
||||||
.then(function(res) {
|
|
||||||
var status = renderServiceStatus(res.running, res.pid);
|
|
||||||
statusView.innerHTML = status;
|
|
||||||
})
|
|
||||||
.catch(function(err) {
|
|
||||||
statusView.innerHTML = '<span style="color:orange">⚠ ' +
|
|
||||||
_('Status check failed') + '</span>';
|
|
||||||
console.error('Status check error:', err);
|
|
||||||
});
|
|
||||||
|
|
||||||
poll.add(function() {
|
|
||||||
return checkTimeControlProcess()
|
|
||||||
.then(function(res) {
|
|
||||||
var status = renderServiceStatus(res.running, res.pid);
|
|
||||||
statusView.innerHTML = status;
|
|
||||||
})
|
|
||||||
.catch(function(err) {
|
|
||||||
statusView.innerHTML = '<span style="color:orange">⚠ ' +
|
|
||||||
_('Status check failed') + '</span>';
|
|
||||||
console.error('Status check error:', err);
|
|
||||||
});
|
|
||||||
}, 5);
|
|
||||||
|
|
||||||
poll.start();
|
|
||||||
return E('div', { class: 'cbi-section', id: 'status_bar' }, [
|
|
||||||
statusView,
|
|
||||||
E('div', { 'style': 'text-align: right; font-style: italic;' }, [
|
|
||||||
E('span', {}, [
|
|
||||||
_('© github '),
|
|
||||||
E('a', {
|
|
||||||
'href': 'https://github.com/sirpdboy',
|
|
||||||
'target': '_blank',
|
|
||||||
'style': 'text-decoration: none;'
|
|
||||||
}, 'by sirpdboy')
|
|
||||||
])
|
|
||||||
])
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
s = m.section(form.TypedSection, 'timecontrol');
|
|
||||||
s.anonymous = true;
|
|
||||||
s.addremove = false;
|
|
||||||
|
|
||||||
o = s.option(cbiRichListValue, 'list_type', _('Control Mode'),
|
|
||||||
_('blacklist: Block the networking of the target address, whitelist: Only allow networking for the target address and block all other addresses.'));
|
|
||||||
o.rmempty = false;
|
|
||||||
o.value('blacklist', _('Blacklist'));
|
|
||||||
// o.value('whitelist', _('Whitelist'));
|
|
||||||
o.default = 'blacklist';
|
|
||||||
|
|
||||||
o = s.option(cbiRichListValue, 'chain', _('Control Intensity'),
|
|
||||||
_('Pay attention to strong control: machines under control will not be able to connect to the software router backend!'));
|
|
||||||
o.value('forward', _('Ordinary forward control'));
|
|
||||||
o.value('input', _('Strong input control'));
|
|
||||||
o.default = 'forward';
|
|
||||||
o.rmempty = false;
|
|
||||||
|
|
||||||
var s = m.section(form.TableSection, 'device', _('Device Rules'));
|
|
||||||
s.addremove = true;
|
|
||||||
s.anonymous = true;
|
|
||||||
s.sortable = false;
|
|
||||||
|
|
||||||
o = s.option(form.Value, 'comment', _('Comment'));
|
|
||||||
o.optional = true;
|
|
||||||
o.placeholder = _('Description');
|
|
||||||
|
|
||||||
o = s.option(form.Flag, 'enable', _('Enabled'));
|
|
||||||
o.rmempty = false;
|
|
||||||
o.default = '1';
|
|
||||||
|
|
||||||
o = s.option(form.Value, 'mac', _('IP/MAC Address'));
|
|
||||||
o.rmempty = false;
|
|
||||||
if (hosts) {
|
|
||||||
var hostOptions = {};
|
|
||||||
|
|
||||||
Object.keys(hosts).forEach(function(mac) {
|
|
||||||
var host = hosts[mac];
|
|
||||||
var name = host.name || _(' ');
|
|
||||||
var ips = L.toArray(host.ipaddrs || host.ipv4 || []);
|
|
||||||
|
|
||||||
if (ips.length > 0) {
|
|
||||||
ips.forEach(function(ip) {
|
|
||||||
var macDisplay = 'MAC: %s (%s - %s)'.format(mac,ip, name);
|
|
||||||
hostOptions['mac:' + mac] = macDisplay;
|
|
||||||
var ipDisplay = 'IP: %s (%s - %s)'.format(ip, mac, name);
|
|
||||||
hostOptions['ip:' + ip] = ipDisplay;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
var sortedKeys = Object.keys(hostOptions).sort(function(a, b) {
|
|
||||||
return hostOptions[a].localeCompare(hostOptions[b]);
|
|
||||||
});
|
|
||||||
|
|
||||||
sortedKeys.forEach(function(key) {
|
|
||||||
if (key.startsWith('ip:')) {
|
|
||||||
o.value(key.substring(3), hostOptions[key]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
sortedKeys.forEach(function(key) {
|
|
||||||
if (key.startsWith('mac:')) {
|
|
||||||
o.value(key.substring(4), hostOptions[key]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 时间控制方式选择
|
|
||||||
o = s.option(cbiRichListValue, 'time_mode', _('Time Control Mode'));
|
|
||||||
o.value('period', _('Time Period Control (allow in period)'));
|
|
||||||
o.value('duration', _('Allow Duration Control (allow limited time)'));
|
|
||||||
o.value('combined', _('Combined Control (allow in period + limit duration)'));
|
|
||||||
o.default = 'period';
|
|
||||||
o.rmempty = false;
|
|
||||||
o.onchange = function(ev, mode) {
|
|
||||||
var row = this.map.findElement('id', this.cbid(this.section_id));
|
|
||||||
if (row) {
|
|
||||||
// 显示/隐藏相关字段
|
|
||||||
var startTime = row.querySelector('[data-field="timestart"]');
|
|
||||||
var endTime = row.querySelector('[data-field="timeend"]');
|
|
||||||
var duration = row.querySelector('[data-field="duration"]');
|
|
||||||
var useDuration = row.querySelector('[data-field="use_duration"]');
|
|
||||||
var resetCycle = row.querySelector('[data-field="reset_cycle"]');
|
|
||||||
|
|
||||||
if (startTime) startTime.parentElement.style.display =
|
|
||||||
(mode === 'period' || mode === 'combined') ? '' : 'none';
|
|
||||||
if (endTime) endTime.parentElement.style.display =
|
|
||||||
(mode === 'period' || mode === 'combined') ? '' : 'none';
|
|
||||||
if (duration) duration.parentElement.style.display =
|
|
||||||
(mode === 'duration' || mode === 'combined') ? '' : 'none';
|
|
||||||
if (useDuration) useDuration.parentElement.style.display =
|
|
||||||
(mode === 'combined') ? '' : 'none';
|
|
||||||
if (resetCycle) resetCycle.parentElement.style.display =
|
|
||||||
(mode === 'duration' || mode === 'combined') ? '' : 'none';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 时间段控制字段
|
|
||||||
o = s.option(form.Value, 'timestart', _('Allow Start Time'));
|
|
||||||
o.placeholder = '00:00';
|
|
||||||
o.default = '00:00';
|
|
||||||
o.depends({ 'time_mode': 'period', '!contains': true });
|
|
||||||
o.depends({ 'time_mode': 'combined', '!contains': true });
|
|
||||||
|
|
||||||
o = s.option(form.Value, 'timeend', _('Allow End Time'));
|
|
||||||
o.placeholder = '00:00';
|
|
||||||
o.default = '00:00';
|
|
||||||
o.depends({ 'time_mode': 'period', '!contains': true });
|
|
||||||
o.depends({ 'time_mode': 'combined', '!contains': true });
|
|
||||||
|
|
||||||
// 持续时间控制字段
|
|
||||||
o = s.option(form.Value, 'duration', _('Allowed Duration (minutes)'));
|
|
||||||
o.placeholder = '60';
|
|
||||||
o.default = '60';
|
|
||||||
o.datatype = 'min(1)';
|
|
||||||
o.depends({ 'time_mode': 'duration', '!contains': true });
|
|
||||||
o.depends({ 'time_mode': 'combined', '!contains': true });
|
|
||||||
o.description = _('设备上线后允许上网的分钟数,超过后将被禁止上网');
|
|
||||||
|
|
||||||
// 重置周期
|
|
||||||
o = s.option(cbiRichListValue, 'reset_cycle', _('Reset Cycle'));
|
|
||||||
o.value('daily', _('Daily Reset'));
|
|
||||||
o.value('weekly', _('Weekly Reset'));
|
|
||||||
o.value('monthly', _('Monthly Reset'));
|
|
||||||
o.value('never', _('Never Reset (until manual reset)'));
|
|
||||||
o.default = 'daily';
|
|
||||||
o.depends({ 'time_mode': 'duration', '!contains': true });
|
|
||||||
o.depends({ 'time_mode': 'combined', '!contains': true });
|
|
||||||
o.description = _('时长重置周期');
|
|
||||||
|
|
||||||
// 组合控制:是否在时间段内启用时长限制
|
|
||||||
o = s.option(form.Flag, 'use_duration', _('Enable Duration Limit in Period'));
|
|
||||||
o.default = '0';
|
|
||||||
o.depends({ 'time_mode': 'combined', '!contains': true });
|
|
||||||
o.description = _('在允许的时间段内限制上网时长');
|
|
||||||
|
|
||||||
o = s.option(form.Value, 'week', _('Week Day (1~7)'));
|
|
||||||
o.value('0', _('Everyday'));
|
|
||||||
o.value('1', _('Monday'));
|
|
||||||
o.value('2', _('Tuesday'));
|
|
||||||
o.value('3', _('Wednesday'));
|
|
||||||
o.value('4', _('Thursday'));
|
|
||||||
o.value('5', _('Friday'));
|
|
||||||
o.value('6', _('Saturday'));
|
|
||||||
o.value('7', _('Sunday'));
|
|
||||||
o.value('1,2,3,4,5', _('Workday'));
|
|
||||||
o.value('6,7', _('Rest Day'));
|
|
||||||
o.default = '0';
|
|
||||||
o.rmempty = false;
|
|
||||||
o.description = _('允许上网的星期');
|
|
||||||
|
|
||||||
return m.render();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,238 +0,0 @@
|
|||||||
// SPDX-License-Identifier: Apache-2.0
|
|
||||||
/*
|
|
||||||
|
|
||||||
* Copyright (C) 2022-2026 sirpdboy <herboy2008@gmail.com>
|
|
||||||
*/
|
|
||||||
'use strict';
|
|
||||||
'require dom';
|
|
||||||
'require fs';
|
|
||||||
'require poll';
|
|
||||||
'require uci';
|
|
||||||
'require view';
|
|
||||||
'require form';
|
|
||||||
|
|
||||||
return view.extend({
|
|
||||||
render: function () {
|
|
||||||
var css = `
|
|
||||||
#log_textarea pre {
|
|
||||||
padding: 10px; /* 内边距 */
|
|
||||||
border-bottom: 1px solid #ddd; /* 边框颜色 */
|
|
||||||
font-size: small;
|
|
||||||
line-height: 1.3; /* 行高 */
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-wrap: break-word;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
.cbi-section small {
|
|
||||||
margin-left: 1rem;
|
|
||||||
font-size: small;
|
|
||||||
}
|
|
||||||
.log-container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
max-height: 1200px;
|
|
||||||
overflow-y: auto;
|
|
||||||
border-radius: 3px;
|
|
||||||
margin-top: 10px;
|
|
||||||
padding: 5px;
|
|
||||||
}
|
|
||||||
.log-line {
|
|
||||||
padding: 3px 0;
|
|
||||||
font-family: monospace;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
.log-line:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
.log-timestamp {
|
|
||||||
margin-right: 10px;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
var log_container = E('div', { 'class': 'log-container', 'id': 'log_container' },
|
|
||||||
E('img', {
|
|
||||||
'src': L.resource(['icons/loading.gif']),
|
|
||||||
'alt': _('Loading...'),
|
|
||||||
'style': 'vertical-align:middle'
|
|
||||||
}, _('Collecting data ...'))
|
|
||||||
);
|
|
||||||
|
|
||||||
var log_path = '/var/log/timecontrol.log';
|
|
||||||
var lastLogContent = '';
|
|
||||||
var lastScrollTop = 0;
|
|
||||||
var isScrolledToTop = true;
|
|
||||||
|
|
||||||
// 解析日志行的时间戳,用于排序
|
|
||||||
function parseLogTimestamp(logLine) {
|
|
||||||
// 假设日志格式为: [2024-01-01 12:00:00] INFO: some message
|
|
||||||
var timestampMatch = logLine.match(/^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\]/);
|
|
||||||
if (timestampMatch) {
|
|
||||||
return new Date(timestampMatch[1]).getTime();
|
|
||||||
}
|
|
||||||
return Date.now();
|
|
||||||
}
|
|
||||||
|
|
||||||
function reverseLogLines(logContent) {
|
|
||||||
if (!logContent || logContent.trim() === '') {
|
|
||||||
return logContent;
|
|
||||||
}
|
|
||||||
|
|
||||||
var lines = logContent.split('\n');
|
|
||||||
|
|
||||||
lines = lines.filter(function(line) {
|
|
||||||
return line.trim() !== '';
|
|
||||||
});
|
|
||||||
|
|
||||||
lines.sort(function(a, b) {
|
|
||||||
var timeA = parseLogTimestamp(a);
|
|
||||||
var timeB = parseLogTimestamp(b);
|
|
||||||
return timeB - timeA; // 降序排列
|
|
||||||
});
|
|
||||||
|
|
||||||
return lines.join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatLogLines(logContent, isNewContent) {
|
|
||||||
if (!logContent || logContent.trim() === '') {
|
|
||||||
return E('div', { 'class': 'log-line' }, _('Log is clean.'));
|
|
||||||
}
|
|
||||||
|
|
||||||
var lines = logContent.split('\n');
|
|
||||||
var formattedLines = [];
|
|
||||||
|
|
||||||
for (var i = 0; i < lines.length; i++) {
|
|
||||||
var line = lines[i].trim();
|
|
||||||
if (line === '') continue;
|
|
||||||
|
|
||||||
var timestampMatch = line.match(/^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\]/);
|
|
||||||
var timestampSpan = null;
|
|
||||||
var messageSpan = null;
|
|
||||||
var lineClass = 'log-line';
|
|
||||||
|
|
||||||
if (timestampMatch) {
|
|
||||||
timestampSpan = E('span', {
|
|
||||||
'class': 'log-timestamp',
|
|
||||||
'title': timestampMatch[1]
|
|
||||||
}, timestampMatch[0] + ' ');
|
|
||||||
messageSpan = E('span', {}, line.substring(timestampMatch[0].length + 1));
|
|
||||||
} else {
|
|
||||||
messageSpan = E('span', {}, line);
|
|
||||||
}
|
|
||||||
|
|
||||||
var lineDiv = E('div', { 'class': lineClass }, [
|
|
||||||
timestampSpan,
|
|
||||||
messageSpan
|
|
||||||
].filter(function(el) { return el !== null; }));
|
|
||||||
|
|
||||||
formattedLines.push(lineDiv);
|
|
||||||
}
|
|
||||||
|
|
||||||
return E('div', {}, formattedLines);
|
|
||||||
}
|
|
||||||
|
|
||||||
var clear_log_button = E('div', {}, [
|
|
||||||
E('button', {
|
|
||||||
'class': 'cbi-button cbi-button-remove',
|
|
||||||
'click': function (ev) {
|
|
||||||
ev.preventDefault();
|
|
||||||
var button = ev.target;
|
|
||||||
button.disabled = true;
|
|
||||||
button.textContent = _('Clear Logs...');
|
|
||||||
fs.exec_direct('/usr/libexec/timecontrol-call', ['clear_log'])
|
|
||||||
.then(function () {
|
|
||||||
button.textContent = _('Logs cleared successfully!');
|
|
||||||
button.disabled = false;
|
|
||||||
button.textContent = _('Clear Logs');
|
|
||||||
// 立即刷新日志显示框
|
|
||||||
var logContent = _('Log is clean.');
|
|
||||||
lastLogContent = logContent;
|
|
||||||
dom.content(log_container, formatLogLines(logContent, false));
|
|
||||||
isScrolledToTop = true; // 清空日志后,保持在顶部
|
|
||||||
})
|
|
||||||
.catch(function () {
|
|
||||||
button.textContent = _('Failed to clear log.');
|
|
||||||
button.disabled = false;
|
|
||||||
button.textContent = _('Clear Logs');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, _('Clear Logs'))
|
|
||||||
]);
|
|
||||||
log_container.addEventListener('scroll', function() {
|
|
||||||
lastScrollTop = this.scrollTop;
|
|
||||||
isScrolledToTop = this.scrollTop <= 1;
|
|
||||||
});
|
|
||||||
|
|
||||||
poll.add(L.bind(function () {
|
|
||||||
return fs.read_direct(log_path, 'text')
|
|
||||||
.then(function (res) {
|
|
||||||
var logContent = res.trim();
|
|
||||||
if (logContent === '') {
|
|
||||||
logContent = _('Log is clean.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查内容是否有变化
|
|
||||||
if (logContent !== lastLogContent) {
|
|
||||||
var isNewContent = lastLogContent !== '' && lastLogContent !== _('Log is clean.');
|
|
||||||
var reversedLog = reverseLogLines(logContent);
|
|
||||||
// 格式化为HTML
|
|
||||||
var formattedLog = formatLogLines(reversedLog, isNewContent);
|
|
||||||
|
|
||||||
var prevScrollHeight = log_container.scrollHeight;
|
|
||||||
var prevScrollTop = log_container.scrollTop;
|
|
||||||
|
|
||||||
dom.content(log_container, formattedLog);
|
|
||||||
lastLogContent = logContent;
|
|
||||||
|
|
||||||
if (isScrolledToTop || isNewContent) {
|
|
||||||
log_container.scrollTop = 0;
|
|
||||||
} else {
|
|
||||||
var newScrollHeight = log_container.scrollHeight;
|
|
||||||
var heightDiff = newScrollHeight - prevScrollHeight;
|
|
||||||
log_container.scrollTop = prevScrollTop + heightDiff;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}).catch(function (err) {
|
|
||||||
var logContent;
|
|
||||||
if (err.toString().includes('NotFoundError')) {
|
|
||||||
logContent = _('Log file does not exist.');
|
|
||||||
} else {
|
|
||||||
logContent = _('Unknown error: %s').format(err);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (logContent !== lastLogContent) {
|
|
||||||
dom.content(log_container, formatLogLines(logContent, false));
|
|
||||||
lastLogContent = logContent;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}));
|
|
||||||
|
|
||||||
// 启动轮询
|
|
||||||
poll.start();
|
|
||||||
return E('div', { 'class': 'cbi-map' }, [
|
|
||||||
E('style', [css]),
|
|
||||||
E('div', { 'class': 'cbi-section' }, [
|
|
||||||
clear_log_button,
|
|
||||||
log_container,
|
|
||||||
E('small', {}, _('Refresh every 5 seconds.').format(L.env.pollinterval)),
|
|
||||||
E('div', { 'class': 'cbi-section-actions cbi-section-actions-right' })
|
|
||||||
]),
|
|
||||||
E('div', { 'style': 'text-align: right; font-style: italic;' }, [
|
|
||||||
E('span', {}, [
|
|
||||||
_('© github '),
|
|
||||||
E('a', {
|
|
||||||
'href': 'https://github.com/sirpdboy',
|
|
||||||
'target': '_blank',
|
|
||||||
'style': 'text-decoration: none;'
|
|
||||||
}, 'by sirpdboy')
|
|
||||||
])
|
|
||||||
])
|
|
||||||
|
|
||||||
|
|
||||||
]);
|
|
||||||
},
|
|
||||||
|
|
||||||
handleSaveApply: null,
|
|
||||||
handleSave: null,
|
|
||||||
handleReset: null
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
module("luci.controller.timecontrol", package.seeall)
|
||||||
|
|
||||||
|
function index()
|
||||||
|
if not nixio.fs.access("/etc/config/timecontrol") then return end
|
||||||
|
|
||||||
|
entry({"admin", "control"}, firstchild(), "Control", 44).dependent = false
|
||||||
|
local page = entry({"admin", "control", "timecontrol"}, cbi("timecontrol"), _("Internet Time Control"))
|
||||||
|
page.order = 10
|
||||||
|
page.dependent = true
|
||||||
|
page.acl_depends = { "luci-app-timecontrol" }
|
||||||
|
entry({"admin", "control", "timecontrol", "status"}, call("status")).leaf = true
|
||||||
|
end
|
||||||
|
|
||||||
|
function status()
|
||||||
|
local e = {}
|
||||||
|
e.status = luci.sys.call("/etc/init.d/timecontrol status >/dev/null 2>&1") == 0
|
||||||
|
luci.http.prepare_content("application/json")
|
||||||
|
luci.http.write_json(e)
|
||||||
|
end
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
local o = require "luci.sys"
|
||||||
|
local a, t, e
|
||||||
|
a = Map("timecontrol", translate("Internet Time Control"))
|
||||||
|
a.template = "timecontrol/index"
|
||||||
|
|
||||||
|
t = a:section(TypedSection, "basic")
|
||||||
|
t.anonymous = true
|
||||||
|
|
||||||
|
e = t:option(DummyValue, "timecontrol_status", translate("Status"))
|
||||||
|
e.template = "timecontrol/timecontrol"
|
||||||
|
e.value = translate("Collecting data...")
|
||||||
|
|
||||||
|
e = t:option(Flag, "enable", translate("Enabled"))
|
||||||
|
e.rmempty = false
|
||||||
|
|
||||||
|
t = a:section(TypedSection, "macbind", translate("Client Settings"))
|
||||||
|
t.template = "cbi/tblsection"
|
||||||
|
t.anonymous = true
|
||||||
|
t.addremove = true
|
||||||
|
|
||||||
|
e = t:option(Flag, "enable", translate("Enabled"))
|
||||||
|
e.rmempty = false
|
||||||
|
|
||||||
|
e = t:option(Value, "macaddr", "MAC")
|
||||||
|
e.rmempty = true
|
||||||
|
o.net.mac_hints(function(t, a) e:value(t, "%s (%s)" % {t, a}) end)
|
||||||
|
|
||||||
|
e = t:option(Value, "timeon", translate("No Internet start time"))
|
||||||
|
e.default = "00:00"
|
||||||
|
e.optional = false
|
||||||
|
|
||||||
|
e = t:option(Value, "timeoff", translate("No Internet end time"))
|
||||||
|
e.default = "23:59"
|
||||||
|
e.optional = false
|
||||||
|
|
||||||
|
e = t:option(Flag, "z1", translate("Monday"))
|
||||||
|
e.rmempty = true
|
||||||
|
|
||||||
|
e = t:option(Flag, "z2", translate("Tuesday"))
|
||||||
|
e.rmempty = true
|
||||||
|
|
||||||
|
e = t:option(Flag, "z3", translate("Wednesday"))
|
||||||
|
e.rmempty = true
|
||||||
|
|
||||||
|
e = t:option(Flag, "z4", translate("Thursday"))
|
||||||
|
e.rmempty = true
|
||||||
|
|
||||||
|
e = t:option(Flag, "z5", translate("Friday"))
|
||||||
|
e.rmempty = true
|
||||||
|
|
||||||
|
e = t:option(Flag, "z6", translate("Saturday"))
|
||||||
|
e.rmempty = true
|
||||||
|
|
||||||
|
e = t:option(Flag, "z7", translate("Sunday"))
|
||||||
|
e.rmempty = true
|
||||||
|
|
||||||
|
a.apply_on_parse = true
|
||||||
|
a.on_after_apply = function(self)
|
||||||
|
luci.sys.call("/etc/init.d/timecontrol reload >/dev/null 2>&1")
|
||||||
|
end
|
||||||
|
|
||||||
|
return a
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<% include("cbi/map") %>
|
||||||
|
<script type="text/javascript">//<![CDATA[
|
||||||
|
XHR.poll(2, '<%=luci.dispatcher.build_url("admin", "control", "timecontrol", "status")%>', null,
|
||||||
|
function (x, result) {
|
||||||
|
var status = document.getElementsByClassName('timecontrol_status')[0];
|
||||||
|
status.setAttribute("style", "font-weight:bold;");
|
||||||
|
status.setAttribute("color", result.status ? "green" : "red");
|
||||||
|
status.innerHTML = result.status ? '<%=translate("RUNNING")%>' : '<%=translate("NOT RUNNING")%>';
|
||||||
|
}
|
||||||
|
)
|
||||||
|
//]]>
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<%+cbi/valueheader%>
|
||||||
|
<font class="timecontrol_status"><%=pcdata(self:cfgvalue(section) or self.default or "")%></font>
|
||||||
|
<%+cbi/valuefooter%>
|
||||||
@@ -1,123 +1,41 @@
|
|||||||
msgid ""
|
|
||||||
msgstr ""
|
|
||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
|
||||||
|
|
||||||
msgid "Control"
|
msgid "Control"
|
||||||
msgstr "管控"
|
msgstr "管控"
|
||||||
|
|
||||||
msgid "Time Control"
|
|
||||||
msgstr "时间控制"
|
|
||||||
|
|
||||||
msgid "Internet Time Control"
|
msgid "Internet Time Control"
|
||||||
msgstr "上网时间控制"
|
msgstr "上网时间控制"
|
||||||
|
|
||||||
msgid "Users can limit their internet usage time through MAC and IP, with available IP ranges such as 192.168.110.00 to 192.168.10.200"
|
msgid "Status"
|
||||||
msgstr "用户可以通过MAC和IP限制其上网时间,可用IP范围如192.168.110.00至192.168.10.200"
|
msgstr "状态"
|
||||||
|
|
||||||
msgid "Suggested feedback:"
|
|
||||||
msgstr "问题反馈:"
|
|
||||||
|
|
||||||
msgid "Checking service status..."
|
|
||||||
msgstr "正在检查服务状态..."
|
|
||||||
|
|
||||||
msgid "Status check failed"
|
|
||||||
msgstr "状态检查失败"
|
|
||||||
|
|
||||||
msgid "RUNNING"
|
|
||||||
msgstr "运行中"
|
|
||||||
|
|
||||||
msgid "NOT RUNNING"
|
|
||||||
msgstr "未运行"
|
|
||||||
|
|
||||||
msgid "TimeControl Service"
|
|
||||||
msgstr "时间控制服务"
|
|
||||||
|
|
||||||
msgid "Control Mode"
|
|
||||||
msgstr "控制模式"
|
|
||||||
|
|
||||||
msgid "blacklist: Block the networking of the target address, whitelist: Only allow networking for the target address and block all other addresses."
|
|
||||||
msgstr "黑名单:阻断目标地址的上网;白名单:仅允许目标地址上网,阻断其他所有地址。"
|
|
||||||
|
|
||||||
msgid "Blacklist"
|
|
||||||
msgstr "黑名单"
|
|
||||||
|
|
||||||
msgid "Whitelist"
|
|
||||||
msgstr "白名单"
|
|
||||||
|
|
||||||
msgid "Control Intensity"
|
|
||||||
msgstr "控制强度"
|
|
||||||
|
|
||||||
msgid "Pay attention to strong control: machines under control will not be able to connect to the software router backend!"
|
|
||||||
msgstr "注意强控制:被控制的机器将无法连接软件路由器后台!"
|
|
||||||
|
|
||||||
msgid "Ordinary forward control"
|
|
||||||
msgstr "普通管制"
|
|
||||||
|
|
||||||
msgid "Strong input control"
|
|
||||||
msgstr "强力管制"
|
|
||||||
|
|
||||||
msgid "Device Rules"
|
|
||||||
msgstr "设备规则"
|
|
||||||
|
|
||||||
msgid "Comment"
|
|
||||||
msgstr "备注"
|
|
||||||
|
|
||||||
msgid "Description"
|
|
||||||
msgstr "描述"
|
|
||||||
|
|
||||||
msgid "Enabled"
|
msgid "Enabled"
|
||||||
msgstr "启用"
|
msgstr "启用"
|
||||||
|
|
||||||
msgid "IP/MAC Address"
|
msgid "Client Settings"
|
||||||
msgstr "IP/MAC地址"
|
msgstr "客户端设置"
|
||||||
|
|
||||||
msgid "192.168.10.100 or 00:11:22:33:44:55"
|
msgid "No Internet start time"
|
||||||
msgstr "192.168.10.100 或 00:11:22:33:44:55"
|
msgstr "禁止上网开始时间"
|
||||||
|
|
||||||
msgid "-- Please select or enter manually --"
|
msgid "No Internet end time"
|
||||||
msgstr "-- 请选择或手动输入 --"
|
msgstr "取消禁止上网时间"
|
||||||
|
|
||||||
msgid "Start Control Time"
|
|
||||||
msgstr "控制开始时间"
|
|
||||||
|
|
||||||
msgid "00:00"
|
|
||||||
msgstr "00:00"
|
|
||||||
|
|
||||||
msgid "Stop Control Time"
|
|
||||||
msgstr "控制结束时间"
|
|
||||||
|
|
||||||
msgid "Week Day (1~7)"
|
|
||||||
msgstr "星期(1~7)"
|
|
||||||
|
|
||||||
msgid "Everyday"
|
|
||||||
msgstr "每天"
|
|
||||||
|
|
||||||
msgid "Monday"
|
msgid "Monday"
|
||||||
msgstr "星期一"
|
msgstr "周一"
|
||||||
|
|
||||||
msgid "Tuesday"
|
msgid "Tuesday"
|
||||||
msgstr "星期二"
|
msgstr "周二"
|
||||||
|
|
||||||
msgid "Wednesday"
|
msgid "Wednesday"
|
||||||
msgstr "星期三"
|
msgstr "周三"
|
||||||
|
|
||||||
msgid "Thursday"
|
msgid "Thursday"
|
||||||
msgstr "星期四"
|
msgstr "周四"
|
||||||
|
|
||||||
msgid "Friday"
|
msgid "Friday"
|
||||||
msgstr "星期五"
|
msgstr "周五"
|
||||||
|
|
||||||
msgid "Saturday"
|
msgid "Saturday"
|
||||||
msgstr "星期六"
|
msgstr "周六"
|
||||||
|
|
||||||
msgid "Sunday"
|
msgid "Sunday"
|
||||||
msgstr "星期日"
|
msgstr "周日"
|
||||||
|
|
||||||
msgid "Workday"
|
|
||||||
msgstr "工作日"
|
|
||||||
|
|
||||||
msgid "Rest Day"
|
|
||||||
msgstr "休息日"
|
|
||||||
|
|
||||||
msgid "© github "
|
|
||||||
msgstr "© 作者 "
|
|
||||||
|
|||||||
@@ -1,21 +1,3 @@
|
|||||||
|
|
||||||
config timecontrol
|
config basic
|
||||||
option enabled '0'
|
|
||||||
option control_mode 'blacklist'
|
|
||||||
option list_type 'blacklist'
|
|
||||||
option chain 'input'
|
|
||||||
|
|
||||||
config device
|
|
||||||
option timestart '00:00'
|
|
||||||
option week '0'
|
|
||||||
option timeend '23:55'
|
|
||||||
option mac ''
|
|
||||||
option enable '0'
|
option enable '0'
|
||||||
|
|
||||||
config device
|
|
||||||
option mac '192.168.10.10/24'
|
|
||||||
option timestart '00:00'
|
|
||||||
option timeend '00:00'
|
|
||||||
option week '0'
|
|
||||||
option enable '0'
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,51 +1,230 @@
|
|||||||
#!/bin/sh /etc/rc.common
|
#!/bin/sh /etc/rc.common
|
||||||
|
|
||||||
#
|
|
||||||
# Copyright (C) 2022-2026 sirpdboy herboy2008@gmail.com
|
|
||||||
#
|
|
||||||
|
|
||||||
START=99
|
START=99
|
||||||
USE_PROCD=1
|
STOP=10
|
||||||
|
|
||||||
NAME=timecontrol
|
EXTRA_COMMANDS="status"
|
||||||
LOCK="/var/lock/$NAME.lock"
|
EXTRA_HELP=" status Check if timecontrol rules are active\n"
|
||||||
|
|
||||||
start_instance() {
|
. /lib/functions.sh
|
||||||
procd_open_instance
|
|
||||||
procd_set_param command /usr/bin/timecontrolctrl
|
|
||||||
procd_set_param respawn
|
|
||||||
procd_set_param stderr 1
|
|
||||||
procd_close_instance
|
|
||||||
}
|
|
||||||
|
|
||||||
_timecontrol_start() {
|
TABLE="timecontrol"
|
||||||
if [ "$(grep -c 'option enable .1.' /etc/config/$NAME 2>/dev/null)" -gt "0" ]; then
|
CHAIN="TIMECONTROL"
|
||||||
touch $LOCK
|
|
||||||
timecontrol start
|
firewall_backend() {
|
||||||
sleep 2
|
if command -v fw4 >/dev/null 2>&1 && command -v nft >/dev/null 2>&1; then
|
||||||
start_instance
|
echo nft
|
||||||
else
|
else
|
||||||
stop_service
|
echo iptables
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
start_service(){
|
have_ip6tables() {
|
||||||
[ -f $LOCK ] && exit
|
command -v ip6tables >/dev/null 2>&1
|
||||||
_timecontrol_start
|
|
||||||
rm -f $LOCK
|
|
||||||
}
|
}
|
||||||
|
|
||||||
service_triggers() {
|
valid_mac() {
|
||||||
procd_add_reload_trigger 'timecontrol'
|
printf '%s\n' "$1" | grep -Eq '^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$'
|
||||||
}
|
}
|
||||||
|
|
||||||
stop_service(){
|
valid_time() {
|
||||||
kill -9 $(busybox ps -w | grep 'timecontrolctrl' | grep -v 'grep' | awk '{print $1}') >/dev/null 2>&1
|
printf '%s\n' "$1" | grep -Eq '^([01][0-9]|2[0-3]):[0-5][0-9]$'
|
||||||
killall timecontrolctrl 2>/dev/null
|
|
||||||
rm -f $LOCK 2>/dev/null
|
|
||||||
timecontrol stop
|
|
||||||
}
|
}
|
||||||
|
|
||||||
reload_service() {
|
add_nft_range() {
|
||||||
restart
|
local macaddr="$1"
|
||||||
|
local timeon="$2"
|
||||||
|
local timeoff="$3"
|
||||||
|
local weekdays="$4"
|
||||||
|
|
||||||
|
nft -f - <<-EOF
|
||||||
|
add rule inet $TABLE forward ether saddr $macaddr meta day { $weekdays } meta hour "$timeon"-"$timeoff" counter drop
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
add_nft_rule() {
|
||||||
|
local macaddr="$1"
|
||||||
|
local timeon="$2"
|
||||||
|
local timeoff="$3"
|
||||||
|
local weekdays="$4"
|
||||||
|
local weekdays_next="$5"
|
||||||
|
|
||||||
|
if [ "$timeon" \< "$timeoff" ] || [ "$timeon" = "$timeoff" ]; then
|
||||||
|
add_nft_range "$macaddr" "$timeon" "$timeoff" "$weekdays"
|
||||||
|
else
|
||||||
|
# Range spans midnight: block until 23:59:59 on the selected
|
||||||
|
# days, then from 00:00 until timeoff on the following days.
|
||||||
|
add_nft_range "$macaddr" "$timeon" "23:59:59" "$weekdays"
|
||||||
|
add_nft_range "$macaddr" "00:00" "$timeoff" "$weekdays_next"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
add_ipt_range() {
|
||||||
|
local cmd="$1"
|
||||||
|
local macaddr="$2"
|
||||||
|
local timeon="$3"
|
||||||
|
local timeoff="$4"
|
||||||
|
local weekdays="$5"
|
||||||
|
|
||||||
|
"$cmd" -w -t filter -A "$CHAIN" -m mac --mac-source "$macaddr" \
|
||||||
|
-m time --kerneltz --timestart "$timeon" --timestop "$timeoff" \
|
||||||
|
--weekdays "$weekdays" -j DROP
|
||||||
|
}
|
||||||
|
|
||||||
|
add_ipt_rule() {
|
||||||
|
local macaddr="$1"
|
||||||
|
local timeon="$2"
|
||||||
|
local timeoff="$3"
|
||||||
|
local weekdays="$4"
|
||||||
|
local weekdays_next="$5"
|
||||||
|
local cmd
|
||||||
|
|
||||||
|
# Mirror every rule into ip6tables as well, otherwise IPv6 traffic
|
||||||
|
# would bypass the time control completely.
|
||||||
|
for cmd in iptables ip6tables; do
|
||||||
|
command -v "$cmd" >/dev/null 2>&1 || continue
|
||||||
|
if [ "$timeon" \< "$timeoff" ] || [ "$timeon" = "$timeoff" ]; then
|
||||||
|
add_ipt_range "$cmd" "$macaddr" "$timeon" "$timeoff" "$weekdays"
|
||||||
|
else
|
||||||
|
# Range spans midnight: block until 23:59:59 on the
|
||||||
|
# selected days, then from 00:00 until timeoff on the
|
||||||
|
# following days.
|
||||||
|
add_ipt_range "$cmd" "$macaddr" "$timeon" "23:59:59" "$weekdays"
|
||||||
|
add_ipt_range "$cmd" "$macaddr" "00:00" "$timeoff" "$weekdays_next"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
load_rule() {
|
||||||
|
local section="$1"
|
||||||
|
local enabled macaddr timeon timeoff
|
||||||
|
local z1 z2 z3 z4 z5 z6 z7
|
||||||
|
local ipt_days nft_days ipt_days_next nft_days_next
|
||||||
|
|
||||||
|
config_get_bool enabled "$section" enable 0
|
||||||
|
[ "$enabled" -eq 1 ] || return 0
|
||||||
|
|
||||||
|
config_get macaddr "$section" macaddr
|
||||||
|
config_get timeon "$section" timeon
|
||||||
|
config_get timeoff "$section" timeoff
|
||||||
|
valid_mac "$macaddr" && valid_time "$timeon" && valid_time "$timeoff" || {
|
||||||
|
logger -t timecontrol "Ignoring invalid rule in section $section"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
config_get_bool z1 "$section" z1 0
|
||||||
|
config_get_bool z2 "$section" z2 0
|
||||||
|
config_get_bool z3 "$section" z3 0
|
||||||
|
config_get_bool z4 "$section" z4 0
|
||||||
|
config_get_bool z5 "$section" z5 0
|
||||||
|
config_get_bool z6 "$section" z6 0
|
||||||
|
config_get_bool z7 "$section" z7 0
|
||||||
|
|
||||||
|
# The *_next lists hold each selected weekday shifted by one day;
|
||||||
|
# they apply to the after-midnight part of ranges spanning midnight.
|
||||||
|
[ "$z1" -eq 1 ] && { append ipt_days Mon ,; append ipt_days_next Tue ,; append nft_days monday ,; append nft_days_next tuesday ,; }
|
||||||
|
[ "$z2" -eq 1 ] && { append ipt_days Tue ,; append ipt_days_next Wed ,; append nft_days tuesday ,; append nft_days_next wednesday ,; }
|
||||||
|
[ "$z3" -eq 1 ] && { append ipt_days Wed ,; append ipt_days_next Thu ,; append nft_days wednesday ,; append nft_days_next thursday ,; }
|
||||||
|
[ "$z4" -eq 1 ] && { append ipt_days Thu ,; append ipt_days_next Fri ,; append nft_days thursday ,; append nft_days_next friday ,; }
|
||||||
|
[ "$z5" -eq 1 ] && { append ipt_days Fri ,; append ipt_days_next Sat ,; append nft_days friday ,; append nft_days_next saturday ,; }
|
||||||
|
[ "$z6" -eq 1 ] && { append ipt_days Sat ,; append ipt_days_next Sun ,; append nft_days saturday ,; append nft_days_next sunday ,; }
|
||||||
|
[ "$z7" -eq 1 ] && { append ipt_days Sun ,; append ipt_days_next Mon ,; append nft_days sunday ,; append nft_days_next monday ,; }
|
||||||
|
[ -n "$ipt_days" ] || return 0
|
||||||
|
|
||||||
|
if [ "$BACKEND" = nft ]; then
|
||||||
|
add_nft_rule "$macaddr" "$timeon" "$timeoff" "$nft_days" "$nft_days_next"
|
||||||
|
else
|
||||||
|
add_ipt_rule "$macaddr" "$timeon" "$timeoff" "$ipt_days" "$ipt_days_next"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
load_basic() {
|
||||||
|
config_get_bool ENABLED "$1" enable 0
|
||||||
|
}
|
||||||
|
|
||||||
|
start_nft() {
|
||||||
|
nft -f - <<-EOF
|
||||||
|
table inet $TABLE {
|
||||||
|
chain forward {
|
||||||
|
type filter hook forward priority -1; policy accept;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Flush fw4's flowtable so that connections already on the fast path
|
||||||
|
# (which bypasses this forward hook) are forced back to the slow path
|
||||||
|
# where our DROP rules can reach them. Non-blocked devices will
|
||||||
|
# re-offload within seconds; the disruption is minimal.
|
||||||
|
nft flush flowtable inet fw4 flowtable_ft 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
start_iptables() {
|
||||||
|
iptables -w -t filter -N "$CHAIN" || return 1
|
||||||
|
iptables -w -t filter -I FORWARD 1 -j "$CHAIN"
|
||||||
|
if have_ip6tables; then
|
||||||
|
ip6tables -w -t filter -N "$CHAIN" || return 1
|
||||||
|
ip6tables -w -t filter -I FORWARD 1 -j "$CHAIN"
|
||||||
|
else
|
||||||
|
logger -t timecontrol "ip6tables not found; IPv6 traffic will not be controlled"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
stop_nft() {
|
||||||
|
command -v nft >/dev/null 2>&1 && nft delete table inet "$TABLE" 2>/dev/null
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
stop_ipt_family() {
|
||||||
|
local cmd="$1"
|
||||||
|
|
||||||
|
command -v "$cmd" >/dev/null 2>&1 || return 0
|
||||||
|
while "$cmd" -w -t filter -C FORWARD -j "$CHAIN" 2>/dev/null; do
|
||||||
|
"$cmd" -w -t filter -D FORWARD -j "$CHAIN" 2>/dev/null || break
|
||||||
|
done
|
||||||
|
"$cmd" -w -t filter -F "$CHAIN" 2>/dev/null
|
||||||
|
"$cmd" -w -t filter -X "$CHAIN" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
stop_iptables() {
|
||||||
|
stop_ipt_family iptables
|
||||||
|
stop_ipt_family ip6tables
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
config_load timecontrol
|
||||||
|
ENABLED=0
|
||||||
|
config_foreach load_basic basic
|
||||||
|
[ "$ENABLED" -eq 1 ] || return 0
|
||||||
|
|
||||||
|
stop_nft
|
||||||
|
stop_iptables
|
||||||
|
BACKEND="$(firewall_backend)"
|
||||||
|
mkdir -p /var/etc
|
||||||
|
printf '%s\n' "/etc/init.d/timecontrol reload" > /var/etc/timecontrol.include
|
||||||
|
|
||||||
|
if [ "$BACKEND" = nft ]; then
|
||||||
|
start_nft || return 1
|
||||||
|
else
|
||||||
|
start_iptables || return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
config_foreach load_rule macbind
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
stop_nft
|
||||||
|
stop_iptables
|
||||||
|
}
|
||||||
|
|
||||||
|
reload() {
|
||||||
|
stop
|
||||||
|
start
|
||||||
|
}
|
||||||
|
|
||||||
|
status() {
|
||||||
|
if [ "$(firewall_backend)" = nft ]; then
|
||||||
|
nft list table inet "$TABLE" >/dev/null 2>&1
|
||||||
|
else
|
||||||
|
iptables -w -t filter -S "$CHAIN" >/dev/null 2>&1
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
[ ! -f "/usr/share/ucitrack/luci-app-timecontrol.json" ] && {
|
||||||
|
cat > /usr/share/ucitrack/luci-app-timecontrol.json << EEOF
|
||||||
|
{
|
||||||
|
"config": "timecontrol",
|
||||||
|
"init": "timecontrol"
|
||||||
|
}
|
||||||
|
EEOF
|
||||||
|
}
|
||||||
|
|
||||||
|
uci -q batch <<-EOF >/dev/null
|
||||||
|
delete firewall.timecontrol
|
||||||
|
EOF
|
||||||
|
|
||||||
|
if ! command -v fw4 >/dev/null 2>&1; then
|
||||||
|
uci -q batch <<-EOF >/dev/null
|
||||||
|
set firewall.timecontrol=include
|
||||||
|
set firewall.timecontrol.type=script
|
||||||
|
set firewall.timecontrol.path=/var/etc/timecontrol.include
|
||||||
|
set firewall.timecontrol.reload=1
|
||||||
|
EOF
|
||||||
|
install -d /var/etc
|
||||||
|
printf '%s\n' "/etc/init.d/timecontrol reload" > /var/etc/timecontrol.include
|
||||||
|
fi
|
||||||
|
|
||||||
|
uci -q commit firewall
|
||||||
|
|
||||||
|
[ -f "/etc/config/ucitrack" ] && {
|
||||||
|
uci -q batch <<-EOF >/dev/null
|
||||||
|
delete ucitrack.@timecontrol[-1]
|
||||||
|
add ucitrack timecontrol
|
||||||
|
set ucitrack.@timecontrol[-1].init=timecontrol
|
||||||
|
commit ucitrack
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
rm -rf /tmp/luci-*cache
|
||||||
|
exit 0
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
[ ! -f "/usr/share/ucitrack/luci-app-timecontrol.json" ] && {
|
|
||||||
cat > /usr/share/ucitrack/luci-app-timecontrol.json << EEOF
|
|
||||||
{
|
|
||||||
"config": "timecontrol",
|
|
||||||
"init": "timecontrol"
|
|
||||||
}
|
|
||||||
EEOF
|
|
||||||
}
|
|
||||||
chmod +x /etc/init.d/timecontrol /usr/bin/timecontrol* /usr/libexec/timecontrol-call
|
|
||||||
uci -q batch <<-EOF >/dev/null
|
|
||||||
delete ucitrack.@timecontrol[-1]
|
|
||||||
add ucitrack timecontrol
|
|
||||||
set ucitrack.@timecontrol[-1].init=timecontrol
|
|
||||||
commit ucitrack
|
|
||||||
EOF
|
|
||||||
|
|
||||||
[ -s /etc/config/timecontrol ] || echo "config timecontrol" > /etc/config/timecontrol
|
|
||||||
|
|
||||||
/etc/init.d/rpcd restart
|
|
||||||
rm -f /tmp/luci-indexcache
|
|
||||||
exit 0
|
|
||||||
@@ -1,600 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Copyright (C) 2006 OpenWrt.org
|
|
||||||
# Copyright 2022-2026 sirpdboy <herboy2008@gmail.com>
|
|
||||||
|
|
||||||
crrun=$1
|
|
||||||
crid=$2
|
|
||||||
NAME=timecontrol
|
|
||||||
DEBUG=1 # 开启调试
|
|
||||||
|
|
||||||
config_t_get() {
|
|
||||||
local index=${3:-0}
|
|
||||||
local ret=$(uci -q get "${NAME}.@${1}[${index}].${2}")
|
|
||||||
echo "${ret:-$4}"
|
|
||||||
}
|
|
||||||
|
|
||||||
LOG_FILE="/var/log/timecontrol.log"
|
|
||||||
IDLIST="/var/$NAME.idlist"
|
|
||||||
|
|
||||||
bin_nft=$(which nft 2>/dev/null)
|
|
||||||
bin_iptables=$(which iptables 2>/dev/null)
|
|
||||||
bin_ip6tables=$(which ip6tables 2>/dev/null)
|
|
||||||
bin_conntrack=$(which conntrack 2>/dev/null)
|
|
||||||
|
|
||||||
nftables_ver=0
|
|
||||||
iptables_ver=0
|
|
||||||
|
|
||||||
# 获取配置
|
|
||||||
chain=$(config_t_get timecontrol chain 0 "forward")
|
|
||||||
list_type=$(config_t_get timecontrol list_type 0 "blacklist")
|
|
||||||
|
|
||||||
if [ "$chain" = "input" ]; then
|
|
||||||
StrongCHAIN=1
|
|
||||||
else
|
|
||||||
StrongCHAIN=0
|
|
||||||
fi
|
|
||||||
|
|
||||||
dbg() {
|
|
||||||
if [ "$DEBUG" -eq 1 ]; then
|
|
||||||
local d="$(date '+%Y-%m-%d %H:%M:%S')"
|
|
||||||
echo "[$d] FW-DEBUG: $@" >> "$LOG_FILE"
|
|
||||||
echo "FW-DEBUG: $@"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
info() {
|
|
||||||
local d="$(date '+%Y-%m-%d %H:%M:%S')"
|
|
||||||
echo "[$d] FW-INFO: $@" >> "$LOG_FILE"
|
|
||||||
echo "FW-INFO: $@"
|
|
||||||
}
|
|
||||||
|
|
||||||
# 地址解析函数 - 修复格式问题
|
|
||||||
parse_target() {
|
|
||||||
local target="$1"
|
|
||||||
|
|
||||||
# 去除空格
|
|
||||||
target=$(echo "${target}" | xargs)
|
|
||||||
|
|
||||||
# dbg "解析目标地址: $target"
|
|
||||||
|
|
||||||
# IPv4单个地址
|
|
||||||
if echo "$target" | grep -qE '^([0-9]{1,3}\.){3}[0-9]{1,3}$'; then
|
|
||||||
local octets=(${target//./ })
|
|
||||||
local valid=1
|
|
||||||
for octet in "${octets[@]}"; do
|
|
||||||
if [ "$octet" -gt 255 ] || [ "$octet" -lt 0 ]; then
|
|
||||||
valid=0
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
[ "$valid" -eq 1 ] && {
|
|
||||||
echo "ipv4:single:$target"
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# IPv4范围
|
|
||||||
elif echo "$target" | grep -qE '^([0-9]{1,3}\.){3}[0-9]{1,3}-([0-9]{1,3}\.){3}[0-9]{1,3}$'; then
|
|
||||||
local start_ip=${target%-*}
|
|
||||||
local end_ip=${target#*-}
|
|
||||||
echo "ipv4:range:$start_ip-$end_ip"
|
|
||||||
return 0
|
|
||||||
|
|
||||||
# CIDR
|
|
||||||
elif echo "$target" | grep -qE '^([0-9]{1,3}\.){3}[0-9]{1,3}/[0-9]{1,2}$'; then
|
|
||||||
local ip=${target%/*}
|
|
||||||
local mask=${target#*/}
|
|
||||||
[ "$mask" -le 32 ] && [ "$mask" -ge 0 ] && {
|
|
||||||
echo "ipv4:cidr:$target"
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# MAC地址
|
|
||||||
elif echo "$target" | grep -qE '^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$'; then
|
|
||||||
echo "mac:single:$(echo "$target" | tr '[:upper:]' '[:lower:]')"
|
|
||||||
return 0
|
|
||||||
|
|
||||||
# IPv6地址
|
|
||||||
elif echo "$target" | grep -qE '^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$'; then
|
|
||||||
echo "ipv6:single:$target"
|
|
||||||
return 0
|
|
||||||
|
|
||||||
# IPv6 CIDR
|
|
||||||
elif echo "$target" | grep -qE '^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}/[0-9]{1,3}$'; then
|
|
||||||
local ipv6=${target%/*}
|
|
||||||
local mask=${target#*/}
|
|
||||||
[ "$mask" -le 128 ] && [ "$mask" -ge 0 ] && {
|
|
||||||
echo "ipv6:cidr:$target"
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
fi
|
|
||||||
|
|
||||||
dbg "无法解析地址: $target"
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# 清理现有连接
|
|
||||||
flush_connections() {
|
|
||||||
local target="$1"
|
|
||||||
|
|
||||||
[ -x "$bin_conntrack" ] || {
|
|
||||||
dbg "conntrack不可用"
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
local parsed_result=$(parse_target "$target")
|
|
||||||
[ $? -eq 0 ] || {
|
|
||||||
dbg "无法解析地址用于清理连接: $target"
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
IFS=':' read -r type subtype value <<< "$parsed_result"
|
|
||||||
|
|
||||||
dbg "清理连接: type=$type, value=$value"
|
|
||||||
|
|
||||||
case "$type" in
|
|
||||||
"ipv4")
|
|
||||||
$bin_conntrack -D -s "$value" 2>/dev/null && dbg "清理源连接: $value"
|
|
||||||
$bin_conntrack -D -d "$value" 2>/dev/null && dbg "清理目标连接: $value"
|
|
||||||
;;
|
|
||||||
"mac")
|
|
||||||
# MAC地址需要先转换为IP
|
|
||||||
if [ -f "/proc/net/arp" ]; then
|
|
||||||
local ip_addr=$(grep -i "$value" /proc/net/arp 2>/dev/null | awk '{print $1}' | head -1)
|
|
||||||
if [ -n "$ip_addr" ]; then
|
|
||||||
$bin_conntrack -D -s "$ip_addr" 2>/dev/null && dbg "清理MAC源连接: $value -> $ip_addr"
|
|
||||||
$bin_conntrack -D -d "$ip_addr" 2>/dev/null && dbg "清理MAC目标连接: $value -> $ip_addr"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
# 检查防火墙工具
|
|
||||||
check_firewall_tool() {
|
|
||||||
if [ -x "$bin_nft" ]; then
|
|
||||||
nftables_ver=1
|
|
||||||
dbg "检测到nftables: $bin_nft"
|
|
||||||
elif [ -x "$bin_iptables" ] && [ -x "$bin_ip6tables" ]; then
|
|
||||||
iptables_ver=1
|
|
||||||
dbg "检测到iptables: $bin_iptables, $bin_ip6tables"
|
|
||||||
else
|
|
||||||
info "错误: 未找到可用的防火墙工具"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# 初始化防火墙
|
|
||||||
init_firewall() {
|
|
||||||
check_firewall_tool || return 1
|
|
||||||
|
|
||||||
info "初始化防火墙规则 (模式: $list_type, 强度: $chain)"
|
|
||||||
|
|
||||||
if [ -n "$nftables_ver" ]; then
|
|
||||||
# 使用nftables
|
|
||||||
dbg "初始化nftables"
|
|
||||||
|
|
||||||
# 删除可能存在的旧表
|
|
||||||
nft delete table inet timecontrol 2>/dev/null
|
|
||||||
sleep 1
|
|
||||||
|
|
||||||
# 创建新表
|
|
||||||
nft add table inet timecontrol
|
|
||||||
nft add chain inet timecontrol forward "{ type filter hook forward priority -100; policy accept; }"
|
|
||||||
|
|
||||||
# 创建黑名单集合
|
|
||||||
nft add set inet timecontrol blacklist "{ type ipv4_addr; flags interval; }"
|
|
||||||
nft add set inet timecontrol blacklist6 "{ type ipv6_addr; flags interval; }"
|
|
||||||
nft add set inet timecontrol blacklist_mac "{ type ether_addr; }"
|
|
||||||
|
|
||||||
# 添加规则(黑名单模式:匹配到就DROP)
|
|
||||||
nft add rule inet timecontrol forward ip saddr @blacklist drop
|
|
||||||
nft add rule inet timecontrol forward ip6 saddr @blacklist6 drop
|
|
||||||
nft add rule inet timecontrol forward ether saddr @blacklist_mac drop
|
|
||||||
|
|
||||||
# 强控制模式
|
|
||||||
if [ "$StrongCHAIN" -eq 1 ]; then
|
|
||||||
nft add chain inet timecontrol input "{ type filter hook input priority -100; policy accept; }"
|
|
||||||
nft add rule inet timecontrol input ip saddr @blacklist drop
|
|
||||||
nft add rule inet timecontrol input ip6 saddr @blacklist6 drop
|
|
||||||
nft add rule inet timecontrol input ether saddr @blacklist_mac drop
|
|
||||||
dbg "已启用强控制模式 (INPUT链)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
info "nftables初始化完成"
|
|
||||||
|
|
||||||
elif [ -n "$iptables_ver" ]; then
|
|
||||||
# 使用iptables
|
|
||||||
dbg "初始化iptables"
|
|
||||||
|
|
||||||
# 创建ipset(如果不存在)
|
|
||||||
ipset create timecontrol_blacklist hash:net 2>/dev/null || {
|
|
||||||
ipset flush timecontrol_blacklist
|
|
||||||
dbg "已存在的ipset timecontrol_blacklist已清空"
|
|
||||||
}
|
|
||||||
|
|
||||||
ipset create timecontrol_blacklist6 hash:net family inet6 2>/dev/null || {
|
|
||||||
ipset flush timecontrol_blacklist6
|
|
||||||
dbg "已存在的ipset timecontrol_blacklist6已清空"
|
|
||||||
}
|
|
||||||
|
|
||||||
# 删除可能存在的旧规则
|
|
||||||
iptables -D FORWARD -m set --match-set timecontrol_blacklist src -j DROP 2>/dev/null
|
|
||||||
ip6tables -D FORWARD -m set --match-set timecontrol_blacklist6 src -j DROP 2>/dev/null
|
|
||||||
|
|
||||||
# 添加新规则(黑名单模式)
|
|
||||||
iptables -I FORWARD -m set --match-set timecontrol_blacklist src -j DROP
|
|
||||||
ip6tables -I FORWARD -m set --match-set timecontrol_blacklist6 src -j DROP
|
|
||||||
|
|
||||||
dbg "已添加FORWARD规则"
|
|
||||||
|
|
||||||
# 强控制模式
|
|
||||||
if [ "$StrongCHAIN" -eq 1 ]; then
|
|
||||||
iptables -D INPUT -m set --match-set timecontrol_blacklist src -j DROP 2>/dev/null
|
|
||||||
ip6tables -D INPUT -m set --match-set timecontrol_blacklist6 src -j DROP 2>/dev/null
|
|
||||||
|
|
||||||
iptables -I INPUT -m set --match-set timecontrol_blacklist src -j DROP
|
|
||||||
ip6tables -I INPUT -m set --match-set timecontrol_blacklist6 src -j DROP
|
|
||||||
|
|
||||||
dbg "已启用强控制模式 (INPUT链)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
info "iptables初始化完成"
|
|
||||||
fi
|
|
||||||
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# 停止防火墙规则
|
|
||||||
stop_firewall() {
|
|
||||||
info "停止防火墙规则"
|
|
||||||
|
|
||||||
if [ -n "$nftables_ver" ]; then
|
|
||||||
nft delete table inet timecontrol 2>/dev/null && info "nftables规则已删除"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -n "$iptables_ver" ]; then
|
|
||||||
# 删除iptables规则
|
|
||||||
iptables -D FORWARD -m set --match-set timecontrol_blacklist src -j DROP 2>/dev/null
|
|
||||||
iptables -D INPUT -m set --match-set timecontrol_blacklist src -j DROP 2>/dev/null
|
|
||||||
ip6tables -D FORWARD -m set --match-set timecontrol_blacklist6 src -j DROP 2>/dev/null
|
|
||||||
ip6tables -D INPUT -m set --match-set timecontrol_blacklist6 src -j DROP 2>/dev/null
|
|
||||||
|
|
||||||
# 删除ipset
|
|
||||||
ipset destroy timecontrol_blacklist 2>/dev/null
|
|
||||||
ipset destroy timecontrol_blacklist6 2>/dev/null
|
|
||||||
|
|
||||||
info "iptables规则已删除"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 清理ID列表
|
|
||||||
rm -f "$IDLIST"
|
|
||||||
}
|
|
||||||
|
|
||||||
# 添加设备到防火墙
|
|
||||||
add_device() {
|
|
||||||
local id="$1"
|
|
||||||
local target=$(config_t_get device mac "$id")
|
|
||||||
[ -z "$target" ] && {
|
|
||||||
dbg "添加设备失败: ID $id 的目标地址为空"
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
local comment=$(config_t_get device comment "$id" "设备$id")
|
|
||||||
info "添加设备到防火墙: $comment ($target)"
|
|
||||||
|
|
||||||
local parsed_result=$(parse_target "$target")
|
|
||||||
if [ $? -ne 0 ]; then
|
|
||||||
info "添加失败: 无法解析地址 $target"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
IFS=':' read -r type subtype value <<< "$parsed_result"
|
|
||||||
dbg "解析结果: type=$type, subtype=$subtype, value=$value"
|
|
||||||
|
|
||||||
if [ -n "$nftables_ver" ]; then
|
|
||||||
# nftables处理
|
|
||||||
case "$type" in
|
|
||||||
"ipv4")
|
|
||||||
nft add element inet timecontrol blacklist "{ $value }" 2>&1 | while read line; do dbg "nft: $line"; done
|
|
||||||
dbg "已添加到nftables黑名单(IPv4): $value"
|
|
||||||
;;
|
|
||||||
"ipv6")
|
|
||||||
nft add element inet timecontrol blacklist6 "{ $value }" 2>&1 | while read line; do dbg "nft: $line"; done
|
|
||||||
dbg "已添加到nftables黑名单(IPv6): $value"
|
|
||||||
;;
|
|
||||||
"mac")
|
|
||||||
nft add element inet timecontrol blacklist_mac "{ $value }" 2>&1 | while read line; do dbg "nft: $line"; done
|
|
||||||
dbg "已添加到nftables黑名单(MAC): $value"
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
elif [ -n "$iptables_ver" ]; then
|
|
||||||
# iptables处理
|
|
||||||
case "$type" in
|
|
||||||
"ipv4")
|
|
||||||
ipset add timecontrol_blacklist "$value" 2>&1 | while read line; do dbg "ipset: $line"; done
|
|
||||||
dbg "已添加到ipset黑名单(IPv4): $value"
|
|
||||||
;;
|
|
||||||
"ipv6")
|
|
||||||
ipset add timecontrol_blacklist6 "$value" 2>&1 | while read line; do dbg "ipset: $line"; done
|
|
||||||
dbg "已添加到ipset黑名单(IPv6): $value"
|
|
||||||
;;
|
|
||||||
"mac")
|
|
||||||
# iptables不支持MAC地址直接过滤,记录日志
|
|
||||||
info "警告: iptables不支持MAC地址过滤,设备 $target 可能无法被阻止"
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 强控制模式清理连接
|
|
||||||
if [ "$StrongCHAIN" -eq 1 ]; then
|
|
||||||
dbg "强控制模式,清理现有连接"
|
|
||||||
flush_connections "$target"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 验证规则
|
|
||||||
verify_firewall_rule "$target"
|
|
||||||
}
|
|
||||||
|
|
||||||
# 验证防火墙规则
|
|
||||||
verify_firewall_rule() {
|
|
||||||
local target="$1"
|
|
||||||
|
|
||||||
dbg "验证防火墙规则: $target"
|
|
||||||
|
|
||||||
if [ -n "$nftables_ver" ]; then
|
|
||||||
nft list table inet timecontrol 2>/dev/null | grep -q "$target" && {
|
|
||||||
dbg "验证成功: $target 在nftables规则中"
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
elif [ -n "$iptables_ver" ]; then
|
|
||||||
ipset test timecontrol_blacklist "$target" 2>/dev/null && {
|
|
||||||
dbg "验证成功: $target 在ipset中"
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
fi
|
|
||||||
|
|
||||||
dbg "验证失败: $target 不在防火墙规则中"
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# 从防火墙移除设备
|
|
||||||
del_device() {
|
|
||||||
local id="$1"
|
|
||||||
local target=$(config_t_get device mac "$id")
|
|
||||||
[ -z "$target" ] && {
|
|
||||||
dbg "移除设备失败: ID $id 的目标地址为空"
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
local comment=$(config_t_get device comment "$id" "设备$id")
|
|
||||||
info "从防火墙移除设备: $comment ($target)"
|
|
||||||
|
|
||||||
local parsed_result=$(parse_target "$target")
|
|
||||||
[ $? -eq 0 ] || {
|
|
||||||
info "移除失败: 无法解析地址 $target"
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
IFS=':' read -r type subtype value <<< "$parsed_result"
|
|
||||||
|
|
||||||
if [ -n "$nftables_ver" ]; then
|
|
||||||
case "$type" in
|
|
||||||
"ipv4")
|
|
||||||
nft delete element inet timecontrol blacklist "{ $value }" 2>/dev/null
|
|
||||||
dbg "已从nftables移除(IPv4): $value"
|
|
||||||
;;
|
|
||||||
"ipv6")
|
|
||||||
nft delete element inet timecontrol blacklist6 "{ $value }" 2>/dev/null
|
|
||||||
dbg "已从nftables移除(IPv6): $value"
|
|
||||||
;;
|
|
||||||
"mac")
|
|
||||||
nft delete element inet timecontrol blacklist_mac "{ $value }" 2>/dev/null
|
|
||||||
dbg "已从nftables移除(MAC): $value"
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
elif [ -n "$iptables_ver" ]; then
|
|
||||||
case "$type" in
|
|
||||||
"ipv4")
|
|
||||||
ipset del timecontrol_blacklist "$value" 2>/dev/null
|
|
||||||
dbg "已从ipset移除(IPv4): $value"
|
|
||||||
;;
|
|
||||||
"ipv6")
|
|
||||||
ipset del timecontrol_blacklist6 "$value" 2>/dev/null
|
|
||||||
dbg "已从ipset移除(IPv6): $value"
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# 显示防火墙状态
|
|
||||||
show_firewall_status() {
|
|
||||||
echo ""
|
|
||||||
echo "防火墙状态:"
|
|
||||||
echo "控制模式: $list_type"
|
|
||||||
echo "控制强度: $chain $( [ "$StrongCHAIN" -eq 1 ] && echo "(强控制)" )"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
if [ -n "$nftables_ver" ]; then
|
|
||||||
echo "nftables规则:"
|
|
||||||
nft list table inet timecontrol 2>/dev/null || echo " 未找到timecontrol表"
|
|
||||||
elif [ -n "$iptables_ver" ]; then
|
|
||||||
echo "iptables规则:"
|
|
||||||
echo "FORWARD链:"
|
|
||||||
iptables -L FORWARD -n | grep -i timecontrol || echo " 未找到timecontrol规则"
|
|
||||||
ip6tables -L FORWARD -n | grep -i timecontrol || echo " 未找到IPv6 timecontrol规则"
|
|
||||||
|
|
||||||
if [ "$StrongCHAIN" -eq 1 ]; then
|
|
||||||
echo ""
|
|
||||||
echo "INPUT链:"
|
|
||||||
iptables -L INPUT -n | grep -i timecontrol || echo " 未找到timecontrol规则"
|
|
||||||
ip6tables -L INPUT -n | grep -i timecontrol || echo " 未找到IPv6 timecontrol规则"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "ipset内容:"
|
|
||||||
ipset list timecontrol_blacklist 2>/dev/null | head -20 || echo " timecontrol_blacklist未找到"
|
|
||||||
echo ""
|
|
||||||
ipset list timecontrol_blacklist6 2>/dev/null | head -20 || echo " timecontrol_blacklist6未找到"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# 诊断函数
|
|
||||||
diagnose() {
|
|
||||||
echo ""
|
|
||||||
echo "=== 时间控制系统诊断 ==="
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# 检查服务
|
|
||||||
echo "1. 服务状态:"
|
|
||||||
if ps | grep -q "timecontrolctrl"; then
|
|
||||||
echo " ✓ timecontrolctrl 正在运行"
|
|
||||||
else
|
|
||||||
echo " ✗ timecontrolctrl 未运行"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 检查配置文件
|
|
||||||
echo ""
|
|
||||||
echo "2. 配置文件:"
|
|
||||||
if [ -f "/etc/config/timecontrol" ]; then
|
|
||||||
echo " ✓ 配置文件存在"
|
|
||||||
uci show timecontrol 2>/dev/null | grep -c "device" | while read count; do
|
|
||||||
echo " 配置了 $count 个设备"
|
|
||||||
done
|
|
||||||
else
|
|
||||||
echo " ✗ 配置文件不存在"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 检查防火墙工具
|
|
||||||
echo ""
|
|
||||||
echo "3. 防火墙工具:"
|
|
||||||
if [ -x "$bin_nft" ]; then
|
|
||||||
echo " ✓ nftables: $bin_nft"
|
|
||||||
echo " 版本: $($bin_nft --version 2>/dev/null | head -1)"
|
|
||||||
elif [ -x "$bin_iptables" ]; then
|
|
||||||
echo " ✓ iptables: $bin_iptables"
|
|
||||||
echo " 版本: $($bin_iptables --version 2>/dev/null | head -1)"
|
|
||||||
else
|
|
||||||
echo " ✗ 未找到防火墙工具"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 显示当前规则
|
|
||||||
show_firewall_status
|
|
||||||
|
|
||||||
# 检查ID列表
|
|
||||||
echo ""
|
|
||||||
echo "4. 当前控制列表:"
|
|
||||||
if [ -f "$IDLIST" ] && [ -s "$IDLIST" ]; then
|
|
||||||
echo " 当前禁止的设备:"
|
|
||||||
cat "$IDLIST" | sed 's/!//g' | while read id; do
|
|
||||||
local target=$(config_t_get device mac "$id")
|
|
||||||
local comment=$(config_t_get device comment "$id" "设备$id")
|
|
||||||
echo " ID$id: $comment ($target)"
|
|
||||||
done
|
|
||||||
else
|
|
||||||
echo " 当前没有设备被禁止"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "=== 诊断完成 ==="
|
|
||||||
}
|
|
||||||
|
|
||||||
# 主命令处理
|
|
||||||
case "$crrun" in
|
|
||||||
"start")
|
|
||||||
info "启动时间控制"
|
|
||||||
stop_firewall
|
|
||||||
init_firewall
|
|
||||||
if [ $? -eq 0 ]; then
|
|
||||||
info "时间控制启动成功"
|
|
||||||
show_firewall_status
|
|
||||||
else
|
|
||||||
info "时间控制启动失败"
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
|
|
||||||
"stop")
|
|
||||||
info "停止时间控制"
|
|
||||||
stop_firewall
|
|
||||||
info "时间控制已停止"
|
|
||||||
;;
|
|
||||||
|
|
||||||
"add")
|
|
||||||
[ -z "$crid" ] && {
|
|
||||||
echo "错误: 需要指定设备ID"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
info "添加设备控制: ID=$crid"
|
|
||||||
add_device "$crid"
|
|
||||||
show_firewall_status
|
|
||||||
;;
|
|
||||||
|
|
||||||
"del")
|
|
||||||
[ -z "$crid" ] && {
|
|
||||||
echo "错误: 需要指定设备ID"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
info "移除设备控制: ID=$crid"
|
|
||||||
del_device "$crid"
|
|
||||||
show_firewall_status
|
|
||||||
;;
|
|
||||||
|
|
||||||
"status")
|
|
||||||
show_firewall_status
|
|
||||||
;;
|
|
||||||
|
|
||||||
"diagnose")
|
|
||||||
diagnose
|
|
||||||
;;
|
|
||||||
|
|
||||||
"test")
|
|
||||||
# 测试地址解析
|
|
||||||
echo "测试地址解析:"
|
|
||||||
for test in "192.168.1.100" "192.168.1.0/24" "00:11:22:33:44:55" "invalid"; do
|
|
||||||
echo -n "$test: "
|
|
||||||
if parse_target "$test" >/dev/null; then
|
|
||||||
echo "✓ 有效"
|
|
||||||
parse_target "$test"
|
|
||||||
else
|
|
||||||
echo "✗ 无效"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
;;
|
|
||||||
|
|
||||||
"flush")
|
|
||||||
# 清理所有连接
|
|
||||||
info "清理所有连接"
|
|
||||||
if [ -x "$bin_conntrack" ]; then
|
|
||||||
$bin_conntrack -F
|
|
||||||
info "连接已清理"
|
|
||||||
else
|
|
||||||
info "conntrack不可用"
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
|
|
||||||
"help"|"")
|
|
||||||
echo "时间控制系统命令工具"
|
|
||||||
echo ""
|
|
||||||
echo "用法: $0 {start|stop|add <id>|del <id>|status|diagnose|test|flush|help}"
|
|
||||||
echo ""
|
|
||||||
echo "命令说明:"
|
|
||||||
echo " start - 初始化防火墙规则"
|
|
||||||
echo " stop - 停止并清理所有防火墙规则"
|
|
||||||
echo " add <id> - 添加设备到控制列表"
|
|
||||||
echo " del <id> - 从控制列表移除设备"
|
|
||||||
echo " status - 显示防火墙状态"
|
|
||||||
echo " diagnose - 系统诊断"
|
|
||||||
echo " test - 测试地址解析"
|
|
||||||
echo " flush - 清理所有网络连接"
|
|
||||||
echo " help - 显示此帮助信息"
|
|
||||||
;;
|
|
||||||
|
|
||||||
*)
|
|
||||||
echo "错误: 未知命令 '$crrun'"
|
|
||||||
echo "使用: $0 help 查看帮助"
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# 时间控制日志查看工具
|
|
||||||
|
|
||||||
NAME=timecontrol
|
|
||||||
LOG_FILE="/var/log/$NAME.log"
|
|
||||||
STATUS_LOG="/var/lib/$NAME/status.log"
|
|
||||||
CONNECTION_LOG="/var/lib/$NAME/connections.log"
|
|
||||||
|
|
||||||
show_realtime_log() {
|
|
||||||
echo "正在显示实时日志,按 Ctrl+C 退出..."
|
|
||||||
echo ""
|
|
||||||
tail -f "$LOG_FILE" | while read line; do
|
|
||||||
# 高亮显示重要信息
|
|
||||||
if echo "$line" | grep -q "STATUS-CHANGE\|TIME_EXCEEDED\|RESET"; then
|
|
||||||
echo -e "\033[1;31m$line\033[0m" # 红色显示重要变更
|
|
||||||
elif echo "$line" | grep -q "ALLOW_ACCESS\|解除限制"; then
|
|
||||||
echo -e "\033[1;32m$line\033[0m" # 绿色显示允许访问
|
|
||||||
elif echo "$line" | grep -q "BLOCK_ACCESS\|添加限制"; then
|
|
||||||
echo -e "\033[1;33m$line\033[0m" # 黄色显示禁止访问
|
|
||||||
else
|
|
||||||
echo "$line"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
}
|
|
||||||
|
|
||||||
show_status_log() {
|
|
||||||
echo "最近状态变更记录:"
|
|
||||||
echo "────────────────────────────────────────────────────────────────────"
|
|
||||||
if [ -f "$STATUS_LOG" ]; then
|
|
||||||
tail -n 20 "$STATUS_LOG" | while read line; do
|
|
||||||
local time=$(echo "$line" | cut -d']' -f1 | sed 's/\[//')
|
|
||||||
local message=$(echo "$line" | cut -d']' -f2-)
|
|
||||||
printf "%-20s %s\n" "$time" "$message"
|
|
||||||
done
|
|
||||||
else
|
|
||||||
echo "暂无状态记录"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
show_connection_log() {
|
|
||||||
echo "设备连接记录:"
|
|
||||||
echo "────────────────────────────────────────────────────────────────────"
|
|
||||||
echo "时间 设备 状态"
|
|
||||||
echo "────────────────────────────────────────────────────────────────────"
|
|
||||||
|
|
||||||
if [ -f "$CONNECTION_LOG" ]; then
|
|
||||||
tail -n 20 "$CONNECTION_LOG" | while read line; do
|
|
||||||
local timestamp=$(echo "$line" | cut -d',' -f1)
|
|
||||||
local target=$(echo "$line" | cut -d',' -f2)
|
|
||||||
local action=$(echo "$line" | cut -d',' -f3)
|
|
||||||
|
|
||||||
local time_str=$(date -d "@$timestamp" '+%Y-%m-%d %H:%M:%S' 2>/dev/null || echo "$timestamp")
|
|
||||||
local action_text=""
|
|
||||||
|
|
||||||
case "$action" in
|
|
||||||
"connect") action_text="上线" ;;
|
|
||||||
"disconnect") action_text="下线" ;;
|
|
||||||
*) action_text="$action" ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
printf "%-20s %-18s %s\n" "$time_str" "$target" "$action_text"
|
|
||||||
done
|
|
||||||
else
|
|
||||||
echo "暂无连接记录"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
show_summary() {
|
|
||||||
local summary_file="/tmp/timecontrol_status.txt"
|
|
||||||
if [ -f "$summary_file" ]; then
|
|
||||||
cat "$summary_file"
|
|
||||||
else
|
|
||||||
echo "状态摘要文件不存在,正在生成..."
|
|
||||||
timecontrol status
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
show_help() {
|
|
||||||
echo "时间控制日志查看工具"
|
|
||||||
echo ""
|
|
||||||
echo "用法: timecontrol-log {realtime|status|connections|summary|help}"
|
|
||||||
echo ""
|
|
||||||
echo "命令:"
|
|
||||||
echo " realtime - 实时显示日志(彩色高亮)"
|
|
||||||
echo " status - 显示状态变更记录"
|
|
||||||
echo " connections - 显示设备连接记录"
|
|
||||||
echo " summary - 显示状态摘要"
|
|
||||||
echo " help - 显示此帮助"
|
|
||||||
echo ""
|
|
||||||
echo "示例:"
|
|
||||||
echo " timecontrol-log realtime # 实时监控"
|
|
||||||
echo " timecontrol-log status # 查看状态变更"
|
|
||||||
}
|
|
||||||
|
|
||||||
case "$1" in
|
|
||||||
"realtime")
|
|
||||||
show_realtime_log
|
|
||||||
;;
|
|
||||||
"status")
|
|
||||||
show_status_log
|
|
||||||
;;
|
|
||||||
"connections")
|
|
||||||
show_connection_log
|
|
||||||
;;
|
|
||||||
"summary")
|
|
||||||
show_summary
|
|
||||||
;;
|
|
||||||
"help"|"")
|
|
||||||
show_help
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "未知命令: $1"
|
|
||||||
show_help
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
@@ -1,335 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
|
|
||||||
# Copyright (C) 2006 OpenWrt.org
|
|
||||||
# Copyright 2022-2026 sirpdboy <herboy2008@gmail.com>
|
|
||||||
|
|
||||||
NAME=timecontrol
|
|
||||||
LOG_FILE="/var/log/timecontrol.log"
|
|
||||||
DEBUG=1
|
|
||||||
|
|
||||||
# 时长数据库目录
|
|
||||||
DURATION_DIR="/var/lib/timecontrol"
|
|
||||||
DURATION_DB="$DURATION_DIR/duration.db"
|
|
||||||
CONNECTION_LOG="$DURATION_DIR/connections.log"
|
|
||||||
|
|
||||||
# 状态文件
|
|
||||||
IDLIST="/var/$NAME.idlist"
|
|
||||||
STATUS_DB="$DURATION_DIR/status.db"
|
|
||||||
|
|
||||||
# 初始化目录
|
|
||||||
init_dirs() {
|
|
||||||
mkdir -p "$DURATION_DIR"
|
|
||||||
touch "$DURATION_DB" "$CONNECTION_LOG" "$STATUS_DB" 2>/dev/null
|
|
||||||
}
|
|
||||||
|
|
||||||
# 日志函数
|
|
||||||
dbg() {
|
|
||||||
[ "$DEBUG" -eq 1 ] && {
|
|
||||||
local d="$(date '+%Y-%m-%d %H:%M:%S')"
|
|
||||||
echo "[$d] CTRL-DEBUG: $@" >> "$LOG_FILE"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
info() {
|
|
||||||
local d="$(date '+%Y-%m-%d %H:%M:%S')"
|
|
||||||
echo "[$d] CTRL-INFO: $@" >> "$LOG_FILE"
|
|
||||||
}
|
|
||||||
|
|
||||||
# 配置文件读取
|
|
||||||
config_t_get() {
|
|
||||||
local index=${3:-0}
|
|
||||||
local ret=$(uci -q get "${NAME}.@${1}[${index}].${2}")
|
|
||||||
echo "${ret:=${4}}"
|
|
||||||
}
|
|
||||||
|
|
||||||
# 获取启用设备
|
|
||||||
get_enabled_devices() {
|
|
||||||
uci show $NAME 2>/dev/null | grep "enable='1'" | grep "device" | grep -oE '\[.*?\]' | grep -o '[0-9]' | sort -n
|
|
||||||
}
|
|
||||||
|
|
||||||
# 时间检查函数
|
|
||||||
is_time_in_range() {
|
|
||||||
local start_time=$1
|
|
||||||
local end_time=$2
|
|
||||||
local current_time=$(date +%H:%M)
|
|
||||||
|
|
||||||
if [ "$start_time" = "$end_time" ]; then
|
|
||||||
return 0
|
|
||||||
elif [ "$start_time" \< "$end_time" ]; then
|
|
||||||
[ "$current_time" \> "$start_time" ] && [ "$current_time" \< "$end_time" ] && return 0
|
|
||||||
else
|
|
||||||
[ "$current_time" \> "$start_time" ] || [ "$current_time" \< "$end_time" ] && return 0
|
|
||||||
fi
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
is_weekday_in_range() {
|
|
||||||
local configured_weekdays=$1
|
|
||||||
local current_weekday=$(date +%u)
|
|
||||||
|
|
||||||
[ "$configured_weekdays" = "0" ] && return 0
|
|
||||||
|
|
||||||
for ww in $(echo $configured_weekdays | sed 's/,/ /g'); do
|
|
||||||
[ "$current_weekday" = "$ww" ] && return 0
|
|
||||||
done
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# 时长管理
|
|
||||||
record_connection_time() {
|
|
||||||
local target="$1"
|
|
||||||
local action="$2"
|
|
||||||
local timestamp=$(date +%s)
|
|
||||||
|
|
||||||
echo "$timestamp,$target,$action" >> "$CONNECTION_LOG"
|
|
||||||
|
|
||||||
if [ "$action" = "connect" ]; then
|
|
||||||
if ! grep -q "^$target," "$DURATION_DB" 2>/dev/null; then
|
|
||||||
echo "$target,$timestamp,0,0" >> "$DURATION_DB"
|
|
||||||
dbg "初始化时长: $target"
|
|
||||||
fi
|
|
||||||
elif [ "$action" = "disconnect" ]; then
|
|
||||||
if grep -q "^$target," "$DURATION_DB" 2>/dev/null; then
|
|
||||||
local last_connect=$(grep "^$target," "$DURATION_DB" | cut -d',' -f2)
|
|
||||||
local total_used=$(grep "^$target," "$DURATION_DB" | cut -d',' -f3)
|
|
||||||
local last_reset=$(grep "^$target," "$DURATION_DB" | cut -d',' -f4)
|
|
||||||
|
|
||||||
local session_duration=$((timestamp - last_connect))
|
|
||||||
local new_total=$((total_used + session_duration))
|
|
||||||
|
|
||||||
sed -i "/^$target,/d" "$DURATION_DB"
|
|
||||||
echo "$target,$timestamp,$new_total,$last_reset" >> "$DURATION_DB"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
get_connection_time() {
|
|
||||||
local target="$1"
|
|
||||||
local current_time=$(date +%s)
|
|
||||||
|
|
||||||
if grep -q "^$target," "$DURATION_DB" 2>/dev/null; then
|
|
||||||
local last_connect=$(grep "^$target," "$DURATION_DB" | cut -d',' -f2)
|
|
||||||
local total_used=$(grep "^$target," "$DURATION_DB" | cut -d',' -f3)
|
|
||||||
|
|
||||||
if tail -n 5 "$CONNECTION_LOG" 2>/dev/null | grep -q "^[0-9]*,$target,connect$"; then
|
|
||||||
local session_duration=$((current_time - last_connect))
|
|
||||||
total_used=$((total_used + session_duration))
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "$total_used"
|
|
||||||
else
|
|
||||||
echo "0"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# 重置检查
|
|
||||||
should_reset_duration() {
|
|
||||||
local target="$1"
|
|
||||||
local reset_cycle="$2"
|
|
||||||
local last_reset_file="$DURATION_DIR/last_reset_$target"
|
|
||||||
|
|
||||||
[ ! -f "$last_reset_file" ] && return 0
|
|
||||||
|
|
||||||
local last_reset=$(cat "$last_reset_file" 2>/dev/null)
|
|
||||||
local current_time=$(date +%s)
|
|
||||||
|
|
||||||
case "$reset_cycle" in
|
|
||||||
"daily")
|
|
||||||
local last_date=$(date -d "@$last_reset" +%Y%m%d 2>/dev/null || echo "0")
|
|
||||||
local current_date=$(date +%Y%m%d)
|
|
||||||
[ "$last_date" != "$current_date" ]
|
|
||||||
;;
|
|
||||||
"weekly")
|
|
||||||
local last_week=$(date -d "@$last_reset" +%Y%W 2>/dev/null || echo "0")
|
|
||||||
local current_week=$(date +%Y%W)
|
|
||||||
[ "$last_week" != "$current_week" ]
|
|
||||||
;;
|
|
||||||
"monthly")
|
|
||||||
local last_month=$(date -d "@$last_reset" +%Y%m 2>/dev/null || echo "0")
|
|
||||||
local current_month=$(date +%Y%m)
|
|
||||||
[ "$last_month" != "$current_month" ]
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
false
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
reset_duration_counter() {
|
|
||||||
local target="$1"
|
|
||||||
local reset_cycle="$2"
|
|
||||||
local current_time=$(date +%s)
|
|
||||||
|
|
||||||
if grep -q "^$target," "$DURATION_DB" 2>/dev/null; then
|
|
||||||
sed -i "/^$target,/d" "$DURATION_DB"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "$target,$current_time,0,$current_time" >> "$DURATION_DB"
|
|
||||||
echo "$current_time" > "$DURATION_DIR/last_reset_$target"
|
|
||||||
|
|
||||||
info "重置时长: $target (周期: $reset_cycle)"
|
|
||||||
}
|
|
||||||
|
|
||||||
# 主检查函数
|
|
||||||
check_device_control() {
|
|
||||||
local id="$1"
|
|
||||||
local target=$(config_t_get device mac "$id")
|
|
||||||
local time_mode=$(config_t_get device time_mode "$id" "period")
|
|
||||||
local weekdays=$(config_t_get device week "$id" "0")
|
|
||||||
|
|
||||||
# 检查星期
|
|
||||||
is_weekday_in_range "$weekdays" || {
|
|
||||||
dbg "星期不允许: $target"
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
case "$time_mode" in
|
|
||||||
"period")
|
|
||||||
local start_time=$(config_t_get device timestart "$id" "00:00")
|
|
||||||
local end_time=$(config_t_get device timeend "$id" "00:00")
|
|
||||||
is_time_in_range "$start_time" "$end_time" || {
|
|
||||||
dbg "时间段外: $target ($start_time-$end_time)"
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
dbg "时间段内: $target"
|
|
||||||
return 0
|
|
||||||
;;
|
|
||||||
|
|
||||||
"duration")
|
|
||||||
local duration=$(config_t_get device duration "$id" "60")
|
|
||||||
local reset_cycle=$(config_t_get device reset_cycle "$id" "daily")
|
|
||||||
|
|
||||||
# 重置检查
|
|
||||||
if should_reset_duration "$target" "$reset_cycle"; then
|
|
||||||
reset_duration_counter "$target" "$reset_cycle"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 记录连接
|
|
||||||
local last_action=$(grep ",$target," "$CONNECTION_LOG" 2>/dev/null | tail -1 | cut -d',' -f3)
|
|
||||||
if [ "$last_action" != "connect" ]; then
|
|
||||||
record_connection_time "$target" "connect"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 检查时长
|
|
||||||
local used_seconds=$(get_connection_time "$target")
|
|
||||||
local used_minutes=$((used_seconds / 60))
|
|
||||||
local max_minutes=$duration
|
|
||||||
|
|
||||||
dbg "时长检查: $target 已用=${used_minutes}分钟, 限制=${max_minutes}分钟"
|
|
||||||
|
|
||||||
if [ "$used_minutes" -ge "$max_minutes" ]; then
|
|
||||||
dbg "已超时: $target"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
dbg "未超时: $target"
|
|
||||||
return 0
|
|
||||||
;;
|
|
||||||
|
|
||||||
"combined")
|
|
||||||
local start_time=$(config_t_get device timestart "$id" "00:00")
|
|
||||||
local end_time=$(config_t_get device timeend "$id" "00:00")
|
|
||||||
local use_duration=$(config_t_get device use_duration "$id" "0")
|
|
||||||
|
|
||||||
# 时间段检查
|
|
||||||
is_time_in_range "$start_time" "$end_time" || {
|
|
||||||
dbg "时间段外: $target"
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# 时长检查
|
|
||||||
if [ "$use_duration" = "1" ]; then
|
|
||||||
local duration=$(config_t_get device duration "$id" "60")
|
|
||||||
local reset_cycle=$(config_t_get device reset_cycle "$id" "daily")
|
|
||||||
|
|
||||||
if should_reset_duration "$target" "$reset_cycle"; then
|
|
||||||
reset_duration_counter "$target" "$reset_cycle"
|
|
||||||
fi
|
|
||||||
|
|
||||||
local last_action=$(grep ",$target," "$CONNECTION_LOG" 2>/dev/null | tail -1 | cut -d',' -f3)
|
|
||||||
if [ "$last_action" != "connect" ]; then
|
|
||||||
record_connection_time "$target" "connect"
|
|
||||||
fi
|
|
||||||
|
|
||||||
local used_seconds=$(get_connection_time "$target")
|
|
||||||
local used_minutes=$((used_seconds / 60))
|
|
||||||
local max_minutes=$duration
|
|
||||||
|
|
||||||
if [ "$used_minutes" -ge "$max_minutes" ]; then
|
|
||||||
dbg "时间段内但已超时: $target"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
dbg "时间段内允许: $target"
|
|
||||||
return 0
|
|
||||||
;;
|
|
||||||
|
|
||||||
*)
|
|
||||||
# 默认时间段控制
|
|
||||||
local start_time=$(config_t_get device timestart "$id" "00:00")
|
|
||||||
local end_time=$(config_t_get device timeend "$id" "00:00")
|
|
||||||
is_time_in_range "$start_time" "$end_time"
|
|
||||||
return $?
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
# 更新设备状态
|
|
||||||
update_device_status() {
|
|
||||||
local id="$1"
|
|
||||||
local should_allow="$2" # 0=允许, 1=禁止
|
|
||||||
local target=$(config_t_get device mac "$id")
|
|
||||||
local comment=$(config_t_get device comment "$id" "设备$id")
|
|
||||||
|
|
||||||
# 检查当前状态
|
|
||||||
local current_blocked=0
|
|
||||||
if [ -f "$IDLIST" ] && grep -q "!${id}!" "$IDLIST" 2>/dev/null; then
|
|
||||||
current_blocked=1
|
|
||||||
fi
|
|
||||||
|
|
||||||
dbg "设备状态: $target, 应该允许=$should_allow, 当前阻止=$current_blocked"
|
|
||||||
|
|
||||||
if [ "$should_allow" -eq 0 ]; then
|
|
||||||
# 应该允许
|
|
||||||
if [ "$current_blocked" -eq 1 ]; then
|
|
||||||
dbg "解除阻止: $target"
|
|
||||||
timecontrol del "$id"
|
|
||||||
sed -i "/!$id!/d" "$IDLIST" 2>/dev/null
|
|
||||||
info "允许上网: $comment ($target)"
|
|
||||||
record_connection_time "$target" "disconnect"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
# 应该阻止
|
|
||||||
if [ "$current_blocked" -eq 0 ]; then
|
|
||||||
dbg "添加阻止: $target"
|
|
||||||
timecontrol add "$id"
|
|
||||||
if ! grep -q "!$id!" "$IDLIST" 2>/dev/null; then
|
|
||||||
echo "!$id!" >> "$IDLIST"
|
|
||||||
fi
|
|
||||||
info "阻止上网: $comment ($target)"
|
|
||||||
record_connection_time "$target" "disconnect"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# 主处理循环
|
|
||||||
main_loop() {
|
|
||||||
info "时间控制守护进程启动"
|
|
||||||
init_dirs
|
|
||||||
while :; do
|
|
||||||
dbg "开始检查设备"
|
|
||||||
[ `uci show $NAME 2>/dev/null | grep "enable='1'" | grep "device" | grep -oE '\[.*?\]' | grep -o '[0-9]' | sort -n | wc -l` eq 0 ] && timecontrol stop && break
|
|
||||||
for id in $(get_enabled_devices); do
|
|
||||||
if check_device_control "$id"; then
|
|
||||||
update_device_status "$id" 0 # 允许
|
|
||||||
else
|
|
||||||
update_device_status "$id" 1 # 阻止
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
sleep 60
|
|
||||||
done
|
|
||||||
}
|
|
||||||
|
|
||||||
# 启动
|
|
||||||
main_loop
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
#
|
|
||||||
# Copyright (C) 2025 sirpdboy herboy2008@gmail.com https://github.com/sirpdboy/luci-app-timecontrol
|
|
||||||
#
|
|
||||||
|
|
||||||
logfile="/var/log/timecontrol.log"
|
|
||||||
lang=$(uci get luci.main.lang 2>/dev/null)
|
|
||||||
if [ -z "$lang" ] || [[ "$lang" == "auto" ]]; then
|
|
||||||
lang=$(echo "${LANG:-${LANGUAGE:-${LC_ALL:-${LC_MESSAGES:-zh_cn}}}}" | awk -F'[ .@]' '{print tolower($1)}' | sed 's/-/_/' 2>/dev/null)
|
|
||||||
fi
|
|
||||||
|
|
||||||
translate() {
|
|
||||||
# 处理特殊字符
|
|
||||||
local lua_script=$(cat <<LUA
|
|
||||||
require "luci.i18n".setlanguage("$lang")
|
|
||||||
print(require "luci.i18n".translate([==[$1]==]))
|
|
||||||
LUA
|
|
||||||
)
|
|
||||||
|
|
||||||
lua -e "$lua_script"
|
|
||||||
}
|
|
||||||
|
|
||||||
if [ "$1" == "clear_log" ]; then
|
|
||||||
# 清空日志
|
|
||||||
>"${logfile}"
|
|
||||||
|
|
||||||
elif [ "$1" == "child" ]; then
|
|
||||||
shift
|
|
||||||
command_name=$1
|
|
||||||
shift
|
|
||||||
"$command_name" "$@"
|
|
||||||
fi
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
{
|
|
||||||
"admin/control/timecontrol": {
|
|
||||||
"title": "Time Control",
|
|
||||||
"order": 10,
|
|
||||||
"action": {
|
|
||||||
"type": "firstchild"
|
|
||||||
},
|
|
||||||
"acl": [ "read" ],
|
|
||||||
"depends": {
|
|
||||||
"acl": [ "luci-app-timecontrol" ]
|
|
||||||
},
|
|
||||||
"recurse": true
|
|
||||||
},
|
|
||||||
"admin/control/timecontrol/basic": {
|
|
||||||
"title": "Time Control",
|
|
||||||
"order": 10,
|
|
||||||
"action": {
|
|
||||||
"type": "view",
|
|
||||||
"path": "timecontrol/basic"
|
|
||||||
},
|
|
||||||
"acl": [ "read" ]
|
|
||||||
},
|
|
||||||
"admin/control/timecontrol/log": {
|
|
||||||
"title": "Log",
|
|
||||||
"order": 40,
|
|
||||||
"action": {
|
|
||||||
"type": "view",
|
|
||||||
"path": "timecontrol/log"
|
|
||||||
},
|
|
||||||
"acl": [ "read" ]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +1,11 @@
|
|||||||
{
|
{
|
||||||
"luci-app-timecontrol": {
|
"luci-app-timecontrol": {
|
||||||
"description": "Grant UCI Internet time control for luci-app-timecontrol",
|
"description": "Grant UCI access for luci-app-timecontrol",
|
||||||
"read": {
|
"read": {
|
||||||
|
"uci": [ "timecontrol" ]
|
||||||
"ubus": {
|
|
||||||
|
|
||||||
"file": ["exec", "list", "stat", "read"],
|
|
||||||
"uci": [ "*" ],
|
|
||||||
"timecontrol": ["*"]
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"write": {
|
"write": {
|
||||||
"ubus": {
|
"uci": [ "timecontrol" ]
|
||||||
"timecontrol": ["*"],
|
|
||||||
"file": ["write"],
|
|
||||||
"uci": ["*"]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"config": "timecontrol",
|
||||||
|
"init": "timecontrol"
|
||||||
|
}
|
||||||
@@ -15,12 +15,12 @@
|
|||||||
include $(TOPDIR)/rules.mk
|
include $(TOPDIR)/rules.mk
|
||||||
|
|
||||||
PKG_NAME:=luci-app-wwand
|
PKG_NAME:=luci-app-wwand
|
||||||
PKG_RELEASE:=10
|
PKG_RELEASE:=11
|
||||||
|
|
||||||
PKG_SOURCE_PROTO:=git
|
PKG_SOURCE_PROTO:=git
|
||||||
PKG_SOURCE_URL:=https://github.com/ddimension/luci-app-wwand.git
|
PKG_SOURCE_URL:=https://github.com/ddimension/luci-app-wwand.git
|
||||||
PKG_SOURCE_VERSION:=bd773d76198d2a92063b349ed4f1e1f75475857c
|
PKG_SOURCE_VERSION:=6fd28614e5e8d40010119d52b83cf78908e2a7d3
|
||||||
PKG_SOURCE_DATE:=2026-09-05
|
PKG_SOURCE_DATE:=2026-09-06
|
||||||
PKG_MIRROR_HASH:=skip
|
PKG_MIRROR_HASH:=skip
|
||||||
|
|
||||||
PKG_LICENSE:=GPL-2.0-only
|
PKG_LICENSE:=GPL-2.0-only
|
||||||
|
|||||||
@@ -154,6 +154,7 @@
|
|||||||
"/dev/mtdblock[0-9]*": [ "read" ],
|
"/dev/mtdblock[0-9]*": [ "read" ],
|
||||||
"/etc/sysupgrade.conf": [ "read" ],
|
"/etc/sysupgrade.conf": [ "read" ],
|
||||||
"/lib/upgrade/platform.sh": [ "list" ],
|
"/lib/upgrade/platform.sh": [ "list" ],
|
||||||
|
"/proc/[0-9]*/mounts": [ "read" ],
|
||||||
"/proc/mounts": [ "read" ],
|
"/proc/mounts": [ "read" ],
|
||||||
"/proc/mtd": [ "read" ],
|
"/proc/mtd": [ "read" ],
|
||||||
"/proc/partitions": [ "read" ],
|
"/proc/partitions": [ "read" ],
|
||||||
|
|||||||
@@ -18,12 +18,12 @@
|
|||||||
include $(TOPDIR)/rules.mk
|
include $(TOPDIR)/rules.mk
|
||||||
|
|
||||||
PKG_NAME:=luci-proto-wwand
|
PKG_NAME:=luci-proto-wwand
|
||||||
PKG_RELEASE:=6
|
PKG_RELEASE:=7
|
||||||
|
|
||||||
PKG_SOURCE_PROTO:=git
|
PKG_SOURCE_PROTO:=git
|
||||||
PKG_SOURCE_URL:=https://github.com/ddimension/luci-proto-wwand.git
|
PKG_SOURCE_URL:=https://github.com/ddimension/luci-proto-wwand.git
|
||||||
PKG_SOURCE_VERSION:=cc0bbfe522c7bc941a25f1ad14dfb2874660311b
|
PKG_SOURCE_VERSION:=b2cbfa2150034e7037a966e4676f301fcea6ed9e
|
||||||
PKG_SOURCE_DATE:=2026-09-02
|
PKG_SOURCE_DATE:=2026-09-06
|
||||||
PKG_MIRROR_HASH:=skip
|
PKG_MIRROR_HASH:=skip
|
||||||
|
|
||||||
PKG_LICENSE:=GPL-2.0-only
|
PKG_LICENSE:=GPL-2.0-only
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@ include $(TOPDIR)/rules.mk
|
|||||||
|
|
||||||
PKG_NAME:=mptcp
|
PKG_NAME:=mptcp
|
||||||
PKG_VERSION:=6.1
|
PKG_VERSION:=6.1
|
||||||
PKG_RELEASE:=12
|
PKG_RELEASE:=13
|
||||||
|
|
||||||
PKG_MAINTAINER:=Ycarus (Yannick Chabanois) <ycarus@zugaina.org>
|
PKG_MAINTAINER:=Ycarus (Yannick Chabanois) <ycarus@zugaina.org>
|
||||||
PKG_BUILD_DIR := $(BUILD_DIR)/$(PKG_NAME)
|
PKG_BUILD_DIR := $(BUILD_DIR)/$(PKG_NAME)
|
||||||
|
|||||||
@@ -5,18 +5,34 @@
|
|||||||
|
|
||||||
/etc/init.d/mptcp enabled || exit 0
|
/etc/init.d/mptcp enabled || exit 0
|
||||||
|
|
||||||
if [ "$ACTION" = ifupdate -a -n "$IFUPDATE_ADDRESSES" ] && [ -n "$(uci -q get network.$INTERFACE.multipath)" ] && [ "$(uci -q get network.$INTERFACE.multipath)" != "off" ]; then
|
# A DHCPv6 companion "<intf>_6" (wizard IPv6 SLAAC/DHCPv6 option on a DHCP
|
||||||
|
# WAN, #4329) shares its parent's device and is kept at multipath=off so it
|
||||||
|
# never counts as a WAN of its own (status page, MPTCP pages, trackers).
|
||||||
|
# Its events still matter for the shared device: a new SLAAC address must
|
||||||
|
# refresh the MPTCP endpoints, and its ifup must re-run the parent's IPv6
|
||||||
|
# routing (mptcp reload reads the gateway from "<intf>_6" status), so the
|
||||||
|
# multipath decision is keyed on the parent interface instead.
|
||||||
|
MP_INTERFACE="$INTERFACE"
|
||||||
|
case "$INTERFACE" in
|
||||||
|
*_6)
|
||||||
|
if [ "$(uci -q get network.$INTERFACE.proto)" = "dhcpv6" ] && [ -n "$(uci -q get network.${INTERFACE%_6})" ]; then
|
||||||
|
MP_INTERFACE="${INTERFACE%_6}"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
MULTIPATH="$(uci -q get network.$MP_INTERFACE.multipath)"
|
||||||
|
|
||||||
|
if [ "$ACTION" = ifupdate -a -n "$IFUPDATE_ADDRESSES" ] && [ -n "$MULTIPATH" ] && [ "$MULTIPATH" != "off" ]; then
|
||||||
logger -t "mptcp" "New IP ($IFUPDATE_ADDRESSES) for $INTERFACE ($DEVICE)"
|
logger -t "mptcp" "New IP ($IFUPDATE_ADDRESSES) for $INTERFACE ($DEVICE)"
|
||||||
multipath $DEVICE off 2>&1 >/dev/null || exit 0
|
multipath $DEVICE off 2>&1 >/dev/null || exit 0
|
||||||
multipath $DEVICE on 2>&1 >/dev/null || exit 0
|
multipath $DEVICE on 2>&1 >/dev/null || exit 0
|
||||||
elif [ "$ACTION" = ifupdate ] && [ -n "$(uci -q get network.$INTERFACE.multipath)" ] && [ "$(uci -q get network.$INTERFACE.multipath)" != "off" ]; then
|
elif [ "$ACTION" = ifupdate ] && [ -n "$MULTIPATH" ] && [ "$MULTIPATH" != "off" ]; then
|
||||||
logger -t "mptcp" "Update of $INTERFACE ($DEVICE)"
|
logger -t "mptcp" "Update of $INTERFACE ($DEVICE)"
|
||||||
multipath $DEVICE off 2>&1 >/dev/null || exit 0
|
multipath $DEVICE off 2>&1 >/dev/null || exit 0
|
||||||
multipath $DEVICE on 2>&1 >/dev/null || exit 0
|
multipath $DEVICE on 2>&1 >/dev/null || exit 0
|
||||||
elif [ "$ACTION" = ifup -o "$ACTION" = iflink -o "$ACTION" = link-up ] && [ -z "$(echo $DEVICE | grep oip | grep gre)" ] && [ -n "$(uci -q get network.$INTERFACE.multipath)" ] && [ "$(uci -q get network.$INTERFACE.multipath)" != "off" ]; then
|
elif [ "$ACTION" = ifup -o "$ACTION" = iflink -o "$ACTION" = link-up ] && [ -z "$(echo $DEVICE | grep oip | grep gre)" ] && [ -n "$MULTIPATH" ] && [ "$MULTIPATH" != "off" ]; then
|
||||||
logger -t "mptcp" "Reloading mptcp config due to $ACTION of $INTERFACE ($DEVICE)"
|
logger -t "mptcp" "Reloading mptcp config due to $ACTION of $INTERFACE ($DEVICE)"
|
||||||
/etc/init.d/mptcp reload "$DEVICE" >/dev/null || exit 0
|
/etc/init.d/mptcp reload "$DEVICE" >/dev/null || exit 0
|
||||||
elif [ "$ACTION" = ifdown -o "$ACTION" = link-down ]; then
|
elif [ "$ACTION" = ifdown -o "$ACTION" = link-down ]; then
|
||||||
multipath $DEVICE off 2>&1 >/dev/null || exit 0
|
multipath $DEVICE off 2>&1 >/dev/null || exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -190,6 +190,27 @@ interface_max_metric() {
|
|||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# sqm-scripts (cake / htb+fq_codel / hfsc set up by /usr/lib/sqm) and
|
||||||
|
# qos-scripts own the root qdisc of the devices they shape. Replacing it with
|
||||||
|
# fq below silently deleted the egress shaper on every "mptcp reload <device>"
|
||||||
|
# (tracker status change, IP change, ifup hotplug) while SQM's state file still
|
||||||
|
# claimed it was running, so SQM never re-applied it until its next restart:
|
||||||
|
# LuCI and the wizard showed SQM enabled, tc showed plain fq (#4329).
|
||||||
|
# Leave the root qdisc alone whenever another shaper manages this device.
|
||||||
|
_root_qdisc_managed_elsewhere() {
|
||||||
|
local dev="$1" config="$2" state_dir sec
|
||||||
|
[ -n "$dev" ] || return 1
|
||||||
|
state_dir="$(. /etc/sqm/sqm.conf 2>/dev/null; echo "${SQM_STATE_DIR:-/var/run/sqm}")"
|
||||||
|
[ -f "${state_dir}/${dev}.state" ] && return 0
|
||||||
|
# an enabled sqm queue on this device: named after the interface by the
|
||||||
|
# wizard, or an anonymous section created from the LuCI SQM page
|
||||||
|
for sec in $(uci -q show sqm 2>/dev/null | sed -n "s/^sqm\.\([^.]*\)\.interface='${dev}'\$/\1/p"); do
|
||||||
|
[ "$(uci -q get sqm.${sec}.enabled)" = "1" ] && return 0
|
||||||
|
done
|
||||||
|
[ "$(uci -q get qos.${config}.enabled)" = "1" ] && return 0
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
interface_multipath_settings() {
|
interface_multipath_settings() {
|
||||||
local mode iface proto metric ip4table qdisc
|
local mode iface proto metric ip4table qdisc
|
||||||
local config="$1"
|
local config="$1"
|
||||||
@@ -433,7 +454,7 @@ interface_multipath_settings() {
|
|||||||
#ifconfig $iface txqueuelen 1000 > /dev/null 2>&1
|
#ifconfig $iface txqueuelen 1000 > /dev/null 2>&1
|
||||||
ip link set dev $iface txqueuelen 1000 > /dev/null 2>&1
|
ip link set dev $iface txqueuelen 1000 > /dev/null 2>&1
|
||||||
fi
|
fi
|
||||||
tc qdisc replace dev $iface root ${qdisc:-fq} > /dev/null 2>&1
|
_root_qdisc_managed_elsewhere "$iface" "$config" || tc qdisc replace dev $iface root ${qdisc:-fq} > /dev/null 2>&1
|
||||||
fi
|
fi
|
||||||
if [ -z "$gateway" ] && [ -n "$network" ]; then
|
if [ -z "$gateway" ] && [ -n "$network" ]; then
|
||||||
if [ "$uci_route" = "1" ]; then
|
if [ "$uci_route" = "1" ]; then
|
||||||
@@ -469,7 +490,7 @@ interface_multipath_settings() {
|
|||||||
#ifconfig $iface txqueuelen 1000 > /dev/null 2>&1
|
#ifconfig $iface txqueuelen 1000 > /dev/null 2>&1
|
||||||
ip link set dev $iface txqueuelen 1000 > /dev/null 2>&1
|
ip link set dev $iface txqueuelen 1000 > /dev/null 2>&1
|
||||||
fi
|
fi
|
||||||
tc qdisc replace dev $iface root ${qdisc:-fq} > /dev/null 2>&1
|
_root_qdisc_managed_elsewhere "$iface" "$config" || tc qdisc replace dev $iface root ${qdisc:-fq} > /dev/null 2>&1
|
||||||
fi
|
fi
|
||||||
if [ "$(uci -q get openmptcprouter.settings.disable_ipv6)" != "1" ] && [ "$config" != "omr6in4" ]; then
|
if [ "$(uci -q get openmptcprouter.settings.disable_ipv6)" != "1" ] && [ "$config" != "omr6in4" ]; then
|
||||||
# IPv6 Updates:
|
# IPv6 Updates:
|
||||||
|
|||||||
+2
-2
@@ -43,11 +43,11 @@
|
|||||||
include $(TOPDIR)/rules.mk
|
include $(TOPDIR)/rules.mk
|
||||||
|
|
||||||
PKG_NAME:=wwand
|
PKG_NAME:=wwand
|
||||||
PKG_RELEASE:=23
|
PKG_RELEASE:=24
|
||||||
|
|
||||||
PKG_SOURCE_PROTO:=git
|
PKG_SOURCE_PROTO:=git
|
||||||
PKG_SOURCE_URL:=https://github.com/ddimension/wwand.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_SOURCE_DATE:=2026-09-06
|
||||||
PKG_MIRROR_HASH:=skip
|
PKG_MIRROR_HASH:=skip
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user