Compare commits

..

61 commits

Author SHA1 Message Date
obsidian-ai
0e99a32084 fix(v0.7): bluebuild type:copy + livemedia-creator clean resultdir
bluebuild (159): 'type: files' module fails 'chmod: Operation not
permitted' inside its own bind-mounted /tmp/modules/files/files.sh
under buildah + privileged-podman in our runner. Switch all four
`type: files` modules to `type: copy` (low-level COPY, no chmod, no
helper script needed).

installer-iso (160): livemedia-creator refused build/out which
checkout had already created (Forgejo runner reuses workspace dir
between runs). rm -rf build/out before invocation; mkdir not needed,
livemedia-creator creates the dir itself.
2026-05-07 01:55:08 +01:00
obsidian-ai
e71ccaf198 docs(ROADMAP): persist install logs to USB by default (debug mode)
Add to v0.7 scope: bootstrap ISO writes /var/log/anaconda + the
resolved ks + ostreecontainer pull log + dmesg back to the USB stick
into veilor-install-logs/<timestamp>/. Toggleable via kernel cmdline
inst.veilor.savelogs=0 for opt-out. ON by default through v0.7-v0.9;
flips OFF for v1.0 final release.

Why: failed install + bricked machine + no screenshots — operator boots
back to a working OS, plugs the USB, reads logs offline. No more
"please take a photo of dracut".
2026-05-07 01:52:31 +01:00
obsidian-ai
cfd2eb69fd ci(installer-iso): drop --title (not supported by livemedia-creator)
livemedia-creator failed with 'unrecognized arguments: --title' — that
flag is livecd-creator-only. Use --volid for the ISO volume label.
2026-05-06 23:58:56 +01:00
obsidian-ai
a06f54dd79 ci(bluebuild): --security-opt label=disable + seccomp=unconfined for files module
BlueBuild's files module fails with 'chmod: Operation not permitted' on
its own bind-mounted /tmp/modules/files/files.sh when run under podman.
Disable SELinux relabeling + seccomp filter on the bluebuild CLI
container so its nested buildah can chmod inside layer mounts.
2026-05-06 21:08:59 +01:00
obsidian-ai
e6aa2d14a8 ci(bluebuild): symlink cosign.{pub,key} to repo root for stage-keys COPY 2026-05-06 18:12:29 +01:00
obsidian-ai
5910989f12 ci: smoke-test workflow for v0.7 OCI image 2026-05-06 18:12:05 +01:00
obsidian-ai
9a087ae0da feat(ci): installer ISO workflow (v0.7 ostreecontainer path)
Add livemedia-creator --make-iso pipeline that produces a small
Anaconda installer ISO consuming a CI-buildable variant of the
runtime ostreecontainer kickstart. Disk/LUKS/user blocks dropped
from the CI ks (Anaconda interactive handles them); ostreecontainer
URL pinned to ghcr.io/veilor-org/veilor-os:43. Output split into
1900M chunks; published to Forgejo installer-latest rolling tag.
2026-05-06 18:09:38 +01:00
obsidian-ai
266090ea0d ci(bluebuild): swap 'file' cmd for 'head' (file not in veilor-build:43) 2026-05-06 18:06:58 +01:00
obsidian-ai
b4c0feb30d ci(bluebuild): -vv + diagnostic ls before bluebuild invocation 2026-05-06 18:02:02 +01:00
obsidian-ai
c152953089 ci(bluebuild): add cosign keypair signing infra
Generated a cosign keypair for v0.7 OCI signing.
- bluebuild/cosign.pub committed alongside the recipe
- cosign.key stored on operator workstation only (chmod 600)
- COSIGN_PRIVATE_KEY Forgejo Actions secret set to the same key
- Workflow stages the secret to bluebuild/cosign.key at build time
  (chmod 600), where the BlueBuild signing module picks it up
- .gitignore guards against any cosign.key accidental commit
- Restored the type:signing module in recipe.yml

The 'stage-keys' COPY step in BlueBuild's generated containerfile
fails without cosign.pub adjacent to recipe.yml even when
type:signing is removed; re-add the module + provide real keys.
2026-05-06 17:48:58 +01:00
claude-veilor-bot
4966a65e37 bluebuild(recipe): drop signing module for first green build 2026-05-06 17:30:48 +01:00
obsidian-ai
2c197796e3 ci(bluebuild): locate podman auth.json + copy to stable bind path
podman login writes to $XDG_RUNTIME_DIR/containers/auth.json by
default; that path varies and was missing. Probe known locations,
copy into /root/.config/containers/auth.json so the bind into the
bluebuild container has a stable source.
2026-05-06 17:21:21 +01:00
obsidian-ai
237968bfac bluebuild: switch base to ghcr.io/secureblue/kinoite-main-hardened
The 'securecore-kinoite-hardened-userns' image we'd been targeting
does not exist in the secureblue org's package list. Their KDE
Plasma (Kinoite) hardened variant is published as
'kinoite-main-hardened' (or 'kinoite-nvidia-hardened' for NV boxes).
Switch the recipe + all doc references.
2026-05-06 17:15:54 +01:00
obsidian-ai
f50f427ff8 ci(bluebuild): login to GHCR with PAT, share auth.json into bluebuild
GHCR rate-limited anonymous pulls (403 on bearer token). Login with
the GHCR_PULL_TOKEN secret (s8n-ru read-only PAT), then bind-mount
podman's auth.json into the bluebuild CLI container so its inner
buildah sees the same login.
2026-05-06 17:12:16 +01:00
obsidian-ai
ded80c6e15 ci(bluebuild): pre-pull secureblue base + share podman storage
GHCR rejected skopeo's anonymous manifest call from inside the
bluebuild CLI container. Pre-pull the secureblue base on the host
podman (which handles the anonymous token dance), then bind-mount
/var/lib/containers/storage into the bluebuild container so its
buildah sees the cached base layer. Drop deprecated --inspect-driver
flag while we are touching the invocation.
2026-05-06 17:09:10 +01:00
obsidian-ai
48ccabe914 ci(bluebuild): bluebuild bin lives at /usr/bin not /usr/local/bin 2026-05-06 17:06:33 +01:00
obsidian-ai
756b03aa5c ci(bluebuild): override CLI container entrypoint to bluebuild binary
Container's default entrypoint is dumb-init, which interpreted 'build'
as a command to exec rather than as a bluebuild subcommand. Pin
--entrypoint /usr/local/bin/bluebuild and pass 'build ...' as args.
2026-05-06 17:03:53 +01:00
obsidian-ai
1e70cc5461 ci(bluebuild): use ghcr.io/blue-build/cli container instead of action
The blue-build/github-action requires docker buildx which podman
doesn't ship. Symlinking podman as docker isn't enough — the action
calls 'docker buildx inspect' / 'docker buildx rm' which podman
doesn't implement. Pull the official BlueBuild CLI container and run
it with --build-driver buildah; works against podman storage with no
docker dependency.
2026-05-06 17:01:22 +01:00
obsidian-ai
9ee2cec20e ci(bluebuild): symlink podman -> docker (action needs docker CLI) 2026-05-06 16:58:50 +01:00
obsidian-ai
8926894ceb ci(bluebuild): chown /etc/sudo* to root before sudo (userns=host fix) 2026-05-06 16:56:36 +01:00
obsidian-ai
6d8164c199 ci(bluebuild): use blue-build/github-action composite (no CLI binary release)
BlueBuild CLI does not ship pre-built binaries on GitHub Releases
(latest tag v0.9.35 has no assets — install path is cargo or their
container image). Drop the curl-tarball install step and use the
official composite action @ pinned SHA — it runs podman + buildah
inside, works on Forgejo runner identically to GH-hosted because
it's bash, not node-bound.
2026-05-06 16:54:04 +01:00
obsidian-ai
bbdafbce94 ci(bluebuild): slim dnf list + install cosign from upstream binary
dnf5 in Fedora 43 strict-fails when 'already installed' packages
appear in -y install. Drop git/curl/tar/sudo (shipped in
veilor-build:43 image already) and use --skip-unavailable. cosign
isn't packaged in F43 — pull v2.4.1 static binary from upstream.
2026-05-06 16:51:17 +01:00
obsidian-ai
6391b1104b bluebuild(recipe): reconcile kickstart %post into BlueBuild modules (A2)
Walk every action in kickstart/veilor-os.ks %post and map to its
v0.7 atomic equivalent:

