Skip to content

Commit 5e8cc07

Browse files
committed
fix: update manage.sh and tests to handle systemd unit file installation and upgrades correctly
1 parent fb996b8 commit 5e8cc07

3 files changed

Lines changed: 123 additions & 53 deletions

File tree

package/manage.sh

Lines changed: 34 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ set -e
33

44
PACKAGE_ROOT="${PACKAGE_ROOT:-"$(dirname -- "$(readlink -f -- "$0";)")"}"
55
export TAILSCALE_ROOT="${TAILSCALE_ROOT:-/data/tailscale}"
6+
SYSTEMD_UNIT_DIR="${SYSTEMD_UNIT_DIR:-/etc/systemd/system}"
67

78
tailscale_status() {
89
if ! command -v tailscale >/dev/null 2>&1; then
@@ -96,23 +97,22 @@ tailscale_install() {
9697
exit 1
9798
}
9899

99-
if [ ! -L "/etc/systemd/system/tailscale-install.service" ]; then
100-
if [ ! -e "${TAILSCALE_ROOT}/tailscale-install.service" ]; then
101-
rm -f /etc/systemd/system/tailscale-install.service
102-
fi
103-
104-
echo "Installing pre-start script to install Tailscale on firmware updates."
105-
ln -s "${TAILSCALE_ROOT}/tailscale-install.service" /etc/systemd/system/tailscale-install.service
106-
fi
107-
108-
if [ ! -L "/etc/systemd/system/tailscale-install.timer" ]; then
109-
if [ ! -e "${TAILSCALE_ROOT}/tailscale-install.timer" ]; then
110-
rm -f /etc/systemd/system/tailscale-install.timer
111-
fi
112-
113-
echo "Installing auto-update timer to ensure that Tailscale is kept installed and up to date."
114-
ln -s "${TAILSCALE_ROOT}/tailscale-install.timer" /etc/systemd/system/tailscale-install.timer
115-
fi
100+
# Remove any pre-existing file or symlink at the destination before copying.
101+
# A plain `cp -f` does NOT pre-unlink the destination; it only retries if the
102+
# open fails. On UDM-SE /data -> /ssd1/.data, so a v3.2.0 symlink at
103+
# ${SYSTEMD_UNIT_DIR}/tailscale-install.{service,timer} pointing back into
104+
# /data/tailscale/ causes both source and destination to canonicalise to the
105+
# same inode, and cp aborts with "are the same file". Explicitly removing the
106+
# destination first handles symlinks, hard-links, and regular files uniformly.
107+
# /etc lives on the overlay root (always mounted), so the copied regular files
108+
# are visible to systemd on every boot even before /ssd1 is mounted.
109+
echo "Installing pre-start script to install Tailscale on firmware updates."
110+
rm -f "${SYSTEMD_UNIT_DIR}/tailscale-install.service"
111+
cp "${PACKAGE_ROOT}/tailscale-install.service" "${SYSTEMD_UNIT_DIR}/tailscale-install.service"
112+
113+
echo "Installing auto-update timer to ensure that Tailscale is kept installed and up to date."
114+
rm -f "${SYSTEMD_UNIT_DIR}/tailscale-install.timer"
115+
cp "${PACKAGE_ROOT}/tailscale-install.timer" "${SYSTEMD_UNIT_DIR}/tailscale-install.timer"
116116

117117
systemctl daemon-reload
118118
systemctl enable tailscale-install.service
@@ -127,15 +127,15 @@ tailscale_uninstall() {
127127
rm -f /etc/apt/sources.list.d/tailscale.list || true
128128

129129
systemctl disable tailscale-install.service || true
130-
rm -f /lib/systemd/system/tailscale-install.service || true
130+
rm -f "${SYSTEMD_UNIT_DIR}/tailscale-install.service" || true
131131

132132
systemctl disable tailscale-install.timer || true
133-
rm -f /lib/systemd/system/tailscale-install.timer || true
133+
rm -f "${SYSTEMD_UNIT_DIR}/tailscale-install.timer" || true
134134

135135
systemctl disable tailscale-cert-renewal.timer || true
136136
systemctl stop tailscale-cert-renewal.timer || true
137-
rm -f /lib/systemd/system/tailscale-cert-renewal.service || true
138-
rm -f /lib/systemd/system/tailscale-cert-renewal.timer || true
137+
rm -f "${SYSTEMD_UNIT_DIR}/tailscale-cert-renewal.service" || true
138+
rm -f "${SYSTEMD_UNIT_DIR}/tailscale-cert-renewal.timer" || true
139139
}
140140

141141
tailscale_has_update() {
@@ -185,17 +185,18 @@ tailscale_cert_generate() {
185185
echo ""
186186
echo "Certificate expires in 90 days. Use '$0 cert renew $TAILSCALE_HOSTNAME' to renew."
187187

188-
# Install auto-renewal timer if not already installed
189-
if [ ! -L "/etc/systemd/system/tailscale-cert-renewal.service" ]; then
190-
if [ -f "${TAILSCALE_ROOT}/tailscale-cert-renewal.service" ] && [ -f "${TAILSCALE_ROOT}/tailscale-cert-renewal.timer" ]; then
191-
echo "Installing certificate auto-renewal timer..."
192-
ln -s "${TAILSCALE_ROOT}/tailscale-cert-renewal.service" /etc/systemd/system/
193-
ln -s "${TAILSCALE_ROOT}/tailscale-cert-renewal.timer" /etc/systemd/system/
194-
systemctl daemon-reload
195-
systemctl enable tailscale-cert-renewal.timer
196-
systemctl start tailscale-cert-renewal.timer
197-
echo "Certificate will be automatically renewed weekly"
198-
fi
188+
# Remove any pre-existing file or symlink before copying (see comment in
189+
# tailscale_install for the full explanation of why rm -f is required).
190+
if [ -f "${PACKAGE_ROOT}/tailscale-cert-renewal.service" ] && [ -f "${PACKAGE_ROOT}/tailscale-cert-renewal.timer" ]; then
191+
echo "Installing certificate auto-renewal timer..."
192+
rm -f "${SYSTEMD_UNIT_DIR}/tailscale-cert-renewal.service"
193+
cp "${PACKAGE_ROOT}/tailscale-cert-renewal.service" "${SYSTEMD_UNIT_DIR}/tailscale-cert-renewal.service"
194+
rm -f "${SYSTEMD_UNIT_DIR}/tailscale-cert-renewal.timer"
195+
cp "${PACKAGE_ROOT}/tailscale-cert-renewal.timer" "${SYSTEMD_UNIT_DIR}/tailscale-cert-renewal.timer"
196+
systemctl daemon-reload
197+
systemctl enable tailscale-cert-renewal.timer
198+
systemctl start tailscale-cert-renewal.timer
199+
echo "Certificate will be automatically renewed weekly"
199200
fi
200201
else
201202
echo "Failed to generate certificate. Ensure:"
@@ -456,4 +457,4 @@ case $1 in
456457
echo "Usage: $0 {status|start|stop|restart|install|uninstall|update|cert}"
457458
exit 1
458459
;;
459-
esac
460+
esac

tests/cert.test.sh

Lines changed: 57 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ trap 'rm -rf ${WORKDIR}' EXIT
1010
export PATH="${WORKDIR}:${PATH}"
1111
export TAILSCALE_ROOT="${WORKDIR}"
1212
export TAILSCALED_SOCK="${WORKDIR}/tailscaled.sock"
13+
export SYSTEMD_UNIT_DIR="${WORKDIR}/systemd"
14+
15+
MANAGE_SH="${ROOT}/package/manage.sh"
16+
17+
mkdir -p "${SYSTEMD_UNIT_DIR}"
1318

1419
mock "${WORKDIR}/ubnt-device-info" "2.0.0"
1520
touch "${TAILSCALED_SOCK}" # Create the tailscaled socket for testing
@@ -24,6 +29,18 @@ case "\$1" in
2429
exit 1
2530
fi
2631
;;
32+
"enable")
33+
echo "--## systemctl enable \$2 ##--"
34+
touch "${WORKDIR}/\$2.enabled"
35+
;;
36+
"daemon-reload")
37+
echo "--## systemctl daemon-reload ##--"
38+
touch "${WORKDIR}/systemctl.daemon-reload"
39+
;;
40+
"start")
41+
echo "--## systemctl start \$2 ##--"
42+
touch "${WORKDIR}/\$2.started"
43+
;;
2744
*)
2845
echo "Unexpected command: \${1}"
2946
exit 1
@@ -62,7 +79,6 @@ mock_tailscale_cert() {
6279
return 1
6380
}
6481
65-
# Override tailscale binary variable for testing
6682
case "\$1" in
6783
cert)
6884
shift
@@ -89,8 +105,7 @@ chmod +x "${WORKDIR}/tailscale"
89105
test_cert_generate() {
90106
touch "$TAILSCALED_SOCK" # Mock running state
91107

92-
# Test generate
93-
output=$("${ROOT}/package/manage.sh" cert generate 2>&1)
108+
output=$("$MANAGE_SH" cert generate 2>&1)
94109
assert_contains "$output" "Certificate generated successfully" "Output contains success message"
95110
assert_file_exists "$TAILSCALE_ROOT/certs/test-host.example.ts.net.crt" "Certificate file exists"
96111
assert_file_exists "$TAILSCALE_ROOT/certs/test-host.example.ts.net.key" "Key file exists"
@@ -113,27 +128,23 @@ test_cert_renew() {
113128
echo "OLD CERT" > "$TAILSCALE_ROOT/certs/test-host.example.ts.net.crt"
114129
echo "OLD KEY" > "$TAILSCALE_ROOT/certs/test-host.example.ts.net.key"
115130

116-
# Test renew
117-
output=$("${ROOT}/package/manage.sh" cert renew 2>&1)
131+
output=$("$MANAGE_SH" cert renew 2>&1)
118132
assert_contains "$output" "Certificate renewed successfully" "Output contains success message"
119133

120-
# Check that certificates were updated
121134
cert_content=$(cat "$TAILSCALE_ROOT/certs/test-host.example.ts.net.crt")
122135
assert_eq "CERTIFICATE" "$cert_content" "Certificate content is correct"
123136

124137
rm -rf "$TAILSCALE_ROOT/certs"
125138
}
126139

127-
# Test certificate listing
140+
# Test certificate info
128141
test_cert_info() {
129142
mkdir -p "$TAILSCALE_ROOT/certs"
130143

131-
# Create test certificates
132144
echo "CERT" > "$TAILSCALE_ROOT/certs/test-host.example.ts.net.crt"
133145
echo "KEY" > "$TAILSCALE_ROOT/certs/test-host.example.ts.net.key"
134146

135-
# Test list
136-
output=$("${ROOT}/package/manage.sh" cert info 2>&1)
147+
output=$("$MANAGE_SH" cert info 2>&1)
137148
assert_contains "$output" "Certificate:" "Output contains Certificate path"
138149
assert_contains "$output" "test-host.example.ts.net.crt" "Output contains test-host.example.ts.net.crt"
139150
assert_contains "$output" "Private key:" "Output contains Private key path"
@@ -146,29 +157,60 @@ test_cert_info() {
146157
test_cert_not_running() {
147158
mkdir -p "$TAILSCALE_ROOT"
148159

149-
# Mock not running state
150160
rm -f "$TAILSCALED_SOCK"
151161

152-
# Test generate when not running
153-
output=$("${ROOT}/package/manage.sh" cert generate 2>&1 || true)
162+
output=$("$MANAGE_SH" cert generate 2>&1) || true
154163
assert_contains "$output" "Tailscale is not running" "Output contains not running message"
155164
}
156165

157166
# Test help command
158167
test_cert_help() {
159-
output=$("${ROOT}/package/manage.sh" cert help 2>&1)
168+
output=$("$MANAGE_SH" cert help 2>&1)
160169
assert_contains "$output" "Usage:" "Output contains usage title"
161170
assert_contains "$output" "generate" "Output contains generate command"
162171
assert_contains "$output" "renew" "Output contains renew command"
163172
assert_contains "$output" "info" "Output contains info command"
164173
assert_contains "$output" "install-unifi" "Output contains install-unifi command"
165174
}
166175

176+
# Test cert-renewal unit upgrade-from-symlink path
177+
# Simulate a v3.2.0 install where tailscale-cert-renewal.{service,timer} in
178+
# SYSTEMD_UNIT_DIR are symlinks pointing back into PACKAGE_ROOT.
179+
# The same-inode cp failure that affects the install path applies here.
180+
test_cert_generate_upgrade_from_symlink() {
181+
touch "$TAILSCALED_SOCK" # Mock running state
182+
183+
# Only run this fixture if the package ships the cert-renewal units;
184+
# skip silently if they are absent (e.g. in minimal test environments).
185+
if [ ! -f "${ROOT}/package/tailscale-cert-renewal.service" ] || \
186+
[ ! -f "${ROOT}/package/tailscale-cert-renewal.timer" ]; then
187+
return 0
188+
fi
189+
190+
ln -sf "${ROOT}/package/tailscale-cert-renewal.service" \
191+
"${SYSTEMD_UNIT_DIR}/tailscale-cert-renewal.service"
192+
ln -sf "${ROOT}/package/tailscale-cert-renewal.timer" \
193+
"${SYSTEMD_UNIT_DIR}/tailscale-cert-renewal.timer"
194+
195+
output=$("$MANAGE_SH" cert generate 2>&1)
196+
assert_contains "$output" "Certificate generated successfully" \
197+
"cert generate succeeds when cert-renewal units are pre-existing symlinks"
198+
199+
[[ ! -L "${SYSTEMD_UNIT_DIR}/tailscale-cert-renewal.service" ]]
200+
assert "tailscale-cert-renewal.service should be a regular file after upgrade, not a symlink"
201+
202+
[[ ! -L "${SYSTEMD_UNIT_DIR}/tailscale-cert-renewal.timer" ]]
203+
assert "tailscale-cert-renewal.timer should be a regular file after upgrade, not a symlink"
204+
205+
rm -rf "$TAILSCALE_ROOT/certs"
206+
}
207+
167208
# Run tests
168209
test_cert_generate
169210
test_cert_renew
170211
test_cert_info
171212
test_cert_not_running
172213
test_cert_help
214+
test_cert_generate_upgrade_from_symlink
173215

174-
echo "All certificate tests passed!"
216+
echo "All certificate tests passed!"

tests/install.test.sh

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,11 @@ trap 'rm -rf ${WORKDIR}' EXIT
1111
export PACKAGE_ROOT="${ROOT}/package"
1212
export TAILSCALE_ROOT="${WORKDIR}"
1313
export TAILSCALED_SOCK="${WORKDIR}/tailscaled.sock"
14+
export SYSTEMD_UNIT_DIR="${WORKDIR}/systemd"
1415
export OS_VERSION="v2"
1516

17+
mkdir -p "${SYSTEMD_UNIT_DIR}"
18+
1619
export PATH="${WORKDIR}:${PATH}"
1720
mock "${WORKDIR}/apt-key" "--## apt-key mock: \$* ##--"
1821
mock "${WORKDIR}/tee" "--## tee mock: \$* ##--"
@@ -59,15 +62,39 @@ export OS_RELEASE_FILE="${WORKDIR}/os-release"
5962

6063
cp "${PACKAGE_ROOT}/tailscale-env" "${WORKDIR}/tailscale-env"
6164

65+
# ── fresh install (clean SYSTEMD_UNIT_DIR) ────────────────────────────────────
6266
"${ROOT}/package/manage.sh" install; assert "Tailscale installer should run successfully"
6367

64-
cat "${WORKDIR}/apt.args"
65-
cat "${WORKDIR}/sed.args"
68+
apt_first=$(head -n 1 "${WORKDIR}/apt.args")
69+
apt_second=$(head -n 2 "${WORKDIR}/apt.args" | tail -n 1)
70+
sed_args=$(cat "${WORKDIR}/sed.args")
6671

67-
assert_contains "$(head -n 1 "${WORKDIR}/apt.args")" "update" "The apt command should be called to update the package list"
68-
assert_contains "$(head -n 2 "${WORKDIR}/apt.args" | tail -n 1)" "install -y tailscale" "The apt command should be called with the command to install tailscale file"
69-
assert_contains "$(cat "${WORKDIR}/sed.args")" "--state /data/tailscale" "The defaults should be updated with state directory"
72+
assert_contains "$apt_first" "update" "The apt command should be called to update the package list"
73+
assert_contains "$apt_second" "install -y tailscale" "The apt command should be called with the command to install tailscale file"
74+
assert_contains "$sed_args" "--state /data/tailscale" "The defaults should be updated with state directory"
7075
[[ -f "${WORKDIR}/tailscaled.restarted" ]]; assert "tailscaled should have been restarted"
7176
[[ -f "${WORKDIR}/tailscaled.service.enabled" ]]; assert "tailscaled unit should be enabled"
7277
[[ -f "${WORKDIR}/systemctl.daemon-reload" ]]; assert "systemctl should have been reloaded"
7378
[[ -f "${WORKDIR}/tailscale-install.service.enabled" ]]; assert "tailscale-install unit should be enabled"
79+
[[ -f "${SYSTEMD_UNIT_DIR}/tailscale-install.service" ]]; assert "tailscale-install.service unit file should be copied to systemd directory"
80+
[[ -f "${SYSTEMD_UNIT_DIR}/tailscale-install.timer" ]]; assert "tailscale-install.timer unit file should be copied to systemd directory"
81+
82+
# ── upgrade-from-symlink path ─────────────────────────────────────────────────
83+
# Simulate a v3.2.0 install where the unit files in SYSTEMD_UNIT_DIR are
84+
# symlinks pointing back into PACKAGE_ROOT (= /data/tailscale on a real
85+
# device). On UDM-SE /data -> /ssd1/.data, so both paths canonicalise to
86+
# the same inode; a plain `cp -f` aborts with "are the same file" and the
87+
# symlinks are left in place, silently breaking the boot-time fix.
88+
# The rm-before-cp change must handle this without error.
89+
ln -sf "${PACKAGE_ROOT}/tailscale-install.service" \
90+
"${SYSTEMD_UNIT_DIR}/tailscale-install.service"
91+
ln -sf "${PACKAGE_ROOT}/tailscale-install.timer" \
92+
"${SYSTEMD_UNIT_DIR}/tailscale-install.timer"
93+
94+
"${ROOT}/package/manage.sh" install; assert "Upgrade from symlink state should succeed without 'same file' error"
95+
96+
# After the fix the destination must be a regular file, not a symlink.
97+
# If rm -f silently failed and cp wrote through the symlink instead, the
98+
# destination would still appear as a file — only -L distinguishes the two.
99+
[[ ! -L "${SYSTEMD_UNIT_DIR}/tailscale-install.service" ]]; assert "tailscale-install.service should be a regular file after upgrade, not a symlink"
100+
[[ ! -L "${SYSTEMD_UNIT_DIR}/tailscale-install.timer" ]]; assert "tailscale-install.timer should be a regular file after upgrade, not a symlink"

0 commit comments

Comments
 (0)