diff --git a/scripts/czdev/publish.py b/scripts/czdev/publish.py index 48e8c91..6430d02 100644 --- a/scripts/czdev/publish.py +++ b/scripts/czdev/publish.py @@ -48,6 +48,18 @@ def run(deb: Optional[str] = None): sys.exit(1) print(" ✓ .desktop file found") + # 1b. Apps must not run as root. Reject any bundled systemd *system* service + # that would run as root (no User=, User=root/0, and no DynamicUser=yes). + root_services = find_root_services(deb_path) + if root_services: + print("ERROR: apps must not run as root, but these systemd services run as root:", file=sys.stderr) + for s in root_services: + print(f" - {s}", file=sys.stderr) + print(" Add `User=` (e.g. `User=pi`) to the [Service] section, or", file=sys.stderr) + print(" rebuild with `pack_deb.py --service-user pi` / `--no-service`.", file=sys.stderr) + sys.exit(1) + print(" ✓ no root systemd services") + # 2. Extract metadata. Ownership is first-come-first-served by package name, # keyed on the uploader's GitHub login (recorded in the release manifest # below and enforced server-side). We no longer require the deb's @@ -299,6 +311,69 @@ def load_store_meta(deb_path: str) -> tuple: sys.exit(1) +# Directories where a *system* systemd unit runs as root by default (user +# units under .../systemd/user/ run as the logged-in user and are not a concern). +_SYSTEMD_SYSTEM_DIRS = ("lib/systemd/system/", "usr/lib/systemd/system/", "etc/systemd/system/") + + +def service_runs_as_root(text: str) -> bool: + """Return True if a systemd .service unit would run as root. + + A unit runs as root unless its [Service] section pins it to a non-root + identity via `User=` or `DynamicUser=yes`. An empty/`root`/`0` + User= (or none) means root. + """ + in_service = False + user = None + dynamic = False + for raw in text.splitlines(): + line = raw.strip() + if not line or line.startswith(("#", ";")): + continue + if line.startswith("[") and line.endswith("]"): + in_service = line.lower() == "[service]" + continue + if not in_service or "=" not in line: + continue + key, _, val = line.partition("=") + key = key.strip().lower() + val = val.strip() + if key == "user": + user = val + elif key == "dynamicuser": + dynamic = val.lower() in ("1", "yes", "true", "on") + if dynamic: + return False + if not user: + return True + return user in ("root", "0") + + +def find_root_services(deb_path: str) -> list: + """List bundled systemd system service units that would run as root.""" + try: + tar_bytes = subprocess.run(["dpkg-deb", "--fsys-tarfile", deb_path], + capture_output=True, check=True).stdout + except (subprocess.CalledProcessError, FileNotFoundError): + return [] + import io + import tarfile + offenders = [] + with tarfile.open(fileobj=io.BytesIO(tar_bytes)) as tf: + for m in tf.getmembers(): + if not m.isfile(): + continue + name = m.name.lstrip("./") + if not (name.endswith(".service") and name.startswith(_SYSTEMD_SYSTEM_DIRS)): + continue + f = tf.extractfile(m) + if f is None: + continue + if service_runs_as_root(f.read().decode("utf-8", "replace")): + offenders.append(name) + return offenders + + def check_desktop(deb_path: str) -> bool: try: result = subprocess.run(["dpkg-deb", "-c", deb_path], diff --git a/scripts/pack_deb.py b/scripts/pack_deb.py index 35e8773..9e304e3 100644 --- a/scripts/pack_deb.py +++ b/scripts/pack_deb.py @@ -22,7 +22,7 @@ def build_deb(package_name, version, bin_name, app_name, src_folder, output_dir, - revision='m5stack1', with_service=True): + revision='m5stack1', with_service=True, service_user='pi'): staging = os.path.join(output_dir, f'debian-{package_name}') deb_file = os.path.join(output_dir, f'{package_name}_{version}-{revision}_arm64.deb') @@ -108,6 +108,12 @@ def build_deb(package_name, version, bin_name, app_name, src_folder, output_dir, f.write('[Unit]\n') f.write(f'Description={app_name} Service\n\n') f.write('[Service]\n') + # AppStore policy: apps must NOT run as root. Pin the service to an + # unprivileged user (defaults to the launcher's run user). Packages + # whose services run as root are rejected by czdev and CI. + if service_user and service_user not in ('root', '0'): + f.write(f'User={service_user}\n') + f.write(f'Group={service_user}\n') f.write(f'ExecStart=/{BIN_PATH}/{bin_name}\n') f.write(f'WorkingDirectory=/{INSTALL_PREFIX}\n') f.write('Restart=always\n') @@ -139,6 +145,9 @@ def main(): parser.add_argument('--output-dir', default='.', help='Output directory for .deb') parser.add_argument('--revision', default='m5stack1', help='Package revision') parser.add_argument('--no-service', action='store_true', help='Skip systemd service generation') + parser.add_argument('--service-user', default='pi', + help='Non-root user the systemd service runs as (default: pi). ' + 'Apps must not run as root; root services are rejected by czdev/CI.') args = parser.parse_args() if not args.app_name: @@ -153,6 +162,7 @@ def main(): output_dir=args.output_dir, revision=args.revision, with_service=not args.no_service, + service_user=args.service_user, )