Skip to content

Add a node with one pasted command: sudo mode, stdin token, safe uninstall - #153

Merged
SaladDay merged 15 commits into
mainfrom
codex/node-sudo-install
Sep 26, 2026
Merged

SaladDay merged 15 commits into
mainfrom
codex/node-sudo-install

Conversation

@SaladDay

@SaladDay SaladDay commented Sep 26, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Phase 2b-node of the new-user install work: add a node by pasting one command. When the node installer runs as root, it prepares the host itself.

  • Mode: the node installer (deploy/install/node_install.py) picks sudo mode when it runs as root, and keeps the user-service mode otherwise.
  • Sudo mode:
    • Creates a parsar-node system user, or adopts a matching pre-created one, refusing foreign or dangerous accounts.
    • Adds it only to docker or kvm, whichever owns the device; privileged groups are refused.
    • Installs a root-owned system service with User=parsar-node, enabled at boot.
    • Root only prepares the account, groups and unit. All node work runs as parsar-node in a child that has its own session, /dev/null on stdin, relayed and sanitized output, and a new session keyring. It is killed if root dies.
    • Docker mode makes the node root-equivalent on that host, and the docs say so.
  • Refusals: each is one line and changes nothing:
    • missing or rootless Docker, or no CPU/memory limits;
    • no KVM, or not enough capacity;
    • SELinux enforcing;
    • a foreign account;
    • another node of the same installation, or another Core's node in sudo mode;
    • a different Core address;
    • the token in an environment variable under root.
  • Token: --enrollment-token-stdin keeps it out of argv, the environment, logs and the sudo log. Rerunning with a used token is idempotent.
  • --uninstall:
    • Proceeds only after Core rejects the node with 401, or with --force.
    • The Core check and the file removal run as parsar-node and never follow symlinks.
    • Root handles only systemctl, the Docker network, the account, and removing the service home after userdel.
    • The account is removed only when it matches the record and the installer created it. Groups added to an adopted account are removed.
    • Sandboxes, volumes, images and the microsandbox store are never removed; the installer prints how to remove them by hand.
  • Downloads: they resume with Range and If-Range, stop when a download stalls (less than 64 KiB in 60 s), and are still verified by size and SHA-256. Redirects are refused.
  • Locking: one host-wide lock serializes installs and uninstalls.
  • Docs: the install, operations and node guides and CONTRIBUTING are updated. The Web command that uses stdin and sudo lands in Add nodes with a sudo command that reads the token from stdin #148 right after this.

