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

This commit is contained in:
github-actions[bot] committed 2026-06-17 21:17:50 +08:00
1 parent 7e638c9c4a
commit 0ec700ea8c
36 files changed
+3856 -3564

No files matched your search

+2 -2
View File
@@ -6,8 +6,8 @@
include $(TOPDIR)/rules.mk include $(TOPDIR)/rules.mk
PKG_NAME:=headscale PKG_NAME:=headscale
PKG_VERSION:=0.28.0 PKG_VERSION:=0.29.0
PKG_RELEASE:=2 PKG_RELEASE:=3
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://codeload.github.com/juanfont/headscale/tar.gz/v$(PKG_VERSION)? PKG_SOURCE_URL:=https://codeload.github.com/juanfont/headscale/tar.gz/v$(PKG_VERSION)?
+4 -1
View File
@@ -6,7 +6,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-daede PKG_NAME:=luci-app-daede
PKG_VERSION:=1.13.0 PKG_VERSION:=1.13.0
PKG_RELEASE:=12 PKG_RELEASE:=13
PKG_MAINTAINER:=kenzok8 PKG_MAINTAINER:=kenzok8
PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_NAME) 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/proxy-check.sh $(1)/usr/share/luci-app-daede/proxy-check.sh
$(INSTALL_BIN) ./root/usr/share/luci-app-daede/fetch-clash-yaml.sh $(1)/usr/share/luci-app-daede/fetch-clash-yaml.sh $(INSTALL_BIN) ./root/usr/share/luci-app-daede/fetch-clash-yaml.sh $(1)/usr/share/luci-app-daede/fetch-clash-yaml.sh
$(INSTALL_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_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_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 $(INSTALL_DIR) $(1)/www/luci-static/resources/view/daede/vendor
@@ -98,6 +98,7 @@ function parseAirportSection(section) {
name: String(section.name || ''), name: String(section.name || ''),
source_hash: String(section.source_hash || ''), source_hash: String(section.source_hash || ''),
group_id: String(section.group_id || ''), group_id: String(section.group_id || ''),
subscription_id: String(section.subscription_id || ''),
node_ids: list(section.node_id) node_ids: list(section.node_id)
}; };
} }
@@ -109,6 +110,7 @@ function airportSectionValues(record) {
name: String(record.name || ''), name: String(record.name || ''),
source_hash: String(record.sourceHash || record.source_hash || ''), source_hash: String(record.sourceHash || record.source_hash || ''),
group_id: String(record.groupId || record.group_id || ''), group_id: String(record.groupId || record.group_id || ''),
subscription_id: String(record.subscriptionId || record.subscription_id || ''),
node_id: list(record.nodeIds || record.node_ids) node_id: list(record.nodeIds || record.node_ids)
}; };
} }
@@ -12,6 +12,7 @@
const FETCHER = '/usr/share/luci-app-daede/fetch-clash-yaml.sh'; const FETCHER = '/usr/share/luci-app-daede/fetch-clash-yaml.sh';
const GENERATOR = '/usr/share/luci-app-daede/gen-dae-config.sh'; const GENERATOR = '/usr/share/luci-app-daede/gen-dae-config.sh';
const SUB_STAGE = '/tmp/daede-sub.txt';
const FETCH_CHUNK_BYTES = 16384; const FETCH_CHUNK_BYTES = 16384;
function decodeHexChunks(chunks) { 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() { function applyUciChanges() {
return uci.save().then(function() { return uci.save().then(function() {
return uci.changes().then(function(changes) { 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 sid = existing && existing.sid ? existing.sid : uci.add('daede', 'airport');
const values = airportSync.airportSectionValues(record); const values = airportSync.airportSectionValues(record);
Object.keys(values).forEach(function(key) { 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; return sid;
} }
@@ -485,114 +480,125 @@ return view.extend({
const existingAirport = currentAirport(); const existingAirport = currentAirport();
const airportId = existingAirport ? existingAirport.id : airportSync.makeAirportId(); const airportId = existingAirport ? existingAirport.id : airportSync.makeAirportId();
const groupName = airportSync.backendGroupName(airportName.value, 'dae', airportId); const groupName = airportSync.backendGroupName(airportName.value, 'dae', airportId);
const batchId = Date.now().toString(36); // the group sources this tag; gen-dae-config slugs tag and source the
const foundGroupSection = existingAirport && existingAirport.group_id ? uciSection('dae', existingAirport.group_id) : null; // same way, so subtag() resolves even for CJK/hyphen names.
const foundOldGroup = foundGroupSection && foundGroupSection.name === existingAirport.name ? foundGroupSection : null; 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 oldGroupSection = foundOldGroup ? JSON.parse(JSON.stringify(foundOldGroup)) : null;
const usedTags = {}; const oldNodeIds = existingAirport ? existingAirport.node_ids : [];
const nodesByLink = {}; let subCreated = false;
(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 : '';
let groupCreated = 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) { const restoreSection = function(sid, snapshot) {
created.forEach(function(sid) { uci.remove('dae', sid); }); const current = uciSection('dae', sid) || {};
if (groupCreated) { Object.keys(current).forEach(function(key) {
uci.remove('dae', groupSid); if (key.charAt(0) !== '.' && !Object.prototype.hasOwnProperty.call(snapshot, key))
} else if (oldGroupSection) { uci.unset('dae', sid, key);
const current = uciSection('dae', groupSid) || {}; });
Object.keys(current).forEach(function(key) { Object.keys(snapshot).forEach(function(key) {
if (key.charAt(0) !== '.' && !Object.prototype.hasOwnProperty.call(oldGroupSection, key)) if (key.charAt(0) !== '.')
uci.unset('dae', groupSid, key); uci.set('dae', sid, key, snapshot[key]);
}); });
Object.keys(oldGroupSection).forEach(function(key) { };
if (key.charAt(0) !== '.')
uci.set('dae', groupSid, key, oldGroupSection[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() { }).then(function() {
const referencedTags = {}; // 4. drop this airport's legacy converted_N manual nodes
(uci.sections('dae', 'group') || []).filter(function(group) { oldNodeIds.forEach(function(sid) {
return group['.name'] !== groupSid; if (uciSection('dae', sid)) uci.remove('dae', sid);
}).forEach(function(group) {
const source = Array.isArray(group.source) ? group.source : (group.source ? [ group.source ] : []);
source.forEach(function(tag) { referencedTags[tag] = true; });
}); });
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, { writeAirportRecord(existingAirport, {
id: airportId, id: airportId,
backend: 'dae', backend: 'dae',
name: airportName.value.trim(), name: airportName.value.trim(),
sourceHash: state.sourceHash, sourceHash: state.sourceHash,
groupId: groupSid, groupId: groupSid,
nodeIds: owned subscriptionId: subSid,
nodeIds: []
}); });
return applyUciChanges().then(runGenerator); return applyUciChanges().then(runGenerator);
}).then(function() { }).then(function() {
items.forEach(function(item) { item.duplicate = true; item.selected = false; }); 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) { const importDaed = function(items) {
let credentials; let credentials;
let token = ''; let token = '';
let existingAirport = currentAirport(); const existingAirport = currentAirport();
const airportId = existingAirport ? existingAirport.id : airportSync.makeAirportId(); const airportId = existingAirport ? existingAirport.id : airportSync.makeAirportId();
const name = airportName.value.trim(); const name = airportName.value.trim();
const managedName = airportSync.backendId(airportId);
const groupName = airportSync.backendGroupName(name, 'daed', airportId); const groupName = airportSync.backendGroupName(name, 'daed', airportId);
const batchTag = managedName + Date.now().toString(36);
const endpoint = daedEndpoint(); 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; let before;
const createdIds = []; let newSubId = '';
let createdGroupId = ''; let createdGroupId = '';
let groupReady = false; 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; credentials = value;
return graphQL(endpoint, return graphQL(endpoint,
'query Login($username:String!,$password:String!){token(username:$username,password:$password)}', 'query Login($username:String!,$password:String!){token(username:$username,password:$password)}',
@@ -601,61 +607,33 @@ return view.extend({
token = login.token; token = login.token;
credentials.password = ''; credentials.password = '';
credentials = null; 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) { }).then(function(dataValue) {
before = dataValue; before = dataValue;
const existing = {}; // drop this airport's previous subscription first: the new one
(before.nodes.edges || []).forEach(function(node) { // reuses the same tag (group name) and daed enforces unique tags.
existing[clashConverter.normalizeLink(node.link)] = node; if (!oldSubId)
}); return null;
const fresh = items.filter(function(item) { return graphQL(endpoint, 'mutation RmSub($ids:[ID!]!){removeSubscriptions(ids:$ids)}', { ids: [ oldSubId ] }, token).catch(function() {});
const duplicate = existing[clashConverter.normalizeLink(item.link)]; }).then(function() {
if (duplicate) { // import the whole converted batch as one subscription
item.duplicate = true;
}
return !duplicate;
});
if (!fresh.length)
return { importNodes: [], fresh: fresh, existing: existing };
return graphQL(endpoint, return graphQL(endpoint,
'mutation ImportNodes($args:[ImportArgument!]!){importNodes(rollbackError:false,args:$args){link error node{id}}}', 'mutation Import($a:ImportArgument!){importSubscription(rollbackError:false,arg:$a){sub{id} nodeImportResult{error}}}',
{ args: fresh.map(function(item, index) { return { link: item.link, tag: batchTag + (index + 1) }; }) }, token) { a: { link: subUrl, tag: groupName } }, token);
.then(function(value) { value.fresh = fresh; value.existing = existing; return value; });
}).then(function(result) { }).then(function(result) {
const rows = result.importNodes || []; const sub = result.importSubscription && result.importSubscription.sub;
const nodeIds = []; if (!sub || !sub.id) {
const ownedIds = []; const rows = (result.importSubscription && result.importSubscription.nodeImportResult) || [];
const rowByLink = {}; const details = rows.map(function(r) { return r.error; }).filter(Boolean).join('; ');
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('; ');
throw new Error(details || _('No usable nodes were imported')); 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 oldGroup = airportSync.findManagedGroup(before.groups, existingAirport);
const group = oldGroup; const ensureGroup = oldGroup
const ensureGroup = group ? Promise.resolve(oldGroup.id)
? Promise.resolve(group.id)
: graphQL(endpoint, : graphQL(endpoint,
'mutation CreateGroup($name:String!,$policy:Policy!){createGroup(name:$name,policy:$policy){id}}', 'mutation CreateGroup($name:String!,$policy:Policy!){createGroup(name:$name,policy:$policy){id}}',
{ name: groupName, policy: 'min_moving_avg' }, token).then(function(value) { { name: groupName, policy: 'min_moving_avg' }, token).then(function(value) {
@@ -663,64 +641,65 @@ return view.extend({
return createdGroupId; return createdGroupId;
}); });
return ensureGroup.then(function(groupId) { return ensureGroup.then(function(groupId) {
const rename = group && group.name !== groupName const rename = oldGroup && oldGroup.name !== groupName
? graphQL(endpoint, 'mutation RenameGroup($id:ID!,$name:String!){renameGroup(id:$id,name:$name)}', { id: groupId, name: groupName }, token) ? graphQL(endpoint, 'mutation Rename($id:ID!,$name:String!){renameGroup(id:$id,name:$name)}', { id: groupId, name: groupName }, token)
: Promise.resolve(); : Promise.resolve();
return rename.then(function() { 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() { }).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() { }).then(function() {
groupReady = true; groupReady = true;
const oldMembers = group ? group.nodes.map(function(node) { return node.id; }) : []; // scrub the group of its previous members: old manual nodes
const removedMembers = oldMembers.filter(function(id) { return nodeIds.indexOf(id) < 0; }); // (from the legacy importNodes flow) and any other subscription.
return (removedMembers.length const oldMemberNodes = oldGroup ? (oldGroup.nodes || []).map(function(n) { return n.id; }) : [];
? graphQL(endpoint, 'mutation DelNodes($id:ID!,$nodeIDs:[ID!]!){groupDelNodes(id:$id,nodeIDs:$nodeIDs)}', { id: groupId, nodeIDs: removedMembers }, token) const oldGroupSubs = oldGroup
: Promise.resolve()).catch(function() { ? (oldGroup.subscriptions || []).map(function(s) { return s.subscription && s.subscription.id; }).filter(function(id) { return id && id !== newSubId; })
failed++; : [];
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() { }).then(function() {
const otherAirportNodes = airportRecords().filter(function(record) { writeAirportRecord(existingAirport, {
return record.backend === 'daed' && (!existingAirport || record.id !== existingAirport.id); id: airportId,
}).map(function(record) { return record.node_ids; }); backend: 'daed',
const otherGroupNodes = before.groups.filter(function(other) { name: name,
return other.id !== groupId; sourceHash: state.sourceHash,
}).map(function(other) { return other.nodes.map(function(node) { return node.id; }); }); groupId: groupId,
const stale = airportSync.safeOldNodeIds(existingAirport ? existingAirport.node_ids : [], ownedIds, otherAirportNodes, otherGroupNodes); subscriptionId: newSubId,
return (stale.length nodeIds: []
? graphQL(endpoint, 'mutation RemoveNodes($ids:[ID!]!){removeNodes(ids:$ids)}', { ids: stale }, token) });
: Promise.resolve()).catch(function() { return applyUciChanges().then(function() {
failed++; items.forEach(function(item) { item.duplicate = true; item.selected = false; });
}).then(function() { return { added: items.length, duplicates: 0, failed: failed };
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 };
});
}); });
}); });
}); });
}); });
}).catch(function(error) { }).catch(function(error) {
if (groupReady || !token || (!createdIds.length && !createdGroupId)) if (groupReady || !token || (!newSubId && !createdGroupId))
throw error; throw error;
const cleanup = []; const cleanup = [];
if (createdIds.length) if (newSubId)
cleanup.push(graphQL(endpoint, 'mutation RemoveNodes($ids:[ID!]!){removeNodes(ids:$ids)}', { ids: createdIds }, token)); cleanup.push(graphQL(endpoint, 'mutation Rm($ids:[ID!]!){removeSubscriptions(ids:$ids)}', { ids: [ newSubId ] }, token));
if (createdGroupId) 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; }); return Promise.all(cleanup).catch(function() {}).then(function() { throw error; });
}).finally(function() { }).finally(function() {
if (credentials) if (credentials)
credentials.password = ''; credentials.password = '';
credentials = null; credentials = null;
token = ''; token = '';
return dropStage();
}); });
}; };
@@ -168,7 +168,7 @@ return view.extend({
}; };
// run a backgrounded script and stream its log into the log pane until it // 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 runJob = function(script, arg, btn, logPath) {
const orig = btn.textContent; const orig = btn.textContent;
btn.disabled = true; btn.disabled = true;
@@ -177,7 +177,7 @@ return view.extend({
const poll = function() { const poll = function() {
return L.resolveDefault(fs.read_direct(logPath, 'text'), '').then(function(c) { return L.resolveDefault(fs.read_direct(logPath, 'text'), '').then(function(c) {
if (c) { logPane.textContent = c; logPane.classList.add('show'); } 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; if (tries++ > 90) return;
return new Promise(function(r) { setTimeout(r, 2000); }).then(poll); return new Promise(function(r) { setTimeout(r, 2000); }).then(poll);
}); });
@@ -59,7 +59,7 @@ import)
fi fi
rm -f "$tmp" "$IMPORT_B64" rm -f "$tmp" "$IMPORT_B64"
if [ "$rc" = 0 ]; then echo "result: config restored, backend restarted"; else echo "result: import failed"; fi 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 & ) </dev/null >/dev/null 2>&1 &
echo "started in background, see $LOG" echo "started in background, see $LOG"
;; ;;
@@ -21,6 +21,8 @@ CONFIG_DAE="/etc/dae/config.dae"
TMP_GEN="/tmp/dae-gen.dae" TMP_GEN="/tmp/dae-gen.dae"
DAE_BIN="/usr/bin/dae" DAE_BIN="/usr/bin/dae"
DAE_INITD="/etc/init.d/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. # dae single-quoted strings have no escaping; strip quotes/newlines defensively.
sanitize() { sanitize() {
@@ -378,10 +380,36 @@ do_import() {
echo "ok" 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 ===================== # ===================== entry =====================
case "$1" in case "$1" in
generate) generate ;; generate) generate ;;
import) do_import ;; import) do_import ;;
*) echo "usage: $0 {generate|import}" >&2; exit 2 ;; 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 esac
@@ -70,7 +70,7 @@ fi
echo "$(date '+%F %T') updated $DEST ($size bytes)" echo "$(date '+%F %T') updated $DEST ($size bytes)"
fi fi
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 & ) </dev/null >/dev/null 2>&1 &
echo "started in background, see $LOG" echo "started in background, see $LOG"
@@ -85,7 +85,7 @@ fi
exit 3 exit 3
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
# luci-app-daede upgrade replaces ACL JSON — reload rpcd so changes apply. # luci-app-daede upgrade replaces ACL JSON — reload rpcd so changes apply.
if [ "$PKG" = "luci-app-daede" ] && [ "$rc" = "0" ]; then if [ "$PKG" = "luci-app-daede" ] && [ "$rc" = "0" ]; then
@@ -42,7 +42,12 @@
"/etc/dae/config.dae": [ "write" ], "/etc/dae/config.dae": [ "write" ],
"/tmp/dae-validate.dae": [ "write" ], "/tmp/dae-validate.dae": [ "write" ],
"/tmp/daede-import.b64": [ "write" ], "/tmp/daede-import.b64": [ "write" ],
"/tmp/daede-sub.txt": [ "write" ],
"/tmp/daede-daedsub-*": [ "write" ],
"/bin/rm -f /tmp/daede-daedsub-*": [ "exec" ],
"/usr/share/luci-app-daede/config-backup.sh import": [ "exec" ], "/usr/share/luci-app-daede/config-backup.sh import": [ "exec" ],
"/usr/share/luci-app-daede/gen-dae-config.sh write-sub *": [ "exec" ],
"/usr/share/luci-app-daede/gen-dae-config.sh delete-sub *": [ "exec" ],
"/etc/config/daede": [ "write" ], "/etc/config/daede": [ "write" ],
"/var/log/dae/dae.log": [ "write" ], "/var/log/dae/dae.log": [ "write" ],
"/var/log/daed/daed.log": [ "write" ], "/var/log/daed/daed.log": [ "write" ],
+25
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"
@@ -184,6 +184,7 @@ const load_balance_strategy = [
]; ];
const outbound_type = [ const outbound_type = [
['rematch', _('Rematch'), _('Rematching routing rules')],
['direct', _('DIRECT') + ' - ' + _('TCP/UDP')], ['direct', _('DIRECT') + ' - ' + _('TCP/UDP')],
['http', _('HTTP') + ' - ' + _('TCP')], ['http', _('HTTP') + ' - ' + _('TCP')],
['socks5', _('SOCKS5') + ' - ' + _('TCP/UDP')], ['socks5', _('SOCKS5') + ' - ' + _('TCP/UDP')],
@@ -281,6 +282,7 @@ const rules_type = [
//['IN-TYPE'], //['IN-TYPE'],
//['IN-USER'], //['IN-USER'],
//['IN-NAME'], //['IN-NAME'],
['REMATCH-NAME'],
['PROCESS-PATH'], ['PROCESS-PATH'],
['PROCESS-PATH-REGEX'], ['PROCESS-PATH-REGEX'],
@@ -300,7 +302,7 @@ const rules_type = [
const rules_type_allowparms = [ const rules_type_allowparms = [
// params only available for types other than // 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', 'GEOIP',
'IP-ASN', 'IP-ASN',
'IP-CIDR', 'IP-CIDR',
@@ -1192,6 +1194,31 @@ function loadSubRuleGroup(preadds, section_id) {
return this.super('load', 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) { function renderStatus(ElId, isRunning, instance, noGlobal) {
const visible = isRunning && (isRunning.http || isRunning.https); const visible = isRunning && (isRunning.http || isRunning.https);
@@ -1797,6 +1824,7 @@ return baseclass.extend({
loadProviderLabel, loadProviderLabel,
loadRulesetLabel, loadRulesetLabel,
loadSubRuleGroup, loadSubRuleGroup,
loadRematchName,
// render // render
renderStatus, renderStatus,
updateStatus, updateStatus,
@@ -602,6 +602,10 @@ function renderListeners(s, uciconfig, isClient) {
/* Extra fields */ /* Extra fields */
if (isClient) { 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'), o = s.taboption('field_general', hm.ListValue, 'rule', _('Sub rule'),
_('Name of the Sub rule used for inbound matching.')); _('Name of the Sub rule used for inbound matching.'));
o.value.apply(o, ['', _('null')]); o.value.apply(o, ['', _('null')]);
@@ -310,7 +310,7 @@ const parseRulesYaml = hm.parseYaml.extend({
if (!entry) if (!entry)
return null; return null;
// key mapping // 2026/01/18 // key mapping // 2026/06/12
let config = { let config = {
id: this.id, id: this.id,
label: '%s %s'.format(this.id.slice(0,7), _('(Imported)')), label: '%s %s'.format(this.id.slice(0,7), _('(Imported)')),
@@ -365,7 +365,7 @@ const parseRulesYaml = hm.parseYaml.extend({
ParseRule(tp, payload, target, params) { ParseRule(tp, payload, target, params) {
// parse rules // 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 // nested ParseRule
let logical_payload, subrule; let logical_payload, subrule;
@@ -427,7 +427,7 @@ const parseRulesYaml = hm.parseYaml.extend({
parseRules(line) { parseRules(line) {
// parse rules // 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); let [tp, payload, target, params] = this.ParseRulePayload(line, true);
if (!target) if (!target)
return null; // error: format invalid return null; // error: format invalid
@@ -561,6 +561,14 @@ function renderPayload(s, total, uciconfig) {
o.depends(Object.fromEntries([[prefix + 'type', /\bPROCESS\b/]])); o.depends(Object.fromEntries([[prefix + 'type', /\bPROCESS\b/]]));
initPayload(o, n, 'factor', uciconfig); 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 = s.option(form.Value, prefix + 'uint', _('Factor') + ` ${n+1}`);
o.datatype = 'uinteger'; o.datatype = 'uinteger';
if (n === 0) if (n === 0)
@@ -571,7 +579,7 @@ function renderPayload(s, total, uciconfig) {
o = s.option(form.Value, prefix + 'ip', _('Factor') + ` ${n+1}`); o = s.option(form.Value, prefix + 'ip', _('Factor') + ` ${n+1}`);
o.datatype = 'cidr'; o.datatype = 'cidr';
if (n === 0) { if (n === 0) {
o.depends({type: /\b(CIDR|CIDR6)\b/}); o.depends({type: /\bCIDR6?\b/});
o.depends({type: /\bIP-SUFFIX\b/}); o.depends({type: /\bIP-SUFFIX\b/});
} }
o.depends(Object.fromEntries([[prefix + 'type', /\bCIDR6?\b/]])); o.depends(Object.fromEntries([[prefix + 'type', /\bCIDR6?\b/]]));
@@ -1386,6 +1394,9 @@ return view.extend({
so.placeholder = '7853'; so.placeholder = '7853';
so.rmempty = false; 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 = ss.option(form.Flag, 'ipv6', _('IPv6 support'));
so.default = so.enabled; so.default = so.enabled;
@@ -648,6 +648,9 @@ return view.extend({
'To access the API on a private network from a public website, it must be enabled.')); 'To access the API on a private network from a public website, it must be enabled.'));
so.default = so.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 = ss.option(form.Value, 'external_controller_port', _('API HTTP port'));
so.datatype = 'port'; so.datatype = 'port';
so.placeholder = '9090'; so.placeholder = '9090';
@@ -247,7 +247,7 @@ return view.extend({
so.default = so.enabled; so.default = so.enabled;
so.editable = true; 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]; so.default = hm.outbound_type[0][0];
hm.outbound_type.forEach((res) => { hm.outbound_type.forEach((res) => {
so.value.apply(so, res); so.value.apply(so, res);
@@ -256,12 +256,24 @@ return view.extend({
so = ss.taboption('field_general', form.Value, 'server', _('Server address')); so = ss.taboption('field_general', form.Value, 'server', _('Server address'));
so.datatype = 'host'; so.datatype = 'host';
so.rmempty = false; 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 = ss.taboption('field_general', form.Value, 'port', _('Port'));
so.datatype = 'port'; so.datatype = 'port';
so.rmempty = false; 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 */ /* HTTP / SOCKS fields */
/* hm.validateAuth */ /* hm.validateAuth */
@@ -579,6 +591,11 @@ return view.extend({
so.depends('type', 'tuic'); so.depends('type', 'tuic');
so.modalonly = true; 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'), 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/>' + _('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.')); 'Disabling this is highly recommended, as it is vulnerable to replay attacks.'));
@@ -721,18 +738,38 @@ return view.extend({
so.datatype = 'ip4addr(1)'; so.datatype = 'ip4addr(1)';
so.placeholder = '172.16.0.2'; so.placeholder = '172.16.0.2';
so.rmempty = false; so.rmempty = false;
so.depends('type', 'masque'); so.depends({type: 'masque', masque_network: /^(|h2)$/});
so.modalonly = true; so.modalonly = true;
so = ss.taboption('field_general', form.Value, 'masque_ipv6', _('Local IPv6 address'), 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')); _('The %s address used by local machine in the Cloudflare WARP network.').format('IPv6'));
so.datatype = 'ip6addr(1)'; so.datatype = 'ip6addr(1)';
so.depends('type', 'masque'); so.depends({type: 'masque', masque_network: /^(|h2)$/});
so.modalonly = true; so.modalonly = true;
so = ss.taboption('field_general', form.Value, 'masque_mtu', _('MTU')); so = ss.taboption('field_general', form.Value, 'masque_mtu', _('MTU'));
so.datatype = 'range(0,9000)'; so.datatype = 'range(0,9000)';
so.placeholder = '1280'; 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.depends('type', 'masque');
so.modalonly = true; so.modalonly = true;
@@ -889,7 +926,8 @@ return view.extend({
hm.congestion_controller.forEach((res) => { hm.congestion_controller.forEach((res) => {
so.value.apply(so, 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.modalonly = true;
so = ss.taboption('field_general', form.ListValue, 'bbr_profile', _('BBR profile')); 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 = ss.taboption('field_general', form.Flag, 'udp', _('UDP'));
so.default = so.disabled; 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.depends({type: 'snell', snell_version: /^(3|4|5)$/});
so.modalonly = true; so.modalonly = true;
@@ -1038,7 +1076,7 @@ return view.extend({
return true; 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.modalonly = true;
so = ss.taboption('field_tls', form.Flag, 'tls_disable_sni', _('Disable SNI'), so = ss.taboption('field_tls', form.Flag, 'tls_disable_sni', _('Disable SNI'),
@@ -1081,9 +1119,6 @@ return view.extend({
case 'vless': case 'vless':
def_alpn = ['h3', 'h2', 'http/1.1']; def_alpn = ['h3', 'h2', 'http/1.1'];
break; break;
case 'masque':
def_alpn = ['h2'];
break;
case 'trusttunnel': case 'trusttunnel':
def_alpn = ['h3', 'h2']; def_alpn = ['h3', 'h2'];
break; break;
@@ -1096,7 +1131,7 @@ return view.extend({
return true; 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.depends({type: 'ss', plugin: 'shadow-tls'});
so.modalonly = true; so.modalonly = true;
@@ -1470,10 +1505,12 @@ return view.extend({
/* Dial fields */ /* Dial fields */
so = ss.taboption('field_dial', form.Flag, 'tfo', _('TFO')); so = ss.taboption('field_dial', form.Flag, 'tfo', _('TFO'));
so.default = so.disabled; so.default = so.disabled;
so.depends({type: /^(rematch)$/, '!reverse': true});
so.modalonly = true; so.modalonly = true;
so = ss.taboption('field_dial', form.Flag, 'mptcp', _('mpTCP')); so = ss.taboption('field_dial', form.Flag, 'mptcp', _('mpTCP'));
so.default = so.disabled; so.default = so.disabled;
so.depends({type: /^(rematch)$/, '!reverse': true});
so.modalonly = true; so.modalonly = true;
/* Features are implemented in proxy chain /* Features are implemented in proxy chain
@@ -1487,11 +1524,13 @@ return view.extend({
_('Priority: Proxy Node > Global.')); _('Priority: Proxy Node > Global.'));
so.multiple = false; so.multiple = false;
so.noaliases = true; so.noaliases = true;
so.depends({type: /^(rematch)$/, '!reverse': true});
so.modalonly = 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.')); _('Priority: Proxy Node > Global.'));
so.datatype = 'uinteger'; so.datatype = 'uinteger';
so.depends({type: /^(rematch)$/, '!reverse': true});
so.modalonly = true; so.modalonly = true;
so = ss.taboption('field_dial', form.ListValue, 'ip_version', _('IP version')); so = ss.taboption('field_dial', form.ListValue, 'ip_version', _('IP version'));
@@ -1499,6 +1538,7 @@ return view.extend({
hm.ip_version.forEach((res) => { hm.ip_version.forEach((res) => {
so.value.apply(so, res); so.value.apply(so, res);
}) })
so.depends({type: /^(rematch)$/, '!reverse': true});
so.modalonly = true; so.modalonly = true;
/* Proxy Node END */ /* Proxy Node END */
@@ -1874,7 +1914,7 @@ return view.extend({
so.depends({type: 'inline', '!reverse': true}); so.depends({type: 'inline', '!reverse': true});
so.modalonly = 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.')); _('Priority: Proxy Node > Global.'));
so.datatype = 'uinteger'; so.datatype = 'uinteger';
so.depends({type: 'inline', '!reverse': true}); 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
@@ -259,11 +259,13 @@ config.tls = {
/* API START */ /* API START */
const api_port = uci.get(uciconf, uciapi, 'external_controller_port'); const api_port = uci.get(uciconf, uciapi, 'external_controller_port');
const api_tls_port = uci.get(uciconf, uciapi, 'external_controller_tls_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 */ /* API settings */
config["external-controller-cors"] = { config["external-controller-cors"] = {
"allow-origins": uci.get(uciconf, uciapi, 'external_controller_cors_allow_origins') || ['*'], "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 "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"] = api_port ? '[::]:' + api_port : null;
config["external-controller-tls"] = api_tls_port ? '[::]:' + api_tls_port : null; config["external-controller-tls"] = api_tls_port ? '[::]:' + api_tls_port : null;
config["external-doh-server"] = uci.get(uciconf, uciapi, 'external_doh_server'); config["external-doh-server"] = uci.get(uciconf, uciapi, 'external_doh_server');
@@ -386,6 +388,7 @@ config.dns = {
enable: true, enable: true,
"prefer-h3": false, "prefer-h3": false,
listen: '[::]:' + (uci.get(uciconf, ucidns, 'dns_port') || '7853'), 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, ipv6: (uci.get(uciconf, ucidns, 'ipv6') === '0') ? false : true,
"enhanced-mode": 'redir-host', "enhanced-mode": 'redir-host',
"use-hosts": true, "use-hosts": true,
@@ -479,14 +482,16 @@ uci.foreach(uciconf, ucinode, (cfg) => {
"routing-mark": strToInt(cfg.routing_mark) || null, "routing-mark": strToInt(cfg.routing_mark) || null,
"ip-version": cfg.ip_version, "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, username: cfg.username,
uuid: cfg.vmess_uuid || cfg.uuid, uuid: cfg.vmess_uuid || cfg.uuid,
cipher: cfg.vmess_chipher || cfg.shadowsocks_chipher, cipher: cfg.vmess_chipher || cfg.shadowsocks_chipher,
password: cfg.shadowsocks_password || cfg.password, password: cfg.shadowsocks_password || cfg.password,
headers: cfg.headers ? json(cfg.headers) : null, 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, ip: cfg.masque_ip || cfg.wireguard_ip,
ipv6: cfg.masque_ipv6 || cfg.wireguard_ipv6, ipv6: cfg.masque_ipv6 || cfg.wireguard_ipv6,
mtu: strToInt(cfg.masque_mtu ?? cfg.wireguard_mtu) || null, 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": strToBool(cfg.tuic_udp_over_stream),
"udp-over-stream-version": cfg.tuic_udp_over_stream_version, "udp-over-stream-version": cfg.tuic_udp_over_stream_version,
"max-udp-relay-packet-size": strToInt(cfg.tuic_max_udp_relay_packet_size) || null, "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), "reduce-rtt": strToBool(cfg.tuic_reduce_rtt),
"heartbeat-interval": strToInt(cfg.tuic_heartbeat) || null, "heartbeat-interval": strToInt(cfg.tuic_heartbeat) || null,
"request-timeout": strToInt(cfg.tuic_request_timeout) || null, "request-timeout": strToInt(cfg.tuic_request_timeout) || null,
// @"fast-open": true,
"max-open-streams": strToInt(cfg.tuic_max_open_streams) || null, "max-open-streams": strToInt(cfg.tuic_max_open_streams) || null,
/* Trojan */ /* Trojan */
@@ -591,8 +596,11 @@ uci.foreach(uciconf, ucinode, (cfg) => {
"packet-encoding": cfg.vmess_packet_encoding, "packet-encoding": cfg.vmess_packet_encoding,
encryption: cfg.vless_encryption === '1' ? cfg.vless_encryption_encryption : null, encryption: cfg.vless_encryption === '1' ? cfg.vless_encryption_encryption : null,
/* Masque */
network: cfg.masque_network || null,
/* TrustTunnel */ /* 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), quic: strToBool(cfg.trusttunnel_quic),
/* WireGuard */ /* WireGuard */
@@ -619,6 +627,7 @@ uci.foreach(uciconf, ucinode, (cfg) => {
"udp-over-tcp": strToBool(cfg.uot), "udp-over-tcp": strToBool(cfg.uot),
"udp-over-tcp-version": cfg.uot_version, "udp-over-tcp-version": cfg.uot_version,
/* SSH / WireGuard / Masque */
/* TLS fields */ /* TLS fields */
tls: (cfg.type in ['trojan', 'anytls', 'hysteria', 'hysteria2', 'tuic', 'trusttunnel']) ? null : strToBool(cfg.tls), tls: (cfg.type in ['trojan', 'anytls', 'hysteria', 'hysteria2', 'tuic', 'trusttunnel']) ? null : strToBool(cfg.tls),
"disable-sni": strToBool(cfg.tls_disable_sni), "disable-sni": strToBool(cfg.tls_disable_sni),
@@ -627,7 +636,8 @@ uci.foreach(uciconf, ucinode, (cfg) => {
alpn: cfg.tls_alpn, // Array alpn: cfg.tls_alpn, // Array
"skip-cert-verify": strToBool(cfg.tls_skip_cert_verify), "skip-cert-verify": strToBool(cfg.tls_skip_cert_verify),
certificate: cfg.tls_cert_path, // mTLS 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, "client-fingerprint": cfg.tls_client_fingerprint,
"ech-opts": cfg.tls_ech === '1' ? { "ech-opts": cfg.tls_ech === '1' ? {
enable: true, enable: true,
@@ -217,8 +217,9 @@ export function parseListener(cfg, isClient, label) {
type: cfg.type, type: cfg.type,
listen: cfg.listen || '::', listen: cfg.listen || '::',
port: cfg.port, port: strToInt(cfg.port),
...(isClient ? { ...(isClient ? {
"routing-mark": strToInt(cfg.routing_mark) || null,
rule: cfg.rule, rule: cfg.rule,
proxy: label, proxy: label,
} : {}), } : {}),
+2 -2
View File
@@ -7,10 +7,10 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-gecoosac PKG_NAME:=luci-app-gecoosac
PKG_VERSION:=2.2 PKG_VERSION:=2.2
PKG_RELEASE:=8 PKG_RELEASE:=9
LUCI_TITLE:=LuCI Support for gecoosac LUCI_TITLE:=LuCI Support for gecoosac
LUCI_DEPENDS:=+luci-compat +gecoosac LUCI_DEPENDS:=+gecoosac
LUCI_PKGARCH:=all LUCI_PKGARCH:=all
PKG_LICENSE:=AGPL-3.0-only PKG_LICENSE:=AGPL-3.0-only
@@ -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();
}
});
@@ -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
@@ -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
@@ -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>
+8 -11
View File
@@ -21,9 +21,6 @@ msgstr "批量集中管理集客 AP,默认密码:admin"
msgid "The current AC version %s, only supports AP 7.6 and above." msgid "The current AC version %s, only supports AP 7.6 and above."
msgstr "当前 AC 版本 %s ,仅支持 AP 7.6 及以上版本。" msgstr "当前 AC 版本 %s ,仅支持 AP 7.6 及以上版本。"
msgid "The AC program does not exist, please check."
msgstr "AC 程序不存在,请检查。"
msgid "Global Settings" msgid "Global Settings"
msgstr "全局设置" msgstr "全局设置"
@@ -99,15 +96,18 @@ msgstr "集客AC控制器 未运行"
msgid "Open the mgmt page" msgid "Open the mgmt page"
msgstr "打开管理页面" msgstr "打开管理页面"
msgid "Collecting data..."
msgstr "收集数据..."
msgid "Clear Upload Directory" msgid "Clear Upload Directory"
msgstr "清理上传目录" msgstr "清理上传目录"
msgid "Clear"
msgstr "清理"
msgid "Only files under the configured Gecoos upload directory will be removed." msgid "Only files under the configured Gecoos upload directory will be removed."
msgstr "只会删除已配置的集客 AC 上传目录中的文件。" msgstr "只会删除已配置的集客 AC 上传目录中的文件。"
msgid "Really clear the configured upload directory?"
msgstr "确定要清理已配置的上传目录吗?"
msgid "Expecting an absolute path" msgid "Expecting an absolute path"
msgstr "请输入绝对路径" msgstr "请输入绝对路径"
@@ -120,8 +120,5 @@ msgstr "上传目录未清理"
msgid "Upload directory cleared" msgid "Upload directory cleared"
msgstr "上传目录已清理" msgstr "上传目录已清理"
msgid "Unable to read %s" msgid "Unable to remove upload directory contents"
msgstr "无法读取 %s" msgstr "无法删除上传目录内容"
msgid "Unable to remove %s"
msgstr "无法删除 %s"
+91
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
@@ -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 }
}
}
}
@@ -1,10 +1,16 @@
{ {
"luci-app-gecoosac": { "luci-app-gecoosac": {
"description": "Grant UCI access for luci-app-gecoosac", "description": "Grant access for luci-app-gecoosac",
"read": { "read": {
"ubus": {
"service": [ "list" ]
},
"uci": [ "gecoosac" ] "uci": [ "gecoosac" ]
}, },
"write": { "write": {
"ubus": {
"luci.gecoosac": [ "clear_upload" ]
},
"uci": [ "gecoosac" ] "uci": [ "gecoosac" ]
} }
} }
+1 -1
View File
@@ -2,7 +2,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-run PKG_NAME:=luci-app-run
PKG_VERSION:=1.0.0 PKG_VERSION:=1.0.0
PKG_RELEASE:=2 PKG_RELEASE:=3
LUCI_TITLE:=LuCI support for running makeself .run installers LUCI_TITLE:=LuCI support for running makeself .run installers
LUCI_DESCRIPTION:=Upload and execute makeself-generated .run installers from LuCI. LUCI_DESCRIPTION:=Upload and execute makeself-generated .run installers from LuCI.
@@ -5,14 +5,11 @@
'require poll'; 'require poll';
// ====================== 纯JSON国际化 · 中英双语 ====================== // ====================== 纯JSON国际化 · 中英双语 ======================
// 25.12 兼容版:唯一变量名 + 可靠语言检测
const RUN_LANG = (function () { const RUN_LANG = (function () {
try { try {
// 优先从 cookie 获取(LuCI 最常用方式)
const m = document.cookie.match(/luci_lang=([a-zA-Z-]+)/); const m = document.cookie.match(/luci_lang=([a-zA-Z-]+)/);
if (m) return m[1].substring(0, 2).toLowerCase(); if (m) return m[1].substring(0, 2).toLowerCase();
// 备选:LuCI 环境变量
if (window.L && L.env && L.env.lang) if (window.L && L.env && L.env.lang)
return L.env.lang.substring(0, 2).toLowerCase(); return L.env.lang.substring(0, 2).toLowerCase();
@@ -25,15 +22,15 @@ const RUN_LANG = (function () {
const I18N = { const I18N = {
zh: { zh: {
title: "Run安装器", title: "Run安装器",
desc: "在路由器上上传并执行 makeself 生成的 .run 安装包。", desc: "在路由器上上传并执行 .run 安装包或 .sh 脚本,注意架构务必匹配。",
drop_tip: "拖入一个 makeself .run 文件,或从电脑选择。", drop_tip: "拖入一个 .run 或 .sh 文件,或从电脑选择。",
choose_file: "选择 .run 文件", choose_file: "选择 .run 或 .sh 文件",
execute: "执行", execute: "执行",
clean_up: "清理", clean_up: "清理",
upload_title: "上传 .run 安装包", upload_title: "上传安装脚本 (.run / .sh)",
log_title: "执行日志", log_title: "执行日志",
clean_done: "临时文件与日志已清理。", clean_done: "临时文件与日志已清理。",
only_run: "仅支持 .run 文件。", only_run: "仅支持 .run 和 .sh 文件。",
prepare_upload: "准备上传:%s (%s)", prepare_upload: "准备上传:%s (%s)",
upload_failed: "上传失败。", upload_failed: "上传失败。",
uploading: "正在上传 %s:%d%%", uploading: "正在上传 %s:%d%%",
@@ -47,15 +44,15 @@ const I18N = {
}, },
en: { en: {
title: "Run Installer", title: "Run Installer",
desc: "Upload and execute a makeself-generated .run package on this router.", desc: "Upload and execute .run packages or .sh scripts on this router, ensuring architecture compatibility.",
drop_tip: "Drop a makeself .run file here, or choose one from your computer.", drop_tip: "Drop a .run or .sh file here, or choose one from your computer.",
choose_file: "Choose .run file", choose_file: "Choose .run or .sh file",
execute: "Execute", execute: "Execute",
clean_up: "Clean up", clean_up: "Clean up",
upload_title: "Upload .run installer", upload_title: "Upload installer script (.run / .sh)",
log_title: "Execution log", log_title: "Execution log",
clean_done: "Temporary files and logs were removed.", 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)", prepare_upload: "Preparing upload: %s (%s)",
upload_failed: "Upload failed.", upload_failed: "Upload failed.",
uploading: "Uploading %s: %d%%", uploading: "Uploading %s: %d%%",
@@ -137,7 +134,6 @@ function bufferToBase64(buffer) {
} }
return view.extend({ return view.extend({
// ====================== 底部按钮正确隐藏 ======================
handleSave: null, handleSave: null,
handleReset: null, handleReset: null,
handleSaveApply: null, handleSaveApply: null,
@@ -156,7 +152,7 @@ return view.extend({
var fileInput = E('input', { var fileInput = E('input', {
'type': 'file', 'type': 'file',
accept: '.run,application/x-sh,application/octet-stream', accept: '.run,.sh,application/x-shellscript,application/octet-stream',
style: 'display:none' style: 'display:none'
}); });
@@ -259,8 +255,7 @@ return view.extend({
}, },
applyStatus: function (status, state) { applyStatus: function (status, state) {
if (!status) if (!status) return;
return;
if (status.running) if (status.running)
state.textContent = _('running'); state.textContent = _('running');
@@ -271,7 +266,8 @@ return view.extend({
uploadFile: function (file, progress, state, runButton) { uploadFile: function (file, progress, state, runButton) {
var self = this; 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'); ui.addNotification(null, E('p', [_('only_run')]), 'danger');
return Promise.reject(); return Promise.reject();
} }
@@ -305,30 +301,20 @@ return view.extend({
xhr.setRequestHeader('Content-Type', 'application/octet-stream'); xhr.setRequestHeader('Content-Type', 'application/octet-stream');
xhr.upload.onprogress = function (ev) { xhr.upload.onprogress = function (ev) {
if (!ev.lengthComputable) if (!ev.lengthComputable) return;
return;
progress.value = Math.floor(ev.loaded * 100 / ev.total); progress.value = Math.floor(ev.loaded * 100 / ev.total);
state.textContent = _('uploading', file.name, progress.value); state.textContent = _('uploading', file.name, progress.value);
}; };
xhr.onerror = function () { xhr.onerror = function () {
// 强制启用按钮
document.querySelector('.cbi-button-action').disabled = false; document.querySelector('.cbi-button-action').disabled = false;
reject(new Error(_('upload_err'))); reject(new Error(_('upload_err')));
}; };
xhr.onload = function () { xhr.onload = function () {
// ==========================================
// 【直接强制解锁按钮:永远生效】
// ==========================================
document.querySelector('.cbi-button-action').disabled = false; document.querySelector('.cbi-button-action').disabled = false;
progress.value = 100; progress.value = 100;
// 忽略所有返回解析
try { JSON.parse(xhr.responseText); } catch (e) { }
// 直接完成流程
uploadFinish(session.id).then(function () { uploadFinish(session.id).then(function () {
state.textContent = _('upload_done', file.name, formatBytes(file.size)); state.textContent = _('upload_done', file.name, formatBytes(file.size));
}).catch(function () { }).catch(function () {
@@ -345,8 +331,7 @@ return view.extend({
startRun: function (runButton, state) { startRun: function (runButton, state) {
var self = this; var self = this;
if (!this.currentUploadId) if (!this.currentUploadId) return;
return;
runButton.disabled = true; runButton.disabled = true;
state.textContent = _('starting'); state.textContent = _('starting');
@@ -367,8 +352,7 @@ return view.extend({
var self = this; var self = this;
return readLog(this.logOffset).then(function (res) { return readLog(this.logOffset).then(function (res) {
if (!res || res.error) if (!res || res.error) return;
return;
if (res.data) { if (res.data) {
log.textContent += res.data; log.textContent += res.data;
@@ -223,7 +223,14 @@ run_installer() {
[ -d "$dir" ] || { json_error "Unknown upload id"; return; } [ -d "$dir" ] || { json_error "Unknown upload id"; return; }
name="$(cat "$dir/name" 2>/dev/null)" name="$(cat "$dir/name" 2>/dev/null)"
file="$dir/$name" 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 read_state
if is_running "$PID"; then if is_running "$PID"; then
@@ -72,8 +72,13 @@ saved_token="$(cat "$dir/token" 2>/dev/null)"
name="$(cat "$dir/name" 2>/dev/null)" name="$(cat "$dir/name" 2>/dev/null)"
case "$name" in case "$name" in
*.run) ;; *.run)
*) fail "Invalid filename" ;; ;;
*.sh)
;;
*)
fail "Invalid filename"
;;
esac esac
length="${CONTENT_LENGTH:-0}" length="${CONTENT_LENGTH:-0}"
+2 -2
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_EXTRA_DEPENDS:=luci-lib-taskd (>=1.0.19)
LUCI_PKGARCH:=all 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 MUST be empty for luci.mk
PKG_RELEASE:= PKG_RELEASE:=
ISTORE_UI_VERSION:=0.2.0 ISTORE_UI_VERSION:=0.2.0
ISTORE_UI_RELEASE:=1 ISTORE_UI_RELEASE:=2
PKG_HASH:=skip PKG_HASH:=skip
PKG_SOURCE_URL_FILE:=v$(ISTORE_UI_VERSION)-$(ISTORE_UI_RELEASE).tar.gz PKG_SOURCE_URL_FILE:=v$(ISTORE_UI_VERSION)-$(ISTORE_UI_RELEASE).tar.gz
+2 -2
View File
@@ -1,8 +1,8 @@
include $(TOPDIR)/rules.mk include $(TOPDIR)/rules.mk
PKG_NAME:=opera-proxy PKG_NAME:=opera-proxy
PKG_VERSION:=1.24.0 PKG_VERSION:=1.25.0
PKG_RELEASE:=5 PKG_RELEASE:=6
PKG_MAINTAINER:=Konstantine Shevlakov <shevlakov@132lan.ru> PKG_MAINTAINER:=Konstantine Shevlakov <shevlakov@132lan.ru>
PKG_LICENSE:=MIT PKG_LICENSE:=MIT