🔥 Sync 2026-10-24 23:19:23

This commit is contained in:
github-actions[bot] committed 2026-10-24 23:19:23 +08:00
1 parent 9886197604
commit 37e1475e87
59 files changed
+5167 -1483

No files matched your search

@@ -0,0 +1,87 @@
#!/bin/sh
# Background worker for the "Auto Configure Firewall" action.
#
# Kept outside the rpcd plugin on purpose: the RPC must return before the
# network is reloaded, otherwise a request coming through the network being
# reloaded (reverse proxy, or LuCI over Tailscale itself) dies with an XHR
# abort and the user cannot tell whether anything happened.
#
# The status file is line based rather than JSON so that a message containing
# a backslash, a quote or a tab cannot corrupt it: whoever reads it builds the
# JSON itself. Line 4 is the run id handed over by the RPC, so a result from a
# previous run can never be mistaken for this one.
#
# Usage: tailscale-setup-firewall <run-id>
STATE_FILE=/var/run/tailscale-fwsetup.status
LOCK_FILE=/var/run/tailscale-fwsetup.lock
RUN_ID="$1"
write_state() {
{
printf '%s\n' "$1"
printf '%s\n' "$2"
# one line only: the reader takes fixed line numbers, so a message that
# wrapped would shift the run id off the last line. tr cannot strip a
# real newline, sed can.
if [ -n "$3" ]; then
printf '%s' "$3" | sed -e 's/\r//g' -e ':a' -e 'N' -e '$!ba' -e 's/\n/ /g'
fi
printf '\n'
printf '%s\n' "$RUN_ID"
} > "$STATE_FILE.tmp" && mv "$STATE_FILE.tmp" "$STATE_FILE"
}
# Only one worker at a time: a second click while a reload is running would
# queue another network reload on top of an unfinished one. mkdir is atomic and
# is the only portable primitive available here; a test-then-write pair lets
# two workers through at the same moment. Stale locks are recognised by age so
# a recycled pid or a killed worker cannot block forever.
LOCK_DIR="$LOCK_FILE.d"
lock_age() {
local ts now
ts=$(cat "$LOCK_DIR/ts" 2>/dev/null)
case "$ts" in ''|*[!0-9]*) echo 999999; return ;; esac
now=$(date +%s)
echo $((now - ts))
}
if ! mkdir "$LOCK_DIR" 2>/dev/null; then
if [ "$(lock_age)" -gt 300 ]; then
# the holder is gone; take the lock over
rm -rf "$LOCK_DIR"
mkdir "$LOCK_DIR" 2>/dev/null || exit 0
else
# someone else is already reloading: say so instead of leaving the
# status file at "queued" and letting the UI time out
write_state busy locked "Another firewall reload is already running"
exit 0
fi
fi
echo "$$" > "$LOCK_DIR/pid"
date +%s > "$LOCK_DIR/ts"
trap 'write_state failed aborted "Worker terminated unexpectedly"; rm -rf "$LOCK_DIR"' EXIT HUP INT TERM
write_state running network "Reloading network"
if ! /etc/init.d/network reload >/dev/null 2>&1; then
write_state failed network "Network reload failed"
trap - EXIT HUP INT TERM
rm -rf "$LOCK_DIR"
exit 1
fi
write_state running firewall "Reloading firewall"
if ! /etc/init.d/firewall reload >/dev/null 2>&1; then
write_state failed firewall "Firewall reload failed"
trap - EXIT HUP INT TERM
rm -rf "$LOCK_DIR"
exit 1
fi
write_state done done "Firewall configuration applied"
trap - EXIT HUP INT TERM
rm -rf "$LOCK_DIR"
@@ -7,7 +7,8 @@
"get_status",
"get_settings",
"get_subroutes",
"get_logs"
"get_logs",
"get_firewall_status"
]
},
"uci": [ "tailscale" ]
@@ -17,7 +18,8 @@
"tailscale": [
"do_login",
"do_logout",
"setup_firewall"
"setup_firewall",
"set_service_enabled"
]
},
"uci": [ "tailscale" ]
@@ -149,7 +149,6 @@ methods.do_login = {
if (status.status != 'logout') {
return { error: 'Tailscale is already logged in and running.' };
}
// --- 1. Prepare and Run Login Command (Once) ---
const loginserver = trim(form_data.loginserver) || '';
const loginserver_authkey = trim(form_data.loginserver_authkey) || '';
@@ -177,7 +176,7 @@ methods.do_login = {
let parts = split(trline, ' ');
for (let part in parts) {
if (index(part, 'http') != -1) {
return { url: part };
return { success: true, url: part };
}
}
}
@@ -199,7 +198,7 @@ methods.do_logout = {
if (logout_result.code != 0) {
return { error: 'Failed to logout: ' + logout_result.stderr };
}
return { success: true };
return { success: true, message: 'Logged out.' };
}
};
@@ -241,6 +240,7 @@ methods.setup_firewall = {
let changed_network = false;
let changed_firewall = false;
let run_id = 0;
// 1. config Network Interface
let net_ts = uci.get('network', 'tailscale');
@@ -335,24 +335,57 @@ methods.setup_firewall = {
changed_firewall = true;
}
// 4. save
// 4. save and hand the reload to a background worker
//
// Reloading network/firewall synchronously here was the reason the
// button failed through a reverse proxy: the reload tears down the
// connection carrying this very request. The worker records its
// progress in /var/run/tailscale-fwsetup.status and the UI polls it.
if (changed_network) {
uci.save('network');
uci.commit('network');
exec('/etc/init.d/network reload');
}
if (changed_firewall) {
uci.save('firewall');
uci.commit('firewall');
exec('/etc/init.d/firewall reload');
}
if (changed_network || changed_firewall) {
if (!access('/usr/libexec/tailscale-setup-firewall')) {
return { error: 'Firewall worker /usr/libexec/tailscale-setup-firewall is missing. Reinstall the package.' };
}
// The run id ties the polled result to this request, and the
// queued state replaces any previous result before the worker
// gets a chance to overwrite it. Without both, a poll arriving
// while the worker is still starting would read the previous
// run's "done" and report success for work that has not run.
run_id = time();
let st = writefile('/var/run/tailscale-fwsetup.status.tmp',
'queued\nstart\n\n' + run_id + '\n');
if (st == null || rename('/var/run/tailscale-fwsetup.status.tmp', '/var/run/tailscale-fwsetup.status') == null) {
// Without a writable status file the poll can never see a
// result, so say so instead of letting the UI wait it out.
return { error: 'Cannot write the firewall status file in /var/run. Configuration was not changed.' };
}
// The background shell exits immediately, so its status only
// tells us whether sh itself started. Anything the worker
// reports afterwards lands in the status file.
let spawn = exec('/bin/sh -c ' + shell_quote('/usr/libexec/tailscale-setup-firewall ' + run_id + ' >/dev/null 2>&1 &'));
if (spawn.code != 0) {
return { error: 'Failed to start the firewall worker: ' + join('\n', spawn.stdout) };
}
}
return {
success: true,
changed_network: changed_network,
changed_firewall: changed_firewall,
message: (changed_network || changed_firewall) ? 'Tailscale firewall/interface configuration applied.' : 'Tailscale firewall/interface already configured.'
job: (changed_network || changed_firewall) ? 'running' : 'idle',
run_id: run_id,
message: (changed_network || changed_firewall) ? 'Configuration committed, reloading network and firewall in the background.' : 'Tailscale firewall/interface already configured.'
};
} catch (e) {
@@ -361,6 +394,75 @@ methods.setup_firewall = {
}
};
// Record that the user asked to disable the service.
//
// The actual stop happens in /etc/init.d/tailscale-settings on the reload
// trigger, which runs right after rpcd commits and *before* it arms the LuCI
// rollback timer. Stopping here would not survive a rollback: rpcd restores
// the config files but never restarts a daemon it did not stop, so the user
// would end up with service_enabled=1 and a dead tunnel. The request file is
// dropped by the reload handler as soon as the config says enabled again.
methods.set_service_enabled = {
args: { enabled: '' },
call: function(request) {
let enabled = request?.args?.enabled;
if (enabled != '0' && enabled != '1') {
return { error: 'Invalid enabled value, expected 0 or 1.' };
}
if (enabled == '1') {
let r = exec('rm -f /var/run/tailscale-settings/disable-requested');
if (r.code != 0) {
return { error: 'Failed to clear the disable request: ' + join('\n', r.stdout) };
}
return { success: true, armed: false };
}
// No daemon probe here: get_status shells out to 'tailscale status'
// and would delay the apply for nothing. A request armed on a box
// without the tailscale package is simply ignored by the reload
// handler, which bails out when the init script is missing.
let r = exec('mkdir -p /var/run/tailscale-settings && touch /var/run/tailscale-settings/disable-requested');
if (r.code != 0) {
return { error: 'Failed to arm the disable request: ' + join('\n', r.stdout) };
}
return { success: true, armed: true };
}
};
// Result of the last background firewall setup job.
//
// Read here rather than in the browser so the ACL needs no file rights: the
// ucode plugin runs as root and rpcd only enforces path ACLs on the file
// ubus object.
methods.get_firewall_status = {
call: function() {
let path = '/var/run/tailscale-fwsetup.status';
if (!access(path)) {
return { success: true, state: 'idle', step: '', message: '', run_id: 0 };
}
try {
let raw = readfile(path);
if (raw == null) {
return { success: true, state: 'idle', step: '', message: '', run_id: 0 };
}
let lines = split(rtrim(raw, '\n'), '\n');
return {
success: true,
state: lines[0] || 'unknown',
step: lines[1] || '',
message: lines[2] || '',
// the run id is always the last line, so a message that somehow
// still contains a newline cannot shift it into line 3
run_id: int(lines[length(lines) - 1]) || 0
};
} catch (e) {
return { success: true, state: 'unknown', step: '', message: 'Unreadable job state: ' + e, run_id: 0 };
}
}
};
methods.get_logs = {
args: { lines: 200 },
call: function(request) {
@@ -368,9 +470,9 @@ methods.get_logs = {
let cmd = 'logread -l ' + lines + ' 2>/dev/null | grep -i -E "tailscale" || true';
let result = exec(cmd);
if (result.code == 0) {
return { logs: result.stdout };
return { success: true, logs: result.stdout };
}
return { logs: [], error: join(' ', result.stderr) };
return { success: false, logs: [], error: join(' ', result.stderr) };
}
};