mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-05 01:33:56 +08:00
🌴 Sync 2026-10-06 02:20:56
This commit is contained in:
1 parent
5c201dd3af
commit
487c4fad50
111 files changed
+4641
-746
No files matched your search
@@ -78,6 +78,74 @@ import { flatten } from '../parser/flatten.uc';
|
||||
*
|
||||
* Returns the { added, removed } counts. No commit here: the caller's
|
||||
* single commit is the boundary of "this run wrote something". */
|
||||
/* --- content identity ---------------------------------------------------- */
|
||||
|
||||
/* A fingerprint of a node's *content*: the same server with the same
|
||||
* credentials and options, whatever it is called.
|
||||
*
|
||||
* Section names are md5(grouphash + label), so a subscription that renames a
|
||||
* node used to make the repository delete the old section and create a new one
|
||||
* - and every reference to it (`main_node`, `main_udp_node`, the urltest lists)
|
||||
* was switched away or dropped along with it, silently. The label is not part
|
||||
* of a node's identity, so it is excluded here along with the section metadata
|
||||
* and the group hash (the group is already the lookup key).
|
||||
*
|
||||
* Keys are sorted so the fingerprint cannot depend on insertion order, and the
|
||||
* values are serialized with %J because UCI options can be lists. */
|
||||
function content_key(flat) {
|
||||
const parts = [];
|
||||
|
||||
for (let k in sort(keys(flat))) {
|
||||
if (k === 'label' || k === 'grouphash' || substr(k, 0, 1) === '.')
|
||||
continue;
|
||||
push(parts, k + '=' + sprintf('%J', flat[k]));
|
||||
}
|
||||
|
||||
return md5(join('\n', parts));
|
||||
}
|
||||
|
||||
/* The section name the orchestrator keyed this node under.
|
||||
*
|
||||
* Normally md5(grouphash + label). When two servers of one subscription share
|
||||
* a label the orchestrator disambiguates the key with the content fingerprint,
|
||||
* and the section has to follow it - otherwise both nodes are written into the
|
||||
* same section and one of them is lost. Returns null for a node the caller did
|
||||
* not put in the cache (the unit tests hand nodes in directly), which keeps the
|
||||
* previous behaviour as the fallback. */
|
||||
function section_name_for(node, cache) {
|
||||
if (!cache)
|
||||
return null;
|
||||
|
||||
for (let key in keys(cache))
|
||||
if (cache[key] === node)
|
||||
return key;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/* group hash -> { content_key: canonical node }, built once and only when a
|
||||
* section was not found by name: a run without renames pays nothing. */
|
||||
function build_content_index(node_cache) {
|
||||
const index = {};
|
||||
|
||||
for (let group in keys(node_cache)) {
|
||||
const group_index = index[group] = {};
|
||||
const done = {};
|
||||
const cache = node_cache[group];
|
||||
|
||||
for (let key in keys(cache)) {
|
||||
const node = cache[key];
|
||||
|
||||
if (done[node])
|
||||
continue;
|
||||
done[node] = true;
|
||||
group_index[content_key(flatten(node))] = node;
|
||||
}
|
||||
}
|
||||
|
||||
return index;
|
||||
}
|
||||
|
||||
function apply_nodes(uci, uciconfig, ucinode, node_cache, node_result, log) {
|
||||
let added = 0, removed = 0;
|
||||
/* Mark the canonical Node objects the foreach loop above has
|
||||
@@ -89,6 +157,9 @@ function apply_nodes(uci, uciconfig, ucinode, node_cache, node_result, log) {
|
||||
* object means a stale string key cannot match a renamed node. */
|
||||
const seen = {};
|
||||
|
||||
/* Built on the first section that is not found by name (see below). */
|
||||
let content_index = null;
|
||||
|
||||
uci.foreach(uciconfig, ucinode, (cfg) => {
|
||||
/* User-created nodes do not have a grouphash. The
|
||||
* repository must never touch them, even if the
|
||||
@@ -103,7 +174,31 @@ function apply_nodes(uci, uciconfig, ucinode, node_cache, node_result, log) {
|
||||
if (!node_cache[cfg.grouphash] || length(node_cache[cfg.grouphash]) === 0)
|
||||
return null;
|
||||
|
||||
const incoming = node_cache[cfg.grouphash][cfg['.name']];
|
||||
let incoming = node_cache[cfg.grouphash][cfg['.name']];
|
||||
|
||||
if (!incoming) {
|
||||
/* Not found by section name: the node may simply have been
|
||||
* renamed upstream. Section names hash the label, so without
|
||||
* this the repository would delete the section and add an
|
||||
* identical one under a new name, taking every reference to it
|
||||
* (`main_node`, `main_udp_node`, urltest members) with it - the
|
||||
* user's node selection silently moves to another server. */
|
||||
if (content_index === null)
|
||||
content_index = build_content_index(node_cache);
|
||||
|
||||
const key = content_key(cfg);
|
||||
|
||||
incoming = content_index[cfg.grouphash][key];
|
||||
|
||||
if (incoming) {
|
||||
/* Claim it: two stored sections with the same content must
|
||||
* not both update themselves into the same node, or the
|
||||
* duplicate would never be pruned. */
|
||||
delete content_index[cfg.grouphash][key];
|
||||
log(sprintf('Node was renamed upstream: %s -> %s; keeping its section and every reference to it.',
|
||||
cfg.label || cfg['.name'], incoming.label || incoming.name));
|
||||
}
|
||||
}
|
||||
|
||||
if (!incoming) {
|
||||
uci.delete(uciconfig, cfg['.name']);
|
||||
@@ -154,7 +249,12 @@ function apply_nodes(uci, uciconfig, ucinode, node_cache, node_result, log) {
|
||||
* identically - four nodes collapsed into one section mixing
|
||||
* several protocols' options. Accept either. */
|
||||
const label = node.label || node.name;
|
||||
const nameHash = md5(node.grouphash + label);
|
||||
|
||||
/* The orchestrator's key is the section name: identical to
|
||||
* md5(grouphash + label) for every node but the ones whose
|
||||
* label is shared by a different server. */
|
||||
const nameHash = section_name_for(node, node_cache[node.grouphash])
|
||||
|| md5(node.grouphash + label);
|
||||
|
||||
/* The one boundary where a canonical Node becomes UCI keys.
|
||||
*
|
||||
@@ -324,8 +424,48 @@ function scrub_stale_urltest_refs(uci, uciconfig, log) {
|
||||
return { changed };
|
||||
}
|
||||
|
||||
/* Remove subscription nodes whose group is no longer configured at all.
|
||||
*
|
||||
* apply_nodes() deliberately leaves a group alone when the fetch produced
|
||||
* nothing for it - that is the transient-blip protection (a failed fetch must
|
||||
* not delete the user's nodes). The same "no cache entry" state also arises
|
||||
* when the user deletes the subscription URL from the configuration, and then
|
||||
* the nodes stayed in /etc/config/homeproxy-pro forever, still compiled into the
|
||||
* sing-box outbounds and urltest pools while the UI showed them as ordinary
|
||||
* nodes. Only the orchestrator knows which URLs are still configured, so the
|
||||
* set is passed in explicitly: a group that is configured but produced no cache
|
||||
* is kept (blip), a group that is not configured is pruned (subscription
|
||||
* removed).
|
||||
*
|
||||
* A section without a grouphash is a user-created node and is never touched.
|
||||
* Returns the number of pruned sections.
|
||||
*/
|
||||
function prune_orphan_nodes(uci, uciconfig, ucinode, configured_groups, log) {
|
||||
let pruned = 0;
|
||||
const configured = {};
|
||||
|
||||
for (let group in configured_groups)
|
||||
configured[group] = true;
|
||||
|
||||
uci.foreach(uciconfig, ucinode, (cfg) => {
|
||||
if (!cfg.grouphash)
|
||||
return null;
|
||||
|
||||
if (configured[cfg.grouphash])
|
||||
return null;
|
||||
|
||||
uci.delete(uciconfig, cfg['.name']);
|
||||
pruned++;
|
||||
log(sprintf('Removing node of a removed subscription: %s.', cfg.label || cfg['.name']));
|
||||
return null;
|
||||
});
|
||||
|
||||
return pruned;
|
||||
}
|
||||
|
||||
export const Repository = {
|
||||
apply_nodes,
|
||||
apply_main_node_refs,
|
||||
scrub_stale_urltest_refs
|
||||
scrub_stale_urltest_refs,
|
||||
prune_orphan_nodes
|
||||
};
|
||||
Reference in new issue
Block a user