Build-time script additions:
- chmod +x /usr/share/veilor-os/scripts/* + /usr/local/bin/veilor-*
  (BlueBuild type:files sometimes drops perms)
- fc-cache -f after Fira Code stamping
- os-release brand override (NAME=veilor-os, ID=veilor, ID_LIKE)
- brand-leak guard: fail the image build if any onyx/personal data
  slipped through into shipped state

Layered packages:
- zram-generator (memory hygiene; replaces dnf install in kickstart)
- jq (used by veilor-doctor for `bootc status --json`)
- vim-enhanced + tmux + htop (admin essentials, parity with v0.5.x)

Systemd unit enables added:
- veilor-postinstall.service (first-login TUI; new in A3)
- veilor-doctor.timer (weekly drift check; new in A3)

Dropped: anaconda transaction_progress.py patch (build-time CI work,
not image content); SDDM display-manager symlink (kinoite ships
sddm.service already); SELinux module build (secureblue has its
own); systemctl set-default multi-user.target (kinoite is
graphical.target by design).
2026-05-06 16:50:02 +01:00
obsidian-ai
4d53d76442 docs: v0.7 user-facing docs (INSTALL-V07, STRATEGY pivot, README, CHANGELOG)
A4 inline (agent failed on API):
- docs/INSTALL-V07.md: 130-line user walkthrough — bootstrap ISO,
  Anaconda LUKS prompts, ostreecontainer pull, first-login TUI, day-
  to-day bootc-upgrade / rpm-ostree-install / bootc-rollback.
- docs/STRATEGY.md: append PIVOT EXECUTION 2026-05-06 section
  recording v0.5 ship, v0.6 cancel, v0.7 active.
- README.md: rewrite Quick install block for v0.7 path; legacy v0.5.0
  block kept below.
- CHANGELOG.md: Unreleased entry covering the spike's CI port +
  atomic CLI port + docs.
2026-05-06 16:48:48 +01:00
obsidian-ai
606806f82f overlay: atomic CLI tools for v0.7+ (bootc upgrade, postinstall, doctor)
A3 inline (agent failed on API). Three CLIs ported / written for the
v0.7+ atomic system:

veilor-update — rewritten on bootc upgrade (was dnf upgrade --refresh).
  Pre-checks bootc status, pauses auditd while staging, prints summary
  and offers reboot. Returns 0/1/2/3 per legacy contract.

veilor-postinstall (NEW) — first-login TUI run via
  veilor-postinstall.service oneshot. Asks once for keyboard, locale,
  hostname, GPU drivers, package presets (dev/media/homelab),
  bluetooth, USBGuard snapshot, then invokes veilor-doctor. Writes
  /var/lib/veilor/postinstall-complete and self-disables on success.

veilor-doctor — Updates section rewritten to parse `bootc status
  --json` (with jq) when available, falls back to dnf history /
  check-update for legacy v0.5.x kickstart-installed systems.

Plus systemd units:
  - veilor-postinstall.service (oneshot on graphical.target, gated on
    absence of done-marker, runs on tty1)
  - veilor-doctor.service + .timer (weekly drift check)
2026-05-06 16:46:59 +01:00
obsidian-ai
61fec5e1a9 ci(bluebuild): port build to Forgejo runner (nullstone label)
A1 inline (agent failed on worktree base mismatch). Adapt
build-bluebuild.yml to run on the Forgejo self-hosted runner using
the same lessons from build-iso.yml debug:

- runs-on: nullstone (resolves to veilor-build:43, fedora43+nodejs)
- BlueBuild CLI installed in-job from upstream release tarball v0.9.10
- podman/buildah/skopeo/cosign installed via dnf
- bluebuild build with podman driver + skopeo inspect + cosign signing
- Push primary to Forgejo registry git.s8n.ru/veilor-org/veilor-os
- GHCR push gated to github.server_url == 'https://github.com' only
- SBOM + attest-build-provenance gated GH-only (Forgejo has no Fulcio)
- All third-party actions remain pinned to node20-shipping versions

Secrets needed in Forgejo repo settings:
- FORGEJO_REGISTRY_TOKEN: PAT with package:write on veilor-org
- FORGEJO_REGISTRY_USER: 's8n-ru' (or org member with write scope)
2026-05-06 16:44:52 +01:00
obsidian-ai
5e94a61ea0 docs(ROADMAP): pivot — v0.6 cancelled, v0.7 BlueBuild OCI is mainline
Strategy pivot 2026-05-06: v0.5.32 produced a green ISO on Forgejo
runner. That's the kickstart-path proof point. Continuing v0.6
kickstart polish is sunk-cost work on tooling retired at v1.0.

Pivot:
- v0.5.0 is the FINAL kickstart-path release. Tag, freeze, ship.
- v0.6 cancelled as a milestone. Original plan kept inline as
  HISTORICAL reference.
- v0.7 promoted to primary active milestone. Absorbs the v0.6
  ergonomic CLI tools (veilor-postinstall / veilor-doctor /
  veilor-update) with bootc upgrade replacing dnf upgrade.
- Active branch: v0.7-bluebuild-spike. All future feature work lands
  there, not on main.
2026-05-06 16:10:03 +01:00
obsidian-ai
d48e59f05b docs: add PROOF-OF-WORK.md — receipts of work, tooling, and decisions
Single document that surfaces the depth of work behind veilor-os:
metrics, distros studied, every tool traversed in the build chain,
all 35+ failure classes hit and beaten, key engineering decisions and
why, what's in the repo beyond the kickstart, and the self-hosted
nullstone CI infrastructure built to support it.

Receipts not narrative — every claim links back to a file path,
commit, error, or config. Useful as portfolio anchor and as a single
read-this-first for anyone returning to the project after a gap.
2026-05-06 16:10:03 +01:00
obsidian-ai
ecd374ab1a ci: gate cosign/sbom/attest steps to github only
cosign keyless sign uses Sigstore Fulcio which requires a
Fulcio-trusted OIDC issuer. Forgejo runs don't have one, so cosign
falls back to the interactive device flow and times out
(error obtaining token: expired_token). Same applies to
attest-build-provenance and the SBOM action's signed attestation.

Skip all three on Forgejo for now; ISO + sha256 are sufficient for
v0.5.x test releases. Re-add when we self-host a Sigstore stack or
sign with a key-pair instead of keyless.
2026-05-06 16:10:03 +01:00
obsidian-ai
e17c04007d docs(README): tone down secureblue credit (no code lifted yet)
We layer on their OCI image as v0.7 base; we don't redistribute their
source. Drop the AGPLv3-attribution prose — that becomes relevant only
if/when we ship a verbatim chunk of their config/policy in our repo.
2026-05-06 16:10:03 +01:00
obsidian-ai
97939d76f8 docs(README): add secureblue column + upstream credit section
secureblue (AGPLv3) is the upstream hardened atomic Fedora that the
v0.7 BlueBuild spike layers on top of. Comparison table now includes
secureblue alongside Kicksecure + stock Fedora KDE. New "Credit &
relationship to secureblue" section spells out where their work
already solves problems we don't need to reinvent (Trivalent,
SELinux policy, kernel cmdline, signed OCI), how veilor-os differs
(kickstart install path + branding + Forgejo CI), and the AGPLv3
attribution rule for any code we lift verbatim.
2026-05-06 16:10:03 +01:00
obsidian-ai
abaff9d3c3 ci: symlink /work -> GITHUB_WORKSPACE for ks %post SRC probe 2026-05-06 16:10:03 +01:00
obsidian-ai
29a6677d54 ks: drop apparmor-* packages — not in Fedora 43 repos
Build error: 'Failed to find package apparmor-parser : No match for
argument'. Fedora 43 base and updates do not ship AppArmor packages;
the prior comment was incorrect. Defer AppArmor to v0.7 secureblue OCI
hybrid (which has its own LSM stack), or land via COPR overlay later.
2026-05-06 16:10:03 +01:00
obsidian-ai
b3572565e2 ci: run build directly in Fedora job container, drop addnab nest
forgejo-runner labels nullstone -> fedora:43 image. Switching
runs-on: ubuntu-24.04 -> nullstone makes the job container itself
the build environment, eliminating the docker-in-docker workspace
bind-mount problem (host path != act-container path).

Build now runs as root in fedora:43, installs livecd-tools directly
via dnf, and writes outputs to $GITHUB_WORKSPACE which is the natural
runner workdir on host. No nested docker, no userns juggling, no
explicit -v workspace bind needed.
2026-05-06 16:10:03 +01:00
obsidian-ai
9bf063a178 ci: add /work diagnostic before sed-redirect to surface bind/perm issue 2026-05-06 16:10:03 +01:00
obsidian-ai
3f138e7435 ci: repin fedora:43 build container to amd64 digest
Prior pin was the arm64 manifest digest (linux/arm64/v8); on x86_64
host it failed with `exec /usr/bin/sh: exec format error`. Pinned to
the amd64 manifest entry from the same fat-manifest.
2026-05-06 16:10:03 +01:00
obsidian-ai
7d6054311b ci: add --userns=host to nested Fedora build container
Forgejo runner on nullstone runs against a daemon with
userns-remap=default. addnab/docker-run-action launches the Fedora 43
build container with --privileged, which is incompatible with
userns-remap unless --userns=host is also set.
2026-05-06 16:10:03 +01:00
obsidian-ai
6b0828d692 ci: pin sbom/cosign/attest actions to node20-safe versions
forgejo-runner v6.4.0 ships node20; floating tags @v0/@v3/@v2 now
resolve to actions whose runs.using=node24, which the runner cannot
exec. Pin to last node20-shipping release of each:

- anchore/sbom-action@v0.17.2
- sigstore/cosign-installer@v3.7.0
- actions/attest-build-provenance@v2.2.3
2026-05-06 16:10:03 +01:00
s8n
a59f1f026a ci: gate softprops release steps + add Forgejo API equivalents
The build-iso workflow used softprops/action-gh-release@v2 unconditionally,
which only speaks the GitHub Releases REST API. When the workflow runs on
the Forgejo runner registered on nullstone, those steps would fail.

Add a server_url check so the GH-only path runs only on github.com, and
mirror it with a curl-based step that hits the Forgejo /api/v1/releases
endpoints. Behaviour:
  - github.com: identical to before (action-gh-release@v2).
  - git.s8n.ru: drop+recreate ci-latest release, upload chunked assets
                via the Forgejo attachments API.

Tag-driven "Attach to release" path mirrored the same way.

Refs: A1 build-eng task — Forgejo runner adaptation.
2026-05-06 16:10:03 +01:00
veilor-org
beef32a77c feat(installer): promote eject-media reminder to its own box
In v0.5 the "Remove the install media" reminder was a single line
inside the green success box, and operators on both onyx and the
friend's RTX 4080 rig missed it — rebooted into the live ISO and
re-ran the installer thinking the install had silently failed.

Promote the reminder to its own loud yellow thick-bordered gum-style
box stacked directly below the success/countdown box, with three
lines of explanation. Renders for the full 10s of the countdown so
it stays in the operator's face the entire window.
2026-05-06 16:10:03 +01:00
veilor-org
0a70eea950 feat(installer): 10s reboot countdown with per-tick redraw
v0.5 used `sleep 5` after a static "System will reboot in 5 seconds."
box, which left the operator guessing how much time was left to grab
the USB stick. The new loop runs 10 → 1, clearing + redrawing the
gum-style success box each tick with the remaining-seconds figure,
giving the operator a visible window to act.

10 seconds (vs 5) because real hardware operators were missing the
window — laptops with the USB on the far side of the dock take
4-5 seconds to physically reach. 10 is comfortable, not annoying.
2026-05-06 16:10:03 +01:00
veilor-org
877ad91096 feat(installer): confirm-twice for LUKS passphrase + admin password
A typo in the LUKS passphrase is unrecoverable — the disk is
unmountable without it and we don't escrow the key. Re-prompting
until the two reads match catches keyboard-layout surprises (the
US/UK quote-key position is the most common one) before they brick
the install.

Admin password gets the same treatment for consistency. Less
catastrophic (resettable from a recovery shell) but a mismatch
still locks the user out of their fresh install on first boot.

Loop bails on cancel/ESC and re-prompts on validate_pw failure.
2026-05-06 16:10:03 +01:00
veilor-org
a3b3d29b38 feat(installer): staged banner reveal at 40ms/line
Read banner.txt line by line with a 40ms sleep between each, then
clear and redraw the bordered gum-style version. 5-line banner ×
40ms = 200ms total reveal — slow enough to land an aesthetic on the
first frame, fast enough that the operator never feels it as lag.

Pure cosmetic; no functional change to the install flow.
2026-05-06 16:10:03 +01:00
veilor-org
55221a6af2 fix(installer): swap gum input --password for bash read -srp
`gum input --password` corrupts the linux fbcon since v0.5.27 — the
bubbletea screen-restore writes back the previous menu buffer because
the framebuffer terminfo entry lacks `civis/cnorm` cursor-hide
sequences, leaving a duplicate "Install" plus a stray "T" rendered on
top of the password field. The fix is a single termios echo-off via
`read -srp`: no redraw, no glitch, no dependency on gum's TUI layer
for the one screen where it broke.

Header still rendered through `gum style` so visual parity with the
disk picker / confirm box is preserved. Whiptail fallback path
unchanged (passwordbox there has always rendered cleanly).
2026-05-06 16:10:03 +01:00
s8n
d76597c57a sec: AppArmor v0.6 stub — load profiles in complain mode
Per docs/research/2026-05-05-agent-wave/04-hardening-tier-2.md (v0.6
scope item 1).

Adds:
  - apparmor-parser apparmor-utils apparmor-profiles to %packages in
    BOTH kickstart/veilor-os.ks (live ks) and overlay/usr/local/bin/
    veilor-installer (generated install ks heredoc).
  - scripts/40-apparmor.sh — wires aa-complain on every veilor-shipped
    profile. Idempotent. "loaded, present, nothing breaks".
  - overlay/etc/apparmor.d/veilor.d/firefox — 1-liner stub (binary
    confinement marker only; full policy post-v0.6).
  - overlay/etc/apparmor.d/veilor.d/thunderbird — same pattern.
  - Wired 40-apparmor.sh into install %post chain after
    30-apply-v03-theme.sh.

Complain mode means: profiles loaded, kernel logs syscall denials but
does NOT enforce. Operator can review audit.log post-install to
inform v0.7 policy authoring.
2026-05-06 16:10:03 +01:00
veilor-org
631e7bd040 ci: TODO marker for SHA-pinning third-party actions
Note that all `uses:` directives still resolve to mutable major-
version tags. SHA-pinning is the Agent 8 audit recommendation but
requires per-action web lookups that stalled the previous SRE
attempt; tracked separately so this PR can land first.
2026-05-06 16:10:03 +01:00
veilor-org
9158532c9d ci: pin fedora:43 base image to digest
Pin registry.fedoraproject.org/fedora:43 to its current manifest
digest so a malicious or accidental tag-rewrite upstream cannot
silently change the base layer of every CI build. Digest was
captured via `skopeo inspect --raw` on 2026-05-06. Refresh
procedure documented inline.
2026-05-06 16:10:03 +01:00
veilor-org
e93ef644e1 ci: add cosign keyless sigs, SBOM, and provenance attestation
Sign each ISO chunk with cosign keyless OIDC, generate an SPDX SBOM
of the build output, and attach an in-toto build-provenance
attestation. Sigs/certs/SBOM are uploaded alongside the ISO parts in
the ci-latest rolling prerelease so the test/auto-install.sh path
can verify before reassembling.

Action versions are major-version tags (@v3, @v0, @v2). SHA-pinning
is tracked separately to keep this PR small and avoid the long web
lookups that stalled the previous attempt.
2026-05-06 16:10:03 +01:00
obsidian-ai
21f2b4da9a ci: pin actions to node20-safe tags + runner sock pass-through
forgejo-runner v6.4.0 ships a node20 javascript engine. v4.2+ of
actions/checkout and v2.0.5+ of softprops/action-gh-release moved to
node24, which the runner refuses to exec. Pin both to last node20
release.

Pairs with a runner-side config change (separately deployed on
nullstone /home/docker/forgejo-runner/conf/config.yaml) that adds
`-v /var/run/docker.sock:/var/run/docker.sock` to per-job container
options + whitelists the socket via valid_volumes — without that
addnab/docker-run-action@v3 inside the catthehacker/ubuntu job
container can't reach the docker engine.

- actions/checkout v4 -> v4.1.7
- softprops/action-gh-release v2 -> v2.0.4
- addnab/docker-run-action v3 unchanged (composite/docker, no node)
- ludeeus/action-shellcheck@master unchanged (docker-based)
2026-05-06 16:10:03 +01:00
s8n
91d5d26473 sec: polish THREAT-MODEL.md for v0.7 public launch
Status flipped Draft → Final.

In-scope rows now cite specific config files / settings (auditable
from clean checkout):
  - LUKS2 params from kickstart/veilor-os.ks
  - sysctl knobs file path
  - USBGuard policy mode + rule type
  - sshd_config drop-in path + every directive
  - auditd rule path + watched paths
  - chrony NTS endpoints
  - systemd-resolved DoT settings
  - bootloader kernel args (lockdown, slab_nomerge, init_on_alloc/free, etc.)

Out-of-scope rows un-hedged. 'May not always' phrasings removed; each
adversary states unambiguously what veilor-os does NOT do.
2026-05-06 16:10:03 +01:00
veilor-org
c7c0a0bcc8 docs: test run report skeleton for v0.5.32 (Forgejo build)
First test-runs/ report off the new template. Records the build host
(forgejo-runner on nullstone, ubuntu-24.04 / catthehacker:act-24.04),
notes that v0.5.32 is the first ISO produced after the GH Actions
mirror was disabled, and pre-populates the Findings section with the
7 v0.5.32 blocker fixes from the 2026-05-05 9-agent wave as expected
behaviours the tester must verify.

Result is left as "pending A1 build" — the operator + A5 fill in
per-step pass/fail and hardening output once the actual VM walkthrough
runs against the produced ISO. This is intentional: the report is the
scaffold; the test is a separate step.
2026-05-06 16:10:03 +01:00
s8n
9011fd2dbf docs: README de-GH + Forgejo build status 2026-05-06 16:10:03 +01:00
s8n
20b3541d38 docs: METHOD-CHANGELOG 2026-05-06 forgejo entry 2026-05-06 16:10:03 +01:00
veilor-org
1fa45c3749 chore: gitignore auto-install-vm test artifacts
Test rig was leaking test/auto-install-vm.{nvram,qcow2} into untracked
state. Pattern matches existing veilor-vm.* exclusion.
2026-05-06 16:10:03 +01:00
veilor-org
d9b206e46b docs: STRATEGY.md — primary git host moved to git.s8n.ru (Forgejo)
Self-hosted Forgejo + forgejo-runner on nullstone now primary.
GitHub becomes public mirror (Forgejo push-mirrors every commit
+ every 8h). 0 GH Actions minutes consumed.

Runner labels:
  ubuntu-24.04 — drop-in for existing build-iso.yml workflow
  nullstone    — privileged Fedora 43 (opt-in via runs-on: nullstone)

Deploy artifacts: ~/ai-lab/nullstone-server/forgejo/.

External TODO (parent operator owns):
  - router port-forward 222 → nullstone:222 for public SSH push
  - no-guest@file allowlist update for external web UI access
2026-05-06 16:10:03 +01:00
veilor-org
89949dc8f2 v0.5.32: ship 7 blockers from 9-agent wave
Per docs/research/2026-05-05-agent-wave/README.md priority list.
All 7 land together to keep iteration cycles useful — partial fixes
bury the lookahead findings agents already mapped.

## 1. CRITICAL — suspend/resume wifi death (Agent 9, B2)

`veilor-modules-lock.service` runs `kernel.modules_disabled=1` 30s
after graphical.target. iwlwifi/iwlmvm/cfg80211 reload on resume
from S3/S0ix → with modules locked, resume breaks wifi until
reboot. Same architectural class as the LUKS bug — security feature
breaks legitimate kernel state transitions.

The unit already has `ConditionKernelCommandLine=!module.sig_enforce=1`
(self-skip when signed-modules enforcement is on cmdline). Adding
`module.sig_enforce=1` to the kernel cmdline retains the security
property (no unsigned modules) without runtime lock-down → resume
works.

Files: kickstart/veilor-os.ks line 61 + overlay/usr/local/bin/veilor-installer
generated bootloader directive both gain `module.sig_enforce=1`.

## 2. veilor-firstboot.service WantedBy=graphical.target (Agent 2)

Was `WantedBy=multi-user.target` only. Real installs default to
graphical.target so the unit never ran on installed systems — admin
pw stayed at install-time + chage -d 0 expired, SDDM PAM bounced
to chauthtok screen (recoverable but ugly UX).

Now `WantedBy=graphical.target multi-user.target`. Live ISO +
multi-user installs both resolve via this list.

## 3. USBGuard hash → id-based baseline (Agent 9, A3)

Mirrors memory feedback_usbguard_dock.md — onyx had hash+parent-hash
rules that broke on dock replug; we shipped no rules.conf so first
boot blocks the USB keyboard.

Adds overlay/etc/usbguard/rules.conf with HID-class allow rule
(`allow with-interface match-all { 03:*:* }`) — covers every USB
keyboard, mouse, gamepad, fingerprint reader, NFC. Survives dock
replug + kernel-bump vendor renumeration. Mass-storage stays
implicit-block; user explicitly allows post-firstboot via
`ujust veilor-usbguard-enroll` (planned v0.6).

## 4. firewalld trusted zone with tailscale0 pre-bound (Agent 9, D1)

User uses Tailscale daily (memory: project_tailscale_mesh.md).
Default firewalld zone = drop, blocks tailnet traffic on tailscale0.

Adds overlay/etc/firewalld/zones/trusted.xml with
`<interface name="tailscale0"/>`. After `tailscale up` brings the
interface up, NetworkManager dispatcher associates it with the
trusted zone automatically — no user intervention.

Default zone stays drop. Only the tailscale0 interface gets ACCEPT.

## 5. /etc/skel branding (Agent 7)

Was completely empty. Result: per-user KDE config (~/.config/kdeglobals
etc.) pre-empty, so the moment user opened System Settings, KDE wrote
fresh ~/.config/* and silently shadowed our /etc/xdg/kdedefaults/*.
Visual brand evaporated on first click.

Seeds:
  /etc/skel/.config/kdeglobals    (copy of assets/kde/veilor-default.kdeglobals)
  /etc/skel/.config/breezerc      (copy of assets/kde/breezerc)
  /etc/skel/.config/kwinrc        (Plasma 6 wayland defaults: opengl, animspeed=0,
                                    blur off, click-to-focus)
  /etc/skel/.config/konsolerc     (default profile = Veilor)
  /etc/skel/.local/share/konsole/Veilor.profile + .colorscheme

User who opens System Settings now writes against branded baseline,
not against vanilla Breeze.

## 6. KMS modeset args + initramfs keymap (Agents 1 + 9)

Real laptop boot has a 5-15s blank between vt switch and SDDM start
because simpledrm releases before i915/nvidia-drm/amdgpu claim. Plus
non-US users get locked out at LUKS prompt because initramfs ships
en-US keymap by default (RHBZ 1405539, RHBZ 1890085).

Adds to bootloader cmdline (live + installed):
  i915.modeset=1 amdgpu.modeset=1 nvidia-drm.modeset=1
  rd.vconsole.keymap=us

`rd.vconsole.keymap=us` is a placeholder; the v0.6 firstboot keymap
picker will rewrite it from /etc/vconsole.conf. Until then, en-US
users get correct LUKS keyboard; non-US users still need the v0.6
fix (per Agent 1).

## 7. virtio-9p log capture (Agent 6)

The v0.5.30 virtio-serial wiring depends on rsyslog inside the live
ISO (anaconda's setupVirtio writes a rsyslog forward rule), which
the live ks doesn't install — files were 0-byte across three
install runs.

test/run-vm.sh now adds a `-virtfs local,...,mount_tag=hostlogs`
share pointing at `test/test-runs/<timestamp>/`. veilor-installer
runs `_dump_logs_to_host` via EXIT trap that mounts the share at
/mnt/hostlogs and rsyncs /tmp/{anaconda,program,storage,packaging,dnf}.log
+ /var/log/veilor-installer.log + dmesg + journalctl + the generated
ks. Runs on success AND failure AND ^C.

No-op on real hardware (9p tag absent) — VM-only debug.

## Validate

  bash -n overlay/usr/local/bin/veilor-installer  # OK
  ksvalidator kickstart/veilor-os.ks               # clean

## Out-of-scope for v0.5.32 (deferred to v0.6)

Per Agent 1 follow-ups: argon2id retune for slow CPUs, recovery key
generation in firstboot, TPM2/FIDO2 unlock helpers. Per Agent 9
follow-ups: Plasma Wayland fallback X11 install, lid-close handling,
SELinux relabel progress UX. Per Agent 4: AppArmor stack +
nftables preset + audit log shipping CLI.

Per Agent 8 (CI hardening): SHA-pin actions + dependabot + SBOM +
SLSA L3 attestation — separate workflow-only commit.
2026-05-06 16:10:03 +01:00
s8n
0b568b016b Merge pull request 'ci(bluebuild): pin actions to node20-safe tags' (#9) from feat/runner-fix-node20-pinning into v0.7-bluebuild-spike 2026-05-06 13:54:31 +01:00
obsidian-ai
e50c9a3b43 ci(bluebuild): pin actions to node20-safe tags
forgejo-runner v6.4.0 javascript runtime is node20. Pin every
javascript action used in the spike branch's workflows to the last
release that ships node20.

- actions/checkout v4 -> v4.1.7 (3 files)
- softprops/action-gh-release v2 -> v2.0.4 (build-iso)
- anchore/sbom-action v0 -> v0.17.2
- actions/attest-build-provenance v2 -> v2.2.3
- blue-build/github-action@v1 unchanged (TODO: SHA pin)

This is the spike-branch counterpart of the main-branch fix in
feat/runner-fix-docker-sock-and-node20.
2026-05-06 13:54:12 +01:00
s8n
9dc2846316 Merge pull request 'ci(bluebuild): pin blue-build/github-action to commit SHA' (#6) from feat/a1-bluebuild-pin into v0.7-bluebuild-spike 2026-05-06 13:53:15 +01:00
s8n
f2e36bfead ci(bluebuild): pin blue-build/github-action to commit SHA
Replace @v1 with @24d146df25adc2cf579e918efe2d9bff6adea408 (the commit
v1 currently resolves to). Tag pins on third-party actions are mutable
— a maintainer or attacker can re-point v1 at a malicious commit and
silently change what runs on every push.

Trailing comment '# v1' preserves human readability for future bumps.

Refs: 9-agent CI hardening wave (agent 8), 2026-05-05.
2026-05-06 10:32:13 +01:00
veilor-org
3c247bc601 v0.7 spike: BlueBuild recipe + ostreecontainer kickstart + cosign workflow
Initial scaffold for the v0.7 hybrid path. Spike branch only — does
NOT land in main until success criteria pass (see bluebuild/README.md).

## What this commits

- bluebuild/recipe.yml — BlueBuild recipe extending
  ghcr.io/secureblue/securecore-kinoite-hardened-userns:latest with:
  * veilor branding overlay (overlay/, assets/, scripts/ at /usr/share/veilor-os)
  * sudo restored (revert secureblue's run0-only)
  * Xwayland restored (some apps still need it)
  * mullvad-browser layered alongside Trivalent (default browser kept)
  * tailscale + yggdrasil packages (mesh stack layers 1 + 2)
  * tailscaled.service pre-disabled (awaits first-boot prompt)
  * yggdrasil.service enabled (idle warm-fallback per STRATEGY.md)
  * veilor-firstboot.service + veilor-modules-lock.service enabled
  * cosign signing module configured

- bluebuild/config/just/60-veilor.just — ujust recipes:
  * install-reticulum (RetiNet AGPL fork — mesh layer 3)
  * install-reticulum-rnode (LoRa hardware)
  * install-thorium (opt-in browser with explicit CVE-lag warning)
  * veilor-mesh-join (token paste / QR for tailscale onboarding)

- bluebuild/README.md — spike doc + smoke-test commands + 5-item
  success criteria checklist

- kickstart/install-ostreecontainer.ks — install kickstart template
  for the v0.7 path. No %packages block; uses
  `ostreecontainer --url=ghcr.io/veilor-org/veilor-os:43 --transport=registry`
  to populate / from the OCI image directly during anaconda's install
  pass. No first-boot rebase, no transition window. Keeps existing
  LUKS+btrfs partitioning verbatim.

- .github/workflows/build-bluebuild.yml — GH Actions workflow:
  * Triggered on push to v0.7-bluebuild-spike, weekly cron, dispatch
  * Uses blue-build/github-action@v1 (TODO: pin to commit SHA per
    CI hardening agent 8 follow-up)
  * Builds + cosign-signs (keyless via Sigstore) + pushes to GHCR
  * Smoke-tests the OCI image (sudo, mullvad-browser, yggdrasil,
    tailscale all present)
  * Generates SBOM (SPDX) via anchore/sbom-action
  * Publishes SLSA build provenance attestation

## What this does NOT change

- main branch is untouched. v0.5.x kickstart path keeps shipping.
- kickstart/veilor-os.ks (the live-ISO ks) is untouched — the v0.7
  hybrid uses the existing live-ISO build path; only the install-time
  ks (install-ostreecontainer.ks) is new.
- overlay/, scripts/, assets/ are untouched on this branch — the
  recipe pulls them in via `type: files` modules at build time.

## Spike success criteria (reproduced from bluebuild/README.md)

- [ ] `bluebuild build recipe.yml` exits 0
- [ ] `bootc container lint` exits 0 on resulting image
- [ ] `podman run` smoke-test passes
- [ ] CI workflow builds + cosign-signs + pushes to GHCR
- [ ] Installer ISO using `ostreecontainer` against this OCI reaches
      SDDM with admin login on first boot

If all 5 land, merge v0.7-bluebuild-spike → main as v0.7.0.

## Reference

- docs/STRATEGY.md (full plan)
- docs/ROADMAP.md v0.7 (schedule)
- docs/THREAT-MODEL.md (publish before v0.7 ship)
- secureblue: https://github.com/secureblue/secureblue
- BlueBuild: https://blue-build.org
- ostreecontainer: https://docs.fedoraproject.org/en-US/bootc/anaconda-install/
2026-05-05 15:30:04 +01:00
22 changed files with 1539 additions and 85 deletions

265
.github/workflows/build-bluebuild.yml vendored Normal file
View file

@ -0,0 +1,265 @@
name: Build veilor-os OCI (BlueBuild)
# v0.7 spike — builds the bootable OCI image used by the bootstrap
# kickstart's `ostreecontainer` directive. Runs on the Forgejo
# self-hosted runner (label `nullstone`); GitHub-side cosign/SBOM/
# attest steps are gated off because Forgejo has no Sigstore Fulcio-
# trusted OIDC issuer (see docs/PROOF-OF-WORK.md, build-iso.yml fix).
#
# Reference: https://blue-build.org/how-to/setup-build-action/
on:
push:
branches: [v0.7-bluebuild-spike]
paths:
- 'bluebuild/**'
- 'overlay/**'
- 'assets/**'
- 'scripts/**'
- '.github/workflows/build-bluebuild.yml'
pull_request:
branches: [main, v0.7-bluebuild-spike]
schedule:
# Rebuild weekly so we pick up upstream secureblue + Fedora updates.
- cron: '0 6 * * 1'
workflow_dispatch:
permissions:
contents: read
jobs:
build:
name: Build + push OCI
# nullstone label resolves to veilor-build:43 (fedora43 + nodejs)
# via runner config. Privileged + userns=host + sock pass-through
# already wired in the runner config (see infra/forgejo/).
runs-on: nullstone
timeout-minutes: 60
permissions:
contents: read
packages: write
id-token: write # for GH-only cosign keyless (skipped on Forgejo)
attestations: write
env:
# Forgejo container registry path. PAT in FORGEJO_REGISTRY_TOKEN
# secret has package:write on veilor-org.
FORGEJO_REGISTRY: git.s8n.ru
FORGEJO_IMAGE: git.s8n.ru/veilor-org/veilor-os
OCI_TAG: "43"
# GH parallel target — only used when run on github.com.
GHCR_IMAGE: ghcr.io/${{ github.repository_owner }}/veilor-os
steps:
- name: Checkout
# Pinned to last v4 tag confirmed to ship on node20.
uses: actions/checkout@v4.1.7
- name: Fix sudo perms (userns=host artefact)
run: |
# Daemon has userns-remap=default; the act job container is
# launched with --userns=host. The image was pulled under
# remap so /etc/sudo.conf + /etc/sudoers ship as uid 100000.
# sudo refuses to read either unless owned by uid 0. Restore.
chown -R 0:0 /etc/sudo.conf /etc/sudoers /etc/sudoers.d 2>/dev/null || true
ls -la /etc/sudo.conf /etc/sudoers 2>&1 | head -5
- name: Install build tooling (Fedora)
run: |
set -euxo pipefail
dnf -y upgrade --refresh
# veilor-build:43 already ships git, curl, tar, sudo, nodejs.
# cosign is not packaged in Fedora 43; we install it from the
# upstream release tarball below in a separate step.
dnf -y install --skip-unavailable \
podman \
buildah \
skopeo \
jq
# blue-build/github-action shells out to `docker`; Fedora ships
# podman. Symlink so the action finds the CLI.
if ! command -v docker >/dev/null; then
ln -sf "$(command -v podman)" /usr/local/bin/docker
docker --version
fi
- name: Install cosign binary (upstream release)
run: |
set -euxo pipefail
# Fedora 43 has no cosign rpm. Pull static x86_64 binary
# from sigstore/cosign GitHub releases. Pinned to v2.4.1.
COSIGN_VERSION="2.4.1"
curl -fsSL \
"https://github.com/sigstore/cosign/releases/download/v${COSIGN_VERSION}/cosign-linux-amd64" \
-o /usr/local/bin/cosign
chmod +x /usr/local/bin/cosign
cosign version
- name: Pre-pull secureblue base image
env:
GHCR_PULL_TOKEN: ${{ secrets.GHCR_PULL_TOKEN }}
run: |
set -euxo pipefail
# GHCR rate-limits anonymous CI pulls (403 on bearer-token).
# Login with a read-only PAT (forgejo secret GHCR_PULL_TOKEN)
# so bluebuild's buildah inside the CLI container also sees a
# valid auth.json via shared storage bind-mount below.
if [ -n "${GHCR_PULL_TOKEN:-}" ]; then
echo "$GHCR_PULL_TOKEN" | podman login \
--username s8n-ru \
--password-stdin ghcr.io
else
echo "[WARN] GHCR_PULL_TOKEN secret empty; trying anonymous pull"
fi
podman pull ghcr.io/secureblue/kinoite-main-hardened:latest
- name: Stage cosign private key for signing module
env:
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
run: |
set -euo pipefail
if [ -z "${COSIGN_PRIVATE_KEY:-}" ]; then
echo "[ERR] COSIGN_PRIVATE_KEY secret missing"
exit 1
fi
# bluebuild signing module reads from this env var when
# building the cosign.key bind stage. Also write to bluebuild/
# so it sits next to cosign.pub for local reproducible runs.
mkdir -p bluebuild
printf '%s' "$COSIGN_PRIVATE_KEY" > bluebuild/cosign.key
chmod 600 bluebuild/cosign.key
# bluebuild's generated Containerfile uses `FROM scratch as
# stage-keys; COPY cosign.pub /keys/`. Buildah's build context
# is the cwd ($PWD) — symlink the keys to repo root so COPY
# finds them there too.
ln -sf bluebuild/cosign.pub cosign.pub
ln -sf bluebuild/cosign.key cosign.key
ls -la cosign.pub cosign.key 2>&1 | head -4
- name: Build OCI image with BlueBuild CLI container
id: bluebuild
# blue-build/github-action requires docker buildx which podman
# doesn't ship. Run the official BlueBuild CLI container with
# buildah driver instead — works against rootless or rootful
# podman, no docker dependency.
run: |
set -euxo pipefail
# Pull cli image; pinned to v0.9.x at action time.
podman pull ghcr.io/blue-build/cli:latest
# Mount the repo + podman socket; build with buildah driver.
# Bind host /var/lib/containers/storage into the bluebuild
# CLI container so buildah inside it can see the pre-pulled
# secureblue base layer (avoids GHCR auth round-trip during
# templating).
# podman login writes to $XDG_RUNTIME_DIR/containers/auth.json
# by default, which is volatile. Find it + copy to a stable
# path that we then bind into the bluebuild container.
AUTH_SRC=""
for cand in \
"${XDG_RUNTIME_DIR:-/run/user/0}/containers/auth.json" \
"/run/containers/0/auth.json" \
"/root/.config/containers/auth.json" \
"/root/.docker/config.json"; do
if [ -f "$cand" ]; then AUTH_SRC="$cand"; break; fi
done
if [ -z "$AUTH_SRC" ]; then
echo "[ERR] no podman/docker auth.json found post-login"
find / -name auth.json -o -name 'config.json' 2>/dev/null | head -10
exit 1
fi
mkdir -p /root/.config/containers
cp "$AUTH_SRC" /root/.config/containers/auth.json
ls -la /root/.config/containers/auth.json
# Diagnostic: confirm the keypair landed where bluebuild expects.
ls -la bluebuild/
head -1 bluebuild/cosign.pub
head -1 bluebuild/cosign.key | cut -c1-30
podman run --rm \
--privileged \
--security-opt label=disable \
--security-opt seccomp=unconfined \
--entrypoint /usr/bin/bluebuild \
-v "$PWD:/work" \
-v /var/lib/containers/storage:/var/lib/containers/storage \
-v /root/.config/containers/auth.json:/root/.config/containers/auth.json:ro \
-w /work \
-e BB_BUILD_DRIVER=buildah \
ghcr.io/blue-build/cli:latest \
build \
--build-driver buildah \
-vv \
bluebuild/recipe.yml
# bluebuild CLI tags as <recipe-name>:<tag> in local podman
# storage. List + verify, then re-tag for the registries.
podman images
podman tag localhost/veilor-os:latest "${FORGEJO_IMAGE}:${OCI_TAG}" || true
podman tag localhost/veilor-os:latest "${FORGEJO_IMAGE}:latest" || true
- name: Push to Forgejo registry (primary)
if: success() && github.event_name != 'pull_request' && github.server_url != 'https://github.com'
env:
FORGEJO_REGISTRY_TOKEN: ${{ secrets.FORGEJO_REGISTRY_TOKEN }}
FORGEJO_REGISTRY_USER: ${{ secrets.FORGEJO_REGISTRY_USER }}
run: |
set -euo pipefail
if [ -z "${FORGEJO_REGISTRY_TOKEN:-}" ]; then
echo "[WARN] FORGEJO_REGISTRY_TOKEN secret is empty; skipping push"
exit 0
fi
echo "$FORGEJO_REGISTRY_TOKEN" | podman login \
--username "${FORGEJO_REGISTRY_USER:-veilor-org}" \
--password-stdin "$FORGEJO_REGISTRY"
podman push "${FORGEJO_IMAGE}:${OCI_TAG}"
podman push "${FORGEJO_IMAGE}:latest"
echo "[OK] pushed ${FORGEJO_IMAGE}:{${OCI_TAG},latest}"
- name: Push to GHCR (mirror, GitHub-only)
if: success() && github.event_name != 'pull_request' && github.server_url == 'https://github.com'
run: |
set -euo pipefail
podman tag localhost/veilor-os:latest "${GHCR_IMAGE}:${OCI_TAG}"
podman tag localhost/veilor-os:latest "${GHCR_IMAGE}:latest"
echo "${{ secrets.GITHUB_TOKEN }}" | podman login \
--username "${{ github.repository_owner }}" \
--password-stdin ghcr.io
podman push "${GHCR_IMAGE}:${OCI_TAG}"
podman push "${GHCR_IMAGE}:latest"
- name: Smoke-test OCI image
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
run: |
set -euxo pipefail
podman run --rm "localhost/veilor-os:latest" /bin/bash -c '
set -e
echo "-- os-release"
head -5 /etc/os-release
echo "-- sudo present"; which sudo
echo "-- mullvad-browser path"; rpm -q mullvad-browser || echo "not installed"
echo "-- yggdrasil"; rpm -q yggdrasil || echo "not installed"
echo "-- tailscale"; rpm -q tailscale || echo "not installed"
echo "-- veilor-firstboot unit"; ls -la /etc/systemd/system/veilor-firstboot.service 2>&1 || true
'
# ── GitHub-only signing/SBOM/attest ────────────────────────────
# cosign keyless needs Sigstore Fulcio-trusted OIDC. Forgejo
# has none, so these are GH-only. v0.7+ TODO: cosign key-pair
# signing for Forgejo using a stored secret.
- name: SBOM (SPDX, GitHub-only)
if: github.event_name == 'push' && github.server_url == 'https://github.com'
# Pinned to last v0.17 release that ships node20.
uses: anchore/sbom-action@v0.17.2
with:
image: ${{ env.GHCR_IMAGE }}:${{ env.OCI_TAG }}
format: spdx-json
output-file: veilor-os-oci.spdx.json
- name: Build provenance attestation (GitHub-only)
if: github.event_name == 'push' && github.server_url == 'https://github.com'
# Pinned to last v2.2 release that ships node20.
uses: actions/attest-build-provenance@v2.2.3
with:
subject-name: ${{ env.GHCR_IMAGE }}
subject-digest: ${{ steps.bluebuild.outputs.digest }}

View file

@ -0,0 +1,167 @@
name: Build veilor-os Installer ISO
# v0.7+ — produces a small Anaconda installer ISO that consumes
# kickstart/install-ostreecontainer-installer.ks. The ISO boots
# Anaconda, asks for LUKS pw + admin pw interactively, then
# `ostreecontainer` populates / from the v0.7 OCI image at
# ghcr.io/veilor-org/veilor-os:43.
on:
push:
branches: [v0.7-bluebuild-spike]
paths:
- 'kickstart/install-ostreecontainer.ks'
- 'kickstart/install-ostreecontainer-installer.ks'
- 'bluebuild/recipe.yml'
- '.github/workflows/build-installer-iso.yml'
workflow_dispatch:
inputs:
releasever:
description: 'Fedora release version'
required: false
default: '43'
permissions:
contents: write # needed to create+update installer-latest release
jobs:
build:
name: Build installer ISO
runs-on: nullstone
timeout-minutes: 120
env:
RELEASEVER: ${{ github.event.inputs.releasever || '43' }}
steps:
- name: Checkout
uses: actions/checkout@v4.1.7
- name: Install build tooling (Fedora)
run: |
set -euxo pipefail
dnf -y upgrade --refresh
dnf -y install --skip-unavailable \
lorax \
pykickstart \
anaconda-tui \
syslinux \
xorriso \
grub2-efi-x64 \
grub2-efi-x64-modules \
grub2-pc \
grub2-pc-modules \
shim-x64 \
efibootmgr
- name: Validate installer kickstart
run: |
set -euxo pipefail
ksvalidator kickstart/install-ostreecontainer-installer.ks
- name: Build installer ISO with livemedia-creator
run: |
set -euxo pipefail
# livemedia-creator refuses an existing non-empty resultdir.
rm -rf build/out
mkdir -p /var/lmc
ln -sfn "$GITHUB_WORKSPACE" /work
# livemedia-creator does NOT support --title (that's livecd-creator).
# --volid replaces it for the ISO volume label.
livemedia-creator \
--make-iso \
--no-virt \
--ks kickstart/install-ostreecontainer-installer.ks \
--resultdir build/out \
--tmp /var/lmc \
--volid "veilor-os-installer-${RELEASEVER}" \
--project "veilor-os" \
--releasever "$RELEASEVER" \
--logfile build/out/build.log \
2>&1 | tee -a build/out/build.log
- name: Rename ISO + sha256
run: |
set -euxo pipefail
ISO_FILE=$(ls build/out/*.iso 2>/dev/null | head -1)
[ -n "$ISO_FILE" ] || { echo "[ERR] no ISO produced"; exit 1; }
ISO_NAME="veilor-os-installer-${RELEASEVER}-$(date +%Y%m%d-%H%M%S).iso"
mv "$ISO_FILE" "build/out/$ISO_NAME"
cd build/out
sha256sum "$ISO_NAME" > "$ISO_NAME.sha256"
ls -lh "$ISO_NAME"
- name: Split ISO into 1900M chunks
if: success() && github.ref == 'refs/heads/v0.7-bluebuild-spike'
run: |
set -euo pipefail
cd build/out
ISO=$(ls *.iso | head -1)
[ -n "$ISO" ] || { echo "[ERR] no ISO"; exit 1; }
split -b 1900M -d --suffix-length=2 "$ISO" "${ISO}.part-"
rm -f "$ISO"
sha256sum *.part-* > "${ISO}.parts.sha256"
ls "${ISO}".part-*
- name: Publish to installer-latest rolling prerelease (Forgejo)
if: success() && github.ref == 'refs/heads/v0.7-bluebuild-spike' && github.server_url != 'https://github.com'
env:
FORGEJO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
FORGEJO_API: ${{ github.server_url }}/api/v1
REPO: ${{ github.repository }}
GIT_SHA: ${{ github.sha }}
run: |
set -euo pipefail
TAG="installer-latest"
REL_JSON=$(curl -fsSL -H "Authorization: token ${FORGEJO_TOKEN}" \
"${FORGEJO_API}/repos/${REPO}/releases/tags/${TAG}" 2>/dev/null || echo "")
if [ -n "$REL_JSON" ]; then
REL_ID=$(echo "$REL_JSON" | grep -oE '"id":\s*[0-9]+' | head -1 | grep -oE '[0-9]+')
if [ -n "$REL_ID" ]; then
curl -fsSL -X DELETE -H "Authorization: token ${FORGEJO_TOKEN}" \
"${FORGEJO_API}/repos/${REPO}/releases/${REL_ID}" || true
curl -fsSL -X DELETE -H "Authorization: token ${FORGEJO_TOKEN}" \
"${FORGEJO_API}/repos/${REPO}/git/refs/tags/${TAG}" || true
fi
fi
BODY="Rolling auto-build from v0.7-bluebuild-spike. Latest commit: ${GIT_SHA}.
Installer ISO — boots Anaconda, prompts for LUKS pw + admin pw,
then ostreecontainer-pulls / from ghcr.io/veilor-org/veilor-os:43.
Reassemble:
cat veilor-os-installer-*.iso.part-* > veilor-os-installer.iso
sha256sum -c veilor-os-installer-*.iso.parts.sha256
Not a stable release — for testing only."
PAYLOAD=$(BODY="$BODY" TAG="$TAG" python3 -c "
import json,os
print(json.dumps({
'tag_name': os.environ['TAG'],
'target_commitish': 'v0.7-bluebuild-spike',
'name': 'installer-latest (auto)',
'body': os.environ['BODY'],
'prerelease': True,
'draft': False,
}))")
REL_ID=$(curl -fsSL -X POST -H "Authorization: token ${FORGEJO_TOKEN}" \
-H "Content-Type: application/json" \
-d "$PAYLOAD" \
"${FORGEJO_API}/repos/${REPO}/releases" | \
grep -oE '"id":\s*[0-9]+' | head -1 | grep -oE '[0-9]+')
[ -n "$REL_ID" ] || { echo "[ERR] failed to create release"; exit 1; }
cd build/out
for f in *.iso.part-* *.sha256; do
[ -f "$f" ] || continue
curl -fsSL -X POST -H "Authorization: token ${FORGEJO_TOKEN}" \
-F "attachment=@${f}" \
"${FORGEJO_API}/repos/${REPO}/releases/${REL_ID}/assets?name=${f}"
done
- name: Print build log on failure
if: failure()
run: |
echo "─── build/out/build.log ───"
tail -200 build/out/build.log 2>/dev/null || echo "(no build.log)"
find build/out -name 'program.log' -exec tail -100 {} \; 2>/dev/null || true
find /var/lmc -name '*.log' -exec tail -50 {} \; 2>/dev/null || true

122
.github/workflows/smoke-test-oci.yml vendored Normal file
View file

@ -0,0 +1,122 @@
name: Smoke-test veilor-os OCI
# Pulls git.s8n.ru/veilor-org/veilor-os:43 and asserts that the image
# contains the veilor brand + the v0.5.x hardening overlay + the v0.7
# CLI tools, and that cosign verifies it against bluebuild/cosign.pub.
on:
workflow_run:
workflows: ["Build veilor-os OCI (BlueBuild)"]
types: [completed]
workflow_dispatch:
permissions:
contents: read
jobs:
smoke:
name: OCI smoke test
runs-on: nullstone
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'workflow_run' &&
github.event.workflow_run.conclusion == 'success')
timeout-minutes: 20
env:
IMAGE: git.s8n.ru/veilor-org/veilor-os:43
steps:
- name: Checkout
uses: actions/checkout@v4.1.7
- name: Fix sudo perms
run: chown -R 0:0 /etc/sudo.conf /etc/sudoers /etc/sudoers.d 2>/dev/null || true
- name: Install podman + cosign
run: |
set -euxo pipefail
command -v podman >/dev/null || dnf -y install --skip-unavailable podman
if ! command -v cosign >/dev/null 2>&1; then
curl -fsSL "https://github.com/sigstore/cosign/releases/download/v2.4.1/cosign-linux-amd64" \
-o /usr/local/bin/cosign
chmod +x /usr/local/bin/cosign
fi
podman --version
cosign version
- name: Login + pull OCI image
env:
FORGEJO_REGISTRY_TOKEN: ${{ secrets.FORGEJO_REGISTRY_TOKEN }}
FORGEJO_REGISTRY_USER: ${{ secrets.FORGEJO_REGISTRY_USER }}
run: |
set -euxo pipefail
if [ -n "${FORGEJO_REGISTRY_TOKEN:-}" ]; then
echo "$FORGEJO_REGISTRY_TOKEN" | podman login \
--username "${FORGEJO_REGISTRY_USER:-veilor-org}" \
--password-stdin git.s8n.ru
fi
podman pull "${IMAGE}"
- name: Verify cosign signature
run: |
set -euo pipefail
[ -f bluebuild/cosign.pub ] || { echo "[ERR] bluebuild/cosign.pub missing"; exit 1; }
cosign verify --key bluebuild/cosign.pub "${IMAGE}" 2>&1 | tail -10
- name: Run OCI assertions
run: |
set -uo pipefail
PASS=0; FAIL=0; ERRORS=""
pass() { echo "[PASS] $1"; PASS=$((PASS+1)); }
fail() { echo "[FAIL] $1"; FAIL=$((FAIL+1)); ERRORS="${ERRORS} - $1\n"; }
img() { podman run --rm "${IMAGE}" /bin/bash -c "$1" 2>/dev/null; }
OS=$(img 'cat /etc/os-release 2>/dev/null')
echo "$OS" | grep -q 'ID=veilor' && pass "ID=veilor" || fail "ID=veilor missing"
echo "$OS" | grep -q 'NAME="veilor-os"' && pass 'NAME="veilor-os"' || fail 'NAME="veilor-os" missing'
img 'which sudo' >/dev/null && pass "sudo present" || fail "sudo missing"
img 'rpm -q mullvad-browser' >/dev/null && pass "mullvad-browser present" || fail "mullvad-browser missing"
img 'rpm -q tailscale' >/dev/null && pass "tailscale present" || fail "tailscale missing"
img 'rpm -q yggdrasil' >/dev/null && pass "yggdrasil present" || fail "yggdrasil missing"
if img 'grep -qi "^SELINUX=enforcing" /etc/selinux/config'; then
pass "SELinux config = enforcing"
else
fail "SELinux not enforcing"
fi
img 'test -e /etc/systemd/system/multi-user.target.wants/veilor-firstboot.service \
-o -e /etc/systemd/system/graphical.target.wants/veilor-firstboot.service' >/dev/null \
&& pass "veilor-firstboot enabled" || fail "veilor-firstboot not enabled"
img 'test -e /etc/systemd/system/multi-user.target.wants/veilor-postinstall.service \
-o -e /etc/systemd/system/graphical.target.wants/veilor-postinstall.service' >/dev/null \
&& pass "veilor-postinstall enabled" || fail "veilor-postinstall not enabled"
for b in veilor-power veilor-update veilor-doctor veilor-postinstall; do
img "test -x /usr/local/bin/${b}" >/dev/null \
&& pass "${b} executable" || fail "${b} missing"
done
if img 'ls /usr/share/veilor-os/scripts/ 2>/dev/null' | grep -qE '(10-harden|20-harden|30-apply)'; then
pass "/usr/share/veilor-os/scripts populated"
else
fail "/usr/share/veilor-os/scripts missing"
fi
LEAKS=$(img "grep -rIni 'onyx\|192\.168\.0\.\|fedora\.local\|xynki\.dev' /etc/veilor* /usr/share/veilor-os 2>/dev/null")
if [ -z "$LEAKS" ]; then
pass "no brand leaks"
else
fail "brand leaks found"
echo "$LEAKS"
fi
echo
echo "═══ ${PASS} passed, ${FAIL} failed ═══"
if [ "$FAIL" -gt 0 ]; then
printf "%b" "$ERRORS"
exit 1
fi
echo "✓ veilor-os:43 smoke test passed"

1
.gitignore vendored
View file

@ -16,3 +16,4 @@ test/veilor-vm.nvram*
test/auto-install-vm.qcow2 test/auto-install-vm.qcow2
test/auto-install-vm.nvram* test/auto-install-vm.nvram*
.claude/worktrees/ .claude/worktrees/
**/cosign.key

