update 2024-11-03 20:36:08

This commit is contained in:
kenzok8 2024-11-03 20:36:08 +08:00
parent 53f4143f61
commit 63a1d9df6d
19 changed files with 2436 additions and 1126 deletions

View File

@ -15,7 +15,7 @@ mkdir -p "$PKG_BUILD_BIN"
curl -L "https://github.com/jqlang/jq/releases/download/jq-${JQVERSION}/jq-${OS}-${ARCH}" -o "$PKG_BUILD_BIN"/jq
chmod +x "$PKG_BUILD_BIN"/jq
latest="$(curl -L https://api.github.com/repos/kpym/gm/releases/latest | jq -rc '.tag_name' 2>/dev/null)"
curl -L "https://github.com/kpym/gm/releases/download/${latest}/gm_${latest#v}_Linux_64bit.tar.gz" -o- | tar -xz -C "$PKG_BUILD_BIN"
curl -L "https://github.com/kpym/gm/releases/download/${latest}/gm_${latest#v}_Linux_intel64.tar.gz" -o- | tar -xz -C "$PKG_BUILD_BIN"
latest="$(curl -L https://api.github.com/repos/tdewolff/minify/releases/latest | jq -rc '.tag_name' 2>/dev/null)"
curl -L "https://github.com/tdewolff/minify/releases/download/${latest}/minify_${OS}_${ARCH}.tar.gz" -o- | tar -xz -C "$PKG_BUILD_BIN"
chmod -R +x "$PKG_BUILD_BIN"

View File

