@fastybird/smart-panel-backend 1.1.0-alpha.62 → 1.1.0-alpha.64

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.
Files changed (28) hide show
  1. package/dist/common/utils/privileged-helper.utils.d.ts +8 -0
  2. package/dist/common/utils/privileged-helper.utils.js +28 -0
  3. package/dist/common/utils/privileged-helper.utils.js.map +1 -0
  4. package/dist/modules/platform/services/platform.service.d.ts +2 -0
  5. package/dist/modules/platform/services/platform.service.js +34 -2
  6. package/dist/modules/platform/services/platform.service.js.map +1 -1
  7. package/dist/modules/system/scripts/privileged/helper.py +268 -0
  8. package/dist/modules/system/scripts/privileged/install-helper.py +238 -0
  9. package/dist/modules/system/scripts/update-worker.sh +296 -16
  10. package/dist/modules/system/services/privileged-worker.service.d.ts +3 -0
  11. package/dist/modules/system/services/privileged-worker.service.js +53 -11
  12. package/dist/modules/system/services/privileged-worker.service.js.map +1 -1
  13. package/dist/modules/system/services/update-executor.service.d.ts +2 -0
  14. package/dist/modules/system/services/update-executor.service.js +99 -51
  15. package/dist/modules/system/services/update-executor.service.js.map +1 -1
  16. package/dist/modules/system/services/update.service.js +1 -1
  17. package/dist/modules/system/services/update.service.js.map +1 -1
  18. package/dist/modules/system/system.constants.d.ts +2 -0
  19. package/dist/modules/system/system.constants.js +3 -1
  20. package/dist/modules/system/system.constants.js.map +1 -1
  21. package/dist/plugins/remote-access-cloudflare-tunnel/scripts/cloudflared-setup.sh +17 -2
  22. package/dist/plugins/remote-access-cloudflare-tunnel/services/cloudflare-tunnel-setup.service.js +9 -3
  23. package/dist/plugins/remote-access-cloudflare-tunnel/services/cloudflare-tunnel-setup.service.js.map +1 -1
  24. package/dist/plugins/remote-access-tailscale/scripts/tailscale-setup.sh +8 -0
  25. package/dist/plugins/remote-access-tailscale/services/tailscale-setup.service.js +9 -3
  26. package/dist/plugins/remote-access-tailscale/services/tailscale-setup.service.js.map +1 -1
  27. package/dist/tsconfig.build.tsbuildinfo +1 -1
  28. 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
- TMP_TARBALL="/tmp/smart-panel-backend-v${CLEAN_VERSION}.tar.gz"
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
- curl -fSL -o "$TMP_TARBALL" "$DOWNLOAD_URL" 2>&1 || {
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
- tar -xzf "$TMP_TARBALL" -C "$NEW_VERSION_DIR" 2>&1 || {
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
- sudo -n chown -R smart-panel:smart-panel "$NEW_VERSION_DIR" || {
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
- set -a
1280
- # shellcheck source=/dev/null
1281
- [ -f "$ENV_FILE" ] && . "$ENV_FILE"
1282
- set +a
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;