View file

@ -11,6 +11,18 @@ future maintainers can see why a change exists, not just what it changes.
## [Unreleased] ## [Unreleased]
### v0.7 BlueBuild OCI spike (active)
- Promote `v0.7-bluebuild-spike` to active mainline; v0.6 cancelled.
- Port `build-bluebuild.yml` to the Forgejo runner (`runs-on: nullstone`):
install BlueBuild CLI in-job, push to `git.s8n.ru/veilor-org/veilor-os`,
gate cosign keyless / SBOM / attest steps to GitHub-only.
- Atomic CLI tools: `veilor-update` rewritten on `bootc upgrade`,
new `veilor-postinstall` first-login TUI, `veilor-doctor` learns
`bootc status --json` while keeping the legacy dnf path.
- Docs: `docs/INSTALL-V07.md`, `docs/STRATEGY.md` PIVOT EXECUTION
section, README quick-install rewritten for v0.7.
### Planned ### Planned
- v0.3 polish — Plymouth black theme, SDDM theme, Konsole profile, - v0.3 polish — Plymouth black theme, SDDM theme, Konsole profile,

View file

@ -48,26 +48,46 @@ spike at v0.7**, **bootc-only at v1.0**.
--- ---
## Quick install ## Quick install — v0.7+ (recommended, atomic / OCI)
```bash ```bash
# 1. Download the ISO from the latest Forgejo release. # 1. Download the bootstrap installer ISO from Forgejo.
# https://git.s8n.ru/veilor-org/veilor-os/releases/tag/ci-latest # https://git.s8n.ru/veilor-org/veilor-os/releases/tag/ci-latest
# (rolling tag; replaced on each successful build-iso.yml run)
sha256sum -c veilor-os-43-*.iso.sha256 sha256sum -c veilor-os-43-*.iso.sha256
# 2. Flash to USB. Replace /dev/sdX with your USB device — triple-check. # 2. Flash to USB. Replace /dev/sdX — triple-check.
sudo dd if=veilor-os-43-*.iso of=/dev/sdX bs=4M status=progress conv=fsync sudo dd if=veilor-os-43-*.iso of=/dev/sdX bs=4M status=progress conv=fsync
sync sync
# 3. Boot from USB, pick "Install veilor-os" from the menu. # 3. Boot from USB. Anaconda asks for LUKS passphrase + admin password.
# 4. Set a strong LUKS passphrase — the only prompt during install. # Anaconda then runs `ostreecontainer --url=git.s8n.ru/veilor-org/veilor-os:43`
# 5. Reboot, remove USB. # which populates / from the signed BlueBuild OCI image.
# 6. On first boot: TTY prompts for an admin password (≥14 chars, mixed case,
# digit, symbol). Once accepted, SDDM starts. Log in as `admin`. # 4. Reboot. Log in as `admin`. The first-login TUI (veilor-postinstall)
# asks for the small set of decisions we defer from install:
# keyboard, locale, hostname, GPU drivers, package presets,
# bluetooth, USBGuard policy snapshot. Each step skippable.
# 5. Day-to-day: `sudo veilor-update` (atomic, A/B, instant rollback).
``` ```
Full install + first-boot walkthrough: [docs/INSTALL.md](docs/INSTALL.md). Full v0.7 walkthrough: [docs/INSTALL-V07.md](docs/INSTALL-V07.md).
---
### Legacy v0.5.0 install (kickstart-flat path)
The kickstart-installed v0.5.0 ISO ships as a frozen proof-of-work
release. Same hardening, no bootc/rpm-ostree atomic layer. Updates
go through `dnf upgrade` instead of `bootc upgrade`.
```bash
# Same flash + boot, then pick "Install veilor-os".
# Single LUKS passphrase prompt during install; admin password set
# on first boot via TTY.
```
Walkthrough: [docs/INSTALL.md](docs/INSTALL.md).
--- ---
@ -147,7 +167,7 @@ clean, locked down, with no manual post-install hardening required.
[secureblue](https://github.com/secureblue/secureblue) is an upstream [secureblue](https://github.com/secureblue/secureblue) is an upstream
hardened atomic Fedora project we benchmark against and plan to **build hardened atomic Fedora project we benchmark against and plan to **build
on top of** at v0.7. The v0.7 BlueBuild spike uses their on top of** at v0.7. The v0.7 BlueBuild spike uses their
`securecore-kinoite-hardened-userns` OCI image as its base — we don't `kinoite-main-hardened` OCI image as its base — we don't
ship their source code in this repo, we layer veilor branding, ship their source code in this repo, we layer veilor branding,
theming, the gum installer, and the kickstart bootstrap on top of theming, the gum installer, and the kickstart bootstrap on top of
their already-signed image. their already-signed image.

96
bluebuild/README.md Normal file
View file

@ -0,0 +1,96 @@
# bluebuild/ — v0.7 spike
This directory contains the BlueBuild recipe + supporting config that
builds the veilor-os bootable OCI image. **Active on the
`v0.7-bluebuild-spike` branch only.** Does NOT land in v0.5.x main
until the spike passes its success criteria (see
`docs/STRATEGY.md`).
## What's here
```
bluebuild/
├── recipe.yml # primary BlueBuild recipe
├── config/
│ └── just/
│ └── 60-veilor.just # ujust recipes for opt-in components
└── README.md # this file
```
The recipe extends
`ghcr.io/secureblue/kinoite-main-hardened:latest`. We
inherit secureblue's hardening (sysctl + kargs + custom SELinux
policy + USBGuard + hardened-malloc + Unbound DoT + chronyd NTS +
Trivalent browser + cosign-signed image chain). On top, we layer:
- veilor branding (overlay/, theme, plymouth, sddm, os-release)
- mullvad-browser (anti-fingerprint companion to Trivalent)
- xorg-x11-server-Xwayland (re-enable; secureblue disables it)
- sudo (re-enable; secureblue replaces with run0)
- tailscale + yggdrasil (mesh stack layer 1 + 2)
- ujust recipes for Reticulum (mesh layer 3) + Thorium (opt-in browser)
Trivalent stays as the default browser (correcting an earlier draft).
## Build locally
```bash
# Requires bluebuild CLI:
# curl -fsSL https://raw.githubusercontent.com/blue-build/cli/main/install.sh | sh
cd bluebuild
bluebuild build recipe.yml
```
Output: `localhost/veilor-os:43` in podman storage. Push to GHCR
via the workflow.
## Test the OCI image
```bash
# Smoke-test (boots into the rootfs; no kernel, no init):
podman run --rm -it ghcr.io/veilor-org/veilor-os:43 /bin/bash
# Inside, sanity:
cat /etc/os-release # PRETTY_NAME=veilor-os
which sudo # /usr/bin/sudo (re-enabled)
which trivalent # secureblue's COPR (default browser)
which mullvad-browser # /usr/bin/mullvad-browser
systemctl is-enabled yggdrasil # enabled (idle)
systemctl is-enabled tailscaled # disabled (awaits ujust veilor-mesh-join)
```
## Test the installer ISO
The installer ISO is built separately by livecd-creator (current path)
or bootc-image-builder (v1.0+). Its kickstart's `%packages` block is
replaced with:
```
ostreecontainer --url=ghcr.io/veilor-org/veilor-os:43 --transport=registry
```
That populates the target's `/` directly from this OCI image during
the install pass. No first-boot rebase. No transition window.
## Spike success criteria (1 day)
- [ ] `bluebuild build recipe.yml` exits 0
- [ ] `bootc container lint` exits 0 on the resulting image
- [ ] `podman run` smoke-test (commands above) all pass
- [ ] `.github/workflows/build-bluebuild.yml` builds + cosign-signs +
pushes to `ghcr.io/veilor-org/veilor-os:43`
- [ ] An installer ISO using `ostreecontainer` against this OCI
reaches SDDM with admin login on first boot
If all five land, merge `v0.7-bluebuild-spike``main` as v0.7.0.
If any fail in ways that aren't trivially fixable, file each as a GH
issue + return to v0.5.x kickstart path.
## See also
- `docs/STRATEGY.md` — the strategic decision + override list
- `docs/ROADMAP.md` v0.7 — full schedule
- `docs/THREAT-MODEL.md` — what we publish before launch
- secureblue: <https://github.com/secureblue/secureblue>
- BlueBuild: <https://blue-build.org>
- bootc / ostreecontainer: <https://docs.fedoraproject.org/en-US/bootc/>