@ -54,6 +54,17 @@ return baseclass.extend({
['https://www.gstatic.com/generate_204']
],
inbound_type: [
['http', _('HTTP')],
['socks', _('SOCKS')],
['mixed', _('Mixed')],
['shadowsocks', _('Shadowsocks')],
['vmess', _('VMess')],
['tuic', _('TUIC')],
['hysteria2', _('Hysteria2')],
//['tunnel', _('Tunnel')]
],
ip_version: [
['', _('Keep default')],
['dual', _('Dual stack')],
@ -163,6 +174,34 @@ return baseclass.extend({
//'SUB-RULE': 0,
},
shadowsocks_cipher_methods: [
/* Stream */
['none', _('none')],
/* AEAD */
['aes-128-gcm', _('aes-128-gcm')],
['aes-192-gcm', _('aes-192-gcm')],
['aes-256-gcm', _('aes-256-gcm')],
['chacha20-ietf-poly1305', _('chacha20-ietf-poly1305')],
['xchacha20-ietf-poly1305', _('xchacha20-ietf-poly1305')],
/* AEAD 2022 */
['2022-blake3-aes-128-gcm', _('2022-blake3-aes-128-gcm')],
['2022-blake3-aes-256-gcm', _('2022-blake3-aes-256-gcm')],
['2022-blake3-chacha20-poly1305', _('2022-blake3-chacha20-poly1305')]
],
shadowsocks_cipher_length: {
/* AEAD */
'aes-128-gcm': 0,
'aes-192-gcm': 0,
'aes-256-gcm': 0,
'chacha20-ietf-poly1305': 0,
'xchacha20-ietf-poly1305': 0,
/* AEAD 2022 */
'2022-blake3-aes-128-gcm': 16,
'2022-blake3-aes-256-gcm': 32,
'2022-blake3-chacha20-poly1305': 32
},
tls_client_fingerprints: [
['chrome'],
['firefox'],
@ -264,7 +303,7 @@ return baseclass.extend({
/* Thanks to luci-app-ssr-plus */
str = str.replace(/-/g, '+').replace(/_/g, '/');
var padding = (4 - str.length % 4) % 4;
var padding = (4 - (str.length % 4)) % 4;
if (padding)
str = str + Array(padding + 1).join('=');
@ -273,6 +312,29 @@ return baseclass.extend({
).join(''));
},
generateRand: function(type, length) {
var byteArr;
if (['base64', 'hex'].includes(type))
byteArr = crypto.getRandomValues(new Uint8Array(length));
switch (type) {
case 'base64':
/* Thanks to https://stackoverflow.com/questions/9267899 */
return btoa(String.fromCharCode.apply(null, byteArr));
case 'hex':
return Array.from(byteArr, (byte) =>
(byte & 255).toString(16).padStart(2, '0')
).join('');
case 'uuid':
/* Thanks to https://stackoverflow.com/a/2117523 */
return (location.protocol === 'https:') ? crypto.randomUUID() :
([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, (c) =>
(c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))).toString(16)
);
default:
return null;
};
},
getFeatures: function() {
var callGetFeatures = rpc.declare({
object: 'luci.fchomo',
@ -554,6 +616,29 @@ return baseclass.extend({
return this.vallist[i];
},
validateAuth: function(section_id, value) {
if (!value)
return true;
if (!value.match(/^[\w-]{3,}:[^:]+$/))
return _('Expecting: %s').format('[A-Za-z0-9_-]{3,}:[^:]+');
return true;
},
validateAuthUsername: function(section_id, value) {
if (!value)
return true;
if (!value.match(/^[\w-]{3,}$/))
return _('Expecting: %s').format('[A-Za-z0-9_-]{3,}');
return true;
},
validateAuthPassword: function(section_id, value) {
if (!value.match(/^[^:]+$/))
return _('Expecting: %s').format('[^:]+');
return true;
},
validateCommonPort: function(section_id, value) {
// thanks to homeproxy
var stubValidator = {
@ -617,7 +702,7 @@ return baseclass.extend({
uci.sections(this.config, this.section.sectiontype, (res) => {
if (res['.name'] !== section_id)
if (res[this.option] === value)
duplicate = true
duplicate = true;
});
if (duplicate)
return _('Expecting: %s').format(_('unique value'));
@ -641,6 +726,15 @@ return baseclass.extend({
return true;
},
validateUUID: function(section_id, value) {
if (!value)
return true;
else if (value.match('^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$') === null)
return _('Expecting: %s').format(_('valid uuid'));
return true;
},
lsDir: function(type) {
var callLsDir = rpc.declare({
object: 'luci.fchomo',
@ -719,5 +813,26 @@ return baseclass.extend({
} else
throw res.error || 'unknown error';
});
},
// thanks to homeproxy
uploadCertificate: function(type, filename, ev) {
var callWriteCertificate = rpc.declare({
object: 'luci.fchomo',
method: 'certificate_write',
params: ['filename'],
expect: { '': {} }
});
return ui.uploadFile('/tmp/fchomo_certificate.tmp', ev.target)
.then(L.bind((btn, res) => {
return L.resolveDefault(callWriteCertificate(filename), {}).then((ret) => {
if (ret.result === true)
ui.addNotification(null, E('p', _('Your %s was successfully uploaded. Size: %sB.').format(type, res.size)));
else
ui.addNotification(null, E('p', _('Failed to upload %s, error: %s.').format(type, ret.error)));
});
}, this, ev.target))
.catch((e) => { ui.addNotification(null, E('p', e.message)) });
}
});

View File

@ -295,7 +295,7 @@ return view.extend({
]);
}
s = m.section(form.NamedSection, 'global', 'fchomo');
s = m.section(form.NamedSection, 'routing', 'fchomo', null);
/* Proxy Group START */
s.tab('group', _('Proxy Group'));
@ -307,7 +307,6 @@ return view.extend({
o.onclick = L.bind(hm.handleReload, o, 'mihomo-c');
o = s.taboption('group', form.ListValue, 'default_proxy', _('Default outbound'));
o.ucisection = 'routing';
o.load = L.bind(hm.loadProxyGroupLabel, o, hm.preset_outbound.direct);
/* Proxy Group */

View File

@ -34,7 +34,7 @@ function handleResUpdate(type, repo) {
});
// Dynamic repo
var label
var label;
if (repo) {
var section_id = this.section.section;
var weight = document.getElementById(this.cbid(section_id));
@ -188,7 +188,7 @@ return view.extend({
});
})
}, [ _('Check') ]),
E('strong', { id: ElId}, [
E('strong', { id: ElId }, [
E('span', { style: 'color:gray' }, ' ' + _('unchecked'))
])
]);
@ -328,14 +328,7 @@ return view.extend({
so = ss.option(form.DynamicList, 'authentication', _('User Authentication'));
so.datatype = 'list(string)';
so.placeholder = 'user1:pass1';
so.validate = function(section_id, value) {
if (!value)
return true;
if (!value.match(/^[\w-]{3,}:[^:]+$/))
return _('Expecting: %s').format('[A-Za-z0-9_-]{3,}:[^:]+');
return true;
}
so.validate = L.bind(hm.validateAuth, so);
so = ss.option(form.DynamicList, 'skip_auth_prefixes', _('No Authentication IP ranges'));
so.datatype = 'list(cidr)';
@ -350,22 +343,22 @@ return view.extend({
ss = o.subsection;
so = ss.option(form.Value, 'mixed_port', _('Mixed port'));
so.datatype = 'port'
so.datatype = 'port';
so.placeholder = '7890';
so.rmempty = false;
so = ss.option(form.Value, 'redir_port', _('Redir port'));
so.datatype = 'port'
so.datatype = 'port';
so.placeholder = '7891';
so.rmempty = false;
so = ss.option(form.Value, 'tproxy_port', _('Tproxy port'));
so.datatype = 'port'
so.datatype = 'port';
so.placeholder = '7892';
so.rmempty = false;
so = ss.option(form.Value, 'tunnel_port', _('DNS port'));
so.datatype = 'port'
so.datatype = 'port';
so.placeholder = '7893';
so.rmempty = false;
@ -397,7 +390,7 @@ return view.extend({
so.onchange = function(ev, section_id, value) {
var desc = ev.target.nextSibling;
if (value === 'mixed')
desc.innerHTML = _('Mixed <code>system</code> TCP stack and <code>gVisor</code> UDP stack.')
desc.innerHTML = _('Mixed <code>system</code> TCP stack and <code>gVisor</code> UDP stack.');
else if (value === 'gvisor')
desc.innerHTML = _('Based on google/gvisor.');
else if (value === 'system')
@ -588,20 +581,20 @@ return view.extend({
so = ss.taboption('interface', form.Value, 'route_table_id', _('Routing table ID'));
so.ucisection = 'config';
so.datatype = 'uinteger'
so.datatype = 'uinteger';
so.placeholder = '2022';
so.rmempty = false;
so = ss.taboption('interface', form.Value, 'route_rule_pref', _('Routing rule priority'));
so.ucisection = 'config';
so.datatype = 'uinteger'
so.datatype = 'uinteger';
so.placeholder = '9000';
so.rmempty = false;
so = ss.taboption('interface', form.Value, 'self_mark', _('Routing mark'),
_('Priority: Proxy Node > Proxy Group > Global.'));
so.ucisection = 'config';
so.datatype = 'uinteger'
so.datatype = 'uinteger';
so.placeholder = '200';
so.rmempty = false;

View File

@ -0,0 +1,390 @@
'use strict';
'require form';
'require poll';
'require uci';
'require ui';
'require view';
'require fchomo as hm';
function handleGenKey(option) {
var section_id = this.section.section;
var type = this.section.getOption('type').formvalue(section_id);
var widget = this.map.findElement('id', 'widget.cbid.fchomo.%s.%s'.format(section_id, option));
var password, required_method;
if (option === 'uuid' || option.match(/_uuid/))
required_method = 'uuid';
else if (type === 'shadowsocks')
required_method = this.section.getOption('shadowsocks_chipher')?.formvalue(section_id);
switch (required_method) {
/* NONE */
case 'none':
password = '';
break;
/* UUID */
case 'uuid':
password = hm.generateRand('uuid');
break;
/* DEFAULT */
default:
password = hm.generateRand('hex', 16);
break;
}
/* AEAD */
(function(length) {
if (length && length > 0)
password = hm.generateRand('base64', length);
}(hm.shadowsocks_cipher_length[required_method]));
return widget.value = password;
}
return view.extend({
load: function() {
return Promise.all([
uci.load('fchomo'),
hm.getFeatures()
]);
},
render: function(data) {
var dashboard_repo = uci.get(data[0], 'api', 'dashboard_repo'),
features = data[1];
var m, s, o;
m = new form.Map('fchomo', _('Mihomo server'),
_('When used as a server, HomeProxy is a better choice.'));
s = m.section(form.TypedSection);
s.render = function () {
poll.add(function () {
return hm.getServiceStatus('mihomo-s').then((isRunning) => {
hm.updateStatus(hm, document.getElementById('_server_bar'), isRunning ? { dashboard_repo: dashboard_repo } : false, 'mihomo-s', true);
});
});
return E('div', { class: 'cbi-section' }, [
E('p', [
hm.renderStatus(hm, '_server_bar', false, 'mihomo-s', true)
])
]);
}
s = m.section(form.NamedSection, 'routing', 'fchomo', null);
/* Server switch */
o = s.option(form.Button, '_reload_server', _('Quick Reload'));
o.inputtitle = _('Reload');
o.inputstyle = 'apply';
o.onclick = L.bind(hm.handleReload, o, 'mihomo-s');
o = s.option(form.Flag, 'server_enabled', _('Enable'));
o.default = o.disabled;
o = s.option(form.Flag, 'server_auto_firewall', _('Auto configure firewall'));
o.default = o.disabled;
/* Server settings START */
s = m.section(form.GridSection, 'server', null);
var prefmt = { 'prefix': 'server_', 'suffix': '' };
s.addremove = true;
s.rowcolors = true;
s.sortable = true;
s.nodescriptions = true;
s.modaltitle = L.bind(hm.loadModalTitle, s, _('Server'), _('Add a server'));
s.sectiontitle = L.bind(hm.loadDefaultLabel, s);
s.renderSectionAdd = L.bind(hm.renderSectionAdd, s, prefmt, false);
s.handleAdd = L.bind(hm.handleAdd, s, prefmt);
/* General fields */
o = s.option(form.Value, 'label', _('Label'));
o.load = L.bind(hm.loadDefaultLabel, o);
o.validate = L.bind(hm.validateUniqueValue, o);
o.modalonly = true;
o = s.option(form.Flag, 'enabled', _('Enable'));
o.default = o.enabled;
o.editable = true;
o = s.option(form.ListValue, 'type', _('Type'));
o.default = hm.inbound_type[0][0];
hm.inbound_type.forEach((res) => {
o.value.apply(o, res);
})
o = s.option(form.Value, 'listen', _('Listen address'));
o.datatype = 'ipaddr';
o.placeholder = '::';
o.modalonly = true;
o = s.option(form.Value, 'port', _('Listen port'));
o.datatype = 'port';
o.rmempty = false;
// dev: Features under development
// rule
// proxy
/* HTTP / SOCKS fields */
/* hm.validateAuth */
o = s.option(form.Value, 'username', _('Username'));
o.validate = L.bind(hm.validateAuthUsername, o);
o.depends({type: /^(http|socks|mixed|hysteria2)$/});
o.modalonly = true;
o = s.option(form.Value, 'password', _('Password'));
o.password = true;
o.renderWidget = function() {
var node = form.Value.prototype.renderWidget.apply(this, arguments);
(node.querySelector('.control-group') || node).appendChild(E('button', {
'class': 'cbi-button cbi-button-add',
'title': _('Generate'),
'click': ui.createHandlerFn(this, handleGenKey, this.option)
}, [ _('Generate') ]));
return node;
}
o.validate = function(section_id, value) {
}
o.validate = L.bind(hm.validateAuthPassword, o);
o.rmempty = false;
o.depends({type: /^(http|socks|mixed|hysteria2)$/, username: /.+/});
o.depends({type: /^(tuic)$/, uuid: /.+/});
o.modalonly = true;
/* Hysteria2 fields */
o = s.option(form.Value, 'hysteria_up_mbps', _('Max upload speed'),
_('In Mbps.'));
o.datatype = 'uinteger';
o.depends('type', 'hysteria2');
o.modalonly = true;
o = s.option(form.Value, 'hysteria_down_mbps', _('Max download speed'),
_('In Mbps.'));
o.datatype = 'uinteger';
o.depends('type', 'hysteria2');
o.modalonly = true;
o = s.option(form.Flag, 'hysteria_ignore_client_bandwidth', _('Ignore client bandwidth'),
_('Tell the client to use the BBR flow control algorithm instead of Hysteria CC.'));
o.default = o.disabled;
o.depends({type: 'hysteria2', hysteria_up_mbps: '', hysteria_down_mbps: ''});
o.modalonly = true;
o = s.option(form.ListValue, 'hysteria_obfs_type', _('Obfuscate type'));
o.value('', _('Disable'));
o.value('salamander', _('Salamander'));
o.depends('type', 'hysteria2');
o.modalonly = true;
o = s.option(form.Value, 'hysteria_obfs_password', _('Obfuscate password'),
_('Enabling obfuscation will make the server incompatible with standard QUIC connections, losing the ability to masquerade with HTTP/3.'));
o.password = true;
o.renderWidget = function() {
var node = form.Value.prototype.renderWidget.apply(this, arguments);
(node.querySelector('.control-group') || node).appendChild(E('button', {
'class': 'cbi-button cbi-button-add',
'title': _('Generate'),
'click': ui.createHandlerFn(this, handleGenKey, this.option)
}, [ _('Generate') ]));
return node;
}
o.rmempty = false;
o.depends('type', 'hysteria');
o.depends({type: 'hysteria2', hysteria_obfs_type: /.+/});
o.modalonly = true;
o = s.option(form.Value, 'hysteria_masquerade', _('Masquerade'),
_('HTTP3 server behavior when authentication fails.<br/>A 404 page will be returned if empty.'));
o.placeholder = 'file:///var/www or http://127.0.0.1:8080'
o.depends('type', 'hysteria2');
o.modalonly = true;
/* Shadowsocks fields */
o = s.option(form.ListValue, 'shadowsocks_chipher', _('Chipher'));
o.default = hm.shadowsocks_cipher_methods[1][0];
hm.shadowsocks_cipher_methods.forEach((res) => {
o.value.apply(o, res);
})
o.depends('type', 'shadowsocks');
o.modalonly = true;
o = s.option(form.Value, 'shadowsocks_password', _('Password'));
o.password = true;
o.renderWidget = function() {
var node = form.Value.prototype.renderWidget.apply(this, arguments);
(node.querySelector('.control-group') || node).appendChild(E('button', {
'class': 'cbi-button cbi-button-add',
'title': _('Generate'),
'click': ui.createHandlerFn(this, handleGenKey, this.option)
}, [ _('Generate') ]));
return node;
}
o.validate = function(section_id, value) {
var encmode = this.section.getOption('shadowsocks_chipher').formvalue(section_id);
var length = hm.shadowsocks_cipher_length[encmode];
if (length) {
length = Math.ceil(length/3)*4;
if (encmode.match(/^2022-/)) {
if (value.length !== length || !value.match(/^(?:[A-Za-z0-9+\/]{4})*(?:[A-Za-z0-9+\/]{2}==|[A-Za-z0-9+\/]{3}=)?$/) || value[length-1] !== '=')
return _('Expecting: %s').format(_('valid base64 key with %d characters').format(length));
} else {
if (length !== 0 && value.length !== length)
return _('Expecting: %s').format(_('valid key length with %d characters').format(length));
}
} else
return true;
return true;
}
o.depends({type: 'shadowsocks', shadowsocks_chipher: /.+/});
o.modalonly = true;
/* Tuic fields */
o = s.option(form.Value, 'uuid', _('UUID'));
o.renderWidget = function() {
var node = form.Value.prototype.renderWidget.apply(this, arguments);
(node.querySelector('.control-group') || node).appendChild(E('button', {
'class': 'cbi-button cbi-button-add',
'title': _('Generate'),
'click': ui.createHandlerFn(this, handleGenKey, this.option)
}, [ _('Generate') ]));
return node;
}
o.rmempty = false;
o.validate = L.bind(hm.validateUUID, o);
o.depends('type', 'tuic');
o.modalonly = true;
o = s.option(form.ListValue, 'tuic_congestion_controller', _('Congestion controller'),
_('QUIC congestion controller.'));
o.default = 'cubic';
o.value('cubic', _('cubic'));
o.value('new_reno', _('new_reno'));
o.value('bbr', _('bbr'));
o.depends('type', 'tuic');
o.modalonly = true;
o = s.option(form.Value, 'tuic_max_idle_time', _('Idle timeout'),
_('In seconds.'));
o.default = '15000';
o.validate = L.bind(hm.validateTimeDuration, o);
o.depends('type', 'tuic');
o.modalonly = true;
o = s.option(form.Value, 'tuic_authentication_timeout', _('Auth timeout'),
_('In seconds.'));
o.default = '1000';
o.validate = L.bind(hm.validateTimeDuration, o);
o.depends('type', 'tuic');
o.modalonly = true;
o = s.option(form.Value, 'tuic_max_udp_relay_packet_size', _('Max UDP relay packet size'));
o.datatype = 'uinteger';
o.default = '1500';
o.depends('type', 'tuic');
o.modalonly = true;
/* VMess fields */
o = s.option(form.Value, 'vmess_uuid', _('UUID'));
o.renderWidget = function() {
var node = form.Value.prototype.renderWidget.apply(this, arguments);
(node.querySelector('.control-group') || node).appendChild(E('button', {
'class': 'cbi-button cbi-button-add',
'title': _('Generate'),
'click': ui.createHandlerFn(this, handleGenKey, this.option)
}, [ _('Generate') ]));
return node;
}
o.rmempty = false;
o.validate = L.bind(hm.validateUUID, o);
o.depends('type', 'vmess');
o.modalonly = true;
o = s.option(form.Value, 'vmess_alterid', _('Alter ID'),
_('Legacy protocol support (VMess MD5 Authentication) is provided for compatibility purposes only, use of alterId > 1 is not recommended.'));
o.datatype = 'uinteger';
o.placeholder = '0';
o.depends('type', 'vmess');
o.modalonly = true;
/* TLS fields */
o = s.option(form.Flag, 'tls', _('TLS'));
o.default = o.disabled;
o.validate = function(section_id, value) {
var type = this.section.getOption('type').formvalue(section_id);
var tls = this.section.getUIElement(section_id, 'tls').node.querySelector('input');
var tls_alpn = this.section.getUIElement(section_id, 'tls_alpn');
// Force enabled
if (['tuic', 'hysteria2'].includes(type)) {
tls.checked = true;
tls.disabled = true;
if (!`${tls_alpn.getValue()}`)
tls_alpn.setValue('h3');
} else {
tls.disabled = null;
}
return true;
}
o.depends({type: /^(vmess|tuic|hysteria2)$/});
o.modalonly = true;
o = s.option(form.DynamicList, 'tls_alpn', _('TLS ALPN'),
_('List of supported application level protocols, in order of preference.'));
o.depends('tls', '1');
o.modalonly = true;
o = s.option(form.Value, 'tls_cert_path', _('Certificate path'),
_('The server public key, in PEM format.'));
o.value('/etc/fchomo/certs/server_publickey.pem');
o.depends('tls', '1');
o.rmempty = false;
o.modalonly = true;
o = s.option(form.Button, '_upload_cert', _('Upload certificate'),
_('<strong>Save your configuration before uploading files!</strong>'));
o.inputstyle = 'action';
o.inputtitle = _('Upload...');
o.depends({tls: '1', tls_cert_path: '/etc/fchomo/certs/server_publickey.pem'});
o.onclick = L.bind(hm.uploadCertificate, o, _('certificate'), 'server_publickey');
o.modalonly = true;
o = s.option(form.Value, 'tls_key_path', _('Key path'),
_('The server private key, in PEM format.'));
o.value('/etc/fchomo/certs/server_privatekey.pem');
o.rmempty = false;
o.depends({tls: '1', tls_cert_path: /.+/});
o.modalonly = true;
o = s.option(form.Button, '_upload_key', _('Upload key'),
_('<strong>Save your configuration before uploading files!</strong>'));
o.inputstyle = 'action';
o.inputtitle = _('Upload...');
o.depends({tls: '1', tls_key_path: '/etc/fchomo/certs/server_privatekey.pem'});
o.onclick = L.bind(hm.uploadCertificate, o, _('private key'), 'server_privatekey');
o.modalonly = true;
/* Extra fields */
o = s.option(form.Flag, 'udp', _('UDP'));
o.default = o.disabled;
o.depends({type: /^(socks|mixed|shadowsocks)$/});
o.modalonly = true;
/* Server settings END */
return m.render();
}
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -46,7 +46,46 @@ export function strToInt(str) {
if (isEmpty(str))
return null;
return !match(str, /^\d+$/) ? str : int(str) || null;
return !match(str, /^\d+$/) ? str : int(str) ?? null;
};
export function durationToSecond(str) {
if (isEmpty(str))
return null;
let seconds = 0;
let arr = match(str, /^(\d+)(s|m|h|d)?$/);
if (arr) {
if (arr[2] === 's') {
seconds = strToInt(arr[1]);
} else if (arr[2] === 'm') {
seconds = strToInt(arr[1]) * 60;
} else if (arr[2] === 'h') {
seconds = strToInt(arr[1]) * 3600;
} else if (arr[2] === 'd') {
seconds = strToInt(arr[1]) * 86400;
} else
seconds = strToInt(arr[1]);
}
return seconds;
};
export function arrToObj(res) {
if (isEmpty(res))
return null;
let object;
if (type(res) === 'array') {
object = {};
map(res, (e) => {
if (type(e) === 'array')
object[e[0]] = e[1];
});
} else
return res;
return object;
};
export function removeBlankAttrs(res) {

View File

@ -9,7 +9,7 @@ import { cursor } from 'uci';
import { urldecode, urlencode } from 'luci.http';
import {
isEmpty, strToBool, strToInt,
isEmpty, strToBool, strToInt, durationToSecond,
removeBlankAttrs,
HM_DIR, RUN_DIR, PRESET_OUTBOUND
} from 'fchomo';
@ -105,28 +105,6 @@ function parse_filter(cfg) {
return cfg;
}
function parse_time_duration(time) {
if (isEmpty(time))
return null;
let seconds = 0;
let arr = match(time, /^(\d+)(s|m|h|d)?$/);
if (arr) {
if (arr[2] === 's') {
seconds = strToInt(arr[1]);
} else if (arr[2] === 'm') {
seconds = strToInt(arr[1]) * 60;
} else if (arr[2] === 'h') {
seconds = strToInt(arr[1]) * 3600;
} else if (arr[2] === 'd') {
seconds = strToInt(arr[1]) * 86400;
} else
seconds = strToInt(arr[1]);
}
return seconds;
}
function get_proxynode(cfg) {
if (isEmpty(cfg))
return null;
@ -191,8 +169,8 @@ config["etag-support"] = (uci.get(uciconf, uciglobal, 'etag_support') === '0') ?
config.ipv6 = (uci.get(uciconf, uciglobal, 'ipv6') === '0') ? false : true;
config["unified-delay"] = strToBool(uci.get(uciconf, uciglobal, 'unified_delay')) || false;
config["tcp-concurrent"] = strToBool(uci.get(uciconf, uciglobal, 'tcp_concurrent')) || false;
config["keep-alive-interval"] = parse_time_duration(uci.get(uciconf, uciglobal, 'keep_alive_interval')) || 30;
config["keep-alive-idle"] = parse_time_duration(uci.get(uciconf, uciglobal, 'keep_alive_idle')) || 600;
config["keep-alive-interval"] = durationToSecond(uci.get(uciconf, uciglobal, 'keep_alive_interval')) || 30;
config["keep-alive-idle"] = durationToSecond(uci.get(uciconf, uciglobal, 'keep_alive_idle')) || 600;
/* ACL settings */
config["interface-name"] = bind_interface;
config["routing-mark"] = self_mark;
@ -345,7 +323,7 @@ if (match(proxy_mode, /tun/))
"route-exclude-address-set": [],
"include-interface": [],
"exclude-interface": [],
"udp-timeout": parse_time_duration(uci.get(uciconf, uciinbound, 'tun_udp_timeout')) || 300,
"udp-timeout": durationToSecond(uci.get(uciconf, uciinbound, 'tun_udp_timeout')) || 300,
"endpoint-independent-nat": strToBool(uci.get(uciconf, uciinbound, 'tun_endpoint_independent_nat')),
"auto-detect-interface": true
});
@ -452,7 +430,7 @@ uci.foreach(uciconf, ucipgrp, (cfg) => {
["routing-mark"]: strToInt(cfg.routing_mark),
// Health fields
url: cfg.url,
interval: cfg.url ? parse_time_duration(cfg.interval) || 600 : null,
interval: cfg.url ? durationToSecond(cfg.interval) || 600 : null,
timeout: cfg.url ? strToInt(cfg.timeout) || 5000 : null,
lazy: (cfg.lazy === '0') ? false : null,
"expected-status": cfg.url ? cfg.expected_status || '204' : null,
@ -476,7 +454,7 @@ uci.foreach(uciconf, uciprov, (cfg) => {
type: cfg.type,
path: HM_DIR + '/provider/' + cfg['.name'],
url: cfg.url,
interval: (cfg.type === 'http') ? parse_time_duration(cfg.interval) || 86400 : null,
interval: (cfg.type === 'http') ? durationToSecond(cfg.interval) || 86400 : null,
proxy: get_proxygroup(cfg.proxy),
header: cfg.header ? json(cfg.header) : null,
"health-check": {},
@ -513,7 +491,7 @@ uci.foreach(uciconf, uciprov, (cfg) => {
config["proxy-providers"][cfg['.name']]["health-check"] = {
enable: true,
url: cfg.health_url,
interval: parse_time_duration(cfg.health_interval) || 600,
interval: durationToSecond(cfg.health_interval) || 600,
timeout: strToInt(cfg.health_timeout) || 5000,
lazy: (cfg.health_lazy === '0') ? false : null,
"expected-status": cfg.health_expected_status || '204'
@ -535,7 +513,7 @@ uci.foreach(uciconf, ucirule, (cfg) => {
behavior: cfg.behavior,
path: HM_DIR + '/ruleset/' + cfg['.name'],
url: cfg.url,
interval: (cfg.type === 'http') ? parse_time_duration(cfg.interval) || 259200 : null,
interval: (cfg.type === 'http') ? durationToSecond(cfg.interval) || 259200 : null,
proxy: get_proxygroup(cfg.proxy)
};
});

View File

@ -0,0 +1,105 @@
#!/usr/bin/ucode
'use strict';
import { cursor } from 'uci';
import {
isEmpty, strToBool, strToInt, durationToSecond,
arrToObj, removeBlankAttrs,
HM_DIR, RUN_DIR, PRESET_OUTBOUND
} from 'fchomo';
/* UCI config START */
const uci = cursor();
const uciconf = 'fchomo';
uci.load(uciconf);
const uciserver = 'server';
/* UCI config END */
/* Config helper START */
function parse_users(cfg) {
if (isEmpty(cfg))
return null;
let uap, arr, users=[];
for (uap in cfg) {
arr = split(uap, ':');
users[arr[0]] = arr[1];
}
return users;
}
/* Config helper END */
/* Main */
const config = {};
/* Inbound START */
config.listeners = [];
uci.foreach(uciconf, uciserver, (cfg) => {
if (cfg.enabled === '0')
return;
push(config.listeners, {
name: cfg['.name'],
type: cfg.type,
listen: cfg.listen || '::',
port: strToInt(cfg.port),
proxy: 'DIRECT',
udp: strToBool(cfg.udp),
/* Hysteria2 */
up: strToInt(cfg.hysteria_up_mbps),
down: strToInt(cfg.hysteria_down_mbps),
"ignore-client-bandwidth": strToBool(cfg.hysteria_ignore_client_bandwidth),
obfs: cfg.hysteria_obfs_type,
"obfs-password": cfg.hysteria_obfs_password,
masquerade: cfg.hysteria_masquerade,
/* Shadowsocks */
cipher: cfg.shadowsocks_chipher,
password: cfg.shadowsocks_password,
/* Tuic */
"congestion-controller": cfg.tuic_congestion_controller,
"max-idle-time": durationToSecond(cfg.tuic_max_idle_time),
"authentication-timeout": durationToSecond(cfg.tuic_authentication_timeout),
"max-udp-relay-packet-size": strToInt(cfg.tuic_max_udp_relay_packet_size),
/* HTTP / SOCKS / VMess / Tuic / Hysteria2 */
users: (cfg.type in ['http', 'socks', 'mixed', 'vmess']) ? [
{
/* HTTP / SOCKS */
username: cfg.username,
password: cfg.password,
/* VMess */
uuid: cfg.vmess_uuid,
alterId: strToInt(cfg.vmess_alterid)
}
/*{
}*/
] : ((cfg.type in ['tuic', 'hysteria2']) ? {
/* Hysteria2 */
...arrToObj([[cfg.username, cfg.password]]),
/* Tuic */
...arrToObj([[cfg.uuid, cfg.password]])
} : null),
/* TLS */
...(cfg.tls === '1' ? {
alpn: cfg.tls_alpn,
certificate: cfg.tls_cert_path,
"private-key": cfg.tls_key_path
} : {})
});
});
/* Inbound END */
printf('%.J\n', removeBlankAttrs(config));

View File

@ -275,20 +275,22 @@ start_service() {
if [ "$server_auto_firewall" = "1" ]; then
add_firewall() {
local enabled udp proto port
local enabled listen port
config_get_bool enabled "$1" "enabled" "1"
config_get_bool udp "$1" "udp" "1"
[ "$udp" = "0" ] && proto='tcp' || proto='tcp udp'
config_get listen "$1" "listen" "::"
config_get port "$1" "port"
[ "$enabled" = "0" ] && return 0
json_add_object ''
json_add_string type rule
json_add_string target ACCEPT
json_add_string name "$1"
json_add_string proto "$proto"
#json_add_string family '' # '' = IPv4 and IPv6
json_add_string proto 'tcp udp'
json_add_string src "*"
#json_add_string dest '' # '' = input
json_add_string dest_ip "$(echo "$listen" | grep -vE '^(0\.\d+\.\d+\.\d+|::)$')"
json_add_string dest_port "$port"
json_close_object
}
@ -323,6 +325,8 @@ start_service() {
log "$(mihomo -v | awk 'NR==1{print $1,$3}') started."
}
service_started() { procd_set_config_changed firewall; }
stop_service() {
# Client
[ -z "$1" -o "$1" = "mihomo-c" ] && stop_client
@ -388,7 +392,8 @@ service_stopped() {
# Client
[ -n "$(/etc/init.d/$CONF info | jsonfilter -q -e '@.'"$CONF"'.instances["mihomo-c"]')" ] || client_stopped
# Server
return 0
procd_set_config_changed firewall;
}
client_stopped() {

View File

@ -15,6 +15,9 @@
"uci": [ "fchomo" ]
},
"write": {
"file": {
"/tmp/fchomo_certificate.tmp": [ "write" ]
},
"uci": [ "fchomo" ]
}
}

View File

@ -9,6 +9,14 @@ function shellquote(s) {
return `'${replace(s, "'", "'\\''")}'`;
}
function isBinary(str) {
for (let off = 0, byte = ord(str); off < length(str); byte = ord(str, ++off))
if (byte <= 8 || (byte >= 14 && byte <= 31))
return true;
return false;
}
function hasKernelModule(kmod) {
return (system(sprintf('[ -e "/lib/modules/$(uname -r)"/%s ]', shellquote(kmod))) === 0);
}
@ -274,6 +282,74 @@ const methods = {
return { result: true };
}
},
// thanks to homeproxy
certificate_write: {
args: { filename: 'filename' },
call: function(req) {
const writeCertificate = function(filename, priv) {
const tmpcert = '/tmp/fchomo_certificate.tmp';
const filestat = lstat(tmpcert);
if (!filestat || filestat.type !== 'file' || filestat.size <= 0) {
system(`rm -f ${tmpcert}`);
return { result: false, error: 'empty certificate file' };
}
let filecontent = readfile(tmpcert);
if (isBinary(filecontent)) {
system(`rm -f ${tmpcert}`);
return { result: false, error: 'illegal file type: binary' };
}
/* Kanged from luci-proto-openconnect */
const beg = priv ? /^-----BEGIN (RSA|EC) PRIVATE KEY-----$/ : /^-----BEGIN CERTIFICATE-----$/,
end = priv ? /^-----END (RSA|EC) PRIVATE KEY-----$/ : /^-----END CERTIFICATE-----$/,
lines = split(trim(filecontent), /[\r\n]/);
let start = false, i;
for (i = 0; i < length(lines); i++) {
if (match(lines[i], beg))
start = true;
else if (start && !b64dec(lines[i]) && length(lines[i]) !== 64)
break;
}
if (!start || i < length(lines) - 1 || !match(lines[i], end)) {
system(`rm -f ${tmpcert}`);
return { result: false, error: 'this does not look like a correct PEM file' };
}
/* Sanitize certificate */
filecontent = trim(filecontent);
filecontent = replace(filecontent, /\r\n?/g, '\n');
if (!match(filecontent, /\n$/))
filecontent += '\n';
system(`mkdir -p ${HM_DIR}/certs`);
writefile(`${HM_DIR}/certs/${filename}.pem`, filecontent);
system(`rm -f ${tmpcert}`);
return { result: true };
};
const filename = req.args?.filename;
if (!filename || match(filename, /\.\.\//))
return { result: false, error: 'illegal cerificate filename' };
switch (filename) {
case 'client_ca':
case 'server_publickey':
return writeCertificate(filename, false);
break;
case 'server_privatekey':
return writeCertificate(filename, true);
break;
default:
return { result: false, error: 'illegal cerificate filename' };
break;
}
}
}
};

View File

@ -37,7 +37,7 @@ include './cfg.php';
<script type="text/javascript" src="./assets/js/feather.min.js"></script>
<script type="text/javascript" src="./assets/js/jquery-2.1.3.min.js"></script>
<script type="text/javascript" src="./assets/js/bootstrap.min.js"></script>
<?php include './status-bar.php'; ?>
<?php include './ping.php'; ?>
</head>
<body>
<div class="container-sm container-bg text-center callout border border-3 rounded-4 col-11">

View File

@ -30,7 +30,6 @@ $dashboard_link = $neko_cfg['ctrl_host'] . ':' . $neko_cfg['ctrl_port'] . '/ui/d
<link href="./assets/theme/<?php echo $neko_theme ?>" rel="stylesheet">
<script type="text/javascript" src="./assets/js/feather.min.js"></script>
<script type="text/javascript" src="./assets/js/jquery-2.1.3.min.js"></script>
<?php include './status-bar.php'; ?>
</head>
<body>
<head>

View File

@ -526,7 +526,7 @@ if (isset($_GET['ajax'])) {
<script type="text/javascript" src="./assets/js/feather.min.js"></script>
<script type="text/javascript" src="./assets/js/jquery-2.1.3.min.js"></script>
<script type="text/javascript" src="./assets/js/neko.js"></script>
<?php include './status-bar.php'; ?>
<?php include './ping.php'; ?>
</head>
<body>
<?php if ($isNginx): ?>

View File

@ -0,0 +1,513 @@
<?php
ob_start();
include './cfg.php';
$translate = [
'United States' => '美国',
'China' => '中国',
'ISP' => '互联网服务提供商',
'Japan' => '日本',
'South Korea' => '韩国',
'Germany' => '德国',
'France' => '法国',
'United Kingdom' => '英国',
'Canada' => '加拿大',
'Australia' => '澳大利亚',
'Russia' => '俄罗斯',
'India' => '印度',
'Brazil' => '巴西',
'Netherlands' => '荷兰',
'Singapore' => '新加坡',
'Hong Kong' => '香港',
'Saudi Arabia' => '沙特阿拉伯',
'Turkey' => '土耳其',
'Italy' => '意大利',
'Spain' => '西班牙',
'Thailand' => '泰国',
'Malaysia' => '马来西亚',
'Indonesia' => '印度尼西亚',
'South Africa' => '南非',
'Mexico' => '墨西哥',
'Israel' => '以色列',
'Sweden' => '瑞典',
'Switzerland' => '瑞士',
'Norway' => '挪威',
'Denmark' => '丹麦',
'Belgium' => '比利时',
'Finland' => '芬兰',
'Poland' => '波兰',
'Austria' => '奥地利',
'Greece' => '希腊',
'Portugal' => '葡萄牙',
'Ireland' => '爱尔兰',
'New Zealand' => '新西兰',
'United Arab Emirates' => '阿拉伯联合酋长国',
'Argentina' => '阿根廷',
'Chile' => '智利',
'Colombia' => '哥伦比亚',
'Philippines' => '菲律宾',
'Vietnam' => '越南',
'Pakistan' => '巴基斯坦',
'Egypt' => '埃及',
'Nigeria' => '尼日利亚',
'Kenya' => '肯尼亚',
'Morocco' => '摩洛哥',
'Google' => '谷歌',
'Amazon' => '亚马逊',
'Microsoft' => '微软',
'Facebook' => '脸书',
'Apple' => '苹果',
'IBM' => 'IBM',
'Alibaba' => '阿里巴巴',
'Tencent' => '腾讯',
'Baidu' => '百度',
'Verizon' => '威瑞森',
'AT&T' => '美国电话电报公司',
'T-Mobile' => 'T-移动',
'Vodafone' => '沃达丰',
'China Telecom' => '中国电信',
'China Unicom' => '中国联通',
'China Mobile' => '中国移动',
'Chunghwa Telecom' => '中华电信',
'Amazon Web Services (AWS)' => '亚马逊网络服务 (AWS)',
'Google Cloud Platform (GCP)' => '谷歌云平台 (GCP)',
'Microsoft Azure' => '微软Azure',
'Oracle Cloud' => '甲骨文云',
'Alibaba Cloud' => '阿里云',
'Tencent Cloud' => '腾讯云',
'DigitalOcean' => '数字海洋',
'Linode' => '林诺德',
'OVHcloud' => 'OVH 云',
'Hetzner' => '赫兹纳',
'Vultr' => '沃尔特',
'OVH' => 'OVH',
'DreamHost' => '梦想主机',
'InMotion Hosting' => '动态主机',
'HostGator' => '主机鳄鱼',
'Bluehost' => '蓝主机',
'A2 Hosting' => 'A2主机',
'SiteGround' => '站点地',
'Liquid Web' => '液态网络',
'Kamatera' => '卡玛特拉',
'IONOS' => 'IONOS',
'InterServer' => '互联服务器',
'Hostwinds' => '主机之风',
'ScalaHosting' => '斯卡拉主机',
'GreenGeeks' => '绿色极客'
];
$lang = $_GET['lang'] ?? 'en';
?>
<!DOCTYPE html>
<html lang="<?php echo htmlspecialchars($lang); ?>">
<head>
<meta charset="utf-8">
<meta http-equiv="x-dns-prefetch-control" content="on">
<link rel="dns-prefetch" href="//cdn.jsdelivr.net">
<link rel="dns-prefetch" href="//whois.pconline.com.cn">
<link rel="dns-prefetch" href="//forge.speedtest.cn">
<link rel="dns-prefetch" href="//api-ipv4.ip.sb">
<link rel="dns-prefetch" href="//api.ipify.org">
<link rel="dns-prefetch" href="//api.ttt.sh">
<link rel="dns-prefetch" href="//qqwry.api.skk.moe">
<link rel="dns-prefetch" href="//d.skk.moe">
<link rel="preconnect" href="https://forge.speedtest.cn">
<link rel="preconnect" href="https://whois.pconline.com.cn">
<link rel="preconnect" href="https://api-ipv4.ip.sb">
<link rel="preconnect" href="https://api.ipify.org">
<link rel="preconnect" href="https://api.ttt.sh">
<link rel="preconnect" href="https://qqwry.api.skk.moe">
<link rel="preconnect" href="https://d.skk.moe">
<style>
.img-con {
width: 55px;
height: 55px;
display: flex;
justify-content: center;
align-items: center;
overflow: visible;
}
#flag {
width: auto;
height: auto;
max-width: 100%;
max-height: 100%;
}
.status-icon {
width: auto;
height: auto;
max-width: 50px;
max-height: 50px;
}
.container-sm.container-bg.callout.border {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
}
.row.align-items-center {
display: flex;
justify-content: space-between;
align-items: center;
}
.col.text-center {
position: static;
left: auto;
transform: none;
}
.container-sm .row .col-4 {
position: static !important;
order: 2 !important;
width: 100% !important;
padding-left: 54px !important;
margin-top: 5px !important;
text-align: left !important;
}
#ping-result {
font-size: 18px !important;
margin: 0 !important;
white-space: nowrap !important;
font-weight: bold !important;
text-align: left !important;
}
#d-ip {
color: #09B63F;
font-weight: 700 !important;
}
.info.small {
color: #ff69b4;
font-weight: 600;
white-space: nowrap;
}
.site-icon, .img-con {
cursor: pointer !important;
transition: all 0.2s ease !important;
position: relative !important;
user-select: none !important;
}
.site-icon:hover, .img-con:hover {
transform: translateY(-2px) !important;
}
.site-icon:active, .img-con:active {
transform: translateY(1px) !important;
opacity: 0.8 !important;
}
@media (min-width: 769px) and (max-width: 1024px) {
.container-sm.container-bg.callout.border {
width: 100% !important;
}
.row.align-items-center {
display: flex !important;
flex-wrap: wrap !important;
width: 100% !important;
gap: 8px !important;
}
.col-auto {
flex: 0 0 30px !important;
width: 30px !important;
min-width: 30px !important;
}
.img-con, #flag {
width: 30px !important;
height: 30px !important;
object-fit: contain !important;
}
.col-3 {
flex: 1 1 auto !important;
width: auto !important;
min-width: 0 !important;
}
#d-ip, .info.small {
font-size: 14px !important;
margin: 0 !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
white-space: nowrap !important;
}
.col-4 {
flex: 0 1 auto !important;
padding: 0 !important;
order: 3 !important;
}
#ping-result {
font-size: 14px !important;
margin: 0 !important;
}
.status-icons {
display: flex !important;
flex: 0 0 auto !important;
margin-left: auto !important;
order: 2 !important;
}
.status-icon {
width: 30px !important;
height: 30px !important;
object-fit: contain !important;
}
}
@media screen and (max-width: 768px) {
#status-bar-component {
display: none !important;
}
}
</style>
<?php if (in_array($lang, ['zh-cn', 'en', 'auto'])): ?>
<div id="status-bar-component" class="container-sm container-bg callout border">
<div class="row align-items-center">
<div class="col-auto">
<div class="img-con">
<img src="./assets/neko/img/loading.svg" id="flag" title="国旗" onclick="IP.getIpipnetIP()">
</div>
</div>
<div class="col-3">
<p id="d-ip" class="ip-address mb-0">Checking...</p>
<p id="ipip" class="info small mb-0"></p>
</div>
<div class="col-4 d-flex justify-content-center">
<p id="ping-result" class="mb-0"></p>
</div>
<div class="col-auto ms-auto">
<div class="status-icons d-flex">
<div class="site-icon mx-1" onclick="pingHost('baidu', 'Baidu')">
<img src="./assets/neko/img/site_icon_01.png" id="baidu-normal" class="status-icon" style="display: none;">
<img src="./assets/neko/img/site_icon1_01.png" id="baidu-gray" class="status-icon">
</div>
<div class="site-icon mx-1" onclick="pingHost('taobao', '淘宝')">
<img src="./assets/neko/img/site_icon_02.png" id="taobao-normal" class="status-icon" style="display: none;">
<img src="./assets/neko/img/site_icon1_02.png" id="taobao-gray" class="status-icon">
</div>
<div class="site-icon mx-1" onclick="pingHost('google', 'Google')">
<img src="./assets/neko/img/site_icon_03.png" id="google-normal" class="status-icon" style="display: none;">
<img src="./assets/neko/img/site_icon1_03.png" id="google-gray" class="status-icon">
</div>
<div class="site-icon mx-1" onclick="pingHost('youtube', 'YouTube')">
<img src="./assets/neko/img/site_icon_04.png" id="youtube-normal" class="status-icon" style="display: none;">
<img src="./assets/neko/img/site_icon1_04.png" id="youtube-gray" class="status-icon">
</div>
</div>
</div>
</div>
</div>
<?php endif; ?>
<script src="./assets/neko/js/jquery.min.js"></script>
<script type="text/javascript">
const _IMG = './assets/neko/';
const translate = <?php echo json_encode($translate, JSON_UNESCAPED_UNICODE); ?>;
let cachedIP = null;
let cachedInfo = null;
let random = parseInt(Math.random() * 100000000);
const checkSiteStatus = {
sites: {
baidu: 'https://www.baidu.com',
taobao: 'https://www.taobao.com',
google: 'https://www.google.com',
youtube: 'https://www.youtube.com'
},
check: async function() {
for (let [site, url] of Object.entries(this.sites)) {
try {
const response = await fetch(url, {
mode: 'no-cors',
cache: 'no-cache'
});
document.getElementById(`${site}-normal`).style.display = 'inline';
document.getElementById(`${site}-gray`).style.display = 'none';
} catch (error) {
document.getElementById(`${site}-normal`).style.display = 'none';
document.getElementById(`${site}-gray`).style.display = 'inline';
}
}
}
};
async function pingHost(site, siteName) {
const url = checkSiteStatus.sites[site];
const resultElement = document.getElementById('ping-result');
try {
resultElement.innerHTML = `<span style="font-size: 18px">正在测试 ${siteName} 的连接延迟...`;
resultElement.style.color = '#87CEFA';
const startTime = performance.now();
await fetch(url, {
mode: 'no-cors',
cache: 'no-cache'
});
const endTime = performance.now();
const pingTime = Math.round(endTime - startTime);
resultElement.innerHTML = `<span style="font-size: 18px">${siteName} 连接延迟: ${pingTime}ms</span>`;
if(pingTime <= 100) {
resultElement.style.color = '#09B63F';
} else if(pingTime <= 200) {
resultElement.style.color = '#FFA500';
} else {
resultElement.style.color = '#ff6b6b';
}
} catch (error) {
resultElement.innerHTML = `<span style="font-size: 18px">${siteName} 连接超时`;
resultElement.style.color = '#ff6b6b';
}
}
let IP = {
isRefreshing: false,
fetchIP: async () => {
try {
const [ipifyResp, ipsbResp, chinaIpResp] = await Promise.all([
IP.get('https://api.ipify.org?format=json', 'json'),
IP.get('https://api-ipv4.ip.sb/geoip', 'json'),
IP.get('https://myip.ipip.net', 'text')
]);
const ipData = ipifyResp.data.ip || ipsbResp.data.ip;
cachedIP = ipData;
document.getElementById('d-ip').innerHTML = ipData;
return ipData;
} catch (error) {
console.error("Error fetching IP:", error);
throw error;
}
},
get: (url, type) =>
fetch(url, {
method: 'GET',
cache: 'no-store'
}).then((resp) => {
if (type === 'text')
return Promise.all([resp.ok, resp.status, resp.text(), resp.headers]);
else
return Promise.all([resp.ok, resp.status, resp.json(), resp.headers]);
}).then(([ok, status, data, headers]) => {
if (ok) {
return { ok, status, data, headers };
} else {
throw new Error(JSON.stringify(data.error));
}
}).catch(error => {
console.error("Error fetching data:", error);
throw error;
}),
Ipip: async (ip, elID) => {
try {
const [ipsbResp, chinaIpResp] = await Promise.all([
IP.get(`https://api.ip.sb/geoip/${ip}`, 'json'),
IP.get(`https://myip.ipip.net`, 'text')
]);
cachedIP = ip;
cachedInfo = ipsbResp.data;
let chinaIpInfo = null;
try {
if(chinaIpResp.data) {
chinaIpInfo = chinaIpResp.data;
}
} catch(e) {
console.error("Error parsing China IP info:", e);
}
const mergedData = {
...ipsbResp.data,
chinaIpInfo: chinaIpInfo
};
IP.updateUI(mergedData, elID);
} catch (error) {
console.error("Error in Ipip function:", error);
document.getElementById(elID).innerHTML = "获取IP信息失败";
}
},
updateUI: (data, elID) => {
try {
if (!data || !data.country_code) {
document.getElementById('d-ip').innerHTML = "无法获取IP信息";
return;
}
let country = translate[data.country] || data.country || "未知";
let isp = translate[data.isp] || data.isp || "";
let asnOrganization = translate[data.asn_organization] || data.asn_organization || "";
if (data.country === 'Taiwan') {
country = (navigator.language === 'en') ? 'China Taiwan' : '中国台湾';
}
const countryAbbr = data.country_code.toLowerCase();
const isChinaIP = ['cn', 'hk', 'mo', 'tw'].includes(countryAbbr);
let firstLineInfo = `<div style="white-space: nowrap;">`;
firstLineInfo += cachedIP + ' ';
let ipLocation = isChinaIP ?
'<span style="color: #00FF00;">[国内 IP]</span> ' :
'<span style="color: #FF0000;">[境外 IP]</span> ';
firstLineInfo += ipLocation;
if (data.chinaIpInfo) {
firstLineInfo += `[${data.chinaIpInfo}]`;
}
firstLineInfo += `</div>`;
document.getElementById('d-ip').innerHTML = firstLineInfo;
document.getElementById('ipip').innerHTML = `${country} ${isp} ${asnOrganization}`;
document.getElementById('ipip').style.color = '#FF00FF';
$("#flag").attr("src", _IMG + "flags/" + countryAbbr + ".png");
} catch (error) {
console.error("Error in updateUI:", error);
document.getElementById('d-ip').innerHTML = "更新IP信息显示失败";
}
},
getIpipnetIP: async () => {
if(IP.isRefreshing) return;
try {
IP.isRefreshing = true;
document.getElementById('d-ip').innerHTML = "Checking...";
document.getElementById('ipip').innerHTML = "Loading...";
$("#flag").attr("src", _IMG + "img/loading.svg");
const ip = await IP.fetchIP();
await IP.Ipip(ip, 'ipip');
} catch (error) {
console.error("Error in getIpipnetIP function:", error);
document.getElementById('ipip').innerHTML = "获取IP信息失败";
} finally {
IP.isRefreshing = false;
}
}
};
IP.getIpipnetIP();
checkSiteStatus.check();
setInterval(() => checkSiteStatus.check(), 30000);
setInterval(IP.getIpipnetIP, 180000);
</script>
</body>
</html>

View File

@ -72,7 +72,7 @@ $uiVersion = getUiVersion();
<script type="text/javascript" src="./assets/bootstrap/bootstrap.bundle.min.js"></script>
<script type="text/javascript" src="./assets/js/jquery-2.1.3.min.js"></script>
<script type="text/javascript" src="./assets/js/neko.js"></script>
<?php include './status-bar.php'; ?>
<?php include './ping.php'; ?>
</head>
<body>
<div class="container-sm container-bg text-center callout border border-3 rounded-4 col-11">
@ -186,10 +186,13 @@ $uiVersion = getUiVersion();
</div>
<div class="modal-body text-center">
<pre id="logOutput" style="white-space: pre-wrap; word-wrap: break-word; text-align: left; display: inline-block;">开始下载更新...</pre>
<div class="alert alert-info mt-3" role="alert">
提示: 如遇到更新失败,请在终端输入 <code>nokobox</code> 进行更新!
</div>
</div>
</div>
</div>
</div>
<div id="logOutput" class="mt-3"></div>
<style>

View File

@ -1,458 +0,0 @@
<?php
$translate = [
'United States' => '美国',
'China' => '中国',
'ISP' => '互联网服务提供商',
'Japan' => '日本',
'South Korea' => '韩国',
'Germany' => '德国',
'France' => '法国',
'United Kingdom' => '英国',
'Canada' => '加拿大',
'Australia' => '澳大利亚',
'Russia' => '俄罗斯',
'India' => '印度',
'Brazil' => '巴西',
'Netherlands' => '荷兰',
'Singapore' => '新加坡',
'Hong Kong' => '香港',
'Saudi Arabia' => '沙特阿拉伯',
'Turkey' => '土耳其',
'Italy' => '意大利',
'Spain' => '西班牙',
'Thailand' => '泰国',
'Malaysia' => '马来西亚',
'Indonesia' => '印度尼西亚',
'South Africa' => '南非',
'Mexico' => '墨西哥',
'Israel' => '以色列',
'Sweden' => '瑞典',
'Switzerland' => '瑞士',
'Norway' => '挪威',
'Denmark' => '丹麦',
'Belgium' => '比利时',
'Finland' => '芬兰',
'Poland' => '波兰',
'Austria' => '奥地利',
'Greece' => '希腊',
'Portugal' => '葡萄牙',
'Ireland' => '爱尔兰',
'New Zealand' => '新西兰',
'United Arab Emirates' => '阿拉伯联合酋长国',
'Argentina' => '阿根廷',
'Chile' => '智利',
'Colombia' => '哥伦比亚',
'Philippines' => '菲律宾',
'Vietnam' => '越南',
'Pakistan' => '巴基斯坦',
'Egypt' => '埃及',
'Nigeria' => '尼日利亚',
'Kenya' => '肯尼亚',
'Morocco' => '摩洛哥',
'Google' => '谷歌',
'Amazon' => '亚马逊',
'Microsoft' => '微软',
'Facebook' => '脸书',
'Apple' => '苹果',
'IBM' => 'IBM',
'Alibaba' => '阿里巴巴',
'Tencent' => '腾讯',
'Baidu' => '百度',
'Verizon' => '威瑞森',
'AT&T' => '美国电话电报公司',
'T-Mobile' => 'T-移动',
'Vodafone' => '沃达丰',
'China Telecom' => '中国电信',
'China Unicom' => '中国联通',
'China Mobile' => '中国移动',
'Chunghwa Telecom' => '中华电信',
'Amazon Web Services (AWS)' => '亚马逊网络服务 (AWS)',
'Google Cloud Platform (GCP)' => '谷歌云平台 (GCP)',
'Microsoft Azure' => '微软Azure',
'Oracle Cloud' => '甲骨文云',
'Alibaba Cloud' => '阿里云',
'Tencent Cloud' => '腾讯云',
'DigitalOcean' => '数字海洋',
'Linode' => '林诺德',
'OVHcloud' => 'OVH 云',
'Hetzner' => '赫兹纳',
'Vultr' => '沃尔特',
'OVH' => 'OVH',
'DreamHost' => '梦想主机',
'InMotion Hosting' => '动态主机',
'HostGator' => '主机鳄鱼',
'Bluehost' => '蓝主机',
'A2 Hosting' => 'A2主机',
'SiteGround' => '站点地',
'Liquid Web' => '液态网络',
'Kamatera' => '卡玛特拉',
'IONOS' => 'IONOS',
'InterServer' => '互联服务器',
'Hostwinds' => '主机之风',
'ScalaHosting' => '斯卡拉主机',
'GreenGeeks' => '绿色极客'
];
$lang = $_GET['lang'] ?? 'en';
?>
<!DOCTYPE html>
<html lang="<?php echo htmlspecialchars($lang); ?>">
<head>
<meta charset="utf-8">
<meta http-equiv="x-dns-prefetch-control" content="on">
<link rel="dns-prefetch" href="//cdn.jsdelivr.net">
<link rel="dns-prefetch" href="//whois.pconline.com.cn">
<link rel="dns-prefetch" href="//forge.speedtest.cn">
<link rel="dns-prefetch" href="//api-ipv4.ip.sb">
<link rel="dns-prefetch" href="//api.ipify.org">
<link rel="dns-prefetch" href="//api.ttt.sh">
<link rel="dns-prefetch" href="//qqwry.api.skk.moe">
<link rel="dns-prefetch" href="//d.skk.moe">
<link rel="preconnect" href="https://forge.speedtest.cn">
<link rel="preconnect" href="https://whois.pconline.com.cn">
<link rel="preconnect" href="https://api-ipv4.ip.sb">
<link rel="preconnect" href="https://api.ipify.org">
<link rel="preconnect" href="https://api.ttt.sh">
<link rel="preconnect" href="https://qqwry.api.skk.moe">
<link rel="preconnect" href="https://d.skk.moe">
<style>
body {
font-family: 'Montserrat', sans-serif;
line-height: 1.6;
}
.cbi-section {
position: fixed;
bottom: 60px;
left: 50%;
transform: translateX(-50%);
width: 100%;
max-width: 1300px;
margin: 0 auto;
padding: 0 15px;
z-index: 1030;
}
.status {
display: flex;
align-items: center;
justify-content: space-between;
background-color: #E6E6FA;
color: var(--bs-btn-color);
text-align: left;
border-radius: 8px;
height: 65px;
letter-spacing: 0.5px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
padding: 0 30px;
width: 100%;
}
.img-con {
margin-right: 1.5rem;
}
.img-con img {
width: 65px;
height: auto;
border-radius: 5px;
}
.block {
display: flex;
flex-direction: column;
justify-content: center;
min-width: 200px;
}
.ip-address {
color: #2dce89;
font-weight: bold;
font-size: 1.1rem;
margin: 0;
}
.info {
font-style: italic;
color: #fb6340;
font-weight: 520;
font-size: 1.1rem;
margin: 0;
letter-spacing: 0.02em;
font-style: normal;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
.site-status {
display: flex;
align-items: center;
min-width: 300px;
}
.status-icons {
display: flex;
align-items: center;
gap: 25px;
margin-left: auto;
height: 100%;
}
.site-icon {
display: flex;
align-items: center;
height: 100%;
}
.status-icon {
width: auto;
height: 48px;
max-height: 48px;
cursor: pointer;
transition: all 0.3s ease;
object-fit: contain;
vertical-align: middle;
}
.status-icon:hover {
transform: scale(1.1);
}
.ping-status {
flex-grow: 1;
text-align: center;
margin: 0 20px;
}
#ping-result {
margin: 0;
color: #666;
font-size: 14px;
}
.site-icon {
cursor: pointer;
}
#flag {
cursor: pointer;
}
@media screen and (max-width: 480px) {
.status-icons {
gap: 4px;
}
.status-icon {
width: 24px;
height: 24px;
}
.status {
padding: 0 8px;
height: auto;
margin-bottom: 4px;
}
.img-con img {
width: 32px;
}
.ip-address {
font-size: 0.5rem;
}
.info {
font-size: 0.7rem;
}
.ping-status {
margin-top: 0;
}
}
</style>
<?php if (in_array($lang, ['zh-cn', 'en', 'auto'])): ?>
<fieldset class="cbi-section">
<div class="status">
<div class="site-status">
<div class="img-con">
<img src="./assets/neko/img/loading.svg" id="flag" class="pure-img" title="国旗" onclick="IP.getIpipnetIP()">
</div>
<div class="block">
<p id="d-ip" class="ip-address">Checking...</p>
<p id="ipip" class="info"></p>
</div>
</div>
<div class="ping-status">
<p id="ping-result"></p>
</div>
<div class="status-icons">
<div class="site-icon">
<img src="./assets/neko/img/site_icon_01.png" id="baidu-normal" class="status-icon" style="display: none;" onclick="pingHost('baidu', 'Baidu')">
<img src="./assets/neko/img/site_icon1_01.png" id="baidu-gray" class="status-icon" onclick="pingHost('baidu', 'Baidu')">
</div>
<div class="site-icon">
<img src="./assets/neko/img/site_icon_02.png" id="taobao-normal" class="status-icon" style="display: none;" onclick="pingHost('taobao', '淘宝')">
<img src="./assets/neko/img/site_icon1_02.png" id="taobao-gray" class="status-icon" onclick="pingHost('taobao', '淘宝')">
</div>
<div class="site-icon">
<img src="./assets/neko/img/site_icon_03.png" id="google-normal" class="status-icon" style="display: none;" onclick="pingHost('google', 'Google')">
<img src="./assets/neko/img/site_icon1_03.png" id="google-gray" class="status-icon" onclick="pingHost('google', 'Google')">
</div>
<div class="site-icon">
<img src="./assets/neko/img/site_icon_04.png" id="youtube-normal" class="status-icon" style="display: none;" onclick="pingHost('youtube', 'YouTube')">
<img src="./assets/neko/img/site_icon1_04.png" id="youtube-gray" class="status-icon" onclick="pingHost('youtube', 'YouTube')">
</div>
</div>
</div>
</fieldset>
<?php endif; ?>
<script src="./assets/neko/js/jquery.min.js"></script>
<script type="text/javascript">
const _IMG = './assets/neko/';
const translate = <?php echo json_encode($translate, JSON_UNESCAPED_UNICODE); ?>;
let cachedIP = null;
let cachedInfo = null;
let random = parseInt(Math.random() * 100000000);
const checkSiteStatus = {
sites: {
baidu: 'https://www.baidu.com',
taobao: 'https://www.taobao.com',
google: 'https://www.google.com',
youtube: 'https://www.youtube.com'
},
check: async function() {
for (let [site, url] of Object.entries(this.sites)) {
try {
const response = await fetch(url, {
mode: 'no-cors',
cache: 'no-cache'
});
document.getElementById(`${site}-normal`).style.display = 'inline';
document.getElementById(`${site}-gray`).style.display = 'none';
} catch (error) {
document.getElementById(`${site}-normal`).style.display = 'none';
document.getElementById(`${site}-gray`).style.display = 'inline';
}
}
}
};
async function pingHost(site, siteName) {
const url = checkSiteStatus.sites[site];
const resultElement = document.getElementById('ping-result');
try {
resultElement.innerHTML = `<span style="font-size: 20px">正在测试 ${siteName} 的连接延迟...`;
resultElement.style.color = '#666';
const startTime = performance.now();
await fetch(url, {
mode: 'no-cors',
cache: 'no-cache'
});
const endTime = performance.now();
const pingTime = Math.round(endTime - startTime);
resultElement.innerHTML = `<span style="font-size: 20px">${siteName} 连接延迟: ${pingTime}ms</span>`;
resultElement.style.color = pingTime > 200 ? '#ff6b6b' : '#20c997';
} catch (error) {
resultElement.innerHTML = `${siteName} 连接超时`;
resultElement.style.color = '#ff6b6b';
}
}
let IP = {
isRefreshing: false,
fetchIP: async () => {
try {
const [ipifyResp, ipsbResp] = await Promise.all([
IP.get('https://api.ipify.org?format=json', 'json'),
IP.get('https://api-ipv4.ip.sb/geoip', 'json')
]);
const ipData = ipifyResp.data.ip || ipsbResp.data.ip;
cachedIP = ipData;
document.getElementById('d-ip').innerHTML = ipData;
return ipData;
} catch (error) {
console.error("Error fetching IP:", error);
throw error;
}
},
get: (url, type) =>
fetch(url, {
method: 'GET',
cache: 'no-store'
}).then((resp) => {
if (type === 'text')
return Promise.all([resp.ok, resp.status, resp.text(), resp.headers]);
else
return Promise.all([resp.ok, resp.status, resp.json(), resp.headers]);
}).then(([ok, status, data, headers]) => {
if (ok) {
return { ok, status, data, headers };
} else {
throw new Error(JSON.stringify(data.error));
}
}).catch(error => {
console.error("Error fetching data:", error);
throw error;
}),
Ipip: async (ip, elID) => {
try {
const resp = await IP.get(`https://api.ip.sb/geoip/${ip}`, 'json');
cachedIP = ip;
cachedInfo = resp.data;
IP.updateUI(resp.data, elID);
} catch (error) {
console.error("Error in Ipip function:", error);
}
},
updateUI: (data, elID) => {
let country = translate[data.country] || data.country;
let isp = translate[data.isp] || data.isp;
let asnOrganization = translate[data.asn_organization] || data.asn_organization;
if (data.country === 'Taiwan') {
country = (navigator.language === 'en') ? 'China Taiwan' : '中国台湾';
}
const countryAbbr = data.country_code.toLowerCase();
document.getElementById(elID).innerHTML = `${country} ${isp} ${asnOrganization}`;
$("#flag").attr("src", _IMG + "flags/" + countryAbbr + ".png");
document.getElementById(elID).style.color = '#FF00FF';
},
getIpipnetIP: async () => {
if(IP.isRefreshing) return;
try {
IP.isRefreshing = true;
document.getElementById('d-ip').innerHTML = "Checking...";
document.getElementById('ipip').innerHTML = "Loading...";
$("#flag").attr("src", _IMG + "img/loading.svg");
const ip = await IP.fetchIP();
await IP.Ipip(ip, 'ipip');
} catch (error) {
console.error("Error in getIpipnetIP function:", error);
} finally {
IP.isRefreshing = false;
}
}
};
IP.getIpipnetIP();
checkSiteStatus.check();
setInterval(() => checkSiteStatus.check(), 30000);
setInterval(IP.getIpipnetIP, 180000);
</script>
</body>
</html>