Compare commits

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