View file

@ -0,0 +1,73 @@
# veilor-os ujust recipes — opt-in components
# Loaded into /usr/share/ublue-os/just/ at image build time;
# `ujust install-X` discovers + dispatches.
# install Reticulum / RetiNet AGPL fork + Sideband (mesh layer 3)
install-reticulum:
#!/usr/bin/env bash
echo "═══ Reticulum (RetiNet AGPL fork) install ═══"
echo
echo "Installs RetiNet (AGPL fork — NOT upstream RNS due to anti-AI"
echo "license) plus Sideband messenger. Default config: AutoInterface"
echo "(LAN multicast) + 1-2 TCP backbone peers. RNode hardware (LoRa"
echo "transceiver) is a separate install."
echo
read -p "Proceed? [y/N]: " confirm
if [[ "$confirm" != "y" ]]; then echo "Cancelled."; exit 0; fi
rpm-ostree install python3-pip
pip install --user retinet sideband-cli
echo
echo "Done. To attach an RNode (LoRa transceiver), run:"
echo " ujust install-reticulum-rnode"
# install Reticulum RNode hardware support (LoRa transceiver)
install-reticulum-rnode:
#!/usr/bin/env bash
echo "═══ RNode (LoRa transceiver) hardware install ═══"
echo
echo "Adds RNode firmware-update tooling + udev rules for the LoRa"
echo "USB hardware. Required only if you have an RNode device."
echo
read -p "Proceed? [y/N]: " confirm
if [[ "$confirm" != "y" ]]; then echo "Cancelled."; exit 0; fi
pip install --user rnodeconf
echo "Done. Plug in your RNode via USB; it will appear as a serial device."
# install Thorium browser (OPT-IN, with explicit CVE-lag warning)
install-thorium:
#!/usr/bin/env bash
echo "═══ Thorium browser install ═══"
echo
echo "WARNING: Thorium is a perf/media-focused fork of Chromium that"
echo "uses LTS Chromium as its base. As of 2026-05 it lags upstream"
echo "stable by ~9 milestones (months of CVE backlog)."
echo
echo "veilor-os ships Trivalent (secureblue's hardened Chromium fork,"
echo "tracking upstream M147+ within hours) as the default browser."
echo "Thorium is provided as an OPT-IN profile for users who"
echo "explicitly need its perf characteristics (e.g. WebGL games,"
echo "media decode profiles)."
echo
echo "DO NOT use Thorium as your daily-driver browser. Use Trivalent"
echo "or Mullvad Browser for that."
echo
read -p "Acknowledge CVE-lag risk and continue? [y/N]: " confirm
if [[ "$confirm" != "y" ]]; then echo "Cancelled."; exit 0; fi
flatpak install --user -y org.thorium.Thorium 2>/dev/null || \
rpm-ostree install thorium-browser
echo "Done. Launch via Plasma menu or `flatpak run org.thorium.Thorium`."
# join the veilor mesh (Tailscale via Headscale)
veilor-mesh-join:
#!/usr/bin/env bash
echo "═══ Join veilor mesh (Tailscale via Headscale) ═══"
echo
echo "Pre-auth keys are minted by the Misskey signup page at"
echo "x.veilor (TTL 24h, single-use). You can paste the hex key"
echo "directly OR scan the QR code shown after signup."
echo
read -p "Hex key (paste): " preauth
if [[ -z "$preauth" ]]; then echo "Empty key. Cancelled."; exit 0; fi
sudo systemctl enable --now tailscaled
sudo tailscale up --login-server=https://hs.s8n.ru --auth-key="$preauth"
echo "Done. Status: $(sudo tailscale status | head -1)"

