diff --git a/.gitattributes b/.gitattributes index f210b0cb..63725b64 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,7 +1,27 @@ * text=auto +/.gitattributes text eol=lf *.sh text eol=lf +*.py text eol=lf *.rs text eol=lf *.toml text eol=lf *.json text eol=lf *.md text eol=lf +*.service text eol=lf +*.timer text eol=lf +*.conf text eol=lf *.ps1 text eol=crlf + +build/usb/version.env text eol=lf +build/usb/hooks/* text eol=lf +build/usb/bootloaders/** text eol=lf +build/usb/package-lists/rigos.list.chroot text eol=lf +build/usb/includes.chroot/usr/local/bin/* text eol=lf +build/usb/includes.chroot/usr/local/sbin/rigos-* text eol=lf +build/usb/includes.chroot/usr/lib/rigos/rigos-firstboot-* text eol=lf +build/usb/includes.chroot/usr/lib/rigos/rigos-randomx-msr text eol=lf +build/usb/includes.chroot/usr/lib/rigos/rigos-runtime-* text eol=lf +build/usb/includes.chroot/usr/lib/rigos/rigos-miner-* text eol=lf +build/usb/includes.chroot/usr/lib/rigos/rigos-remote-* text eol=lf +build/usb/includes.chroot/usr/lib/rigos/rigos-lifecycle-* text eol=lf +build/usb/grub-theme/** text eol=lf +build/usb/grub-theme/**/*.png -text diff --git a/.github/workflows/source-gate.yml b/.github/workflows/source-gate.yml new file mode 100644 index 00000000..98293b3a --- /dev/null +++ b/.github/workflows/source-gate.yml @@ -0,0 +1,60 @@ +name: RIGOS Source Gate + +on: + pull_request: + branches: + - performance/randomx-sustained-hash + - stability/hive-exit-gates + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: source-gate-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + verify: + name: Authoritative Linux source verification + runs-on: ubuntu-24.04 + timeout-minutes: 45 + + steps: + - name: Check out exact pull-request head + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Install required host tools + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install --yes --no-install-recommends ripgrep + + - name: Install locked Rust toolchain + run: | + set -euo pipefail + rustup toolchain install 1.85.1 \ + --profile minimal \ + --component rustfmt \ + --component clippy + rustup override set 1.85.1 + rustc --version + cargo --version + cargo fmt --version + cargo clippy --version + + - name: Run authoritative RIGOS source gate + env: + CARGO_TERM_COLOR: always + run: | + set -euo pipefail + bash ./scripts/verify-wsl-entrypoint.sh "$GITHUB_WORKSPACE" + + - name: Record exact verified revision + if: always() + run: | + set -euo pipefail + printf 'RIGOS_VERIFIED_HEAD=%s\n' "$(git rev-parse HEAD)" + git status --short diff --git a/.gitignore b/.gitignore index 19d2cb47..11610b1e 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,5 @@ *.age.partial *.pem *.key +__pycache__/ +*.py[cod] diff --git a/Cargo.lock b/Cargo.lock index 88b1e7e3..20357e13 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -845,6 +845,19 @@ dependencies = [ "rigos-machine", ] +[[package]] +name = "rigos-performance" +version = "0.0.1" +dependencies = [ + "chrono", + "clap", + "rigos-schema", + "serde", + "serde_json", + "thiserror", + "uuid", +] + [[package]] name = "rigos-pool" version = "0.0.1" diff --git a/Cargo.toml b/Cargo.toml index 5108ed96..49215704 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ members = [ "crates/rigos-pool", "crates/rigos-state", "crates/rigos-config", + "crates/rigos-performance", ] resolver = "2" diff --git a/README.md b/README.md index 20b779a5..9f98a20b 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Local-first CPU appliance delivered as a bootable USB image. -Current development preview is `RIGOS 0.0.4-alpha.5`. +Current development preview is `RIGOS 0.0.4-alpha.6`. The persistent appliance uses a raw MBR disk image for Legacy BIOS and removable-media UEFI boot. @@ -23,9 +23,11 @@ The recovery ISO is stateless and does not grow the state partition. 0.0.4-alpha.3 fixed console order but kept the first boot screen hidden 0.0.4-alpha.4 keeps the first boot screen on tty and captures answers separately 0.0.4-alpha.5 adds local rig profiles and portable XMRig Flight Sheets +0.0.4-alpha.6 adds visible machine-wide huge page authority ``` -Alpha five is isolated on its development branch. Alpha four physical state validation remains separate. +Alpha five is frozen at its physically validated image and Alpha six develops +performance authority on a separate branch. ## Verification diff --git a/build/usb/Dockerfile b/build/usb/Dockerfile index 9f773489..02958f38 100644 --- a/build/usb/Dockerfile +++ b/build/usb/Dockerfile @@ -7,5 +7,11 @@ RUN apt-get update \ ripgrep rsync squashfs-tools syslinux-common syslinux-utils udev uuid-runtime xorriso zstd \ && rm -rf /var/lib/apt/lists/* +ENV PATH="/usr/local/cargo/bin:/usr/local/rustup/bin:${PATH}" + +RUN command -v cargo \ + && cargo --version \ + && rustc --version + WORKDIR /source -ENTRYPOINT ["./scripts/build-usb-image.sh"] +ENTRYPOINT ["/bin/bash", "-c", "exec /bin/bash ./scripts/build-usb-image-entrypoint.sh"] diff --git a/build/usb/bootloaders/grub-pc/live-theme/theme.txt b/build/usb/bootloaders/grub-pc/live-theme/theme.txt index 2e75711f..245f766c 100644 --- a/build/usb/bootloaders/grub-pc/live-theme/theme.txt +++ b/build/usb/bootloaders/grub-pc/live-theme/theme.txt @@ -1,6 +1,6 @@ title-color: "#ffffff" title-font: "Unifont Regular 16" -title-text: "RIGOS Recovery 0.0.4-alpha.5 - Stateless Diagnostics" +title-text: "RIGOS Recovery" message-font: "Unifont Regular 16" terminal-font: "Unifont Regular 16" diff --git a/build/usb/bootloaders/syslinux_common/live.cfg.in b/build/usb/bootloaders/syslinux_common/live.cfg.in index d17a6983..451c9852 100644 --- a/build/usb/bootloaders/syslinux_common/live.cfg.in +++ b/build/usb/bootloaders/syslinux_common/live.cfg.in @@ -1,12 +1,12 @@ label rigos-@FLAVOUR@ - menu label ^RIGOS Recovery 0.0.4-alpha.5 + menu label ^RIGOS Recovery menu default linux @LINUX@ initrd @INITRD@ append @APPEND_LIVE@ label rigos-@FLAVOUR@-failsafe - menu label RIGOS Recovery 0.0.4-alpha.5 -- ^safe mode + menu label RIGOS Recovery (^Safe Mode) linux @LINUX@ initrd @INITRD@ append @APPEND_LIVE_FAILSAFE@ diff --git a/build/usb/grub-theme/rigos/select_c.png b/build/usb/grub-theme/rigos/select_c.png new file mode 100644 index 00000000..f36ab618 Binary files /dev/null and b/build/usb/grub-theme/rigos/select_c.png differ diff --git a/build/usb/grub-theme/rigos/select_e.png b/build/usb/grub-theme/rigos/select_e.png new file mode 100644 index 00000000..f36ab618 Binary files /dev/null and b/build/usb/grub-theme/rigos/select_e.png differ diff --git a/build/usb/grub-theme/rigos/select_n.png b/build/usb/grub-theme/rigos/select_n.png new file mode 100644 index 00000000..f36ab618 Binary files /dev/null and b/build/usb/grub-theme/rigos/select_n.png differ diff --git a/build/usb/grub-theme/rigos/select_ne.png b/build/usb/grub-theme/rigos/select_ne.png new file mode 100644 index 00000000..f36ab618 Binary files /dev/null and b/build/usb/grub-theme/rigos/select_ne.png differ diff --git a/build/usb/grub-theme/rigos/select_nw.png b/build/usb/grub-theme/rigos/select_nw.png new file mode 100644 index 00000000..f36ab618 Binary files /dev/null and b/build/usb/grub-theme/rigos/select_nw.png differ diff --git a/build/usb/grub-theme/rigos/select_s.png b/build/usb/grub-theme/rigos/select_s.png new file mode 100644 index 00000000..f36ab618 Binary files /dev/null and b/build/usb/grub-theme/rigos/select_s.png differ diff --git a/build/usb/grub-theme/rigos/select_se.png b/build/usb/grub-theme/rigos/select_se.png new file mode 100644 index 00000000..f36ab618 Binary files /dev/null and b/build/usb/grub-theme/rigos/select_se.png differ diff --git a/build/usb/grub-theme/rigos/select_sw.png b/build/usb/grub-theme/rigos/select_sw.png new file mode 100644 index 00000000..f36ab618 Binary files /dev/null and b/build/usb/grub-theme/rigos/select_sw.png differ diff --git a/build/usb/grub-theme/rigos/select_w.png b/build/usb/grub-theme/rigos/select_w.png new file mode 100644 index 00000000..f36ab618 Binary files /dev/null and b/build/usb/grub-theme/rigos/select_w.png differ diff --git a/build/usb/grub-theme/rigos/theme.txt b/build/usb/grub-theme/rigos/theme.txt new file mode 100644 index 00000000..388e7e8b --- /dev/null +++ b/build/usb/grub-theme/rigos/theme.txt @@ -0,0 +1,112 @@ +desktop-color: "#00040a" +title-color: "#00ff66" +title-font: "Unifont Regular 16" +title-text: "RIGOS" +message-font: "Unifont Regular 16" +terminal-font: "Unifont Regular 16" + ++ label { + top = 9% + left = 8% + width = 84% + height = 24 + text = "+--------------------------------------------------------------+" + color = "#008f39" + font = "Unifont Regular 16" + align = "center" +} + ++ label { + top = 13% + left = 8% + width = 84% + height = 24 + text = "| RIGOS DEADBYTE UTILITY BIOS |" + color = "#00ff66" + font = "Unifont Regular 16" + align = "center" +} + ++ label { + top = 17% + left = 8% + width = 84% + height = 24 + text = "| USB COMPUTE APPLIANCE 0.0.4-alpha.25 ALPHA CHANNEL |" + color = "#ffcc33" + font = "Unifont Regular 16" + align = "center" +} + ++ label { + top = 21% + left = 8% + width = 84% + height = 24 + text = "+--------------------------------------------------------------+" + color = "#008f39" + font = "Unifont Regular 16" + align = "center" +} + ++ label { + top = 31% + left = 8% + width = 84% + height = 24 + text = "SELECT BOOT TARGET // LOCAL CONTROL" + color = "#ff66cc" + font = "Unifont Regular 16" + align = "left" +} + ++ boot_menu { + left = 12% + width = 76% + top = 37% + height = 28% + item_color = "#00ff66" + item_font = "Unifont Regular 16" + selected_item_color = "#00040a" + selected_item_font = "Unifont Regular 16" + selected_item_pixmap_style = "select_*.png" + item_height = 26 + item_padding = 4 + item_spacing = 5 +} + ++ label { + top = 72% + left = 8% + width = 84% + height = 24 + text = "STATUS READY MEDIA USB MODE LOCAL STYLE RETRO" + color = "#00bfff" + font = "Unifont Regular 16" + align = "left" +} + ++ label { + top = 82% + left = 8% + width = 84% + height = 24 + text = "[ ENTER BOOT ] [ E EDIT ] [ C CONSOLE ]" + color = "#ffcc33" + font = "Unifont Regular 16" + align = "center" +} + ++ progress_bar { + id = "__timeout__" + left = 14% + top = 88% + height = 12 + width = 72% + font = "Unifont Regular 16" + text_color = "#000000" + fg_color = "#00ff66" + bg_color = "#001018" + border_color = "#008f39" + text = "@TIMEOUT_NOTIFICATION_SHORT@" +} diff --git a/build/usb/hooks/010-rigos.chroot b/build/usb/hooks/010-rigos.chroot index c3e04f65..27485b79 100644 --- a/build/usb/hooks/010-rigos.chroot +++ b/build/usb/hooks/010-rigos.chroot @@ -4,11 +4,14 @@ set -eu useradd --system --home-dir /var/lib/rigos --shell /usr/sbin/nologin --user-group rigos useradd --create-home --shell /bin/bash --groups sudo rigosadmin passwd --lock rigosadmin -install -d -o rigos -g rigos -m 0750 /var/lib/rigos /run/rigos -install -d -m 0755 /usr/lib/rigos +install -d -o root -g rigos -m 0750 /var/lib/rigos +install -d -m 0755 /usr/lib/rigos /usr/local/bin +systemd-tmpfiles --create /usr/lib/tmpfiles.d/rigos.conf -chmod 0755 /usr/local/sbin/rigos-firstboot /usr/lib/rigos/rigos-state-init /usr/lib/rigos/rigos-config /usr/lib/rigos/xmrig -systemctl enable NetworkManager.service rigos-state.service rigos-profile-apply.service rigos-firstboot.service rigos-miner.service tmp.mount +rm -f /etc/ssh/ssh_host_*_key /etc/ssh/ssh_host_*_key.pub +chmod 0755 /usr/local/bin/rig /usr/local/bin/rigosd /usr/local/bin/rigosctl /usr/local/sbin/rigos-firstboot /usr/local/sbin/rigos-recovery-access /usr/local/sbin/rigos-state-orchestrate /usr/local/sbin/rigos-utility /usr/lib/rigos/rigos-admin-password /usr/lib/rigos/rigos-firstboot-whiptail /usr/lib/rigos/rigos-miner-gate /usr/lib/rigos/rigos-miner-health /usr/lib/rigos/rigos-runtime-render /usr/lib/rigos/rigos-runtime-publish /usr/lib/rigos/rigos-runtime-gate /usr/lib/rigos/rigos-randomx-msr /usr/lib/rigos/rigos-ssh-hostkeys /usr/lib/rigos/rigos-state-init /usr/lib/rigos/rigos-state-ready /usr/lib/rigos/rigos-config /usr/lib/rigos/rigos-performance /usr/lib/rigos/rigos-lifecycle-cycles /usr/lib/rigos/rigos-identity-seed /usr/lib/rigos/xmrig +systemctl enable NetworkManager.service ssh.service rigos-state.service rigos-recovery-access.service rigos-state-ready.service rigos-ssh-hostkeys.service rigos-profile-apply.service rigos-runtime-render.service rigos-hugepages.service rigos-firstboot.service rigos-boot-utility.service rigos-randomx-msr.service rigos-miner.service rigos-miner-health.timer tmp.mount +systemctl disable ssh.socket 2>/dev/null || true systemctl disable apt-daily.timer apt-daily-upgrade.timer logrotate.timer fstrim.timer 2>/dev/null || true systemctl disable systemd-journald-audit.socket 2>/dev/null || true diff --git a/build/usb/includes.chroot/etc/ssh/sshd_config.d/00-rigos.conf b/build/usb/includes.chroot/etc/ssh/sshd_config.d/00-rigos.conf new file mode 100644 index 00000000..720f859f --- /dev/null +++ b/build/usb/includes.chroot/etc/ssh/sshd_config.d/00-rigos.conf @@ -0,0 +1,12 @@ +PasswordAuthentication yes +KbdInteractiveAuthentication no +UsePAM yes +AuthenticationMethods any +PubkeyAuthentication yes +PermitRootLogin no +AllowUsers rigosadmin +X11Forwarding no +AllowAgentForwarding no +AllowTcpForwarding no +PermitTunnel no +GatewayPorts no diff --git a/build/usb/includes.chroot/etc/ssh/sshd_config.d/01-rigos-hostkeys.conf b/build/usb/includes.chroot/etc/ssh/sshd_config.d/01-rigos-hostkeys.conf new file mode 100644 index 00000000..7b9489f5 --- /dev/null +++ b/build/usb/includes.chroot/etc/ssh/sshd_config.d/01-rigos-hostkeys.conf @@ -0,0 +1,4 @@ +HostKey /run/rigos/ssh-hostkeys/ssh_host_ed25519_key +PasswordAuthentication yes +PermitRootLogin no +AllowUsers rigosadmin diff --git a/build/usb/includes.chroot/etc/systemd/system/getty@tty1.service.d/rigos-firstboot.conf b/build/usb/includes.chroot/etc/systemd/system/getty@tty1.service.d/rigos-firstboot.conf new file mode 100644 index 00000000..79cac4f7 --- /dev/null +++ b/build/usb/includes.chroot/etc/systemd/system/getty@tty1.service.d/rigos-firstboot.conf @@ -0,0 +1,3 @@ +[Unit] +Wants=rigos-firstboot.service +After=rigos-firstboot.service diff --git a/build/usb/includes.chroot/etc/systemd/system/rigos-boot-utility.service b/build/usb/includes.chroot/etc/systemd/system/rigos-boot-utility.service new file mode 100644 index 00000000..feb84b2d --- /dev/null +++ b/build/usb/includes.chroot/etc/systemd/system/rigos-boot-utility.service @@ -0,0 +1,20 @@ +[Unit] +Description=RIGOS local boot utility console +ConditionKernelCommandLine=rigos.utility=1 +After=rigos-recovery-access.service rigos-state-ready.service +Wants=rigos-recovery-access.service rigos-state-ready.service +Before=getty@tty1.service + +[Service] +Type=oneshot +ExecStart=/usr/local/sbin/rigos-utility +StandardInput=tty-force +StandardOutput=tty +StandardError=journal +TTYPath=/dev/tty1 +TTYReset=yes +TTYVTDisallocate=yes +RemainAfterExit=no + +[Install] +WantedBy=multi-user.target diff --git a/build/usb/includes.chroot/etc/systemd/system/rigos-firstboot.service b/build/usb/includes.chroot/etc/systemd/system/rigos-firstboot.service index 0738a1a6..0dded3e4 100644 --- a/build/usb/includes.chroot/etc/systemd/system/rigos-firstboot.service +++ b/build/usb/includes.chroot/etc/systemd/system/rigos-firstboot.service @@ -1,20 +1,19 @@ [Unit] Description=RIGOS first-boot configuration -After=network-online.target rigos-state.service rigos-profile-apply.service -Wants=network-online.target -Requires=rigos-state.service +ConditionKernelCommandLine=!rigos.utility=1 +After=rigos-state.service rigos-state-ready.service +Wants=rigos-state-ready.service Before=getty@tty1.service -ConditionPathExists=!/var/lib/rigos/policy.json [Service] Type=oneshot +ExecCondition=/usr/lib/rigos/rigos-config needs-activation ExecStart=/usr/local/sbin/rigos-firstboot -StandardInput=tty +StandardInput=tty-force StandardOutput=tty -StandardError=tty +StandardError=journal TTYPath=/dev/tty1 TTYReset=yes -TTYVHangup=yes TTYVTDisallocate=yes [Install] diff --git a/build/usb/includes.chroot/etc/systemd/system/rigos-firstboot.service.d/2009-console-theme.conf b/build/usb/includes.chroot/etc/systemd/system/rigos-firstboot.service.d/2009-console-theme.conf new file mode 100644 index 00000000..78da015e --- /dev/null +++ b/build/usb/includes.chroot/etc/systemd/system/rigos-firstboot.service.d/2009-console-theme.conf @@ -0,0 +1,3 @@ +[Service] +Environment=RIGOS_WHIPTAIL=/usr/lib/rigos/rigos-firstboot-whiptail +Environment="RIGOS_FIRSTBOOT_BACKTITLE=RIGOS SETUP UTILITY LOCAL NODE CONFIGURATION" diff --git a/build/usb/includes.chroot/etc/systemd/system/rigos-hugepages.service b/build/usb/includes.chroot/etc/systemd/system/rigos-hugepages.service new file mode 100644 index 00000000..d1a39d60 --- /dev/null +++ b/build/usb/includes.chroot/etc/systemd/system/rigos-hugepages.service @@ -0,0 +1,24 @@ +[Unit] +Description=Apply RIGOS huge page authority +After=rigos-state-ready.service rigos-profile-apply.service +Requires=rigos-state-ready.service rigos-profile-apply.service +Before=rigos-miner.service + +[Service] +Type=oneshot +ExecStart=/usr/lib/rigos/rigos-performance hugepages apply +RemainAfterExit=yes +NoNewPrivileges=yes +PrivateTmp=yes +ProtectHome=yes +ProtectSystem=strict +ProtectKernelTunables=no +ProtectKernelModules=yes +ProtectControlGroups=yes +RestrictAddressFamilies=AF_UNIX +RestrictNamespaces=yes +LockPersonality=yes +ReadWritePaths=/proc/sys/vm/nr_hugepages /run/rigos + +[Install] +WantedBy=multi-user.target diff --git a/build/usb/includes.chroot/etc/systemd/system/rigos-miner-health.service b/build/usb/includes.chroot/etc/systemd/system/rigos-miner-health.service new file mode 100644 index 00000000..7665618a --- /dev/null +++ b/build/usb/includes.chroot/etc/systemd/system/rigos-miner-health.service @@ -0,0 +1,20 @@ +[Unit] +Description=Observe RIGOS miner health truth +After=rigos-state-ready.service rigos-runtime-render.service rigos-miner.service + +[Service] +Type=oneshot +ExecStart=/usr/bin/python3 /usr/lib/rigos/rigos-miner-health +NoNewPrivileges=yes +PrivateTmp=yes +ProtectHome=yes +ProtectSystem=strict +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectControlGroups=yes +RestrictAddressFamilies=AF_UNIX AF_INET +IPAddressDeny=any +IPAddressAllow=127.0.0.0/8 +RestrictNamespaces=yes +LockPersonality=yes +ReadWritePaths=/run/rigos /var/lib/rigos/system diff --git a/build/usb/includes.chroot/etc/systemd/system/rigos-miner-health.timer b/build/usb/includes.chroot/etc/systemd/system/rigos-miner-health.timer new file mode 100644 index 00000000..97de0c73 --- /dev/null +++ b/build/usb/includes.chroot/etc/systemd/system/rigos-miner-health.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Sample RIGOS miner health truth + +[Timer] +OnBootSec=2min +OnUnitActiveSec=1min +AccuracySec=5s +Unit=rigos-miner-health.service + +[Install] +WantedBy=timers.target diff --git a/build/usb/includes.chroot/etc/systemd/system/rigos-miner.service b/build/usb/includes.chroot/etc/systemd/system/rigos-miner.service index f93f487e..ff6d93ba 100644 --- a/build/usb/includes.chroot/etc/systemd/system/rigos-miner.service +++ b/build/usb/includes.chroot/etc/systemd/system/rigos-miner.service @@ -1,15 +1,15 @@ [Unit] Description=RIGOS managed XMRig -After=network-online.target rigos-state.service rigos-profile-apply.service +After=network-online.target rigos-state-ready.service rigos-profile-apply.service rigos-hugepages.service Wants=network-online.target -Requires=rigos-state.service +Requires=rigos-state-ready.service rigos-hugepages.service ConditionPathExists=/var/lib/rigos/xmrig.json [Service] Type=simple User=rigos Group=rigos -ExecCondition=/usr/lib/rigos/rigos-config gate +ExecCondition=/usr/lib/rigos/rigos-miner-gate ExecStart=/usr/lib/rigos/xmrig --config=/var/lib/rigos/xmrig.json Restart=on-failure RestartSec=5s @@ -25,8 +25,6 @@ RestrictNamespaces=yes LockPersonality=yes MemoryDenyWriteExecute=no ReadWritePaths=/var/lib/rigos -RuntimeDirectory=rigos -RuntimeDirectoryMode=0750 [Install] WantedBy=multi-user.target diff --git a/build/usb/includes.chroot/etc/systemd/system/rigos-miner.service.d/randomx-msr.conf b/build/usb/includes.chroot/etc/systemd/system/rigos-miner.service.d/randomx-msr.conf new file mode 100644 index 00000000..e90c9d0b --- /dev/null +++ b/build/usb/includes.chroot/etc/systemd/system/rigos-miner.service.d/randomx-msr.conf @@ -0,0 +1,3 @@ +[Unit] +Wants=rigos-randomx-msr.service +After=rigos-randomx-msr.service diff --git a/build/usb/includes.chroot/etc/systemd/system/rigos-miner.service.d/runtime-render.conf b/build/usb/includes.chroot/etc/systemd/system/rigos-miner.service.d/runtime-render.conf new file mode 100644 index 00000000..3b94d5ff --- /dev/null +++ b/build/usb/includes.chroot/etc/systemd/system/rigos-miner.service.d/runtime-render.conf @@ -0,0 +1,11 @@ +[Unit] +After=rigos-runtime-render.service +Requires=rigos-runtime-render.service +ConditionPathExists= +ConditionPathExists=/var/lib/rigos/current + +[Service] +ExecCondition=+/usr/lib/rigos/rigos-runtime-authority +ExecCondition=/usr/lib/rigos/rigos-runtime-gate +ExecStart= +ExecStart=/usr/lib/rigos/xmrig -c /run/rigos/xmrig.json diff --git a/build/usb/includes.chroot/etc/systemd/system/rigos-miner.service.d/stability.conf b/build/usb/includes.chroot/etc/systemd/system/rigos-miner.service.d/stability.conf new file mode 100644 index 00000000..5bf95881 --- /dev/null +++ b/build/usb/includes.chroot/etc/systemd/system/rigos-miner.service.d/stability.conf @@ -0,0 +1,17 @@ +[Unit] +StartLimitIntervalSec=10min +StartLimitBurst=5 + +[Service] +Restart=on-failure +RestartSec=15s +TimeoutStartSec=2min +TimeoutStopSec=30s +KillSignal=SIGINT +FinalKillSignal=SIGKILL +SendSIGKILL=yes +SuccessExitStatus=SIGINT SIGTERM +OOMPolicy=stop +OOMScoreAdjust=-500 +TasksMax=64 +LimitNOFILE=4096 diff --git a/build/usb/includes.chroot/etc/systemd/system/rigos-profile-apply.service b/build/usb/includes.chroot/etc/systemd/system/rigos-profile-apply.service index 6dc7e7b9..a8999457 100644 --- a/build/usb/includes.chroot/etc/systemd/system/rigos-profile-apply.service +++ b/build/usb/includes.chroot/etc/systemd/system/rigos-profile-apply.service @@ -1,13 +1,12 @@ [Unit] Description=Apply RIGOS persistent machine profile -After=rigos-state.service -Requires=rigos-state.service -Before=rigos-firstboot.service rigos-miner.service +After=rigos-state-ready.service +Requires=rigos-state-ready.service +Before=rigos-hugepages.service rigos-miner.service [Service] Type=oneshot -ExecStartPre=/usr/lib/rigos/rigos-config recover -ExecStart=/usr/lib/rigos/rigos-config timezone +ExecStart=/usr/lib/rigos/rigos-config profile RemainAfterExit=yes [Install] diff --git a/build/usb/includes.chroot/etc/systemd/system/rigos-randomx-msr.service b/build/usb/includes.chroot/etc/systemd/system/rigos-randomx-msr.service new file mode 100644 index 00000000..25ba6c4c --- /dev/null +++ b/build/usb/includes.chroot/etc/systemd/system/rigos-randomx-msr.service @@ -0,0 +1,32 @@ +[Unit] +Description=Apply reversible RIGOS RandomX MSR optimization +After=rigos-state-ready.service rigos-profile-apply.service +Requires=rigos-state-ready.service rigos-profile-apply.service +Before=rigos-miner.service +ConditionArchitecture=x86-64 +ConditionPathExists=/var/lib/rigos/current + +[Service] +Type=oneshot +ExecStartPre=-/usr/sbin/modprobe msr +ExecStart=/usr/bin/python3 /usr/lib/rigos/rigos-randomx-msr apply +ExecStop=/usr/bin/python3 /usr/lib/rigos/rigos-randomx-msr restore +RemainAfterExit=yes +TimeoutStartSec=20s +TimeoutStopSec=20s +NoNewPrivileges=yes +PrivateTmp=yes +ProtectHome=yes +ProtectSystem=strict +ProtectKernelTunables=yes +ProtectKernelModules=no +ProtectControlGroups=yes +RestrictAddressFamilies=AF_UNIX +RestrictNamespaces=yes +LockPersonality=yes +CapabilityBoundingSet=CAP_SYS_MODULE CAP_SYS_RAWIO +AmbientCapabilities=CAP_SYS_MODULE CAP_SYS_RAWIO +ReadWritePaths=/run/rigos -/dev/cpu + +[Install] +WantedBy=multi-user.target diff --git a/build/usb/includes.chroot/etc/systemd/system/rigos-recovery-access.service b/build/usb/includes.chroot/etc/systemd/system/rigos-recovery-access.service new file mode 100644 index 00000000..2d44224d --- /dev/null +++ b/build/usb/includes.chroot/etc/systemd/system/rigos-recovery-access.service @@ -0,0 +1,20 @@ +[Unit] +Description=Establish local RIGOS recovery access +After=rigos-state.service +Wants=rigos-state.service +Before=rigos-state-ready.service rigos-firstboot.service getty@tty1.service ssh.service + +[Service] +Type=oneshot +ExecStart=/usr/local/sbin/rigos-recovery-access +SuccessExitStatus=1 +ExecStartPost=/usr/bin/python3 /usr/lib/rigos/rigos-recovery-access-verify +StandardInput=tty +StandardOutput=tty +StandardError=journal +TTYPath=/dev/tty1 +TTYReset=yes +TTYVTDisallocate=yes + +[Install] +WantedBy=multi-user.target diff --git a/build/usb/includes.chroot/etc/systemd/system/rigos-remote-access-observe.service b/build/usb/includes.chroot/etc/systemd/system/rigos-remote-access-observe.service new file mode 100644 index 00000000..8f0afe23 --- /dev/null +++ b/build/usb/includes.chroot/etc/systemd/system/rigos-remote-access-observe.service @@ -0,0 +1,10 @@ +[Unit] +Description=Observe RIGOS SSH listener truth +After=network-online.target ssh.service rigos-recovery-access.service +Wants=network-online.target +Requires=ssh.service rigos-recovery-access.service + +[Service] +Type=oneshot +ExecStart=/usr/bin/python3 /usr/lib/rigos/rigos-remote-access-probe +RemainAfterExit=yes diff --git a/build/usb/includes.chroot/etc/systemd/system/rigos-runtime-render.service b/build/usb/includes.chroot/etc/systemd/system/rigos-runtime-render.service new file mode 100644 index 00000000..cbf8f72a --- /dev/null +++ b/build/usb/includes.chroot/etc/systemd/system/rigos-runtime-render.service @@ -0,0 +1,24 @@ +[Unit] +Description=Render and publish RIGOS runtime miner configuration +After=rigos-state-ready.service rigos-profile-apply.service +Requires=rigos-state-ready.service rigos-profile-apply.service +Before=rigos-hugepages.service rigos-miner.service +ConditionPathExists=/var/lib/rigos/current + +[Service] +Type=oneshot +ExecStart=/usr/lib/rigos/rigos-runtime-authority +NoNewPrivileges=yes +PrivateTmp=yes +ProtectHome=yes +ProtectSystem=strict +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectControlGroups=yes +RestrictAddressFamilies=AF_UNIX +RestrictNamespaces=yes +LockPersonality=yes +ReadWritePaths=/run/rigos + +[Install] +WantedBy=multi-user.target diff --git a/build/usb/includes.chroot/etc/systemd/system/rigos-runtime-render.service.d/remain.conf b/build/usb/includes.chroot/etc/systemd/system/rigos-runtime-render.service.d/remain.conf new file mode 100644 index 00000000..75039e3a --- /dev/null +++ b/build/usb/includes.chroot/etc/systemd/system/rigos-runtime-render.service.d/remain.conf @@ -0,0 +1,2 @@ +[Service] +RemainAfterExit=yes diff --git a/build/usb/includes.chroot/etc/systemd/system/rigos-ssh-hostkeys.service b/build/usb/includes.chroot/etc/systemd/system/rigos-ssh-hostkeys.service new file mode 100644 index 00000000..b4f7e2f0 --- /dev/null +++ b/build/usb/includes.chroot/etc/systemd/system/rigos-ssh-hostkeys.service @@ -0,0 +1,25 @@ +[Unit] +Description=Establish RIGOS SSH host identity +After=rigos-state-ready.service +Wants=rigos-state-ready.service +Before=ssh.service + +[Service] +Type=oneshot +ExecStart=/usr/lib/rigos/rigos-ssh-hostkeys +RemainAfterExit=yes +UMask=0077 +NoNewPrivileges=yes +PrivateTmp=yes +ProtectHome=yes +ProtectSystem=strict +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectControlGroups=yes +RestrictAddressFamilies=AF_UNIX +RestrictNamespaces=yes +LockPersonality=yes +ReadWritePaths=/var/lib/rigos /run/rigos + +[Install] +WantedBy=multi-user.target diff --git a/build/usb/includes.chroot/etc/systemd/system/rigos-state-ready.service b/build/usb/includes.chroot/etc/systemd/system/rigos-state-ready.service new file mode 100644 index 00000000..593b274c --- /dev/null +++ b/build/usb/includes.chroot/etc/systemd/system/rigos-state-ready.service @@ -0,0 +1,13 @@ +[Unit] +Description=Verify RIGOS persistent state readiness +After=rigos-state.service rigos-recovery-access.service +Requires=rigos-state.service +Before=rigos-ssh-hostkeys.service rigos-profile-apply.service rigos-firstboot.service rigos-hugepages.service rigos-miner.service + +[Service] +Type=oneshot +ExecStart=/usr/lib/rigos/rigos-state-ready +RemainAfterExit=yes + +[Install] +WantedBy=multi-user.target diff --git a/build/usb/includes.chroot/etc/systemd/system/rigos-state.service b/build/usb/includes.chroot/etc/systemd/system/rigos-state.service index 5bba5cd9..017dc20b 100644 --- a/build/usb/includes.chroot/etc/systemd/system/rigos-state.service +++ b/build/usb/includes.chroot/etc/systemd/system/rigos-state.service @@ -1,14 +1,14 @@ [Unit] Description=Mount RIGOS persistent state DefaultDependencies=no -After=systemd-udev-settle.service local-fs-pre.target -Before=local-fs.target rigos-firstboot.service rigos-miner.service +After=systemd-udevd.service local-fs-pre.target +Before=local-fs.target rigos-state-ready.service [Service] Type=oneshot -RuntimeDirectory=rigos -RuntimeDirectoryMode=0755 -ExecStart=/usr/lib/rigos/rigos-state-init +ExecStartPre=/usr/bin/systemd-tmpfiles --create /usr/lib/tmpfiles.d/rigos.conf +ExecStart=/usr/local/sbin/rigos-state-orchestrate +TimeoutStartSec=20min RemainAfterExit=yes [Install] diff --git a/build/usb/includes.chroot/etc/systemd/system/ssh.service.d/rigos-observe.conf b/build/usb/includes.chroot/etc/systemd/system/ssh.service.d/rigos-observe.conf new file mode 100644 index 00000000..28ca00d7 --- /dev/null +++ b/build/usb/includes.chroot/etc/systemd/system/ssh.service.d/rigos-observe.conf @@ -0,0 +1,4 @@ +[Unit] +After=rigos-recovery-access.service rigos-ssh-hostkeys.service +Requires=rigos-ssh-hostkeys.service +Wants=rigos-remote-access-observe.service diff --git a/build/usb/includes.chroot/usr/lib/rigos/rigos-admin-password b/build/usb/includes.chroot/usr/lib/rigos/rigos-admin-password new file mode 100644 index 00000000..357f62e2 --- /dev/null +++ b/build/usb/includes.chroot/usr/lib/rigos/rigos-admin-password @@ -0,0 +1,297 @@ +#!/usr/bin/python3 +import argparse +import os +import subprocess +import sys +import termios +import tty +from pathlib import Path + +CONSOLE = Path(os.environ.get("RIGOS_CONSOLE", "/dev/tty1")) +USER = "rigosadmin" +WIDTH_FRACTION = 0.76 +HEIGHT_FRACTION = 0.56 +MIN_PANEL_COLUMNS = 58 +MIN_PANEL_ROWS = 18 +FALLBACK_COLUMNS = 80 +FALLBACK_ROWS = 25 +SETFONT = Path("/usr/bin/setfont") +FONT_CANDIDATES = [ + Path("/usr/share/consolefonts/Lat15-TerminusBold20x10.psf.gz"), + Path("/usr/share/consolefonts/Lat15-TerminusBold18x10.psf.gz"), + Path("/usr/share/consolefonts/Uni3-TerminusBold20x10.psf.gz"), + Path("/usr/share/consolefonts/Uni3-TerminusBold18x10.psf.gz"), + Path("/usr/share/consolefonts/default8x16.psf.gz"), +] + + +class PasswordError(Exception): + pass + + +def apply_console_font() -> None: + if os.environ.get("RIGOS_ADMIN_PASSWORD_SKIP_SETFONT") == "1": + return + + if not SETFONT.exists(): + return + + for font in FONT_CANDIDATES: + if not font.exists(): + continue + + subprocess.run( + [str(SETFONT), str(font)], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + return + + +def draw( + stream, + password: bytearray, + confirmation: bytearray, + reveal: bool, + focus: int, + message: str, +) -> None: + controls = [ + "PASSWORD", + "CONFIRM", + "SHOW PASSWORD" if not reveal else "HIDE PASSWORD", + "BACK", + "APPLY", + ] + + try: + size = os.get_terminal_size(stream.fileno()) + console_columns = size.columns + console_rows = size.lines + except (AttributeError, OSError, ValueError): + console_columns = FALLBACK_COLUMNS + console_rows = FALLBACK_ROWS + + # Leave the final terminal column unused. Writing into the last column can + # arm automatic wrapping before the following newline is processed. + console_columns = max(console_columns, 24) + console_rows = max(console_rows, 12) + draw_columns = max(20, console_columns - 1) + panel_width = int(draw_columns * WIDTH_FRACTION) + panel_width = max(MIN_PANEL_COLUMNS, panel_width) + panel_width = min(panel_width, draw_columns - 2) + panel_width = max(20, panel_width) + panel_height = int(console_rows * HEIGHT_FRACTION) + panel_height = max(MIN_PANEL_ROWS, panel_height) + panel_height = min(panel_height, console_rows - 2) + panel_height = max(10, panel_height) + left = max(0, (draw_columns - panel_width) // 2) + top = max(0, (console_rows - panel_height) // 2) + inner_width = panel_width - 2 + field_width = max(1, inner_width - 17) + + def field(value: bytearray) -> str: + text = value.decode("utf-8", "replace") if reveal else "*" * len(value) + return text[-field_width:].ljust(field_width) + + def marker(index: int) -> str: + return ">" if focus == index else " " + + def frame(text: str = "") -> str: + return "|" + text[:inner_width].ljust(inner_width) + "|" + + def line(text: str = "") -> None: + if text: + stream.write(" " * left) + stream.write(text) + stream.write("\n") + + border = "+" + ("-" * inner_width) + "+" + + stream.write("\x1b[2J\x1b[H") + for _ in range(top): + line() + + rows = [ + border, + frame("RIGOS // DEADBYTE LOCAL CONSOLE".center(inner_width)), + frame("ADMIN PASSWORD AUTHORITY".center(inner_width)), + border, + frame(), + frame(f"{marker(0)} PASSWORD [ {field(password)} ]"), + frame(f"{marker(1)} CONFIRM [ {field(confirmation)} ]"), + frame(), + frame(f"{marker(2)} [{'x' if reveal else ' '}] {controls[2]}"), + frame(), + frame("HASH ONLY PERSISTENCE NO LOGGED PLAINTEXT"), + frame("LOCAL TTY INPUT NO ARGV SECRETS"), + frame(), + ] + + buttons = f"{marker(3)}< BACK > {marker(4)}< APPLY >" + footer = [ + frame(buttons.center(inner_width)), + frame(message), + frame("TAB NEXT SPACE SHOW/HIDE ENTER SELECT"), + frame("ESC CANCEL"), + border, + ] + + available_middle = max(0, panel_height - len(rows) - len(footer)) + rows.extend(frame() for _ in range(available_middle)) + rows.extend(footer) + + for row in rows[:panel_height]: + line(row) + stream.flush() + + +def collect_password(console_path: Path) -> bytes: + password = bytearray() + confirmation = bytearray() + reveal = False + focus = 0 + message = "" + + with console_path.open("r+b", buffering=0) as raw: + fd = raw.fileno() + old = termios.tcgetattr(fd) + stream = os.fdopen( + os.dup(fd), + "w", + encoding="utf-8", + buffering=1, + ) + + try: + apply_console_font() + # cbreak disables canonical input and echo while preserving the + # terminal output processing required for correct line movement. + tty.setcbreak(fd) + + while True: + draw( + stream, + password, + confirmation, + reveal, + focus, + message, + ) + + ch = raw.read(1) + if not ch: + raise PasswordError("console closed") + + code = ch[0] + message = "" + + if code == 27: + raise PasswordError("cancelled") + + if code == 9: + focus = (focus + 1) % 5 + continue + + if code in (13, 10): + if focus == 2: + reveal = not reveal + continue + + if focus == 3: + raise PasswordError("cancelled") + + if focus != 4: + focus = (focus + 1) % 5 + continue + + if not password: + message = "Password must not be empty." + focus = 0 + continue + + if password != confirmation: + message = "Password confirmation does not match." + confirmation.clear() + focus = 1 + continue + + return bytes(password) + + if code == 32 and focus == 2: + reveal = not reveal + continue + + if code in (127, 8): + if focus == 0 and password: + password.pop() + elif focus == 1 and confirmation: + confirmation.pop() + continue + + if focus in (0, 1) and 32 <= code <= 126: + target = password if focus == 0 else confirmation + if len(target) < 256: + target.append(code) + continue + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old) + stream.write("\x1b[2J\x1b[H") + stream.flush() + password.clear() + confirmation.clear() + stream.close() + + +def apply_password(value: bytes, dry_run: bool = False) -> None: + if not value: + raise PasswordError("empty password") + + if b"\n" in value or b"\r" in value or b":" in value: + raise PasswordError("password contains unsupported control characters") + + payload = USER.encode("ascii") + b":" + value + b"\n" + + try: + if dry_run: + return + + result = subprocess.run( + ["/usr/sbin/chpasswd"], + input=payload, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + finally: + payload = b"" + + if result.returncode != 0: + raise PasswordError("password apply failed") + + +def main() -> int: + parser = argparse.ArgumentParser( + description="RIGOS local administrator password UI" + ) + parser.add_argument("--dry-run", action="store_true") + args = parser.parse_args() + + try: + scripted = os.environ.get("RIGOS_ADMIN_PASSWORD_TEST_VALUE") + + if scripted is not None: + value = scripted.encode("utf-8") + else: + value = collect_password(CONSOLE) + + apply_password(value, args.dry_run) + return 0 + except PasswordError as error: + print(f"rigos-admin-password: {error}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/build/usb/includes.chroot/usr/lib/rigos/rigos-firstboot-whiptail b/build/usb/includes.chroot/usr/lib/rigos/rigos-firstboot-whiptail new file mode 100644 index 00000000..ab6ad464 --- /dev/null +++ b/build/usb/includes.chroot/usr/lib/rigos/rigos-firstboot-whiptail @@ -0,0 +1,35 @@ +#!/bin/sh +# RIGOS firstboot presentation wrapper. +# Keeps the configuration engine and dialog return-code contract unchanged. +set -eu + +TERM=${TERM:-linux} +export TERM + +NEWT_COLORS=${NEWT_COLORS:-'root=white,blue:border=blue,lightgray:window=black,lightgray:shadow=black,black:title=white,blue:button=black,lightgray:actbutton=white,blue:compactbutton=black,lightgray:checkbox=black,lightgray:actcheckbox=white,blue:entry=black,white:disentry=gray,lightgray:label=black,lightgray:listbox=black,lightgray:actlistbox=white,blue:sellistbox=black,cyan:actsellistbox=white,blue:textbox=black,lightgray:acttextbox=white,blue:helpline=white,blue:roottext=white,blue'} +export NEWT_COLORS + +backtitle=${RIGOS_FIRSTBOOT_BACKTITLE:-'RIGOS SETUP UTILITY LOCAL NODE CONFIGURATION'} +whiptail_real=${RIGOS_WHIPTAIL_REAL:-/usr/bin/whiptail} + +if [ ! -x "$whiptail_real" ]; then + echo "rigos-firstboot-whiptail: backend is not executable: $whiptail_real" >&2 + exit 127 +fi + +case " $* " in + *' --menu '*) + set -- --ok-button SELECT --cancel-button BACK "$@" + ;; + *' --yesno '*) + set -- --yes-button APPLY --no-button BACK "$@" + ;; + *' --inputbox '*|*' --passwordbox '*) + set -- --ok-button NEXT --cancel-button BACK "$@" + ;; + *' --msgbox '*) + set -- --ok-button CONTINUE "$@" + ;; +esac + +exec "$whiptail_real" --backtitle "$backtitle" "$@" diff --git a/build/usb/includes.chroot/usr/lib/rigos/rigos-identity-seed b/build/usb/includes.chroot/usr/lib/rigos/rigos-identity-seed new file mode 100644 index 00000000..2a288d84 --- /dev/null +++ b/build/usb/includes.chroot/usr/lib/rigos/rigos-identity-seed @@ -0,0 +1,295 @@ +#!/usr/bin/python3 +from __future__ import annotations + +import argparse +import json +import os +import re +import stat +import subprocess +import sys +import tempfile +from pathlib import Path +from typing import Iterator + +RUNTIME = Path('/run/rigos') +STATE_STATUS = RUNTIME / 'state-status.json' +ATTESTATION = RUNTIME / 'boot-device.json' +BOOT_ID = Path('/proc/sys/kernel/random/boot_id') +STATE_INIT = '/usr/lib/rigos/rigos-state-init' +MOUNT = '/usr/bin/mount' +UMOUNT = '/usr/bin/umount' +FINDMNT = '/usr/bin/findmnt' +SLUG_RE = re.compile(r'^[A-Za-z0-9][A-Za-z0-9-]{0,63}$') +SAFE_JSON_RE = re.compile(r'^[A-Za-z0-9][A-Za-z0-9._-]{0,122}\.json$') +MAX_SEED_BYTES = 4096 +MAX_VALUE_BYTES = 512 +MAX_SEED_FILES = 128 + + +class SeedError(Exception): + def __init__(self, code: str, message: str, *, file: str | None = None, key: str | None = None): + super().__init__(message) + self.code = code + self.message = message + self.file = file + self.key = key + + def diagnostic(self) -> dict: + return { + 'code': self.code, + 'file': self.file, + 'line': None, + 'key': self.key, + 'message': self.message, + } + + +def invalid(message: str, *, file: str | None = None, key: str | None = None) -> SeedError: + return SeedError('RIGOS_IDENTITY_SEED_INVALID', message, file=file, key=key) + + +def unproven(message: str) -> SeedError: + return SeedError('RIGOS_CONFIG_BOOT_DEVICE_UNPROVEN', message) + + +def read_json(path: Path, message: str) -> dict: + try: + value = json.loads(path.read_text(encoding='utf-8')) + except (OSError, UnicodeError, json.JSONDecodeError) as error: + raise unproven(message) from error + if not isinstance(value, dict): + raise unproven(message) + return value + + +def ready_state() -> bool: + try: + status = read_json(STATE_STATUS, 'persistent state status is unavailable') + except SeedError: + return False + return status.get('outcome') == 'ready' + + +def fresh_attestation() -> dict: + RUNTIME.mkdir(mode=0o755, parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix='identity-seed-attest-', dir=RUNTIME) as temporary: + root = Path(temporary) + attestation_path = root / 'boot-device.json' + status_path = root / 'state-status.json' + result = subprocess.run( + [ + STATE_INIT, + '--dry-run', + '--attestation-only', + '--attestation', + str(attestation_path), + '--status', + str(status_path), + ], + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + if result.returncode != 0: + raise unproven('boot device attestation refresh failed') + return read_json(attestation_path, 'fresh boot device attestation is unavailable') + + +def folded(value: object) -> str: + return str(value or '').lower() + + +def identity_matches(expected: dict, fresh: dict) -> bool: + try: + current_boot_id = BOOT_ID.read_text(encoding='ascii').strip() + return ( + expected.get('schema') == 'rigos.boot-device/v1' + and fresh.get('schema') == expected.get('schema') + and expected.get('verification_outcome') == 'verified' + and fresh.get('verification_outcome') == 'verified' + and expected.get('boot_id') == current_boot_id + and fresh.get('boot_id') == current_boot_id + and fresh['disk']['major_minor'] == expected['disk']['major_minor'] + and folded(fresh['disk']['ptuuid']) == folded(expected['disk']['ptuuid']) + and fresh['partition1']['major_minor'] == expected['partition1']['major_minor'] + and folded(fresh['partition1']['partuuid']) == folded(expected['partition1']['partuuid']) + and fresh['root']['major_minor'] == expected['root']['major_minor'] + ) + except (OSError, KeyError, TypeError): + return False + + +def unique_object(pairs: list[tuple[str, object]]) -> dict: + output: dict[str, object] = {} + for key, value in pairs: + if key in output: + raise ValueError(f'duplicate member {key}') + output[key] = value + return output + + +def read_seed(path: Path) -> dict: + name = path.name + try: + info = path.lstat() + except OSError as error: + raise invalid('identity seed metadata is unavailable', file=name) from error + if not stat.S_ISREG(info.st_mode) or stat.S_ISLNK(info.st_mode): + raise invalid('identity seed must be a regular file', file=name) + if info.st_size > MAX_SEED_BYTES: + raise invalid('identity seed exceeds 4 KiB', file=name) + + flags = os.O_RDONLY | getattr(os, 'O_NOFOLLOW', 0) + try: + descriptor = os.open(path, flags) + except OSError as error: + raise invalid('identity seed could not be opened safely', file=name) from error + try: + opened = os.fstat(descriptor) + if not stat.S_ISREG(opened.st_mode) or opened.st_size > MAX_SEED_BYTES: + raise invalid('identity seed must be a bounded regular file', file=name) + raw = os.read(descriptor, MAX_SEED_BYTES + 1) + finally: + os.close(descriptor) + if len(raw) > MAX_SEED_BYTES: + raise invalid('identity seed exceeds 4 KiB', file=name) + + try: + seed = json.loads(raw.decode('utf-8'), object_pairs_hook=unique_object) + except (UnicodeError, json.JSONDecodeError, ValueError) as error: + raise invalid('identity seed is not strict JSON', file=name) from error + if not isinstance(seed, dict): + raise invalid('identity seed must be a JSON object', file=name) + if set(seed) != {'schema', 'alias', 'kind', 'value'}: + raise invalid('identity seed fields do not match schema', file=name) + + alias = seed.get('alias') + value = seed.get('value') + if seed.get('schema') != 'rigos.identity-seed/v1': + raise invalid('unsupported identity seed schema', file=name, key='schema') + if seed.get('kind') != 'mining_identity': + raise invalid('unsupported identity seed kind', file=name, key='kind') + if not isinstance(alias, str) or SLUG_RE.fullmatch(alias) is None: + raise invalid('identity seed alias is invalid', file=name, key='alias') + if path.stem != alias: + raise invalid('identity seed alias must match its filename', file=name, key='alias') + if ( + not isinstance(value, str) + or not value + or len(value.encode('utf-8')) > MAX_VALUE_BYTES + or not value.isascii() + or any(ord(character) < 33 or ord(character) > 126 for character in value) + ): + raise invalid('mining identity value is invalid', file=name, key='value') + return { + 'schema': 'rigos.identity/v1', + 'alias': alias, + 'kind': 'mining_identity', + 'value': value, + 'created_locally': True, + } + + +def seed_paths(directory: Path) -> Iterator[Path]: + try: + info = directory.lstat() + except FileNotFoundError: + return + except OSError as error: + raise invalid('identity seed directory is unavailable') from error + if not stat.S_ISDIR(info.st_mode) or stat.S_ISLNK(info.st_mode): + raise invalid('identity seed path must be a directory') + + count = 0 + for path in sorted(directory.iterdir(), key=lambda item: item.name.lower()): + if path.suffix.lower() != '.json': + continue + count += 1 + if count > MAX_SEED_FILES: + raise invalid('too many identity seed files') + if SAFE_JSON_RE.fullmatch(path.name) is None or path.name.startswith('.') or '..' in path.name: + raise invalid('unsafe identity seed filename', file=path.name) + yield path + + +def load_identity_seed(root: Path, requested_alias: str) -> dict | None: + if SLUG_RE.fullmatch(requested_alias) is None: + raise invalid('requested identity alias is invalid', key='identity_ref') + selected: dict | None = None + aliases: set[str] = set() + for path in seed_paths(root / 'rigos' / 'identities'): + identity = read_seed(path) + alias = identity['alias'].lower() + if alias in aliases: + raise invalid('duplicate identity seed alias', file=path.name, key='alias') + aliases.add(alias) + if identity['alias'] == requested_alias: + selected = identity + return selected + + +def mount_and_read(alias: str) -> dict | None: + expected = read_json(ATTESTATION, 'boot device attestation is unavailable') + fresh = fresh_attestation() + if not identity_matches(expected, fresh): + raise unproven('boot device identity changed after attestation') + + with tempfile.TemporaryDirectory(prefix='identity-seed-efi-', dir=RUNTIME) as temporary: + mountpoint = Path(temporary) + mounted = False + try: + partition = fresh['partition1'] + result = subprocess.run( + [MOUNT, '-o', 'ro,nodev,nosuid,noexec', str(partition['path']), str(mountpoint)], + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + if result.returncode != 0: + raise unproven('verified EFI partition could not be mounted read only') + mounted = True + identity = subprocess.run( + [FINDMNT, '--noheadings', '--output', 'MAJ:MIN', '--target', str(mountpoint)], + check=False, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + ) + if identity.returncode != 0 or identity.stdout.strip() != str(partition['major_minor']): + raise unproven('mounted EFI identity changed after verification') + return load_identity_seed(mountpoint, alias) + finally: + if mounted: + subprocess.run( + [UMOUNT, str(mountpoint)], + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument('--alias', required=True) + args = parser.parse_args() + if not ready_state(): + print(json.dumps(unproven('state is not ready').diagnostic(), sort_keys=True), file=sys.stderr) + return 2 + try: + seed = mount_and_read(args.alias) + except SeedError as error: + print(json.dumps(error.diagnostic(), sort_keys=True), file=sys.stderr) + return 2 + except Exception as error: + diagnostic = invalid(f'identity seed failed with {type(error).__name__}') + print(json.dumps(diagnostic.diagnostic(), sort_keys=True), file=sys.stderr) + return 2 + if seed is None: + return 1 + print(json.dumps(seed, sort_keys=True)) + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/build/usb/includes.chroot/usr/lib/rigos/rigos-lifecycle-cycles b/build/usb/includes.chroot/usr/lib/rigos/rigos-lifecycle-cycles new file mode 100644 index 00000000..5d452324 --- /dev/null +++ b/build/usb/includes.chroot/usr/lib/rigos/rigos-lifecycle-cycles @@ -0,0 +1,31 @@ +#!/bin/sh +# Repository contract: LF line endings are required for appliance execution. +set -eu + +cycles="${1:-20}" +case "$cycles" in + *[!0-9]*|'') echo "invalid cycle count" >&2; exit 2 ;; +esac +[ "$cycles" -ge 20 ] || { echo "at least 20 cycles are required" >&2; exit 2; } +[ "$(id -u)" -eq 0 ] || { echo "run as root" >&2; exit 2; } +[ -L /var/lib/rigos/current ] || { echo "node is not provisioned" >&2; exit 2; } + +current="$(readlink /var/lib/rigos/current)" +revisions="$(find /var/lib/rigos/revisions -mindepth 1 -maxdepth 1 -type d | wc -l)" +index=1 +while [ "$index" -le "$cycles" ]; do + systemctl restart rigos-profile-apply.service + systemctl restart rigos-hugepages.service + systemctl restart rigos-miner.service + [ -d /run/rigos ] || { echo "shared runtime directory disappeared at cycle $index" >&2; exit 1; } + [ "$(readlink /var/lib/rigos/current)" = "$current" ] || { echo "current revision changed at cycle $index" >&2; exit 1; } + [ "$(find /var/lib/rigos/revisions -mindepth 1 -maxdepth 1 -type d | wc -l)" -eq "$revisions" ] || { echo "revision count changed at cycle $index" >&2; exit 1; } + index=$((index + 1)) +done + +if journalctl -b --no-pager -u rigos-profile-apply.service -u rigos-hugepages.service -u rigos-miner.service | grep -Fq '226/NAMESPACE'; then + echo "systemd namespace failure observed" >&2 + exit 1 +fi + +printf '{"schema":"rigos.lifecycle-cycles/v1","cycles":%s,"revision":"%s","outcome":"pass"}\n' "$cycles" "$current" diff --git a/build/usb/includes.chroot/usr/lib/rigos/rigos-miner-gate b/build/usb/includes.chroot/usr/lib/rigos/rigos-miner-gate new file mode 100644 index 00000000..8d80131e --- /dev/null +++ b/build/usb/includes.chroot/usr/lib/rigos/rigos-miner-gate @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +import argparse +import json +import sys +from pathlib import Path + +MAX_JSON_BYTES = 2 * 1024 * 1024 +PRODUCTION_STATE = Path("/var/lib/rigos") +DEFAULT_MSR_STATUS = Path("/run/rigos/randomx-msr-status.json") +DEFAULT_MSR_STATE = Path("/run/rigos/randomx-msr-state.json") +DEFAULT_BOOT_ID = Path("/proc/sys/kernel/random/boot_id") +MSR_REGISTER = "0x00000000000001a4" +MSR_TARGET = "0x000000000000000f" + + +def emit(outcome: str, reason: str | None = None) -> None: + value = { + "schema": "rigos.miner-gate/v1", + "outcome": outcome, + "reason": reason, + } + stream = sys.stdout if outcome == "allowed" else sys.stderr + print(json.dumps(value, sort_keys=True), file=stream) + + +def read_object(path: Path) -> dict: + raw = path.read_bytes() + if len(raw) > MAX_JSON_BYTES: + raise ValueError("JSON state exceeds its size limit") + value = json.loads(raw) + if not isinstance(value, dict): + raise ValueError("JSON state is not an object") + return value + + +def deny(reason: str) -> int: + emit("denied", reason) + return 2 + + +def validate_msr_authority( + status_path: Path, + state_path: Path, + boot_id_path: Path, +) -> str | None: + try: + status = read_object(status_path) + boot_id = boot_id_path.read_text(encoding="ascii").strip() + except (OSError, UnicodeError, ValueError, json.JSONDecodeError): + return "randomx_msr_status_unreadable" + + if not boot_id: + return "randomx_msr_boot_id_unreadable" + if status.get("schema") != "rigos.randomx-msr-status/v1": + return "randomx_msr_status_schema_invalid" + if status.get("boot_id") != boot_id: + return "randomx_msr_status_stale" + if status.get("register") != MSR_REGISTER: + return "randomx_msr_register_invalid" + if status.get("target_value") != MSR_TARGET: + return "randomx_msr_target_invalid" + + outcome = status.get("outcome") + if outcome == "ready": + if not state_path.exists(): + return "randomx_msr_restore_state_missing" + return None + + if outcome in {"unsupported", "unavailable"}: + if state_path.exists(): + return "randomx_msr_baseline_has_mutation_state" + return None + + if ( + outcome == "degraded" + and status.get("reason") == "apply_failed_rolled_back" + and status.get("rollback") == {"attempted": True, "complete": True} + ): + if state_path.exists(): + return "randomx_msr_rollback_state_present" + return None + + return "randomx_msr_authority_unsafe" + + +def resolve_msr_paths(args: argparse.Namespace) -> tuple[Path, Path, Path] | None: + supplied = (args.msr_status, args.msr_state, args.boot_id) + if any(value is not None for value in supplied): + if not all(value is not None for value in supplied): + raise ValueError("MSR fixture paths must be supplied together") + return args.msr_status, args.msr_state, args.boot_id + + if args.state == PRODUCTION_STATE: + return DEFAULT_MSR_STATUS, DEFAULT_MSR_STATE, DEFAULT_BOOT_ID + + return None + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--state", type=Path, default=PRODUCTION_STATE) + parser.add_argument("--cmdline", type=Path, default=Path("/proc/cmdline")) + parser.add_argument("--msr-status", type=Path) + parser.add_argument("--msr-state", type=Path) + parser.add_argument("--boot-id", type=Path) + args = parser.parse_args() + + policy_path = args.state / "current" / "policy.json" + xmrig_path = args.state / "current" / "xmrig.json" + + try: + policy = read_object(policy_path) + except (OSError, ValueError, json.JSONDecodeError): + return deny("policy_unreadable") + + if policy.get("schema") != "rigos.policy/v1": + return deny("policy_schema_invalid") + if policy.get("miner_start_mode") != "on_boot": + return deny("manual_start_policy") + + try: + flags = args.cmdline.read_text(encoding="utf-8").split() + except OSError: + return deny("kernel_cmdline_unreadable") + if "rigos.nomine=1" in flags: + return deny("nomine_requested") + if "rigos.stateless=1" in flags: + return deny("stateless_mode") + + try: + read_object(xmrig_path) + except (OSError, ValueError, json.JSONDecodeError): + return deny("xmrig_config_unreadable") + + try: + msr_paths = resolve_msr_paths(args) + except ValueError: + return deny("randomx_msr_fixture_paths_incomplete") + + if msr_paths is not None: + msr_reason = validate_msr_authority(*msr_paths) + if msr_reason is not None: + return deny(msr_reason) + + emit("allowed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/build/usb/includes.chroot/usr/lib/rigos/rigos-miner-health b/build/usb/includes.chroot/usr/lib/rigos/rigos-miner-health new file mode 100644 index 00000000..62607e1d --- /dev/null +++ b/build/usb/includes.chroot/usr/lib/rigos/rigos-miner-health @@ -0,0 +1,658 @@ +#!/usr/bin/python3 +import datetime as dt +import http.client +import json +import math +import os +import re +import stat +import subprocess +import sys +import uuid +from pathlib import Path + +RUNTIME = Path(os.environ.get("RIGOS_RUNTIME_PATH", "/run/rigos")) +BOOT_ID = Path(os.environ.get("RIGOS_BOOT_ID_PATH", "/proc/sys/kernel/random/boot_id")) +CURRENT = Path(os.environ.get("RIGOS_CURRENT_REVISION_PATH", "/var/lib/rigos/current")) +STATE_DIR = Path(os.environ.get("RIGOS_MINER_HEALTH_STATE_DIR", "/var/lib/rigos/system/miner-health")) +PROC = Path(os.environ.get("RIGOS_PROC_ROOT", "/proc")) +SYSTEMCTL = os.environ.get("RIGOS_SYSTEMCTL", "/usr/bin/systemctl") +JOURNALCTL = os.environ.get("RIGOS_JOURNALCTL", "/usr/bin/journalctl") +STATUS = RUNTIME / "miner-health-status.json" +API_TOKEN = Path(os.environ.get("RIGOS_XMRIG_API_TOKEN_PATH", str(RUNTIME / "xmrig-api-token"))) +API_HOST = os.environ.get("RIGOS_XMRIG_API_HOST", "127.0.0.1") +API_PORT = int(os.environ.get("RIGOS_XMRIG_API_PORT", "18080")) +API_TIMEOUT_SECONDS = float(os.environ.get("RIGOS_XMRIG_API_TIMEOUT_SECONDS", "2")) +API_MAX_BYTES = 256 * 1024 +TEST_MODE = os.environ.get("RIGOS_MINER_HEALTH_TEST_MODE") == "1" +WINDOW_SECONDS = int(os.environ.get("RIGOS_MINER_HEALTH_WINDOW_SECONDS", "180")) +WARMUP_SECONDS = int(os.environ.get("RIGOS_MINER_WARMUP_SECONDS", "240")) +MAX_JOURNAL_LINES = 500 +FAULT_STREAK_THRESHOLD = int(os.environ.get("RIGOS_MINER_HEALTH_FAULT_STREAK_THRESHOLD", "2")) +RESTART_COOLDOWN_SECONDS = int(os.environ.get("RIGOS_MINER_HEALTH_RESTART_COOLDOWN_SECONDS", "600")) +RESTART_BUDGET_WINDOW_SECONDS = int(os.environ.get("RIGOS_MINER_HEALTH_RESTART_BUDGET_WINDOW_SECONDS", "1800")) +RESTART_BUDGET_MAX = int(os.environ.get("RIGOS_MINER_HEALTH_RESTART_BUDGET_MAX", "3")) +TOKEN_RE = re.compile(r"^[A-Za-z0-9_-]{32,128}$") + +READY_MARKERS = ( + "miner speed", + "accepted (", +) +EXTERNAL_MARKERS = ( + "connect error", + "connection refused", + "connection reset", + "network error", + "dns error", + "no active pools", + "operation timed out", + "connection timeout", +) +SHARE_RE = re.compile(r"accepted \((\d+)/(\d+)\)") + + +def atomic_json(path: Path, value: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp") + data = json.dumps(value, sort_keys=True, separators=(",", ":")) + "\n" + fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644) + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write(data) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) + directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(directory) + finally: + os.close(directory) + finally: + try: + temporary.unlink() + except FileNotFoundError: + pass + + +def now_epoch() -> int: + return int(dt.datetime.now(dt.timezone.utc).timestamp()) + + +def command(argv: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.run( + argv, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=10, + check=False, + ) + + +def systemctl_action(*args: str) -> subprocess.CompletedProcess[str]: + return command([SYSTEMCTL, *args]) + + +def systemd_properties() -> dict[str, str]: + result = command([ + SYSTEMCTL, + "show", + "rigos-miner.service", + "--property=ActiveState", + "--property=SubState", + "--property=MainPID", + "--property=NRestarts", + "--property=Result", + "--property=ExecMainStatus", + "--property=ActiveEnterTimestampMonotonic", + "--no-pager", + ]) + if result.returncode != 0: + raise RuntimeError("systemctl_show_failed") + values = {} + for line in result.stdout.splitlines(): + if "=" in line: + key, value = line.split("=", 1) + values[key] = value + return values + + +def current_revision() -> str | None: + try: + return CURRENT.resolve(strict=True).name + except OSError: + return None + + +def read_json_object(path: Path) -> dict | None: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError): + return None + return value if isinstance(value, dict) else None + + +def read_supervisor_state() -> dict: + value = read_json_object(STATE_DIR / "supervisor-state.json") + if not value or value.get("schema") != "rigos.miner-supervisor-state/v1": + return { + "schema": "rigos.miner-supervisor-state/v1", + "fault_key": None, + "fault_streak": 0, + "last_restart_epoch": None, + "restart_epochs": [], + "last_accepted_shares": None, + "last_rejected_shares": None, + } + if not isinstance(value.get("restart_epochs"), list): + value["restart_epochs"] = [] + return value + + +def write_supervisor_state(value: dict) -> None: + STATE_DIR.mkdir(parents=True, exist_ok=True) + atomic_json(STATE_DIR / "supervisor-state.json", value) + + +def runtime_revision() -> tuple[str | None, str | None]: + try: + value = json.loads((RUNTIME / "runtime-config-status.json").read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError): + return None, None + if not isinstance(value, dict): + return None, None + return value.get("outcome"), value.get("revision") + + +def process_state(pid: int) -> str | None: + try: + raw = (PROC / str(pid) / "stat").read_text(encoding="ascii") + except (OSError, UnicodeError): + return None + end = raw.rfind(")") + if end < 0: + return None + fields = raw[end + 1 :].split() + return fields[0] if fields else None + + +def active_seconds(properties: dict[str, str]) -> int | None: + try: + entered = int(properties.get("ActiveEnterTimestampMonotonic", "0")) + uptime = float((PROC / "uptime").read_text(encoding="ascii").split()[0]) + except (OSError, UnicodeError, ValueError, IndexError): + return None + if entered <= 0: + return None + return max(0, int(uptime - entered / 1_000_000)) + + +def recent_journal() -> tuple[str, bool]: + result = command([ + JOURNALCTL, + "-b", + "-u", + "rigos-miner.service", + "--since", + f"-{WINDOW_SECONDS} seconds", + "--lines", + str(MAX_JOURNAL_LINES), + "--no-pager", + "-o", + "cat", + ]) + return result.stdout, result.returncode == 0 + + +def last_share_counts(text: str) -> tuple[int | None, int | None]: + matches = SHARE_RE.findall(text) + if not matches: + return None, None + accepted, rejected = matches[-1] + return int(accepted), int(rejected) + + +def latest_marker_index(text: str, markers: tuple[str, ...]) -> int: + return max((text.rfind(marker) for marker in markers), default=-1) + + +def latest_journal_signal(text: str) -> str | None: + lowered = text.lower() + ready_index = latest_marker_index(lowered, READY_MARKERS) + external_index = latest_marker_index(lowered, EXTERNAL_MARKERS) + if ready_index < 0 and external_index < 0: + return None + return "external_wait" if external_index > ready_index else "ready" + + +def journal_fallback_state( + journal: str, + active_for: int | None, + api_error: str | None, +) -> tuple[str, str | None]: + signal = latest_journal_signal(journal) + if signal == "ready": + return "ready", None + if signal == "external_wait": + return "waiting_external", "pool_or_network_unavailable" + if active_for is not None and active_for < WARMUP_SECONDS: + return "warming_up", None + return "degraded", api_error or "miner_api_unavailable" + + +def expected_token_uid() -> int: + return os.geteuid() if TEST_MODE else 0 + + +def read_api_token() -> str: + nofollow = getattr(os, "O_NOFOLLOW", 0) + descriptor = os.open(API_TOKEN, os.O_RDONLY | os.O_CLOEXEC | nofollow) + try: + observed = os.fstat(descriptor) + if not stat.S_ISREG(observed.st_mode): + raise RuntimeError("api_token_not_regular") + if observed.st_uid != expected_token_uid(): + raise RuntimeError("api_token_owner_unsafe") + if stat.S_IMODE(observed.st_mode) != 0o600: + raise RuntimeError("api_token_mode_unsafe") + if observed.st_size > 256: + raise RuntimeError("api_token_oversized") + raw = os.read(descriptor, 257) + finally: + os.close(descriptor) + try: + token = raw.decode("ascii").strip() + except UnicodeDecodeError as error: + raise RuntimeError("api_token_not_ascii") from error + if not TOKEN_RE.fullmatch(token): + raise RuntimeError("api_token_invalid") + return token + + +def fetch_api_summary() -> tuple[dict | None, str | None]: + connection = None + try: + token = read_api_token() + connection = http.client.HTTPConnection( + API_HOST, + API_PORT, + timeout=API_TIMEOUT_SECONDS, + ) + connection.request( + "GET", + "/2/summary", + headers={ + "Accept": "application/json", + "Authorization": f"Bearer {token}", + "Connection": "close", + }, + ) + response = connection.getresponse() + if response.status != 200: + response.read(min(API_MAX_BYTES, 4096)) + return None, f"api_http_status_{response.status}" + length = response.getheader("Content-Length") + if length is not None: + try: + if int(length) > API_MAX_BYTES: + return None, "api_response_oversized" + except ValueError: + return None, "api_content_length_invalid" + raw = response.read(API_MAX_BYTES + 1) + if len(raw) > API_MAX_BYTES: + return None, "api_response_oversized" + value = json.loads(raw) + if not isinstance(value, dict): + return None, "api_response_not_object" + return value, None + except FileNotFoundError: + return None, "api_token_missing" + except PermissionError: + return None, "api_token_permission_denied" + except (OSError, RuntimeError, http.client.HTTPException, json.JSONDecodeError) as error: + message = str(error) + if message.startswith("api_"): + return None, message + return None, "api_unavailable" + finally: + if connection is not None: + connection.close() + + +def nonnegative_number(value: object) -> int | float | None: + if isinstance(value, bool) or not isinstance(value, (int, float)): + return None + if not math.isfinite(float(value)) or value < 0: + return None + return value + + +def nonnegative_integer(value: object) -> int | None: + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + return None + return value + + +def summary_metrics(summary: dict) -> dict: + hashrate = summary.get("hashrate") + if not isinstance(hashrate, dict): + hashrate = {} + raw_total = hashrate.get("total") + rates: list[int | float | None] = [None, None, None] + if isinstance(raw_total, list): + for index, value in enumerate(raw_total[:3]): + rates[index] = nonnegative_number(value) + + connection = summary.get("connection") + if not isinstance(connection, dict): + connection = {} + results = summary.get("results") + if not isinstance(results, dict): + results = {} + + accepted = nonnegative_integer(connection.get("accepted")) + rejected = nonnegative_integer(connection.get("rejected")) + if accepted is None: + accepted = nonnegative_integer(results.get("shares_good")) + if rejected is None: + total = nonnegative_integer(results.get("shares_total")) + if total is not None and accepted is not None and total >= accepted: + rejected = total - accepted + + pool = connection.get("pool") + if not isinstance(pool, str) or not pool: + pool = None + connection_ip = connection.get("ip") + if not isinstance(connection_ip, str) or not connection_ip: + connection_ip = None + connection_uptime_seconds = nonnegative_integer(connection.get("uptime")) + connection_uptime_ms = nonnegative_integer(connection.get("uptime_ms")) + pool_connected = pool is not None and ( + connection_ip is not None + or (connection_uptime_ms is not None and connection_uptime_ms > 0) + or (connection_uptime_seconds is not None and connection_uptime_seconds > 0) + ) + + hugepages = summary.get("hugepages") + hugepages_used = None + hugepages_total = None + if isinstance(hugepages, list) and len(hugepages) >= 2: + hugepages_used = nonnegative_integer(hugepages[0]) + hugepages_total = nonnegative_integer(hugepages[1]) + + return { + "uptime_seconds": nonnegative_integer(summary.get("uptime")), + "algorithm": summary.get("algo") if isinstance(summary.get("algo"), str) else None, + "hashrate_10s": rates[0], + "hashrate_60s": rates[1], + "hashrate_15m": rates[2], + "hashrate_highest": nonnegative_number(hashrate.get("highest")), + "pool_connected": pool_connected, + "pool": pool, + "connection_ip": connection_ip, + "connection_uptime_seconds": connection_uptime_seconds, + "connection_uptime_ms": connection_uptime_ms, + "connection_failures": nonnegative_integer(connection.get("failures")), + "pool_ping_ms": nonnegative_integer(connection.get("ping")), + "accepted_shares": accepted, + "rejected_shares": rejected, + "hugepages_used": hugepages_used, + "hugepages_total": hugepages_total, + } + + +def rejected_share_growth(metrics: dict | None, supervisor: dict) -> bool: + if metrics is None: + return False + rejected = metrics.get("rejected_shares") + if not isinstance(rejected, int): + return False + previous = supervisor.get("last_rejected_shares") + return isinstance(previous, int) and rejected > previous + + +def classify( + properties: dict[str, str], + proc_state: str | None, + active_for: int | None, + canonical_revision: str | None, + rendered_outcome: str | None, + rendered_revision: str | None, + api_metrics: dict | None, + api_error: str | None, + journal: str, + journal_available: bool, + supervisor: dict, +) -> tuple[str, str | None]: + if properties.get("ActiveState") != "active" or properties.get("SubState") != "running": + return "failed", "miner_unit_not_running" + try: + pid = int(properties.get("MainPID", "0")) + except ValueError: + pid = 0 + if pid <= 0 or proc_state is None or proc_state in ("X", "Z"): + return "failed", "miner_process_unavailable" + if canonical_revision is None: + return "blocked", "current_revision_unavailable" + if rendered_outcome != "ready" or rendered_revision != canonical_revision: + return "blocked", "runtime_revision_mismatch" + + if api_metrics is not None: + if rejected_share_growth(api_metrics, supervisor): + return "degraded", "rejected_shares_increased" + current_hashrate = api_metrics.get("hashrate_10s") + current_hashrate_positive = ( + isinstance(current_hashrate, (int, float)) + and not isinstance(current_hashrate, bool) + and current_hashrate > 0 + ) + pool_connected = api_metrics.get("pool_connected") is True + if pool_connected and current_hashrate_positive: + return "ready", None + if active_for is not None and active_for < WARMUP_SECONDS: + return "warming_up", None + if not pool_connected: + return "waiting_external", "pool_or_network_unavailable" + if current_hashrate is None: + return "degraded", "current_hashrate_unavailable" + return "degraded", "no_current_hashrate_from_api" + + if api_error not in (None, "api_unavailable"): + return "degraded", api_error + if not journal_available: + return "unknown", api_error or "journal_unavailable" + return journal_fallback_state(journal, active_for, api_error) + + +def actionable_fault(state: str, reason: str | None) -> bool: + if state == "waiting_external": + return False + if state == "blocked": + return False + return reason in { + "miner_unit_not_running", + "miner_process_unavailable", + "current_hashrate_unavailable", + "no_current_hashrate_from_api", + "rejected_shares_increased", + "miner_api_unavailable", + "api_unavailable", + } + + +def update_fault_streak(supervisor: dict, state: str, reason: str | None) -> tuple[dict, str]: + key = f"{state}:{reason or 'none'}" + if actionable_fault(state, reason): + if supervisor.get("fault_key") == key: + supervisor["fault_streak"] = int(supervisor.get("fault_streak") or 0) + 1 + else: + supervisor["fault_key"] = key + supervisor["fault_streak"] = 1 + else: + supervisor["fault_key"] = None + supervisor["fault_streak"] = 0 + return supervisor, key + + +def remediation_decision(supervisor: dict, state: str, reason: str | None) -> tuple[str, str | None]: + if not actionable_fault(state, reason): + return "none", "not_actionable" + streak = int(supervisor.get("fault_streak") or 0) + if streak < FAULT_STREAK_THRESHOLD: + return "observe", "fault_streak_below_threshold" + now = now_epoch() + last_restart = supervisor.get("last_restart_epoch") + if isinstance(last_restart, int) and now - last_restart < RESTART_COOLDOWN_SECONDS: + return "cooldown", "restart_cooldown_active" + restart_epochs = [ + value for value in supervisor.get("restart_epochs", []) + if isinstance(value, int) and now - value < RESTART_BUDGET_WINDOW_SECONDS + ] + supervisor["restart_epochs"] = restart_epochs + if len(restart_epochs) >= RESTART_BUDGET_MAX: + return "degraded_budget_exhausted", "restart_budget_exhausted" + return "restart_miner", None + + +def apply_remediation(supervisor: dict, action: str) -> tuple[str, str | None]: + if action != "restart_miner": + return action, None + result = systemctl_action("restart", "rigos-miner.service") + if result.returncode != 0: + return "restart_failed", "systemctl_restart_failed" + now = now_epoch() + epochs = [ + value for value in supervisor.get("restart_epochs", []) + if isinstance(value, int) and now - value < RESTART_BUDGET_WINDOW_SECONDS + ] + epochs.append(now) + supervisor["restart_epochs"] = epochs + supervisor["last_restart_epoch"] = now + supervisor["fault_streak"] = 0 + supervisor["fault_key"] = None + return "restart_miner", None + + +def update_observed_counters(supervisor: dict, metrics: dict | None) -> None: + if metrics is None: + return + accepted = metrics.get("accepted_shares") + rejected = metrics.get("rejected_shares") + if isinstance(accepted, int): + supervisor["last_accepted_shares"] = accepted + if isinstance(rejected, int): + supervisor["last_rejected_shares"] = rejected + + +def main() -> int: + try: + boot_id = BOOT_ID.read_text(encoding="ascii").strip() + if not boot_id: + raise RuntimeError("boot_id_unavailable") + properties = systemd_properties() + try: + pid = int(properties.get("MainPID", "0")) + except ValueError: + pid = 0 + proc_state = process_state(pid) if pid > 0 else None + active_for = active_seconds(properties) + canonical = current_revision() + rendered_outcome, rendered_revision = runtime_revision() + summary, api_error = fetch_api_summary() + metrics = summary_metrics(summary) if summary is not None else None + supervisor = read_supervisor_state() + journal, journal_available = recent_journal() + journal_accepted, journal_rejected = last_share_counts(journal) + state, reason = classify( + properties, + proc_state, + active_for, + canonical, + rendered_outcome, + rendered_revision, + metrics, + api_error, + journal, + journal_available, + supervisor, + ) + supervisor, fault_key = update_fault_streak(supervisor, state, reason) + remediation_action, remediation_reason = remediation_decision(supervisor, state, reason) + remediation_action, remediation_apply_error = apply_remediation(supervisor, remediation_action) + if remediation_apply_error is not None: + remediation_reason = remediation_apply_error + update_observed_counters(supervisor, metrics) + write_supervisor_state(supervisor) + lowered_journal = journal.lower() + accepted = metrics.get("accepted_shares") if metrics is not None else journal_accepted + rejected = metrics.get("rejected_shares") if metrics is not None else journal_rejected + value = { + "schema": "rigos.miner-health-status/v1", + "generated_at": dt.datetime.now(dt.timezone.utc).isoformat(timespec="milliseconds"), + "boot_id": boot_id, + "state": state, + "reason": reason, + "unit": { + "active_state": properties.get("ActiveState"), + "sub_state": properties.get("SubState"), + "result": properties.get("Result"), + "main_pid": pid or None, + "process_state": proc_state, + "restart_count": int(properties.get("NRestarts", "0") or 0), + "exec_main_status": int(properties.get("ExecMainStatus", "0") or 0), + "active_seconds": active_for, + }, + "config": { + "current_revision": canonical, + "runtime_outcome": rendered_outcome, + "runtime_revision": rendered_revision, + }, + "api": { + "available": metrics is not None, + "error": api_error, + "endpoint": f"http://{API_HOST}:{API_PORT}/2/summary", + "metrics": metrics, + }, + "evidence": { + "source": "xmrig_http_api" if metrics is not None else "journal_fallback", + "window_seconds": WINDOW_SECONDS, + "journal_available": journal_available, + "journal_line_limit": MAX_JOURNAL_LINES, + "recent_speed": "miner speed" in lowered_journal, + "external_wait_marker": any( + marker in lowered_journal for marker in EXTERNAL_MARKERS + ), + "latest_journal_signal": latest_journal_signal(journal), + "accepted_shares": accepted, + "rejected_shares": rejected, + }, + "supervisor": { + "fault_key": fault_key if actionable_fault(state, reason) else None, + "fault_streak": supervisor.get("fault_streak"), + "restart_budget_max": RESTART_BUDGET_MAX, + "restart_budget_window_seconds": RESTART_BUDGET_WINDOW_SECONDS, + "restart_cooldown_seconds": RESTART_COOLDOWN_SECONDS, + "restart_epochs": supervisor.get("restart_epochs", []), + "last_restart_epoch": supervisor.get("last_restart_epoch"), + }, + "remediation": { + "action": remediation_action, + "reason": remediation_reason, + }, + } + atomic_json(STATUS, value) + print(json.dumps({ + "schema": "rigos.miner-health-observer/v1", + "state": state, + "reason": reason, + "evidence_source": value["evidence"]["source"], + "remediation": remediation_action, + }, sort_keys=True)) + return 0 + except (OSError, UnicodeError, RuntimeError, subprocess.SubprocessError, ValueError) as error: + print(f"rigos-miner-health: {error}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/build/usb/includes.chroot/usr/lib/rigos/rigos-randomx-msr b/build/usb/includes.chroot/usr/lib/rigos/rigos-randomx-msr new file mode 100644 index 00000000..27009d6f --- /dev/null +++ b/build/usb/includes.chroot/usr/lib/rigos/rigos-randomx-msr @@ -0,0 +1,440 @@ +#!/usr/bin/python3 +# Repository contract: LF line endings are required for appliance execution. +import argparse +import datetime as dt +import fcntl +import json +import os +import struct +import sys +import uuid +from pathlib import Path + +STATUS_SCHEMA = "rigos.randomx-msr-status/v1" +STATE_SCHEMA = "rigos.randomx-msr-state/v1" +REGISTER = 0x1A4 +TARGET_VALUE = 0xF +SUPPORTED_CPUS = {("GenuineIntel", 6, 42)} + + +class AuthorityError(RuntimeError): + pass + + +def utc_now() -> str: + return dt.datetime.now(dt.timezone.utc).isoformat(timespec="milliseconds") + + +def value_hex(value: int) -> str: + return f"0x{value:016x}" + + +def read_trimmed(path: Path, name: str) -> str: + try: + value = path.read_text(encoding="ascii").strip() + except (OSError, UnicodeError) as error: + raise AuthorityError(f"{name}: {error}") from error + if not value: + raise AuthorityError(f"{name} is empty") + return value + + +def parse_cpu_identity(path: Path) -> tuple[str, int, int]: + try: + text = path.read_text(encoding="ascii") + except (OSError, UnicodeError) as error: + raise AuthorityError(f"cpuinfo: {error}") from error + first = text.split("\n\n", 1)[0] + fields: dict[str, str] = {} + for line in first.splitlines(): + if ":" not in line: + continue + key, value = line.split(":", 1) + fields[key.strip()] = value.strip() + vendor = fields.get("vendor_id") + family = fields.get("cpu family") + model = fields.get("model") + if not vendor or family is None or model is None: + raise AuthorityError("cpu identity fields are incomplete") + try: + return vendor, int(family, 10), int(model, 10) + except ValueError as error: + raise AuthorityError("cpu family/model is malformed") from error + + +def parse_online_cpus(text: str) -> list[int]: + value = text.strip() + if not value: + raise AuthorityError("online CPU list is empty") + cpus: set[int] = set() + for item in value.split(","): + item = item.strip() + if not item: + raise AuthorityError("online CPU list contains an empty item") + if "-" in item: + start_text, end_text = item.split("-", 1) + try: + start = int(start_text, 10) + end = int(end_text, 10) + except ValueError as error: + raise AuthorityError("online CPU range is malformed") from error + if start < 0 or end < start: + raise AuthorityError("online CPU range is invalid") + cpus.update(range(start, end + 1)) + else: + try: + cpu = int(item, 10) + except ValueError as error: + raise AuthorityError("online CPU identifier is malformed") from error + if cpu < 0: + raise AuthorityError("online CPU identifier is invalid") + cpus.add(cpu) + if not cpus: + raise AuthorityError("online CPU list resolved to no CPUs") + return sorted(cpus) + + +def msr_path(device_root: Path, cpu: int) -> Path: + return device_root / str(cpu) / "msr" + + +def read_msr(device_root: Path, cpu: int, register: int = REGISTER) -> int: + path = msr_path(device_root, cpu) + try: + fd = os.open(path, os.O_RDONLY | os.O_CLOEXEC) + except OSError as error: + raise AuthorityError(f"open {path}: {error}") from error + try: + data = os.pread(fd, 8, register) + except OSError as error: + raise AuthorityError(f"read CPU {cpu} MSR {register:#x}: {error}") from error + finally: + os.close(fd) + if len(data) != 8: + raise AuthorityError(f"short read from CPU {cpu} MSR {register:#x}") + return struct.unpack(" None: + path = msr_path(device_root, cpu) + try: + fd = os.open(path, os.O_RDWR | os.O_CLOEXEC) + except OSError as error: + raise AuthorityError(f"open {path} for write: {error}") from error + try: + written = os.pwrite(fd, struct.pack(" None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp") + data = json.dumps(value, sort_keys=True, separators=(",", ":")) + "\n" + fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_CLOEXEC, mode) + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write(data) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) + directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC) + try: + os.fsync(directory) + finally: + os.close(directory) + finally: + try: + temporary.unlink() + except FileNotFoundError: + pass + + +def unlink_synced(path: Path) -> None: + try: + path.unlink() + except FileNotFoundError: + return + directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC) + try: + os.fsync(directory) + finally: + os.close(directory) + + +def load_state(path: Path) -> dict | None: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError: + return None + except (OSError, UnicodeError, json.JSONDecodeError) as error: + raise AuthorityError(f"MSR state is unreadable: {error}") from error + if not isinstance(value, dict) or value.get("schema") != STATE_SCHEMA: + raise AuthorityError("MSR state schema is invalid") + if value.get("register") != value_hex(REGISTER): + raise AuthorityError("MSR state register is invalid") + entries = value.get("cpus") + if not isinstance(entries, list) or not entries: + raise AuthorityError("MSR state CPU list is invalid") + for entry in entries: + if not isinstance(entry, dict): + raise AuthorityError("MSR state CPU entry is invalid") + if not isinstance(entry.get("cpu"), int) or not isinstance(entry.get("original"), str): + raise AuthorityError("MSR state CPU value is invalid") + try: + int(entry["original"], 16) + except ValueError as error: + raise AuthorityError("MSR state original value is malformed") from error + return value + + +def base_status(boot_id: str, vendor: str | None, family: int | None, model: int | None) -> dict: + return { + "schema": STATUS_SCHEMA, + "generated_at": utc_now(), + "boot_id": boot_id, + "vendor_id": vendor, + "cpu_family": family, + "cpu_model": model, + "register": value_hex(REGISTER), + "target_value": value_hex(TARGET_VALUE), + } + + +def publish(status_path: Path, value: dict) -> None: + atomic_json(status_path, value, 0o644) + print(json.dumps(value, sort_keys=True, separators=(",", ":"))) + + +def restore_values(device_root: Path, state: dict) -> tuple[bool, list[str], list[dict]]: + errors: list[str] = [] + evidence: list[dict] = [] + entries = state["cpus"] + for entry in entries: + cpu = entry["cpu"] + original = int(entry["original"], 16) + try: + write_msr(device_root, cpu, original) + except AuthorityError as error: + errors.append(str(error)) + for entry in entries: + cpu = entry["cpu"] + original = int(entry["original"], 16) + try: + readback = read_msr(device_root, cpu) + evidence.append({ + "cpu": cpu, + "original": value_hex(original), + "readback": value_hex(readback), + }) + if readback != original: + errors.append( + f"CPU {cpu} restore readback mismatch: expected {value_hex(original)} got {value_hex(readback)}" + ) + except AuthorityError as error: + errors.append(str(error)) + return not errors, errors, evidence + + +def current_values_match_target(device_root: Path, state: dict) -> bool: + try: + return all( + read_msr(device_root, entry["cpu"]) == TARGET_VALUE + for entry in state["cpus"] + ) + except AuthorityError: + return False + + +def apply(args: argparse.Namespace) -> int: + boot_id = read_trimmed(args.boot_id, "boot ID") + vendor, family, model = parse_cpu_identity(args.cpuinfo) + cpus = parse_online_cpus(read_trimmed(args.online, "online CPU list")) + status = base_status(boot_id, vendor, family, model) + status["online_cpus"] = cpus + + state = load_state(args.state) + if state is not None: + if state.get("boot_id") != boot_id: + unlink_synced(args.state) + state = None + elif current_values_match_target(args.device_root, state): + status.update({ + "outcome": "ready", + "reason": "already_applied", + "cpus": state["cpus"], + "rollback": {"attempted": False, "complete": None}, + }) + publish(args.status, status) + return 0 + else: + restored, errors, evidence = restore_values(args.device_root, state) + if not restored: + status.update({ + "outcome": "degraded", + "reason": "previous_state_restore_failed", + "errors": errors, + "cpus": evidence, + "rollback": {"attempted": True, "complete": False}, + }) + publish(args.status, status) + return 0 + unlink_synced(args.state) + state = None + + if (vendor, family, model) not in SUPPORTED_CPUS: + status.update({ + "outcome": "unsupported", + "reason": "cpu_not_allowlisted", + "cpus": [], + "rollback": {"attempted": False, "complete": None}, + }) + publish(args.status, status) + return 0 + + originals: list[dict] = [] + try: + for cpu in cpus: + originals.append({"cpu": cpu, "original": value_hex(read_msr(args.device_root, cpu))}) + except AuthorityError as error: + status.update({ + "outcome": "unavailable", + "reason": "msr_device_unavailable", + "errors": [str(error)], + "cpus": originals, + "rollback": {"attempted": False, "complete": None}, + }) + publish(args.status, status) + return 0 + + state = { + "schema": STATE_SCHEMA, + "boot_id": boot_id, + "register": value_hex(REGISTER), + "target_value": value_hex(TARGET_VALUE), + "cpus": originals, + } + atomic_json(args.state, state, 0o600) + + apply_error: str | None = None + evidence: list[dict] = [] + try: + for cpu in cpus: + write_msr(args.device_root, cpu, TARGET_VALUE) + for entry in originals: + cpu = entry["cpu"] + readback = read_msr(args.device_root, cpu) + evidence.append({ + "cpu": cpu, + "original": entry["original"], + "readback": value_hex(readback), + }) + if readback != TARGET_VALUE: + raise AuthorityError( + f"CPU {cpu} apply readback mismatch: expected {value_hex(TARGET_VALUE)} got {value_hex(readback)}" + ) + except AuthorityError as error: + apply_error = str(error) + + if apply_error is not None: + restored, restore_errors, restore_evidence = restore_values(args.device_root, state) + if restored: + unlink_synced(args.state) + status.update({ + "outcome": "degraded", + "reason": "apply_failed_rolled_back" if restored else "apply_failed_rollback_incomplete", + "errors": [apply_error, *restore_errors], + "cpus": restore_evidence, + "rollback": {"attempted": True, "complete": restored}, + }) + publish(args.status, status) + return 0 + + status.update({ + "outcome": "ready", + "reason": None, + "cpus": evidence, + "rollback": {"attempted": False, "complete": None}, + }) + publish(args.status, status) + return 0 + + +def restore(args: argparse.Namespace) -> int: + boot_id = read_trimmed(args.boot_id, "boot ID") + vendor: str | None = None + family: int | None = None + model: int | None = None + try: + vendor, family, model = parse_cpu_identity(args.cpuinfo) + except AuthorityError: + pass + status = base_status(boot_id, vendor, family, model) + state = load_state(args.state) + if state is None: + status.update({ + "outcome": "restored", + "reason": "state_absent", + "cpus": [], + "rollback": {"attempted": False, "complete": True}, + }) + publish(args.status, status) + return 0 + if state.get("boot_id") != boot_id: + unlink_synced(args.state) + status.update({ + "outcome": "restored", + "reason": "stale_state_discarded", + "cpus": [], + "rollback": {"attempted": False, "complete": True}, + }) + publish(args.status, status) + return 0 + restored, errors, evidence = restore_values(args.device_root, state) + if restored: + unlink_synced(args.state) + status.update({ + "outcome": "restored" if restored else "degraded", + "reason": None if restored else "restore_failed", + "errors": errors, + "cpus": evidence, + "rollback": {"attempted": True, "complete": restored}, + }) + publish(args.status, status) + return 0 + + +def parser() -> argparse.ArgumentParser: + result = argparse.ArgumentParser(description="RIGOS reversible RandomX MSR authority") + result.add_argument("command", choices=("apply", "restore")) + result.add_argument("--cpuinfo", type=Path, default=Path("/proc/cpuinfo")) + result.add_argument("--online", type=Path, default=Path("/sys/devices/system/cpu/online")) + result.add_argument("--device-root", type=Path, default=Path("/dev/cpu")) + result.add_argument("--boot-id", type=Path, default=Path("/proc/sys/kernel/random/boot_id")) + result.add_argument("--status", type=Path, default=Path("/run/rigos/randomx-msr-status.json")) + result.add_argument("--state", type=Path, default=Path("/run/rigos/randomx-msr-state.json")) + result.add_argument("--lock", type=Path, default=Path("/run/rigos/.randomx-msr.lock")) + return result + + +def main() -> int: + args = parser().parse_args() + args.lock.parent.mkdir(parents=True, exist_ok=True) + with args.lock.open("a+b") as lock_handle: + os.chmod(args.lock, 0o600) + fcntl.flock(lock_handle.fileno(), fcntl.LOCK_EX) + if args.command == "apply": + return apply(args) + return restore(args) + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (AuthorityError, OSError, ValueError) as error: + print(f"rigos-randomx-msr: {error}", file=sys.stderr) + raise SystemExit(2) diff --git a/build/usb/includes.chroot/usr/lib/rigos/rigos-recovery-access-verify b/build/usb/includes.chroot/usr/lib/rigos/rigos-recovery-access-verify new file mode 100644 index 00000000..cd1b2057 --- /dev/null +++ b/build/usb/includes.chroot/usr/lib/rigos/rigos-recovery-access-verify @@ -0,0 +1,69 @@ +#!/usr/bin/python3 +import json +import os +import sys +from pathlib import Path + +RUNTIME = Path(os.environ.get("RIGOS_RUNTIME_PATH", "/run/rigos")) +BOOT_ID = Path(os.environ.get("RIGOS_BOOT_ID_PATH", "/proc/sys/kernel/random/boot_id")) +STATUS = RUNTIME / "recovery-access-status.json" +MAX_STATUS_BYTES = 64 * 1024 + + +def emit(outcome: str, reason: str | None = None) -> None: + value = { + "schema": "rigos.recovery-access-gate/v1", + "outcome": outcome, + "reason": reason, + } + print(json.dumps(value, sort_keys=True), file=sys.stdout if outcome == "allowed" else sys.stderr) + + +def deny(reason: str) -> int: + emit("denied", reason) + return 2 + + +def main() -> int: + try: + boot_id = BOOT_ID.read_text(encoding="ascii").strip() + raw = STATUS.read_bytes() + if not boot_id or not raw or len(raw) > MAX_STATUS_BYTES: + return deny("recovery_status_unreadable") + status = json.loads(raw) + except (OSError, UnicodeError, json.JSONDecodeError): + return deny("recovery_status_unreadable") + + if not isinstance(status, dict): + return deny("recovery_status_invalid") + if status.get("schema") != "rigos.recovery-access-status/v1": + return deny("recovery_schema_mismatch") + if status.get("boot_id") != boot_id: + return deny("recovery_status_stale") + if status.get("local_console_access") is not True: + return deny("local_credential_unavailable") + if status.get("credential_action") not in ("existing", "created", "restored"): + return deny("credential_action_invalid") + + scope = status.get("credential_scope") + persisted = status.get("credential_persisted") + state_outcome = status.get("state_outcome") + if scope == "persistent": + if persisted is not True: + return deny("persistent_credential_missing") + if state_outcome != "ready": + return deny("persistent_credential_without_ready_state") + elif scope == "boot": + if persisted is not False: + return deny("boot_credential_claims_persistence") + if state_outcome == "ready": + return deny("ready_state_without_persistent_credential") + else: + return deny("credential_scope_invalid") + + emit("allowed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/build/usb/includes.chroot/usr/lib/rigos/rigos-remote-access-probe b/build/usb/includes.chroot/usr/lib/rigos/rigos-remote-access-probe new file mode 100644 index 00000000..b7b2d004 --- /dev/null +++ b/build/usb/includes.chroot/usr/lib/rigos/rigos-remote-access-probe @@ -0,0 +1,99 @@ +#!/usr/bin/python3 +import json +import os +import subprocess +import tempfile +from pathlib import Path + +RUNTIME = Path("/run/rigos") +STATUS = RUNTIME / "recovery-access-status.json" +BOOT_ID = Path("/proc/sys/kernel/random/boot_id") +SSH_PORT = 22 + + +def unit_state(action: str, unit: str) -> bool: + return subprocess.run( + ["/usr/bin/systemctl", action, "--quiet", unit], + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ).returncode == 0 + + +def has_listener(path: Path, port: int) -> bool: + try: + lines = path.read_text(encoding="ascii").splitlines()[1:] + except OSError: + return False + for line in lines: + fields = line.split() + if len(fields) < 4 or fields[3] != "0A": + continue + try: + observed = int(fields[1].rsplit(":", 1)[1], 16) + except (IndexError, ValueError): + continue + if observed == port: + return True + return False + + +def fsync_directory(path: Path) -> None: + descriptor = os.open(path, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + + +def main() -> int: + boot_id = BOOT_ID.read_text(encoding="ascii").strip() + status = json.loads(STATUS.read_text(encoding="utf-8")) + if status.get("boot_id") != boot_id: + raise RuntimeError("recovery access status belongs to another boot") + enabled = unit_state("is-enabled", "ssh.service") + active = unit_state("is-active", "ssh.service") + ipv4 = has_listener(Path("/proc/net/tcp"), SSH_PORT) + ipv6 = has_listener(Path("/proc/net/tcp6"), SSH_PORT) + listening = ipv4 or ipv6 + if enabled and active and listening: + remote_access = "active" + elif enabled and not listening: + remote_access = "enabled_no_listener" + elif listening and not active: + remote_access = "listener_without_service" + else: + remote_access = "inactive" + operational = ( + status.get("state_outcome") == "ready" + and status.get("local_console_access") is True + and remote_access == "active" + ) + status.update( + { + "mode": "operational" if operational else "recovery", + "remote_access": remote_access, + "remote_protocol": "ssh", + "remote_port": SSH_PORT, + "ssh_service_enabled": enabled, + "ssh_service_active": active, + "ssh_listener_ipv4": ipv4, + "ssh_listener_ipv6": ipv6, + } + ) + with tempfile.NamedTemporaryFile( + mode="w", encoding="utf-8", dir=RUNTIME, prefix=".recovery-access-", delete=False + ) as stream: + json.dump(status, stream, sort_keys=True) + stream.write("\n") + stream.flush() + os.fsync(stream.fileno()) + temporary = Path(stream.name) + temporary.chmod(0o644) + os.replace(temporary, STATUS) + fsync_directory(RUNTIME) + return 0 if operational else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/build/usb/includes.chroot/usr/lib/rigos/rigos-runtime-authority b/build/usb/includes.chroot/usr/lib/rigos/rigos-runtime-authority new file mode 100755 index 00000000..aa09747b --- /dev/null +++ b/build/usb/includes.chroot/usr/lib/rigos/rigos-runtime-authority @@ -0,0 +1,32 @@ +#!/bin/sh +# Repository contract: LF line endings are required for WSL and appliance execution. +set -eu + +runtime=${RIGOS_RUNTIME_PATH:-/run/rigos} +publisher=${RIGOS_RUNTIME_PUBLISHER:-/usr/lib/rigos/rigos-runtime-publish} +mkdir -p "$runtime" +chmod 0755 "$runtime" + +lock="$runtime/.runtime-publish.lock" +exec 9>"$lock" +chmod 0600 "$lock" +if ! flock -x -w 30 9; then + echo 'rigos-runtime-authority: publication lock timeout' >&2 + exit 75 +fi + +cleanup_stale() { + find -P "$runtime" -mindepth 1 -maxdepth 1 -type d \ + -name '.render-stage.*' -exec rm -rf -- {} + + find -P "$runtime" -mindepth 1 -maxdepth 1 -type f \ + \( -name '.xmrig-public.*' \ + -o -name '.runtime-status.*' \ + -o -name '.xmrig-runtime.*' \) \ + -delete +} + +cleanup_stale +status=0 +"$publisher" || status=$? +cleanup_stale +exit "$status" diff --git a/build/usb/includes.chroot/usr/lib/rigos/rigos-runtime-gate b/build/usb/includes.chroot/usr/lib/rigos/rigos-runtime-gate new file mode 100644 index 00000000..c028d554 --- /dev/null +++ b/build/usb/includes.chroot/usr/lib/rigos/rigos-runtime-gate @@ -0,0 +1,66 @@ +#!/usr/bin/python3 +import argparse +import json +import sys +from pathlib import Path + +MAX_JSON_BYTES = 2 * 1024 * 1024 + + +def emit(outcome: str, reason: str | None = None) -> None: + value = { + "schema": "rigos.runtime-gate/v1", + "outcome": outcome, + "reason": reason, + } + print(json.dumps(value, sort_keys=True), file=sys.stdout if outcome == "allowed" else sys.stderr) + + +def read_object(path: Path) -> dict: + raw = path.read_bytes() + if len(raw) > MAX_JSON_BYTES: + raise ValueError("JSON exceeds size limit") + value = json.loads(raw) + if not isinstance(value, dict): + raise ValueError("JSON root is not an object") + return value + + +def deny(reason: str) -> int: + emit("denied", reason) + return 2 + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--state", type=Path, default=Path("/var/lib/rigos")) + parser.add_argument("--runtime", type=Path, default=Path("/run/rigos")) + args = parser.parse_args() + try: + revision = (args.state / "current").resolve(strict=True).name + status = read_object(args.runtime / "runtime-config-status.json") + config = read_object(args.runtime / "xmrig.json") + except (OSError, ValueError, json.JSONDecodeError): + return deny("runtime_truth_unreadable") + if status.get("schema") != "rigos.runtime-config-status/v1" or status.get("outcome") != "ready": + return deny("runtime_status_invalid") + if status.get("revision") != revision: + return deny("runtime_revision_stale") + pools = config.get("pools") + cpu = config.get("cpu") + if not isinstance(pools, list) or not pools or not isinstance(cpu, dict): + return deny("runtime_config_invalid") + exact_threads = status.get("exact_threads") + profile = status.get("profile") + if exact_threads is not None: + if not isinstance(exact_threads, int) or exact_threads < 1 or not isinstance(profile, str): + return deny("runtime_thread_truth_invalid") + entries = cpu.get(profile) + if not isinstance(entries, list) or len(entries) != exact_threads: + return deny("runtime_thread_profile_mismatch") + emit("allowed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/build/usb/includes.chroot/usr/lib/rigos/rigos-runtime-publish b/build/usb/includes.chroot/usr/lib/rigos/rigos-runtime-publish new file mode 100644 index 00000000..7e3611bc --- /dev/null +++ b/build/usb/includes.chroot/usr/lib/rigos/rigos-runtime-publish @@ -0,0 +1,115 @@ +#!/bin/sh +# Repository contract: LF line endings are required for WSL and appliance execution. +set -eu + +runtime=${RIGOS_RUNTIME_PATH:-/run/rigos} +renderer=${RIGOS_RUNTIME_RENDERER:-/usr/lib/rigos/rigos-runtime-render} +jq_bin=${RIGOS_JQ:-/usr/bin/jq} +if [ ! -x "$jq_bin" ]; then + echo "rigos-runtime-publish: required jq runtime is missing: $jq_bin" >&2 + exit 127 +fi +mkdir -p "$runtime" +chmod 0755 "$runtime" +stage=$(mktemp -d "$runtime/.render-stage.XXXXXX") +chmod 0700 "$stage" +public_tmp= +status_tmp= +config_tmp= +cleanup() { + [ -z "$public_tmp" ] || rm -f "$public_tmp" + [ -z "$status_tmp" ] || rm -f "$status_tmp" + [ -z "$config_tmp" ] || rm -f "$config_tmp" + rm -rf "$stage" +} +trap cleanup EXIT HUP INT TERM + +RIGOS_RUNTIME_PATH="$stage" \ +RIGOS_XMRIG_API_TOKEN_PATH="$runtime/xmrig-api-token" \ + "$renderer" + +"$jq_bin" -e ' + .schema == "rigos.runtime-config-status/v1" and + .outcome == "ready" and + (.revision | type == "string") and + (.algorithm | type == "string") +' "$stage/runtime-config-status.json" >/dev/null + +algorithm=$("$jq_bin" -r '.algorithm' "$stage/runtime-config-status.json") +revision=$("$jq_bin" -r '.revision' "$stage/runtime-config-status.json") +thread_count=$("$jq_bin" -r '.exact_threads // empty' "$stage/runtime-config-status.json") +profile=$("$jq_bin" -r '.profile // empty' "$stage/runtime-config-status.json") + +public_tmp=$(mktemp "$runtime/.xmrig-public.XXXXXX") +status_tmp=$(mktemp "$runtime/.runtime-status.XXXXXX") +config_tmp=$(mktemp "$runtime/.xmrig-runtime.XXXXXX") + +"$jq_bin" \ + --arg algorithm "$algorithm" \ + --arg revision "$revision" \ + --arg profile "$profile" \ + --argjson thread_count "${thread_count:-null}" ' + def present: with_entries(select(.value != null)); + def endpoint: + if type == "string" then split("@") | last else null end; + { + algo: $algorithm, + cpu: ({ + enabled: .cpu.enabled, + "huge-pages": .cpu["huge-pages"], + "max-threads-hint": .cpu["max-threads-hint"] + } | present), + pools: [ + .pools[] | ({ + url: (.url | endpoint), + algo: .algo, + tls: .tls, + nicehash: .nicehash, + keepalive: .keepalive, + priority: .priority + } | present) + ], + http: ({ + enabled: (.http.enabled // false), + host: .http.host, + port: .http.port, + restricted: .http.restricted + } | present), + randomx: { + "huge-pages": (.cpu["huge-pages"] // false) + }, + "rigos-public-view": { + schema: "rigos.xmrig-public-config/v1", + identity_redacted: true, + construction: "allowlist", + source_revision: $revision + } + } + | if $thread_count != null then .threads = $thread_count else . end + | if ($profile | length) > 0 and $thread_count != null + then .cpu[$profile] = [range(0; $thread_count) | -1] + else . + end +' "$stage/xmrig.json" >"$public_tmp" + +"$jq_bin" '. + { + identity_redacted_public_view: true, + public_view_construction: "allowlist" +}' "$stage/runtime-config-status.json" >"$status_tmp" + +cp "$stage/xmrig.json" "$config_tmp" +chmod 0640 "$config_tmp" +if [ "${RIGOS_RENDER_SKIP_CHOWN:-0}" != 1 ]; then + chown root:rigos "$config_tmp" +fi +chmod 0644 "$public_tmp" "$status_tmp" +sync -f "$config_tmp" +sync -f "$public_tmp" +sync -f "$status_tmp" +mv -f "$config_tmp" "$runtime/xmrig.json" +config_tmp= +mv -f "$public_tmp" "$runtime/xmrig-public.json" +public_tmp= +mv -f "$status_tmp" "$runtime/runtime-config-status.json" +status_tmp= +sync -f "$runtime" diff --git a/build/usb/includes.chroot/usr/lib/rigos/rigos-runtime-render b/build/usb/includes.chroot/usr/lib/rigos/rigos-runtime-render new file mode 100644 index 00000000..c1f1bdce --- /dev/null +++ b/build/usb/includes.chroot/usr/lib/rigos/rigos-runtime-render @@ -0,0 +1,236 @@ +#!/usr/bin/python3 +import copy +import grp +import hashlib +import json +import os +import re +import secrets +import stat +import tempfile +from pathlib import Path + +STATE = Path(os.environ.get("RIGOS_STATE_PATH", "/var/lib/rigos")) +RUNTIME = Path(os.environ.get("RIGOS_RUNTIME_PATH", "/run/rigos")) +PRIVATE_CONFIG = RUNTIME / "xmrig.json" +PUBLIC_CONFIG = RUNTIME / "xmrig-public.json" +STATUS = RUNTIME / "runtime-config-status.json" +API_TOKEN = Path(os.environ.get("RIGOS_XMRIG_API_TOKEN_PATH", str(RUNTIME / "xmrig-api-token"))) +API_HOST = "127.0.0.1" +API_PORT = 18080 +MAX_JSON_BYTES = 2 * 1024 * 1024 +TOKEN_RE = re.compile(r"^[A-Za-z0-9_-]{32,128}$") +SKIP_CHOWN = os.environ.get("RIGOS_RENDER_SKIP_CHOWN") == "1" + + +def read_object(path: Path) -> dict: + raw = path.read_bytes() + if len(raw) > MAX_JSON_BYTES: + raise RuntimeError(f"JSON exceeds size limit: {path}") + value = json.loads(raw) + if not isinstance(value, dict): + raise RuntimeError(f"JSON root is not an object: {path}") + return value + + +def fsync_directory(path: Path) -> None: + descriptor = os.open(path, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + + +def atomic_json(path: Path, value: dict, mode: int, gid: int | None = None) -> None: + path.parent.mkdir(mode=0o755, parents=True, exist_ok=True) + descriptor, name = tempfile.mkstemp(prefix=f".{path.name}-", dir=path.parent) + temporary = Path(name) + try: + os.fchmod(descriptor, mode) + if gid is not None: + os.fchown(descriptor, 0, gid) + with os.fdopen(descriptor, "w", encoding="utf-8", closefd=True) as stream: + json.dump(value, stream, sort_keys=True) + stream.write("\n") + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + path.chmod(mode) + fsync_directory(path.parent) + finally: + temporary.unlink(missing_ok=True) + + +def token_owner_uid() -> int: + return os.geteuid() if SKIP_CHOWN else 0 + + +def validate_token_descriptor(descriptor: int) -> str: + observed = os.fstat(descriptor) + if not stat.S_ISREG(observed.st_mode): + raise RuntimeError("XMRig API token is not a regular file") + if observed.st_uid != token_owner_uid(): + raise RuntimeError("XMRig API token owner is unsafe") + if stat.S_IMODE(observed.st_mode) != 0o600: + raise RuntimeError("XMRig API token mode is unsafe") + if observed.st_size > 256: + raise RuntimeError("XMRig API token is oversized") + os.lseek(descriptor, 0, os.SEEK_SET) + raw = os.read(descriptor, 257) + try: + token = raw.decode("ascii").strip() + except UnicodeDecodeError as error: + raise RuntimeError("XMRig API token is not ASCII") from error + if not TOKEN_RE.fullmatch(token): + raise RuntimeError("XMRig API token format is invalid") + return token + + +def load_or_create_api_token() -> str: + API_TOKEN.parent.mkdir(mode=0o755, parents=True, exist_ok=True) + nofollow = getattr(os, "O_NOFOLLOW", 0) + flags = os.O_RDONLY | os.O_CLOEXEC | nofollow + try: + descriptor = os.open(API_TOKEN, flags) + except FileNotFoundError: + token = secrets.token_urlsafe(48) + if not TOKEN_RE.fullmatch(token): + raise RuntimeError("generated XMRig API token format is invalid") + create_flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_CLOEXEC | nofollow + descriptor = os.open(API_TOKEN, create_flags, 0o600) + try: + os.write(descriptor, (token + "\n").encode("ascii")) + os.fsync(descriptor) + finally: + os.close(descriptor) + API_TOKEN.chmod(0o600) + fsync_directory(API_TOKEN.parent) + descriptor = os.open(API_TOKEN, flags) + try: + return validate_token_descriptor(descriptor) + finally: + os.close(descriptor) + + +def exact_profile_name(algorithm: str) -> str: + if algorithm in ("rx", "rx/0"): + return "rx" + if algorithm.startswith("rx/"): + return algorithm + raise RuntimeError("explicit threads require a RandomX algorithm") + + +def render() -> tuple[dict, dict, dict]: + current = (STATE / "current").resolve(strict=True) + if current.parent != (STATE / "revisions").resolve(strict=True): + raise RuntimeError("current revision is outside the revisions directory") + policy = read_object(current / "policy.json") + if policy.get("schema") != "rigos.policy/v1": + raise RuntimeError("policy schema mismatch") + sheet_name = policy.get("active_flight_sheet") + if not isinstance(sheet_name, str) or not sheet_name or "/" in sheet_name or ".." in sheet_name: + raise RuntimeError("active flight sheet name is invalid") + sheet = read_object(current / "flight-sheets" / f"{sheet_name}.json") + if sheet.get("schema") != "rigos.flight-sheet/v1" or sheet.get("backend") != "xmrig": + raise RuntimeError("flight sheet schema or backend mismatch") + committed = read_object(current / "xmrig.json") + runtime = copy.deepcopy(committed) + cpu = runtime.get("cpu") + if not isinstance(cpu, dict): + raise RuntimeError("committed XMRig CPU policy is missing") + algorithm = sheet.get("algorithm") + if not isinstance(algorithm, str) or not algorithm: + raise RuntimeError("flight sheet algorithm is invalid") + pools = runtime.get("pools") + if not isinstance(pools, list) or not pools: + raise RuntimeError("committed XMRig pools are missing") + if any(not isinstance(pool, dict) or pool.get("algo") != algorithm for pool in pools): + raise RuntimeError("committed pool algorithm disagrees with the flight sheet") + sheet_cpu = sheet.get("cpu") + if not isinstance(sheet_cpu, dict): + raise RuntimeError("flight sheet CPU policy is missing") + threads = sheet_cpu.get("threads") + profile = None + exact_threads = None + if isinstance(threads, int) and not isinstance(threads, bool): + if not 1 <= threads <= 1024: + raise RuntimeError("explicit thread count is out of range") + profile = exact_profile_name(algorithm) + cpu["max-threads-hint"] = sheet_cpu.get("max_threads_hint", 100) + cpu[profile] = [-1] * threads + exact_threads = threads + elif threads != "auto": + raise RuntimeError("threads must be auto or an integer") + + api_token = load_or_create_api_token() + runtime["http"] = { + "enabled": True, + "host": API_HOST, + "port": API_PORT, + "access-token": api_token, + "restricted": True, + } + + public = copy.deepcopy(runtime) + for pool in public.get("pools", []): + if isinstance(pool, dict): + pool.pop("user", None) + pool.pop("pass", None) + http = public.get("http") + if isinstance(http, dict): + http.pop("access-token", None) + + public["algo"] = algorithm + if exact_threads is not None: + public["threads"] = exact_threads + else: + public.pop("threads", None) + public_randomx = public.get("randomx") + if not isinstance(public_randomx, dict): + public_randomx = {} + huge_pages = cpu.get("huge-pages") + if isinstance(huge_pages, bool): + public_randomx["huge-pages"] = huge_pages + public["randomx"] = public_randomx + public["rigos-public-view"] = { + "schema": "rigos.xmrig-public-config/v1", + "identity_redacted": True, + "source_revision": current.name, + } + + status = { + "schema": "rigos.runtime-config-status/v1", + "outcome": "ready", + "revision": current.name, + "algorithm": algorithm, + "thread_mode": "exact" if exact_threads is not None else "auto", + "exact_threads": exact_threads, + "profile": profile, + "private_sha256": hashlib.sha256( + (json.dumps(runtime, sort_keys=True) + "\n").encode("utf-8") + ).hexdigest(), + "identity_redacted_public_view": True, + "http_api": { + "enabled": True, + "host": API_HOST, + "port": API_PORT, + "restricted": True, + "token_path": str(API_TOKEN), + }, + } + return runtime, public, status + + +def main() -> int: + runtime, public, status = render() + gid = None + if not SKIP_CHOWN: + gid = grp.getgrnam("rigos").gr_gid + atomic_json(PRIVATE_CONFIG, runtime, 0o640, gid) + atomic_json(PUBLIC_CONFIG, public, 0o644) + atomic_json(STATUS, status, 0o644) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/build/usb/includes.chroot/usr/lib/rigos/rigos-ssh-hostkeys b/build/usb/includes.chroot/usr/lib/rigos/rigos-ssh-hostkeys new file mode 100644 index 00000000..83880877 --- /dev/null +++ b/build/usb/includes.chroot/usr/lib/rigos/rigos-ssh-hostkeys @@ -0,0 +1,493 @@ +#!/usr/bin/env python3 +import datetime +import grp +import hashlib +import json +import os +import shutil +import stat +import subprocess +import sys +import tempfile +from pathlib import Path + +STATE = Path("/var/lib/rigos") +SYSTEM = STATE / "system" +KEYS = SYSTEM / "ssh-hostkeys" +PRIVATE_KEY = KEYS / "ssh_host_ed25519_key" +PUBLIC_KEY = KEYS / "ssh_host_ed25519_key.pub" +MANIFEST = KEYS / "manifest.json" +RUNTIME = Path("/run/rigos") +ACTIVE_KEYS = RUNTIME / "ssh-hostkeys" +ACTIVE_PRIVATE_KEY = ACTIVE_KEYS / "ssh_host_ed25519_key" +ACTIVE_PUBLIC_KEY = ACTIVE_KEYS / "ssh_host_ed25519_key.pub" +ACTIVE_MANIFEST = ACTIVE_KEYS / "manifest.json" +STATUS = RUNTIME / "ssh-hostkeys-status.json" +STATE_STATUS = RUNTIME / "state-status.json" +BOOT_ID = Path("/proc/sys/kernel/random/boot_id") +SSH_KEYGEN = "/usr/bin/ssh-keygen" +FINDMNT = "/usr/bin/findmnt" + + +class AuthorityError(RuntimeError): + pass + + +def read_json(path: Path) -> dict: + try: + raw = path.read_bytes() + if len(raw) > 64 * 1024: + return {} + value = json.loads(raw) + return value if isinstance(value, dict) else {} + except (OSError, json.JSONDecodeError): + return {} + + +def fsync_directory(path: Path) -> None: + descriptor = os.open(path, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + + +def write_atomic_json(path: Path, value: dict, mode: int = 0o644) -> None: + path.parent.mkdir(mode=0o755, parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + dir=path.parent, + prefix=f".{path.name}-", + delete=False, + ) as stream: + json.dump(value, stream, indent=2, sort_keys=True) + stream.write("\n") + stream.flush() + os.fsync(stream.fileno()) + temporary = Path(stream.name) + os.chown(temporary, 0, 0) + temporary.chmod(mode) + os.replace(temporary, path) + fsync_directory(path.parent) + + +def current_boot_id() -> str: + try: + value = BOOT_ID.read_text(encoding="ascii").strip() + except OSError as error: + raise AuthorityError(f"boot id unavailable: {error}") from error + if not value: + raise AuthorityError("boot id is empty") + return value + + +def validate_state_ready(boot_id: str) -> None: + status = read_json(STATE_STATUS) + if ( + status.get("schema") != "rigos.state-status/v1" + or status.get("boot_id") != boot_id + or status.get("outcome") != "ready" + ): + raise AuthorityError("current persistent state readiness is unavailable") + + observed = subprocess.run( + [FINDMNT, "--json", "--target", str(STATE), "--output", "TARGET,FSTYPE,OPTIONS"], + check=False, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + if observed.returncode != 0: + raise AuthorityError("persistent state mount query failed") + try: + filesystems = json.loads(observed.stdout).get("filesystems", []) + except json.JSONDecodeError as error: + raise AuthorityError("persistent state mount query returned invalid JSON") from error + if len(filesystems) != 1: + raise AuthorityError("persistent state mount is ambiguous") + filesystem = filesystems[0] + options = set(str(filesystem.get("options") or "").split(",")) + if ( + filesystem.get("target") != str(STATE) + or filesystem.get("fstype") != "ext4" + or "rw" not in options + ): + raise AuthorityError("persistent state is not the expected read-write ext4 mount") + + +def secure_state_root() -> None: + try: + state = STATE.lstat() + except OSError as error: + raise AuthorityError(f"persistent state root unavailable: {error}") from error + if stat.S_ISLNK(state.st_mode) or not stat.S_ISDIR(state.st_mode): + raise AuthorityError("persistent state root is not a real directory") + try: + rigos_gid = grp.getgrnam("rigos").gr_gid + os.chown(STATE, 0, rigos_gid) + STATE.chmod(0o750) + except OSError as error: + raise AuthorityError(f"persistent state root could not be secured: {error}") from error + + +def ensure_secure_directory(path: Path, mode: int) -> None: + if not path.exists(): + path.mkdir(mode=mode) + os.chown(path, 0, 0) + path.chmod(mode) + observed = path.lstat() + if stat.S_ISLNK(observed.st_mode) or not stat.S_ISDIR(observed.st_mode): + raise AuthorityError(f"{path} is not a real directory") + if observed.st_uid != 0 or observed.st_gid != 0: + raise AuthorityError(f"{path} is not owned by root:root") + if stat.S_IMODE(observed.st_mode) != mode: + raise AuthorityError(f"{path} has unsafe mode") + + +def ensure_runtime_root() -> None: + if not RUNTIME.exists(): + RUNTIME.mkdir(mode=0o755, parents=True) + os.chown(RUNTIME, 0, 0) + RUNTIME.chmod(0o755) + observed = RUNTIME.lstat() + if stat.S_ISLNK(observed.st_mode) or not stat.S_ISDIR(observed.st_mode): + raise AuthorityError("runtime root is not a real directory") + if observed.st_uid != 0 or observed.st_gid != 0: + raise AuthorityError("runtime root is not owned by root:root") + if stat.S_IMODE(observed.st_mode) != 0o755: + raise AuthorityError("runtime root has unsafe mode") + + +def validate_regular_file(path: Path, mode: int) -> None: + try: + observed = path.lstat() + except OSError as error: + raise AuthorityError(f"required host identity file is unavailable: {path.name}") from error + if stat.S_ISLNK(observed.st_mode) or not stat.S_ISREG(observed.st_mode): + raise AuthorityError(f"host identity file is not regular: {path.name}") + if observed.st_uid != 0 or observed.st_gid != 0: + raise AuthorityError(f"host identity file owner is unsafe: {path.name}") + if stat.S_IMODE(observed.st_mode) != mode: + raise AuthorityError(f"host identity file mode is unsafe: {path.name}") + + +def command_output(arguments: list[str]) -> str: + result = subprocess.run( + arguments, + check=False, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=30, + ) + if result.returncode != 0: + raise AuthorityError(f"host identity command failed: {Path(arguments[0]).name}") + return result.stdout.strip() + + +def key_fingerprint(public_key: Path) -> str: + output = command_output([SSH_KEYGEN, "-E", "sha256", "-lf", str(public_key)]) + fields = output.split() + if len(fields) < 2 or not fields[1].startswith("SHA256:"): + raise AuthorityError("host identity fingerprint is invalid") + return fields[1] + + +def validate_key_pair(private_key: Path, public_key: Path) -> str: + public_fields = public_key.read_text(encoding="ascii").strip().split() + derived_fields = command_output([SSH_KEYGEN, "-y", "-f", str(private_key)]).split() + if len(public_fields) < 2 or derived_fields[:2] != public_fields[:2]: + raise AuthorityError("SSH public and private keys do not match") + return key_fingerprint(public_key) + + +def validate_persistent_keyset() -> str: + ensure_secure_directory(KEYS, 0o700) + validate_regular_file(PRIVATE_KEY, 0o600) + validate_regular_file(PUBLIC_KEY, 0o644) + validate_regular_file(MANIFEST, 0o644) + + fingerprint = validate_key_pair(PRIVATE_KEY, PUBLIC_KEY) + public_sha256 = hashlib.sha256(PUBLIC_KEY.read_bytes()).hexdigest() + manifest = read_json(MANIFEST) + if ( + manifest.get("schema") != "rigos.ssh-hostkeys/v1" + or manifest.get("algorithm") != "ssh-ed25519" + or manifest.get("fingerprint") != fingerprint + or manifest.get("public_key_sha256") != public_sha256 + or not isinstance(manifest.get("created_boot_id"), str) + or not manifest.get("created_boot_id") + ): + raise AuthorityError("persistent SSH host identity manifest is invalid") + return fingerprint + + +def validate_active_keyset( + boot_id: str, mode: str, expected_source_fingerprint: str | None +) -> str: + ensure_secure_directory(ACTIVE_KEYS, 0o700) + validate_regular_file(ACTIVE_PRIVATE_KEY, 0o600) + validate_regular_file(ACTIVE_PUBLIC_KEY, 0o644) + validate_regular_file(ACTIVE_MANIFEST, 0o644) + + fingerprint = validate_key_pair(ACTIVE_PRIVATE_KEY, ACTIVE_PUBLIC_KEY) + public_sha256 = hashlib.sha256(ACTIVE_PUBLIC_KEY.read_bytes()).hexdigest() + manifest = read_json(ACTIVE_MANIFEST) + if ( + manifest.get("schema") != "rigos.ssh-active-hostkeys/v1" + or manifest.get("algorithm") != "ssh-ed25519" + or manifest.get("boot_id") != boot_id + or manifest.get("mode") != mode + or manifest.get("fingerprint") != fingerprint + or manifest.get("public_key_sha256") != public_sha256 + or manifest.get("source_fingerprint") != expected_source_fingerprint + ): + raise AuthorityError("active SSH host identity manifest is invalid") + return fingerprint + + +def remove_stale_staging(parent: Path, prefix: str) -> None: + for candidate in parent.glob(f"{prefix}*"): + observed = candidate.lstat() + if stat.S_ISLNK(observed.st_mode) or not stat.S_ISDIR(observed.st_mode): + raise AuthorityError("unsafe SSH host identity staging path exists") + if observed.st_uid != 0 or observed.st_gid != 0: + raise AuthorityError("untrusted SSH host identity staging directory exists") + shutil.rmtree(candidate) + fsync_directory(parent) + + +def generate_persistent_keyset(boot_id: str) -> str: + if KEYS.exists() or KEYS.is_symlink(): + raise AuthorityError("persistent SSH host identity exists without a valid manifest") + remove_stale_staging(SYSTEM, ".ssh-hostkeys.tmp-") + + temporary = Path(tempfile.mkdtemp(prefix=".ssh-hostkeys.tmp-", dir=SYSTEM)) + try: + os.chown(temporary, 0, 0) + temporary.chmod(0o700) + private_key = temporary / PRIVATE_KEY.name + public_key = temporary / PUBLIC_KEY.name + manifest_path = temporary / MANIFEST.name + + result = subprocess.run( + [ + SSH_KEYGEN, + "-q", + "-t", + "ed25519", + "-N", + "", + "-C", + "rigos-host", + "-f", + str(private_key), + ], + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + text=True, + timeout=30, + ) + if result.returncode != 0: + raise AuthorityError("persistent SSH host identity generation failed") + + os.chown(private_key, 0, 0) + os.chown(public_key, 0, 0) + private_key.chmod(0o600) + public_key.chmod(0o644) + fingerprint = key_fingerprint(public_key) + manifest = { + "schema": "rigos.ssh-hostkeys/v1", + "algorithm": "ssh-ed25519", + "created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(), + "created_boot_id": boot_id, + "fingerprint": fingerprint, + "public_key_sha256": hashlib.sha256(public_key.read_bytes()).hexdigest(), + } + write_atomic_json(manifest_path, manifest, 0o644) + + for path in (private_key, public_key, manifest_path): + descriptor = os.open(path, os.O_RDONLY) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + fsync_directory(temporary) + os.rename(temporary, KEYS) + fsync_directory(SYSTEM) + finally: + if temporary.exists(): + shutil.rmtree(temporary) + return validate_persistent_keyset() + + +def install_active_keyset( + boot_id: str, + mode: str, + source_private: Path | None = None, + source_public: Path | None = None, + source_fingerprint: str | None = None, +) -> str: + ensure_runtime_root() + if ACTIVE_KEYS.exists() or ACTIVE_KEYS.is_symlink(): + raise AuthorityError("active SSH host identity exists without a valid manifest") + remove_stale_staging(RUNTIME, ".ssh-hostkeys.active-") + + temporary = Path(tempfile.mkdtemp(prefix=".ssh-hostkeys.active-", dir=RUNTIME)) + try: + os.chown(temporary, 0, 0) + temporary.chmod(0o700) + private_key = temporary / ACTIVE_PRIVATE_KEY.name + public_key = temporary / ACTIVE_PUBLIC_KEY.name + manifest_path = temporary / ACTIVE_MANIFEST.name + + if mode == "persistent": + if source_private is None or source_public is None or source_fingerprint is None: + raise AuthorityError("persistent active identity source is incomplete") + shutil.copyfile(source_private, private_key) + shutil.copyfile(source_public, public_key) + elif mode == "ephemeral": + result = subprocess.run( + [ + SSH_KEYGEN, + "-q", + "-t", + "ed25519", + "-N", + "", + "-C", + "rigos-diagnostic-host", + "-f", + str(private_key), + ], + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + text=True, + timeout=30, + ) + if result.returncode != 0: + raise AuthorityError("ephemeral SSH host identity generation failed") + else: + raise AuthorityError("unknown active SSH host identity mode") + + os.chown(private_key, 0, 0) + os.chown(public_key, 0, 0) + private_key.chmod(0o600) + public_key.chmod(0o644) + fingerprint = validate_key_pair(private_key, public_key) + if mode == "persistent" and fingerprint != source_fingerprint: + raise AuthorityError("active SSH host identity differs from persistent source") + + manifest = { + "schema": "rigos.ssh-active-hostkeys/v1", + "algorithm": "ssh-ed25519", + "boot_id": boot_id, + "mode": mode, + "fingerprint": fingerprint, + "public_key_sha256": hashlib.sha256(public_key.read_bytes()).hexdigest(), + "source_fingerprint": source_fingerprint, + } + write_atomic_json(manifest_path, manifest, 0o644) + + for path in (private_key, public_key, manifest_path): + descriptor = os.open(path, os.O_RDONLY) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + fsync_directory(temporary) + os.rename(temporary, ACTIVE_KEYS) + fsync_directory(RUNTIME) + finally: + if temporary.exists(): + shutil.rmtree(temporary) + + return validate_active_keyset(boot_id, mode, source_fingerprint) + + +def write_status(boot_id: str, outcome: str, **extra: object) -> None: + value = { + "schema": "rigos.ssh-hostkeys-status/v1", + "boot_id": boot_id, + "outcome": outcome, + "active_path": str(ACTIVE_PRIVATE_KEY), + "persistent_path": str(PRIVATE_KEY), + } + value.update(extra) + try: + write_atomic_json(STATUS, value) + except OSError: + pass + + +def main() -> int: + boot_id = "unavailable" + try: + boot_id = current_boot_id() + state_reason = None + try: + validate_state_ready(boot_id) + persistent_state_ready = True + except AuthorityError as error: + persistent_state_ready = False + state_reason = str(error) + + ensure_runtime_root() + active_reused = ACTIVE_KEYS.exists() or ACTIVE_KEYS.is_symlink() + + if persistent_state_ready: + secure_state_root() + ensure_secure_directory(SYSTEM, 0o700) + persistent_reused = KEYS.exists() or KEYS.is_symlink() + persistent_fingerprint = ( + validate_persistent_keyset() + if persistent_reused + else generate_persistent_keyset(boot_id) + ) + fingerprint = ( + validate_active_keyset(boot_id, "persistent", persistent_fingerprint) + if active_reused + else install_active_keyset( + boot_id, + "persistent", + PRIVATE_KEY, + PUBLIC_KEY, + persistent_fingerprint, + ) + ) + mode = "persistent" + else: + persistent_reused = False + fingerprint = ( + validate_active_keyset(boot_id, "ephemeral", None) + if active_reused + else install_active_keyset(boot_id, "ephemeral") + ) + mode = "ephemeral" + + write_status( + boot_id, + "ready", + algorithm="ssh-ed25519", + fingerprint=fingerprint, + persistence_mode=mode, + active_reused=active_reused, + persistent_reused=persistent_reused, + state_reason=state_reason, + ) + print( + "RIGOS_SSH_HOSTKEYS=READY " + f"mode={mode} fingerprint={fingerprint} active_reused={str(active_reused).lower()}" + ) + return 0 + except (AuthorityError, OSError, subprocess.SubprocessError) as error: + write_status(boot_id, "error", reason=str(error)) + print(f"rigos-ssh-hostkeys: {error}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/build/usb/includes.chroot/usr/lib/tmpfiles.d/rigos.conf b/build/usb/includes.chroot/usr/lib/tmpfiles.d/rigos.conf new file mode 100644 index 00000000..e746490d --- /dev/null +++ b/build/usb/includes.chroot/usr/lib/tmpfiles.d/rigos.conf @@ -0,0 +1 @@ +d /run/rigos 0755 root root - diff --git a/build/usb/includes.chroot/usr/local/bin/rig b/build/usb/includes.chroot/usr/local/bin/rig new file mode 100644 index 00000000..fb7e138e --- /dev/null +++ b/build/usb/includes.chroot/usr/local/bin/rig @@ -0,0 +1,510 @@ +#!/usr/bin/env python3 +"""RIGOS short operator command. + +This command is intentionally a thin operator surface. It delegates runtime +authority to rigosctl, systemd and journalctl, and constructs operator JSON +from explicit allowlists only. +""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +import time +from pathlib import Path +from typing import Any + + +RIGOSCTL = Path("/usr/local/bin/rigosctl") +MINER_UNIT = "rigos-miner.service" +FIRSTBOOT_UNIT = "rigos-firstboot.service" +CMDLINE = Path("/proc/cmdline") +RELEASE = Path("/etc/rigos-release") +FIRSTBOOT_STATUS = Path("/var/lib/rigos/firstboot-ui-status.json") + +OPERATOR_STATUS_SCHEMA = "rigos.operator-status/v1" +OPERATOR_HEALTH_SCHEMA = "rigos.operator-health/v1" +OPERATOR_CONTRACT = ( + "rigosctl health inspect --json", + "rigosctl state inspect --json", +) + +HEALTH_EXIT = { + "healthy": 0, + "ready": 0, + "starting": 1, + "warming_up": 1, + "waiting_external": 1, + "degraded": 1, + "recovering": 1, + "failed": 2, + "blocked": 2, + "not_configured": 3, + "unknown": 4, + "unavailable": 4, +} + + +class RigError(RuntimeError): + def __init__(self, message: str, code: int = 4) -> None: + super().__init__(message) + self.code = code + + +def run(args: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.run(args, check=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + + +def run_passthrough(args: list[str]) -> int: + return subprocess.run(args, check=False).returncode + + +def is_root() -> bool: + return hasattr(os, "geteuid") and os.geteuid() == 0 + + +def require_root(action: str) -> None: + if not is_root(): + raise RigError(f"{action} requires root privileges; run: sudo rig {action}", 4) + + +def load_release() -> dict[str, str]: + fields: dict[str, str] = {} + try: + for line in RELEASE.read_text(encoding="utf-8").splitlines(): + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + fields[key] = value.strip().strip('"') + except FileNotFoundError: + pass + return fields + + +def capture_json(args: list[str]) -> tuple[dict[str, Any] | None, str | None]: + completed = run(args) + if completed.returncode != 0: + return None, completed.stderr.strip() or completed.stdout.strip() or f"{args[0]} exited {completed.returncode}" + try: + decoded = json.loads(completed.stdout) + except json.JSONDecodeError as error: + return None, f"invalid JSON from {args[0]}: {error}" + return decoded if isinstance(decoded, dict) else {}, None + + +def rigosctl_json(*args: str) -> tuple[dict[str, Any] | None, str | None]: + return capture_json([str(RIGOSCTL), *args, "--json"]) + + +def envelope_data(envelope: dict[str, Any] | None) -> dict[str, Any]: + if not isinstance(envelope, dict): + return {} + value = envelope.get("data") + return value if isinstance(value, dict) else {} + + +def safe_get(value: Any, *path: str, default: Any = None) -> Any: + current = value + for key in path: + if not isinstance(current, dict): + return default + current = current.get(key) + return default if current is None else current + + +def load_operator_snapshot() -> dict[str, Any]: + release = load_release() + health, health_error = rigosctl_json("health", "inspect") + state, state_error = rigosctl_json("state", "inspect") + network, network_error = rigosctl_json("network", "inspect") + health_data = envelope_data(health) + state_data = envelope_data(state) + network_data = envelope_data(network) + miner = safe_get(health_data, "miner", default={}) + metrics = safe_get(miner, "api", "metrics", default={}) + runtime = safe_get(health_data, "runtime", default={}) + + current_revision = ( + safe_get(state_data, "current_revision") + or safe_get(miner, "config", "current_revision") + or safe_get(health_data, "activation", "revision") + ) + miner_health = safe_get(miner, "state", default="unavailable") + diagnostics = [] + for component, error in [ + ("health", health_error), + ("state", state_error), + ("network", network_error), + ]: + if error: + diagnostics.append({"component": component, "reason": error}) + + return { + "schema": OPERATOR_STATUS_SCHEMA, + "observed_at": safe_get(health, "observed_at") or safe_get(state, "observed_at") or safe_get(network, "observed_at"), + "version": release.get("VERSION_ID") or safe_get(health, "meta", "image_version") or "unknown", + "node_name": os.uname().nodename if hasattr(os, "uname") else "unknown", + "configuration_state": safe_get(state_data, "activation_status", "outcome", default="unavailable"), + "configuration_revision": current_revision, + "state_outcome": safe_get(state_data, "state_status", "outcome") + or safe_get(health_data, "state", "outcome", default="unavailable"), + "network_state": safe_get(network_data, "state") or safe_get(health_data, "network", "state", default="unavailable"), + "miner_state": safe_get(miner, "unit", "active_state", default="unavailable"), + "algorithm": safe_get(runtime, "algorithm") or safe_get(metrics, "algorithm", default="unknown"), + "redacted_pool_endpoint": safe_get(metrics, "pool", default="unknown"), + "hashrate_hs": safe_get(metrics, "hashrate_10s"), + "accepted_shares": safe_get(metrics, "accepted_shares"), + "rejected_shares": safe_get(metrics, "rejected_shares"), + "huge_pages": { + "used": safe_get(metrics, "hugepages_used"), + "total": safe_get(metrics, "hugepages_total"), + }, + "health": miner_health, + "last_recovery_action": safe_get(miner, "remediation", "action", default="unknown"), + "diagnostics": diagnostics, + } + + +def load_operator_health() -> dict[str, Any]: + snapshot = load_operator_snapshot() + return { + "schema": OPERATOR_HEALTH_SCHEMA, + "observed_at": snapshot.get("observed_at"), + "health": snapshot.get("health", "unavailable"), + "configuration_state": snapshot.get("configuration_state", "unavailable"), + "network_state": snapshot.get("network_state", "unavailable"), + "miner_state": snapshot.get("miner_state", "unavailable"), + "hashrate_hs": snapshot.get("hashrate_hs"), + "accepted_shares": snapshot.get("accepted_shares"), + "rejected_shares": snapshot.get("rejected_shares"), + "last_recovery_action": snapshot.get("last_recovery_action", "unknown"), + "diagnostics": snapshot.get("diagnostics", []), + } + + +def print_status(json_output: bool) -> int: + snapshot = load_operator_snapshot() + if json_output: + print(json.dumps(snapshot, sort_keys=True)) + return 0 + print(f"version: {snapshot['version']}") + print(f"node: {snapshot['node_name']}") + print(f"state: {snapshot['state_outcome']}") + print(f"configuration: {snapshot['configuration_state']}") + print(f"revision: {snapshot['configuration_revision'] or 'none'}") + print(f"network: {snapshot['network_state']}") + print(f"miner: {snapshot['miner_state']}") + print(f"algorithm: {snapshot['algorithm']}") + print(f"pool: {snapshot['redacted_pool_endpoint']}") + print(f"hashrate: {snapshot['hashrate_hs'] if snapshot['hashrate_hs'] is not None else 'n/a'} H/s") + print(f"shares: accepted={snapshot['accepted_shares'] if snapshot['accepted_shares'] is not None else 'n/a'} rejected={snapshot['rejected_shares'] if snapshot['rejected_shares'] is not None else 'n/a'}") + pages = snapshot["huge_pages"] + print(f"huge_pages: {pages.get('used') if pages.get('used') is not None else 'n/a'}/{pages.get('total') if pages.get('total') is not None else 'n/a'}") + print(f"health: {snapshot['health']}") + print(f"last_recovery_action: {snapshot['last_recovery_action']}") + for diagnostic in snapshot["diagnostics"]: + print(f"diagnostic: {diagnostic['component']}: {diagnostic['reason']}", file=sys.stderr) + return 0 + + +def health_exit(state: str | None) -> int: + return HEALTH_EXIT.get(state or "unavailable", 4) + + +def print_health(json_output: bool) -> int: + health = load_operator_health() + if json_output: + print(json.dumps(health, sort_keys=True)) + else: + print(f"health: {health['health']}") + print(f"configuration: {health['configuration_state']}") + print(f"network: {health['network_state']}") + print(f"miner: {health['miner_state']}") + print(f"hashrate: {health['hashrate_hs'] if health['hashrate_hs'] is not None else 'n/a'} H/s") + print(f"last_recovery_action: {health['last_recovery_action']}") + for diagnostic in health["diagnostics"]: + print(f"diagnostic: {diagnostic['component']}: {diagnostic['reason']}", file=sys.stderr) + return health_exit(health.get("health")) + + +def print_config(json_output: bool) -> int: + envelope, error = rigosctl_json("state", "inspect") + state = envelope_data(envelope) + summary = { + "schema": "rigos.operator-config/v1", + "observed_at": safe_get(envelope, "observed_at"), + "current_revision": safe_get(state, "current_revision"), + "state_outcome": safe_get(state, "state_status", "outcome", default="unavailable"), + "activation": safe_get(state, "activation_status", "outcome", default="unavailable"), + "runtime": safe_get(state, "runtime_config_status", "outcome", default="unavailable"), + "diagnostics": [{"component": "state", "reason": error}] if error else [], + } + if json_output: + print(json.dumps(summary, sort_keys=True)) + else: + print(f"state: {summary['state_outcome']}") + print(f"current_revision: {summary['current_revision'] or 'none'}") + print(f"activation: {summary['activation']}") + print(f"runtime: {summary['runtime']}") + for diagnostic in summary["diagnostics"]: + print(f"diagnostic: {diagnostic['component']}: {diagnostic['reason']}", file=sys.stderr) + return 0 if not error else 4 + + +def systemctl_show(unit: str) -> dict[str, str]: + completed = run(["systemctl", "show", unit, "-p", "ActiveState", "-p", "SubState", "-p", "Result", "-p", "ExecMainStatus"]) + result: dict[str, str] = {} + for line in completed.stdout.splitlines(): + if "=" in line: + key, value = line.split("=", 1) + result[key] = value + if completed.returncode != 0 and completed.stderr: + result["Error"] = completed.stderr.strip() + return result + + +def miner_active() -> bool: + completed = run(["systemctl", "is-active", "--quiet", MINER_UNIT]) + return completed.returncode == 0 + + +def wait_miner_active(timeout_seconds: float = 10.0) -> bool: + deadline = time.monotonic() + timeout_seconds + while time.monotonic() < deadline: + if miner_active(): + return True + time.sleep(0.25) + return miner_active() + + +def print_gate_reason() -> None: + show = systemctl_show(MINER_UNIT) + print( + "miner postcondition failed: " + f"ActiveState={show.get('ActiveState', 'unknown')} " + f"SubState={show.get('SubState', 'unknown')} " + f"Result={show.get('Result', 'unknown')} " + f"ExecMainStatus={show.get('ExecMainStatus', 'unknown')}", + file=sys.stderr, + ) + + +def miner_start(action: str) -> int: + require_root(action) + completed = run(["systemctl", action, MINER_UNIT]) + if completed.returncode != 0: + if completed.stderr: + print(completed.stderr.strip(), file=sys.stderr) + print_gate_reason() + return completed.returncode + if wait_miner_active(): + print("miner: active") + return 0 + print_gate_reason() + return 2 + + +def miner_stop() -> int: + require_root("stop") + if not miner_active(): + print("miner: already stopped") + return 0 + completed = run(["systemctl", "stop", MINER_UNIT]) + if completed.returncode != 0: + if completed.stderr: + print(completed.stderr.strip(), file=sys.stderr) + return completed.returncode + if miner_active(): + print("miner stop postcondition failed: still active", file=sys.stderr) + return 2 + print("miner: stopped") + return 0 + + +def logs(args: argparse.Namespace) -> int: + units: list[str] + if args.miner: + units = [MINER_UNIT] + elif args.health: + units = ["rigos-miner-health.service"] + elif args.network: + units = ["NetworkManager.service", "systemd-networkd.service"] + elif args.boot: + units = [ + "rigos-state.service", + "rigos-state-ready.service", + "rigos-recovery-access.service", + "rigos-firstboot.service", + "rigos-profile-apply.service", + "rigos-runtime-render.service", + "rigos-hugepages.service", + ] + else: + units = [ + "rigos-state.service", + "rigos-state-ready.service", + "rigos-profile-apply.service", + "rigos-runtime-render.service", + "rigos-hugepages.service", + MINER_UNIT, + "rigos-miner-health.service", + ] + command = ["journalctl", "-b", "--no-pager", "-n", str(args.lines)] + for unit in units: + command.extend(["-u", unit]) + if args.since: + command.extend(["--since", args.since]) + if args.follow: + command.append("--follow") + return run_passthrough(command) + + +def version() -> int: + if RELEASE.exists(): + print(RELEASE.read_text(encoding="utf-8").strip()) + return 0 + completed = run([str(RIGOSCTL), "about"]) + print(completed.stdout, end="") + if completed.stderr: + print(completed.stderr, file=sys.stderr, end="") + return completed.returncode + + +def local_tty1() -> bool: + if os.environ.get("SSH_CONNECTION") or os.environ.get("SSH_TTY"): + return False + try: + return os.ttyname(sys.stdin.fileno()) == "/dev/tty1" + except OSError: + return False + + +def cmdline_has_utility() -> bool: + try: + return "rigos.utility=1" in CMDLINE.read_text(encoding="utf-8") + except FileNotFoundError: + return False + + +def firstboot(args: argparse.Namespace) -> int: + if args.firstboot_command != "run": + if FIRSTBOOT_STATUS.exists(): + print(FIRSTBOOT_STATUS.read_text(encoding="utf-8").strip()) + else: + print("firstboot: no status available") + print("run with: sudo rig firstboot run") + return 0 + require_root("firstboot run") + if cmdline_has_utility(): + raise RigError("firstboot refused: utility mode is active", 4) + if not local_tty1(): + raise RigError("firstboot refused: run from local tty1, not SSH or a non-local terminal", 4) + state, error = rigosctl_json("state", "inspect") + state_data = envelope_data(state) + if error: + raise RigError(f"firstboot refused: state inspect failed: {error}", 4) + if safe_get(state_data, "state_status", "outcome") != "ready": + raise RigError("firstboot refused: persistent state is not ready", 4) + if safe_get(state_data, "current_revision"): + raise RigError("firstboot refused: committed configuration already exists", 4) + if run(["systemctl", "is-active", "--quiet", FIRSTBOOT_UNIT]).returncode == 0: + raise RigError("firstboot refused: another firstboot instance is active", 4) + completed = run(["systemctl", "start", FIRSTBOOT_UNIT]) + if completed.returncode != 0 and completed.stderr: + print(completed.stderr.strip(), file=sys.stderr) + return completed.returncode + + +def recover(args: argparse.Namespace) -> int: + if args.recover_command == "status": + return print_config(args.json) + if args.recover_command == "rollback": + require_root("recover rollback") + print("rollback is not available in this alpha image", file=sys.stderr) + return 4 + return print_config(args.json) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="rig", description="RIGOS short operator command") + sub = parser.add_subparsers(dest="command", required=True) + + for name in ["status", "s"]: + p = sub.add_parser(name) + p.add_argument("--json", action="store_true") + for name in ["health", "h"]: + p = sub.add_parser(name) + p.add_argument("--json", action="store_true") + p = sub.add_parser("config") + p.add_argument("config_command", nargs="?", choices=["status", "validate", "show"], default="status") + p.add_argument("--json", action="store_true") + + for name in ["start", "up"]: + sub.add_parser(name) + for name in ["stop", "down"]: + sub.add_parser(name) + for name in ["restart", "r"]: + sub.add_parser(name) + for name in ["version", "help"]: + sub.add_parser(name) + + for name in ["logs", "log"]: + p = sub.add_parser(name) + p.add_argument("--miner", action="store_true") + p.add_argument("--boot", action="store_true") + p.add_argument("--network", action="store_true") + p.add_argument("--health", action="store_true") + p.add_argument("--since") + p.add_argument("--follow", action="store_true") + p.add_argument("-n", "--lines", type=int, default=120) + + p = sub.add_parser("firstboot") + p.add_argument("firstboot_command", nargs="?", choices=["status", "run"], default="status") + + p = sub.add_parser("recover") + p.add_argument("recover_command", nargs="?", choices=["status", "rollback"], default="status") + p.add_argument("--json", action="store_true") + return parser + + +def main() -> int: + parser = build_parser() + args = parser.parse_args() + aliases = {"s": "status", "h": "health", "up": "start", "down": "stop", "r": "restart", "log": "logs"} + command = aliases.get(args.command, args.command) + try: + if command == "status": + return print_status(args.json) + if command == "health": + return print_health(args.json) + if command == "config": + return print_config(args.json) + if command == "logs": + return logs(args) + if command == "start": + return miner_start("start") + if command == "stop": + return miner_stop() + if command == "restart": + return miner_start("restart") + if command == "firstboot": + return firstboot(args) + if command == "recover": + return recover(args) + if command == "version": + return version() + if command == "help": + parser.print_help() + return 0 + except RigError as error: + print(f"rig: {error}", file=sys.stderr) + return error.code + print("rig: unknown command", file=sys.stderr) + return 4 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/build/usb/includes.chroot/usr/local/bin/rigosctl b/build/usb/includes.chroot/usr/local/bin/rigosctl new file mode 100644 index 00000000..593f87d5 --- /dev/null +++ b/build/usb/includes.chroot/usr/local/bin/rigosctl @@ -0,0 +1,14 @@ +#!/bin/sh +set -eu + +for argument in "$@"; do + case "$argument" in + --xmrig-config|--xmrig-config=*) + exec /usr/lib/rigos/rigosctl "$@" + ;; + esac +done + +exec /usr/lib/rigos/rigosctl \ + --xmrig-config /run/rigos/xmrig-public.json \ + "$@" diff --git a/build/usb/includes.chroot/usr/local/bin/rigosd b/build/usb/includes.chroot/usr/local/bin/rigosd new file mode 100644 index 00000000..336a08dc --- /dev/null +++ b/build/usb/includes.chroot/usr/local/bin/rigosd @@ -0,0 +1,14 @@ +#!/bin/sh +set -eu + +for argument in "$@"; do + case "$argument" in + --xmrig-config|--xmrig-config=*) + exec /usr/lib/rigos/rigosd "$@" + ;; + esac +done + +exec /usr/lib/rigos/rigosd \ + --xmrig-config /run/rigos/xmrig-public.json \ + "$@" diff --git a/build/usb/includes.chroot/usr/local/sbin/rigos-firstboot b/build/usb/includes.chroot/usr/local/sbin/rigos-firstboot index bc7027fd..3347cb98 100644 --- a/build/usb/includes.chroot/usr/local/sbin/rigos-firstboot +++ b/build/usb/includes.chroot/usr/local/sbin/rigos-firstboot @@ -2,239 +2,402 @@ import json import os import re +import signal import subprocess +import sys import tempfile from pathlib import Path +os.environ.setdefault('TERM', 'linux') +STATE = Path('/var/lib/rigos') +ENGINE = '/usr/lib/rigos/rigos-config' +IDENTITY_SEED = '/usr/lib/rigos/rigos-identity-seed' +STATE_STATUS = Path('/run/rigos/state-status.json') +UI_STATUS = Path(os.environ.get('RIGOS_FIRSTBOOT_UI_STATUS', '/var/lib/rigos/firstboot-ui-status.json')) +BOOT_ID_PATH = Path(os.environ.get('RIGOS_FIRSTBOOT_BOOT_ID', '/proc/sys/kernel/random/boot_id')) +WHIPTAIL = os.environ.get('RIGOS_WHIPTAIL', '/usr/bin/whiptail') +SYSTEMD_CAT = os.environ.get('RIGOS_SYSTEMD_CAT', '/usr/bin/systemd-cat') +HOST_RE = re.compile('^(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)*[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$|^[0-9a-fA-F:]+$') +SLUG_RE = re.compile('^[A-Za-z0-9][A-Za-z0-9-]{0,63}$') +CURRENT_STAGE = 'startup' +CURRENT_DIALOG = None -STATE = Path("/var/lib/rigos") -ENGINE = "/usr/lib/rigos/rigos-config" -HOST_RE = re.compile(r"^(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)*[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$|^[0-9a-fA-F:]+$") -SLUG_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9-]{0,63}$") +class FirstbootCancelled(Exception): + def __init__(self, reason: str, returncode: int | None=None): + super().__init__(reason) + self.reason = reason + self.returncode = returncode -def dialog(*args: str) -> str: - result = subprocess.run( - ["whiptail", "--title", "RIGOS FIRST BOOT", *args], - check=False, text=True, stderr=subprocess.PIPE, - ) - if result.returncode != 0: - raise SystemExit("Configuration cancelled; reboot to try again.") - return result.stderr.strip() +class FirstbootFailure(Exception): + + def __init__(self, reason: str, returncode: int | None=None, signal_name: str | None=None): + super().__init__(reason) + self.reason = reason + self.returncode = returncode + self.signal_name = signal_name + +class FirstbootInterrupted(Exception): + + def __init__(self, signal_number: int): + super().__init__(signal_number) + self.signal_number = signal_number +class RebootRequested(Exception): + pass -def prompt(label: str, default: str = "", password: bool = False) -> str: - kind = "--passwordbox" if password else "--inputbox" - return dialog(kind, label, "10", "72", default) +def set_stage(stage: str, dialog_kind: str | None=None) -> None: + global CURRENT_STAGE, CURRENT_DIALOG + CURRENT_STAGE = stage + CURRENT_DIALOG = dialog_kind +def boot_id() -> str: + try: + return BOOT_ID_PATH.read_text(encoding='ascii').strip() + except OSError: + return 'unavailable' -def confirm(message: str) -> bool: - result = subprocess.run(["whiptail", "--title", "RIGOS FIRST BOOT", "--yesno", message, "14", "72"], check=False) - return result.returncode == 0 +def write_atomic_json(path: Path, value: dict) -> None: + with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', dir=path.parent, prefix=f'.{path.name}-', delete=False) as stream: + json.dump(value, stream, sort_keys=True) + stream.write('\n') + stream.flush() + os.fsync(stream.fileno()) + temporary = Path(stream.name) + temporary.chmod(384) + os.replace(temporary, path) + directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(directory) + finally: + os.close(directory) +def journal(value: dict, priority: str) -> None: + payload = json.dumps(value, sort_keys=True) + if not os.environ.get('INVOCATION_ID'): + print(payload, file=sys.stderr, flush=True) + return + try: + result = subprocess.run([SYSTEMD_CAT, '--identifier=rigos-firstboot', f'--priority={priority}'], input=payload + '\n', text=True, check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + if result.returncode == 0: + return + except OSError: + pass + print(payload, file=sys.stderr, flush=True) + +def record_event(outcome: str, reason: str | None=None, returncode: int | None=None, signal_name: str | None=None) -> None: + value = {'schema': 'rigos.firstboot-ui-status/v1', 'boot_id': boot_id(), 'outcome': outcome, 'stage': CURRENT_STAGE, 'dialog': CURRENT_DIALOG, 'reason': reason, 'return_code': returncode, 'signal': signal_name} + try: + write_atomic_json(UI_STATUS, value) + except OSError: + fallback = dict(value) + fallback['status_write'] = 'failed' + journal(fallback, 'err') + return + journal(value, 'info' if outcome in {'running', 'ready', 'declined'} else 'err') + +def signal_name(returncode: int) -> str: + try: + return signal.Signals(-returncode).name + except ValueError: + return f'SIGNAL_{-returncode}' + +def classify_dialog_result(result: subprocess.CompletedProcess, allow_no: bool) -> bool: + if result.returncode == 0: + return True + if result.returncode < 0: + name = signal_name(result.returncode) + raise FirstbootFailure('dialog_signal', result.returncode, name) + if result.returncode == 1: + if allow_no: + record_event('declined', 'user_selected_no', result.returncode) + return False + raise FirstbootCancelled('dialog_cancelled', result.returncode) + if result.returncode == 255: + detail = (result.stderr or '').strip() + if not detail: + raise FirstbootCancelled('dialog_escaped', result.returncode) + raise FirstbootFailure('whiptail_runtime_error', result.returncode) + raise FirstbootFailure('whiptail_unexpected_exit', result.returncode) + +def dialog(*args: str, stage: str | None=None) -> str: + kind = next((item[2:] for item in args if item.startswith('--')), 'unknown') + set_stage(stage or CURRENT_STAGE, kind) + record_event('running') + try: + result = subprocess.run([WHIPTAIL, '--title', 'RIGOS FIRST BOOT', *args], check=False, text=True, stderr=subprocess.PIPE) + except OSError: + raise FirstbootFailure('whiptail_exec_error') from None + classify_dialog_result(result, allow_no=False) + record_event('running', 'dialog_completed', result.returncode) + return result.stderr.strip() -def engine(*args: str, stdin: object | None = None) -> tuple[bool, dict]: - raw = None if stdin is None else (json.dumps(stdin) + "\n") - result = subprocess.run([ENGINE, *args], input=raw, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False) +def prompt(stage: str, label: str, default: str='', password: bool=False) -> str: + kind = '--passwordbox' if password else '--inputbox' + return dialog(kind, label, '10', '72', default, stage=stage) + +def confirm(message: str, stage: str | None=None) -> bool: + set_stage(stage or CURRENT_STAGE, 'yesno') + record_event('running') + try: + result = subprocess.run([WHIPTAIL, '--title', 'RIGOS FIRST BOOT', '--yesno', message, '14', '72'], check=False, text=True, stderr=subprocess.PIPE) + except OSError: + raise FirstbootFailure('whiptail_exec_error') from None + accepted = classify_dialog_result(result, allow_no=True) + if accepted: + record_event('running', 'dialog_completed', result.returncode) + return accepted + +def engine(*args: str, stdin: object | None=None) -> tuple[bool, dict]: + raw = None if stdin is None else json.dumps(stdin) + '\n' + try: + result = subprocess.run([ENGINE, *args], input=raw, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False) + except OSError: + return (False, {'code': 'RIGOS_CONFIG_INVALID_VALUE', 'message': 'configuration engine could not be executed'}) source = result.stdout if result.returncode == 0 else result.stderr try: value = json.loads(source.strip().splitlines()[-1]) except (json.JSONDecodeError, IndexError): - value = {"code": "RIGOS_CONFIG_INVALID_VALUE", "message": "configuration engine failed without a diagnostic"} - return result.returncode == 0, value - + value = {'code': 'RIGOS_CONFIG_INVALID_VALUE', 'message': 'configuration engine failed without a diagnostic'} + return (result.returncode == 0, value) def diagnostic_text(error: dict) -> str: - parts = [error.get("code", "RIGOS_CONFIG_INVALID_VALUE")] - location = error.get("file") or "rig.conf" - if error.get("line"): + parts = [error.get('code', 'RIGOS_CONFIG_INVALID_VALUE')] + location = error.get('file') or 'rig.conf' + if error.get('line'): location += f" line {error['line']}" parts.append(location) - if error.get("key"): + if error.get('key'): parts.append(f"Key {error['key']}") - parts.append(error.get("message", "Invalid configuration")) - return "\n".join(parts) + parts.append(error.get('message', 'Invalid configuration')) + return '\n'.join(parts) +def load_identity_seed(alias: str) -> dict | None: + if not SLUG_RE.fullmatch(alias): + return None + try: + result = subprocess.run([IDENTITY_SEED, '--alias', alias], check=False, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + except OSError: + return None + if result.returncode == 1: + return None + if result.returncode != 0: + try: + error = json.loads((result.stderr or '').strip().splitlines()[-1]) + except (json.JSONDecodeError, IndexError): + error = {'message': 'identity seed resolver failed'} + dialog('--msgbox', f"Identity seed rejected\n\nAlias {alias}\n{error.get('message', 'invalid identity seed')}", '14', '72', stage='identity_seed_invalid') + raise FirstbootFailure('identity_seed_invalid', result.returncode) + try: + identity = json.loads(result.stdout.strip().splitlines()[-1]) + except (json.JSONDecodeError, IndexError): + raise FirstbootFailure('identity_seed_invalid', result.returncode) + value = identity.get('value') + if identity.get('schema') != 'rigos.identity/v1' or identity.get('alias') != alias or identity.get('kind') != 'mining_identity' or not isinstance(value, str) or not value or len(value) > 512 or any((character.isspace() or ord(character) < 32 for character in value)): + raise FirstbootFailure('identity_seed_invalid', result.returncode) + return identity def manual_proposal() -> tuple[dict, str, str]: - node = prompt("Node name", "rig01").strip() - timezone = prompt("IANA timezone", "UTC").strip() - if not Path("/usr/share/zoneinfo", timezone).is_file(): - raise SystemExit("Timezone is not installed or is invalid.") - endpoint = prompt("Pool endpoint host:port", "gulf.moneroocean.stream:10128").strip() - algorithm = prompt("XMRig algorithm", "rx/0").strip() - tls = dialog("--menu", "TLS policy", "12", "60", "2", "required", "TLS enabled", "disabled", "Plain Stratum") - threads = prompt("CPU threads auto or 1 to 1024", "auto").strip() - huge_pages = dialog("--menu", "Huge pages", "12", "60", "2", "enabled", "Enable huge pages", "disabled", "Disable huge pages") - start = dialog("--menu", "Miner start policy", "12", "64", "2", "on_boot", "Start automatically on boot", "manual", "Manual start only") - alias = prompt("Local mining identity alias", "main-xmr").strip() + node = prompt('node_name', 'Node name', 'rig01').strip() + timezone = prompt('timezone', 'IANA timezone', 'UTC').strip() + if not Path('/usr/share/zoneinfo', timezone).is_file(): + raise FirstbootFailure('invalid_timezone') + endpoint = prompt('pool_endpoint', 'Pool endpoint host:port', 'gulf.moneroocean.stream:10128').strip() + algorithm = prompt('algorithm', 'XMRig algorithm', 'rx/0').strip() + tls = dialog('--menu', 'TLS policy', '12', '60', '2', 'required', 'TLS enabled', 'disabled', 'Plain Stratum', stage='tls_policy') + threads = prompt('cpu_threads', 'CPU threads auto or 1 to 1024', 'auto').strip() + huge_pages = dialog('--menu', 'Huge pages', '12', '60', '2', 'enabled', 'Enable huge pages', 'disabled', 'Disable huge pages', stage='huge_pages') + start = dialog('--menu', 'Miner start policy', '12', '64', '2', 'on_boot', 'Start automatically on boot', 'manual', 'Manual start only', stage='miner_start_policy') + alias = prompt('identity_alias', 'Local mining identity alias', 'main-xmr').strip() if not SLUG_RE.fullmatch(node) or not SLUG_RE.fullmatch(alias): - raise SystemExit("Invalid node name or identity alias.") - if endpoint.count(":") != 1: - raise SystemExit("Endpoint must use host:port syntax.") - host, port_text = endpoint.rsplit(":", 1) - if not HOST_RE.fullmatch(host) or not port_text.isdigit() or not 1 <= int(port_text) <= 65535: - raise SystemExit("Invalid pool endpoint.") - if threads != "auto" and (not threads.isdigit() or not 1 <= int(threads) <= 1024): - raise SystemExit("Invalid CPU thread count.") - proposal = { - "schema": "rigos.config-proposal/v1", - "profile": {"node_name": node, "timezone": timezone, "flight_source": "interactive", "flight_ref": None, "miner_start_mode": start}, - "flight_sheet": { - "schema": "rigos.flight-sheet/v1", "name": "manual-xmrig", "coin": "XMR", "backend": "xmrig", "algorithm": algorithm, - "pools": [{"host": host.lower(), "port": int(port_text), "tls": tls == "required", "priority": 0}], - "identity_ref": alias, "worker_template": "{node_name}", - "cpu": {"threads": threads if threads == "auto" else int(threads), "huge_pages": huge_pages == "enabled", "max_threads_hint": 100}, - }, - "provenance": None, "source_sha256": "manual", - } - return proposal, alias, "" - + raise FirstbootFailure('invalid_node_or_alias') + if endpoint.count(':') != 1: + raise FirstbootFailure('invalid_pool_endpoint') + host, port_text = endpoint.rsplit(':', 1) + if not HOST_RE.fullmatch(host) or not port_text.isdigit() or (not 1 <= int(port_text) <= 65535): + raise FirstbootFailure('invalid_pool_endpoint') + if threads != 'auto' and (not threads.isdigit() or not 1 <= int(threads) <= 1024): + raise FirstbootFailure('invalid_cpu_threads') + proposal = {'schema': 'rigos.config-proposal/v1', 'profile': {'node_name': node, 'timezone': timezone, 'flight_source': 'interactive', 'flight_ref': None, 'miner_start_mode': start}, 'flight_sheet': {'schema': 'rigos.flight-sheet/v1', 'name': 'manual-xmrig', 'coin': 'XMR', 'backend': 'xmrig', 'algorithm': algorithm, 'pools': [{'host': host.lower(), 'port': int(port_text), 'tls': tls == 'required', 'priority': 0}], 'identity_ref': alias, 'worker_template': '{node_name}', 'cpu': {'threads': threads if threads == 'auto' else int(threads), 'huge_pages': huge_pages == 'enabled', 'max_threads_hint': 100}}, 'provenance': None, 'source_sha256': 'manual'} + return (proposal, alias, '') def prepare_import(path: str) -> tuple[dict, bool]: while True: - ok, result = engine("prepare", "--output", path) + set_stage('prepare_import') + ok, result = engine('prepare', '--output', path) if ok: - return json.loads(Path(path).read_text(encoding="utf-8")), False - if result.get("code") == "RIGOS_CONFIG_FILE_MISSING": - return manual_proposal()[0], True - if result.get("code") == "RIGOS_FLIGHT_SHEET_MISSING" and "interactive" in result.get("message", ""): - found, choices = engine("discover") + return (json.loads(Path(path).read_text(encoding='utf-8')), False) + if result.get('code') == 'RIGOS_CONFIG_FILE_MISSING': + return (manual_proposal()[0], True) + if result.get('code') == 'RIGOS_FLIGHT_SHEET_MISSING' and 'interactive' in result.get('message', ''): + found, choices = engine('discover') if found: - items = [("manual", "Configure manually"), ("none", "Do not configure mining now")] - items += [(f"native:{name}", f"Native {name}") for name in choices.get("native", [])] - items += [(f"import:{name}", f"Import {name}") for name in choices.get("import", [])] + items = [('manual', 'Configure manually'), ('none', 'Do not configure mining now')] + items += [(f'native:{name}', f'Native {name}') for name in choices.get('native', [])] + items += [(f'import:{name}', f'Import {name}') for name in choices.get('import', [])] flat = [value for pair in items for value in pair] - selected = dialog("--menu", "Select Flight Sheet", "20", "76", str(min(12, len(items))), *flat) - if selected == "manual": - return manual_proposal()[0], True - if selected == "none": - raise SystemExit("Mining left unconfigured. Reboot to configure later.") - source, reference = selected.split(":", 1) - ok, prepared = engine("prepare", "--output", path, "--flight-source", source, "--flight-ref", reference) + selected = dialog('--menu', 'Select Flight Sheet', '20', '76', str(min(12, len(items))), *flat, stage='flight_sheet_selection') + if selected == 'manual': + return (manual_proposal()[0], True) + if selected == 'none': + raise FirstbootCancelled('mining_left_unconfigured') + source, reference = selected.split(':', 1) + ok, prepared = engine('prepare', '--output', path, '--flight-source', source, '--flight-ref', reference) if ok: - return json.loads(Path(path).read_text(encoding="utf-8")), False + return (json.loads(Path(path).read_text(encoding='utf-8')), False) result = prepared - action = dialog("--menu", diagnostic_text(result), "20", "76", "4", "retry", "Retry import", "manual", "Continue with manual setup", "diagnostics", "Show diagnostics", "reboot", "Reboot") - if action == "retry": + action = dialog('--menu', diagnostic_text(result), '20', '76', '4', 'retry', 'Retry import', 'manual', 'Continue with manual setup', 'diagnostics', 'Show diagnostics', 'reboot', 'Reboot', stage='import_error_action') + if action == 'retry': continue - if action == "diagnostics": - dialog("--msgbox", diagnostic_text(result), "18", "76") + if action == 'diagnostics': + dialog('--msgbox', diagnostic_text(result), '18', '76', stage='import_diagnostics') continue - if action == "reboot": - subprocess.run(["systemctl", "reboot"], check=False) - raise SystemExit(0) - if confirm("Discard invalid rigos.conf for this boot and continue manually\n\nThe source file will not be changed or deleted."): - return manual_proposal()[0], True - + if action == 'reboot': + subprocess.run(['systemctl', 'reboot'], check=False) + raise RebootRequested + if confirm('Discard invalid rigos.conf for this boot and continue manually\n\nThe source file will not be changed or deleted.', stage='discard_invalid_import'): + return (manual_proposal()[0], True) def resolve_identity(proposal: dict) -> dict: - requested = proposal["flight_sheet"]["identity_ref"] - identity_dir = STATE / "identities" + requested = proposal['flight_sheet']['identity_ref'] + identity_dir = STATE / 'identities' candidates = [] if identity_dir.is_dir(): - for path in sorted(identity_dir.glob("*.json"))[:64]: + for path in sorted(identity_dir.glob('*.json'))[:64]: try: - record = json.loads(path.read_text(encoding="utf-8")) - if record.get("schema") == "rigos.identity/v1": + record = json.loads(path.read_text(encoding='utf-8')) + if record.get('schema') == 'rigos.identity/v1': candidates.append(record) except (OSError, json.JSONDecodeError): continue - external = (proposal.get("provenance") or {}).get("external_reference") - mapping_path = STATE / "external-identity-map.json" + external = (proposal.get('provenance') or {}).get('external_reference') + mapping_path = STATE / 'external-identity-map.json' if external and mapping_path.is_file(): try: - mapping_document = json.loads(mapping_path.read_text(encoding="utf-8")) - mappings = mapping_document.get("mappings", []) if mapping_document.get("schema") == "rigos.external-identity-map/v1" else [] + mapping_document = json.loads(mapping_path.read_text(encoding='utf-8')) + mappings = mapping_document.get('mappings', []) if mapping_document.get('schema') == 'rigos.external-identity-map/v1' else [] except (OSError, json.JSONDecodeError): mappings = [] - mapping = next((item for item in mappings if item.get("source") == external.get("source") and item.get("external_type") == external.get("external_type") and item.get("external_value") == external.get("external_value")), None) - mapped_identity = next((item for item in candidates if mapping and item.get("alias") == mapping.get("identity_ref")), None) + mapping = next((item for item in mappings if item.get('source') == external.get('source') and item.get('external_type') == external.get('external_type') and (item.get('external_value') == external.get('external_value'))), None) + mapped_identity = next((item for item in candidates if mapping and item.get('alias') == mapping.get('identity_ref')), None) if mapped_identity: - source_status = "same source hash" if mapping.get("confirmed_source_sha256") == proposal.get("source_sha256") else "source hash changed" + source_status = 'same source hash' if mapping.get('confirmed_source_sha256') == proposal.get('source_sha256') else 'source hash changed' + set_stage('reuse_external_identity') if confirm(f"Reuse confirmed external identity mapping\n\nLocal alias {mapped_identity['alias']}\n{source_status}\nValue suffix ****{mapped_identity['value'][-4:]}"): - proposal["flight_sheet"]["identity_ref"] = mapped_identity["alias"] + proposal['flight_sheet']['identity_ref'] = mapped_identity['alias'] return mapped_identity - exact = next((item for item in candidates if item.get("alias") == requested), None) + exact = next((item for item in candidates if item.get('alias') == requested), None) + set_stage('reuse_local_identity') if exact and confirm(f"Reuse local identity alias {requested}\n\nValue suffix {exact['value'][-6:].rjust(len(exact['value'][-6:]) + 4, '*')}"): - proposal["flight_sheet"]["identity_ref"] = exact["alias"] + proposal['flight_sheet']['identity_ref'] = exact['alias'] return exact + seeded_identity = load_identity_seed(requested) + if seeded_identity: + set_stage('identity_seed_confirmation') + if confirm(f"Use USB identity seed\n\nAlias {seeded_identity['alias']}\nValue suffix ****{seeded_identity['value'][-4:]}\n\nThe full value will be copied once into local persistent state and will not be written back to the USB."): + proposal['flight_sheet']['identity_ref'] = seeded_identity['alias'] + return seeded_identity + raise FirstbootCancelled('identity_seed_not_confirmed') aliases = [(f"existing:{item['alias']}", f"Use {item['alias']} suffix ****{item['value'][-4:]}") for item in candidates] - choices = aliases + [("new", "Add a new local identity"), ("none", "Leave mining unconfigured"), ("reboot", "Cancel and reboot")] - selected = dialog("--menu", "Mining identity is unresolved", "18", "76", str(min(12, len(choices))), *[value for pair in choices for value in pair]) - if selected.startswith("existing:"): - alias = selected.split(":", 1)[1] - selected_identity = next(item for item in candidates if item["alias"] == alias) + choices = aliases + [('new', 'Add a new local identity'), ('none', 'Leave mining unconfigured'), ('reboot', 'Cancel and reboot')] + set_stage('identity_selection') + selected = dialog('--menu', 'Mining identity is unresolved', '18', '76', str(min(12, len(choices))), *[value for pair in choices for value in pair]) + if selected.startswith('existing:'): + alias = selected.split(':', 1)[1] + selected_identity = next((item for item in candidates if item['alias'] == alias)) + set_stage('confirm_existing_identity') if not confirm(f"Use local identity alias {alias} for this Flight Sheet\n\nValue suffix ****{selected_identity['value'][-4:]}"): return resolve_identity(proposal) - proposal["flight_sheet"]["identity_ref"] = alias + proposal['flight_sheet']['identity_ref'] = alias return selected_identity - if selected == "none": - subprocess.run(["systemctl", "stop", "rigos-miner.service"], check=False) - raise SystemExit("Mining remains unconfigured and stopped.") - if selected == "reboot": - subprocess.run(["systemctl", "reboot"], check=False) - raise SystemExit(0) - alias = prompt("Local identity alias", requested if SLUG_RE.fullmatch(requested) else "main-xmr").strip() - value = prompt("Mining identity value", password=True).strip() - if not SLUG_RE.fullmatch(alias) or not value or len(value) > 512 or any(character.isspace() or ord(character) < 32 for character in value): - raise SystemExit("Invalid local mining identity.") - proposal["flight_sheet"]["identity_ref"] = alias - return {"schema": "rigos.identity/v1", "alias": alias, "kind": "mining_identity", "value": value, "created_locally": True} - - -def ensure_administrator_password() -> None: - status = subprocess.run(["passwd", "--status", "rigosadmin"], check=False, text=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) - fields = status.stdout.split() - if status.returncode == 0 and len(fields) >= 2 and fields[1] == "P": - return - dialog("--msgbox", "Set the local administrator password next. It remains on this machine and enables local recovery diagnostics.", "11", "72") - subprocess.run(["passwd", "rigosadmin"], check=True) - verified = subprocess.run(["passwd", "--status", "rigosadmin"], check=False, text=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) - verified_fields = verified.stdout.split() - if verified.returncode != 0 or len(verified_fields) < 2 or verified_fields[1] != "P": - raise SystemExit("Local administrator password was not established.") - + if selected == 'none': + subprocess.run(['systemctl', 'stop', 'rigos-miner.service'], check=False) + raise FirstbootCancelled('mining_left_unconfigured') + if selected == 'reboot': + subprocess.run(['systemctl', 'reboot'], check=False) + raise RebootRequested + alias = prompt('new_identity_alias', 'Local identity alias', requested if SLUG_RE.fullmatch(requested) else 'main-xmr').strip() + value = prompt('new_identity_value', 'Mining identity value', password=True).strip() + if not SLUG_RE.fullmatch(alias) or not value or len(value) > 512 or any((character.isspace() or ord(character) < 32 for character in value)): + raise FirstbootFailure('invalid_identity') + proposal['flight_sheet']['identity_ref'] = alias + return {'schema': 'rigos.identity/v1', 'alias': alias, 'kind': 'mining_identity', 'value': value, 'created_locally': True} def main() -> None: - ensure_administrator_password() - status_path = Path("/run/rigos/state-status.json") + set_stage('state_preflight') try: - state_outcome = json.loads(status_path.read_text(encoding="utf-8"))["outcome"] + state_outcome = json.loads(STATE_STATUS.read_text(encoding='utf-8'))['outcome'] except (OSError, KeyError, json.JSONDecodeError): - state_outcome = "unavailable" - if state_outcome not in {"ready", "grown"}: - dialog("--msgbox", f"Persistent state status: {state_outcome}\n\nConfiguration and mining are blocked until the verified USB state is ready.", "13", "72") - raise SystemExit(1) - - with tempfile.NamedTemporaryFile(prefix="rigos-proposal-", dir="/run/rigos", delete=False) as stream: + state_outcome = 'unavailable' + if state_outcome != 'ready': + dialog('--msgbox', f'Persistent state status: {state_outcome}\n\nConfiguration and mining are blocked until the verified USB state is ready.', '13', '72', stage='state_not_ready') + raise FirstbootFailure('state_not_ready') + set_stage('current_revision') + existing, current = engine('current') + if existing and current.get('outcome') == 'configuration_committed': + set_stage('activation_retry') + activated, result = engine('activate') + if not activated: + dialog('--msgbox', f'Configuration activation failed\n{diagnostic_text(result)}\n\nThe committed revision was preserved and mining remains stopped. Reboot to retry activation.', '18', '76', stage='activation_retry_failed') + raise FirstbootFailure('activation_retry_failed') + dialog('--msgbox', f"Existing configuration activated.\nMiner started: {result.get('miner_started', False)}", '10', '64', stage='activation_retry_ready') + return + with tempfile.NamedTemporaryFile(prefix='rigos-proposal-', dir='/run/rigos', delete=False) as stream: proposal_path = stream.name os.unlink(proposal_path) try: proposal, _manual = prepare_import(proposal_path) identity = resolve_identity(proposal) - sheet = proposal["flight_sheet"] - profile = proposal["profile"] - primary = sheet["pools"][0] - summary = ( - f"Node {profile['node_name']}\nFlight Sheet {sheet['name']}\n" - f"Pool {primary['host']}:{primary['port']}\nFallback pools {len(sheet['pools']) - 1}\n" - f"Algorithm {sheet['algorithm']}\nTLS {'enabled' if primary['tls'] else 'disabled'}\n" - f"Identity {identity['alias']} suffix ****{identity['value'][-4:]}\n" - f"Start policy {profile['miner_start_mode']}\nHuge pages {sheet['cpu']['huge_pages']}" - ) - if not confirm(summary + "\n\nCommit this configuration locally?"): - raise SystemExit("Configuration not confirmed; no changes were written.") - ok, result = engine("transact", stdin={"proposal": proposal, "identity": identity}) + sheet = proposal['flight_sheet'] + profile = proposal['profile'] + primary = sheet['pools'][0] + summary = f"Node {profile['node_name']}\nFlight Sheet {sheet['name']}\nPool {primary['host']}:{primary['port']}\nFallback pools {len(sheet['pools']) - 1}\nAlgorithm {sheet['algorithm']}\nTLS {('enabled' if primary['tls'] else 'disabled')}\nIdentity {identity['alias']} suffix ****{identity['value'][-4:]}\nStart policy {profile['miner_start_mode']}\nHuge pages {sheet['cpu']['huge_pages']}" + if not confirm(summary + '\n\nCommit this configuration locally?', stage='commit_confirmation'): + raise FirstbootCancelled('configuration_not_confirmed') + set_stage('configuration_commit') + ok, result = engine('commit', stdin={'proposal': proposal, 'identity': identity}) if not ok: - dialog("--msgbox", f"Configuration transaction failed\n{diagnostic_text(result)}\n\nPrevious runtime state was restored or mining was left stopped for recovery.", "18", "76") - raise SystemExit(1) - dialog("--msgbox", f"Configuration saved atomically.\nMiner started: {result.get('miner_started', False)}", "10", "64") + dialog('--msgbox', f'Configuration commit failed\n{diagnostic_text(result)}\n\nNo activation was attempted.', '18', '76', stage='configuration_commit_failed') + raise FirstbootFailure('configuration_commit_failed') + set_stage('configuration_activation') + activated, activation = engine('activate') + if not activated: + dialog('--msgbox', f'Configuration was committed but activation failed\n{diagnostic_text(activation)}\n\nThe revision was preserved and mining remains stopped. Reboot to retry activation.', '18', '76', stage='configuration_activation_failed') + raise FirstbootFailure('configuration_activation_failed') + dialog('--msgbox', f"Configuration saved and activated atomically.\nMiner started: {activation.get('miner_started', False)}", '10', '64', stage='configuration_ready') finally: try: os.unlink(proposal_path) except FileNotFoundError: pass +def handle_signal(signal_number: int, _frame: object) -> None: + name = signal.Signals(signal_number).name + record_event('failed', 'process_signal', signal_name=name) + raise FirstbootInterrupted(signal_number) + +def run() -> int: + signal.signal(signal.SIGHUP, handle_signal) + signal.signal(signal.SIGTERM, handle_signal) + set_stage('startup') + record_event('running', 'firstboot_started') + try: + main() + except FirstbootCancelled as error: + record_event('cancelled', error.reason, error.returncode) + print(f'RIGOS firstboot cancelled stage={CURRENT_STAGE} reason={error.reason}', file=sys.stderr, flush=True) + return 10 + except FirstbootFailure as error: + record_event('failed', error.reason, error.returncode, error.signal_name) + print(f'RIGOS firstboot failed stage={CURRENT_STAGE} reason={error.reason}', file=sys.stderr, flush=True) + return 20 + except FirstbootInterrupted as error: + return 128 + error.signal_number + except RebootRequested: + record_event('cancelled', 'reboot_requested') + return 0 + set_stage('complete') + record_event('ready', 'firstboot_completed') + return 0 -if __name__ == "__main__": - main() +if __name__ == '__main__': + raise SystemExit(run()) diff --git a/build/usb/includes.chroot/usr/local/sbin/rigos-recovery-access b/build/usb/includes.chroot/usr/local/sbin/rigos-recovery-access new file mode 100644 index 00000000..672fd173 --- /dev/null +++ b/build/usb/includes.chroot/usr/local/sbin/rigos-recovery-access @@ -0,0 +1,306 @@ +#!/usr/bin/python3 +import json +import os +import re +import stat +import subprocess +import tempfile +from pathlib import Path + +RUNTIME = Path("/run/rigos") +STATE = Path("/var/lib/rigos") +CONSOLE = Path(os.environ.get("RIGOS_CONSOLE", "/dev/tty1")) +PASSWORD_HELPER = os.environ.get("RIGOS_ADMIN_PASSWORD_HELPER", "/usr/lib/rigos/rigos-admin-password") +CREDENTIAL_DIRECTORY = STATE / "recovery" +CREDENTIAL_FILE = CREDENTIAL_DIRECTORY / "rigosadmin-password.hash" +BOOT_ID = Path("/proc/sys/kernel/random/boot_id") +STATE_STATUS = RUNTIME / "state-status.json" +MAX_HASH_BYTES = 512 +MODULAR_CRYPT = re.compile(r"^\$[A-Za-z0-9][A-Za-z0-9./,_=-]*(?:\$[A-Za-z0-9./,_=-]+){2,}$") + + +def password_ready() -> bool: + result = subprocess.run( + ["/usr/bin/passwd", "--status", "rigosadmin"], + check=False, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + ) + fields = result.stdout.split() + return result.returncode == 0 and len(fields) >= 2 and fields[1] == "P" + + +def current_password_hash() -> str | None: + result = subprocess.run( + ["/usr/bin/getent", "shadow", "rigosadmin"], + check=False, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + ) + if result.returncode != 0: + return None + fields = result.stdout.rstrip("\n").split(":") + if len(fields) < 2 or not valid_password_hash(fields[1]): + return None + return fields[1] + + +def valid_password_hash(value: str) -> bool: + if not value or len(value.encode("utf-8")) > MAX_HASH_BYTES: + return False + if value.startswith(("!", "*")) or any(character in value for character in ":\r\n"): + return False + return MODULAR_CRYPT.fullmatch(value) is not None + + +def read_state_status() -> dict: + try: + raw = STATE_STATUS.read_bytes() + if not raw or len(raw) > 64 * 1024: + return {} + value = json.loads(raw) + return value if isinstance(value, dict) else {} + except (OSError, UnicodeError, json.JSONDecodeError): + return {} + + +def persistent_store_ready(status: dict) -> bool: + try: + boot_id = BOOT_ID.read_text(encoding="ascii").strip() + except OSError: + return False + if ( + status.get("schema") != "rigos.state-status/v1" + or status.get("boot_id") != boot_id + or status.get("outcome") != "ready" + or status.get("mountpoint") != str(STATE) + ): + return False + result = subprocess.run( + [ + "/usr/bin/findmnt", + "--json", + "--target", + str(STATE), + "--output", + "TARGET,FSTYPE,OPTIONS", + ], + check=False, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + ) + if result.returncode != 0: + return False + try: + document = json.loads(result.stdout) + filesystems = document.get("filesystems", []) + mount = filesystems[0] if isinstance(filesystems, list) and filesystems else {} + except (TypeError, json.JSONDecodeError): + return False + options = set(str(mount.get("options", "")).split(",")) + required = {"rw", "nosuid", "nodev", "noexec", "noatime"} + return ( + mount.get("target") == str(STATE) + and mount.get("fstype") == "ext4" + and required.issubset(options) + ) + + +def ensure_credential_directory() -> None: + CREDENTIAL_DIRECTORY.mkdir(mode=0o700, parents=True, exist_ok=True) + info = CREDENTIAL_DIRECTORY.lstat() + if not stat.S_ISDIR(info.st_mode) or stat.S_ISLNK(info.st_mode): + raise RuntimeError("unsafe credential directory") + if info.st_uid != os.geteuid(): + raise RuntimeError("credential directory owner mismatch") + CREDENTIAL_DIRECTORY.chmod(0o700) + + +def load_persistent_hash() -> tuple[str | None, bool]: + try: + info = CREDENTIAL_FILE.lstat() + except FileNotFoundError: + return None, False + if not stat.S_ISREG(info.st_mode) or stat.S_ISLNK(info.st_mode): + return None, True + if info.st_uid != os.geteuid() or stat.S_IMODE(info.st_mode) != 0o600: + return None, True + try: + value = CREDENTIAL_FILE.read_text(encoding="ascii").rstrip("\n") + except (OSError, UnicodeError): + return None, True + return (value, False) if valid_password_hash(value) else (None, True) + + +def persist_password_hash(value: str) -> None: + if not valid_password_hash(value): + raise RuntimeError("refusing invalid credential hash") + ensure_credential_directory() + descriptor, name = tempfile.mkstemp(prefix=".rigosadmin-password-", dir=CREDENTIAL_DIRECTORY) + temporary = Path(name) + try: + os.fchmod(descriptor, 0o600) + with os.fdopen(descriptor, "w", encoding="ascii", closefd=True) as stream: + stream.write(value) + stream.write("\n") + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, CREDENTIAL_FILE) + CREDENTIAL_FILE.chmod(0o600) + directory = os.open(CREDENTIAL_DIRECTORY, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(directory) + finally: + os.close(directory) + finally: + temporary.unlink(missing_ok=True) + + +def restore_password_hash(value: str) -> bool: + if not valid_password_hash(value): + return False + result = subprocess.run( + ["/usr/sbin/chpasswd", "--encrypted"], + input=f"rigosadmin:{value}\n", + text=True, + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + return result.returncode == 0 and password_ready() + + +def prompt_for_password(invalid_stored_hash: bool, persistent: bool) -> None: + if invalid_stored_hash: + message = ( + "The saved local credential is invalid and will be replaced. " + "Set the local rig administrator password next." + ) + elif persistent: + message = ( + "Set the local rig administrator password next. The encrypted password hash " + "will be stored in verified persistent state." + ) + else: + message = ( + "Persistent state is unavailable. Set a temporary rig administrator password " + "for this boot so local and SSH diagnostics remain available. " + "This password is not persistent and may be requested again after reboot." + ) + with CONSOLE.open("r+b", buffering=0) as console: + subprocess.run( + [ + "/usr/bin/whiptail", + "--title", + "RIGOS RECOVERY ACCESS", + "--msgbox", + message, + "12", + "76", + ], + stdin=console, + stdout=console, + stderr=console, + check=False, + ) + result = subprocess.run( + [PASSWORD_HELPER], + stdin=console, + stdout=console, + stderr=console, + check=False, + ) + if result.returncode != 0: + raise RuntimeError("local credential setup was cancelled or failed") + + +def unit_active(name: str) -> bool: + return subprocess.run( + ["/usr/bin/systemctl", "is-active", "--quiet", name], check=False + ).returncode == 0 + + +def unit_enabled(name: str) -> bool: + return subprocess.run( + ["/usr/bin/systemctl", "is-enabled", "--quiet", name], check=False + ).returncode == 0 + + +def write_status(value: dict) -> None: + RUNTIME.mkdir(mode=0o755, parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile( + mode="w", encoding="utf-8", dir=RUNTIME, prefix=".recovery-access-", delete=False + ) as stream: + json.dump(value, stream, sort_keys=True) + stream.write("\n") + stream.flush() + os.fsync(stream.fileno()) + temporary = Path(stream.name) + temporary.chmod(0o644) + os.replace(temporary, RUNTIME / "recovery-access-status.json") + directory = os.open(RUNTIME, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(directory) + finally: + os.close(directory) + + +def main() -> int: + state_status = read_state_status() + state_outcome = str(state_status.get("outcome") or "unavailable") + persistent = persistent_store_ready(state_status) + stored_hash = None + stored_hash_invalid = False + if persistent: + ensure_credential_directory() + stored_hash, stored_hash_invalid = load_persistent_hash() + + credential_action = "existing" + if password_ready(): + live_hash = current_password_hash() + if live_hash is None: + raise RuntimeError("usable local credential hash is unavailable") + if persistent and stored_hash != live_hash: + persist_password_hash(live_hash) + elif persistent and stored_hash is not None and restore_password_hash(stored_hash): + credential_action = "restored" + else: + prompt_for_password(stored_hash_invalid, persistent) + if not password_ready(): + raise RuntimeError("local credential setup did not produce a usable password") + live_hash = current_password_hash() + if live_hash is None: + raise RuntimeError("new local credential hash is unavailable") + if persistent: + persist_password_hash(live_hash) + credential_action = "created" + + local_access = password_ready() + remote_active = any(unit_active(name) for name in ("ssh.service", "sshd.service")) + remote_enabled = any(unit_enabled(name) for name in ("ssh.service", "sshd.service")) + remote_inactive = not remote_active and not remote_enabled + configured = persistent and (STATE / "current").is_symlink() + mode = "operational" if state_outcome == "ready" and remote_inactive else "recovery" + credential_persisted = persistent and CREDENTIAL_FILE.is_file() + write_status( + { + "schema": "rigos.recovery-access-status/v1", + "boot_id": BOOT_ID.read_text(encoding="ascii").strip(), + "mode": mode, + "local_console_access": local_access, + "state_outcome": state_outcome, + "remote_access": "inactive" if remote_inactive else "unexpected_active", + "configuration_commit": "present" if configured else "absent", + "credential_action": credential_action, + "credential_scope": "persistent" if persistent else "boot", + "credential_persisted": credential_persisted, + } + ) + return 0 if local_access and remote_inactive else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/build/usb/includes.chroot/usr/local/sbin/rigos-state-orchestrate b/build/usb/includes.chroot/usr/local/sbin/rigos-state-orchestrate new file mode 100644 index 00000000..027da288 --- /dev/null +++ b/build/usb/includes.chroot/usr/local/sbin/rigos-state-orchestrate @@ -0,0 +1,376 @@ +#!/usr/bin/env python3 +import json +import os +import re +import stat +import subprocess +import tempfile +import time +from pathlib import Path + +CORE = Path("/usr/lib/rigos/rigos-state-init") +RUNTIME = Path("/run/rigos") +STATUS = RUNTIME / "state-status.json" +ATTESTATION = RUNTIME / "boot-device.json" +BOOT_ID = Path("/proc/sys/kernel/random/boot_id") +PARTUUID_ROOT = Path("/dev/disk/by-partuuid") +SYS_DEV_BLOCK = Path("/sys/dev/block") +PARTUUID_RE = re.compile(r"^[0-9A-Fa-f-]{1,128}$") +MAJOR_MINOR_RE = re.compile(r"^[0-9]+:[0-9]+$") +E2FSCK_EXIT_RE = re.compile( + r"\be2fsck: exit (?:(?P[0-9]+)|Some\((?P