diff --git a/Justfile b/Justfile index a8b8bf2..798f84f 100644 --- a/Justfile +++ b/Justfile @@ -490,7 +490,7 @@ install-vm: } trap cleanup INT TERM - until curl --silent --show-error --max-time 2 --output /dev/null http://127.0.0.1:8080/; do + until curl --silent --insecure --max-time 2 --output /dev/null https://127.0.0.1:8080/ || curl --silent --max-time 2 --output /dev/null http://127.0.0.1:8080/; do if ! kill -0 "$QEMU_PID" 2>/dev/null; then wait "$QEMU_PID" exit 1 @@ -500,7 +500,7 @@ install-vm: HOST_IP="$(ip -4 -o addr show scope global | awk '{print $4}' | cut -d/ -f1 | head -n1)" echo "==> KubeStellar Console is ready!" - echo "==> Access URL (LAN): http://${HOST_IP:-localhost}:8080/" - echo "==> Access URL (Local): http://localhost:8080/" - xdg-open "http://${HOST_IP:-localhost}:8080/" || xdg-open http://localhost:8080/ || true + echo "==> Access URL (LAN): https://${HOST_IP:-localhost}:8080/" + echo "==> Access URL (Local): https://localhost:8080/" + xdg-open "https://${HOST_IP:-localhost}:8080/" || xdg-open https://localhost:8080/ || true wait "$QEMU_PID" diff --git a/README.md b/README.md index 51aebcc..77e7b18 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Bluefin Server is currently in **Alpha**: - **systemd-native installer** — `systemd-sysinstall` provides the interactive terminal UI and `systemd-repart` handles partitioning and block-copy DDI placement. - **Optional k0s as a `systemd-sysext`** so the base image stays minimal. -> **Remote diagnostics:** OpenSSH is installed for on-demand diagnostics, but is disabled by default via systemd presets. It can be started manually with `systemctl start sshd` when remote access is needed. See [`docs/skills/factory-integration.md`](docs/skills/factory-integration.md). +> **Remote diagnostics:** SSH is key-only for the provisioned `core` operator account and is enabled after successful key provisioning. See [`docs/skills/factory-integration.md`](docs/skills/factory-integration.md). ## Quick start diff --git a/docs/MVP_1_0_READINESS.md b/docs/MVP_1_0_READINESS.md index 3f8bef8..0ee5a83 100644 --- a/docs/MVP_1_0_READINESS.md +++ b/docs/MVP_1_0_READINESS.md @@ -8,7 +8,7 @@ This audit tracks the gap between the current tree and a first public/usable MVP 2. **Signed release artifacts** — combined `SHA256SUMS` + detached GPG signature published to GitHub Releases. 3. **Automated boot verification** — at least one non-human test that proves the installer writes a bootable disk and the installed OS reaches a target. 4. **Functional update path** — host can pull the signed manifest and apply an OS update without manual intervention. -5. **Basic first-boot provisioning** — unattended way to set root credential and drop an SSH authorized key. +5. **Basic first-boot provisioning** — unattended `core` operator key-provisioning model for SSH authorized keys. 6. **Documented recovery** — A/B rollback or reinstall-from-media path for a failed update. ## Current state @@ -22,7 +22,7 @@ This audit tracks the gap between the current tree and a first public/usable MVP | Automated boot test | 🔄 | Phase B in progress for Alpha; `bluefin-server-boot-test` workflow running on lab cluster | | A/B root rollback | ❌ | `50-root.transfer` names `root-a`/`root-b`, installer only creates `root-a` | | Root immutability | ❌ | DDI boots read/write (`rw` on cmdline) | -| First-boot SSH keys | ❌ | Only root password credential path exists | +| First-boot SSH keys | ✅ | Implemented `tmpfiles.extra` path writing `/var/home/core/.ssh/authorized_keys` | Competitor context: [gap-analysis-distros.md](skills/gap-analysis-distros.md) @@ -53,7 +53,7 @@ Priority order. Each item depends on the ones above it. - [ ] Add `root-b` to installer repart recipes and verify `systemd-sysupdate` stages into the inactive slot. - [ ] Switch UKI cmdline from `rw` to `ro` and rely on `/var` for mutable state. -- [ ] Consume `systemd-creds` for SSH authorized keys and static network config. +- [ ] Consume `systemd-creds` for static network config. - [ ] Add boot menu entry to select the previous slot after a failed update. ### Phase D: release discipline @@ -61,6 +61,10 @@ Priority order. Each item depends on the ones above it. - [ ] Tag `v1.0.0-MVP` once Phase B passes. - [ ] Publish release notes: verified boot path, trust model, known gaps. +## Migration prerequisites + +Users of `root / bluefin` must provision the `core` key credential before booting the hardened release, and recovery is offline ESP replacement of `/loader/credentials/tmpfiles.extra.cred`. There is no password or root fallback. + ## Open decisions - Does MVP 1.0 require A/B dual-slot rollback, or is a single-slot signed update with documented reinstall recovery acceptable for the first release? diff --git a/docs/skills/architecture-roadmap.md b/docs/skills/architecture-roadmap.md index afa8eb0..6d26602 100644 --- a/docs/skills/architecture-roadmap.md +++ b/docs/skills/architecture-roadmap.md @@ -4,7 +4,7 @@ description: Roadmap for future Bluefin Server architecture work. Use when plann metadata: type: reference status: stable - last_updated: "2026-09-07" + last_updated: "2026-09-09" context7-sources: - /systemd/systemd --- @@ -25,7 +25,7 @@ Priorities are derived from [gap-analysis-distros.md](gap-analysis-distros.md). | 1 | A/B dual-slot root partitions with matching ESP/UKI slots | Root fs only has slot A today; sysupdate already names slots A+B. | | 2 | Mount `/usr` read-only and enforce the state model | DDI is currently booted `rw`; sysext-first design assumes immutable `/usr`. | | 3 | Boot-time selection / automatic rollback of a failed update | No previous OS version is kept once a root update overwrites the slot. | -| 4 | Broader `systemd-creds` integration for SSH keys and network configuration | Only `root` password credential path is shipped in `os-creds-prov.bst`. | +| 4 | Broader `systemd-creds` integration for static network configuration | `core` SSH keys already arrive through the `tmpfiles.extra` credential; no credential path ships static network configuration. | | 5 | TPM2-bound credential delivery at first boot | Documented but not wired into the installed OS image. | | 6 | Native reboot coordination for non-Kubernetes and single-node hosts | Kured only covers Kubernetes nodes; no FleetLock/locksmith equivalent. | | 7 | Staged rollout behavior for larger fleets | Future after items 1-3 are implemented. | diff --git a/docs/skills/factory-integration.md b/docs/skills/factory-integration.md index 75124ca..db71ba5 100644 --- a/docs/skills/factory-integration.md +++ b/docs/skills/factory-integration.md @@ -63,7 +63,12 @@ The workloads the factory tests and ships live in other repositories or image pi ## SSH and Remote Diagnostics -> `sshd` is present in the OS image for on-demand diagnostics and bring-up troubleshooting, but is disabled by default via `disable sshd.service` in systemd presets. Operators can start it on-demand with `systemctl start sshd` or enable it when remote access is required. Root login is permitted with password and pubkey. +SSH access is key-only for the `core` operator account (`PermitRootLogin no`, `PasswordAuthentication no`). Root login and password authentication are completely disabled. `sshd` is enabled at boot and gated by `bluefin-core-access.service`, starting only after `core` authorization keys are provisioned via `tmpfiles.extra`. Operators connect as `core` and elevate with passwordless sudo: + +```sh +ssh core@server.example +sudo -i +``` ## When to Use diff --git a/docs/skills/gap-analysis-distros.md b/docs/skills/gap-analysis-distros.md index 12b4413..3b760cd 100644 --- a/docs/skills/gap-analysis-distros.md +++ b/docs/skills/gap-analysis-distros.md @@ -97,7 +97,7 @@ Bluefin Server is a BuildStream 2-based, image-based Linux server OS built from | **Philosophy** | Systemd-native, minimal, image-based server OS appliance; base DDI includes bash for login and bring-up while heavy developer/debug tools live in sysexts or system containers; intended to run container workloads and Kubernetes via optional sysexts. Sources: [AGENTS.md](../../AGENTS.md), [factory-integration.md](factory-integration.md). | | **State model** | Target OS DDI is an XFS filesystem image. A separate persistent `/var` partition is created by the installer. There is no second root slot provisioned today, and the UKI cmdline currently uses `rw`, so the root is not mounted read-only at runtime. Sources: [bluefin-server-ddi.bst](../../elements/oci/bluefin-server-ddi.bst), [20-root-a.conf](../../files/installer/repart.d/20-root-a.conf), [bluefin-server-installer.bst](../../elements/oci/bluefin-server-installer.bst). | | **Updates** | `systemd-sysupdate` reads root/UKI transfers from `files/os/sysupdate.d/` and the optional k0s transfer from the `k0s` component directory. Assets are published to GitHub Releases, and the combined `SHA256SUMS` manifest is signed in CI with a GPG key. `Verify=yes` is the default. Sources: [systemd-sysupdate-verification.md](systemd-sysupdate-verification.md), [50-root.transfer](../../files/os/sysupdate.d/50-root.transfer), [60-uki.transfer](../../files/os/sysupdate.d/60-uki.transfer), [70-k0s.transfer](../../files/os/sysupdate.k0s.d/70-k0s.transfer), also `systemd-sysupdate(8)`. | -| **Provisioning** | The installer is an offline `systemd-sysinstall` image that embeds the DDI as a data partition. First-boot configuration is intended to be delivered via `systemd-creds` through the ESP or hypervisor metadata. Today only `passwd.hashed-password.root` is consumed via `systemd-sysusers.d`; the documented `tmpfiles.extra` path for SSH keys and similar files is not implemented. Sources: [bluefin-server-installer.bst](../../elements/oci/bluefin-server-installer.bst), [10-root-creds.conf](../../files/os/sysusers.d/10-root-creds.conf), [os-creds-prov.bst](../../elements/bluefin-server/os-creds-prov.bst), [systemd-creds(1)](https://www.freedesktop.org/software/systemd/man/latest/systemd-creds.html). | +| **Provisioning** | The installer is an offline `systemd-sysinstall` image that embeds the DDI as a data partition. First-boot configuration is delivered via `systemd-creds` through the ESP or hypervisor metadata. `core` keys are written by the `tmpfiles.extra` credential into persistent `/var/home/core/.ssh/authorized_keys`. Sources: [bluefin-server-installer.bst](../../elements/oci/bluefin-server-installer.bst), [10-core-user.conf](../../files/os/sysusers.d/10-core-user.conf), [os-creds-prov.bst](../../elements/bluefin-server/os-creds-prov.bst), [systemd-creds(1)](https://www.freedesktop.org/software/systemd/man/latest/systemd-creds.html). | | **Customization** | Adds software through `systemd-sysext` (overlay `/usr`) and `systemd-confext` (overlay `/etc`) images. The base OS `os-release` advertises `ID=flatcar` and a matching `VERSION_ID` so pre-built Flatcar Bakery extensions load. k0s is shipped as a separately built, optionally enabled sysext. Sources: [systemd-sysext-extensions.md](systemd-sysext-extensions.md), [k0s-sysext.md](k0s-sysext.md), [os-release-flatcar.bst](../../elements/bluefin-server/os-release-flatcar.bst), [systemd-sysext(8)](https://www.freedesktop.org/software/systemd/man/latest/systemd-sysext.html). | | **Reboot coordination** | `systemd-sysupdate.service` has an `ExecStartPost` that touches `/run/reboot-required`. Rolling reboots across Kubernetes nodes rely on Kured reading that file. Sources: [os-kured-hook.bst](../../elements/bluefin-server/os-kured-hook.bst), [kured-hook.conf](../../files/os/systemd/systemd-sysupdate.service.d/kured-hook.conf), [Kured project](https://github.com/weaveworks/kured). | @@ -112,8 +112,7 @@ Bluefin Server is a BuildStream 2-based, image-based Linux server OS built from ### Provisioning -- **Gap:** The documented `systemd-creds` first-boot provisioning for SSH keys (`tmpfiles.extra`) is not present in the build (`os-creds-prov.bst` only ships `sysusers.d`). - Operators can only pre-seed the root password today. +- **Gap:** `core` keys are written by the `tmpfiles.extra` credential into persistent `/var/home/core/.ssh/authorized_keys`, but broader first-boot credential provisioning (such as network configuration or multiple operator identities) is not yet implemented. - **Gap:** `systemd-firstboot` is masked in the installer environment, so interactive first-boot questions are skipped; any further user/network/timezone configuration must be supplied through credentials that the build does not yet consume. - **Gap:** TPM2 sealing for credentials is documented in `docs/skills/tpm2-credential-sealing.md`, but there is no evidence in the OS build that sealed credentials are generated, shipped, or decrypted automatically during first boot. @@ -134,7 +133,7 @@ Bluefin Server is a BuildStream 2-based, image-based Linux server OS built from 1. **A/B dual-slot rollback is not wired end-to-end.** The `sysupdate` transfer names `root-a`/`root-b`, but the installer only provisions `root-a`, so Bluefin cannot atomically stage and roll back a new root image today. 2. **Root filesystem immutability is not enforced.** The DDI is built and booted read-write; the intended read-only `/usr` + overlay model depends entirely on optional sysext behavior rather than runtime policy. -3. **First-boot credential provisioning is incomplete.** Only the `root` password credential path is shipped; SSH keys, network configuration, and other `systemd-creds`-based provisioning remain documented but not implemented. +3. **First-boot credential provisioning is incomplete.** `core` keys are written by the `tmpfiles.extra` credential into persistent `/var/home/core/.ssh/authorized_keys`, but network configuration and other `systemd-creds`-based provisioning remain documented but not implemented. These gaps drive the priorities in [architecture-roadmap.md](architecture-roadmap.md). @@ -193,5 +192,6 @@ These gaps drive the priorities in [architecture-roadmap.md](architecture-roadma - [files/os/sysupdate.d/50-root.transfer](../../files/os/sysupdate.d/50-root.transfer) - [files/os/sysupdate.d/60-uki.transfer](../../files/os/sysupdate.d/60-uki.transfer) - [files/os/sysupdate.k0s.d/70-k0s.transfer](../../files/os/sysupdate.k0s.d/70-k0s.transfer) -- [files/os/sysusers.d/10-root-creds.conf](../../files/os/sysusers.d/10-root-creds.conf) +- [files/os/sysusers.d/10-core-user.conf](../../files/os/sysusers.d/10-core-user.conf) +- [files/os/tmpfiles.d/10-core-home.conf](../../files/os/tmpfiles.d/10-core-home.conf) - [files/os/systemd/systemd-sysupdate.service.d/kured-hook.conf](../../files/os/systemd/systemd-sysupdate.service.d/kured-hook.conf) diff --git a/docs/skills/tpm2-credential-sealing.md b/docs/skills/tpm2-credential-sealing.md index 44cef76..5c42e37 100644 --- a/docs/skills/tpm2-credential-sealing.md +++ b/docs/skills/tpm2-credential-sealing.md @@ -1,25 +1,25 @@ --- name: tpm2-credential-sealing -description: Securing provisioning credentials (such as hashed root passwords or SSH keys) with TPM2 sealing via systemd-creds. +description: Securing provisioning credentials (such as operator SSH keys via tmpfiles.extra) with TPM2 sealing via systemd-creds. metadata: type: how-to status: stable - last_updated: "2026-09-07" + last_updated: "2026-09-09" context7-sources: - /systemd/systemd --- # TPM2 Credential Sealing -To secure sensitive provisioning credentials (such as hashed root passwords or -SSH keys) against physical tampering or unauthorized extraction, bind them to -the TPM2 and the UKI boot state using `systemd-creds`. +To secure sensitive provisioning credentials (such as operator SSH keys via +`tmpfiles.extra`) against offline tampering, bind them to the TPM2 and the UKI +boot state using `systemd-creds`. ## Current scope -This skill is the canonical home for sealed credentials such as hashed root -passwords and similar provisioning secrets. Broader `systemd-creds` integration -for SSH keys and network configuration is a future roadmap item in -[architecture-roadmap.md](architecture-roadmap.md). +This skill is the canonical home for sealed credentials, specifically the +`tmpfiles.extra` credential used to provision SSH access for the `core` operator +account. Broader `systemd-creds` integration for network configuration is a +future roadmap item in [architecture-roadmap.md](architecture-roadmap.md). ## Verify TPM2 device availability @@ -32,20 +32,33 @@ not running in a container. ## Encrypt and seal a credential +The `tmpfiles.extra` credential payload establishes directory ownership and +writes the authorized SSH keys for the `core` operator account: + +```text +d /var/home/core 0700 core core - +d /var/home/core/.ssh 0700 core core - +f~ /var/home/core/.ssh/authorized_keys 0600 core core - c3NoLWVkMjU1MTkgQUFBQUMzTnphQzFsWkRJMU5UR... +``` + +The base64 data in the `f~` line is an SSH public key, not a secret. Sealing +the credential ensures that unauthorized keys cannot be injected into the +machine offline when TPM2 protection is active. + Seal the credential against PCR 7 (Secure Boot state) and PCR 11 (Unified Kernel Image state) on the TPM2 chip: ```bash systemd-creds encrypt \ - --name=passwd.hashed-password.root \ + --name=tmpfiles.extra \ --with-key=tpm2 \ --tpm2-pcrs=7+11 \ - /path/to/plaintext_password_hash.txt \ + /path/to/plaintext_tmpfiles_extra.txt \ /path/to/secured_credential.cred ``` - `--name=` must match the credential name the consumer expects (for example, - `passwd.hashed-password.root` is read by `systemd-sysusers`). + `tmpfiles.extra` is read by `systemd-tmpfiles`). - `--with-key=tpm2` forces a TPM2-bound credential. The default `auto` also uses the host key if `/var/lib/systemd/` is on persistent media; omit the switch if you want both bindings. @@ -55,19 +68,35 @@ systemd-creds encrypt \ ## Provide the encrypted credential to the host Place the output `.cred` file in the ESP credential directory or pass it via a -container/hypervisor mechanism: +container/hypervisor mechanism. The credential file is +`/loader/credentials/tmpfiles.extra.cred` on the target ESP: ```bash # ESP delivery mkdir -p /loader/credentials/ -cp /path/to/secured_credential.cred /loader/credentials/passwd.hashed-password.root.cred +cp /path/to/secured_credential.cred /loader/credentials/tmpfiles.extra.cred # Or via a container/hypervisor argument ---set-credential=passwd.hashed-password.root:/path/to/secured_credential.cred +--set-credential=tmpfiles.extra:/path/to/secured_credential.cred ``` +## Offline recovery sequence + +There is no fallback password or root SSH bypass. If the `core` key is lost or +unusable, SSH fails closed. The supported offline recovery sequence requires +physical access or equivalent hypervisor access: + +1. Shut down the target host. +2. Mount the target ESP from a trusted machine. +3. Replace `/loader/credentials/tmpfiles.extra.cred` on the ESP with a + credential that writes the replacement `core` public key. +4. Boot the host. `systemd-tmpfiles` applies the credential, `bluefin-core-access` + verifies key readiness, and `sshd` starts. +5. Authenticate as `core` (`ssh core@server.example`) and elevate using `sudo -i`. + ## See also - [CONTEXT.md](../../CONTEXT.md) — canonical project domain glossary. +- [factory-integration.md](factory-integration.md) — operator login and remote diagnostics. - `systemd-creds(1)` - `systemd.system-credentials(7)` diff --git a/docs/superpowers/specs/2026-09-09-appliance-identity-design.md b/docs/superpowers/specs/2026-09-09-appliance-identity-design.md new file mode 100644 index 0000000..ca5cef2 --- /dev/null +++ b/docs/superpowers/specs/2026-09-09-appliance-identity-design.md @@ -0,0 +1,147 @@ +# Appliance Identity, mDNS, and Bash Prompt Design + +**Status:** Approved design + +## Goal + +Give each Bluefin Server appliance a stable, human-readable network identity +and a clear interactive prompt. A newly provisioned appliance without an +operator-selected hostname is `blueserver-0`, reachable as +`blueserver-0.local`. + +## Scope + +- Persist a selected hostname across OS DDI updates. +- Default an unselected appliance to `blueserver-0`. +- Resolve and announce appliance names through mDNS on the wired appliance + network. +- Display `username@hostname` with a blue `@` in interactive Bash sessions. +- Direct console users to the hostname-based KubeStellar URL. + +This design does not add an authoritative DNS server, DHCP server, Avahi, or +automatic hostname allocation. + +## Naming Policy + +The recommended fleet sequence is `blueserver-0`, `blueserver-1`, +`blueserver-2`, and so on. The default is always `blueserver-0`; later +appliances receive an explicitly selected hostname through provisioning. + +An operator may select another valid lower-case static hostname, but +documentation recommends the numbered pattern and requires checking +`.local` before selecting a name. The system does not probe mDNS and +choose a number automatically: two simultaneous installations can otherwise +choose the same name. A collision is an operator-visible configuration error, +not a reason to silently rename an appliance. + +`blueserver.local` is intentionally not an alias. The canonical address is +always the selected hostname followed by `.local`, so each device has exactly +one predictable appliance name. + +## Persistent Hostname State + +`/var/lib/bluefin-server/hostname` is the canonical hostname source. It lives +on the persistent `/var` partition, outside the replaceable OS DDI. + +The image creates `/etc/hostname` as a symlink to that file. A static tmpfiles +rule creates the persistent source with `blueserver-0` only if it does not +exist. An operator-provided `tmpfiles.extra` credential can replace it before +first boot with a selected hostname: + +```text +f+~ /var/lib/bluefin-server/hostname 0644 root root - Ymx1ZXNlcnZlci0xCg== +``` + +The base64 payload in this example is `blueserver-1\n`. The hostname +application service validates the stored value as a lower-case hostname before +calling `hostnamectl set-hostname --static`. It runs after `/var` and tmpfiles +setup, and before the network and resolver services. If validation fails, the +service fails visibly and leaves networking available for local diagnosis; it +does not substitute or silently generate a different hostname. + +Because `/etc/hostname` points at the persistent source, an authenticated +operator may use `sudo hostnamectl set-hostname --static blueserver-1` to +rename an appliance. The change survives reboot and OS DDI replacement. + +## DNS and Discovery Plan + +Bluefin uses `systemd-resolved` and standards-based multicast DNS, not a local +DNS authority: + +1. Enable `systemd-resolved.service` in the installed OS and make + `/etc/resolv.conf` point at its local stub resolver. +2. Set `MulticastDNS=yes` and `LLMNR=no` on the existing wired + `systemd-networkd` profile. mDNS is required for `.local`; LLMNR is neither + needed nor desirable on an appliance network. +3. Set `MulticastDNS=yes` in resolved configuration as the explicit global + policy. +4. Announce and resolve the static hostname as `.local`; no search + domain or unicast DNS zone is configured. + +This works without router configuration on clients whose system resolver +supports mDNS. The appliance documentation will identify +`https://blueserver-0.local:8080/` as the first-appliance URL. The existing +local TLS wildcard certificate coverage remains compatible with named +`.local` access. + +## Bash Prompt + +Add FSDK's `bash-config` component so `/etc/bashrc` loads +`/etc/profile.d/*.sh` for interactive Bash sessions. A small profile hook sets +the following prompt only when `PS1` is present: + +```bash +PS1='\u\[\e[1;34m\]@\[\e[0m\]\h:\w\$ ' +``` + +It renders, for example: + +```text +core@blueserver-0:~$ +``` + +Only the `@` is bright blue. `\h` tracks the live system hostname, `\w` +retains working-directory context, and `\$` renders `$` for `core` and `#` +after `sudo -i`. + +## Console Experience + +The installed OS console issue banner uses systemd's hostname escape rather +than the address-derived IP escape. It directs an operator to: + +```text +KubeStellar Console: https://.local:8080/ +``` + +At runtime, a device named `blueserver-0` therefore displays +`https://blueserver-0.local:8080/`. + +## Failure Handling + +- An invalid supplied hostname fails the hostname service visibly; networking + is not blocked. +- A duplicate hostname is not renamed automatically. mDNS collision symptoms + require the operator to select a different persistent hostname. +- If `systemd-resolved` is unavailable, normal DNS resolution must fail + visibly through the service dependency rather than falling back to a + hand-written resolver file. +- The hostname and mDNS controls apply only to the installed OS, not the + Installer environment. + +## Verification + +Focused tests will assert the default and credential hostname rules, hostname +validation and service ordering, resolver enablement and mDNS/LLMNR policy, +the `/etc/resolv.conf` and `/etc/hostname` links, the console `.local` URL, +and the exact color-safe prompt definition. A QEMU smoke test will confirm +that a default installation announces and resolves `blueserver-0.local` after +DHCP is ready. + +## References + +- `systemd.network(5)`: + +- `systemd-resolved.service(8)`: + +- `tmpfiles.d(5)`: + diff --git a/elements/bluefin-server/os-sshd-preset.bst b/elements/bluefin-server/os-sshd-preset.bst deleted file mode 100644 index 59333d4..0000000 --- a/elements/bluefin-server/os-sshd-preset.bst +++ /dev/null @@ -1,10 +0,0 @@ -kind: import -description: | - Install systemd presets ensuring sshd is present but off by default. - -sources: - - kind: local - path: files/os/systemd/system-preset - -config: - target: /usr/lib/systemd/system-preset diff --git a/elements/bluefin-server/os-stack.bst b/elements/bluefin-server/os-stack.bst index 004c09f..f11a177 100644 --- a/elements/bluefin-server/os-stack.bst +++ b/elements/bluefin-server/os-stack.bst @@ -22,9 +22,11 @@ depends: - freedesktop-sdk.bst:components/openssh-systemd.bst # SSH enablement drop-ins - - bluefin-server/os-sshd-preset.bst - bluefin-server/os-sshd-config.bst + # systemd unit presets (networkd, k0s first boot, persistent /var mount) + - bluefin-server/os-systemd-presets.bst + # Stock FSDK Kernel & Server-Chiseled Firmware - freedesktop-sdk.bst:components/linux.bst - bluefin-server/linux-firmware-split.bst @@ -55,6 +57,9 @@ depends: # First-Boot Credentials Provisioning - bluefin-server/os-creds-prov.bst + - freedesktop-sdk.bst:components/sudo.bst + - bluefin-server/os-tmpfiles.bst + - bluefin-server/os-sudo.bst # Reboot Coordination for Kured - bluefin-server/os-kured-hook.bst diff --git a/elements/bluefin-server/os-sudo.bst b/elements/bluefin-server/os-sudo.bst new file mode 100644 index 0000000..80f2b74 --- /dev/null +++ b/elements/bluefin-server/os-sudo.bst @@ -0,0 +1,9 @@ +kind: import +description: Install passwordless wheel sudo policy for key-only operators. + +sources: + - kind: local + path: files/os/sudoers.d + +config: + target: /etc/sudoers.d diff --git a/elements/bluefin-server/os-systemd-presets.bst b/elements/bluefin-server/os-systemd-presets.bst new file mode 100644 index 0000000..1f498e1 --- /dev/null +++ b/elements/bluefin-server/os-systemd-presets.bst @@ -0,0 +1,11 @@ +kind: import +description: | + Install the systemd system presets that enable networkd, the k0s first-boot + activation unit, and the persistent /var mount on the installed OS. + +sources: + - kind: local + path: files/os/systemd/system-preset + +config: + target: /usr/lib/systemd/system-preset diff --git a/elements/bluefin-server/os-tmpfiles.bst b/elements/bluefin-server/os-tmpfiles.bst new file mode 100644 index 0000000..ec98bf7 --- /dev/null +++ b/elements/bluefin-server/os-tmpfiles.bst @@ -0,0 +1,9 @@ +kind: import +description: Install persistent core operator home directory definitions. + +sources: + - kind: local + path: files/os/tmpfiles.d + +config: + target: /usr/lib/tmpfiles.d diff --git a/elements/oci/bluefin-server-ddi.bst b/elements/oci/bluefin-server-ddi.bst index 9f80157..e643931 100644 --- a/elements/oci/bluefin-server-ddi.bst +++ b/elements/oci/bluefin-server-ddi.bst @@ -66,24 +66,16 @@ config: # Ensure persistent /var is mounted from its partition label printf '/dev/disk/by-partlabel/var /var xfs defaults 0 0\n' > /layer/etc/fstab - # Ensure all standard virtual, serial, and hypervisor consoles allow root login - printf 'tty0\ntty1\ntty2\ntty3\ntty4\ntty5\ntty6\nttyS0\nttyS1\nconsole\nhvc0\n' > /layer/etc/securetty - - # Seed default root password 'bluefin' into /etc/shadow for console and ssh login - mkdir -p /layer/etc - printf 'root:$6$bluefin123$o2pQjPN1n3ZCnYUROlA01xAdnGO7mhVRAVw98x4xc8LAUIJv7b44bBNoER6fM4P.rrj4ePYFfkx9WvZICU8nt/:19700:0:99999:7:::\n' > /layer/etc/shadow - chmod 0600 /layer/etc/shadow - printf 'root:x:0:0:root:/root:/bin/bash\n' > /layer/etc/passwd - chmod 0644 /layer/etc/passwd - printf 'root:x:0:\n' > /layer/etc/group - chmod 0644 /layer/etc/group - - # Pre-generate SSH host keys for first boot remote diagnostics - mkdir -p /layer/etc/ssh - ssh-keygen -q -N "" -t ed25519 -f /layer/etc/ssh/ssh_host_ed25519_key - ssh-keygen -q -N "" -t rsa -b 3072 -f /layer/etc/ssh/ssh_host_rsa_key - chmod 0600 /layer/etc/ssh/ssh_host_*_key - chmod 0644 /layer/etc/ssh/ssh_host_*.pub + # Persistent home alias and explicit SSH enablement + rm -rf /layer/home + ln -sfn /var/home /layer/home + mkdir -p /layer/etc/systemd/system/multi-user.target.wants + ln -sfn /usr/lib/systemd/system/sshd.service \ + /layer/etc/systemd/system/multi-user.target.wants/sshd.service + + # BuildStream strips setuid bits from artifacts; the key-only core operator + # cannot elevate through wheel unless sudo is restored to setuid root. + chmod 4755 /layer/usr/bin/sudo # Sum actual file sizes across bind mounts (du -sb reports content bytes, not overlay blocks). ROOT_BYTES=$(du -sb /layer | cut -f1) diff --git a/files/os/issue.d/40-kubestellar.issue b/files/os/issue.d/40-kubestellar.issue index 61319dc..62a89c1 100644 --- a/files/os/issue.d/40-kubestellar.issue +++ b/files/os/issue.d/40-kubestellar.issue @@ -1,5 +1,4 @@ Bluefin Server (\l) -Default login: root / bluefin KubeStellar Console: http://\4:8080/ (or http://127.0.0.1:8080 from host) diff --git a/files/os/ssh/sshd_config.d/bluefin-server.conf b/files/os/ssh/sshd_config.d/bluefin-server.conf index a06b12d..6d8337f 100644 --- a/files/os/ssh/sshd_config.d/bluefin-server.conf +++ b/files/os/ssh/sshd_config.d/bluefin-server.conf @@ -1,7 +1,6 @@ -# SSH configuration for Bluefin Server. -# Bring-up diagnostics: permit root login with password and pubkey. - -PermitRootLogin yes +PermitRootLogin no PubkeyAuthentication yes -PasswordAuthentication yes -KbdInteractiveAuthentication yes +PasswordAuthentication no +KbdInteractiveAuthentication no +HostKey /var/lib/ssh/ssh_host_ed25519_key +HostKey /var/lib/ssh/ssh_host_rsa_key diff --git a/files/os/sudoers.d/10-wheel-nopasswd b/files/os/sudoers.d/10-wheel-nopasswd new file mode 100644 index 0000000..7c499c2 --- /dev/null +++ b/files/os/sudoers.d/10-wheel-nopasswd @@ -0,0 +1 @@ +%wheel ALL=(ALL) NOPASSWD: ALL diff --git a/files/os/systemd/system-preset/zz-enable-sshd.preset b/files/os/systemd/system-preset/zz-enable-sshd.preset deleted file mode 100644 index 50a19e7..0000000 --- a/files/os/systemd/system-preset/zz-enable-sshd.preset +++ /dev/null @@ -1,3 +0,0 @@ -# SSH is present in the image for on-demand diagnostics, but off by default. -disable sshd.service -disable sshd.socket diff --git a/files/os/systemd/system/bluefin-core-access.service b/files/os/systemd/system/bluefin-core-access.service new file mode 100644 index 0000000..530ae20 --- /dev/null +++ b/files/os/systemd/system/bluefin-core-access.service @@ -0,0 +1,9 @@ +[Unit] +Description=Verify core SSH authorization +Requires=var.mount +After=var.mount systemd-tmpfiles-setup.service +Before=sshd.service + +[Service] +Type=oneshot +ExecStart=/usr/bin/test -s /var/home/core/.ssh/authorized_keys diff --git a/files/os/systemd/system/bluefin-ssh-host-keys.service b/files/os/systemd/system/bluefin-ssh-host-keys.service new file mode 100644 index 0000000..ccadc48 --- /dev/null +++ b/files/os/systemd/system/bluefin-ssh-host-keys.service @@ -0,0 +1,10 @@ +[Unit] +Description=Generate persistent SSH host keys +Requires=var.mount +After=var.mount +Before=sshd.service + +[Service] +Type=oneshot +StateDirectory=ssh +ExecStart=/bin/bash -eu -c 'test -s /var/lib/ssh/ssh_host_ed25519_key || /usr/bin/ssh-keygen -q -N "" -t ed25519 -f /var/lib/ssh/ssh_host_ed25519_key; test -s /var/lib/ssh/ssh_host_rsa_key || /usr/bin/ssh-keygen -q -N "" -t rsa -b 3072 -f /var/lib/ssh/ssh_host_rsa_key' diff --git a/files/os/systemd/system/sshd.service.d/10-bluefin-access.conf b/files/os/systemd/system/sshd.service.d/10-bluefin-access.conf new file mode 100644 index 0000000..8657eca --- /dev/null +++ b/files/os/systemd/system/sshd.service.d/10-bluefin-access.conf @@ -0,0 +1,8 @@ +[Unit] +Requires=bluefin-ssh-host-keys.service bluefin-core-access.service +After=bluefin-ssh-host-keys.service bluefin-core-access.service + +[Service] +ExecStartPre= +ExecStartPre=/usr/bin/test -s /var/lib/ssh/ssh_host_ed25519_key +ExecStartPre=/usr/bin/test -s /var/lib/ssh/ssh_host_rsa_key diff --git a/files/os/sysusers.d/10-core-user.conf b/files/os/sysusers.d/10-core-user.conf new file mode 100644 index 0000000..ff3c5e1 --- /dev/null +++ b/files/os/sysusers.d/10-core-user.conf @@ -0,0 +1,3 @@ +u root 0 "root" /root /bin/bash +u core 1000 "Bluefin Server Operator" /var/home/core /bin/bash +m core wheel diff --git a/files/os/sysusers.d/10-root-creds.conf b/files/os/sysusers.d/10-root-creds.conf deleted file mode 100644 index 5198d84..0000000 --- a/files/os/sysusers.d/10-root-creds.conf +++ /dev/null @@ -1,2 +0,0 @@ -# Read root user hashed password from systemd credential storage -u root 0 "root" /root /bin/bash diff --git a/files/os/tmpfiles.d/10-core-home.conf b/files/os/tmpfiles.d/10-core-home.conf new file mode 100644 index 0000000..d625c81 --- /dev/null +++ b/files/os/tmpfiles.d/10-core-home.conf @@ -0,0 +1,3 @@ +d /var/home 0755 root root - +d /var/home/core 0700 core core - +d /var/home/core/.ssh 0700 core core - diff --git a/tests/unit/test_core_login_contract.py b/tests/unit/test_core_login_contract.py new file mode 100644 index 0000000..e0a59c5 --- /dev/null +++ b/tests/unit/test_core_login_contract.py @@ -0,0 +1,155 @@ +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parents[2] +SYSUSERS = ROOT / "files/os/sysusers.d/10-core-user.conf" +TMPFILES = ROOT / "files/os/tmpfiles.d/10-core-home.conf" +SUDOERS = ROOT / "files/os/sudoers.d/10-wheel-nopasswd" +STACK = ROOT / "elements/bluefin-server/os-stack.bst" + + +def test_core_operator_is_stable_key_only_and_persistent() -> None: + assert SYSUSERS.read_text(encoding="utf-8") == ( + 'u root 0 "root" /root /bin/bash\n' + 'u core 1000 "Bluefin Server Operator" /var/home/core /bin/bash\n' + "m core wheel\n" + ) + assert TMPFILES.read_text(encoding="utf-8") == ( + "d /var/home 0755 root root -\n" + "d /var/home/core 0700 core core -\n" + "d /var/home/core/.ssh 0700 core core -\n" + ) + assert SUDOERS.read_text(encoding="utf-8") == "%wheel ALL=(ALL) NOPASSWD: ALL\n" + + +def test_core_login_elements_are_composed() -> None: + depends = yaml.safe_load(STACK.read_text(encoding="utf-8"))["depends"] + assert "freedesktop-sdk.bst:components/sudo.bst" in depends + assert "bluefin-server/os-tmpfiles.bst" in depends + assert "bluefin-server/os-sudo.bst" in depends + + +SSH_CONFIG = ROOT / "files/os/ssh/sshd_config.d/bluefin-server.conf" +HOST_KEYS_SERVICE = ROOT / "files/os/systemd/system/bluefin-ssh-host-keys.service" +CORE_ACCESS_SERVICE = ROOT / "files/os/systemd/system/bluefin-core-access.service" +SSHD_DROP_IN = ROOT / "files/os/systemd/system/sshd.service.d/10-bluefin-access.conf" + + +def test_ssh_is_key_only_and_never_accepts_root() -> None: + assert SSH_CONFIG.read_text(encoding="utf-8") == ( + "PermitRootLogin no\n" + "PubkeyAuthentication yes\n" + "PasswordAuthentication no\n" + "KbdInteractiveAuthentication no\n" + "HostKey /var/lib/ssh/ssh_host_ed25519_key\n" + "HostKey /var/lib/ssh/ssh_host_rsa_key\n" + ) + + +def test_sshd_requires_persistent_host_keys_and_core_authorization() -> None: + host_keys = HOST_KEYS_SERVICE.read_text(encoding="utf-8") + access = CORE_ACCESS_SERVICE.read_text(encoding="utf-8") + drop_in = SSHD_DROP_IN.read_text(encoding="utf-8") + + assert "StateDirectory=ssh" in host_keys + assert "After=var.mount" in host_keys + assert "Before=sshd.service" in host_keys + assert "/var/lib/ssh/ssh_host_ed25519_key" in host_keys + assert "/var/lib/ssh/ssh_host_rsa_key" in host_keys + assert "After=var.mount systemd-tmpfiles-setup.service" in access + assert "ExecStart=/usr/bin/test -s /var/home/core/.ssh/authorized_keys" in access + assert "Requires=bluefin-ssh-host-keys.service bluefin-core-access.service" in drop_in + assert "ExecStartPre=" in drop_in + assert "ExecStartPre=/usr/bin/test -s /var/lib/ssh/ssh_host_ed25519_key" in drop_in + + +ELEMENTS = ROOT / "elements" +FILES_OS = ROOT / "files/os" +PRESET_DIR = FILES_OS / "systemd/system-preset" + + +def _os_stack_import_sources() -> dict[str, dict[str, str | None]]: + """Map every local source path packaged by the OS stack to its import element.""" + depends = yaml.safe_load(STACK.read_text(encoding="utf-8"))["depends"] + packaged: dict[str, dict[str, str | None]] = {} + for dep in depends: + if not isinstance(dep, str) or not dep.startswith("bluefin-server/"): + continue + data = yaml.safe_load((ELEMENTS / dep).read_text(encoding="utf-8")) + if not isinstance(data, dict) or data.get("kind") != "import": + continue + target = (data.get("config") or {}).get("target") + for source in data.get("sources") or []: + if isinstance(source, dict) and source.get("kind") == "local": + packaged[source["path"]] = {"element": dep, "target": target} + return packaged + + +def test_systemd_presets_are_packaged_by_the_os_stack() -> None: + packaged = _os_stack_import_sources() + entry = packaged.get("files/os/systemd/system-preset") + assert entry is not None, ( + "files/os/systemd/system-preset is not imported by any element in os-stack.bst" + ) + assert entry["target"] == "/usr/lib/systemd/system-preset" + assert {path.name for path in PRESET_DIR.iterdir()} == { + "zz-enable-networkd.preset", + "zz-enable-k0s-first-boot.preset", + "zz-enable-var-mount.preset", + } + + +def test_no_os_payload_file_is_orphaned_from_the_os_stack() -> None: + packaged = set(_os_stack_import_sources()) + orphans = [] + for path in sorted(FILES_OS.rglob("*")): + if not path.is_file(): + continue + rel = path.relative_to(ROOT) + covered = str(rel) in packaged or any(str(p) in packaged for p in rel.parents) + if not covered: + orphans.append(str(rel)) + assert orphans == [], f"OS payload files are not imported by any os-stack element: {orphans}" + + +DDI = ROOT / "elements/oci/bluefin-server-ddi.bst" +ISSUE = ROOT / "files/os/issue.d/40-kubestellar.issue" +SSHD_PRESET = ROOT / "files/os/systemd/system-preset/zz-enable-sshd.preset" + + +def test_ddi_restores_setuid_root_on_sudo() -> None: + ddi = DDI.read_text(encoding="utf-8") + assert "chmod 4755 /layer/usr/bin/sudo" in ddi, ( + "BuildStream strips setuid bits; the DDI must restore mode 4755 on /usr/bin/sudo " + "so the key-only core operator can elevate through wheel" + ) + + +def test_ddi_contains_no_root_credential_or_shared_host_key() -> None: + ddi = DDI.read_text(encoding="utf-8") + assert 'u root 0 "root" /root /bin/bash' in SYSUSERS.read_text(encoding="utf-8").splitlines() + assert "/etc/shadow" not in ddi + assert "bluefin123" not in ddi + assert "$6$" not in ddi + assert "root:!:" not in ddi + assert "Default login: root / bluefin" not in ISSUE.read_text(encoding="utf-8") + assert "/layer/etc/securetty" not in ddi + assert "ssh-keygen -q -N" not in ddi + assert "ln -sfn /var/home /layer/home" in ddi + assert "multi-user.target.wants/sshd.service" in ddi + assert not SSHD_PRESET.exists() + + +ROADMAP = ROOT / "docs/skills/architecture-roadmap.md" + + +def test_roadmap_scopes_the_open_credential_gap_to_network_configuration() -> None: + roadmap = ROADMAP.read_text(encoding="utf-8") + assert "root` password credential" not in roadmap + assert "root password" not in roadmap + assert "tmpfiles.extra" in roadmap, ( + "the roadmap must recognize that core SSH keys are already provisioned " + "through the tmpfiles.extra credential" + ) + assert "static network configuration" in roadmap diff --git a/tests/unit/test_vm_dashboard_contract.py b/tests/unit/test_vm_dashboard_contract.py index c18840f..4b50d56 100644 --- a/tests/unit/test_vm_dashboard_contract.py +++ b/tests/unit/test_vm_dashboard_contract.py @@ -16,8 +16,8 @@ def test_install_vm_keeps_state_and_forwards_ports() -> None: assert "qemu-system-x86_64" in recipe assert "8080-:8080" in recipe assert ( - "curl --silent --show-error --max-time 2 --output /dev/null " - "http://127.0.0.1:8080/" + "curl --silent --insecure --max-time 2 --output /dev/null " + "https://127.0.0.1:8080/" ) in recipe assert "xdg-open" in recipe assert "unattended" not in recipe