🌴 Sync 2026-06-17 21:17:50

This commit is contained in:
github-actions[bot] 2026-06-17 21:17:50 +08:00
parent 613096ac36
commit bd9e30ea67
36 changed files with 3856 additions and 3564 deletions

View File

@ -6,8 +6,8 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=headscale
PKG_VERSION:=0.28.0
PKG_RELEASE:=2
PKG_VERSION:=0.29.0
PKG_RELEASE:=3
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://codeload.github.com/juanfont/headscale/tar.gz/v$(PKG_VERSION)?

View File

@ -6,7 +6,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-daede
PKG_VERSION:=1.13.0
PKG_RELEASE:=12
PKG_RELEASE:=13
PKG_MAINTAINER:=kenzok8
PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_NAME)
@ -78,6 +78,9 @@ 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_DIR) $(1)/www/cgi-bin
$(INSTALL_BIN) ./root/www/cgi-bin/daede-sub $(1)/www/cgi-bin/daede-sub
$(INSTALL_DIR) $(1)/www/luci-static/resources/view/daede
$(INSTALL_DATA) ./htdocs/luci-static/resources/view/daede/*.js $(1)/www/luci-static/resources/view/daede/
$(INSTALL_DIR) $(1)/www/luci-static/resources/view/daede/vendor

View File

@ -98,6 +98,7 @@ function parseAirportSection(section) {
name: String(section.name || ''),
source_hash: String(section.source_hash || ''),
group_id: String(section.group_id || ''),
subscription_id: String(section.subscription_id || ''),
node_ids: list(section.node_id)
};
}
@ -109,6 +110,7 @@ function airportSectionValues(record) {
name: String(record.name || ''),
source_hash: String(record.sourceHash || record.source_hash || ''),
group_id: String(record.groupId || record.group_id || ''),
subscription_id: String(record.subscriptionId || record.subscription_id || ''),
node_id: list(record.nodeIds || record.node_ids)
};
}

View File

@ -12,6 +12,7 @@
const FETCHER = '/usr/share/luci-app-daede/fetch-clash-yaml.sh';
const GENERATOR = '/usr/share/luci-app-daede/gen-dae-config.sh';
const SUB_STAGE = '/tmp/daede-sub.txt';
const FETCH_CHUNK_BYTES = 16384;
function decodeHexChunks(chunks) {
@ -130,17 +131,6 @@ function requestDaedCredentials() {
});
}
function uniqueNodeTag(existing, index) {
let n = index + 1;
let tag = 'converted_' + n;
while (existing[tag]) {
n++;
tag = 'converted_' + n;
}
existing[tag] = true;
return tag;
}
function applyUciChanges() {
return uci.save().then(function() {
return uci.changes().then(function(changes) {
@ -165,7 +155,12 @@ function writeAirportRecord(existing, record) {
const sid = existing && existing.sid ? existing.sid : uci.add('daede', 'airport');
const values = airportSync.airportSectionValues(record);
Object.keys(values).forEach(function(key) {
uci.set('daede', sid, key, values[key]);
const v = values[key];
// uci/set rejects an empty list value; clear the option instead.
if (Array.isArray(v) && v.length === 0)
uci.unset('daede', sid, key);
else
uci.set('daede', sid, key, v);
});
return sid;
}
@ -485,114 +480,125 @@ return view.extend({
const existingAirport = currentAirport();
const airportId = existingAirport ? existingAirport.id : airportSync.makeAirportId();
const groupName = airportSync.backendGroupName(airportName.value, 'dae', airportId);
const batchId = Date.now().toString(36);
const foundGroupSection = existingAirport && existingAirport.group_id ? uciSection('dae', existingAirport.group_id) : null;
const foundOldGroup = foundGroupSection && foundGroupSection.name === existingAirport.name ? foundGroupSection : null;
// the group sources this tag; gen-dae-config slugs tag and source the
// same way, so subtag() resolves even for CJK/hyphen names.
const subTag = groupName;
const subUrl = 'file://subscriptions/' + airportId + '.sub';
const subContent = items.map(function(item) { return item.link; }).join('\n') + '\n';
let subSid = existingAirport && existingAirport.subscription_id ? existingAirport.subscription_id : '';
let groupSid = existingAirport && existingAirport.group_id ? existingAirport.group_id : '';
const foundOldSub = (subSid && uciSection('dae', subSid)) || null;
const foundOldGroup = (groupSid && uciSection('dae', groupSid)) || null;
const oldSubSection = foundOldSub ? JSON.parse(JSON.stringify(foundOldSub)) : null;
const oldGroupSection = foundOldGroup ? JSON.parse(JSON.stringify(foundOldGroup)) : null;
const usedTags = {};
const nodesByLink = {};
(uci.sections('dae', 'node') || []).forEach(function(section) {
if (section.tag)
usedTags[section.tag] = true;
if (section.link)
nodesByLink[clashConverter.normalizeLink(section.link)] = section;
});
const oldOwned = existingAirport ? existingAirport.node_ids : [];
const owned = [];
const sources = [];
const created = [];
items.forEach(function(item, index) {
const existingNode = nodesByLink[clashConverter.normalizeLink(item.link)];
if (existingNode) {
sources.push(existingNode.tag);
if (oldOwned.indexOf(existingNode['.name']) >= 0)
owned.push(existingNode['.name']);
return;
}
const sid = uci.add('dae', 'node', airportId + '_node_' + batchId + '_' + (index + 1));
const tag = uniqueNodeTag(usedTags, index);
uci.set('dae', sid, 'tag', tag);
uci.set('dae', sid, 'link', item.link);
uci.set('dae', sid, 'enabled', '1');
sources.push(tag);
owned.push(sid);
created.push(sid);
});
let groupSid = foundOldGroup ? existingAirport.group_id : '';
const oldNodeIds = existingAirport ? existingAirport.node_ids : [];
let subCreated = false;
let groupCreated = false;
if (!groupSid) {
const groupBase = airportId + '_group';
groupSid = groupBase;
let suffix = 2;
while (uci.get('dae', groupSid))
groupSid = groupBase + '_' + suffix++;
uci.add('dae', 'group', groupSid);
groupCreated = true;
}
uci.set('dae', groupSid, 'name', groupName);
uci.set('dae', groupSid, 'policy', 'min_moving_avg');
uci.set('dae', groupSid, 'source', sources);
return applyUciChanges().then(runGenerator).catch(function(error) {
created.forEach(function(sid) { uci.remove('dae', sid); });
if (groupCreated) {
uci.remove('dae', groupSid);
} else if (oldGroupSection) {
const current = uciSection('dae', groupSid) || {};
Object.keys(current).forEach(function(key) {
if (key.charAt(0) !== '.' && !Object.prototype.hasOwnProperty.call(oldGroupSection, key))
uci.unset('dae', groupSid, key);
});
Object.keys(oldGroupSection).forEach(function(key) {
if (key.charAt(0) !== '.')
uci.set('dae', groupSid, key, oldGroupSection[key]);
});
const restoreSection = function(sid, snapshot) {
const current = uciSection('dae', sid) || {};
Object.keys(current).forEach(function(key) {
if (key.charAt(0) !== '.' && !Object.prototype.hasOwnProperty.call(snapshot, key))
uci.unset('dae', sid, key);
});
Object.keys(snapshot).forEach(function(key) {
if (key.charAt(0) !== '.')
uci.set('dae', sid, key, snapshot[key]);
});
};
// 1. stage the converted links and let the backend write the local
// .sub file (mkdir + chmod 0600 — dae rejects loose perms).
return fs.write(SUB_STAGE, subContent).then(function() {
return fs.exec(GENERATOR, [ 'write-sub', airportId ]);
}).then(function(res) {
if (res && res.code !== 0)
throw new Error((res && (res.stderr || res.stdout)) || _('Failed to write subscription file'));
// 2. one file:// subscription for the whole airport
if (!foundOldSub) {
subSid = uci.add('dae', 'subscription', airportId + '_sub');
subCreated = true;
}
return applyUciChanges().then(runGenerator).catch(function() {}).then(function() { throw error; });
uci.set('dae', subSid, 'tag', subTag);
uci.set('dae', subSid, 'url', subUrl);
uci.set('dae', subSid, 'enabled', '1');
// 3. group that sources the subscription
if (!foundOldGroup) {
const groupBase = airportId + '_group';
groupSid = groupBase;
let suffix = 2;
while (uci.get('dae', groupSid))
groupSid = groupBase + '_' + suffix++;
uci.add('dae', 'group', groupSid);
groupCreated = true;
}
uci.set('dae', groupSid, 'name', groupName);
uci.set('dae', groupSid, 'policy', 'min_moving_avg');
uci.set('dae', groupSid, 'source', [ subTag ]);
return applyUciChanges().then(runGenerator).catch(function(error) {
if (subCreated) uci.remove('dae', subSid);
else if (oldSubSection) restoreSection(subSid, oldSubSection);
if (groupCreated) uci.remove('dae', groupSid);
else if (oldGroupSection) restoreSection(groupSid, oldGroupSection);
return applyUciChanges().then(runGenerator).catch(function() {})
.then(function() {
if (subCreated) return fs.exec(GENERATOR, [ 'delete-sub', airportId ]).catch(function() {});
})
.then(function() { throw error; });
});
}).then(function() {
const referencedTags = {};
(uci.sections('dae', 'group') || []).filter(function(group) {
return group['.name'] !== groupSid;
}).forEach(function(group) {
const source = Array.isArray(group.source) ? group.source : (group.source ? [ group.source ] : []);
source.forEach(function(tag) { referencedTags[tag] = true; });
// 4. drop this airport's legacy converted_N manual nodes
oldNodeIds.forEach(function(sid) {
if (uciSection('dae', sid)) uci.remove('dae', sid);
});
const stale = oldOwned.filter(function(sid) {
const section = uciSection('dae', sid);
return owned.indexOf(sid) < 0 && !(section && referencedTags[section.tag]);
});
stale.forEach(function(sid) { uci.remove('dae', sid); });
writeAirportRecord(existingAirport, {
id: airportId,
backend: 'dae',
name: airportName.value.trim(),
sourceHash: state.sourceHash,
groupId: groupSid,
nodeIds: owned
subscriptionId: subSid,
nodeIds: []
});
return applyUciChanges().then(runGenerator);
}).then(function() {
items.forEach(function(item) { item.duplicate = true; item.selected = false; });
return { added: created.length, duplicates: items.length - created.length, failed: 0 };
return { added: items.length, duplicates: 0, failed: 0 };
});
};
const importDaed = function(items) {
let credentials;
let token = '';
let existingAirport = currentAirport();
const existingAirport = currentAirport();
const airportId = existingAirport ? existingAirport.id : airportSync.makeAirportId();
const name = airportName.value.trim();
const managedName = airportSync.backendId(airportId);
const groupName = airportSync.backendGroupName(name, 'daed', airportId);
const batchTag = managedName + Date.now().toString(36);
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;
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');
let before;
const createdIds = [];
let newSubId = '';
let createdGroupId = '';
let groupReady = false;
return requestDaedCredentials().then(function(value) {
const oldSubId = existingAirport ? existingAirport.subscription_id : '';
const oldNodeIds = existingAirport ? existingAirport.node_ids : [];
const dropStage = function() { return fs.exec('/bin/rm', [ '-f', stageFile ]).catch(function() {}); };
return fs.write(stageFile, b64).then(function() {
return requestDaedCredentials();
}).then(function(value) {
credentials = value;
return graphQL(endpoint,
'query Login($username:String!,$password:String!){token(username:$username,password:$password)}',
@ -601,61 +607,33 @@ return view.extend({
token = login.token;
credentials.password = '';
credentials = null;
return graphQL(endpoint, 'query AirportState{nodes(first:10000){edges{id link tag}} groups{id name nodes{id}}}', {}, token);
return graphQL(endpoint, 'query State{groups{id name nodes{id} subscriptions{subscription{id}}}}', {}, token);
}).then(function(dataValue) {
before = dataValue;
const existing = {};
(before.nodes.edges || []).forEach(function(node) {
existing[clashConverter.normalizeLink(node.link)] = node;
});
const fresh = items.filter(function(item) {
const duplicate = existing[clashConverter.normalizeLink(item.link)];
if (duplicate) {
item.duplicate = true;
}
return !duplicate;
});
if (!fresh.length)
return { importNodes: [], fresh: fresh, existing: existing };
// drop this airport's previous subscription first: the new one
// reuses the same tag (group name) and daed enforces unique tags.
if (!oldSubId)
return null;
return graphQL(endpoint, 'mutation RmSub($ids:[ID!]!){removeSubscriptions(ids:$ids)}', { ids: [ oldSubId ] }, token).catch(function() {});
}).then(function() {
// import the whole converted batch as one subscription
return graphQL(endpoint,
'mutation ImportNodes($args:[ImportArgument!]!){importNodes(rollbackError:false,args:$args){link error node{id}}}',
{ args: fresh.map(function(item, index) { return { link: item.link, tag: batchTag + (index + 1) }; }) }, token)
.then(function(value) { value.fresh = fresh; value.existing = existing; return value; });
'mutation Import($a:ImportArgument!){importSubscription(rollbackError:false,arg:$a){sub{id} nodeImportResult{error}}}',
{ a: { link: subUrl, tag: groupName } }, token);
}).then(function(result) {
const rows = result.importNodes || [];
const nodeIds = [];
const ownedIds = [];
const rowByLink = {};
rows.forEach(function(row) {
rowByLink[clashConverter.normalizeLink(row.link)] = row;
});
let failed = rows.filter(function(row) { return !!row.error && row.error !== 'node already exists'; }).length;
items.forEach(function(item) {
const key = clashConverter.normalizeLink(item.link);
const row = rowByLink[key];
const old = result.existing[key];
const id = row && row.node && row.node.id || old && old.id;
if (id)
nodeIds.push(id);
if (row && !row.error && row.node) {
ownedIds.push(row.node.id);
createdIds.push(row.node.id);
}
else if (existingAirport && existingAirport.node_ids.indexOf(id) >= 0)
ownedIds.push(id);
});
if (!nodeIds.length) {
const details = rows.filter(function(row) { return !!row.error; }).map(function(row) {
return row.error;
}).filter(function(error, index, errors) {
return errors.indexOf(error) === index;
}).join('; ');
const sub = result.importSubscription && result.importSubscription.sub;
if (!sub || !sub.id) {
const rows = (result.importSubscription && result.importSubscription.nodeImportResult) || [];
const details = rows.map(function(r) { return r.error; }).filter(Boolean).join('; ');
throw new Error(details || _('No usable nodes were imported'));
}
newSubId = sub.id;
const rows = result.importSubscription.nodeImportResult || [];
const failed = rows.filter(function(r) { return !!r.error && r.error !== 'node already exists'; }).length;
const oldGroup = airportSync.findManagedGroup(before.groups, existingAirport);
const group = oldGroup;
const ensureGroup = group
? Promise.resolve(group.id)
const ensureGroup = oldGroup
? Promise.resolve(oldGroup.id)
: graphQL(endpoint,
'mutation CreateGroup($name:String!,$policy:Policy!){createGroup(name:$name,policy:$policy){id}}',
{ name: groupName, policy: 'min_moving_avg' }, token).then(function(value) {
@ -663,64 +641,65 @@ return view.extend({
return createdGroupId;
});
return ensureGroup.then(function(groupId) {
const rename = group && group.name !== groupName
? graphQL(endpoint, 'mutation RenameGroup($id:ID!,$name:String!){renameGroup(id:$id,name:$name)}', { id: groupId, name: groupName }, token)
const rename = oldGroup && oldGroup.name !== groupName
? graphQL(endpoint, 'mutation Rename($id:ID!,$name:String!){renameGroup(id:$id,name:$name)}', { id: groupId, name: groupName }, token)
: Promise.resolve();
return rename.then(function() {
return graphQL(endpoint, 'mutation SetPolicy($id:ID!,$policy:Policy!){groupSetPolicy(id:$id,policy:$policy)}', { id: groupId, policy: 'min_moving_avg' }, token);
return graphQL(endpoint, 'mutation Pol($id:ID!,$policy:Policy!){groupSetPolicy(id:$id,policy:$policy)}', { id: groupId, policy: 'min_moving_avg' }, token);
}).then(function() {
return graphQL(endpoint, 'mutation AddNodes($id:ID!,$nodeIDs:[ID!]!){groupAddNodes(id:$id,nodeIDs:$nodeIDs)}', { id: groupId, nodeIDs: nodeIds }, token);
// attach the whole subscription to the group
return graphQL(endpoint, 'mutation AddSubs($id:ID!,$ids:[ID!]!){groupAddSubscriptions(id:$id,subscriptionIDs:$ids)}', { id: groupId, ids: [ newSubId ] }, token);
}).then(function() {
groupReady = true;
const oldMembers = group ? group.nodes.map(function(node) { return node.id; }) : [];
const removedMembers = oldMembers.filter(function(id) { return nodeIds.indexOf(id) < 0; });
return (removedMembers.length
? graphQL(endpoint, 'mutation DelNodes($id:ID!,$nodeIDs:[ID!]!){groupDelNodes(id:$id,nodeIDs:$nodeIDs)}', { id: groupId, nodeIDs: removedMembers }, token)
: Promise.resolve()).catch(function() {
failed++;
// scrub the group of its previous members: old manual nodes
// (from the legacy importNodes flow) and any other subscription.
const oldMemberNodes = oldGroup ? (oldGroup.nodes || []).map(function(n) { return n.id; }) : [];
const oldGroupSubs = oldGroup
? (oldGroup.subscriptions || []).map(function(s) { return s.subscription && s.subscription.id; }).filter(function(id) { return id && id !== newSubId; })
: [];
const scrub = [];
if (oldMemberNodes.length)
scrub.push(graphQL(endpoint, 'mutation DelNodes($id:ID!,$nodeIDs:[ID!]!){groupDelNodes(id:$id,nodeIDs:$nodeIDs)}', { id: groupId, nodeIDs: oldMemberNodes }, token).catch(function() {}));
if (oldGroupSubs.length)
scrub.push(graphQL(endpoint, 'mutation DelSubs($id:ID!,$ids:[ID!]!){groupDelSubscriptions(id:$id,subscriptionIDs:$ids)}', { id: groupId, ids: oldGroupSubs }, token).catch(function() {}));
return Promise.all(scrub).then(function() {
// delete this airport's orphaned legacy manual nodes (the old
// subscription was already removed before the import).
if (!oldNodeIds.length)
return null;
return graphQL(endpoint, 'mutation Rm($ids:[ID!]!){removeNodes(ids:$ids)}', { ids: oldNodeIds }, token).catch(function() {});
}).then(function() {
const otherAirportNodes = airportRecords().filter(function(record) {
return record.backend === 'daed' && (!existingAirport || record.id !== existingAirport.id);
}).map(function(record) { return record.node_ids; });
const otherGroupNodes = before.groups.filter(function(other) {
return other.id !== groupId;
}).map(function(other) { return other.nodes.map(function(node) { return node.id; }); });
const stale = airportSync.safeOldNodeIds(existingAirport ? existingAirport.node_ids : [], ownedIds, otherAirportNodes, otherGroupNodes);
return (stale.length
? graphQL(endpoint, 'mutation RemoveNodes($ids:[ID!]!){removeNodes(ids:$ids)}', { ids: stale }, token)
: Promise.resolve()).catch(function() {
failed++;
}).then(function() {
writeAirportRecord(existingAirport, {
id: airportId,
backend: 'daed',
name: name,
sourceHash: state.sourceHash,
groupId: groupId,
nodeIds: ownedIds
});
return applyUciChanges().then(function() {
items.forEach(function(item) { item.duplicate = true; item.selected = false; });
return { added: ownedIds.length, duplicates: items.length - ownedIds.length, failed: failed };
});
writeAirportRecord(existingAirport, {
id: airportId,
backend: 'daed',
name: name,
sourceHash: state.sourceHash,
groupId: groupId,
subscriptionId: newSubId,
nodeIds: []
});
return applyUciChanges().then(function() {
items.forEach(function(item) { item.duplicate = true; item.selected = false; });
return { added: items.length, duplicates: 0, failed: failed };
});
});
});
});
}).catch(function(error) {
if (groupReady || !token || (!createdIds.length && !createdGroupId))
if (groupReady || !token || (!newSubId && !createdGroupId))
throw error;
const cleanup = [];
if (createdIds.length)
cleanup.push(graphQL(endpoint, 'mutation RemoveNodes($ids:[ID!]!){removeNodes(ids:$ids)}', { ids: createdIds }, token));
if (newSubId)
cleanup.push(graphQL(endpoint, 'mutation Rm($ids:[ID!]!){removeSubscriptions(ids:$ids)}', { ids: [ newSubId ] }, token));
if (createdGroupId)
cleanup.push(graphQL(endpoint, 'mutation RemoveGroup($id:ID!){removeGroup(id:$id)}', { id: createdGroupId }, token));
cleanup.push(graphQL(endpoint, 'mutation RmG($id:ID!){removeGroup(id:$id)}', { id: createdGroupId }, token));
return Promise.all(cleanup).catch(function() {}).then(function() { throw error; });
}).finally(function() {
if (credentials)
credentials.password = '';
credentials = null;
token = '';
return dropStage();
});
};

View File

@ -168,7 +168,7 @@ return view.extend({
};
// run a backgrounded script and stream its log into the log pane until it
// logs "done (rc=N)"; then refresh the badges. Result shows inline (no popup).
// logs a final ✓/✗ status line; then refresh the badges. Inline, no popup.
const runJob = function(script, arg, btn, logPath) {
const orig = btn.textContent;
btn.disabled = true;
@ -177,7 +177,7 @@ return view.extend({
const poll = function() {
return L.resolveDefault(fs.read_direct(logPath, 'text'), '').then(function(c) {
if (c) { logPane.textContent = c; logPane.classList.add('show'); }
if (/done \(rc=\d+\)/.test(c)) { refresh(); return; }
if (/[✓✗]/.test(c)) { refresh(); return; }
if (tries++ > 90) return;
return new Promise(function(r) { setTimeout(r, 2000); }).then(poll);
});

View File

@ -59,7 +59,7 @@ import)
fi
rm -f "$tmp" "$IMPORT_B64"
if [ "$rc" = 0 ]; then echo "result: config restored, backend restarted"; else echo "result: import failed"; fi
echo "$(date '+%F %T') done (rc=$rc)"
if [ "$rc" = 0 ]; then echo "$(date '+%F %T') ✓ 完成"; else echo "$(date '+%F %T') ✗ 失败 (rc=$rc)"; fi
) </dev/null >/dev/null 2>&1 &
echo "started in background, see $LOG"
;;

View File

@ -21,6 +21,8 @@ CONFIG_DAE="/etc/dae/config.dae"
TMP_GEN="/tmp/dae-gen.dae"
DAE_BIN="/usr/bin/dae"
DAE_INITD="/etc/init.d/dae"
SUBS_DIR="/etc/dae/subscriptions"
SUB_STAGE="/tmp/daede-sub.txt"
# dae single-quoted strings have no escaping; strip quotes/newlines defensively.
sanitize() {
@ -378,10 +380,36 @@ do_import() {
echo "ok"
}
# Converter writes a batch of share links to a local file that dae reads as a
# file:// subscription, so a converted airport is one subscription instead of
# hundreds of manual nodes. $1=id (airport id), reads links from SUB_STAGE.
# id is restricted to [A-Za-z0-9_] to keep the path inside SUBS_DIR.
do_write_sub() {
local id="$1"
case "$id" in ''|*[!A-Za-z0-9_]*) echo "bad id" >&2; return 2 ;; esac
[ -f "$SUB_STAGE" ] || { echo "no staged data" >&2; return 1; }
mkdir -p "$SUBS_DIR"
# dae rejects subscription/config files readable by group/other (needs <=0640)
cp "$SUB_STAGE" "$SUBS_DIR/$id.sub.tmp" || { echo "write failed" >&2; return 1; }
chmod 0600 "$SUBS_DIR/$id.sub.tmp"
mv "$SUBS_DIR/$id.sub.tmp" "$SUBS_DIR/$id.sub"
rm -f "$SUB_STAGE"
echo "ok"
}
do_delete_sub() {
local id="$1"
case "$id" in ''|*[!A-Za-z0-9_]*) return 0 ;; esac
rm -f "$SUBS_DIR/$id.sub"
echo "ok"
}
# ===================== entry =====================
case "$1" in
generate) generate ;;
import) do_import ;;
*) echo "usage: $0 {generate|import}" >&2; exit 2 ;;
generate) generate ;;
import) do_import ;;
write-sub) do_write_sub "$2" ;;
delete-sub) do_delete_sub "$2" ;;
*) echo "usage: $0 {generate|import|write-sub <id>|delete-sub <id>}" >&2; exit 2 ;;
esac

View File

@ -70,7 +70,7 @@ fi
echo "$(date '+%F %T') updated $DEST ($size bytes)"
fi
fi
echo "$(date '+%F %T') done (rc=$rc)"
if [ "$rc" = 0 ]; then echo "$(date '+%F %T') ✓ 完成"; else echo "$(date '+%F %T') ✗ 失败 (rc=$rc)"; fi
) </dev/null >/dev/null 2>&1 &
echo "started in background, see $LOG"

View File

@ -85,7 +85,7 @@ fi
exit 3
fi
echo "$(date '+%F %T') done (rc=$rc)"
if [ "$rc" = 0 ]; then echo "$(date '+%F %T') ✓ 完成"; else echo "$(date '+%F %T') ✗ 失败 (rc=$rc)"; fi
# luci-app-daede upgrade replaces ACL JSON — reload rpcd so changes apply.
if [ "$PKG" = "luci-app-daede" ] && [ "$rc" = "0" ]; then

View File

@ -42,7 +42,12 @@
"/etc/dae/config.dae": [ "write" ],
"/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" ],
"/usr/share/luci-app-daede/config-backup.sh import": [ "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" ],
"/var/log/dae/dae.log": [ "write" ],
"/var/log/daed/daed.log": [ "write" ],

View File

@ -0,0 +1,25 @@
#!/bin/sh
# 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
# 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.
deny() { printf 'Status: 403 Forbidden\r\nContent-Type: text/plain\r\n\r\nforbidden\n'; exit 0; }
[ "$REMOTE_ADDR" = "127.0.0.1" ] || deny
# token: only [A-Za-z0-9] so the path can't escape /tmp
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 "$f" ] || deny
printf 'Content-Type: text/plain\r\n\r\n'
cat "$f"

View File

@ -184,6 +184,7 @@ const load_balance_strategy = [
];
const outbound_type = [
['rematch', _('Rematch'), _('Rematching routing rules')],
['direct', _('DIRECT') + ' - ' + _('TCP/UDP')],
['http', _('HTTP') + ' - ' + _('TCP')],
['socks5', _('SOCKS5') + ' - ' + _('TCP/UDP')],
@ -281,6 +282,7 @@ const rules_type = [
//['IN-TYPE'],
//['IN-USER'],
//['IN-NAME'],
['REMATCH-NAME'],
['PROCESS-PATH'],
['PROCESS-PATH-REGEX'],
@ -300,7 +302,7 @@ const rules_type = [
const rules_type_allowparms = [
// params only available for types other than
// https://github.com/muink/mihomo/blob/300eb8b12a75504c4bd4a6037d2f6503fd3b347f/rules/parser.go#L12
// https://github.com/muink/mihomo/blob/ea19cda0c9b666aa0fc1b0412ae6fbc0ea9d44e0/rules/parser.go#L12
'GEOIP',
'IP-ASN',
'IP-CIDR',
@ -1192,6 +1194,31 @@ function loadSubRuleGroup(preadds, section_id) {
return this.super('load', section_id);
}
function loadRematchName(preadds, section_id) {
delete this.keylist;
delete this.vallist;
preadds?.forEach((arr) => {
this.value.apply(this, arr);
});
let names = {};
for (const section_type of ['rules', 'subrules'])
uci.sections(this.config, section_type, (res) => {
if (res.enabled !== '0')
try {
const obj = JSON.parse(res?.entry?.trim() || '{}');
for (const p of obj.payload || [])
if (p.type === 'REMATCH-NAME')
names[p.factor] = p.factor;
} catch {}
});
Object.keys(names).forEach((name) => {
this.value(name, name);
});
return this.super('load', section_id);
}
function renderStatus(ElId, isRunning, instance, noGlobal) {
const visible = isRunning && (isRunning.http || isRunning.https);
@ -1797,6 +1824,7 @@ return baseclass.extend({
loadProviderLabel,
loadRulesetLabel,
loadSubRuleGroup,
loadRematchName,
// render
renderStatus,
updateStatus,

View File

@ -602,6 +602,10 @@ function renderListeners(s, uciconfig, isClient) {
/* Extra fields */
if (isClient) {
o = s.taboption('field_general', form.Value, 'routing_mark', _('Routing mark (Fwmark)'));
o.datatype = 'uinteger';
o.editable = true;
o = s.taboption('field_general', hm.ListValue, 'rule', _('Sub rule'),
_('Name of the Sub rule used for inbound matching.'));
o.value.apply(o, ['', _('null')]);

View File

@ -310,7 +310,7 @@ const parseRulesYaml = hm.parseYaml.extend({
if (!entry)
return null;
// key mapping // 2026/01/18
// key mapping // 2026/06/12
let config = {
id: this.id,
label: '%s %s'.format(this.id.slice(0,7), _('(Imported)')),
@ -365,7 +365,7 @@ const parseRulesYaml = hm.parseYaml.extend({
ParseRule(tp, payload, target, params) {
// parse rules
// https://github.com/muink/mihomo/blob/487de9b5482d838acc33b067045a0dc293e35d40/rules/parser.go#L12
// https://github.com/muink/mihomo/blob/ea19cda0c9b666aa0fc1b0412ae6fbc0ea9d44e0/rules/parser.go#L12
// nested ParseRule
let logical_payload, subrule;
@ -427,7 +427,7 @@ const parseRulesYaml = hm.parseYaml.extend({
parseRules(line) {
// parse rules
// https://github.com/muink/mihomo/blob/300eb8b12a75504c4bd4a6037d2f6503fd3b347f/config/config.go#L1038-L1062
// https://github.com/muink/mihomo/blob/ad730ae9744971dfaa75bda20cfc2ffe07eeb59d/config/config.go#L1090-L1118
let [tp, payload, target, params] = this.ParseRulePayload(line, true);
if (!target)
return null; // error: format invalid
@ -561,6 +561,14 @@ function renderPayload(s, total, uciconfig) {
o.depends(Object.fromEntries([[prefix + 'type', /\bPROCESS\b/]]));
initPayload(o, n, 'factor', uciconfig);
o = s.option(form.Value, prefix + 'rematchname', _('Factor') + ` ${n+1}`);
o.value('rematch1');
o.validate = hm.validateAuthUsername;
if (n === 0)
o.depends('type', 'REMATCH-NAME');
o.depends(prefix + 'type', 'REMATCH-NAME');
initPayload(o, n, 'factor', uciconfig);
o = s.option(form.Value, prefix + 'uint', _('Factor') + ` ${n+1}`);
o.datatype = 'uinteger';
if (n === 0)
@ -571,7 +579,7 @@ function renderPayload(s, total, uciconfig) {
o = s.option(form.Value, prefix + 'ip', _('Factor') + ` ${n+1}`);
o.datatype = 'cidr';
if (n === 0) {
o.depends({type: /\b(CIDR|CIDR6)\b/});
o.depends({type: /\bCIDR6?\b/});
o.depends({type: /\bIP-SUFFIX\b/});
}
o.depends(Object.fromEntries([[prefix + 'type', /\bCIDR6?\b/]]));
@ -1386,6 +1394,9 @@ return view.extend({
so.placeholder = '7853';
so.rmempty = false;
so = ss.option(form.Value, 'routing_mark', _('Listen routing mark (Fwmark)'));
so.datatype = 'uinteger'
so = ss.option(form.Flag, 'ipv6', _('IPv6 support'));
so.default = so.enabled;

View File

@ -648,6 +648,9 @@ return view.extend({
'To access the API on a private network from a public website, it must be enabled.'));
so.default = so.enabled;
so = ss.option(form.Value, 'external_controller_routing_mark', _('API routing mark (Fwmark)'));
so.datatype = 'uinteger';
so = ss.option(form.Value, 'external_controller_port', _('API HTTP port'));
so.datatype = 'port';
so.placeholder = '9090';

View File

@ -247,7 +247,7 @@ return view.extend({
so.default = so.enabled;
so.editable = true;
so = ss.taboption('field_general', form.ListValue, 'type', _('Type'));
so = ss.taboption('field_general', form.RichListValue, 'type', _('Type'));
so.default = hm.outbound_type[0][0];
hm.outbound_type.forEach((res) => {
so.value.apply(so, res);
@ -256,12 +256,24 @@ return view.extend({
so = ss.taboption('field_general', form.Value, 'server', _('Server address'));
so.datatype = 'host';
so.rmempty = false;
so.depends({type: 'direct', '!reverse': true});
so.depends({type: /^(rematch|direct)$/, '!reverse': true});
so = ss.taboption('field_general', form.Value, 'port', _('Port'));
so.datatype = 'port';
so.rmempty = false;
so.depends({type: /^(direct|mieru)$/, '!reverse': true});
so.depends({type: /^(rematch|direct|mieru)$/, '!reverse': true});
/* Rematch fields */
so = ss.taboption('field_general', form.ListValue, 'target_rematch_name', _('REMATCH-NAME marking'));
so.load = L.bind(hm.loadRematchName, so, [['', _('-- Please choose --')]]);
so.rmempty = false;
so.depends('type', 'rematch');
so.modalonly = true;
so = ss.taboption('field_general', form.ListValue, 'target_sub_rule', _('Use sub rule'));
so.load = L.bind(hm.loadSubRuleGroup, so, [['', _('-- Please choose --')]]);
so.depends('type', 'rematch');
so.modalonly = true;
/* HTTP / SOCKS fields */
/* hm.validateAuth */
@ -579,6 +591,11 @@ return view.extend({
so.depends('type', 'tuic');
so.modalonly = true;
so = ss.taboption('field_general', form.Flag, 'tuic_fast_open', _('Enable fast open'));
so.default = so.disabled;
so.depends('type', 'tuic');
so.modalonly = true;
so = ss.taboption('field_general', form.Flag, 'tuic_reduce_rtt', _('Enable 0-RTT handshake'),
_('Enable 0-RTT QUIC connection handshake on the client side. This is not impacting much on the performance, as the protocol is fully multiplexed.<br/>' +
'Disabling this is highly recommended, as it is vulnerable to replay attacks.'));
@ -721,18 +738,38 @@ return view.extend({
so.datatype = 'ip4addr(1)';
so.placeholder = '172.16.0.2';
so.rmempty = false;
so.depends('type', 'masque');
so.depends({type: 'masque', masque_network: /^(|h2)$/});
so.modalonly = true;
so = ss.taboption('field_general', form.Value, 'masque_ipv6', _('Local IPv6 address'),
_('The %s address used by local machine in the Cloudflare WARP network.').format('IPv6'));
so.datatype = 'ip6addr(1)';
so.depends('type', 'masque');
so.depends({type: 'masque', masque_network: /^(|h2)$/});
so.modalonly = true;
so = ss.taboption('field_general', form.Value, 'masque_mtu', _('MTU'));
so.datatype = 'range(0,9000)';
so.placeholder = '1280';
so.depends({type: 'masque', masque_network: /^(|h2)$/});
so.modalonly = true;
so = ss.taboption('field_general', form.ListValue, 'masque_network', _('Network'));
so.default = '';
so.value('', _('h3'));
so.value('h3-l4proxy', _('h3-l4proxy'));
so.value('h2', _('h2'));
so.validate = function(section_id, value) {
const udp = this.section.getUIElement(section_id, 'udp').node.querySelector('input');
// Force disabled
if (value === 'h3-l4proxy') {
udp.checked = false;
udp.disabled = true;
} else
udp.removeAttribute('disabled');
return true;
}
so.depends('type', 'masque');
so.modalonly = true;
@ -889,7 +926,8 @@ return view.extend({
hm.congestion_controller.forEach((res) => {
so.value.apply(so, res);
})
so.depends({type: /^(tuic|masque|trusttunnel)$/});
so.depends({type: /^(tuic|trusttunnel)$/});
so.depends({type: 'masque', masque_network: /^(|h3-l4proxy)$/});
so.modalonly = true;
so = ss.taboption('field_general', form.ListValue, 'bbr_profile', _('BBR profile'));
@ -903,7 +941,7 @@ return view.extend({
so = ss.taboption('field_general', form.Flag, 'udp', _('UDP'));
so.default = so.disabled;
so.depends({type: /^(direct|socks5|ss|mieru|vmess|vless|trojan|anytls|trusttunnel|masque|wireguard)$/});
so.depends({type: /^(rematch|direct|socks5|ss|mieru|vmess|vless|trojan|anytls|trusttunnel|masque|wireguard)$/});
so.depends({type: 'snell', snell_version: /^(3|4|5)$/});
so.modalonly = true;
@ -1038,7 +1076,7 @@ return view.extend({
return true;
}
so.depends({type: /^(http|socks5|vmess|vless|trojan|anytls|hysteria|hysteria2|tuic|masque|trusttunnel)$/});
so.depends({type: /^(http|socks5|vmess|vless|trojan|anytls|hysteria|hysteria2|tuic|trusttunnel)$/});
so.modalonly = true;
so = ss.taboption('field_tls', form.Flag, 'tls_disable_sni', _('Disable SNI'),
@ -1081,9 +1119,6 @@ return view.extend({
case 'vless':
def_alpn = ['h3', 'h2', 'http/1.1'];
break;
case 'masque':
def_alpn = ['h2'];
break;
case 'trusttunnel':
def_alpn = ['h3', 'h2'];
break;
@ -1096,7 +1131,7 @@ return view.extend({
return true;
}
so.depends({tls: '1', type: /^(vmess|vless|trojan|anytls|hysteria|hysteria2|tuic|masque|trusttunnel)$/});
so.depends({tls: '1', type: /^(vmess|vless|trojan|anytls|hysteria|hysteria2|tuic|trusttunnel)$/});
so.depends({type: 'ss', plugin: 'shadow-tls'});
so.modalonly = true;
@ -1470,10 +1505,12 @@ return view.extend({
/* Dial fields */
so = ss.taboption('field_dial', form.Flag, 'tfo', _('TFO'));
so.default = so.disabled;
so.depends({type: /^(rematch)$/, '!reverse': true});
so.modalonly = true;
so = ss.taboption('field_dial', form.Flag, 'mptcp', _('mpTCP'));
so.default = so.disabled;
so.depends({type: /^(rematch)$/, '!reverse': true});
so.modalonly = true;
/* Features are implemented in proxy chain
@ -1487,11 +1524,13 @@ return view.extend({
_('Priority: Proxy Node > Global.'));
so.multiple = false;
so.noaliases = true;
so.depends({type: /^(rematch)$/, '!reverse': true});
so.modalonly = true;
so = ss.taboption('field_dial', form.Value, 'routing_mark', _('Routing mark'),
so = ss.taboption('field_dial', form.Value, 'routing_mark', _('Routing mark (Fwmark)'),
_('Priority: Proxy Node > Global.'));
so.datatype = 'uinteger';
so.depends({type: /^(rematch)$/, '!reverse': true});
so.modalonly = true;
so = ss.taboption('field_dial', form.ListValue, 'ip_version', _('IP version'));
@ -1499,6 +1538,7 @@ return view.extend({
hm.ip_version.forEach((res) => {
so.value.apply(so, res);
})
so.depends({type: /^(rematch)$/, '!reverse': true});
so.modalonly = true;
/* Proxy Node END */
@ -1874,7 +1914,7 @@ return view.extend({
so.depends({type: 'inline', '!reverse': true});
so.modalonly = true;
so = ss.taboption('field_override', form.Value, 'override_routing_mark', _('Routing mark'),
so = ss.taboption('field_override', form.Value, 'override_routing_mark', _('Routing mark (Fwmark)'),
_('Priority: Proxy Node > Global.'));
so.datatype = 'uinteger';
so.depends({type: 'inline', '!reverse': true});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -259,11 +259,13 @@ config.tls = {
/* API START */
const api_port = uci.get(uciconf, uciapi, 'external_controller_port');
const api_tls_port = uci.get(uciconf, uciapi, 'external_controller_tls_port');
const api_routing_mark = uci.get(uciconf, uciapi, 'external_controller_routing_mark');
/* API settings */
config["external-controller-cors"] = {
"allow-origins": uci.get(uciconf, uciapi, 'external_controller_cors_allow_origins') || ['*'],
"allow-private-network" : (uci.get(uciconf, uciapi, 'external_controller_cors_allow_private_network') === '0') ? false : true
};
config["external-controller-routing-mark"] = strToInt(api_routing_mark) || null;
config["external-controller"] = api_port ? '[::]:' + api_port : null;
config["external-controller-tls"] = api_tls_port ? '[::]:' + api_tls_port : null;
config["external-doh-server"] = uci.get(uciconf, uciapi, 'external_doh_server');
@ -386,6 +388,7 @@ config.dns = {
enable: true,
"prefer-h3": false,
listen: '[::]:' + (uci.get(uciconf, ucidns, 'dns_port') || '7853'),
"listen-routing-mark": strToInt(uci.get(uciconf, ucidns, 'routing_mark')) || null,
ipv6: (uci.get(uciconf, ucidns, 'ipv6') === '0') ? false : true,
"enhanced-mode": 'redir-host',
"use-hosts": true,
@ -479,14 +482,16 @@ uci.foreach(uciconf, ucinode, (cfg) => {
"routing-mark": strToInt(cfg.routing_mark) || null,
"ip-version": cfg.ip_version,
/* HTTP / SOCKS / Shadowsocks / VMess / VLESS / Trojan / hysteria2 / TUIC / SSH / WireGuard / Masque */
/* Rematch */
"target-rematch-name": cfg.target_rematch_name,
"target-sub-rule": cfg.target_sub_rule,
/* HTTP / SOCKS / Shadowsocks / VMess / VLESS / Trojan / hysteria2 / TUIC / WireGuard / Masque */
username: cfg.username,
uuid: cfg.vmess_uuid || cfg.uuid,
cipher: cfg.vmess_chipher || cfg.shadowsocks_chipher,
password: cfg.shadowsocks_password || cfg.password,
headers: cfg.headers ? json(cfg.headers) : null,
"private-key": cfg.masque_private_key || cfg.wireguard_private_key || cfg.ssh_priv_key,
"public-key": cfg.masque_endpoint_public_key || cfg.wireguard_peer_public_key,
ip: cfg.masque_ip || cfg.wireguard_ip,
ipv6: cfg.masque_ipv6 || cfg.wireguard_ipv6,
mtu: strToInt(cfg.masque_mtu ?? cfg.wireguard_mtu) || null,
@ -565,10 +570,10 @@ uci.foreach(uciconf, ucinode, (cfg) => {
"udp-over-stream": strToBool(cfg.tuic_udp_over_stream),
"udp-over-stream-version": cfg.tuic_udp_over_stream_version,
"max-udp-relay-packet-size": strToInt(cfg.tuic_max_udp_relay_packet_size) || null,
"fast-open": strToBool(cfg.tuic_fast_open),
"reduce-rtt": strToBool(cfg.tuic_reduce_rtt),
"heartbeat-interval": strToInt(cfg.tuic_heartbeat) || null,
"request-timeout": strToInt(cfg.tuic_request_timeout) || null,
// @"fast-open": true,
"max-open-streams": strToInt(cfg.tuic_max_open_streams) || null,
/* Trojan */
@ -591,8 +596,11 @@ uci.foreach(uciconf, ucinode, (cfg) => {
"packet-encoding": cfg.vmess_packet_encoding,
encryption: cfg.vless_encryption === '1' ? cfg.vless_encryption_encryption : null,
/* Masque */
network: cfg.masque_network || null,
/* TrustTunnel */
"health-check": cfg.trusttunnel_health_check === '0' ? false : true,
"health-check": cfg.type === 'trusttunnel' ? (cfg.trusttunnel_health_check === '0' ? false : true) : null,
quic: strToBool(cfg.trusttunnel_quic),
/* WireGuard */
@ -619,6 +627,7 @@ uci.foreach(uciconf, ucinode, (cfg) => {
"udp-over-tcp": strToBool(cfg.uot),
"udp-over-tcp-version": cfg.uot_version,
/* SSH / WireGuard / Masque */
/* TLS fields */
tls: (cfg.type in ['trojan', 'anytls', 'hysteria', 'hysteria2', 'tuic', 'trusttunnel']) ? null : strToBool(cfg.tls),
"disable-sni": strToBool(cfg.tls_disable_sni),
@ -627,7 +636,8 @@ uci.foreach(uciconf, ucinode, (cfg) => {
alpn: cfg.tls_alpn, // Array
"skip-cert-verify": strToBool(cfg.tls_skip_cert_verify),
certificate: cfg.tls_cert_path, // mTLS
"private-key": cfg.tls_key_path, // mTLS
"private-key": cfg.masque_private_key || cfg.wireguard_private_key || cfg.ssh_priv_key || cfg.tls_key_path, // mTLS/SSH/WireGuard/Masque
"public-key": cfg.masque_endpoint_public_key || cfg.wireguard_peer_public_key, // WireGuard/Masque
"client-fingerprint": cfg.tls_client_fingerprint,
"ech-opts": cfg.tls_ech === '1' ? {
enable: true,

View File

@ -217,8 +217,9 @@ export function parseListener(cfg, isClient, label) {
type: cfg.type,
listen: cfg.listen || '::',
port: cfg.port,
port: strToInt(cfg.port),
...(isClient ? {
"routing-mark": strToInt(cfg.routing_mark) || null,
rule: cfg.rule,
proxy: label,
} : {}),

View File

@ -7,10 +7,10 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-gecoosac
PKG_VERSION:=2.2
PKG_RELEASE:=8
PKG_RELEASE:=9
LUCI_TITLE:=LuCI Support for gecoosac
LUCI_DEPENDS:=+luci-compat +gecoosac
LUCI_DEPENDS:=+gecoosac
LUCI_PKGARCH:=all
PKG_LICENSE:=AGPL-3.0-only

View File

@ -0,0 +1,233 @@
'use strict';
'require form';
'require poll';
'require rpc';
'require uci';
'require ui';
'require view';
const DEFAULT_UPLOAD_DIR = '/tmp/gecoosac/upload/';
const DEFAULT_DB_DIR = '/etc/gecoosac/';
const DEFAULT_CRT_FILE = '/etc/gecoosac/tls/gecoosac.crt';
const DEFAULT_KEY_FILE = '/etc/gecoosac/tls/gecoosac.key';
const DEFAULT_PID_DIR = '/var/run/';
const callServiceList = rpc.declare({
object: 'service',
method: 'list',
params: [ 'name' ],
expect: { '': {} }
});
const callClearUpload = rpc.declare({
object: 'luci.gecoosac',
method: 'clear_upload',
expect: { '': {} }
});
function validPort(value, defaultValue) {
const port = Number(value || defaultValue);
return Number.isInteger(port) && port >= 1 && port <= 65535 ? String(port) : defaultValue;
}
function serviceRunning(status) {
const service = status && status.gecoosac;
const instances = service && service.instances;
if (!instances)
return false;
for (const name in instances)
if (instances[name] && instances[name].running)
return true;
return false;
}
function clientHost() {
let host = window.location.hostname;
if (host.indexOf(':') !== -1 && host.charAt(0) !== '[')
host = '[' + host + ']';
return host;
}
function clientUrl() {
const singlePort = uci.get('gecoosac', 'config', 'isonlyoneprot') !== '0';
const https = uci.get('gecoosac', 'config', 'https') === '1';
const port = singlePort
? validPort(uci.get('gecoosac', 'config', 'port'), '60650')
: validPort(uci.get('gecoosac', 'config', 'm_port'), '8080');
return (singlePort || !https ? 'http://' : 'https://') + clientHost() + ':' + port;
}
function renderStatusContent(status) {
const running = serviceRunning(status);
const text = running
? _('The GecoosAC service is running.')
: _('The GecoosAC service is not running.');
const state = E('span', { 'class': running ? 'gecoosac-running' : 'gecoosac-stopped' }, text);
if (!running)
return E('p', {}, state);
return E('p', {}, [
state,
E('button', {
'class': 'cbi-button cbi-button-reload',
'click': function() {
const client = window.open(clientUrl(), '_blank', 'noopener');
if (client)
client.opener = null;
}
}, _('Open the mgmt page'))
]);
}
function updateStatus(status) {
const node = document.getElementById('gecoosac_status');
if (!node)
return;
while (node.firstChild)
node.removeChild(node.firstChild);
node.appendChild(renderStatusContent(status));
}
function clearUploadError(res) {
return res && res.error ? _(res.error) : _('Upload directory was not cleared');
}
return view.extend({
load() {
return Promise.all([
uci.load('gecoosac'),
L.resolveDefault(callServiceList('gecoosac'), {})
]);
},
render(data) {
let m, s, o;
m = new form.Map('gecoosac', _('Gecoos AC'),
_('Batch management Gecoos AP, Default password: admin') + '<br />' +
_('The current AC version %s, only supports AP 7.6 and above.').format('2.2'));
s = m.section(form.TypedSection, 'gecoosac');
s.anonymous = true;
s.render = function() {
poll.add(function() {
return L.resolveDefault(callServiceList('gecoosac'), {}).then(updateStatus);
}, 3);
return E('fieldset', { 'class': 'cbi-section' }, [
E('style', {}, [
'#gecoosac_status .gecoosac-running{color:green}',
'#gecoosac_status .gecoosac-stopped{color:red}',
'#gecoosac_status .cbi-button{margin-left:1em}'
].join('\n')),
E('div', { 'id': 'gecoosac_status' }, renderStatusContent(data[1]))
]);
};
s = m.section(form.TypedSection, 'gecoosac', _('Global Settings'));
s.addremove = false;
s.anonymous = true;
o = s.option(form.Flag, 'enabled', _('Enabled AC'));
o.rmempty = false;
o = s.option(form.Value, 'port', _('Set interface port'));
o.placeholder = '60650';
o.default = '60650';
o.datatype = 'port';
o.rmempty = false;
o = s.option(form.Flag, 'isonlyoneprot', _('Single Port Mode'),
_('Do not enable the independent management port, only use one port for management.'));
o.default = '1';
o.rmempty = false;
o = s.option(form.Value, 'm_port', _('Set management port'));
o.placeholder = '8080';
o.default = '8080';
o.datatype = 'port';
o.depends('isonlyoneprot', '0');
o = s.option(form.Flag, 'https', _('Enable HTTPS service'),
_('Default certificate files are generated when HTTPS starts; custom paths must point to readable files.'));
o.default = '0';
o.depends('isonlyoneprot', '0');
o = s.option(form.Value, 'crt_file', _('Specify crt certificate file'));
o.placeholder = DEFAULT_CRT_FILE;
o.default = DEFAULT_CRT_FILE;
o.datatype = 'file';
o.depends('https', '1');
o = s.option(form.Value, 'key_file', _('Specify key certificate file'));
o.placeholder = DEFAULT_KEY_FILE;
o.default = DEFAULT_KEY_FILE;
o.datatype = 'file';
o.depends('https', '1');
o = s.option(form.Value, 'upload_dir', _('Upload dir path'), _('The path to upload AP upgrade firmware'));
o.placeholder = DEFAULT_UPLOAD_DIR;
o.default = DEFAULT_UPLOAD_DIR;
o.datatype = 'directory';
o.rmempty = false;
o = s.option(form.Value, 'db_dir', _('Database dir path'), _('The path to store the config database'));
o.placeholder = DEFAULT_DB_DIR;
o.default = DEFAULT_DB_DIR;
o.datatype = 'directory';
o.rmempty = false;
o = s.option(form.Value, 'piddir', _('PID dir path'), _('The path to store the AC program pid file'));
o.placeholder = DEFAULT_PID_DIR;
o.default = DEFAULT_PID_DIR;
o.datatype = 'directory';
o.rmempty = false;
o = s.option(form.ListValue, 'lang', _('Language'));
o.value('zh', _('Chinese'));
o.value('en', _('English'));
o.default = 'zh';
o.rmempty = false;
o = s.option(form.Flag, 'debug', _('Debug Mode'));
o.default = '0';
o.rmempty = false;
o = s.option(form.Flag, 'showtip', _('Show IP Tip'),
_('Show the IP 6.7.8.9 setup tip when it is not configured.'));
o.default = '0';
o.rmempty = false;
o = s.option(form.Flag, 'log', _('Enable Log'));
o.default = '0';
o.rmempty = false;
o = s.option(form.Button, '_clear_upload', _('Clear Upload Directory'),
_('Only files under the configured Gecoos upload directory will be removed.'));
o.inputstyle = 'remove';
o.inputtitle = _('Clear');
o.onclick = function() {
if (!confirm(_('Really clear the configured upload directory?')))
return Promise.resolve();
return callClearUpload().then(function(res) {
if (res && res.result === true)
ui.addNotification(null, E('p', {}, _('Upload directory cleared')));
else
ui.addNotification(null, E('p', {}, clearUploadError(res)), 'danger');
});
};
return m.render();
}
});

View File

@ -1,39 +0,0 @@
module("luci.controller.gecoosac", package.seeall)
local fs = require "nixio.fs"
local sys = require "luci.sys"
local uci = require "luci.model.uci"
local ACL_DEPENDS = { "luci-app-gecoosac" }
local function service_running()
if not fs.access("/etc/init.d/gecoosac") then
return false
end
return sys.call("/etc/init.d/gecoosac status >/dev/null 2>&1") == 0
end
function index()
if not fs.access("/etc/config/gecoosac") then
return
end
local page
page = entry({"admin", "services", "gecoosac"}, cbi("gecoosac"), _("Gecoos AC"), 100)
page.dependent = true
page.acl_depends = ACL_DEPENDS
page = entry({"admin", "services", "gecoosac", "status"}, call("act_status"))
page.leaf = true
page.acl_depends = ACL_DEPENDS
end
function act_status()
local cur = uci.cursor()
local enabled = cur:get("gecoosac", "config", "enabled") == "1"
local e = {
enabled = enabled,
running = enabled and service_running()
}
luci.http.prepare_content("application/json")
luci.http.write_json(e)
end

View File

@ -1,197 +0,0 @@
local fs = require "nixio.fs"
local DEFAULT_UPLOAD_DIR = "/tmp/gecoosac/upload/"
local DEFAULT_DB_DIR = "/etc/gecoosac/"
local DEFAULT_CRT_FILE = "/etc/gecoosac/tls/gecoosac.crt"
local DEFAULT_KEY_FILE = "/etc/gecoosac/tls/gecoosac.key"
local DEFAULT_PID_DIR = "/var/run/"
local function trim(value)
return (value or ""):gsub("^%s+", ""):gsub("%s+$", "")
end
local function is_abs_path(value)
return type(value) == "string" and value:match("^/") and not value:find("[%z\r\n]")
end
local function validate_abs_path(self, value)
value = trim(value)
if is_abs_path(value) then
return value
end
return nil, translate("Expecting an absolute path")
end
local function is_upload_path(path)
path = trim(path):gsub("/+$", "")
return path == "/tmp/gecoosac/upload" or path:match("/gecoosac/upload$") ~= nil
end
local function remove_tree(path)
local stat = fs.lstat and fs.lstat(path) or fs.stat(path)
if not stat then
return true
end
if stat.type == "dir" then
local iter = fs.dir(path)
if not iter then
return nil, translatef("Unable to read %s", path)
end
for entry in iter do
if entry ~= "." and entry ~= ".." then
local ok, err = remove_tree(path .. "/" .. entry)
if not ok then
return nil, err
end
end
end
if not fs.rmdir(path) and fs.stat(path, "type") == "dir" then
return nil, translatef("Unable to remove %s", path)
end
else
if not fs.unlink(path) and (fs.lstat and fs.lstat(path) or fs.stat(path)) then
return nil, translatef("Unable to remove %s", path)
end
end
return true
end
local function clear_upload_dir(path)
path = trim(path)
if path == "" then
path = DEFAULT_UPLOAD_DIR
end
path = path:gsub("/+$", "")
if not is_abs_path(path) then
return nil, translate("Expecting an absolute path")
end
local real = fs.realpath(path) or path
real = real:gsub("/+$", "")
if not is_upload_path(real) then
return nil, translate("Only Gecoos upload directories can be cleared")
end
if fs.stat(real, "type") ~= "dir" then
return true
end
local iter = fs.dir(real)
if not iter then
return nil, translatef("Unable to read %s", real)
end
for entry in iter do
if entry ~= "." and entry ~= ".." then
local ok, err = remove_tree(real .. "/" .. entry)
if not ok then
return nil, err
end
end
end
return true
end
if fs.access("/usr/bin/gecoosac") then
m = Map("gecoosac", translate("Gecoos AC"), translate("Batch management Gecoos AP, Default password: admin") .. "<br>" .. translatef("The current AC version %s, only supports AP 7.6 and above.","2.2"))
else
m = Map("gecoosac", translate("Gecoos AC"), translate("Batch management Gecoos AP, Default password: admin") .. "<br>" .. translate("The AC program does not exist, please check."))
end
m:section(SimpleSection).template = "gecoosac/gecoosac_status"
s = m:section(TypedSection, "gecoosac", translate("Global Settings"))
s.addremove = false
s.anonymous = true
enable = s:option(Flag, "enabled", translate("Enabled AC"))
enable.rmempty = false
o = s:option(Value, "port", translate("Set interface port"))
o.placeholder = 60650
o.default = 60650
o.datatype = "port"
o.rmempty = false
o = s:option(Flag, "isonlyoneprot", translate("Single Port Mode"), translate("Do not enable the independent management port, only use one port for management."))
o.default = 1
o.rmempty = false
o = s:option(Value, "m_port", translate("Set management port"))
o.placeholder = 8080
o.default = 8080
o.datatype = "port"
o:depends("isonlyoneprot", false)
o = s:option(Flag, "https", translate("Enable HTTPS service"), translate("Default certificate files are generated when HTTPS starts; custom paths must point to readable files."))
o.default = 0
o:depends("isonlyoneprot", false)
o = s:option(Value, "crt_file", translate("Specify crt certificate file"))
o.placeholder = DEFAULT_CRT_FILE
o.default = DEFAULT_CRT_FILE
o.validate = validate_abs_path
o:depends("https", true)
o = s:option(Value, "key_file", translate("Specify key certificate file"))
o.placeholder = DEFAULT_KEY_FILE
o.default = DEFAULT_KEY_FILE
o.validate = validate_abs_path
o:depends("https", true)
upload_dir = s:option(Value, "upload_dir", translate("Upload dir path"), translate("The path to upload AP upgrade firmware"))
upload_dir.placeholder = DEFAULT_UPLOAD_DIR
upload_dir.default = DEFAULT_UPLOAD_DIR
upload_dir.rmempty = false
upload_dir.validate = validate_abs_path
db_dir = s:option(Value, "db_dir", translate("Database dir path"), translate("The path to store the config database"))
db_dir.placeholder = DEFAULT_DB_DIR
db_dir.default = DEFAULT_DB_DIR
db_dir.rmempty = false
db_dir.validate = validate_abs_path
o = s:option(Value, "piddir", translate("PID dir path"), translate("The path to store the AC program pid file"))
o.placeholder = DEFAULT_PID_DIR
o.default = DEFAULT_PID_DIR
o.rmempty = false
o.validate = validate_abs_path
o = s:option(ListValue, "lang", translate("Language"))
o:value("zh", translate("Chinese"))
o:value("en", translate("English"))
o.default = "zh"
o.rmempty = false
debug = s:option(Flag, "debug", translate("Debug Mode"))
debug.default = 0
debug.rmempty = false
showtip = s:option(Flag, "showtip", translate("Show IP Tip"), translate("Show the IP 6.7.8.9 setup tip when it is not configured."))
showtip.default = 0
showtip.rmempty = false
log = s:option(Flag, "log", translate("Enable Log"))
log.default = 0
log.rmempty = false
clear_upload = s:option(Button, "clear_upload", translate("Clear Upload Directory"), translate("Only files under the configured Gecoos upload directory will be removed."))
clear_upload.inputstyle = "remove"
clear_upload.write = function(self, section)
local path = upload_dir:formvalue(section) or upload_dir:cfgvalue(section) or DEFAULT_UPLOAD_DIR
local ok, err = clear_upload_dir(path)
if not ok then
self.map.message = err or translate("Upload directory was not cleared")
else
self.map.message = translate("Upload directory cleared")
end
end
return m

View File

@ -1,101 +0,0 @@
<%
local uci=require"luci.model.uci".cursor()
local json=require"luci.jsonc"
local function valid_port(value, default)
value = tostring(value or default)
if value:match("^%d+$") then
local port = tonumber(value)
if port and port >= 1 and port <= 65535 then
return tostring(port)
end
end
return default
end
local isonlyoneprot = uci:get("gecoosac", "config", "isonlyoneprot") or "1"
local https = uci:get("gecoosac", "config", "https") or "0"
local port = valid_port(uci:get("gecoosac", "config", "port"), "60650")
local m_port = valid_port(uci:get("gecoosac", "config", "m_port"), "8080")
local http = "http://"
if isonlyoneprot == "0" then
port = m_port
if https == "1" then
http = "https://"
end
end
-%>
<style>
#gecoosac_status .gecoosac-running { color: green; }
#gecoosac_status .gecoosac-stopped { color: red; }
#gecoosac_status .cbi-button { margin-left: 1em; }
.gecoosac-status-text { display: none; }
</style>
<fieldset class="cbi-section">
<p id="gecoosac_status">
<em><%:Collecting data...%></em>
</p>
<span class="gecoosac-status-text" id="gecoosac_text_running"><%:The GecoosAC service is running.%></span>
<span class="gecoosac-status-text" id="gecoosac_text_stopped"><%:The GecoosAC service is not running.%></span>
<span class="gecoosac-status-text" id="gecoosac_text_open"><%:Open the mgmt page%></span>
</fieldset>
<script type="text/javascript">//<![CDATA[
var Port = <%=json.stringify(port)%>;
var Http = <%=json.stringify(http)%>;
var StatusText = {
running: document.getElementById('gecoosac_text_running').textContent,
stopped: document.getElementById('gecoosac_text_stopped').textContent,
open: document.getElementById('gecoosac_text_open').textContent
};
function clearStatus(node) {
while (node.firstChild) {
node.removeChild(node.firstChild);
}
}
function setStatus(node, running) {
var status = document.createElement('span');
status.className = running ? 'gecoosac-running' : 'gecoosac-stopped';
status.textContent = running ? StatusText.running : StatusText.stopped;
clearStatus(node);
node.appendChild(status);
if (running) {
var button = document.createElement('input');
button.className = 'cbi-button cbi-button-reload';
button.type = 'button';
button.value = StatusText.open;
button.onclick = openClient;
node.appendChild(button);
}
}
XHR.poll(3, '<%=url([[admin]], [[services]], [[gecoosac]], [[status]])%>', null,
function(x, data) {
var tb = document.getElementById('gecoosac_status');
if (data && tb) {
setStatus(tb, data.running);
}
}
);
function clientHost() {
var host = window.location.hostname;
if (host.indexOf(':') !== -1 && host.charAt(0) !== '[') {
host = '[' + host + ']';
}
return host;
}
function openClient() {
var url = Http + clientHost() + ":" + Port;
var client = window.open(url, '_blank', 'noopener');
if (client) {
client.opener = null;
}
}
//]]>
</script>

View File

@ -21,9 +21,6 @@ msgstr "批量集中管理集客 AP默认密码admin"
msgid "The current AC version %s, only supports AP 7.6 and above."
msgstr "当前 AC 版本 %s ,仅支持 AP 7.6 及以上版本。"
msgid "The AC program does not exist, please check."
msgstr "AC 程序不存在,请检查。"
msgid "Global Settings"
msgstr "全局设置"
@ -99,15 +96,18 @@ msgstr "集客AC控制器 未运行"
msgid "Open the mgmt page"
msgstr "打开管理页面"
msgid "Collecting data..."
msgstr "收集数据..."
msgid "Clear Upload Directory"
msgstr "清理上传目录"
msgid "Clear"
msgstr "清理"
msgid "Only files under the configured Gecoos upload directory will be removed."
msgstr "只会删除已配置的集客 AC 上传目录中的文件。"
msgid "Really clear the configured upload directory?"
msgstr "确定要清理已配置的上传目录吗?"
msgid "Expecting an absolute path"
msgstr "请输入绝对路径"
@ -120,8 +120,5 @@ msgstr "上传目录未清理"
msgid "Upload directory cleared"
msgstr "上传目录已清理"
msgid "Unable to read %s"
msgstr "无法读取 %s"
msgid "Unable to remove %s"
msgstr "无法删除 %s"
msgid "Unable to remove upload directory contents"
msgstr "无法删除上传目录内容"

View File

@ -0,0 +1,91 @@
#!/bin/sh
. /usr/share/libubox/jshn.sh
DEFAULT_UPLOAD_DIR=/tmp/gecoosac/upload/
json_result() {
local ok="$1"
local message="$2"
local path="$3"
json_init
json_add_boolean result "$ok"
[ -n "$message" ] && json_add_string error "$message"
[ -n "$path" ] && json_add_string path "$path"
json_dump
json_cleanup
}
strip_trailing_slashes() {
local path="$1"
while [ "$path" != "/" ] && [ "${path%/}" != "$path" ]; do
path="${path%/}"
done
printf '%s\n' "$path"
}
safe_upload_path() {
local path
path="$(strip_trailing_slashes "$1")"
[ "$path" = "/tmp/gecoosac/upload" ] && return 0
case "$path" in
*/gecoosac/upload) return 0 ;;
esac
return 1
}
clear_upload() {
local path real entry
path="$(uci -q get gecoosac.config.upload_dir)"
[ -n "$path" ] || path="$DEFAULT_UPLOAD_DIR"
case "$path" in
/*) ;;
*) json_result 0 "Expecting an absolute path"; return ;;
esac
path="$(strip_trailing_slashes "$path")"
real="$(readlink -f "$path" 2>/dev/null)"
[ -n "$real" ] || real="$path"
real="$(strip_trailing_slashes "$real")"
if ! safe_upload_path "$real"; then
json_result 0 "Only Gecoos upload directories can be cleared" "$real"
return
fi
[ -d "$real" ] || { json_result 1 "" "$real"; return; }
for entry in "$real"/* "$real"/.[!.]* "$real"/..?*; do
[ -e "$entry" ] || [ -L "$entry" ] || continue
if ! rm -rf "$entry"; then
json_result 0 "Unable to remove upload directory contents" "$real"
return
fi
done
json_result 1 "" "$real"
}
case "$1" in
list)
printf '{ "clear_upload": {} }'
;;
call)
case "$2" in
clear_upload) clear_upload ;;
*) json_result 0 "Unknown method" ;;
esac
;;
*)
json_result 0 "Usage: luci.gecoosac list|call <method>"
;;
esac

View File

@ -0,0 +1,14 @@
{
"admin/services/gecoosac": {
"title": "Gecoos AC",
"order": 100,
"action": {
"type": "view",
"path": "gecoosac"
},
"depends": {
"acl": [ "luci-app-gecoosac" ],
"uci": { "gecoosac": true }
}
}
}

View File

@ -1,10 +1,16 @@
{
"luci-app-gecoosac": {
"description": "Grant UCI access for luci-app-gecoosac",
"description": "Grant access for luci-app-gecoosac",
"read": {
"ubus": {
"service": [ "list" ]
},
"uci": [ "gecoosac" ]
},
"write": {
"ubus": {
"luci.gecoosac": [ "clear_upload" ]
},
"uci": [ "gecoosac" ]
}
}

View File

@ -2,7 +2,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-run
PKG_VERSION:=1.0.0
PKG_RELEASE:=2
PKG_RELEASE:=3
LUCI_TITLE:=LuCI support for running makeself .run installers
LUCI_DESCRIPTION:=Upload and execute makeself-generated .run installers from LuCI.

View File

@ -5,14 +5,11 @@
'require poll';
// ====================== 纯JSON国际化 · 中英双语 ======================
// 25.12 兼容版:唯一变量名 + 可靠语言检测
const RUN_LANG = (function () {
try {
// 优先从 cookie 获取LuCI 最常用方式)
const m = document.cookie.match(/luci_lang=([a-zA-Z-]+)/);
if (m) return m[1].substring(0, 2).toLowerCase();
// 备选LuCI 环境变量
if (window.L && L.env && L.env.lang)
return L.env.lang.substring(0, 2).toLowerCase();
@ -25,15 +22,15 @@ const RUN_LANG = (function () {
const I18N = {
zh: {
title: "Run安装器",
desc: "在路由器上上传并执行 makeself 生成的 .run 安装包。",
drop_tip: "拖入一个 makeself .run 文件,或从电脑选择。",
choose_file: "选择 .run 文件",
desc: "在路由器上上传并执行 .run 安装包或 .sh 脚本,注意架构务必匹配。",
drop_tip: "拖入一个 .run 或 .sh 文件,或从电脑选择。",
choose_file: "选择 .run 或 .sh 文件",
execute: "执行",
clean_up: "清理",
upload_title: "上传 .run 安装包",
upload_title: "上传安装脚本 (.run / .sh)",
log_title: "执行日志",
clean_done: "临时文件与日志已清理。",
only_run: "仅支持 .run 文件。",
only_run: "仅支持 .run 和 .sh 文件。",
prepare_upload: "准备上传:%s (%s)",
upload_failed: "上传失败。",
uploading: "正在上传 %s%d%%",
@ -47,15 +44,15 @@ const I18N = {
},
en: {
title: "Run Installer",
desc: "Upload and execute a makeself-generated .run package on this router.",
drop_tip: "Drop a makeself .run file here, or choose one from your computer.",
choose_file: "Choose .run file",
desc: "Upload and execute .run packages or .sh scripts on this router, ensuring architecture compatibility.",
drop_tip: "Drop a .run or .sh file here, or choose one from your computer.",
choose_file: "Choose .run or .sh file",
execute: "Execute",
clean_up: "Clean up",
upload_title: "Upload .run installer",
upload_title: "Upload installer script (.run / .sh)",
log_title: "Execution log",
clean_done: "Temporary files and logs were removed.",
only_run: "Only .run files are accepted.",
only_run: "Only .run and .sh files are accepted.",
prepare_upload: "Preparing upload: %s (%s)",
upload_failed: "Upload failed.",
uploading: "Uploading %s: %d%%",
@ -137,7 +134,6 @@ function bufferToBase64(buffer) {
}
return view.extend({
// ====================== 底部按钮正确隐藏 ======================
handleSave: null,
handleReset: null,
handleSaveApply: null,
@ -156,7 +152,7 @@ return view.extend({
var fileInput = E('input', {
'type': 'file',
accept: '.run,application/x-sh,application/octet-stream',
accept: '.run,.sh,application/x-shellscript,application/octet-stream',
style: 'display:none'
});
@ -259,8 +255,7 @@ return view.extend({
},
applyStatus: function (status, state) {
if (!status)
return;
if (!status) return;
if (status.running)
state.textContent = _('running');
@ -271,7 +266,8 @@ return view.extend({
uploadFile: function (file, progress, state, runButton) {
var self = this;
if (!file.name.match(/\.run$/i)) {
// 支持 .run 和 .sh 文件
if (!file.name.match(/\.(run|sh)$/i)) {
ui.addNotification(null, E('p', [_('only_run')]), 'danger');
return Promise.reject();
}
@ -305,30 +301,20 @@ return view.extend({
xhr.setRequestHeader('Content-Type', 'application/octet-stream');
xhr.upload.onprogress = function (ev) {
if (!ev.lengthComputable)
return;
if (!ev.lengthComputable) return;
progress.value = Math.floor(ev.loaded * 100 / ev.total);
state.textContent = _('uploading', file.name, progress.value);
};
xhr.onerror = function () {
// 强制启用按钮
document.querySelector('.cbi-button-action').disabled = false;
reject(new Error(_('upload_err')));
};
xhr.onload = function () {
// ==========================================
// 【直接强制解锁按钮:永远生效】
// ==========================================
document.querySelector('.cbi-button-action').disabled = false;
progress.value = 100;
// 忽略所有返回解析
try { JSON.parse(xhr.responseText); } catch (e) { }
// 直接完成流程
uploadFinish(session.id).then(function () {
state.textContent = _('upload_done', file.name, formatBytes(file.size));
}).catch(function () {
@ -345,8 +331,7 @@ return view.extend({
startRun: function (runButton, state) {
var self = this;
if (!this.currentUploadId)
return;
if (!this.currentUploadId) return;
runButton.disabled = true;
state.textContent = _('starting');
@ -367,8 +352,7 @@ return view.extend({
var self = this;
return readLog(this.logOffset).then(function (res) {
if (!res || res.error)
return;
if (!res || res.error) return;
if (res.data) {
log.textContent += res.data;

View File

@ -223,7 +223,14 @@ run_installer() {
[ -d "$dir" ] || { json_error "Unknown upload id"; return; }
name="$(cat "$dir/name" 2>/dev/null)"
file="$dir/$name"
[ -x "$file" ] || { json_error "Uploaded file is not executable"; return; }
# 确保文件可执行(.run 和 .sh 都需要)
chmod 700 "$file" 2>/dev/null || true
[ -x "$file" ] || {
json_error "Uploaded file is not executable. Please check file permissions."
return
}
read_state
if is_running "$PID"; then

View File

@ -72,8 +72,13 @@ saved_token="$(cat "$dir/token" 2>/dev/null)"
name="$(cat "$dir/name" 2>/dev/null)"
case "$name" in
*.run) ;;
*) fail "Invalid filename" ;;
*.run)
;;
*.sh)
;;
*)
fail "Invalid filename"
;;
esac
length="${CONTENT_LENGTH:-0}"

View File

@ -13,12 +13,12 @@ LUCI_DEPENDS+=$(if $(CONFIG_USE_APK),+apk +luci-compat,+opkg)
LUCI_EXTRA_DEPENDS:=luci-lib-taskd (>=1.0.19)
LUCI_PKGARCH:=all
PKG_VERSION:=0.2.0-r2
PKG_VERSION:=0.2.0-r3
# PKG_RELEASE MUST be empty for luci.mk
PKG_RELEASE:=
ISTORE_UI_VERSION:=0.2.0
ISTORE_UI_RELEASE:=1
ISTORE_UI_RELEASE:=2
PKG_HASH:=skip
PKG_SOURCE_URL_FILE:=v$(ISTORE_UI_VERSION)-$(ISTORE_UI_RELEASE).tar.gz

View File

@ -1,8 +1,8 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=opera-proxy
PKG_VERSION:=1.24.0
PKG_RELEASE:=5
PKG_VERSION:=1.25.0
PKG_RELEASE:=6
PKG_MAINTAINER:=Konstantine Shevlakov <shevlakov@132lan.ru>
PKG_LICENSE:=MIT