Review

  1. Round 1: a P1 (uninstall as root followed symlinks in the service user's home) and four P2s, all fixed.
  2. Round 2: the earlier P1 was confirmed fixed. A new P1: the service-user child kept root's controlling terminal, so TIOCSTI injection was possible. Fixed in 1273d6d6/220f0675, together with the P3s.
  3. Focused review of that fix: no P0 or P1. Its P2s (escape sequences in relayed output, the inherited session keyring) and P3s (signal race around fork, orphaned child, process-group kill, messages, stall check) were fixed in 0ef0d4bd. Those were verified by tests, the gate and a real pty check, per the small-fix rule.

Verification

  • Gate: the full local server gate on 0ef0d4bd, the exact head, which includes main 2b5c99a3, passed. The Playwright browser cases were skipped on the server (Chrome unavailable; approved skip).
  • Installer suite: 159 tests, run normally, under umask 077 and under nohup.
  • Real checks on isolated mx1 (Core) and mx2 (node) hosts, with Kimi only:
    • Docker add as root in a real pty, an idempotent rerun, the refusal paths, a Kimi Turn on the new node, Remove, then uninstall.
    • An adopted pre-created account: uninstall kept the account and removed only the group the installer added.
    • Microsandbox in sudo mode: a Kimi Turn in a microVM, and uninstall kept the store.
    • Closing the terminal and Ctrl-C: the child is stopped and the lock is released.
    • Every parsar-node process had no controlling terminal (tty=?), and the output shown to root had no escape bytes.
  • Not covered: the live demo cluster and the rehearsal install were not touched, and no reboot was tested.

View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith with what you need. Autofix is disabled.

…nloads

- Run as root, the node installer prepares the host: it creates the
  parsar-node system user when missing, adds it to the group that owns the
  Docker socket or /dev/kvm, and runs the node as a root-owned system service
  (User=parsar-node, enabled for boot). No lingering or login session is
  needed. Node work runs in a child with the service user's credentials; root
  never runs a file that user can write.
- It never installs Docker, KVM or packages and never changes device
  permissions. Missing Docker or KVM, Docker without CPU and memory limits, too
  little capacity, SELinux enforcing, a foreign parsar-node account, or a node
  for the same installation already on the host stop it with one line before
  anything changes. Reruns change nothing.
- The token can come on standard input (--enrollment-token-stdin), never in
  argv, the environment or sudo's log. The installer's local node still passes
  it in its variable until the installer rework.
- --uninstall removes a node only after Core answers 401 to its credential (or
  with --force when Core is gone). It removes the service, node state and
  Docker network, and deletes the parsar-node account only when the installer
  created it and no node remains. It never removes sandboxes, volumes or images.
- Without sudo, the installer finds the user's systemd manager from su or
  sudo -iu. A registered node that Core removed is told to uninstall.
- Artifact downloads resume from a private partial file with HTTP Range; the
  complete file is still verified by size and SHA-256 before use.
docker info's JSON reports CpuCfsQuota, not the Go field name CPUCfsQuota, so
every Docker host was refused as lacking CPU limits. Found on mx2.
Found on mx2:
- /etc/parsar-node was created 0700 under the command's umask 077, so a no-sudo
  run could not see a sudo-mode node and failed with a generic error instead
  of refusing. The directory is now 0755 (records stay 0644), and an
  unreadable one counts as present.
- A removed node's service ends failed (exit 78); uninstall now resets that
  state so systemd lists no leftover unit.
- The service-user step no longer repeats root's first two progress lines.
…de node

Review round 1 of the sudo mode:
- Uninstall no longer follows links the service user controls. The Core check
  and the node-file deletion run with parsar-node's own credentials and refuse
  any path reached through a symbolic link. Root keeps only systemctl, the
  Docker network, the account, and the home it removes after userdel succeeds.
- The Core address for that check comes from the root-owned record, checked
  like a command-line origin. The retained identity is read without following
  links and with the owner and size checked on the open file.
- The account is validated before any path is derived from it or it is
  deleted: its recorded uid, home and nologin shell must match.
  /etc/parsar-node/account.json records whether the installer created or
  adopted it and which groups it added. An adopted account is left as found,
  minus those groups.
- A microsandbox store (images and sandbox state) is kept on uninstall, with
  instructions; a created account stays until it is gone.
- The service user joins only the docker or kvm group; a device owned by any
  other group is refused. Every check, including the home directory and a
  stale account record, runs before useradd. One host-wide lock serializes
  installs and uninstalls. Root mode sets its own PATH and refuses a token in
  the environment (sudo would log it). A second node is detected in any
  account's home. The service-user step reports unexpected failures clearly.
- Resumed downloads send If-Range, so a changed file restarts from zero.
- Docs state that Docker mode makes the node root-equivalent, that sudo
  log_input records standard input, the checked uninstall command, and what
  uninstall keeps.
Found on mx2: the account and the home directory the installer created for it
stay; only the groups the installer added are removed.
Review round 2, P1: the child that runs as parsar-node kept root's controlling
terminal. A program the service user can replace (ldd, msb,
parsar-sandbox-node, a docker CLI plugin) could then open /dev/tty and inject
commands into the administrator's root shell with TIOCSTI.

The child now starts its own session (setsid) before dropping privileges,
takes /dev/null as standard input, and writes to pipes the parent relays, so
it holds no terminal and cannot open /dev/tty. The token was already read by
the parent and stays in memory. Its own session gets no terminal signals, so
the parent stops the child's process group on Ctrl-C or any error and reaps
it. The service user's docker calls read a root-owned, empty DOCKER_CONFIG
directory, so no CLI plugin or credential helper it controls runs. A test runs
as_service_user and checks the new session, /dev/null input and DOCKER_CONFIG.
Review round 2, P3s:
- account.json records a created account right after useradd, so a later
  failure cannot make it look adopted. Adoption is refused for uid or gid 0 and
  for groups beyond its own, docker and kvm. An adopted home's mode is restored
  on uninstall.
- Root no longer follows the service user's links for decisions. Whether a
  node is installed comes from root-owned records and the unit. The
  microsandbox-store check opens each component without following links. The
  second-node check uses /etc/parsar-node, the invoking user's home and the
  Docker network instead of statting every home.
- Sudo mode serves one Core per host, since its nodes share parsar-node; a
  second Core's node is refused.
- The host-wide lock is created only after the refusal checks, re-checks the
  records it protects, has a bounded wait, and is shared by non-root installs
  and uninstalls.
- Cleanup instructions use sudo -u parsar-node rm -rf. gpasswd is skipped when
  the user is no longer a member. The unit uses the account's own primary group
  (no Group=). uninstall_user tolerates an identity file without core_url.
- Tests: node work goes through run_as, one Core per host, the account record
  written after useradd, adoption criteria, and the adopted home's mode.
…erminal closes

The child runs in its own session, so a closed terminal's SIGHUP reached only the
root parent. The parent died and left the child running as parsar-node, still
downloading and holding the host lock; a broken output pipe even counted as a
network error and was retried. While the child runs, the parent now turns SIGINT,
SIGHUP and SIGTERM (unless ignored, as under nohup) into an error that stops and
reaps the child. The child always reaches its exit, even when its output pipe is
closed.
The Core installer no longer enrolls its own host, so local_node.py is gone. Resolve
the doc overlaps: node state lives under the home of the account that runs the node,
/var/lib/parsar-node for a node added with sudo. The token variable remains only for
the Web console's no-sudo command until it sends the token on standard input.
Under nohup, as in the gate, SIGHUP is ignored and the installer rightly leaves it
ignored, so the test must restore the terminal default itself.
…mask

The Web command runs under umask 077, so root created /run/parsar-node.lock as
0600 and a no-sudo install on the same host could not open it to share the lock;
it then went ahead without waiting. Root now sets the lock file to 0644.
…tep further

Review round 3 of the sudo-mode node installer:
- Relay the child's output through an incremental UTF-8 decoder and replace C0
  controls other than tab and newline, DEL and C1 with "?", so nothing the
  service user controls can write OSC 52, retitle, erase or forge lines on
  root's terminal. It also writes in the terminal's own encoding and keeps
  characters split across reads intact. Uninstall prints the kept Runtime image
  only when provider.json holds an image ID, and store names only as plain text.
- After setuid the child joins a new, empty session keyring, so it cannot use
  the administrator's keys (keyrings(7)).
- Block SIGINT, SIGHUP and SIGTERM across the fork; each side installs its own
  handlers before unblocking.
- The child dies with the parent (PR_SET_PDEATHSIG) and exits at once if the
  parent is already gone. A closed output pipe stops a download instead of
  being retried as a network error.
- stop_child keeps the child a zombie while it waits, then SIGKILLs its whole
  process group, so programs that ignore SIGTERM end too.
- Ctrl-C outside the service-user steps, as while waiting for the host lock,
  prints the interrupted message instead of a traceback; a child ended by a
  signal is reported as such.
- A download that brings less than 64 KiB in 60 s stops, keeping its part for
  the next run to resume.
@SaladDay
SaladDay merged commit 1c0e305 into main Sep 26, 2026
3 checks passed
@SaladDay
SaladDay deleted the codex/node-sudo-install branch October 7, 2026 06:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant