mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-04 22:33:56 +08:00
🔥 Sync 2026-10-24 23:19:23
This commit is contained in:
1 parent
9886197604
commit
37e1475e87
59 files changed
+5167
-1483
No files matched your search
@@ -13,9 +13,42 @@ const callDoLogout = rpc.declare({ object: 'tailscale', method: 'do_logout' });
|
||||
const callGetSubroutes = rpc.declare({ object: 'tailscale', method: 'get_subroutes' });
|
||||
const callSetupFirewall = rpc.declare({ object: 'tailscale', method: 'setup_firewall' });
|
||||
const callGetLogs = rpc.declare({ object: 'tailscale', method: 'get_logs' });
|
||||
const callSetServiceEnabled = rpc.declare({ object: 'tailscale', method: 'set_service_enabled', params: ['enabled'] });
|
||||
const callGetFirewallStatus = rpc.declare({ object: 'tailscale', method: 'get_firewall_status' });
|
||||
let map;
|
||||
let lastDevicesStatus;
|
||||
|
||||
// uci.changes() returns an object keyed by config name; count the entries so
|
||||
// the firewall button can warn about unsaved work.
|
||||
function countPending(changes) {
|
||||
if (changes == null)
|
||||
return 0;
|
||||
if (Array.isArray(changes))
|
||||
return changes.length;
|
||||
let count = 0;
|
||||
for (const key in changes)
|
||||
count += Array.isArray(changes[key]) ? changes[key].length : 1;
|
||||
return count;
|
||||
}
|
||||
|
||||
// The ucode methods report business failures as { error: ... } in a *successful*
|
||||
// ubus reply, so rpc.declare's rejection never fires for them. Every consumer of
|
||||
// a write operation has to unwrap the reply itself; without this the UI happily
|
||||
// shows "applied" for a reply that only says { error: '...' }.
|
||||
function unwrapRpc(res, action) {
|
||||
const what = action || _('Operation');
|
||||
if (res == null)
|
||||
throw new Error(_('Empty reply from server'));
|
||||
if (res.error)
|
||||
throw new Error(String(res.error));
|
||||
if (res.success === false || res.ok === false) {
|
||||
if (res.message)
|
||||
throw new Error(String(res.message));
|
||||
throw new Error(_('%s failed').format(what));
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
const tailscaleSettingsConf = [
|
||||
[form.Flag, 'service_enabled', _('Enable Tailscale Service'), _('Enable or disable the Tailscale service. When disabled, the service will be stopped and the process will be killed.'), { rmempty: false }],
|
||||
[form.ListValue, 'fw_mode', _('Firewall Mode'), _('Select the firewall backend for Tailscale to use. Requires service restart to take effect.'), {values: ['nftables','iptables'],rmempty: false}],
|
||||
@@ -78,6 +111,94 @@ function getRunningStatus() {
|
||||
});
|
||||
}
|
||||
|
||||
// Poll the background firewall job until it finishes. A poll that fails
|
||||
// (network reloading, the connection dropped for a moment) is expected and is
|
||||
// simply retried on the next tick instead of being reported as an error.
|
||||
function pollFirewallJob(runId, timeoutSec) {
|
||||
const deadline = Date.now() + (timeoutSec || 30) * 1000;
|
||||
|
||||
return new Promise(function(resolve, reject) {
|
||||
let lastState = '';
|
||||
let finished = false;
|
||||
|
||||
function stop() {
|
||||
if (finished)
|
||||
return false;
|
||||
finished = true;
|
||||
clearTimeout(timer);
|
||||
L.Poll.remove(poll);
|
||||
return true;
|
||||
}
|
||||
|
||||
function giveUp(message) {
|
||||
if (stop())
|
||||
reject(new Error(message));
|
||||
}
|
||||
|
||||
function check() {
|
||||
if (finished)
|
||||
return Promise.resolve();
|
||||
|
||||
if (Date.now() > deadline) {
|
||||
giveUp(_('Timed out waiting for the firewall reload'));
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return callGetFirewallStatus().then(function(res) {
|
||||
if (finished)
|
||||
return;
|
||||
|
||||
// a result from an earlier run is not this run's answer
|
||||
if (runId && res?.run_id && res.run_id !== runId)
|
||||
return;
|
||||
|
||||
const state = res?.state || 'unknown';
|
||||
|
||||
if (state === 'done') {
|
||||
if (stop())
|
||||
ui.addTimeLimitedNotification(null, [ E('p', _('Firewall configuration applied.')) ], 5000, 'info');
|
||||
resolve(res);
|
||||
return;
|
||||
}
|
||||
|
||||
if (state === 'failed') {
|
||||
giveUp(res?.message || _('Firewall reload failed'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (state === 'busy') {
|
||||
giveUp(res?.message || _('Another firewall reload is already running'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (state !== lastState) {
|
||||
lastState = state;
|
||||
if (state === 'running' && res?.step === 'firewall')
|
||||
ui.addTimeLimitedNotification(null, [ E('p', _('Reloading firewall...')) ], 3000, 'info');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function poll() {
|
||||
if (finished)
|
||||
return Promise.resolve();
|
||||
// a dropped connection during the reload is expected: leave it to
|
||||
// the next tick, the timer below still guarantees termination
|
||||
return check().catch(function() {});
|
||||
}
|
||||
|
||||
// L.Poll skips a tick while the previous call is still pending, so a
|
||||
// request that never settles would stop the polling loop entirely.
|
||||
// The timer is what actually ends the wait.
|
||||
const timer = setTimeout(function() {
|
||||
giveUp(_('Timed out waiting for the firewall reload'));
|
||||
}, (timeoutSec || 30) * 1000);
|
||||
|
||||
L.Poll.add(poll, 2);
|
||||
poll();
|
||||
});
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
const bytes_num = parseInt(bytes, 10);
|
||||
if (isNaN(bytes_num) || bytes_num === 0) return '-';
|
||||
@@ -522,21 +643,46 @@ return view.extend({
|
||||
|
||||
const fwBtn = s.taboption('general', form.Button, '_setup_firewall', _('Auto Configure Firewall'));
|
||||
fwBtn.description = _('Essential configuration for Subnet Routing (Site-to-Site) and Exit Node features.')
|
||||
+'<br>'+_('It automatically creates the tailscale interface, sets up firewall zones for LAN <-> Tailscale forwarding,')
|
||||
+'<br>'+_('and enables Masquerading and MSS Clamping (MTU fix) to ensure stable connections.');
|
||||
+'<br>'+_('It creates the tailscale interface, adds it to a firewall zone, and sets up forwarding between LAN and Tailscale, plus Tailscale to WAN.')
|
||||
+'<br>'+_('The Tailscale to WAN forwarding is what lets clients send their internet traffic through this device as an Exit Node.')
|
||||
+'<br>'+_('Masquerading and MSS Clamping (MTU fix) are enabled to keep connections stable.');
|
||||
fwBtn.inputstyle = 'action';
|
||||
fwBtn.onclick = function() {
|
||||
return callSetupFirewall().then(function(res) {
|
||||
const msg = res?.message || _('Firewall configuration applied.');
|
||||
ui.addNotification(null, E('p', {}, msg), 'info');
|
||||
}).catch(function(err) {
|
||||
ui.addNotification(null, E('p', {}, _('Failed to configure firewall: %s').format(err?.message || err || 'Unknown error')), 'error');
|
||||
}).then(function() {
|
||||
return new Promise(function(resolve) {
|
||||
window.setTimeout(resolve, 3000);
|
||||
// The button writes network/firewall UCI directly, so staged changes
|
||||
// in the form above would be left unapplied and the user would be
|
||||
// comparing the firewall against an old config. Ask at click time
|
||||
// rather than trusting a snapshot: uci.changes() is an RPC and the
|
||||
// answer goes stale as soon as the page is saved once.
|
||||
// This only warns; blocking would misfire whenever the answer is
|
||||
// stale, and the apply below is harmless either way.
|
||||
return L.resolveDefault(uci.changes(), {}).then(function(changes) {
|
||||
if (countPending(changes) > 0) {
|
||||
ui.addNotification(null, E('p', {}, _('You have unapplied changes. Save & Apply them first so the firewall matches the form.')), 'warning');
|
||||
}
|
||||
return runSetupFirewall();
|
||||
}).catch(function() {
|
||||
return runSetupFirewall();
|
||||
});
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
// The reload runs in the background and can briefly interrupt the
|
||||
// connection carrying this page, so the reply only confirms the commit;
|
||||
// the outcome is polled separately.
|
||||
function runSetupFirewall() {
|
||||
return callSetupFirewall().then(function(res) {
|
||||
unwrapRpc(res, _('Configuring firewall'));
|
||||
|
||||
if (!res.changed_network && !res.changed_firewall) {
|
||||
ui.addNotification(null, E('p', {}, _('Firewall configuration already applied.')), 'info');
|
||||
return null;
|
||||
}
|
||||
|
||||
ui.addTimeLimitedNotification(null, [ E('p', _('Configuration committed, reloading network and firewall. This can take a moment.')) ], 5000, 'info');
|
||||
return pollFirewallJob(res.run_id);
|
||||
}).catch(function(err) {
|
||||
ui.addNotification(null, E('p', {}, _('Failed to configure firewall: %s').format(err?.message || err?.error || err || _('Unknown error'))), 'error');
|
||||
});
|
||||
}
|
||||
|
||||
const helpTitle = s.taboption('general', form.DummyValue, '_help_title');
|
||||
helpTitle.title = _('How to enable Site-to-Site?');
|
||||
@@ -552,6 +698,29 @@ return view.extend({
|
||||
]);
|
||||
};
|
||||
|
||||
// Exit Node is a different feature with a different step that LuCI cannot
|
||||
// complete on its own: the admin console approval happens in the user's
|
||||
// Tailscale account. Without it a client reaches the LAN but gets no
|
||||
// internet, which reads as "the firewall button did not work".
|
||||
const exitHelp = s.taboption('general', form.DummyValue, '_exit_node_help');
|
||||
exitHelp.title = _('How to enable Exit Node?');
|
||||
exitHelp.render = function() {
|
||||
return E('div', { 'class': 'cbi-value', 'style': 'margin-top: 1em; border-top: 1px font-weight: bold;' }, [
|
||||
E('label', { 'class': 'cbi-value-title' }, this.title),
|
||||
E('div', { 'class': 'cbi-value-field', 'style': 'line-height: 1.6em; font-size: 95%; color: #555;' }, [
|
||||
_('1. Enable "Advertise Exit Node".'), E('br'),
|
||||
_('2. Click "Auto Configure Firewall" (it forwards Tailscale traffic to WAN).'), E('br'),
|
||||
_('3. In the Tailscale admin console, edit this device under the route settings and approve it as an Exit Node.'), E('br'),
|
||||
// No quotes around the client's control on purpose: "Exit node"
|
||||
// is a menu in the Tailscale client, not a field on this page,
|
||||
// and quoting it would send the reader to the dropdown above,
|
||||
// which selects the opposite direction.
|
||||
_('4. On the client, set this device as its exit node.'), E('br'),
|
||||
E('strong', { 'style': 'color: #d9534f;' }, _('[Important] Step 3 cannot be done here: it needs your Tailscale account.'))
|
||||
])
|
||||
]);
|
||||
};
|
||||
|
||||
// Create the account settings
|
||||
s.tab('account', _('Account Settings'));
|
||||
defTabOpts(s, 'account', accountConf, { optional: false });
|
||||
@@ -617,7 +786,7 @@ return view.extend({
|
||||
doc.body.innerHTML =
|
||||
'<h2>' + _('Error') + '</h2>' +
|
||||
'<p>' + _('Failed to get login URL. You may close this tab.') + '</p>';
|
||||
ui.addTimeLimitedNotification(null, [ E('p', _('Failed to get login URL: Invalid response from server.')) ], 7000, 'error');
|
||||
ui.addTimeLimitedNotification(null, [ E('p', _('Failed to get login URL: %s').format(res?.error || _('Invalid response from server.'))) ], 7000, 'error');
|
||||
}
|
||||
}).catch(function(err) {
|
||||
ui.hideModal();
|
||||
@@ -643,6 +812,7 @@ return view.extend({
|
||||
ui.showModal(_('Logging out...'), E('em', {}, _('Please wait.')));
|
||||
|
||||
return callDoLogout().then(function(res) {
|
||||
unwrapRpc(res, _('Logout'));
|
||||
ui.hideModal();
|
||||
ui.addTimeLimitedNotification(null, [ E('p', _('Successfully logged out.')) ], 5000, 'info');
|
||||
}).catch(function(err) {
|
||||
@@ -680,6 +850,7 @@ return view.extend({
|
||||
display.replaceChildren(E('em', {}, _('Collecting data ...')));
|
||||
}
|
||||
return callGetLogs().then(function(res) {
|
||||
unwrapRpc(res, _('Loading logs'));
|
||||
if (display) {
|
||||
display.replaceChildren(renderLogs(res));
|
||||
}
|
||||
@@ -701,7 +872,26 @@ return view.extend({
|
||||
// standard OpenWrt apply mechanism, which triggers /etc/init.d/tailscale-settings
|
||||
// and provides automatic rollback protection if the device becomes unreachable.
|
||||
handleSaveApply(ev, mode) {
|
||||
// Read the submitted value before map.save() consumes the staged delta.
|
||||
// uci.get() prefers the staged change over the saved value, and the
|
||||
// staged delta is only populated once the form has been parsed, which
|
||||
// map.save() guarantees here.
|
||||
const wantEnabled = uci.get('tailscale', 'settings', 'service_enabled');
|
||||
|
||||
return map.save().then(function () {
|
||||
// Only records the disable request; the reload handler performs
|
||||
// the stop after the commit, so a LuCI rollback still finds a
|
||||
// running daemon. One concurrency slot on purpose: an overlapping
|
||||
// apply could let the trigger read a half-written request file.
|
||||
// Fail-open on purpose: an install whose rpcd ACL cache predates
|
||||
// this method would otherwise be unable to apply anything. The
|
||||
// worst case is that the stop is skipped for this apply.
|
||||
return callSetServiceEnabled({ enabled: wantEnabled === '0' ? '0' : '1' })
|
||||
.catch(function(err) {
|
||||
console.warn('set_service_enabled failed: %s', err);
|
||||
return null;
|
||||
});
|
||||
}).then(function () {
|
||||
return ui.changes.apply(mode == '0');
|
||||
});
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user