@fastybird/smart-panel-backend 1.1.0-alpha.62 → 1.1.0-alpha.63
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/common/utils/privileged-helper.utils.d.ts +8 -0
- package/dist/common/utils/privileged-helper.utils.js +28 -0
- package/dist/common/utils/privileged-helper.utils.js.map +1 -0
- package/dist/modules/platform/services/platform.service.d.ts +2 -0
- package/dist/modules/platform/services/platform.service.js +34 -2
- package/dist/modules/platform/services/platform.service.js.map +1 -1
- package/dist/modules/system/scripts/privileged/helper.py +266 -0
- package/dist/modules/system/scripts/privileged/install-helper.py +238 -0
- package/dist/modules/system/scripts/update-worker.sh +296 -16
- package/dist/modules/system/services/privileged-worker.service.d.ts +3 -0
- package/dist/modules/system/services/privileged-worker.service.js +53 -11
- package/dist/modules/system/services/privileged-worker.service.js.map +1 -1
- package/dist/modules/system/services/update-executor.service.d.ts +2 -0
- package/dist/modules/system/services/update-executor.service.js +95 -45
- package/dist/modules/system/services/update-executor.service.js.map +1 -1
- package/dist/plugins/remote-access-cloudflare-tunnel/scripts/cloudflared-setup.sh +17 -2
- package/dist/plugins/remote-access-cloudflare-tunnel/services/cloudflare-tunnel-setup.service.js +9 -3
- package/dist/plugins/remote-access-cloudflare-tunnel/services/cloudflare-tunnel-setup.service.js.map +1 -1
- package/dist/plugins/remote-access-tailscale/scripts/tailscale-setup.sh +8 -0
- package/dist/plugins/remote-access-tailscale/services/tailscale-setup.service.js +9 -3
- package/dist/plugins/remote-access-tailscale/services/tailscale-setup.service.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
#!/usr/bin/python3 -I
|
|
2
|
+
"""Root/operator-only provisioning. Never exposed by the application's sudo policy."""
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import importlib.util
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import pwd
|
|
10
|
+
import re
|
|
11
|
+
import shlex
|
|
12
|
+
import stat
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import tempfile
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def check_source(path):
|
|
19
|
+
# Resolve only to root-protected real paths, not into service-owned package links.
|
|
20
|
+
path = Path(path).resolve(strict=True)
|
|
21
|
+
for item in (path,) + tuple(path.parents):
|
|
22
|
+
info = item.lstat()
|
|
23
|
+
if info.st_uid != 0 or info.st_mode & 0o022 or stat.S_ISLNK(info.st_mode):
|
|
24
|
+
raise ValueError('Helper source must be root-owned and not writable: ' + str(item))
|
|
25
|
+
if not path.is_file() or path.stat().st_nlink != 1:
|
|
26
|
+
raise ValueError('Helper source must be a regular, singly linked file')
|
|
27
|
+
return path
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def protected_directory(path, gid=0, mode=0o755):
|
|
31
|
+
if path.exists() or path.is_symlink():
|
|
32
|
+
helper.trusted(path, directory=True)
|
|
33
|
+
else:
|
|
34
|
+
helper.trusted(path.parent, directory=True)
|
|
35
|
+
path.mkdir(mode=mode)
|
|
36
|
+
os.chown(path, 0, gid)
|
|
37
|
+
os.chmod(path, mode)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def install_file(path, data, mode, gid=0):
|
|
41
|
+
helper.trusted(path.parent, directory=True)
|
|
42
|
+
if path.exists() or path.is_symlink():
|
|
43
|
+
helper.trusted(path)
|
|
44
|
+
fd, temporary = tempfile.mkstemp(prefix='.install-', dir=str(path.parent))
|
|
45
|
+
try:
|
|
46
|
+
os.fchmod(fd, mode)
|
|
47
|
+
os.fchown(fd, 0, gid)
|
|
48
|
+
with os.fdopen(fd, 'wb') as stream:
|
|
49
|
+
stream.write(data)
|
|
50
|
+
stream.flush()
|
|
51
|
+
os.fsync(stream.fileno())
|
|
52
|
+
os.replace(temporary, path)
|
|
53
|
+
finally:
|
|
54
|
+
if os.path.exists(temporary):
|
|
55
|
+
os.unlink(temporary)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def sudo_policy(user):
|
|
59
|
+
return ('# Smart Panel fixed operations; installed by the root-only helper installer.\n'
|
|
60
|
+
+ user + ' ALL=(root) NOPASSWD: /usr/local/libexec/smart-panel/helper *\n'
|
|
61
|
+
+ ''.join(user + ' ALL=(root) NOPASSWD: ' + command + '\n' for command in (
|
|
62
|
+
'/usr/bin/true', '/sbin/reboot', '/sbin/poweroff', '/usr/bin/vcgencmd get_throttled',
|
|
63
|
+
'/usr/bin/systemctl stop smart-panel', '/usr/bin/systemctl start smart-panel',
|
|
64
|
+
'/usr/bin/systemctl restart smart-panel', '/usr/bin/systemctl stop smart-panel-display',
|
|
65
|
+
'/usr/bin/systemctl start smart-panel-display', '/usr/bin/systemctl restart smart-panel-display')))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def existing_data_paths(data_dir):
|
|
69
|
+
values = {'FB_DB_PATH': data_dir + '/data', 'FB_CONFIG_PATH': data_dir + '/config'}
|
|
70
|
+
environment = Path('/etc/smart-panel/environment')
|
|
71
|
+
if environment.exists() or environment.is_symlink():
|
|
72
|
+
helper.trusted(environment)
|
|
73
|
+
for line in environment.read_text().splitlines():
|
|
74
|
+
key, separator, raw = line.partition('=')
|
|
75
|
+
if separator and key in values:
|
|
76
|
+
# Parse literal assignments as data; never evaluate shell expansions.
|
|
77
|
+
parts = shlex.split(raw, comments=True)
|
|
78
|
+
if len(parts) != 1 or not re.fullmatch(r'/[a-zA-Z0-9_./-]+', parts[0]):
|
|
79
|
+
raise ValueError('Use an absolute literal path for ' + key + ' before privilege migration')
|
|
80
|
+
if '..' in Path(parts[0]).parts or str(Path(parts[0])) != parts[0]:
|
|
81
|
+
raise ValueError('Use a canonical path for ' + key + ' before privilege migration')
|
|
82
|
+
values[key] = parts[0]
|
|
83
|
+
return values
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def require_resolved_attempt(directory, protected=False):
|
|
87
|
+
attempt = directory / 'attempt.json'
|
|
88
|
+
lock = directory / 'lock'
|
|
89
|
+
if attempt.exists() or attempt.is_symlink():
|
|
90
|
+
if protected:
|
|
91
|
+
value = helper.read_json(attempt)
|
|
92
|
+
else:
|
|
93
|
+
# Legacy metadata is data only; preserve it and reject any ambiguous
|
|
94
|
+
# record instead of hiding an unresolved attempt by switching paths.
|
|
95
|
+
if attempt.is_symlink() or not attempt.is_file():
|
|
96
|
+
raise ValueError('Unsafe legacy update evidence; resolve before migration')
|
|
97
|
+
with attempt.open(encoding='utf-8') as stream:
|
|
98
|
+
raw = stream.read(65537)
|
|
99
|
+
if len(raw) > 65536:
|
|
100
|
+
raise ValueError('Oversized legacy update evidence')
|
|
101
|
+
value = json.loads(raw)
|
|
102
|
+
if (not isinstance(value, dict) or type(value.get('recoveryRequired')) is not bool
|
|
103
|
+
or value.get('state') not in ('complete', 'failed_preflight', 'reconciled')
|
|
104
|
+
or value['recoveryRequired']):
|
|
105
|
+
raise ValueError('Resolve the previous update attempt before privilege migration')
|
|
106
|
+
elif lock.exists() or lock.is_symlink():
|
|
107
|
+
raise ValueError('Resolve the previous update lock before privilege migration')
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def require_idle_installation(worker_refresh):
|
|
111
|
+
# Offline image builds have no system manager. On a live host, replacing the
|
|
112
|
+
# protocol must not strand an already running legacy or setup operation.
|
|
113
|
+
if not worker_refresh:
|
|
114
|
+
require_resolved_attempt(Path('/var/lib/smart-panel/update-attempt'))
|
|
115
|
+
require_resolved_attempt(helper.STATE / 'update-attempt', protected=True)
|
|
116
|
+
if not Path('/run/systemd/system').is_dir():
|
|
117
|
+
return
|
|
118
|
+
for operation, (unit, _, _, _) in helper.OPERATIONS.items():
|
|
119
|
+
if helper.unit_active(unit, 'scope'):
|
|
120
|
+
raise ValueError('Wait for legacy privileged scopes to finish before migration')
|
|
121
|
+
if helper.unit_active(unit) and not (worker_refresh and operation == 'update'):
|
|
122
|
+
raise ValueError('Wait for privileged operations to finish before migration')
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def main():
|
|
126
|
+
if os.geteuid() != 0:
|
|
127
|
+
raise ValueError('Run this installer as root from a trusted release, not through the service account')
|
|
128
|
+
os.environ.clear()
|
|
129
|
+
os.environ.update(helper.SAFE_ENV)
|
|
130
|
+
os.chdir('/')
|
|
131
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
132
|
+
parser.add_argument('--backend-root', required=True)
|
|
133
|
+
parser.add_argument('--install-type', required=True, choices=('npm', 'image'))
|
|
134
|
+
parser.add_argument('--service-user', required=True)
|
|
135
|
+
parser.add_argument('--data-dir', required=True)
|
|
136
|
+
parser.add_argument('--port', required=True, type=int)
|
|
137
|
+
parser.add_argument('--image-base-dir', default='/opt/smart-panel')
|
|
138
|
+
parser.add_argument('--check-only', action='store_true',
|
|
139
|
+
help='Validate idle state and release inputs without installing; rechecked during installation')
|
|
140
|
+
parser.add_argument('--worker-refresh', action='store_true', help=argparse.SUPPRESS)
|
|
141
|
+
args = parser.parse_args()
|
|
142
|
+
require_idle_installation(args.worker_refresh)
|
|
143
|
+
if not re.fullmatch(r'[a-z_][a-z0-9_-]{0,31}', args.service_user):
|
|
144
|
+
raise ValueError('Invalid service account')
|
|
145
|
+
account = pwd.getpwnam(args.service_user)
|
|
146
|
+
if account.pw_uid == 0 or account.pw_gid == 0:
|
|
147
|
+
raise ValueError('Service account cannot be root')
|
|
148
|
+
for directory in (args.data_dir, args.image_base_dir):
|
|
149
|
+
if (not re.fullmatch(r'/[a-zA-Z0-9_./-]+', directory)
|
|
150
|
+
or '..' in Path(directory).parts or str(Path(directory)) != directory):
|
|
151
|
+
raise ValueError('Unsupported installation path')
|
|
152
|
+
if not 1 <= args.port <= 65535:
|
|
153
|
+
raise ValueError('Invalid backend port')
|
|
154
|
+
backend = Path(args.backend_root).resolve(strict=True)
|
|
155
|
+
scripts = backend / 'dist/modules/system/scripts'
|
|
156
|
+
sources = {
|
|
157
|
+
'helper': check_source(scripts / 'privileged/helper.py'),
|
|
158
|
+
'jobs/update-worker.sh': check_source(scripts / 'update-worker.sh'),
|
|
159
|
+
'jobs/tailscale-setup.sh': check_source(backend / 'dist/plugins/remote-access-tailscale/scripts/tailscale-setup.sh'),
|
|
160
|
+
'jobs/cloudflared-setup.sh': check_source(backend / 'dist/plugins/remote-access-cloudflare-tunnel/scripts/cloudflared-setup.sh'),
|
|
161
|
+
}
|
|
162
|
+
# Read/validate everything before replacing either the helper or the sudo policy.
|
|
163
|
+
payloads = {name: source.read_bytes() for name, source in sources.items()}
|
|
164
|
+
compile(payloads['helper'], 'helper', 'exec')
|
|
165
|
+
for name, data in payloads.items():
|
|
166
|
+
if name.startswith('jobs/'):
|
|
167
|
+
subprocess.run(['/bin/bash', '-n'], input=data, env=helper.SAFE_ENV, check=True)
|
|
168
|
+
config = {'version': 1, 'serviceUser': args.service_user, 'dataDir': args.data_dir,
|
|
169
|
+
'installType': args.install_type, 'imageBaseDir': args.image_base_dir, 'port': args.port}
|
|
170
|
+
data_paths = existing_data_paths(args.data_dir)
|
|
171
|
+
config.update(dbDir=data_paths['FB_DB_PATH'], configDir=data_paths['FB_CONFIG_PATH'])
|
|
172
|
+
if args.install_type == 'image':
|
|
173
|
+
base = Path(args.image_base_dir)
|
|
174
|
+
# A legacy image owns this parent as the service user. Protect the parent only;
|
|
175
|
+
# taking ownership of old application code would not make that code trustworthy.
|
|
176
|
+
helper.trusted(base.parent, directory=True)
|
|
177
|
+
info = base.lstat()
|
|
178
|
+
if not stat.S_ISDIR(info.st_mode) or info.st_uid not in (0, account.pw_uid):
|
|
179
|
+
raise ValueError('Unsafe image installation parent')
|
|
180
|
+
if args.check_only:
|
|
181
|
+
# This is a read-only observation, not a reservation. The real invocation
|
|
182
|
+
# must repeat the checks after the caller has stopped backend requests.
|
|
183
|
+
print('Smart Panel helper preflight passed; no installation files changed.')
|
|
184
|
+
return
|
|
185
|
+
policy_dir = Path('/etc/sudoers.d')
|
|
186
|
+
helper.trusted(policy_dir, directory=True)
|
|
187
|
+
# Validate a private temporary policy before removing the broad legacy permissions.
|
|
188
|
+
fd, candidate = tempfile.mkstemp(prefix='.smart-panel-', dir=str(policy_dir))
|
|
189
|
+
try:
|
|
190
|
+
os.fchmod(fd, 0o440)
|
|
191
|
+
with os.fdopen(fd, 'w', encoding='utf-8') as stream:
|
|
192
|
+
stream.write(sudo_policy(args.service_user))
|
|
193
|
+
subprocess.run(['/usr/sbin/visudo', '-cf', candidate], env=helper.SAFE_ENV, check=True)
|
|
194
|
+
if args.install_type == 'image':
|
|
195
|
+
os.chown(base, 0, 0)
|
|
196
|
+
os.chmod(base, 0o755)
|
|
197
|
+
for path in (Path('/usr/local/libexec'), helper.ROOT, helper.ROOT / 'jobs'):
|
|
198
|
+
protected_directory(path)
|
|
199
|
+
protected_directory(helper.STATE, account.pw_gid, 0o2750)
|
|
200
|
+
# sudo inherits the backend's mount namespace. Permit this one directory
|
|
201
|
+
# in ProtectSystem=strict, while filesystem ownership still denies writes
|
|
202
|
+
# to the service UID. The manager-owned worker has its own namespace.
|
|
203
|
+
dropin = Path('/etc/systemd/system/smart-panel.service.d')
|
|
204
|
+
for directory in (Path('/etc/systemd'), dropin.parent, dropin):
|
|
205
|
+
protected_directory(directory)
|
|
206
|
+
install_file(dropin / 'privileged-helper.conf',
|
|
207
|
+
b'[Service]\nReadWritePaths=/var/lib/smart-panel-privileged\n', 0o644)
|
|
208
|
+
for name, data in payloads.items():
|
|
209
|
+
install_file(helper.ROOT / name, data, 0o755 if name == 'helper' else 0o644)
|
|
210
|
+
install_file(helper.CONFIG, (json.dumps(config, indent=2) + '\n').encode(), 0o644)
|
|
211
|
+
helper.load_config()
|
|
212
|
+
for _, worker, _, _ in helper.OPERATIONS.values():
|
|
213
|
+
helper.trusted(helper.ROOT / 'jobs' / worker)
|
|
214
|
+
target = policy_dir / 'smart-panel'
|
|
215
|
+
if target.exists() or target.is_symlink():
|
|
216
|
+
helper.trusted(target)
|
|
217
|
+
os.replace(candidate, target)
|
|
218
|
+
finally:
|
|
219
|
+
if os.path.exists(candidate):
|
|
220
|
+
os.unlink(candidate)
|
|
221
|
+
if Path('/run/systemd/system').is_dir():
|
|
222
|
+
subprocess.run(['/usr/bin/systemctl', 'daemon-reload'], env=helper.SAFE_ENV, check=True)
|
|
223
|
+
print('Smart Panel fixed helper installed; legacy wildcard grants removed from smart-panel policy.')
|
|
224
|
+
if not args.worker_refresh:
|
|
225
|
+
print('An already running backend must be restarted to adopt the protected state mount; '
|
|
226
|
+
'smart-panel-service migrate-privileges handles this restart.')
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
if __name__ == '__main__':
|
|
230
|
+
try:
|
|
231
|
+
module_file = check_source(Path(__file__).with_name('helper.py'))
|
|
232
|
+
spec = importlib.util.spec_from_file_location('smart_panel_helper', module_file)
|
|
233
|
+
helper = importlib.util.module_from_spec(spec)
|
|
234
|
+
spec.loader.exec_module(helper)
|
|
235
|
+
main()
|
|
236
|
+
except (ValueError, OSError, KeyError, subprocess.SubprocessError) as error:
|
|
237
|
+
print('Smart Panel helper installation refused: ' + str(error), file=sys.stderr)
|
|
238
|
+
sys.exit(1)
|
|
@@ -9,6 +9,55 @@ DOWNLOAD_URL="${DOWNLOAD_URL:-}"
|
|
|
9
9
|
HEALTH_URL="${HEALTH_URL:-}"
|
|
10
10
|
HEALTH_EXPECTED_VERSION="${HEALTH_EXPECTED_VERSION:-$VERSION}"
|
|
11
11
|
UPDATE_START_MODE="${UPDATE_START_MODE:-running-service}"
|
|
12
|
+
HELPER_MANAGED="${HELPER_MANAGED:-0}"
|
|
13
|
+
SMART_PANEL_USER="${SMART_PANEL_USER:-smart-panel}"
|
|
14
|
+
JOB_ID="${PRIVILEGED_WORKER_JOB_ID:-}"
|
|
15
|
+
NATIVE_OWNERSHIP_PENDING=0
|
|
16
|
+
native_dirs=()
|
|
17
|
+
|
|
18
|
+
restore_native_ownership() {
|
|
19
|
+
[ "$NATIVE_OWNERSHIP_PENDING" = 1 ] || return 0
|
|
20
|
+
local native_dir
|
|
21
|
+
for native_dir in "${native_dirs[@]}"; do
|
|
22
|
+
chown -hR root:root "$native_dir" || return 1
|
|
23
|
+
chmod -R a+rX,go-w "$native_dir" || return 1
|
|
24
|
+
done
|
|
25
|
+
NATIVE_OWNERSHIP_PENDING=0
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if [ -n "$JOB_ID" ] && [[ ! "$JOB_ID" =~ ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$ ]]; then
|
|
29
|
+
printf 'Invalid privileged worker job identity\n' >&2
|
|
30
|
+
exit 64
|
|
31
|
+
fi
|
|
32
|
+
|
|
33
|
+
# The protected helper supplies the complete environment from root-owned configuration.
|
|
34
|
+
# Legacy/operator mode remains a compatibility path until explicit privilege migration.
|
|
35
|
+
if [ "$HELPER_MANAGED" = 1 ]; then
|
|
36
|
+
[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$ ]] || {
|
|
37
|
+
printf 'Protected updates require an exact semantic version\n' >&2
|
|
38
|
+
exit 64
|
|
39
|
+
}
|
|
40
|
+
[ "$EUID" -eq 0 ] && [ -n "$JOB_ID" ] || exit 64
|
|
41
|
+
fi
|
|
42
|
+
|
|
43
|
+
run_application() {
|
|
44
|
+
if [ "$HELPER_MANAGED" = 1 ]; then
|
|
45
|
+
/usr/sbin/runuser -u "$SMART_PANEL_USER" -- "$@"
|
|
46
|
+
else
|
|
47
|
+
"$@"
|
|
48
|
+
fi
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
provision_helper() {
|
|
52
|
+
local backend_root="$1"
|
|
53
|
+
[ "$HELPER_MANAGED" = 1 ] || return 0
|
|
54
|
+
local installer="${backend_root}/dist/modules/system/scripts/privileged/install-helper.py"
|
|
55
|
+
[ -f "$installer" ] && [ ! -L "$installer" ] || return 1
|
|
56
|
+
local args=(--worker-refresh --backend-root "$backend_root" --install-type "$INSTALL_TYPE"
|
|
57
|
+
--service-user "$SMART_PANEL_USER" --data-dir "$FB_DATA_DIR" --port "$FB_BACKEND_PORT")
|
|
58
|
+
if [ "$INSTALL_TYPE" = image ]; then args+=(--image-base-dir "$IMAGE_BASE_DIR"); fi
|
|
59
|
+
/usr/bin/python3 -I "$installer" "${args[@]}"
|
|
60
|
+
}
|
|
12
61
|
|
|
13
62
|
# `stopped-maintenance` is an operator-only mode. The API always supplies the default
|
|
14
63
|
# running-service mode; this mode is intentionally guarded by a complete, explicit identity
|
|
@@ -434,12 +483,14 @@ target_process_is_stable() {
|
|
|
434
483
|
state="$(service_property ActiveState 2>/dev/null || true)"
|
|
435
484
|
main_pid="$(service_property MainPID 2>/dev/null || true)"
|
|
436
485
|
start_identity="$(service_property ExecMainStartTimestampMonotonic 2>/dev/null || true)"
|
|
437
|
-
resolved_target="$(readlink -f "$CURRENT_LINK" 2>/dev/null || true)"
|
|
438
|
-
expected_target="$(realpath "$NEW_VERSION_DIR" 2>/dev/null || true)"
|
|
439
486
|
|
|
440
487
|
[ "$state" = "active" ] || return 1
|
|
441
488
|
[ "$main_pid" != "0" ] && [ -n "$main_pid" ] || return 1
|
|
442
489
|
[ -n "$start_identity" ] || return 1
|
|
490
|
+
# npm has no versioned image symlink; process and health checks still apply.
|
|
491
|
+
[ "$INSTALL_TYPE" = "image" ] || return 0
|
|
492
|
+
resolved_target="$(readlink -f "$CURRENT_LINK" 2>/dev/null || true)"
|
|
493
|
+
expected_target="$(realpath "$NEW_VERSION_DIR" 2>/dev/null || true)"
|
|
443
494
|
[ -n "$CURRENT_LINK" ] && [ -n "$NEW_VERSION_DIR" ] || return 1
|
|
444
495
|
[ -n "$expected_target" ] && [ "$resolved_target" = "$expected_target" ] || return 1
|
|
445
496
|
}
|
|
@@ -862,8 +913,8 @@ update_status() {
|
|
|
862
913
|
safe_error=$(printf '%s' "$error" | sed 's/\\/\\\\/g; s/"/\\"/g')
|
|
863
914
|
fi
|
|
864
915
|
|
|
865
|
-
printf '{\n\t"status": "%s",\n\t"phase": "%s",\n\t"targetVersion": "%s",\n\t"startedAt": "%s"' \
|
|
866
|
-
"$status" "$phase" "$VERSION" "$STARTED_AT" > "$tmp_file"
|
|
916
|
+
printf '{\n\t"jobId": "%s",\n\t"status": "%s",\n\t"phase": "%s",\n\t"targetVersion": "%s",\n\t"startedAt": "%s"' \
|
|
917
|
+
"$JOB_ID" "$status" "$phase" "$VERSION" "$STARTED_AT" > "$tmp_file"
|
|
867
918
|
|
|
868
919
|
if [ -n "$completed_at" ]; then
|
|
869
920
|
printf ',\n\t"completedAt": "%s"' "$completed_at" >> "$tmp_file"
|
|
@@ -877,6 +928,13 @@ update_status() {
|
|
|
877
928
|
|
|
878
929
|
cleanup() {
|
|
879
930
|
local exit_code=$?
|
|
931
|
+
if ! restore_native_ownership; then
|
|
932
|
+
if [ -n "$ATTEMPT_ID" ]; then
|
|
933
|
+
hold_attempt "installing" "Could not restore protected native workspace ownership"
|
|
934
|
+
fi
|
|
935
|
+
update_status "failed" "failed" "Native workspace ownership requires operator recovery"
|
|
936
|
+
FINALIZED=true
|
|
937
|
+
fi
|
|
880
938
|
|
|
881
939
|
if [ -n "${BACKUP_TMP_PATH:-}" ]; then
|
|
882
940
|
rm -f "$BACKUP_TMP_PATH" 2>/dev/null || true
|
|
@@ -1064,11 +1122,22 @@ if [ "$INSTALL_TYPE" = "image" ]; then
|
|
|
1064
1122
|
fail_before_migration "downloading" "No download URL provided for image update"
|
|
1065
1123
|
fi
|
|
1066
1124
|
|
|
1067
|
-
|
|
1125
|
+
if [ "$HELPER_MANAGED" = 1 ]; then
|
|
1126
|
+
TMP_TARBALL="$(mktemp "${ATTEMPT_DIR}/backend-XXXXXX.tar.gz")"
|
|
1127
|
+
else
|
|
1128
|
+
TMP_TARBALL="/tmp/smart-panel-backend-v${CLEAN_VERSION}.tar.gz"
|
|
1129
|
+
fi
|
|
1068
1130
|
|
|
1069
|
-
|
|
1131
|
+
if [ "$HELPER_MANAGED" = 1 ]; then
|
|
1132
|
+
curl --proto '=https' --proto-redir '=https' --connect-timeout 15 --max-time 300 \
|
|
1133
|
+
-fSL -o "$TMP_TARBALL" "$DOWNLOAD_URL" 2>&1 || {
|
|
1134
|
+
fail_before_migration "downloading" "Release download failed"
|
|
1135
|
+
}
|
|
1136
|
+
else
|
|
1137
|
+
curl -fSL -o "$TMP_TARBALL" "$DOWNLOAD_URL" 2>&1 || {
|
|
1070
1138
|
fail_before_migration "downloading" "Download failed from ${DOWNLOAD_URL}"
|
|
1071
|
-
|
|
1139
|
+
}
|
|
1140
|
+
fi
|
|
1072
1141
|
|
|
1073
1142
|
if [ "$UPDATE_START_MODE" = "stopped-maintenance" ]; then
|
|
1074
1143
|
actual_archive_sha256="$(sha256sum "$TMP_TARBALL" 2>/dev/null | awk '{print $1}')"
|
|
@@ -1079,13 +1148,102 @@ if [ "$INSTALL_TYPE" = "image" ]; then
|
|
|
1079
1148
|
# ── Extract ──
|
|
1080
1149
|
update_status "installing" "installing"
|
|
1081
1150
|
|
|
1151
|
+
if [ "$HELPER_MANAGED" = 1 ] && { [ -e "$NEW_VERSION_DIR" ] || [ -L "$NEW_VERSION_DIR" ]; }; then
|
|
1152
|
+
fail_before_migration "installing" "Protected update target already exists"
|
|
1153
|
+
fi
|
|
1082
1154
|
mkdir -p "$NEW_VERSION_DIR"
|
|
1083
1155
|
|
|
1084
|
-
|
|
1156
|
+
set +e
|
|
1157
|
+
if [ "$HELPER_MANAGED" = 1 ]; then
|
|
1158
|
+
# Validate the entire archive before writing. Debian 12's Python predates tarfile filters.
|
|
1159
|
+
/usr/bin/python3 -I - "$TMP_TARBALL" "$NEW_VERSION_DIR" <<'PY'
|
|
1160
|
+
import sys, tarfile
|
|
1161
|
+
import os, posixpath, shutil, stat
|
|
1162
|
+
from pathlib import PurePosixPath
|
|
1163
|
+
|
|
1164
|
+
def member_name(value):
|
|
1165
|
+
if not value or '\0' in value or value.startswith('/') or '..' in PurePosixPath(value).parts:
|
|
1166
|
+
raise ValueError('Unsafe archive member path')
|
|
1167
|
+
return str(PurePosixPath(value))
|
|
1168
|
+
|
|
1169
|
+
def link_target(name, member):
|
|
1170
|
+
value = member.linkname
|
|
1171
|
+
if not value or '\0' in value or value.startswith('/'):
|
|
1172
|
+
raise ValueError('Unsafe archive link target')
|
|
1173
|
+
parent = posixpath.dirname(name) if member.issym() else ''
|
|
1174
|
+
target = posixpath.normpath(posixpath.join(parent, value))
|
|
1175
|
+
if target == '..' or target.startswith('../') or target.startswith('/'):
|
|
1176
|
+
raise ValueError('Archive link escapes release directory')
|
|
1177
|
+
return target
|
|
1178
|
+
|
|
1179
|
+
destination = os.path.abspath(sys.argv[2])
|
|
1180
|
+
if not stat.S_ISDIR(os.lstat(destination).st_mode) or os.listdir(destination):
|
|
1181
|
+
raise ValueError('Extraction requires an empty real directory')
|
|
1182
|
+
|
|
1183
|
+
with tarfile.open(sys.argv[1], 'r:gz') as archive:
|
|
1184
|
+
members = {}
|
|
1185
|
+
targets = {}
|
|
1186
|
+
directories = {}
|
|
1187
|
+
for member in archive.getmembers():
|
|
1188
|
+
name = member_name(member.name)
|
|
1189
|
+
if name in members:
|
|
1190
|
+
raise ValueError('Duplicate archive member')
|
|
1191
|
+
if not (member.isdir() or member.type in (tarfile.REGTYPE, tarfile.AREGTYPE)
|
|
1192
|
+
or member.issym() or member.islnk()):
|
|
1193
|
+
raise ValueError('Non-data archive member')
|
|
1194
|
+
if name == '.' and not member.isdir():
|
|
1195
|
+
raise ValueError('Archive root must be a directory')
|
|
1196
|
+
members[name] = member
|
|
1197
|
+
if member.issym() or member.islnk():
|
|
1198
|
+
targets[name] = link_target(name, member)
|
|
1199
|
+
|
|
1200
|
+
for name, member in members.items():
|
|
1201
|
+
for parent in PurePosixPath(name).parents:
|
|
1202
|
+
ancestor = str(parent)
|
|
1203
|
+
if ancestor in members and not members[ancestor].isdir():
|
|
1204
|
+
raise ValueError('Archive member descends through a link or non-directory')
|
|
1205
|
+
if ancestor != '.':
|
|
1206
|
+
directories.setdefault(ancestor, 0o755)
|
|
1207
|
+
if member.isdir() and name != '.':
|
|
1208
|
+
directories[name] = (member.mode & 0o755) | 0o700
|
|
1209
|
+
if member.islnk():
|
|
1210
|
+
target = members.get(targets[name])
|
|
1211
|
+
if target is None or target.type not in (tarfile.REGTYPE, tarfile.AREGTYPE):
|
|
1212
|
+
raise ValueError('Archive hardlink must target a declared regular file')
|
|
1213
|
+
|
|
1214
|
+
# No path created below has a link ancestor: all declared links are installed last.
|
|
1215
|
+
for name in sorted(directories, key=lambda value: (value.count('/'), value)):
|
|
1216
|
+
os.mkdir(os.path.join(destination, name), 0o700)
|
|
1217
|
+
for name, member in members.items():
|
|
1218
|
+
if member.type not in (tarfile.REGTYPE, tarfile.AREGTYPE):
|
|
1219
|
+
continue
|
|
1220
|
+
path = os.path.join(destination, name)
|
|
1221
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
|
1222
|
+
with os.fdopen(fd, 'wb') as output, archive.extractfile(member) as content:
|
|
1223
|
+
shutil.copyfileobj(content, output)
|
|
1224
|
+
os.fchmod(output.fileno(), (member.mode & 0o755) | 0o600)
|
|
1225
|
+
for name, target in targets.items():
|
|
1226
|
+
path = os.path.join(destination, name)
|
|
1227
|
+
if members[name].issym():
|
|
1228
|
+
# Normalize before storing: a raw "link/.." must never escape after link dereference.
|
|
1229
|
+
value = posixpath.relpath(target, posixpath.dirname(name) or '.')
|
|
1230
|
+
os.symlink(value, path)
|
|
1231
|
+
else:
|
|
1232
|
+
os.link(os.path.join(destination, target), path, follow_symlinks=False)
|
|
1233
|
+
for name, mode in directories.items():
|
|
1234
|
+
os.chmod(os.path.join(destination, name), mode)
|
|
1235
|
+
PY
|
|
1236
|
+
extract_exit=$?
|
|
1237
|
+
else
|
|
1238
|
+
tar -xzf "$TMP_TARBALL" -C "$NEW_VERSION_DIR" 2>&1
|
|
1239
|
+
extract_exit=$?
|
|
1240
|
+
fi
|
|
1241
|
+
set -e
|
|
1242
|
+
if [ "$extract_exit" -ne 0 ]; then
|
|
1085
1243
|
rm -rf "$NEW_VERSION_DIR"
|
|
1086
1244
|
rm -f "$TMP_TARBALL"
|
|
1087
1245
|
fail_before_migration "installing" "Failed to extract update archive"
|
|
1088
|
-
|
|
1246
|
+
fi
|
|
1089
1247
|
|
|
1090
1248
|
rm -f "$TMP_TARBALL"
|
|
1091
1249
|
|
|
@@ -1123,6 +1281,9 @@ if [ "$INSTALL_TYPE" = "image" ]; then
|
|
|
1123
1281
|
# compiled for ARM64 by the CI arm-runner. Only install as fallback if
|
|
1124
1282
|
# node_modules is missing (e.g. manually extracted tarball without deps).
|
|
1125
1283
|
if [ ! -d "node_modules" ]; then
|
|
1284
|
+
if [ "$HELPER_MANAGED" = 1 ]; then
|
|
1285
|
+
fail_before_migration "installing" "Protected image updates require bundled release dependencies"
|
|
1286
|
+
fi
|
|
1126
1287
|
npm install --omit=dev 2>&1 || {
|
|
1127
1288
|
rm -rf "$NEW_VERSION_DIR"
|
|
1128
1289
|
fail_before_migration "installing" "npm install failed"
|
|
@@ -1132,10 +1293,16 @@ if [ "$INSTALL_TYPE" = "image" ]; then
|
|
|
1132
1293
|
# Set ownership
|
|
1133
1294
|
# All sudo calls use -n (non-interactive) to fail fast instead of
|
|
1134
1295
|
# hanging on password prompt when running as a detached process.
|
|
1135
|
-
|
|
1296
|
+
if [ "$HELPER_MANAGED" = 1 ]; then
|
|
1297
|
+
chown -hR root:root "$NEW_VERSION_DIR"
|
|
1298
|
+
chmod -R a+rX,go-w "$NEW_VERSION_DIR"
|
|
1299
|
+
provision_helper "$NEW_VERSION_DIR" || fail_before_migration "installing" "Failed to provision protected release helper"
|
|
1300
|
+
else
|
|
1301
|
+
sudo -n chown -R smart-panel:smart-panel "$NEW_VERSION_DIR" || {
|
|
1136
1302
|
rm -rf "$NEW_VERSION_DIR"
|
|
1137
1303
|
fail_before_migration "installing" "Failed to set ownership on ${NEW_VERSION_DIR}"
|
|
1138
|
-
|
|
1304
|
+
}
|
|
1305
|
+
fi
|
|
1139
1306
|
|
|
1140
1307
|
# ── Stop/qualify service ──
|
|
1141
1308
|
update_status "stopping" "stopping"
|
|
@@ -1276,12 +1443,15 @@ if [ "$INSTALL_TYPE" = "image" ]; then
|
|
|
1276
1443
|
|
|
1277
1444
|
set +e
|
|
1278
1445
|
(
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1446
|
+
if [ "$HELPER_MANAGED" != 1 ]; then
|
|
1447
|
+
set -a
|
|
1448
|
+
# Compatibility/operator mode only; protected jobs never source shell configuration.
|
|
1449
|
+
# shellcheck source=/dev/null
|
|
1450
|
+
[ -f "$ENV_FILE" ] && . "$ENV_FILE"
|
|
1451
|
+
set +a
|
|
1452
|
+
fi
|
|
1283
1453
|
cd "$NEW_VERSION_DIR"
|
|
1284
|
-
node node_modules/typeorm/cli.js migration:run -d dist/dataSource.js
|
|
1454
|
+
run_application node node_modules/typeorm/cli.js migration:run -d dist/dataSource.js
|
|
1285
1455
|
) 2>&1 | awk -v output="$MIGRATION_LOG" '
|
|
1286
1456
|
BEGIN { limit = 65536; bytes = 0 }
|
|
1287
1457
|
{
|
|
@@ -1355,6 +1525,116 @@ fi
|
|
|
1355
1525
|
# NPM-based update (global npm installs)
|
|
1356
1526
|
# ──────────────────────────────────────────────────────────────
|
|
1357
1527
|
|
|
1528
|
+
if [ "$HELPER_MANAGED" = 1 ]; then
|
|
1529
|
+
if ! acquire_attempt; then
|
|
1530
|
+
update_status "failed" "failed" "An unresolved update attempt already owns the updater"
|
|
1531
|
+
FINALIZED="true"
|
|
1532
|
+
exit 1
|
|
1533
|
+
fi
|
|
1534
|
+
update_status "stopping" "stopping"
|
|
1535
|
+
write_attempt "active" "stopping"
|
|
1536
|
+
if ! capture_service_identity || ! /usr/bin/systemctl stop "$SERVICE_NAME" || ! wait_for_quiescence; then
|
|
1537
|
+
hold_attempt "stopping" "Service did not reach verified quiescence before npm update"
|
|
1538
|
+
update_status "failed" "failed" "Service quiescence could not be verified; update is held for recovery"
|
|
1539
|
+
FINALIZED="true"
|
|
1540
|
+
exit 1
|
|
1541
|
+
fi
|
|
1542
|
+
update_status "installing" "installing"
|
|
1543
|
+
# npm lifecycle code is application code, never a root installation hook.
|
|
1544
|
+
if ! npm install -g --ignore-scripts --registry=https://registry.npmjs.org "@fastybird/smart-panel@$VERSION"; then
|
|
1545
|
+
hold_attempt "installing" "npm package publication failed"
|
|
1546
|
+
update_status "failed" "failed" "npm installation failed; update is held for recovery"
|
|
1547
|
+
FINALIZED="true"
|
|
1548
|
+
exit 1
|
|
1549
|
+
fi
|
|
1550
|
+
NPM_PACKAGE_ROOT="$(npm root -g)/@fastybird/smart-panel"
|
|
1551
|
+
NPM_BACKEND_ROOT="${NPM_PACKAGE_ROOT}/node_modules/@fastybird/smart-panel-backend"
|
|
1552
|
+
# The helper's restrictive umask must not make published application code inaccessible.
|
|
1553
|
+
chmod -R a+rX,go-w "$NPM_PACKAGE_ROOT"
|
|
1554
|
+
if ! run_application test -r "${NPM_BACKEND_ROOT}/dist/dataSource.js"; then
|
|
1555
|
+
hold_attempt "installing" "Service account cannot traverse the npm release"
|
|
1556
|
+
update_status "failed" "failed" "Installed release permissions prevent service execution; update is held for recovery"
|
|
1557
|
+
FINALIZED="true"
|
|
1558
|
+
exit 1
|
|
1559
|
+
fi
|
|
1560
|
+
# Copy trusted helper artifacts before any application code can write this package tree.
|
|
1561
|
+
if ! provision_helper "$NPM_BACKEND_ROOT"; then
|
|
1562
|
+
hold_attempt "installing" "Failed to provision protected npm release helper"
|
|
1563
|
+
update_status "failed" "failed" "Helper installation failed; update is held for recovery"
|
|
1564
|
+
FINALIZED="true"
|
|
1565
|
+
exit 1
|
|
1566
|
+
fi
|
|
1567
|
+
# Only native dependency workspaces become writable; helper sources and all
|
|
1568
|
+
# application entry points retain their release ownership throughout the rebuild.
|
|
1569
|
+
mapfile -d '' native_dirs < <(find "$NPM_PACKAGE_ROOT/node_modules" -type d \( -name sqlite3 -o -name bcrypt \) -print0)
|
|
1570
|
+
NATIVE_OWNERSHIP_PENDING=1
|
|
1571
|
+
for native_dir in "${native_dirs[@]}"; do chown -hR "$SMART_PANEL_USER" "$native_dir"; done
|
|
1572
|
+
set +e
|
|
1573
|
+
run_application env HOME="$FB_DATA_DIR" npm_config_cache="$FB_DATA_DIR/.npm" \
|
|
1574
|
+
npm rebuild --prefix "$NPM_PACKAGE_ROOT" --omit=dev sqlite3 bcrypt
|
|
1575
|
+
rebuild_exit=$?
|
|
1576
|
+
set -e
|
|
1577
|
+
if ! restore_native_ownership; then
|
|
1578
|
+
hold_attempt "installing" "Could not restore protected native workspace ownership"
|
|
1579
|
+
update_status "failed" "failed" "Native workspace ownership requires operator recovery"
|
|
1580
|
+
FINALIZED=true
|
|
1581
|
+
exit 1
|
|
1582
|
+
fi
|
|
1583
|
+
if [ "$rebuild_exit" -ne 0 ]; then
|
|
1584
|
+
hold_attempt "installing" "Native npm rebuild failed"
|
|
1585
|
+
update_status "failed" "failed" "Native npm rebuild failed; update is held for recovery"
|
|
1586
|
+
FINALIZED="true"
|
|
1587
|
+
exit 1
|
|
1588
|
+
fi
|
|
1589
|
+
if ! service_quiesced; then
|
|
1590
|
+
hold_attempt "migrating" "Stopped service changed before npm migration entry"
|
|
1591
|
+
update_status "failed" "failed" "Stopped service changed before migration entry; update is held for recovery"
|
|
1592
|
+
FINALIZED="true"
|
|
1593
|
+
exit 1
|
|
1594
|
+
fi
|
|
1595
|
+
update_status "migrating" "migrating"
|
|
1596
|
+
MIGRATION_ENTERED=true
|
|
1597
|
+
MIGRATION_LOG="${ATTEMPT_DIR}/${ATTEMPT_ID}.migration.log"
|
|
1598
|
+
: > "$MIGRATION_LOG"
|
|
1599
|
+
chmod 600 "$MIGRATION_LOG"
|
|
1600
|
+
write_attempt "active" "migrating" "" "false" "true"
|
|
1601
|
+
set +e
|
|
1602
|
+
run_application node "${NPM_PACKAGE_ROOT}/node_modules/typeorm/cli.js" migration:run \
|
|
1603
|
+
-d "${NPM_BACKEND_ROOT}/dist/dataSource.js" 2>&1 | awk -v output="$MIGRATION_LOG" '
|
|
1604
|
+
BEGIN { limit = 65536; bytes = 0 }
|
|
1605
|
+
{
|
|
1606
|
+
if (bytes < limit) {
|
|
1607
|
+
line = $0 ORS;
|
|
1608
|
+
remaining = limit - bytes;
|
|
1609
|
+
if (length(line) > remaining) line = substr(line, 1, remaining);
|
|
1610
|
+
printf "%s", line >> output;
|
|
1611
|
+
bytes += length(line);
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
'
|
|
1615
|
+
npm_migration_status=("${PIPESTATUS[@]}")
|
|
1616
|
+
set -e
|
|
1617
|
+
if [ "${npm_migration_status[0]}" -ne 0 ] || [ "${npm_migration_status[1]}" -ne 0 ]; then
|
|
1618
|
+
hold_attempt "migration_failed" "Database migration failed"
|
|
1619
|
+
update_status "failed" "failed" "Database migration failed; update is held for recovery"
|
|
1620
|
+
FINALIZED="true"
|
|
1621
|
+
exit 1
|
|
1622
|
+
fi
|
|
1623
|
+
update_status "starting" "starting"
|
|
1624
|
+
write_attempt "active" "starting" "" "false" "true"
|
|
1625
|
+
if ! /usr/bin/systemctl start "$SERVICE_NAME" || ! wait_for_started_service; then
|
|
1626
|
+
hold_attempt "start_failed" "Target service did not reach verified health after migration"
|
|
1627
|
+
update_status "failed" "failed" "Target service failed to become healthy; update is held for recovery"
|
|
1628
|
+
FINALIZED="true"
|
|
1629
|
+
exit 1
|
|
1630
|
+
fi
|
|
1631
|
+
release_attempt
|
|
1632
|
+
update_status "complete" "complete"
|
|
1633
|
+
FINALIZED=true
|
|
1634
|
+
trap - EXIT
|
|
1635
|
+
exit 0
|
|
1636
|
+
fi
|
|
1637
|
+
|
|
1358
1638
|
# Downloading / preparing
|
|
1359
1639
|
update_status "downloading" "downloading"
|
|
1360
1640
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { PrivilegedOperation } from '../../../common/utils/privileged-helper.utils';
|
|
1
2
|
import { PlatformService } from '../../platform/services/platform.service';
|
|
2
3
|
export interface PrivilegedJobSpec {
|
|
4
|
+
job?: PrivilegedOperation;
|
|
3
5
|
unit: string;
|
|
4
6
|
script: string;
|
|
5
7
|
args: string[];
|
|
@@ -29,6 +31,7 @@ export declare class PrivilegedWorkerService {
|
|
|
29
31
|
run(spec: PrivilegedJobSpec): Promise<{
|
|
30
32
|
id: string;
|
|
31
33
|
}>;
|
|
34
|
+
clearUpdateStatus(): Promise<void>;
|
|
32
35
|
getStatus(id: string): PrivilegedJobStatus | null;
|
|
33
36
|
onStatus(id: string, handler: StatusHandler): () => void;
|
|
34
37
|
private getCapturedStderr;
|