4
bluebuild/cosign.pub Normal file
View file

@ -0,0 +1,4 @@
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE5xQcyP7FHNSiG7+VLsN2ViWlvvIB
FYmu2XmPah7/VBlmuQ88H0ZbqCqqnS2u9x5+P1OMaMK+//k89V0Blrx65Q==
-----END PUBLIC KEY-----

155
bluebuild/recipe.yml Normal file
View file

@ -0,0 +1,155 @@
# veilor-os — BlueBuild recipe (v0.7 spike, 1-day target)
#
# Extends secureblue's hardened Kinoite OCI image with veilor branding,
# threat-model-driven UX choices, and the three-layer mesh stack
# (Tailscale + Yggdrasil + opt-in Reticulum). This is the OCI image
# that the v0.7+ kickstart's `ostreecontainer` directive pulls into
# the target root during the install pass.
#
# Build: bluebuild build recipe.yml
# Test: podman run --rm -it ghcr.io/veilor-org/veilor-os:43 /bin/bash
# CI: .github/workflows/build-bluebuild.yml signs + pushes to GHCR.
#
# Reference: https://blue-build.org/reference/recipe/
---
name: veilor-os
description: Hardened security-branded Fedora KDE on top of secureblue.
# Base image: secureblue's hardened Kinoite variant with userns sandboxing.
# That brings in: sysctl + kargs + custom SELinux policy + USBGuard +
# hardened-malloc + Unbound DoT + chronyd NTS + Trivalent browser.
base-image: ghcr.io/secureblue/kinoite-main-hardened
image-version: latest
modules:
# ── 1. veilor branding overlay ──────────────────────────────────
# `type: copy` is a low-level direct COPY (no chmod, no script).
# `type: files` was failing with `chmod: Operation not permitted` on
# the BlueBuild-shipped /tmp/modules/files/files.sh under buildah +
# podman privileged in our runner — the script tries to make itself
# executable inside its own bind-mounted layer.
- type: copy
source: ../overlay
destination: /
- type: copy
source: ../assets
destination: /usr/share/veilor-os/assets
- type: copy
source: ../scripts
destination: /usr/share/veilor-os/scripts
# ── 2. Branding overrides at build time ─────────────────────────
- type: script
snippets:
- |
# os-release brand
sed -i \
-e 's|^GRUB_DISTRIBUTOR=.*|GRUB_DISTRIBUTOR="veilor-os"|' \
/etc/default/grub 2>/dev/null || true
# Apply our kde-theme + plymouth in build
bash /usr/share/veilor-os/scripts/kde-theme-apply.sh || true
bash /usr/share/veilor-os/scripts/30-apply-v03-theme.sh 2>/dev/null || true
plymouth-set-default-theme details 2>/dev/null || true
# Mark all our shipped scripts + CLIs executable. cp -a from the
# repo preserves perms but BlueBuild's `type: files` sometimes
# drops the +x bit on the way through; belt-and-braces here.
chmod +x /usr/share/veilor-os/scripts/*.sh \
/usr/share/veilor-os/scripts/selinux/*.sh \
/usr/local/bin/veilor-* 2>/dev/null || true
# Refresh fontconfig cache so Fira Code is picked up by KDE
fc-cache -f 2>/dev/null || true
# os-release brand override (atomic /etc is r/w; safe to overwrite)
if [ -f /etc/os-release ]; then
sed -i \
-e 's|^NAME=.*|NAME="veilor-os"|' \
-e 's|^PRETTY_NAME=.*|PRETTY_NAME="veilor-os 0.7 (atomic)"|' \
-e 's|^ID=.*|ID=veilor|' \
-e 's|^ID_LIKE=.*|ID_LIKE="fedora kinoite"|' \
/etc/os-release || true
fi
# Sanity: brand-leak check, fail build if any onyx/personal data slipped in
if grep -rqi 'onyx\|192\.168\.0\.\|fedora\.local\|xynki\.dev' \
/etc/veilor* /etc/tuned/profiles/veilor-* /usr/share/veilor-os 2>/dev/null; then
echo "[ERR] brand leak detected in shipped state"
exit 1
fi
# ── 3. Override secureblue's run0-only — restore sudo ───────────
# secureblue removes sudo + replaces with run0. Too disruptive for
# daily-driver workflows. Restore sudo, keep run0 available.
- type: rpm-ostree
install:
- sudo
# ── 4. Re-enable Xwayland ───────────────────────────────────────
# secureblue disables Xwayland for attack-surface reduction. Some
# apps (Element, Slack-likes, older Qt5 tools) still need it.
# User who wants it removed back can `rpm-ostree override remove`.
- type: rpm-ostree
install:
- xorg-x11-server-Xwayland
# ── 5. Mullvad Browser as anti-fingerprint companion ────────────
# Layered alongside Trivalent (kept as default per STRATEGY.md).
# Trivalent for daily browsing, Mullvad for pseudonymous browsing.
# Thorium remains opt-in only via `ujust install-thorium` — see
# config/thorium.just for the warning + install logic.
- type: rpm-ostree
install:
- mullvad-browser
# ── 6. Mesh stack packages ──────────────────────────────────────
# Layer 1 (Day 1 daily driver, service pre-disabled): Tailscale
# Layer 2 (Day 1 idle warm-fallback): Yggdrasil-go
# Layer 3 (opt-in via ujust): Reticulum / RetiNet — handled in just/
- type: rpm-ostree
install:
- tailscale
- yggdrasil
# ── 6b. Memory hygiene + ergonomic deps ─────────────────────────
# zram-generator gives us zram swap (no disk swap, no cold-boot
# leak). gum is the TUI primitive used by veilor-postinstall +
# veilor-update + veilor-doctor — vendor binary at build time so
# post-install layering doesn't need it.
- type: rpm-ostree
install:
- zram-generator
- jq
- vim-enhanced
- tmux
- htop
# ── 7. ujust recipes for opt-in components ──────────────────────
- type: copy
source: config/just
destination: /usr/share/ublue-os/just
# ── 8. Service tuning: tailscale pre-disabled, yggdrasil idle ───
- type: systemd
system:
enabled:
- yggdrasil.service # idle warm-fallback (config = empty Listen[])
disabled:
- tailscaled.service # awaits first-boot prompt for join
# secureblue parents already enable: sshd, fail2ban, usbguard,
# auditd, firewalld, chronyd, sddm — no re-enable needed.
# ── 9. veilor-os specific systemd units ─────────────────────────
# All veilor-* units come in via overlay/etc/systemd/system/ —
# explicit enable here since they aren't part of secureblue's set.
- type: systemd
system:
enabled:
- veilor-firstboot.service
- veilor-modules-lock.service
- veilor-postinstall.service
- veilor-doctor.timer
# ── 10. signing config ──────────────────────────────────────────
# cosign.pub committed alongside this recipe; cosign.key kept off
# repo and provided to CI as Forgejo secret COSIGN_PRIVATE_KEY.
# The action exports it to /tmp at build time.
- type: signing

138
docs/INSTALL-V07.md Normal file
View file

@ -0,0 +1,138 @@
# Installing veilor-os (v0.7+)
> v0.7 is the first OCI / atomic release. The kickstart-installed
> v0.5.x path still ships as legacy — if you want that flow, see
> [INSTALL.md](INSTALL.md). Both paths produce a hardened veilor-os
> system; the v0.7 path is what we recommend going forward.
## What's different from v0.5
| Topic | v0.5.x (kickstart) | v0.7+ (BlueBuild OCI) |
|---|---|---|
| Root filesystem | mutable, `/usr` writable | atomic / immutable, layered via `rpm-ostree` |
| Updates | `sudo dnf upgrade` | `sudo bootc upgrade` (atomic A/B, instant rollback) |
| Adding a package | `sudo dnf install foo` | `sudo rpm-ostree install foo` (layered into next deployment) |
| Base hardening | re-derived in our `%post` scripts | inherited from secureblue OCI image |
| Build artefact | `~2.7 GB` live ISO | small bootstrap ISO + signed OCI image at registry |
## Step-by-step
### 1. Download the bootstrap installer ISO
The bootstrap ISO is a tiny Anaconda-driven installer. It does
nothing more than collect a LUKS passphrase + admin password and
then call `ostreecontainer --url=...:43 --transport=registry` to
populate `/` from the pre-built signed OCI image.
Download from the Forgejo release:
<https://git.s8n.ru/veilor-org/veilor-os/releases/tag/ci-latest>
Reassemble the chunked ISO if needed (legacy artefact format):
```sh
cat veilor-os-*.iso.part-* > veilor-os.iso
sha256sum -c veilor-os-*.iso.parts.sha256
```
### 2. Verify the OCI image signature (optional, recommended)
The OCI image is cosign-signed at build time. If you have `cosign`
installed:
```sh
cosign verify --key cosign.pub git.s8n.ru/veilor-org/veilor-os:43
```
The public key `cosign.pub` ships with the bootstrap ISO and is also
on the Forgejo release page.
### 3. Flash to USB
Replace `/dev/sdX` with your USB device — triple-check the path.
```sh
sudo dd if=veilor-os.iso of=/dev/sdX bs=4M status=progress conv=fsync
sync
```
### 4. Boot from USB
Pick **Install veilor-os** from the boot menu. Anaconda starts and
asks two things, no more:
- **LUKS passphrase** for the encrypted root
- **admin password** (≥14 chars, mixed case, digit, symbol)
Anaconda then runs the `ostreecontainer` directive — pulls the
signed OCI image, writes it to disk, configures bootloader.
### 5. Reboot, remove USB
The first boot lands on SDDM with `admin` pre-filled. Log in.
### 6. First-login TUI
`veilor-postinstall` runs once, asks for the small set of things we
defer from install time:
- Keyboard / locale (defaults are fine for most operators)
- Hostname (default `veilor`)
- GPU drivers (NVIDIA layered via `rpm-ostree install`; mesa = no-op)
- Package presets (`dev` / `media` / `homelab`, all opt-in)
- Bluetooth (opt-in)
- USBGuard snapshot (plug in trusted devices first)
- `veilor-doctor` first run
Each step is skippable. The TUI writes a marker file and disables
itself; it never runs again.
If you need to re-run it: `sudo veilor-postinstall --force`.
### 7. Day-to-day
```sh
# update (atomic, A/B, instant rollback)
sudo veilor-update
# layer a package (takes effect after reboot)
sudo rpm-ostree install foo
# remove a layered package
sudo rpm-ostree uninstall foo
# health check + drift report
veilor-doctor
# rollback to previous deployment
sudo bootc rollback
# inspect current and staged deployments
bootc status
```
### Troubleshooting
| Symptom | Try |
|---|---|
| `veilor-update` says "no rollback target" | First boot — bootc only has rollback after the first successful upgrade. Normal. |
| Network down inside Anaconda | Bootstrap ISO uses NetworkManager defaults; plug in ethernet for the first install. WiFi support post-first-boot. |
| `rpm-ostree install foo` fails | Run `bootc status` — if a staged deployment exists, reboot first, then re-try. rpm-ostree won't layer onto a staged tree. |
| First-login TUI didn't appear | Marker check: `ls /var/lib/veilor/postinstall-complete`. If present, run `sudo veilor-postinstall --force`. |
| GPU is black after NVIDIA layer + reboot | `bootc rollback` and try mesa first; check `journalctl -b -1 -u sddm` from the previous boot. |
### Where the OCI image comes from
The image is built by `.github/workflows/build-bluebuild.yml` on the
self-hosted Forgejo runner (label `nullstone`). Build inputs:
- Base: `ghcr.io/secureblue/kinoite-main-hardened`
- Recipe: [`bluebuild/recipe.yml`](../bluebuild/recipe.yml)
- Veilor overlay: stamped via BlueBuild `type: files` modules
- Layered RPMs: `sudo`, `xorg-x11-server-Xwayland`, `mullvad-browser`,
`tailscale`, `yggdrasil`
- Output: `git.s8n.ru/veilor-org/veilor-os:{43,latest}`
The build is cosign-signed (key-pair on Forgejo, keyless on GitHub
parallel mirror). See [`bluebuild/README.md`](../bluebuild/README.md)
for the recipe walk-through.

View file

@ -30,7 +30,7 @@
| Project | Role in veilor-os | | Project | Role in veilor-os |
|---|---| |---|---|
| Fedora 43 KDE | Base OS for v0.5.x kickstart-installed flat builds | | Fedora 43 KDE | Base OS for v0.5.x kickstart-installed flat builds |
| [secureblue](https://github.com/secureblue/secureblue) | Upstream hardened atomic Fedora; v0.7 BlueBuild spike layers our overlay on top of `securecore-kinoite-hardened-userns` | | [secureblue](https://github.com/secureblue/secureblue) | Upstream hardened atomic Fedora; v0.7 BlueBuild spike layers our overlay on top of `kinoite-main-hardened` |
| Kicksecure / Whonix | Reference for AppArmor + apt-transport-tor model (we don't ship Tor; we did read their docs) | | Kicksecure / Whonix | Reference for AppArmor + apt-transport-tor model (we don't ship Tor; we did read their docs) |
| Bluefin / Bazzite (uBlue) | Reference for BlueBuild recipe shape and OCI publishing pattern | | Bluefin / Bazzite (uBlue) | Reference for BlueBuild recipe shape and OCI publishing pattern |
| Tails | Reference for live-only install model — explicitly **not** veilor's path | | Tails | Reference for live-only install model — explicitly **not** veilor's path |
@ -194,7 +194,7 @@ The repo carries more than just an ISO recipe:
| `scripts/selinux/veilor-systemd.te` | Custom SELinux module (targeted policy gap fixes) | | `scripts/selinux/veilor-systemd.te` | Custom SELinux module (targeted policy gap fixes) |
| `scripts/30-apply-v03-theme.sh` | Plymouth + SDDM + Konsole + wallpaper apply | | `scripts/30-apply-v03-theme.sh` | Plymouth + SDDM + Konsole + wallpaper apply |
| `scripts/40-apparmor.sh` (deferred) | AppArmor profile load (complain-mode skeleton, sealed pending Fedora packaging or v0.7 secureblue) | | `scripts/40-apparmor.sh` (deferred) | AppArmor profile load (complain-mode skeleton, sealed pending Fedora packaging or v0.7 secureblue) |
| `bluebuild/recipe.yml` | v0.7 OCI recipe (base = secureblue securecore-kinoite-hardened-userns) | | `bluebuild/recipe.yml` | v0.7 OCI recipe (base = secureblue kinoite-main-hardened) |
| `kickstart/install-ostreecontainer.ks` | v0.7 install ks: 10 lines, just `ostreecontainer --url=ghcr.io/veilor-org/veilor-os:43 --transport=registry` | | `kickstart/install-ostreecontainer.ks` | v0.7 install ks: 10 lines, just `ostreecontainer --url=ghcr.io/veilor-org/veilor-os:43 --transport=registry` |
| `assets/installer/{banner.txt,colors.gum}` | Pure-block VEILOR OS wordmark + branded gum colour palette | | `assets/installer/{banner.txt,colors.gum}` | Pure-block VEILOR OS wordmark + branded gum colour palette |
| `assets/branding/` | Logo, wallpapers, plymouth theme assets | | `assets/branding/` | Logo, wallpapers, plymouth theme assets |

View file

@ -252,7 +252,7 @@ ergonomic work and becomes the next ship target.
Scope: Scope:
- BlueBuild recipe (`bluebuild/recipe.yml`) layering on - BlueBuild recipe (`bluebuild/recipe.yml`) layering on
`ghcr.io/secureblue/securecore-kinoite-hardened-userns` `ghcr.io/secureblue/kinoite-main-hardened`
- `kickstart/install-ostreecontainer.ks` — 10-line kickstart that calls - `kickstart/install-ostreecontainer.ks` — 10-line kickstart that calls
`ostreecontainer --url=ghcr.io/veilor-org/veilor-os:43 --transport=registry` `ostreecontainer --url=ghcr.io/veilor-org/veilor-os:43 --transport=registry`
and lets Anaconda's LUKS UX drive the install and lets Anaconda's LUKS UX drive the install
@ -264,6 +264,21 @@ Scope:
- `veilor-update` rewritten on `bootc upgrade` (was `dnf upgrade`) - `veilor-update` rewritten on `bootc upgrade` (was `dnf upgrade`)
- Forgejo registry as primary OCI publish target; GHCR mirror optional - Forgejo registry as primary OCI publish target; GHCR mirror optional
- cosign key-pair signing of OCI image (replaces broken keyless flow) - cosign key-pair signing of OCI image (replaces broken keyless flow)
- **Installer logs persisted to USB stick by default** (debug mode):
the bootstrap ISO writes `/var/log/anaconda/*` + the resolved
kickstart + ostreecontainer pull log + dmesg back onto the USB
install medium (mounted rw at `/run/install/repo` during install)
into a `veilor-install-logs/<timestamp>/` folder. Toggleable via
kernel cmdline `inst.veilor.savelogs=0` for opt-out, or
`inst.veilor.savelogs=1` (default). Stays **ON by default through
v0.7+v0.8+v0.9; flips OFF for v1.0 final release**. Why: any failed
install, the operator boots back to a working OS, plugs the USB,
reads the logs offline — no need to take screenshots of dracut on a
bricked machine. Implementation: `%post --nochroot` block in
`kickstart/install-ostreecontainer.ks` that detects the install
medium via `/run/install/repo` rw remount, copies the log set,
syncs, then unmounts. If the medium is read-only (DVD), skip
silently with a `journalctl` warning.
Public-flex items kept from original v0.7 entry: Public-flex items kept from original v0.7 entry:
@ -292,7 +307,7 @@ spike on `quay.io/fedora/fedora-bootc:43`. Research on 2026-05-05
`docs/research/2026-05-05-agent-wave/`), then a parent-operator `docs/research/2026-05-05-agent-wave/`), then a parent-operator
refinement same day, locked the path: **layer veilor's branding + refinement same day, locked the path: **layer veilor's branding +
threat model + UX on top of secureblue's already-shipping threat model + UX on top of secureblue's already-shipping
`securecore-kinoite-hardened-userns` OCI image** via a BlueBuild `kinoite-main-hardened` OCI image** via a BlueBuild
recipe, and install it directly during the Anaconda pass via the recipe, and install it directly during the Anaconda pass via the
`ostreecontainer` kickstart directive (no first-boot rebase). `ostreecontainer` kickstart directive (no first-boot rebase).

View file

@ -12,7 +12,7 @@ Locked at: **v0.5.31 → v0.7 spike → v1.0**
works). works).
- Anaconda's `ostreecontainer` directive populates the root filesystem - Anaconda's `ostreecontainer` directive populates the root filesystem
directly from a **veilor-os OCI image** (built via BlueBuild on top directly from a **veilor-os OCI image** (built via BlueBuild on top
of secureblue's `securecore-kinoite-hardened-userns`) **during the of secureblue's `kinoite-main-hardened`) **during the
install pass — no first-boot rebase, no mutable→atomic transition**. install pass — no first-boot rebase, no mutable→atomic transition**.
- All future updates flow through `bootc upgrade` — atomic A/B, - All future updates flow through `bootc upgrade` — atomic A/B,
instant rollback, cosign-signed. instant rollback, cosign-signed.
@ -236,7 +236,7 @@ distro: **honest, scoped, public threat model**.
The Containerfile-from-scratch spike plan (Agent 3 of 2026-05-05 The Containerfile-from-scratch spike plan (Agent 3 of 2026-05-05
wave) is **superseded** by this hybrid: don't build a Containerfile wave) is **superseded** by this hybrid: don't build a Containerfile
from scratch on `fedora-bootc:43`. Instead, write a BlueBuild recipe from scratch on `fedora-bootc:43`. Instead, write a BlueBuild recipe
on `securecore-kinoite-hardened-userns`. With `ostreecontainer` on `kinoite-main-hardened`. With `ostreecontainer`
swap, spike compresses 1 week → 1 day. swap, spike compresses 1 week → 1 day.
## Next concrete steps ## Next concrete steps
@ -254,7 +254,7 @@ in the v0.7 spike branch only.
### v0.7-spike (1 day, separate branch) ### v0.7-spike (1 day, separate branch)
1. New repo dir: `bluebuild/recipe.yml`. 1. New repo dir: `bluebuild/recipe.yml`.
2. `from`: `ghcr.io/secureblue/securecore-kinoite-hardened-userns:latest`. 2. `from`: `ghcr.io/secureblue/kinoite-main-hardened:latest`.
3. Override modules: 3. Override modules:
- `type: files` — stamp our `overlay/*` tree (branding, themes, - `type: files` — stamp our `overlay/*` tree (branding, themes,
veilor scripts, sddm theme, plymouth theme). veilor scripts, sddm theme, plymouth theme).
@ -334,3 +334,29 @@ dir.
- Yggdrasil: <https://github.com/yggdrasil-network/yggdrasil-go> - Yggdrasil: <https://github.com/yggdrasil-network/yggdrasil-go>
- Reticulum manual: <https://reticulum.network/manual/> - Reticulum manual: <https://reticulum.network/manual/>
- Iroh blobs design: <https://github.com/n0-computer/iroh-blobs/blob/main/DESIGN.md> - Iroh blobs design: <https://github.com/n0-computer/iroh-blobs/blob/main/DESIGN.md>
---
## PIVOT EXECUTION — 2026-05-06
The hybrid strategy locked at v0.5 is now in execution.
- **v0.5.0 shipped** as the proof-of-work / portfolio release of the
kickstart-flat path. Self-hosted Forgejo CI green-built a 2.7 GB
ISO; tag pushed; download lives at the ci-latest release.
- **v0.6 milestone cancelled.** Continuing to debug
`livecd-creator + anaconda` quirks for v0.6 polish would be sunk-
cost work on tooling we retire at v1.0. Original v0.6 plan kept in
ROADMAP.md as historical reference.
- **v0.7 BlueBuild OCI is the active mainline.** The
`v0.7-bluebuild-spike` branch carries the BlueBuild recipe layered
on `ghcr.io/secureblue/kinoite-main-hardened`, the
`ostreecontainer` kickstart bootstrap, and the new `bootc upgrade`-
driven update channel.
- **v0.6 ergonomic CLIs ported, not rewritten.** `veilor-update`
rewrites onto `bootc upgrade`; `veilor-postinstall` becomes the
first-login TUI on the atomic system; `veilor-doctor` learns
`bootc status --json` while keeping the legacy dnf path for v0.5.x.
- **v1.0 retires the kickstart entirely.** Only `kickstart/install-
ostreecontainer.ks` (10 lines) ships forward — bootstrap installer
for ostreecontainer pulls.

View file

@ -0,0 +1,47 @@
# veilor-os installer kickstart — v0.7 CI build variant
#
# Derived from kickstart/install-ostreecontainer.ks by stripping all
# __PLACEHOLDER__ tokens that the runtime gum TUI substitutes at install
# time. Anaconda's interactive TUI handles disk selection, LUKS passphrase,
# and user account creation in their place.
#
# Consumed by livemedia-creator --make-iso to produce
# veilor-os-installer-43-*.iso. Do NOT add __PLACEHOLDER__ tokens here —
# they cannot be filled at build time. See install-ostreecontainer.ks
# for the runtime template the gum TUI fills in.
# ── Locale / keyboard / time ──
keyboard --xlayouts='us'
lang en_US.UTF-8
timezone Europe/London --utc
# ── Install mode ──
text
firstboot --disable
eula --agreed
selinux --enforcing
# ── Network ──
network --bootproto=dhcp --device=link --activate --hostname=veilor-install
firewall --enabled --service=ssh
# ── Identity ──
# rootpw --lock only. No user directive — Anaconda's user spoke handles
# admin account creation interactively. Runtime ks substitutes
# --password=__ADMIN_PW__ for unattended installs.
rootpw --lock
# ── Disk / partitioning ──
# Intentionally absent. Anaconda's disk spoke presents interactive
# disk + LUKS + btrfs selection. Runtime ks (gum TUI) provides the
# full partition spec at real-install time.
# ── ostreecontainer: populate / from veilor-os OCI ──
ostreecontainer --url=ghcr.io/veilor-org/veilor-os:43 --transport=registry
# ── %post (chroot) ──
%post
set -uo pipefail
echo veilor-install > /etc/hostname
chage -d 0 admin 2>/dev/null || true
%end

View file

@ -0,0 +1,80 @@
# veilor-os install kickstart — v0.7 spike (ostreecontainer path)
#
# This is the install-time kickstart for the v0.7 hybrid path. The live
# ISO boots; the gum TUI collects user answers (disk, LUKS pw, admin pw);
# this template gets the answers substituted in and is fed to anaconda.
#
# Anaconda partitions the disk + creates LUKS + btrfs subvols + mounts
# /boot/efi + /boot, then `ostreecontainer` populates `/` directly from
# the cosign-signed veilor-os OCI image at `ghcr.io/veilor-org/veilor-os:43`.
#
# No `%packages` block. No first-boot rebase. No
# `veilor-firstboot-rebase.service`. The ostreecontainer install pass is
# the entire transition from "Fedora live ISO" to "veilor-os on disk".
#
# Reference: pykickstart docs ostreecontainer command;
# https://docs.fedoraproject.org/en-US/bootc/anaconda-install/
# ── Locale / keyboard / time ──
keyboard --xlayouts='us'
lang en_US.UTF-8
timezone Europe/London --utc
# ── Install mode / behaviour ──
firstboot --disable
eula --agreed
# SELinux state inherited from the OCI image; --enforcing is implicit
# since secureblue's image ships /etc/selinux/config = enforcing.
selinux --enforcing
# ── Network / hostname ──
network --bootproto=dhcp --device=link --activate --hostname=__HOSTNAME__
firewall --enabled --service=ssh
# ── Identity (single LUKS prompt asked at install via gum TUI) ──
rootpw --lock
user --name=admin --groups=wheel --gecos="veilor admin" --password=__ADMIN_PW__ --plaintext
# ── Bootloader ──
# fbcon=nodefer for laptop KMS handoff (real-hardware audit, agent 9 of
# 2026-05-05 wave). rd.luks.options=tries=5,timeout=0 for UX.
# rd.luks.uuid is auto-injected by anaconda based on the encrypted
# part directive below.
#
# All other hardening kargs (lockdown=integrity, slab_nomerge, etc.)
# come from /usr/lib/bootc/kargs.d/ inside the OCI image — bootc
# applies them at install time. We only add what the OCI image can't
# know (laptop-specific KMS flag).
bootloader --append="fbcon=nodefer"
# ── Disk: LUKS2 (argon2id) + btrfs subvols ──
zerombr
clearpart --all --initlabel --drives=__DISK_BASENAME__
part /boot/efi --fstype=efi --size=600
part /boot --fstype=ext4 --size=1024
part btrfs.veilor --grow --encrypted --luks-version=luks2 --pbkdf=argon2id --passphrase=__LUKS_PW__
btrfs none --label=veilor btrfs.veilor
btrfs / --subvol --name=root LABEL=veilor
btrfs /home --subvol --name=home LABEL=veilor
# ── ostreecontainer: populate / from the veilor-os OCI image ──
# `--transport=registry` pulls from ghcr.io directly. Authentication
# token can be supplied via /etc/ostree/auth.json baked into the live
# rootfs OR via a kickstart `--remote-token` if the registry is private.
# At v0.7 spike the OCI image is public, so no auth needed.
#
# DO NOT migrate to the new `bootc` kickstart command until v1.0 — it
# blocks multi-disk and authenticated registries (per parent-operator
# handoff 2026-05-05).
ostreecontainer --url=ghcr.io/veilor-org/veilor-os:43 --transport=registry
# ── %post (chroot) — minimal; OCI image already has everything ──
# What we keep:
# - chage -d 0 admin so first SDDM login forces password change
# - hostname write (anaconda's --hostname doesn't always survive)
# - veilor-firstboot.service is enabled in the OCI image already
%post
set -uo pipefail
echo veilor > /etc/hostname
chage -d 0 admin || true
%end

View file

@ -0,0 +1,7 @@
[Unit]
Description=veilor-doctor — system health + drift check
After=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/veilor-doctor --quiet

View file

@ -0,0 +1,10 @@
[Unit]
Description=veilor-doctor weekly drift check
[Timer]
OnCalendar=weekly
Persistent=true
RandomizedDelaySec=30m
[Install]
WantedBy=timers.target

View file

@ -0,0 +1,17 @@
[Unit]
Description=veilor-os one-time post-install TUI (first login)
After=graphical.target
ConditionPathExists=!/var/lib/veilor/postinstall-complete
[Service]
Type=oneshot
ExecStart=/usr/local/bin/veilor-postinstall
StandardInput=tty
StandardOutput=tty
StandardError=journal
TTYPath=/dev/tty1
TTYReset=yes
TTYVHangup=yes
[Install]
WantedBy=graphical.target multi-user.target

View file

@ -147,23 +147,44 @@ PUBLIC_IP=$(curl -s --max-time 3 ifconfig.me 2>/dev/null || echo "")
|| check Network public_ip fail "lookup timed out" || check Network public_ip fail "lookup timed out"
# ── 5. Updates ────────────────────────────────────────────────────── # ── 5. Updates ──────────────────────────────────────────────────────
LAST_DNF=$(sudo -n dnf history list 2>/dev/null \ # v0.7+ atomic — bootc replaces dnf as the update channel. Parse
| awk 'NR==4 {for(i=4;i<NF;i++)printf "%s ", $i; print $NF; exit}') # `bootc status --json` for the booted deployment + staged/cached image
[[ -n $LAST_DNF ]] && check Updates last_dnf pass "$LAST_DNF" \ # age. Fall back to dnf history if bootc not present (legacy v0.5.x).
|| check Updates last_dnf pass "(unknown — try \`sudo dnf history\`)" if have bootc; then
BOOTC_JSON=$(sudo -n bootc status --json 2>/dev/null || echo "")
# `dnf check-update` exits 100 if updates available, 0 if not. if [[ -n $BOOTC_JSON ]] && have jq; then
sudo -n dnf check-update -q >/dev/null 2>&1 BOOTED_IMG=$(jq -r '.status.booted.image.image.image // "unknown"' <<<"$BOOTC_JSON")
RC=$? BOOTED_DIGEST=$(jq -r '.status.booted.image.imageDigest // ""' <<<"$BOOTC_JSON")
case $RC in check Updates booted_image pass "${BOOTED_IMG}@${BOOTED_DIGEST:0:12}"
0) check Updates pending pass "system up-to-date" ;; STAGED=$(jq -r '.status.staged.image.image.image // ""' <<<"$BOOTC_JSON")
100) if [[ -n $STAGED ]]; then
AVAIL=$(sudo -n dnf check-update -q 2>/dev/null \ check Updates staged_image fail "staged: $STAGED — reboot to apply"
| awk 'NF>=3 && $1!~/^Last/ {n++} END {print n+0}') else
check Updates pending fail "${AVAIL} update(s) available — run \`veilor-update\`" check Updates staged_image pass "no staged update"
;; fi
*) check Updates pending fail "dnf check-update returned $RC (need sudo?)" ;; else
esac check Updates bootc_state pass "bootc present (jq missing — install for richer detail)"
fi
elif have dnf; then
# Legacy v0.5.x kickstart-installed system.
LAST_DNF=$(sudo -n dnf history list 2>/dev/null \
| awk 'NR==4 {for(i=4;i<NF;i++)printf "%s ", $i; print $NF; exit}')
[[ -n $LAST_DNF ]] && check Updates last_dnf pass "$LAST_DNF" \
|| check Updates last_dnf pass "(unknown — try \`sudo dnf history\`)"
sudo -n dnf check-update -q >/dev/null 2>&1
RC=$?
case $RC in
0) check Updates pending pass "system up-to-date" ;;
100)
AVAIL=$(sudo -n dnf check-update -q 2>/dev/null \
| awk 'NF>=3 && $1!~/^Last/ {n++} END {print n+0}')
check Updates pending fail "${AVAIL} update(s) available — run \`veilor-update\`"
;;
*) check Updates pending fail "dnf check-update returned $RC (need sudo?)" ;;
esac
else
check Updates channel fail "neither bootc nor dnf available"
fi
# ── 6. veilor services ────────────────────────────────────────────── # ── 6. veilor services ──────────────────────────────────────────────
for unit in veilor-firstboot.service veilor-modules-lock.service; do for unit in veilor-firstboot.service veilor-modules-lock.service; do

View file

@ -0,0 +1,178 @@
#!/usr/bin/bash
# veilor-postinstall — first-login TUI on v0.7+ atomic systems.
#
# Runs ONCE on first SDDM login via the user-mode systemd unit
# `veilor-postinstall.service`. Asks the operator for the small set
# of decisions we deliberately defer from install time:
# - keyboard / locale
# - hostname override
# - GPU drivers (NVIDIA layered via rpm-ostree, mesa = no-op)
# - package preset (dev / media / homelab — additive, opt-out)
# - bluetooth opt-in
# - USBGuard policy snapshot
# - veilor-doctor first run
# Writes /var/lib/veilor/postinstall-complete on success and disables
# its own autostart unit. Idempotent: safe to re-run.
#
# Style: gum if present, plain bash read fallback. No decorative ASCII.
set -uo pipefail
export TERM="${TERM:-linux}"
STATE_DIR=/var/lib/veilor
DONE_MARKER="$STATE_DIR/postinstall-complete"
LOG=/var/log/veilor-postinstall.log
have() { command -v "$1" >/dev/null 2>&1; }
GUM=$(have gum && echo gum || echo "")
# Always log + tee to stdout for live progress.
mkdir -p "$STATE_DIR" 2>/dev/null || true
exec > >(tee -a "$LOG") 2>&1
if [[ -e $DONE_MARKER && ${1:-} != "--force" ]]; then
echo "veilor-postinstall already ran (marker: $DONE_MARKER). Pass --force to re-run."
exit 0
fi
# ── Wrappers ────────────────────────────────────────────────────────
choose() {
local header=$1; shift
if [[ -n $GUM ]]; then
gum choose --header "$header" "$@"
else
echo
echo "$header"
local i=1
for opt in "$@"; do printf ' %d) %s\n' "$i" "$opt"; ((i++)); done
local n
read -rp " choice (1-$#): " n
[[ $n -ge 1 && $n -le $# ]] || return 1
eval "echo \${$n}"
fi
}
ask() {
local prompt=$1 default=${2:-}
if [[ -n $GUM ]]; then
gum input --header "$prompt" --value "$default"
else
local v
read -rp "$prompt [$default] " v
echo "${v:-$default}"
fi
}
confirm() {
local prompt=$1
if [[ -n $GUM ]]; then
gum confirm "$prompt" && return 0 || return 1
else
read -rp "$prompt [y/N] " y
[[ ${y,,} == y* ]]
fi
}
say() {
if [[ -n $GUM ]]; then
gum style --foreground 212 --bold "$1"
else
printf '\n=== %s ===\n' "$1"
fi
}
# Need root for several actions; re-exec under sudo if not root.
if [[ $EUID -ne 0 ]]; then
say "veilor-postinstall: sudo required"
exec sudo -E bash "$0" "$@"
fi
say "veilor-postinstall — one-time setup"
echo " This runs once. Each step is skippable. Defaults are sane."
echo
# ── 1. Keyboard layout ──────────────────────────────────────────────
KB=$(choose "Keyboard layout" us gb de fr es ru "skip") || KB=skip
if [[ $KB != skip ]]; then
localectl set-keymap "$KB" 2>/dev/null || true
echo " [OK] keymap = $KB"
fi
# ── 2. Locale ───────────────────────────────────────────────────────
LOC=$(choose "Locale" en_US.UTF-8 en_GB.UTF-8 de_DE.UTF-8 fr_FR.UTF-8 "skip") || LOC=skip
if [[ $LOC != skip ]]; then
localectl set-locale LANG="$LOC" 2>/dev/null || true
echo " [OK] locale = $LOC"
fi
# ── 3. Hostname ─────────────────────────────────────────────────────
HN=$(ask "Hostname" "veilor")
if [[ -n $HN && $HN != $(hostnamectl --static 2>/dev/null) ]]; then
hostnamectl set-hostname "$HN"
echo " [OK] hostname = $HN"
fi
# ── 4. GPU drivers ──────────────────────────────────────────────────
GPU=$(choose "GPU drivers" "Skip (use mesa defaults)" "NVIDIA proprietary (akmod-nvidia)" "Intel/AMD mesa (no-op)") || GPU=skip
case "$GPU" in
*NVIDIA*)
say "Layering NVIDIA driver — this takes a few minutes"
rpm-ostree install --idempotent akmod-nvidia xorg-x11-drv-nvidia-cuda \
&& echo " [OK] NVIDIA driver layered (reboot to use)" \
|| echo " [WARN] NVIDIA layer failed; check rpm-ostree status"
;;
*) echo " (skipped GPU layering)" ;;
esac
# ── 5. Package presets (multi-select) ───────────────────────────────
say "Package presets — pick any combination (skip = none)"
PRESET_DEV="git tmux vim-enhanced htop podman skopeo"
PRESET_MEDIA="vlc obs-studio"
PRESET_HOMELAB="wireguard-tools jq yq tmux"
PICKED=()
confirm "Install dev preset? ($PRESET_DEV)" && PICKED+=($PRESET_DEV) || true
confirm "Install media preset? ($PRESET_MEDIA)" && PICKED+=($PRESET_MEDIA) || true
confirm "Install homelab preset? ($PRESET_HOMELAB)" && PICKED+=($PRESET_HOMELAB) || true
if (( ${#PICKED[@]} > 0 )); then
# de-dupe
UNIQ=$(printf '%s\n' "${PICKED[@]}" | sort -u | tr '\n' ' ')
say "Layering: $UNIQ"
rpm-ostree install --idempotent $UNIQ \
&& echo " [OK] preset packages layered (reboot to use)" \
|| echo " [WARN] preset layer failed; check rpm-ostree status"
fi
# ── 6. Bluetooth ────────────────────────────────────────────────────
if confirm "Enable Bluetooth?"; then
systemctl enable --now bluetooth.service 2>/dev/null || true
echo " [OK] bluetooth enabled"
else
echo " (skipped bluetooth)"
fi
# ── 7. USBGuard snapshot ────────────────────────────────────────────
say "USBGuard policy snapshot"
echo " Plug in EVERY USB device you trust right now (keyboard,"
echo " mouse, dock, yubikey, etc.) before continuing."
if confirm "Snapshot current USB devices into the allowlist?"; then
usbguard generate-policy > /etc/usbguard/rules.conf \
&& echo " [OK] policy written to /etc/usbguard/rules.conf" \
|| echo " [WARN] generate-policy failed"
systemctl restart usbguard 2>/dev/null || true
fi
# ── 8. veilor-doctor ────────────────────────────────────────────────
if confirm "Run veilor-doctor now?"; then
veilor-doctor || true
fi
# ── Done ────────────────────────────────────────────────────────────
date -u +"%Y-%m-%dT%H:%M:%SZ" > "$DONE_MARKER"
say "veilor-postinstall complete"
echo " Marker written: $DONE_MARKER"
echo " Disabling autostart unit so this never runs again."
systemctl --user --global disable veilor-postinstall.service 2>/dev/null || true
systemctl disable veilor-postinstall.service 2>/dev/null || true
echo
echo " If you layered any packages or drivers, reboot to activate."

View file

@ -1,25 +1,22 @@
#!/usr/bin/bash #!/usr/bin/bash
# veilor-update — system update wrapper. # veilor-update — atomic update wrapper for v0.7+ (bootc + rpm-ostree).
# Wraps `dnf upgrade --refresh` + `flatpak update` behind a single command. #
# User-facing CLI shipped in /usr/local/bin/. v0.6 ergonomic tooling. # Wraps `bootc upgrade` + flatpak update behind a single command.
# Pre-checks rollback availability, pauses auditd while staging the
# new image, prints a clear post-state summary, and offers reboot.
# #
# Exit codes: # Exit codes:
# 0 success # 0 success (with or without pending reboot)
# 1 dnf failed # 1 bootc upgrade failed
# 2 flatpak failed (dnf still ran successfully) # 2 flatpak failed (bootc still ran successfully)
# 3 no network # 3 no network
#
# Uses `gum` for spinner output if present, falls back to plain stdout.
set -uo pipefail set -uo pipefail
# ── Helpers ─────────────────────────────────────────────────────────
have() { command -v "$1" >/dev/null 2>&1; } have() { command -v "$1" >/dev/null 2>&1; }
GUM=$(have gum && echo gum || echo "") GUM=$(have gum && echo gum || echo "")
say() { say() {
# Print a status line. Coloured if gum present, else plain.
if [[ -n $GUM ]]; then if [[ -n $GUM ]]; then
gum style --foreground 212 --bold "$1" gum style --foreground 212 --bold "$1"
else else
@ -27,46 +24,50 @@ say() {
fi fi
} }
run_with_spinner() { confirm() {
local title=$1; shift local prompt=$1
if [[ -n $GUM ]]; then if [[ -n $GUM ]]; then
gum spin --spinner dot --title "$title" -- "$@" gum confirm "$prompt"
else else
echo "[+] $title" read -r -p "$prompt [y/N] " yn
"$@" [[ ${yn,,} == y* ]]
fi fi
} }
# ── Pre-flight: network check ─────────────────────────────────────── # ── Pre-flight: network ─────────────────────────────────────────────
say "veilor-update: checking network" say "veilor-update: checking network"
if ! ping -c 1 -W 2 mirrors.fedoraproject.org >/dev/null 2>&1; then if ! ping -c 1 -W 2 1.1.1.1 >/dev/null 2>&1; then
echo echo " No network. Connect and re-run \`veilor-update\`."
echo " No route to mirrors.fedoraproject.org."
echo " Connect to a network and re-run \`veilor-update\`."
exit 3 exit 3
fi fi
# ── Snapshot kernel before upgrade so we can warn about reboot need ─ # ── Pre-flight: rollback target available ───────────────────────────
KERNEL_BEFORE=$(uname -r) # bootc has two deployments by design (booted + rollback). If
# something's wrong we want the user to see it before staging more.
# ── DNF upgrade ───────────────────────────────────────────────────── if have bootc; then
say "veilor-update: refreshing DNF metadata + applying updates" say "veilor-update: bootc status"
# Capture upgrade output so we can count packages afterwards. Tee to bootc status || true
# stdout for live progress; swallow into a tempfile for the count. else
LOG=$(mktemp -t veilor-update.XXXXXX) echo " bootc not present — this CLI targets v0.7+ atomic systems."
trap 'rm -f "$LOG"' EXIT
if ! sudo dnf upgrade --refresh -y 2>&1 | tee "$LOG"; then
echo
echo " dnf upgrade failed. See output above."
exit 1 exit 1
fi fi
# ── Count packages updated ────────────────────────────────────────── # ── Pause auditd while staging ──────────────────────────────────────
# DNF prints "Upgraded: N", "Installed: N", "Removed: N" at end. # Reduces audit log noise during the heavy fs writes; resume after.
# Sum the upgrade/install lines for the user-visible total. AUDIT_PAUSED=0
UPDATED=$(grep -E '^(Upgraded|Installed)\b' "$LOG" 2>/dev/null \ if systemctl is-active auditd >/dev/null 2>&1; then
| awk -F: '{ gsub(/[^0-9]/,"",$2); s+=$2 } END { print s+0 }') if sudo systemctl stop auditd 2>/dev/null; then
AUDIT_PAUSED=1
fi
fi
trap '[[ $AUDIT_PAUSED == 1 ]] && sudo systemctl start auditd 2>/dev/null || true' EXIT
# ── bootc upgrade ───────────────────────────────────────────────────
say "veilor-update: bootc upgrade"
if ! sudo bootc upgrade; then
echo " bootc upgrade failed. See output above."
exit 1
fi
# ── Flatpak (best-effort) ─────────────────────────────────────────── # ── Flatpak (best-effort) ───────────────────────────────────────────
FLATPAK_RC=0 FLATPAK_RC=0
@ -74,21 +75,20 @@ if have flatpak; then
say "veilor-update: updating flatpaks" say "veilor-update: updating flatpaks"
if ! flatpak update -y; then if ! flatpak update -y; then
FLATPAK_RC=2 FLATPAK_RC=2
echo " flatpak update failed; continuing anyway." echo " flatpak update failed; continuing."
fi fi
else
echo " (flatpak not installed — skipping)"
fi fi
# ── Post-update: reboot hint if kernel changed ────────────────────── # ── Post-update summary ─────────────────────────────────────────────
KERNEL_AFTER_LATEST=$(rpm -q kernel --last 2>/dev/null \
| awk 'NR==1 { sub(/^kernel-/,"",$1); print $1 }')
say "veilor-update: complete" say "veilor-update: complete"
printf ' Packages updated : %s\n' "${UPDATED:-0}" bootc status 2>/dev/null | head -20 || true
printf ' Running kernel : %s\n' "$KERNEL_BEFORE"
if [[ -n ${KERNEL_AFTER_LATEST:-} && $KERNEL_AFTER_LATEST != "$KERNEL_BEFORE" ]]; then # ── Reboot prompt ───────────────────────────────────────────────────
printf ' Newest kernel : %s (reboot suggested)\n' "$KERNEL_AFTER_LATEST" # bootc always writes the new image into the staged deployment; reboot
# is required for it to become the running root.
if confirm " Reboot now to activate the new image?"; then
say "veilor-update: rebooting"
sudo systemctl reboot
fi fi
exit $FLATPAK_RC exit $FLATPAK_RC