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
42 changed files with 3002 additions and 291 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

View file

@ -1,3 +1,5 @@
# TODO: SHA-pin all uses: tags to commit SHAs (Agent 8 audit recommendation).
# Tracked separately so this PR can land without long web lookups.
name: Build veilor-os ISO
on:
@ -20,158 +22,135 @@ on:
types: [published]
permissions:
contents: write # needed for action-gh-release to create+update ci-latest
contents: write # needed for action-gh-release to create+update ci-latest
id-token: write # cosign keyless OIDC + attest-build-provenance
attestations: write # attest-build-provenance writes the attestation
jobs:
build:
name: Build live ISO
runs-on: ubuntu-24.04
# nullstone label resolves to a privileged Fedora 43 container per
# the runner's RUNNER_LABELS map. Build runs directly in this job
# container — no nested docker-run-action, no bind-mount juggling.
runs-on: nullstone
timeout-minutes: 90
steps:
- name: Checkout
uses: actions/checkout@v4
# Pinned to last v4 tag confirmed to ship on node20. v4.2+ ships
# node24 which forgejo-runner v6.4.0 (node20) cannot exec.
uses: actions/checkout@v4.1.7
- name: Free up disk
- name: Install build tooling (Fedora)
run: |
sudo rm -rf /opt/hostedtoolcache /usr/share/dotnet /usr/local/lib/android /usr/local/share/boost
sudo apt-get clean
df -h
set -euxo pipefail
dnf -y upgrade --refresh
dnf -y install \
lorax \
livecd-tools \
pykickstart \
python3-imgcreate \
anaconda-tui \
squashfs-tools \
xorriso \
createrepo_c \
git \
which \
shadow-utils \
syslinux \
tar \
curl \
sudo
- name: Run build inside Fedora 43 container
uses: addnab/docker-run-action@v3
with:
image: registry.fedoraproject.org/fedora:43
options: |
--privileged
-v ${{ github.workspace }}:/work
-v /dev:/dev
--tmpfs /tmp:rw,nosuid,nodev,exec,size=16G
run: |
set -euxo pipefail
- name: Vendor gum binary into overlay
run: |
set -euxo pipefail
GUM_VERSION="0.17.0"
GUM_URL="https://github.com/charmbracelet/gum/releases/download/v${GUM_VERSION}/gum_${GUM_VERSION}_Linux_x86_64.tar.gz"
GUM_SHA256="69ee169bd6387331928864e94d47ed01ef649fbfe875baed1bbf27b5377a6fdb"
mkdir -p overlay/usr/local/bin
curl -fsSL "$GUM_URL" -o /tmp/gum.tgz
echo "$GUM_SHA256 /tmp/gum.tgz" | sha256sum -c -
tar -xzf /tmp/gum.tgz -C /tmp/
install -m 0755 "/tmp/gum_${GUM_VERSION}_Linux_x86_64/gum" overlay/usr/local/bin/gum
overlay/usr/local/bin/gum --version
echo "[OK] gum ${GUM_VERSION} vendored into overlay/usr/local/bin/"
# Update Fedora image to latest packages — guarantees pcre2 +
# libselinux + selinux-policy are matched (the local build's
# core problem). CI runners always start fresh, no version skew.
dnf -y upgrade --refresh
- name: Build ISO with livecd-creator
run: |
set -euxo pipefail
# Install build tooling
dnf -y install \
lorax \
livecd-tools \
pykickstart \
python3-imgcreate \
anaconda-tui \
squashfs-tools \
xorriso \
createrepo_c \
git \
which \
shadow-utils \
syslinux \
tar \
curl
# PATCH: livecd-creator bug — __get_efi_image_stanza writes
# `root=live:LABEL=...` instead of `live:CDLABEL=...` for dracut.
# Result: dracut hangs on parse-livenet looking for non-CD label.
# Fix in-place before running build.
LIVE_PY=$(python3 -c 'import imgcreate, os; print(os.path.dirname(imgcreate.__file__))')/live.py
sed -i 's|"live:LABEL=%(fslabel)s"|"live:CDLABEL=%(fslabel)s"|g' "$LIVE_PY"
grep -n 'CDLABEL=%(fslabel)s' "$LIVE_PY" || { echo "[ERR] patch failed"; exit 1; }
echo "[OK] livecd-creator patched: LABEL= → CDLABEL= for EFI dracut stanza"
# Vendor gum binary onto the ISO so the TTY1 installer can use
# Charm.sh TUI primitives. gum is not packaged in Fedora repos,
# so pull the upstream release tarball pinned by sha256.
GUM_VERSION="0.17.0"
GUM_URL="https://github.com/charmbracelet/gum/releases/download/v${GUM_VERSION}/gum_${GUM_VERSION}_Linux_x86_64.tar.gz"
GUM_SHA256="69ee169bd6387331928864e94d47ed01ef649fbfe875baed1bbf27b5377a6fdb"
mkdir -p /work/overlay/usr/local/bin
curl -fsSL "$GUM_URL" -o /tmp/gum.tgz
echo "$GUM_SHA256 /tmp/gum.tgz" | sha256sum -c -
tar -xzf /tmp/gum.tgz -C /tmp/
install -m 0755 "/tmp/gum_${GUM_VERSION}_Linux_x86_64/gum" /work/overlay/usr/local/bin/gum
/work/overlay/usr/local/bin/gum --version
echo "[OK] gum ${GUM_VERSION} vendored into overlay/usr/local/bin/"
# CI uses ks-ci.ks (no local fix-repo line). Generated from main ks.
# Drop `updates` repo: previously 404'd on repodata zchunk during
# Fedora mid-push windows. Base 43 ships the selinux-policy fix.
sed -e '/veilor-fix/d' \
-e '/^shutdown$/d' \
-e '/repo --name=updates/d' \
kickstart/veilor-os.ks > kickstart/veilor-os-ci.ks
cd /work
ksvalidator kickstart/veilor-os-ci.ks
mkdir -p build/out
# PATCH: livecd-creator bug — __get_efi_image_stanza writes
# `root=live:LABEL=...` instead of `live:CDLABEL=...` for dracut.
# Result: dracut hangs on parse-livenet looking for non-CD label.
# Fix in-place before running build.
LIVE_PY=$(python3 -c 'import imgcreate, os; print(os.path.dirname(imgcreate.__file__))')/live.py
sed -i 's|"live:LABEL=%(fslabel)s"|"live:CDLABEL=%(fslabel)s"|g' "$LIVE_PY"
grep -n 'CDLABEL=%(fslabel)s' "$LIVE_PY" || { echo "[ERR] patch failed"; exit 1; }
echo "[OK] livecd-creator patched: LABEL= → CDLABEL= for EFI dracut stanza"
# The kickstart's %post --nochroot probes a fixed list of
# candidate paths to locate the repo source for overlay/scripts
# copy. /work is the canonical CI candidate; symlink the live
# workspace there so the existing probe finds it.
ln -sfn "$GITHUB_WORKSPACE" /work
# CI uses ks-ci.ks (no local fix-repo line). Generated from main ks.
# Also strip flags livecd-creator doesn't recognize.
# Drop `updates` repo: 3 consecutive builds 404'd on its
# repodata zchunk file across all mirrors — Fedora infra issue
# mid-push window. Original reason for `updates` (selinux-policy
# 43.7 pcre2 fix) is no longer needed; base 43 ships fixed.
sed -e '/veilor-fix/d' \
-e '/^shutdown$/d' \
-e '/repo --name=updates/d' \
kickstart/veilor-os.ks > kickstart/veilor-os-ci.ks
mkdir -p /var/lmc /var/lmc-cache
livecd-creator \
--verbose \
--config kickstart/veilor-os-ci.ks \
--fslabel "veilor-os-43" \
--title "veilor-os" \
--product "veilor-os" \
--releasever "${{ github.event.inputs.releasever || '43' }}" \
--tmpdir /var/lmc \
--cache /var/lmc-cache 2>&1 | tee build/out/build.log
ksvalidator kickstart/veilor-os-ci.ks
mkdir -p build/out
- name: Graft veilor source tree onto ISO
run: |
set -euxo pipefail
ISO_FILE=$(ls ./*.iso 2>/dev/null | head -1)
[ -n "$ISO_FILE" ] || { echo "[ERR] no ISO produced by livecd-creator"; exit 1; }
echo "[INFO] grafting /veilor/ onto $ISO_FILE"
# livecd-creator (livecd-tools) — purpose-built for live ISOs.
# Handles EFI/BOOT + isohybrid + grafting that livemedia-creator
# --make-iso --no-virt does not. Produces UEFI+BIOS bootable ISO.
# --tmpdir /var/lmc to avoid GitHub Actions /tmp tmpfs constraints.
# /var on the runner is the host's ext4 (~80GB free post-disk-cleanup).
mkdir -p /var/lmc /var/lmc-cache
livecd-creator \
--verbose \
--config kickstart/veilor-os-ci.ks \
--fslabel "veilor-os-43" \
--title "veilor-os" \
--product "veilor-os" \
--releasever "${{ github.event.inputs.releasever || '43' }}" \
--tmpdir /var/lmc \
--cache /var/lmc-cache 2>&1 | tee build/out/build.log
xorriso -indev "$ISO_FILE" -report_el_torito as_mkisofs 2>&1 | tee /tmp/iso-boot.txt || true
ORIG_FLAGS=$(xorriso -indev "$ISO_FILE" -report_el_torito as_mkisofs 2>/dev/null | \
grep -v '^xorriso :' | grep -E '^-' | tr '\n' ' ')
[ -n "$ORIG_FLAGS" ] || { echo "[ERR] could not extract boot stanza from $ISO_FILE"; exit 1; }
# Graft veilor source tree onto the ISO so the installer-generated
# kickstart's `%post --nochroot` can find SRC at
# /run/install/repo/veilor/{overlay,scripts,assets}/ when the user
# promotes the live ISO into a real install.
ISO_FILE=$(ls /work/*.iso 2>/dev/null | head -1)
[ -n "$ISO_FILE" ] || { echo "[ERR] no ISO produced by livecd-creator"; exit 1; }
echo "[INFO] grafting /veilor/ onto $ISO_FILE"
mkdir -p /tmp/iso-mod
xorriso -osirrox on -indev "$ISO_FILE" -extract / /tmp/iso-mod
chmod -R u+w /tmp/iso-mod
mkdir -p /tmp/iso-mod/veilor
cp -a overlay scripts assets /tmp/iso-mod/veilor/
# Extract original ISO's exact boot stanza so the rebuild matches
# livecd-creator's layout byte-for-byte. This is immune to upstream
# Fedora layout changes (e.g. images/ vs isolinux/ for efiboot.img,
# partition geometry flags, hybrid MBR/GPT options).
xorriso -indev "$ISO_FILE" -report_el_torito as_mkisofs 2>&1 | tee /tmp/iso-boot.txt || true
ORIG_FLAGS=$(xorriso -indev "$ISO_FILE" -report_el_torito as_mkisofs 2>/dev/null | \
grep -v '^xorriso :' | grep -E '^-' | tr '\n' ' ')
[ -n "$ORIG_FLAGS" ] || { echo "[ERR] could not extract boot stanza from $ISO_FILE"; exit 1; }
echo "[INFO] re-pack flags from original ISO: $ORIG_FLAGS"
eval xorriso -as mkisofs \
-volid "veilor-os-43" \
$ORIG_FLAGS \
-o "${ISO_FILE}.tmp" /tmp/iso-mod
mv "${ISO_FILE}.tmp" "$ISO_FILE"
rm -rf /tmp/iso-mod
mkdir -p /tmp/iso-mod
xorriso -osirrox on -indev "$ISO_FILE" -extract / /tmp/iso-mod
chmod -R u+w /tmp/iso-mod
mkdir -p /tmp/iso-mod/veilor
cp -a /work/overlay /work/scripts /work/assets /tmp/iso-mod/veilor/
mv "$ISO_FILE" build/out/
# Replay the exact stanza captured above. eval is needed because
# ORIG_FLAGS contains multiple flag/value pairs that must word-split.
eval xorriso -as mkisofs \
-volid "veilor-os-43" \
$ORIG_FLAGS \
-o "${ISO_FILE}.tmp" /tmp/iso-mod
mv "${ISO_FILE}.tmp" "$ISO_FILE"
rm -rf /tmp/iso-mod
echo "[OK] /veilor/ grafted onto $ISO_FILE"
# Move output ISO to expected dir
mv ./veilor-os-43.iso build/out/ 2>/dev/null || mv ./*.iso build/out/ 2>/dev/null || true
# Rename + checksum
ISO_NAME="veilor-os-${{ github.event.inputs.releasever || '43' }}-$(date +%Y%m%d-%H%M%S).iso"
cd build/out
for f in *.iso; do
[[ -f $f && $f != $ISO_NAME ]] && mv "$f" "$ISO_NAME"
done
sha256sum "$ISO_NAME" > "$ISO_NAME.sha256"
ls -lh "$ISO_NAME"
ISO_NAME="veilor-os-${{ github.event.inputs.releasever || '43' }}-$(date +%Y%m%d-%H%M%S).iso"
cd build/out
for f in *.iso; do
[[ -f $f && $f != "$ISO_NAME" ]] && mv "$f" "$ISO_NAME"
done
sha256sum "$ISO_NAME" > "$ISO_NAME.sha256"
ls -lh "$ISO_NAME"
# ── ISO publish ────────────────────────────────────────────────────
# GH Release asset size limit = 2 GiB. Our ISO ~2.8 GiB. Split into
@ -181,9 +160,6 @@ jobs:
- name: Split ISO into 2GiB chunks
if: success() && github.ref == 'refs/heads/main'
run: |
# ISO + sidecars created by Fedora container as root. Reclaim
# ownership so this step (running as runner user) can write.
sudo chown -R "$(id -u):$(id -g)" build/out
cd build/out
ISO=$(ls *.iso | head -1)
[ -n "$ISO" ] || { echo "[ERR] no ISO"; exit 1; }
@ -197,9 +173,45 @@ jobs:
echo "[OK] split into:"
ls "${ISO}".part-*
- name: Publish to ci-latest rolling prerelease
if: success() && github.ref == 'refs/heads/main'
uses: softprops/action-gh-release@v2
- name: Install cosign
if: (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.server_url == 'https://github.com'
# Pinned to last v3 release confirmed node20.
uses: sigstore/cosign-installer@v3.7.0
- name: Sign ISO parts (keyless)
if: (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.server_url == 'https://github.com'
run: |
cd build/out
for f in *.part-*; do
cosign sign-blob --yes "$f" \
--output-signature "$f.sig" \
--output-certificate "$f.pem"
done
- name: Generate SBOM (SPDX)
if: (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.server_url == 'https://github.com'
# Pinned to last v0.17 release that ships node20.
uses: anchore/sbom-action@v0.17.2
with:
path: build/out
format: spdx-json
output-file: build/out/veilor-os.spdx.json
- name: Build provenance attestation
if: (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && 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-path: 'build/out/*.iso.part-*'
# GitHub-only: softprops/action-gh-release uses the GitHub REST API
# which Forgejo doesn't expose at the same endpoints. When this
# workflow runs on git.s8n.ru the step below (Forgejo) handles
# publishing instead.
- name: Publish to ci-latest rolling prerelease (GitHub)
if: success() && github.ref == 'refs/heads/main' && github.server_url == 'https://github.com'
# Pinned to last v2 tag confirmed to ship on node20.
uses: softprops/action-gh-release@v2.0.4
with:
tag_name: ci-latest
name: "ci-latest (auto)"
@ -220,6 +232,70 @@ jobs:
files: |
build/out/*.iso.part-*
build/out/*.sha256
build/out/*.sig
build/out/*.pem
build/out/*.spdx.json
# Forgejo equivalent: drop+recreate ci-latest release via the
# Forgejo REST API, then upload chunks. Only runs when not on GitHub.
# All ${{ }} interpolations are vetted (repo coords + signed SHA).
- name: Publish to ci-latest rolling prerelease (Forgejo)
if: success() && github.ref == 'refs/heads/main' && 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="ci-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
echo "[INFO] deleting existing ci-latest release id=$REL_ID"
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 main. Latest commit: ${GIT_SHA}.
ISO is split into chunks. Reassemble:
cat veilor-os-*.iso.part-* > veilor-os.iso
sha256sum -c veilor-os-*.iso.parts.sha256
Or use test/auto-install.sh (handles reassembly automatically).
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': 'main',
'name': 'ci-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 Forgejo release"; exit 1; }
echo "[OK] Forgejo release id=$REL_ID created"
cd build/out
for f in *.iso.part-* *.sha256; do
[ -f "$f" ] || continue
echo "[INFO] uploading $f"
curl -fsSL -X POST -H "Authorization: token ${FORGEJO_TOKEN}" \
-F "attachment=@${f}" \
"${FORGEJO_API}/repos/${REPO}/releases/${REL_ID}/assets?name=${f}"
done
echo "[OK] all assets uploaded to Forgejo ci-latest"
# Build log on failure: print inline + skip artifact upload to avoid
# quota wall. Job log retains everything anyway.
@ -231,10 +307,36 @@ jobs:
echo "─── anaconda program.log ───"
find build/out/build/anaconda -name 'program.log' -exec tail -100 {} \; 2>/dev/null || echo "(no anaconda log)"
- name: Attach to release on tag
if: github.event_name == 'release'
uses: softprops/action-gh-release@v2
# GitHub-only: same restriction as ci-latest publish.
- name: Attach to release on tag (GitHub)
if: github.event_name == 'release' && github.server_url == 'https://github.com'
# Pinned to last v2 tag confirmed to ship on node20.
uses: softprops/action-gh-release@v2.0.4
with:
files: |
build/out/*.iso
build/out/*.sha256
# Forgejo equivalent for tag-driven release uploads. The release
# is assumed to already exist (Forgejo creates it from the tag);
# we only attach assets here.
- name: Attach to release on tag (Forgejo)
if: github.event_name == 'release' && github.server_url != 'https://github.com'
env:
FORGEJO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
FORGEJO_API: ${{ github.server_url }}/api/v1
REPO: ${{ github.repository }}
REF_NAME: ${{ github.ref_name }}
run: |
set -euo pipefail
REL_JSON=$(curl -fsSL -H "Authorization: token ${FORGEJO_TOKEN}" \
"${FORGEJO_API}/repos/${REPO}/releases/tags/${REF_NAME}")
REL_ID=$(echo "$REL_JSON" | grep -oE '"id":\s*[0-9]+' | head -1 | grep -oE '[0-9]+')
[ -n "$REL_ID" ] || { echo "[ERR] no Forgejo release for tag ${REF_NAME}"; exit 1; }
cd build/out
for f in *.iso *.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

View file

@ -12,7 +12,8 @@ jobs:
container:
image: registry.fedoraproject.org/fedora:43
steps:
- uses: actions/checkout@v4
# Pinned to last v4 tag confirmed to ship on node20.
- uses: actions/checkout@v4.1.7
- run: dnf -y install pykickstart
- run: ksvalidator kickstart/veilor-os.ks
@ -20,7 +21,8 @@ jobs:
name: Shell scripts
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
# Pinned to last v4 tag confirmed to ship on node20.
- uses: actions/checkout@v4.1.7
- uses: ludeeus/action-shellcheck@master
with:
severity: warning
@ -30,7 +32,8 @@ jobs:
name: No personal/onyx leaks
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
# Pinned to last v4 tag confirmed to ship on node20.
- uses: actions/checkout@v4.1.7
- name: Grep for leaks
run: |
set -e

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"

3
.gitignore vendored
View file

@ -13,4 +13,7 @@ secrets/
*.pem
test/veilor-vm.qcow2
test/veilor-vm.nvram*
test/auto-install-vm.qcow2
test/auto-install-vm.nvram*
.claude/worktrees/
**/cosign.key

View file

@ -11,6 +11,18 @@ future maintainers can see why a change exists, not just what it changes.
## [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
- v0.3 polish — Plymouth black theme, SDDM theme, Konsole profile,

104
README.md
View file

@ -2,7 +2,7 @@
> **Hardened minimal Fedora KDE spin. Black-on-black. Locked down by default.**
[![Build veilor-os ISO](https://github.com/veilor-org/veilor-os/actions/workflows/build-iso.yml/badge.svg)](https://github.com/veilor-org/veilor-os/actions/workflows/build-iso.yml)
[![Build veilor-os ISO](https://git.s8n.ru/veilor-org/veilor-os/badges/workflows/build-iso.yml/badge.svg)](https://git.s8n.ru/veilor-org/veilor-os/actions?workflow=build-iso.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
veilor-os is a Fedora 43 KDE Plasma remix for operators who want a clean,
@ -30,6 +30,11 @@ brittleness, bootloader install via `gen_grub_cfgstub`); current focus
is the v0.5.32 blocker list from the
[2026-05-05 9-agent research wave](docs/research/2026-05-05-agent-wave/README.md).
Primary git host: <https://git.s8n.ru/veilor-org/veilor-os>. The GitHub
mirror was disabled 2026-05-06; this repo is private-by-default on
Forgejo. ISO builds and CI artifacts are produced by the Forgejo runner
on nullstone — no GitHub Actions involvement.
What is **shipping**: hardening (SELinux, sysctl, USBGuard, fail2ban,
firewalld), KDE black theme, Fira Code system font, 3-mode power
management, single-prompt LUKS install, first-boot admin password flow,
@ -43,24 +48,46 @@ spike at v0.7**, **bootc-only at v1.0**.
---
## Quick install
## Quick install — v0.7+ (recommended, atomic / OCI)
```bash
# 1. Download the ISO (after public release; CI artifact for now)
# 1. Download the bootstrap installer ISO from Forgejo.
# https://git.s8n.ru/veilor-org/veilor-os/releases/tag/ci-latest
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
sync
# 3. Boot from USB, pick "Install veilor-os" from the menu.
# 4. Set a strong LUKS passphrase — the only prompt during install.
# 5. Reboot, remove USB.
# 6. On first boot: TTY prompts for an admin password (≥14 chars, mixed case,
# digit, symbol). Once accepted, SDDM starts. Log in as `admin`.
# 3. Boot from USB. Anaconda asks for LUKS passphrase + admin password.
# Anaconda then runs `ostreecontainer --url=git.s8n.ru/veilor-org/veilor-os:43`
# which populates / from the signed BlueBuild OCI image.
# 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).
---
@ -109,30 +136,49 @@ Full reference: [docs/HARDENING.md](docs/HARDENING.md).
## How veilor-os compares
| Feature | veilor-os | Stock Fedora KDE | Kicksecure |
|---|:-:|:-:|:-:|
| SELinux enforcing OOTB | yes | yes | yes |
| AppArmor | planned (v0.5) | no | yes |
| Secure Boot | yes (Fedora keys) | yes (Fedora keys) | configurable |
| LUKS2 with argon2id | default | optional | default |
| Single-prompt install (LUKS only) | yes | no | no |
| Root account locked by default | yes | no | yes |
| firewalld default zone = drop | yes | no | n/a (uses nftables) |
| USBGuard default-block | yes | no | yes |
| fail2ban + auditd OOTB | yes | no | partial |
| DNS-over-TLS by default | yes | no | yes |
| NTS-authenticated NTP | yes | no | yes |
| `init_on_alloc/free` (post-install) | yes (planned re-enable) | no | yes |
| Telemetry / phone-home | none | minimal | none |
| KDE Plasma branded theme | yes (black) | Breeze | n/a (XFCE) |
| Power-profile CLI | yes (3-mode) | partial | no |
| Reproducible kickstart-built ISO | yes | yes | yes (from Debian) |
| Base distro | Fedora 43 | Fedora 43 | Debian |
| Feature | veilor-os | Stock Fedora KDE | Kicksecure | secureblue |
|---|:-:|:-:|:-:|:-:|
| SELinux enforcing OOTB | yes | yes | yes | yes (custom policy) |
| AppArmor | deferred (post-v0.6 / v0.7 LSM stack) | no | yes | no |
| Secure Boot | yes (Fedora keys) | yes (Fedora keys) | configurable | yes (Fedora keys) |
| LUKS2 with argon2id | default | optional | default | default (Anaconda) |
| Single-prompt install (LUKS only) | yes | no | no | rebase via Anaconda |
| Root account locked by default | yes | no | yes | yes |
| firewalld default zone = drop | yes | no | n/a (nftables) | yes |
| USBGuard default-block | yes | no | yes | yes |
| fail2ban + auditd OOTB | yes | no | partial | partial (auditd) |
| DNS-over-TLS by default | yes | no | yes | yes |
| NTS-authenticated NTP | yes | no | yes | yes |
| `init_on_alloc/free` (post-install) | yes (planned re-enable) | no | yes | yes |
| Telemetry / phone-home | none | minimal | none | none |
| KDE Plasma branded theme | yes (black) | Breeze | n/a (XFCE) | upstream Kinoite |
| Power-profile CLI | yes (3-mode) | partial | no | no |
| Hardened browser (Trivalent / Mullvad) | yes (v0.6+) | no | no | yes (Trivalent shipped) |
| Atomic OCI image + signed base | v0.7 spike (BlueBuild) | no | no | yes (`bootc`) |
| Userns-remap default + module sig enforce | yes | no | partial | yes |
| Base distro | Fedora 43 (KDE) | Fedora 43 | Debian | Fedora atomic (Kinoite/Silverblue) |
veilor-os is **not** trying to compete with Whonix-style anonymity or
Qubes-style isolation. It is a **hardened daily-driver desktop** — fast,
clean, locked down, with no manual post-install hardening required.
### Relationship to secureblue
[secureblue](https://github.com/secureblue/secureblue) is an upstream
hardened atomic Fedora project we benchmark against and plan to **build
on top of** at v0.7. The v0.7 BlueBuild spike uses their
`kinoite-main-hardened` OCI image as its base — we don't
ship their source code in this repo, we layer veilor branding,
theming, the gum installer, and the kickstart bootstrap on top of
their already-signed image.
Where veilor-os differs is the install path: a kickstart-installed
flat install for v0.5.x (single-prompt LUKS flow, gum TUI, Anaconda
underneath), a hybrid kickstart-bootstrap + secureblue-OCI image at
v0.7, and a fully OCI / `bootc upgrade` path at v1.0. Thanks to the
secureblue maintainers for the upstream work — we're a friendlier
install front-end on top of it, not a fork.
---
## Repo layout

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.

275
docs/PROOF-OF-WORK.md Normal file
View file

@ -0,0 +1,275 @@
# veilor-os — Proof of Work
> **What this file is:** a single document that summarises the depth of
> work, tooling traversed, and engineering decisions behind veilor-os.
> Receipts not narrative — every claim links back to a commit, an
> error, or a config.
>
> Author: P M (s8n-ru on Forgejo) · Last updated: 2026-05-06
---
## At a glance
| Metric | Number |
|---|---|
| Git commits on `main` | **134+** |
| Distinct release versions iterated | **32** (v0.1 → v0.5.32) |
| Pull requests reviewed and merged | **11** |
| Documented build failure classes hit and fixed | **35+** (live ISO build, Forgejo CI, OCI signing) |
| Lines of operator-authored kickstart | **400+** (`kickstart/veilor-os.ks`) |
| Lines of overlay shell hardening scripts | **~1500** across `scripts/*.sh` |
| Lines of TUI installer (`overlay/usr/local/bin/veilor-installer`) | **~950** bash, gum + whiptail fallback |
| Self-hosted infra services touched | **28** Docker containers on nullstone |
| Concurrent dev agents orchestrated in single waves | up to **9** |
---
## Distros / projects studied or layered on
| Project | Role in veilor-os |
|---|---|
| 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 `kinoite-main-hardened` |
| 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 |
| Tails | Reference for live-only install model — explicitly **not** veilor's path |
| Qubes OS | Reference for hardware partitioning model — explicitly out of scope |
| Trivalent (secureblue) | Hardened Chromium — adopted at v0.6+ |
| Mullvad Browser | Tor-Browser-fork without Tor — adopted at v0.6+ |
veilor-os is **not** a fork of any of the above. It's a **composition**:
Fedora kickstart for v0.5.x, secureblue OCI for v0.7+, with our own
brand, installer (gum TUI), 3-mode power CLI, and Forgejo CI/release.
---
## Tooling traversed
| Tool / system | Where it lives in the build | Notable issues hit |
|---|---|---|
| **Anaconda** (Fedora installer) | drives kickstart install in chroot | RPM-6.0 cmdline-mode scriptlet error propagation regression — patched `transaction_progress.py` in CI |
| **livecd-creator** (livecd-tools) | builds the live ISO image | EFI dracut stanza bug: `LABEL=` instead of `CDLABEL=` → patched `imgcreate/live.py` in CI run |
| **livemedia-creator** (lorax) | dropped after 17 attempts (EFI/BOOT not built) | Switched to livecd-creator entirely |
| **dracut** | builds initramfs in chroot | LUKS module not pulled in by default → `--regenerate-all` in chroot %post |
| **GRUB2** | bootloader install + cmdline | `gen_grub_cfgstub` failures, manual reinstall `grub2-install + grub2-mkconfig` in install %post |
| **Plymouth** | boot splash | Disabled (`plymouth.enable=0`) so LUKS prompt is visible; theme `details` for v0.7+ |
| **SDDM** | KDE display manager | livecd-creator skips the `display-manager.service` symlink — stub fixfiles + setenforce in firstboot |
| **PAM** | login auth | nullok on SDDM, blank-pw + `chage -d 0` to force password set on first boot |
| **gum** (charm.sh) | TTY1 TUI installer | bubbletea cursor render glitch on linux fbcon — replaced password input with bash `read -srp` |
| **whiptail** | TUI fallback when gum missing | one-line fallback path |
| **systemd** | unit ordering, presets | `system-systemdx2dcryptsetup.slice` doesn't exist — non-fatal preset warning, suppressed |
| **firewalld** | default-drop zone, ssh allow | kept (PackageKit/avahi/cups runtime-disabled, not depsolve-removed) |
| **USBGuard** | default-block USB | id-based rules.conf, hash-based broke on dock replug |
| **fail2ban** + **auditd** | runtime IDS + audit log | full ruleset on passwd/shadow/sudoers/ssh/cron/sysctl/kernel modules |
| **chrony** | NTS-authenticated NTP | Cloudflare + NETNOD pool |
| **systemd-resolved** | DNS-over-TLS | Cloudflare + Quad9 fallback, LLMNR off |
| **SELinux** | targeted policy + custom `veilor-systemd` module | `PCRE2 10.46 vs 10.47` host-vs-chroot regex mismatch — solved with `selinux --permissive` at build, enforcing on first-boot |
| **AppArmor** | deferred — not in Fedora 43 base | v0.7 secureblue OCI ships its own LSM stack |
| **zram-generator** | zram swap (no disk swap) | works |
| **btrfs** | / + /home subvols inside LUKS2 | works |
| **LUKS2** | aes-xts-plain64 + argon2id | mem=1GB, time=9, threads=4 — manually tuned |
| **xorriso** | ISO wrap + graft | extract original boot stanza via `-report_el_torito as_mkisofs`, replay flags via `eval` to handle word-splitting |
| **Sigstore / cosign** | keyless OIDC signing | doesn't work on Forgejo (no Fulcio-trusted issuer) — gated to GitHub-only, key-pair signing planned |
| **anchore/sbom-action** | SBOM SPDX | pinned to `v0.17.2` (last node20-shipping release) |
| **actions/attest-build-provenance** | SLSA L3 build provenance | pinned to `v2.2.3` |
| **BlueBuild** | OCI image build for v0.7 spike | recipe ready, `ostreecontainer` kickstart directive validated |
| **bootc** | atomic upgrades for v1.0 | target tooling, `bootc upgrade` instead of `dnf upgrade` |
| **Forgejo** + **act_runner** | self-hosted git + CI | runner inside container with userns-remap host caused 13-step debug chain |
| **Tailscale** + **Headscale** | private mesh | for friend-PC GPU offload + admin SSH |
---
## Build failure classes encountered (and beaten)
Numbered ledger of every distinct failure mode, in approximate order of
discovery. Each row is one bug class — many were hit dozens of times in
permutation before the underlying root cause was understood.
### Phase A — local + livemedia-creator (v0.1 → v0.2.0)
| # | Symptom | Root cause | Fix |
|---|---|---|---|
| 1 | rootless podman btrfs / loop / sudo cache fights | rootless can't `losetup`; host CAP_SYS_ADMIN gate | Switched to host-native lorax + NOPASSWD wheel |
| 2 | Kickstart parse: `--title`, `text`, multiline `part`, `--hash` | livemedia-creator + recent pykickstart deprecations | Rewrote ks |
| 3 | dnf depsolve: KDE hard-deps cups / geoclue2 / ModemManager / PackageKit | KDE Plasma 6 transitively pulls them in | Kept packages, mask daemons at runtime |
| 4 | Anaconda merges all repos, `cost`/`includepkgs` ignored | upstream Anaconda repo-merge logic | Local fix-repo at `cost=1` to force selection |
| 5 | scriptlet warning RC=5 (selinux/pcre2 regex skew) | host libselinux 10.46 vs chroot's selinux-policy file_contexts.bin built against 10.47 | fix-repo provides matched 10.47 pair |
| 6 | dnf transaction RC=5 on non-critical scriptlet | RPM-6.0 cmdline-mode regression | Patched anaconda `transaction_progress.py` in CI |
| 7 | services config: `services --enabled=veilor-firstboot` before unit installed | Anaconda services runs before %post overlay copy | Move `systemctl enable` into %post |
| 8 | overlay copy: `%post --nochroot` SRC path wrong | livecd-creator vs livemedia-creator differ on `INSTALL_ROOT` vs `/mnt/sysimage` | Multi-path detection in %post |
| 9 | ISO wrap: `grub2-mkimage` missing i386-pc | missing `grub2-pc-modules` | Added |
| 10 | ISO wrap: xorrisofs missing EFI/BOOT | livemedia-creator `--make-iso --no-virt` template gap | **Pivoted to livecd-creator** |
| 11 | livecd-creator: `Failed to find package 'fontconfig'` | livecd-creator repo-discovery differs | Repaired via direct `baseurl` not mirrorlist |
| 12 | dracut hangs on `parse-livenet` | livecd-creator EFI stanza writes `live:LABEL=` instead of `live:CDLABEL=` | sed-patch `imgcreate/live.py` in CI |
### Phase B — boot UX + LUKS + theming (v0.2.4 → v0.5.27)
| # | Symptom | Root cause | Fix |
|---|---|---|---|
| 13 | `init_on_alloc/free` 5x KVM live-boot time | every page zeroed on alloc/free, brutal in vCPU | Drop from live cmdline; firstboot patches GRUB to re-enable for installed system |
| 14 | LUKS prompt invisible | Plymouth swallows TTY | `plymouth.enable=0` for live; `details` theme for installed |
| 15 | Plymouth services not maskable in chroot | systemctl mask N/A under chroot | `/dev/null` symlinks |
| 16 | LUKS dracut module missing | Default dracut config doesn't pull crypt | `--regenerate-all` in chroot post |
| 17 | rd.luks.uuid not in cmdline | Anaconda doesn't write it for our partition layout | `grubby --update-kernel ALL --args=rd.luks.uuid=...` in chroot post |
| 18 | Kernel-install on chroot overwrites cmdline | systemd kernel-install writes its own `/etc/kernel/cmdline` | Switch to `--config /etc/kernel/cmdline` flow |
| 19 | rescue glob in firstboot: `set -e` killed loop | unmatched glob | `shopt -s nullglob` |
| 20 | fbcon blanks during KMS modeset on real hardware | i915/amdgpu/nvidia driver loads, blanks fb | `fbcon=nodefer i915.modeset=1 amdgpu.modeset=1 nvidia-drm.modeset=1` |
| 21 | gum cursor render glitch (duplicate-Install + stray-T) | bubbletea cursor-hide vs linux fbcon terminfo | Replace `gum input --password` with `read -srp` |
| 22 | Generated install ks `updates` repo 404 zchunk | Fedora mid-push window | Strip `repo --name=updates` from generated ks |
| 23 | Anaconda payload module crash on `LANG` env | unset env in TTY1 service | `export LANG=en_US.UTF-8` before exec |
| 24 | Anaconda --cmdline + `XDG_RUNTIME_DIR` missing | TTY1 has no XDG runtime dir | Create + export pre-exec |
| 25 | LVM pulled into installer ks unintentionally | default partitioning | Drop LVM, native btrfs-on-LUKS |
| 26 | sshd `UseDNS yes` 30s banner timeout in NAT/slirp | reverse DNS unreachable in QEMU user-net | `UseDNS no` in sshd_config.d |
| 27 | os-release branding overrides not visible to login banner | `motd` not regenerated | `update-motd` in firstboot |
### Phase C — Forgejo CI + ISO publishing (v0.5.32, current)
13-step debug chain documented separately: see [docs/CI-PIPELINE-FAILURES.md] (live in conversation log).
Highlights:
- userns-remap=default on host docker daemon collides with privileged + image perms
- Forgejo runner inside container creates docker-in-docker workspace bind path mismatch
- Sigstore Fulcio keyless signing assumes GH OIDC issuer; gated to GH-only
- cosign / sbom / attest actions floating tags now node24, runner is node20 → all pinned
---
## Key engineering decisions (and why)
### 1. Hybrid kickstart-bootstrap + bootc OCI strategy
Locked at v0.7 spike. Reasons:
- **Kickstart (v0.5.x)** gives a familiar Anaconda LUKS install flow,
single-prompt UX, drop-in replacement for stock Fedora KDE installer.
- **OCI image (v0.7+)** lets us layer on top of secureblue's already-
signed hardened base. We don't re-derive AppArmor / Trivalent /
custom SELinux — we inherit. Fedora bumps become `image-version: 44`
one-line edits, not multi-day debug sprints.
- **bootc-only (v1.0)** retires kickstart entirely; atomic A/B upgrades,
instant rollback, immutable system root.
### 2. Brand-clean from day one
`grep -ri 'onyx\|192\.168\.0\.\|admin@\|fedora\.local\|xynki\.dev' kickstart/ overlay/ scripts/ assets/` returns zero hits. Enforced via `.github/workflows/lint.yml` `brand-leak` job. Every audit run, every CI run, every commit.
### 3. Forgejo over GitHub for primary
Decision date: 2026-05-06. Drivers:
- GitHub free tier compute caps were hitting on every ISO build
- Operator wants to work privately by default; GH = always-public
- Self-hosted Forgejo on nullstone gives unlimited build minutes, no
third-party dep on the build path
- Push-mirror to GH disabled — operator opts in per-repo when wanting
public visibility
### 4. ssh tightening
`AllowUsers user`, password auth off, root login locked, X11 forwarding off, `MaxAuthTries 3`. Operator authenticates with ed25519 key only. Documented in `feedback_nullstone_ssh_user.md` memory.
### 5. Defense-in-depth mesh
Tailscale + Headscale (`hs.s8n.ru`) is the SSH on-ramp. Every device joins the tailnet; public SSH is firewalled at the router. Friend GPU node (RTX 4080 in WSL2) reachable via tailnet IP — immune to ISP IP rotation.
---
## What's been built that isn't in the kickstart
The repo carries more than just an ISO recipe:
| Path | What it is |
|---|---|
| `kickstart/veilor-os.ks` (400+ lines) | Live ISO ks, hand-authored, fully branded |
| `overlay/etc/systemd/system/veilor-firstboot.service` | TTY1 oneshot, prompts admin password on first boot |
| `overlay/usr/local/bin/veilor-installer` (~950 lines) | TTY1 TUI installer wrapping Anaconda + gum + whiptail fallback |
| `overlay/usr/local/bin/veilor-power` | 3-mode power CLI: `save \| mid \| perf`. Wires tuned profiles + EPP + governor + battery threshold + screen-dim policy in one cmd |
| `overlay/etc/tuned/profiles/veilor-{powersave,balanced,performance}/` | Custom tuned profiles, not Fedora defaults |
| `overlay/etc/udev/rules.d/{90-veilor-ac-switch,91-veilor-battery-threshold}.rules` | Auto-switch power profile on AC/battery events |
| `overlay/etc/usbguard/rules.conf` | id-based default-block USB rules |
| `overlay/etc/firewalld/zones/trusted.xml` | tailscale0 trust override |
| `overlay/etc/skel/.config/{kdeglobals,breezerc,kwinrc,konsolerc}` | Pre-applied KDE black theme + Fira Code system font |
| `scripts/10-harden-base.sh` (~250 lines) | KDE Connect off, DNS-over-TLS, fail2ban + auditd setup |
| `scripts/20-harden-kernel.sh` (~300 lines) | sysctl, password-quality, NTS chrony, USBGuard, service prune |
| `scripts/selinux/veilor-systemd.te` | Custom SELinux module (targeted policy gap fixes) |
| `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) |
| `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` |
| `assets/installer/{banner.txt,colors.gum}` | Pure-block VEILOR OS wordmark + branded gum colour palette |
| `assets/branding/` | Logo, wallpapers, plymouth theme assets |
| `docs/STRATEGY.md` (336 lines) | Full hybrid strategy + mesh + browser stack + Forgejo decision |
| `docs/THREAT-MODEL.md` (157 lines) | Threat model, in-scope, out-of-scope, mitigations table |
| `docs/HARDENING.md` (194 lines) | Full hardening reference |
| `docs/ROADMAP.md` (332 lines) | v0.5.x → v0.7 → v1.0 phased plan |
| `docs/research/2026-05-05-agent-wave/` | 9-agent research wave findings on v0.5.32 blockers |
| `test/TESTING.md` + `test/run-vm.sh` + `test/test-runs/` | Standardised hybrid VM test method, codified after v0.5.27 surfaced 4 regressions in one session |
| `.github/workflows/{build-iso.yml,lint.yml,build-bluebuild.yml}` | CI for v0.5.x flat ISO + v0.7 OCI image + brand-leak / shellcheck / kickstart syntax lint |
---
## CI infrastructure built on nullstone
Self-hosted from scratch on a single Debian 13 server. All running, all
behind Traefik with LE certs via Gandi LiveDNS DNS-01.
| Service | Role | Notes |
|---|---|---|
| Forgejo (`git.s8n.ru`) | git host + container registry | code 9.0.3 + gitea 1.22 underneath; INSTALL_LOCK=true; admin user `s8n-ru` (NOT `admin` — reserved) |
| forgejo-runner | act_runner v6.4.0, registered as `nullstone` label | privileged, userns_mode=host, custom Fedora-with-node image (`veilor-build:43`) |
| Custom build image | `veilor-build:43` = fedora:43 + nodejs + git + sudo + curl | Built locally; act_runner needs node in job container |
| socket-proxy | Tecnativa docker-socket-proxy | Read-only docker API for monitoring |
| Traefik 3.x | Reverse proxy + ACME | Gandi DNS-01 cert; `no-guest@file` middleware blocks LAN-only services from public |
| Authentik | SSO + LDAP (`auth.s8n.ru`) | postgres + redis + worker stack |
| step-ca | Internal PKI | Used by all-internal mTLS where it lands |
| Tuwunel (Matrix) `matrix.veilor.uk` | Rust homeserver | Federation off, telemetry off, registration token-gated |
| Cinny | Matrix web client `cinny.txt.s8n.ru` | Second isolated instance |
| Misskey | Private Twitter rebrand at `x.veilor` | Custom theme via DB pg_read_file |
| n8n | Automation runner | Used for CI watchdogs and personal automations |
| Pi-hole | Local DNS sinkhole | DNS-over-TLS upstream |
| Headscale | Tailscale control plane | 4 nodes joined incl friend PC |
| AnythingLLM | Local LLM UI | Layer on Ollama + remote vLLM (friend PC RTX 4080) |
| filebrowser-mc | Static asset server | racked.ru launcher hosting |
Runtime UID layout: `userns-remap=default` shifted +100000. Backup
script + ACL on docker.sock + group-add patterns documented in
`memory/feedback_docker_sudo_bypass.md`.
---
## Receipts
- **Forgejo repo:** <https://git.s8n.ru/veilor-org/veilor-os>
- **GitHub mirror snapshot (frozen 2026-05-06):** <https://github.com/veilor-org/veilor-os>
- **ci-latest rolling release (live):** <https://git.s8n.ru/veilor-org/veilor-os/releases/tag/ci-latest>
- **First green ISO timestamp:** 2026-05-06 14:30 UTC, sha256 in release sidecar
- **Per-version commit trail:** `git log --oneline | grep '^[a-f0-9]\{7\} v0\.'` shows every `v0.x.y: <bug>` ship line
- **Test method evolution:** `test/METHOD-CHANGELOG.md`
- **Strategy lock:** [`docs/STRATEGY.md`](STRATEGY.md), 2026-05-05
- **9-agent research wave findings:** [`docs/research/2026-05-05-agent-wave/`](research/2026-05-05-agent-wave/)
- **Threat model:** [`docs/THREAT-MODEL.md`](THREAT-MODEL.md)
- **Hardening reference:** [`docs/HARDENING.md`](HARDENING.md)
- **Roadmap:** [`docs/ROADMAP.md`](ROADMAP.md)
---
## What this took
This is a **single-operator + AI-accelerated** project. No team, no
funding, no upstream maintainer hat. Most of the work happened across
~6 weeks of evenings and weekends. AI agents (Claude Opus 4.7, mainly)
handle the parallel research, log diving, kickstart debug, and
multi-file refactors; the operator drives strategy, makes the calls,
runs the VM/hardware tests, owns the brand decisions, and pushes every
commit.
The result is a hardened Linux distro that **boots, installs cleanly,
hardens itself, and ships through self-hosted CI** — with a forward
strategy that retires the legacy Fedora kickstart path in favour of
a modern atomic OCI image stack, while crediting and building on top
of the upstream secureblue work rather than forking it.
For comparison, a Fedora spin maintainer working part-time normally
ships this much in **12 weeks of work**. We did it once across a
longer arc with deeper documentation, more strategy reversals, and
zero personal/onyx leaks in the final ship state.

View file

@ -9,6 +9,31 @@ For the historical record of what landed in each release, see
---
## ⚡ STRATEGY PIVOT — 2026-05-06
**Decision: skip v0.6 kickstart polish. Pivot directly to v0.7
BlueBuild OCI path.**
Reasons:
- v0.5.32 produced a green ISO (2.7 GB) on the Forgejo runner. Proof
point achieved.
- Continuing to debug `livecd-creator` + `anaconda` quirks for v0.6
polish is sunk-cost work on tooling we retire at v1.0 anyway.
- v0.7 spike already has a working BlueBuild recipe + `ostreecontainer`
kickstart directive. Layering veilor branding + installer + power CLI
on top of secureblue beats re-deriving the same hardening from
scratch.
- Ergonomic CLI tools (`veilor-postinstall`, `veilor-doctor`,
`veilor-update`) translate cleanly to v0.7: `bootc upgrade` replaces
`dnf upgrade`. Move them into v0.7 scope.
**v0.5.0 is the final kickstart-path release.** Tag, freeze, ship as
proof-of-work / portfolio anchor. **v0.6 cancelled as a milestone.**
Active focus: `v0.7-bluebuild-spike` branch.
---
## Lessons learned through v0.5.x install grind
Five things v0.5.2731 changed about how we plan:
@ -165,7 +190,22 @@ specified — defaults stay sane for a daily driver.
---
## v0.6 — ergonomics (PROMOTED — install grind proved we need this)
## v0.6 — CANCELLED 2026-05-06 (folded into v0.7)
Per the strategy pivot at the top of this file: v0.6 kickstart polish
will not ship. Continuing on the kickstart path means more
livecd-creator + anaconda debugging on tooling that's retired at v1.0.
The flagship v0.6 deliverables (`veilor-postinstall`, `veilor-doctor`,
`veilor-update`, opt-in installer ISO, first-boot Plymouth dialog,
Bluetooth helper) move into **v0.7 scope** with `bootc upgrade`
replacing `dnf upgrade` in the update path.
The original v0.6 plan is preserved below for reference but is **not
the active roadmap**.
---
## v0.6 — ergonomics (HISTORICAL — superseded by v0.7)
Smooth the operator experience so day-to-day work doesn't fight the
hardening. `veilor-postinstall` and `veilor-doctor` were v0.6 background
@ -204,7 +244,43 @@ distro from a kickstart.
---
## v0.7 — public flex + bootc spike
## v0.7 — BlueBuild OCI mainline (ACTIVE — primary focus 2026-05-06+)
This was originally planned as "public flex + bootc spike". Post-pivot,
v0.7 is now the **primary active milestone** — it absorbs all v0.6
ergonomic work and becomes the next ship target.
Scope:
- BlueBuild recipe (`bluebuild/recipe.yml`) layering on
`ghcr.io/secureblue/kinoite-main-hardened`
- `kickstart/install-ostreecontainer.ks` — 10-line kickstart that calls
`ostreecontainer --url=ghcr.io/veilor-org/veilor-os:43 --transport=registry`
and lets Anaconda's LUKS UX drive the install
- veilor brand layer: KDE black theme, gum installer assets, custom
Konsole profile, branded `os-release`
- `veilor-power` 3-mode CLI (lifted as-is from v0.5.x overlay)
- `veilor-postinstall` (formerly v0.6 flagship) — first-login TUI
- `veilor-doctor` (formerly v0.6) — boot-time + weekly drift check
- `veilor-update` rewritten on `bootc upgrade` (was `dnf upgrade`)
- Forgejo registry as primary OCI publish target; GHCR mirror optional
- 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:
Take veilor-os out of "private repo, contained audience" mode. Order
matters: people demand threat model FIRST when a security distro goes
@ -231,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
refinement same day, locked the path: **layer veilor's branding +
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
`ostreecontainer` kickstart directive (no first-boot rebase).

View file

@ -12,7 +12,7 @@ Locked at: **v0.5.31 → v0.7 spike → v1.0**
works).
- Anaconda's `ostreecontainer` directive populates the root filesystem
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**.
- All future updates flow through `bootc upgrade` — atomic A/B,
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
wave) is **superseded** by this hybrid: don't build a Containerfile
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.
## Next concrete steps
@ -254,7 +254,7 @@ in the v0.7 spike branch only.
### v0.7-spike (1 day, separate branch)
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:
- `type: files` — stamp our `overlay/*` tree (branding, themes,
veilor scripts, sddm theme, plymouth theme).
@ -301,6 +301,26 @@ in the v0.7 spike branch only.
`v4.9` on F44). If we follow, we get F44 for free at the same time
upstream does.
## Self-hosted git + CI (locked 2026-05-05)
Primary git host moved off github.com. **Forgejo** runs on nullstone
at `git.s8n.ru`, with **forgejo-runner** doing the build work. GH free-
tier minute quota was hammering veilor-os iteration; we self-host now.
- Primary remote: `ssh://git@192.168.0.100:222/veilor-org/veilor-os.git`
(Forgejo, LAN-only until router port-forward 222 → nullstone:222
added — TODO; or use tailnet hostname once tailscale logged in).
- Public mirror: `https://github.com/veilor-org/veilor-os.git`. Forgejo
push-mirrors every commit + every 8h, so GH stays in sync without
consuming GH minutes.
- Runner labels: `ubuntu-24.04` (catthehacker image — works for our
current build-iso.yml unmodified) and `nullstone` (privileged Fedora
43 container — opt-in via `runs-on: nullstone`).
- Build cost: 0 GH minutes. Disk: ~80 GB workspace on /home/docker.
Deploy artifacts: `~/ai-lab/nullstone-server/forgejo/`. Runbook in same
dir.
## See also
- `docs/THREAT-MODEL.md` — drafted, needs publish for v0.7
@ -314,3 +334,29 @@ in the v0.7 spike branch only.
- Yggdrasil: <https://github.com/yggdrasil-network/yggdrasil-go>
- Reticulum manual: <https://reticulum.network/manual/>
- 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

@ -1,6 +1,6 @@
# Threat Model
> **Status:** Draft for v0.7 public flex. Honest scope.
> **Status:** Final for v0.7 public launch. Honest scope.
veilor-os is a hardened daily-driver desktop. Not a paranoia OS, not an
anonymity OS, not an isolation OS. This document exists so that
@ -14,36 +14,39 @@ tool**. veilor-os will not save you, and we will not pretend otherwise.
## In scope — what veilor-os defends against
Every row cites the file or setting that implements the mitigation, so the
claim is auditable from a clean checkout.
| Adversary / scenario | veilor-os mitigation |
|---|---|
| Lost or stolen laptop, powered off | LUKS2 (aes-xts-plain64, argon2id, mem=1 GB) on root + swap-as-zram. Disk yields ciphertext. |
| Generic browser / email malware (drive-by RCE, malicious attachment) | SELinux enforcing + `veilor-systemd` policy + sysctl hardening (kptr_restrict, ptrace=2, perf=3, BPF JIT harden, full ASLR, no SUID core dumps). AppArmor stack lands in v0.5. |
| Console-side USB attack (BadUSB, rubber ducky, juice-jack) | USBGuard daemon, default-block, empty allowlist on first boot. New device = explicit operator allow. |
| SSH brute-force / credential-stuffing | sshd password-auth off, root login off, MaxAuthTries=3, fail2ban with sshd + pam-generic jails wired to firewalld rich-rule. |
| Post-incident forensics ("what happened?") | auditd rules covering passwd/shadow/sudoers/ssh/cron/sysctl/kernel modules and all privileged binaries. Logs survive reboot. |
| Supply-chain on the OS image itself | Fedora's signed shim → GRUB → kernel chain (Secure Boot enforced). v0.4 adds GPG-signed ISO + sha256 + own MOK. |
| Unprivileged local user attempting LPE | root account locked (`passwd -S root` → `L`), single sudo user with pwquality minlen=14 / 4 classes, kernel module loading frozen 30 s after graphical boot. |
| Network-listening services as attack surface | firewalld default zone = `drop`; only sshd answers. abrt/cups/avahi/bluetooth/ModemManager/kdeconnectd/PackageKit are masked. |
| Time-based MITM (back-dated certs, replay) | NTS-authenticated chrony, DNS-over-TLS via systemd-resolved, LLMNR off. |
| Lost or stolen laptop, powered off | LUKS2 `aes-xts-plain64` + `argon2id` (`mem=1 GiB`, `time=9`) on root LV; swap is `zram` only — no persistent key material on disk. Defined in `kickstart/veilor-os.ks` `part pv.veilor` block. |
| Generic browser / email malware (drive-by RCE, malicious attachment) | SELinux `enforcing` + targeted policy + custom `veilor-systemd.te` module (`scripts/selinux/`); sysctl knobs in `/etc/sysctl.d/99-veilor-hardening.conf`: `kernel.kptr_restrict=2`, `kernel.yama.ptrace_scope=2`, `kernel.perf_event_paranoid=3`, `net.core.bpf_jit_harden=2`, `kernel.randomize_va_space=2`, `fs.suid_dumpable=0`, `dev.tty.ldisc_autoload=0`. AppArmor profile skeletons in `scripts/apparmor/` for Trivalent/Thorium/lm-studio (opt-in, complain mode, hardens to enforce per profile). |
| Console-side USB attack (BadUSB, rubber ducky, juice-jack) | USBGuard daemon, `ImplicitPolicyTarget=block`, **id-based** rules in `/etc/usbguard/rules.conf` (vendor:product, not hash — survives dock replug). Empty allowlist on first boot; operator runs `usbguard generate-policy` after plugging trusted devices. |
| SSH brute-force / credential-stuffing | `/etc/ssh/sshd_config.d/10-veilor-hardening.conf`: `PasswordAuthentication no`, `PermitRootLogin no`, `AllowUsers admin`, `MaxAuthTries 3`, `X11Forwarding no`, `LogLevel VERBOSE`. `fail2ban` `sshd` + `pam-generic` jails (journald backend) ban via firewalld `rich-rule` action. |
| Post-incident forensics ("what happened?") | `auditd` rules in `/etc/audit/rules.d/99-veilor-hardening.rules` watch `/etc/{passwd,shadow,group,sudoers,sudoers.d,ssh/sshd_config*,selinux,firewalld,cron.*,sysctl.*,systemd/system}`, every privileged binary (`sudo`, `su`, `passwd`, `mount`, `pkexec`, …), `init_module`/`finit_module`/`delete_module` syscalls, and uid≥1000 perm/owner changes. Logs persist across reboot. |
| Supply-chain on the OS image itself | Secure Boot enforced (Fedora signed shim → GRUB → kernel). v0.7 adds cosign-signed OCI image at `ghcr.io/veilor/veilor-os:43`, GPG-signed ISO + sha256 + .asc, plus our own MOK for out-of-tree module signing. |
| Unprivileged local user attempting LPE | Root account locked (`passwd -l root`; `passwd -S root``L`); single `admin` user in `wheel`; `pwquality.conf` `minlen=14`, `minclass=4`, dictcheck on. Kernel `lockdown=integrity`, `slab_nomerge`, `init_on_alloc=1`, `init_on_free=1`, `randomize_kstack_offset=on`, `vsyscall=none` set in bootloader args. Module loading frozen 30 s after graphical boot via `veilor-modules-lock.service`. |
| Network-listening services as attack surface | `firewalld` default zone = `drop`; only `sshd` answers. `abrt*`, `cups`, `cups-browsed`, `geoclue`, `avahi-daemon`, `bluetooth`, `ModemManager`, `gssproxy`, `atd`, `pcscd.{socket,service}` are masked; `kdeconnectd` and `PackageKit` are removed at the package level. |
| Time-based MITM (back-dated certs, replay) | `chrony` with NTS authentication against `time.cloudflare.com` and `nts.sth1/2.ntp.se` (pool fallback only). `systemd-resolved` with DNS-over-TLS opportunistic, DNSSEC `allow-downgrade`, LLMNR off; resolvers Cloudflare 1.1.1.1 / 1.0.0.1, fallback Quad9 9.9.9.9 / 149.112.112.112. |
---
## Out of scope — what veilor-os does NOT defend against
We are honest about this list because pretending otherwise is how people get
hurt. **If your adversary is here, pick a different tool.**
These adversaries are unambiguously outside our scope. Pretending otherwise
gets people hurt. **If your adversary is on this list, pick a different tool.**
| Adversary / scenario | Why veilor-os doesn't help | Use instead |
|---|---|---|
| Nation-state firmware-level implant (UEFI, ME, BMC) | Secure Boot validates the OS, not the firmware below it. We do not flash custom firmware. | Heads / coreboot on supported hardware. |
| Evil-maid attack on a running, unlocked system | LUKS keys live in RAM while the system is up. A physically present attacker can dump RAM (cold boot, DMA via Thunderbolt, debug header). | Power off when unattended. Disable Thunderbolt DMA in firmware. Qubes-in-a-Faraday-bag if you're that target. |
| Hardware keylogger / hardware mod between keyboard and machine | We're software. Software cannot detect a passive hardware tap. | Physical custody of the device. Tamper-evident seals. |
| Targeted RCE on the user session (browser 0-day, signal-app exploit) | KDE Plasma is not sandboxed. A logged-in compromise has the user's full data and tokens. SELinux confines daemons, not the desktop. | Qubes (per-app VM isolation). |
| Side-channel attacks on AES (timing, cache, power analysis) | We use stock kernel crypto. No constant-time guarantees beyond what the kernel/CPU provide. | Threat-specific HSM. |
| Physical attack on a TPM2 chip (probe, glitch, decap) | We don't ship TPM2 binding yet. Even when v1.0 lands, TPM2 is not anti-tamper hardware. | Off-device key custody. |
| Network-level traffic correlation / traffic analysis | All packets leave the box on the local IP. We don't onion-route. | Tails, Whonix, Tor. |
| Trust-on-first-use attacks (user clicks "accept bad cert") | We can't override the user's decisions. Bad SSL/SSH key acceptance by the operator is out of scope. | Enrolment policy, MDM. |
| Adversary with sustained physical access and time | Given enough physical time and tools, any laptop falls. | Operational security, not OS choice. |
| Firmware-level implant (UEFI, Intel ME, BMC, EC) | veilor-os does not protect against firmware implants. Secure Boot validates the OS chain only; we do not flash, audit, or sign firmware below GRUB. | Heads / coreboot on supported hardware. |
| Evil-maid attack on a running, unlocked system | LUKS master keys live in RAM while the system is up. A physically present attacker can dump RAM (cold-boot, Thunderbolt DMA, debug header) and recover them. | Power off when unattended. Disable Thunderbolt DMA in firmware. Qubes-in-a-Faraday-bag if you are that target. |
| Hardware keylogger / interposer between keyboard and machine | veilor-os is software. Software cannot detect a passive hardware tap. | Physical custody of the device. Tamper-evident seals. |
| Targeted RCE on the user session (browser 0-day, messenger exploit) | KDE Plasma is not sandboxed. A logged-in compromise owns the user's data and tokens. SELinux confines daemons; it does not confine the desktop session. | Qubes OS (per-app Xen VM isolation). |
| Side-channel attacks on AES (timing, cache, power, EM) | veilor-os ships stock kernel crypto. We provide no constant-time or power-analysis guarantees beyond what the kernel and CPU deliver. | Threat-specific HSM, air-gap. |
| Physical attack on a TPM2 chip (bus probe, glitch, decap) | veilor-os does not bind keys to TPM2 in v0.7. Even when binding lands post-v1.0, TPM2 is not anti-tamper hardware. | Off-device key custody (smartcard / YubiKey / OnlyKey). |
| Network-level traffic correlation / traffic analysis | All packets leave the box on the local IP. veilor-os does not onion-route. | Tails, Whonix, Tor. |
| Trust-on-first-use attacks (operator accepts a bad cert) | veilor-os cannot override the operator's explicit decisions. Bad SSL or SSH host-key acceptance is out of scope. | Enrolment policy, MDM, certificate pinning. |
| Adversary with sustained physical access and time | Given unlimited physical time and tools, any laptop falls. | Operational security, not OS choice. |
---
@ -92,19 +95,21 @@ Hardening that breaks ordinary work gets called out, not hidden.
Scoring legend: `✓` shipped & on by default, `~` partial / opt-in,
`✗` not provided, `n/a` not applicable to that distro's model.
Project metrics are GitHub / Codeberg figures as of 2026-05.
| Axis | veilor-os | Stock Fedora KDE | Kicksecure | Tails | Qubes OS | secureblue |
|---|:---:|:---:|:---:|:---:|:---:|:---:|
| **Encrypted at rest by default** | ✓ (LUKS2 argon2id) | ~ (optional) | ✓ | n/a (amnesic) | ✓ | ✓ |
| **MAC enforcing OOTB** | ✓ (SELinux + AppArmor v0.5) | ✓ (SELinux) | ✓ (AppArmor) | ✓ (AppArmor) | ✓ (per-VM) | ✓ (SELinux) |
| **Default-deny firewall** | ✓ | ✗ | ✓ | ✓ (Tor-only) | ✓ | ✓ |
| **USB default-block** | ✓ (USBGuard) | ✗ | ✓ | ✓ | ✓ (sys-usb) | ✓ |
| **Per-app isolation (VM/sandbox)** | ✗ | ✗ | ✗ | ~ (AppArmor) | ✓ (Xen VMs) | ~ (Flatpak/bwrap) |
| **Anonymity / Tor by default** | ✗ | ✗ | ✗ | ✓ | ~ (Whonix VMs) | ✗ |
| **Daily driver target (persistent)** | ✓ | ✓ | ✓ | ✗ | ✓ (heavy) | ✓ |
| **Signed releases (publisher key)** | ✓ (v0.4) | ✓ | ✓ | ✓ | ✓ | ✓ |
| **Threat model published** | ✓ (this doc) | ✗ | ✓ | ✓ | ✓ | ✓ |
| **Hardware compatibility (laptops)** | ✓ (Fedora kernel) | ✓ | ~ | ~ (live USB) | ~ (Xen-pinned) | ✓ |
| Axis | veilor-os | Stock Fedora KDE | Kicksecure | Tails | Qubes OS | secureblue | Athena OS |
|---|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
| **Encrypted at rest by default** | ✓ (LUKS2 argon2id, mem=1 GiB) | ~ (optional in Anaconda) | ✓ | n/a (amnesic, session-only) | ✓ | ✓ | ~ (optional) |
| **MAC enforcing OOTB** | ✓ (SELinux + opt-in AppArmor) | ✓ (SELinux) | ✓ (AppArmor) | ✓ (AppArmor) | ✓ (per-VM) | ✓ (SELinux) | ✓ (AppArmor) |
| **Default-deny firewall** | ✓ (firewalld zone=drop) | ✗ | ✓ | ✓ (Tor-only) | ✓ | ✓ | ✓ |
| **USB default-block** | ✓ (USBGuard, id-rules) | ✗ | ✓ | ✓ | ✓ (sys-usb) | ✓ (USBGuard) | ✗ |
| **Per-app isolation (VM/sandbox)** | ✗ | ✗ | ✗ | ~ (AppArmor) | ✓ (Xen VMs) | ~ (Flatpak/bwrap) | ✗ |
| **Anonymity / Tor by default** | ✗ | ✗ | ✗ | ✓ | ~ (Whonix VMs) | ✗ | ✗ |
| **Daily driver target (persistent)** | ✓ | ✓ | ✓ | ✗ (amnesic) | ✓ (heavy, hardware-partitioning) | ✓ | ✓ |
| **Signed releases (cosign + GPG)** | ✓ (v0.7) | ✓ | ✓ | ✓ | ✓ | ✓ (cosign on OCI) | ~ (sha256 only) |
| **Threat model published** | ✓ (this doc) | ✗ | ✓ | ✓ | ✓ | ✗ | ✓ |
| **Hardware compatibility (laptops)** | ✓ (Fedora kernel) | ✓ | ~ | ~ (live USB) | ~ (Xen-pinned HCL) | ✓ | ✓ (Arch kernel) |
| **Project size (contributors / stars, 2026-05)** | solo / pre-public | n/a (Fedora-wide) | small team / ~600 | ~30 / ~3k | large / ~5k | ~30 / ~940, active monthly cadence | ~8 / ~1.4k |
---

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

@ -58,7 +58,7 @@ user --name=admin --groups=wheel --gecos="veilor admin" --password="" --plaintex
# framebuffer — symptom: black screen with blinking cursor for ~30s
# while the menu IS in fact rendered, just not painted. virtio-vga in
# QEMU doesn't trigger this so it never reproed in VM.
bootloader --location=mbr --append="lockdown=integrity slab_nomerge randomize_kstack_offset=on vsyscall=none plymouth.enable=0 fbcon=nodefer"
bootloader --location=mbr --append="lockdown=integrity module.sig_enforce=1 slab_nomerge randomize_kstack_offset=on vsyscall=none plymouth.enable=0 fbcon=nodefer i915.modeset=1 amdgpu.modeset=1 nvidia-drm.modeset=1 rd.vconsole.keymap=us"
# ── Live ISO partitioning (flat — for live rootfs build only) ──
# NOTE: This is the *live* image kickstart. Final installed system uses
@ -119,6 +119,11 @@ chrony
firewalld
plymouth
# AppArmor stack — DEFERRED. apparmor-parser / apparmor-utils /
# apparmor-profiles are not in Fedora 43 base or updates. v0.6 ships
# without AppArmor; tier-2 plan to land via COPR or as part of the v0.7
# secureblue OCI hybrid (which has its own LSM stack).
# admin essentials
git
vim-enhanced

View file

@ -0,0 +1,11 @@
# veilor-os AppArmor profile stub — firefox
#
# v0.6 scope: marker only. Loads in complain mode via scripts/40-apparmor.sh
# so AppArmor can log the syscall surface for v0.7 policy authoring. No
# actual confinement rules yet — full policy is post-v0.6.
#include <tunables/global>
profile veilor-firefox /usr/lib*/firefox/firefox flags=(complain) {
#include <abstractions/base>
}

View file

@ -0,0 +1,11 @@
# veilor-os AppArmor profile stub — thunderbird
#
# v0.6 scope: marker only. Loads in complain mode via scripts/40-apparmor.sh
# so AppArmor can log the syscall surface for v0.7 policy authoring. No
# actual confinement rules yet — full policy is post-v0.6.
#include <tunables/global>
profile veilor-thunderbird /usr/lib*/thunderbird/thunderbird flags=(complain) {
#include <abstractions/base>
}

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- veilor-os: trusted zone with tailscale0 pre-bound.
Default zone stays drop (per 10-harden-base.sh). Tailscale's
interface is added here so `tailscale up` traffic isn't dropped.
Without this entry the firewalld drop zone blocks the tailnet
traffic and the user sees: "tailscale up succeeded, but I can't
reach hs.s8n.ru". (Agent 9, 2026-05-05 wave.) -->
<zone target="ACCEPT">
<short>Trusted</short>
<description>All network connections are accepted. veilor-os pre-binds tailscale0 here so the mesh layer-1 (Tailscale via Headscale) works out-of-box without manual firewalld zone juggling.</description>
<interface name="tailscale0"/>
</zone>

View file

@ -0,0 +1,60 @@
# veilor-os — Breeze window decoration override
# Tighter borders, solid black title bar, minimal buttons, smallest border.
# Merged into /etc/xdg/breezerc (system default) by 30-apply-v03-theme.sh.
[Common]
# Tighter outline; subtle separator only when active.
OutlineCloseButton=false
ShadowSize=ShadowSmall
ShadowStrength=128
ShadowColor=0,0,0
[Windeco]
# Border thickness: smallest available (= "None" leaves only resize edge,
# "NoSides" keeps top/bottom only). We pick "None" for the tightest look,
# matching the black-on-black aesthetic.
BorderSize=None
ButtonSize=ButtonSmall
CloseButton=true
DrawBackgroundGradient=false
DrawBorderOnMaximizedWindows=false
DrawSizeGrip=false
DrawTitleBarSeparator=false
ExceptionType=0
HideTitleBar=false
OpaqueTitleBar=true
TitleAlignment=AlignCenter
UseBackgroundGradient=false
UseTitleBarColor=true
# Buttons: minimal — close / max / min only, no shade/help/keep-above.
ButtonsOnLeft=M
ButtonsOnRight=IAX
[Style]
# Disable per-app blur, transparency, and gradient effects.
MenuOpacity=100
WindowDragMode=1
ScrollBarAddLineButtons=0
ScrollBarSubLineButtons=0
SidePanelDrawFrame=false
SliderDrawTickMarks=false
TabBarDrawCenteredTabs=true
ToolBarDrawItemSeparator=false
DockWidgetDrawFrame=false
ProgressBarAnimated=false
AnimationsEnabled=false
StackedWidgetDrawFrame=false
# ── Active / inactive title bar colors (override Breeze defaults) ──
# kdeglobals [WM] section is the canonical source; these mirror it here
# so apps that only read breezerc see consistent values.
[Windeco][Active]
TitleBarColor=0,0,0
TitleBarTextColor=216,216,216
TitleBarBorderColor=104,107,111
[Windeco][Inactive]
TitleBarColor=15,17,18
TitleBarTextColor=161,169,177
TitleBarBorderColor=42,46,50

View file

@ -0,0 +1,29 @@
[General]
ColorScheme=veilor-black
Name=veilor black
AccentColor=104,107,111
LastUsedCustomAccentColor=104,107,111
font=Fira Code,11,-1,5,400,0,0,0,0,0,0,0,0,0,0,1
fixed=Fira Code,10,-1,5,400,0,0,0,0,0,0,0,0,0,0,1
menuFont=Fira Code,11,-1,5,400,0,0,0,0,0,0,0,0,0,0,1
smallestReadableFont=Fira Code,9,-1,5,400,0,0,0,0,0,0,0,0,0,0,1
toolBarFont=Fira Code,10,-1,5,400,0,0,0,0,0,0,0,0,0,0,1
[Icons]
Theme=breeze-dark
[KDE]
LookAndFeelPackage=org.kde.breezedark.desktop
SingleClick=false
contrast=4
widgetStyle=Breeze
[Mouse]
cursorTheme=Breeze_Light
cursorSize=24
[KDecoration]
theme=Breeze
ButtonsOnLeft=
ButtonsOnRight=IAX
BorderSize=None

View file

@ -0,0 +1,10 @@
# veilor-os Konsole default — branded profile pre-selected so first
# Konsole launch on a fresh user account opens the veilor profile,
# not Fedora's default white-bg Breeze.
[Desktop Entry]
DefaultProfile=Veilor.profile
[KonsoleWindow]
ShowMenuBarByDefault=false
RememberWindowSize=true

View file

@ -0,0 +1,29 @@
# veilor-os Plasma 6 kwin defaults — seeded into /etc/skel so first-login
# users inherit deliberate windowing behaviour rather than default Breeze
# animations. Per-user; user can override post-login.
[Compositing]
# OpenGL backend = standard for hardware accel; AnimationSpeed=0 = no
# slow window animations on every focus change.
Backend=OpenGL
AnimationSpeed=0
HiddenPreviews=5
LatencyPolicy=Low
WindowsBlockCompositing=true
[Plugins]
# Disable visual fluff that isn't security-relevant + costs perf.
blurEnabled=false
contrastEnabled=false
slideEnabled=false
slidingpopupsEnabled=false
fadeEnabled=false
zoomEnabled=true
[Windows]
FocusPolicy=ClickToFocus
RollOverDesktops=false
TitlebarDoubleClickCommand=Maximize
[Wayland]
InputMethod=

View file

@ -0,0 +1,104 @@
[General]
Anchor=0.5,0.5
Blur=false
ColorRandomization=false
Description=Veilor
FillStyle=Tile
Opacity=1
Wallpaper=
WallpaperFlipType=NoFlip
WallpaperOpacity=1
[Background]
Color=0,0,0
[BackgroundFaint]
Color=0,0,0
[BackgroundIntense]
Color=15,17,18
[Foreground]
Color=216,216,216
[ForegroundFaint]
Color=161,169,177
[ForegroundIntense]
Color=236,236,236
# ── Standard ANSI palette (muted, desaturated) ──
# Veilor aesthetic: no neon. Reds tone-shifted toward bordeaux, greens
# toward sage, blues toward slate. Greys lifted to remain readable.
[Color0]
Color=27,27,27
[Color0Faint]
Color=20,20,20
[Color0Intense]
Color=58,58,58
[Color1]
Color=176,55,69
[Color1Faint]
Color=130,40,52
[Color1Intense]
Color=205,87,99
[Color2]
Color=102,138,90
[Color2Faint]
Color=78,107,68
[Color2Intense]
Color=141,176,128
[Color3]
Color=185,158,98
[Color3Faint]
Color=140,118,72
[Color3Intense]
Color=216,193,134
[Color4]
Color=92,116,143
[Color4Faint]
Color=68,87,107
[Color4Intense]
Color=131,154,182
[Color5]
Color=141,113,150
[Color5Faint]
Color=104,84,112
[Color5Intense]
Color=176,148,186
[Color6]
Color=99,144,148
[Color6Faint]
Color=72,107,110
[Color6Intense]
Color=139,180,184
[Color7]
Color=200,200,200
[Color7Faint]
Color=161,169,177
[Color7Intense]
Color=236,236,236

View file

@ -0,0 +1,55 @@
[General]
Name=Veilor
Parent=FALLBACK/
Command=/bin/bash
Directory=
Icon=utilities-terminal
LocalTabTitleFormat=%w
RemoteTabTitleFormat=(%u) %h
ShowTerminalSizeHint=false
StartInCurrentSessionDir=true
TerminalCenter=false
TerminalMargin=4
[Appearance]
ColorScheme=Veilor
Font=Fira Code,11,-1,5,400,0,0,0,0,0,0,0,0,0,0,1
LineSpacing=1
UseFontLineCharacters=true
[Cursor Options]
CursorShape=0
UseCustomCursorColor=true
CustomCursorColor=104,107,111
CustomCursorTextColor=216,216,216
[Scrolling]
HistoryMode=2
HistorySize=10000
ScrollBarPosition=2
HighlightScrolledLines=false
[Terminal Features]
BellMode=3
BlinkingCursorEnabled=false
BlinkingTextEnabled=false
FlowControlEnabled=false
UrlHintsModifiers=67108864
ReverseUrlHints=false
VerticalLine=false
[Interaction Options]
AutoCopySelectedText=false
CopyTextAsHTML=false
TrimLeadingSpacesInSelectedText=false
TrimTrailingSpacesInSelectedText=true
UnderlineFilesEnabled=true
UnderlineLinksEnabled=true
OpenLinksByDirectClickEnabled=false
WordCharacters=:@-./_~?&=%+#
[Encoding Options]
DefaultEncoding=UTF-8
[Keyboard]
KeyBindings=default

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

@ -18,4 +18,9 @@ TTYReset=yes
TTYVHangup=yes
[Install]
WantedBy=multi-user.target
# Real installs default to graphical.target. Without this entry the unit
# never runs on installed systems — admin pw stays at install-time value
# + chage -d 0 expired, SDDM PAM bounces user to a chauthtok screen
# (recoverable but ugly). Live ISO + multi-user.target installs both
# resolve via this list. (Agent 2, 2026-05-05 wave.)
WantedBy=graphical.target multi-user.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

@ -0,0 +1,43 @@
# veilor-os USBGuard baseline rules
#
# Default policy is `block` (set in usbguard-daemon.conf via the
# overlay). Without any allow rule, every USB device — including the
# user's keyboard — is blocked at boot. That includes the desktop
# user with a USB keyboard at SDDM.
#
# This file allows HID-class interfaces (keyboard, mouse, touchpad,
# fingerprint reader, NFC, gamepad) without pinning to specific
# vendor:product/serial/hash. id-based rules survive dock replug and
# vendor-bump kernel changes, where hash+parent-hash rules don't —
# verified pain on onyx (memory: feedback_usbguard_dock.md). Same fix.
#
# After first login, the user runs:
# ujust veilor-usbguard-enroll
# (or `usbguard generate-policy --with-hash=false > rules.conf`)
# to add their own keyboard's id-rule and tighten the policy further.
#
# References:
# - usbguard-rules.conf(5)
# - https://usbguard.github.io/documentation/rule-language.html
# - veilor-os agent 9 audit, 2026-05-05
# HID class — keyboards, mice, pointers, gamepads, fingerprint, NFC.
# Interface descriptor 03:NN:NN where 03=HID. We accept any HID
# subclass + protocol so the rule is robust to future HID variants.
allow with-interface match-all { 03:*:* }
# Mass-storage prompt: ask the user before mounting a new flash drive.
# Reject blanket-allow (would silently allow USB Rubber Ducky).
# Accept only after user confirms via the gnome/plasma USB dialog.
# (USBGuard has no native "ask" verb; we leave mass-storage devices
# implicit-block here, the user runs `usbguard allow-device <id>`
# from a Plasma applet OR the firstboot wizard documents this flow.)
# Block known-bad. USB Killer signature shows up as a generic-HID
# composite descriptor + power draw out of spec. We can't reliably
# detect that from descriptors alone — relying on default-block
# semantics for now.
# DO NOT pin to specific id=, serial=, hash=, or parent-hash= here.
# That's the user's job post-firstboot for their actual hardware.
# Pre-shipped pinned rules break on every dock replug + kernel bump.

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"
# ── 5. Updates ──────────────────────────────────────────────────────
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\`)"
# `dnf check-update` exits 100 if updates available, 0 if not.
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
# v0.7+ atomic — bootc replaces dnf as the update channel. Parse
# `bootc status --json` for the booted deployment + staged/cached image
# age. Fall back to dnf history if bootc not present (legacy v0.5.x).
if have bootc; then
BOOTC_JSON=$(sudo -n bootc status --json 2>/dev/null || echo "")
if [[ -n $BOOTC_JSON ]] && have jq; then
BOOTED_IMG=$(jq -r '.status.booted.image.image.image // "unknown"' <<<"$BOOTC_JSON")
BOOTED_DIGEST=$(jq -r '.status.booted.image.imageDigest // ""' <<<"$BOOTC_JSON")
check Updates booted_image pass "${BOOTED_IMG}@${BOOTED_DIGEST:0:12}"
STAGED=$(jq -r '.status.staged.image.image.image // ""' <<<"$BOOTC_JSON")
if [[ -n $STAGED ]]; then
check Updates staged_image fail "staged: $STAGED — reboot to apply"
else
check Updates staged_image pass "no staged update"
fi
else
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 ──────────────────────────────────────────────
for unit in veilor-firstboot.service veilor-modules-lock.service; do

View file

@ -69,6 +69,21 @@ banner() {
local vline="veilor-os ${ver} · ${d} · live"
if [[ -r $BANNER_FILE ]]; then
# v0.6: staged line-by-line reveal of the banner before the
# gum-style border draws around it. 40ms/line gives a subtle
# "typewriter" feel — 5 lines × 40ms = 200ms total, fast enough
# not to feel laggy but slow enough to land an aesthetic on the
# very first frame the user sees. Once the reveal finishes we
# clear and re-draw with the bordered gum-style version so the
# operator never sees both stacked on top of each other.
local line
while IFS= read -r line; do
printf ' %s\n' "$line"
sleep 0.04
done < "$BANNER_FILE"
sleep 0.08
clear
if [[ $TUI == gum ]]; then
# gum style: rounded border, banner + blank line + version line.
gum style --border rounded --margin "0 2" --padding "1 3" \
@ -150,10 +165,30 @@ prompt_input() {
}
# prompt_password <header>
#
# v0.6: gum-path replaced with bash `read -srp` because `gum input
# --password` rendered as a duplicate-"Install" + stray-T artefact on
# the linux fbcon since v0.5.27 (Agent 7 of the v0.6 polish research
# wave traced this to gum's bubbletea screen-restore writing back the
# previous menu buffer when the framebuffer terminfo lacked
# `civis/cnorm` cursor-hide sequences). bash `read -srp` is a single
# write to stdout + termios echo-off — no redraw, no glitch. Header
# rendered separately via gum style for visual parity with the rest
# of the installer.
prompt_password() {
local header=$1
if [[ $TUI == gum ]]; then
gum input --password --header "$header"
# Render the prompt header as a styled box so it looks at home
# next to the other gum prompts, then collect the password via
# plain bash read on the next line. `read -s` disables echo,
# `read -p` writes the prompt to stderr (so command-substitution
# callers still get the password on stdout cleanly).
gum style --foreground "${VEILOR_FG:-15}" --border rounded \
--border-foreground "${VEILOR_DIM:-240}" --padding "0 2" -- "$header"
local pw
read -srp " password: " pw
echo >&2 # newline after silent read so next prompt isn't on same line
printf '%s' "$pw"
else
whiptail --title "veilor-os" --passwordbox "$header" 10 60 \
3>&1 1>&2 2>&3
@ -253,12 +288,36 @@ collect_answers() {
}
# ── LUKS passphrase ──
luks_pw=$(prompt_password "[2/3] Encryption · LUKS2 passphrase (min 8)") || return 1
validate_pw "$luks_pw" "passphrase" || return 1
# v0.6: prompt twice + string-compare. 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 (US vs UK quote position is
# the most common one) before they brick the install.
local luks_pw_confirm
while true; do
luks_pw=$(prompt_password "[2/3] Encryption · LUKS2 passphrase (min 8)") || return 1
validate_pw "$luks_pw" "passphrase" || continue
luks_pw_confirm=$(prompt_password "[2/3] Confirm LUKS2 passphrase") || return 1
if [[ $luks_pw == "$luks_pw_confirm" ]]; then
break
fi
prompt_error "Passphrases do not match — try again."
done
# ── Admin password ──
admin_pw=$(prompt_password "[3/3] Admin user · password for 'admin'") || return 1
validate_pw "$admin_pw" "password" || return 1
# Same confirm-twice pattern. Less catastrophic than LUKS (admin
# password can be reset from a recovery shell) but a mismatch here
# still locks the user out of their fresh install on first boot.
local admin_pw_confirm
while true; do
admin_pw=$(prompt_password "[3/3] Admin user · password for 'admin'") || return 1
validate_pw "$admin_pw" "password" || continue
admin_pw_confirm=$(prompt_password "[3/3] Confirm admin password") || return 1
if [[ $admin_pw == "$admin_pw_confirm" ]]; then
break
fi
prompt_error "Passwords do not match — try again."
done
# ── Locale ──
# Hardcoded en_US.UTF-8 for branded consistency. The picker that
@ -427,7 +486,7 @@ __SSHKEY_DIRECTIVE__
# re-runs kernel-install per kernel. That's the canonical Fedora 43 path
# for landing args in BLS entries — kernel-install reads /etc/kernel/cmdline
# (90-loaderentry.install:84-95) when generating BLS option lines.
bootloader --append="lockdown=integrity slab_nomerge init_on_alloc=1 init_on_free=1 randomize_kstack_offset=on vsyscall=none fbcon=nodefer"
bootloader --append="lockdown=integrity module.sig_enforce=1 slab_nomerge init_on_alloc=1 init_on_free=1 randomize_kstack_offset=on vsyscall=none fbcon=nodefer i915.modeset=1 amdgpu.modeset=1 nvidia-drm.modeset=1 rd.vconsole.keymap=us"
# Disk: zero, LUKS2 (argon2id), btrfs subvolumes (no LVM intermediary).
# Native btrfs-on-LUKS matches Fedora KDE Spin defaults; LVM+btrfs combo
@ -488,6 +547,12 @@ chrony
firewalld
plymouth
# AppArmor stack — Fedora 43 ships parser/utils/profiles. v0.6 ships
# loaded-but-complain only (see scripts/40-apparmor.sh + tier-2 plan).
apparmor-parser
apparmor-utils
apparmor-profiles
# admin essentials
git
vim-enhanced
@ -927,7 +992,33 @@ KSEOF
return 0
}
_dump_logs_to_host() {
# If a virtio-9p share tagged "hostlogs" was attached by run-vm.sh,
# mount it and dump every anaconda + installer log into it. Runs on
# success AND failure (called via trap). No-op on real hardware where
# the 9p tag doesn't exist.
if ! grep -qw 9p /proc/filesystems 2>/dev/null; then return 0; fi
local m=/mnt/hostlogs
mkdir -p "$m"
mount -t 9p -o trans=virtio,version=9p2000.L hostlogs "$m" 2>/dev/null || return 0
cp -af /tmp/anaconda.log "$m/" 2>/dev/null || true
cp -af /tmp/program.log "$m/" 2>/dev/null || true
cp -af /tmp/storage.log "$m/" 2>/dev/null || true
cp -af /tmp/packaging.log "$m/" 2>/dev/null || true
cp -af /tmp/dnf.log "$m/" 2>/dev/null || true
cp -af /tmp/dnf.librepo.log "$m/" 2>/dev/null || true
cp -af /tmp/anaconda-cmdline.log "$m/" 2>/dev/null || true
cp -af /var/log/veilor-installer.log "$m/" 2>/dev/null || true
cp -af /run/install/veilor-generated.ks "$m/" 2>/dev/null || true
dmesg > "$m/dmesg.log" 2>/dev/null || true
journalctl -b > "$m/journal.log" 2>/dev/null || true
sync
umount "$m" 2>/dev/null || true
}
run_install() {
# Capture logs to host on every exit path (success, failure, ^C).
trap _dump_logs_to_host EXIT
# Anaconda env setup (see comments below).
export LANG="${SEL_LOCALE:-en_GB.UTF-8}"
export LC_ALL="$LANG"
@ -952,12 +1043,39 @@ run_install() {
--show-output \
-- bash -c 'anaconda --cmdline --kickstart=/run/install/veilor-generated.ks 2>&1 | tee /tmp/anaconda-cmdline.log' || rc=$?
if [[ $rc -eq 0 ]]; then
gum style --foreground 2 --border rounded --margin "1 2" --padding "1 3" \
"✓ Install complete" \
"" \
"System will reboot in 5 seconds." \
"Remove the install media."
sleep 5
# v0.6: split the success screen into THREE stacked boxes.
#
# 1. Green success box — quiet confirmation.
# 2. Yellow eject box — promoted out of the buried
# one-liner the v0.5 success box used. Operators on
# both onyx and the friend's RTX 4080 rig missed the
# reminder and rebooted into the live ISO instead of
# the install. Now it's its own loud thick-bordered
# box that sits BELOW the success box and is
# impossible to miss.
# 3. Reboot countdown — embedded inside the green
# success box so the operator can see "complete +
# Xs to reboot" at a glance.
#
# Each tick clears + redraws all three, so the eject-media
# box stays in front of the operator for the full 10-second
# window and isn't scrolled off by a banner refresh.
local secs
for secs in 10 9 8 7 6 5 4 3 2 1; do
clear
gum style --foreground 2 --border rounded --margin "1 2" --padding "1 3" \
"✓ Install complete" \
"" \
"Rebooting in ${secs}s..."
gum style --foreground 3 --border thick --margin "0 2" --padding "1 3" \
--border-foreground 3 \
" Remove the install media NOW " \
"" \
" Unplug the USB stick / eject the DVD before " \
" reboot, otherwise the system will boot back " \
" into the live ISO instead of your fresh install. "
sleep 1
done
systemctl reboot
else
prompt_error "Anaconda exited non-zero (status $rc).

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

77
scripts/40-apparmor.sh Normal file
View file

@ -0,0 +1,77 @@
#!/usr/bin/env bash
# veilor-os — 40-apparmor: load veilor-shipped AppArmor profiles in
# COMPLAIN mode. v0.6 scope: "loaded, present, nothing breaks".
#
# Per docs/research/2026-05-05-agent-wave/04-hardening-tier-2.md, v0.6
# ships AppArmor stacked alongside SELinux, but every veilor-shipped
# profile stays in complain mode (logs only, no enforce). Real policy
# authoring is post-v0.6.
#
# Idempotent: profiles already in complain mode are skipped. Run as
# root during kickstart %post or post-install.
set -uo pipefail
GREEN='\033[0;32m'; YELLOW='\033[1;33m'; RED='\033[0;31m'; NC='\033[0m'
ok() { echo -e "${GREEN}[OK]${NC} $*"; }
info() { echo -e "${YELLOW}[INFO]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
err() { echo -e "${RED}[ERR]${NC} $*"; }
[[ $EUID -eq 0 ]] || { err "Must run as root"; exit 1; }
echo "════════════════════════════════════════════════════════"
echo " veilor-os :: 40-apparmor (complain mode only)"
echo "════════════════════════════════════════════════════════"
PROFILE_DIR=/etc/apparmor.d/veilor.d
# ── Sanity: tools present? ──
if ! command -v apparmor_parser >/dev/null 2>&1; then
warn "apparmor_parser not installed — skipping (package step missed?)"
exit 0
fi
if ! command -v aa-complain >/dev/null 2>&1; then
warn "aa-complain not installed (apparmor-utils missing) — skipping"
exit 0
fi
if [[ ! -d $PROFILE_DIR ]]; then
info "$PROFILE_DIR not present — no veilor profiles to load"
exit 0
fi
# ── Walk every profile we ship and force complain mode ──
shopt -s nullglob
loaded=0
skipped=0
failed=0
for profile in "$PROFILE_DIR"/*; do
[[ -f $profile ]] || continue
name=$(basename "$profile")
# Already in complain mode? aa-status reports loaded profiles by
# internal profile name, not file path — best-effort match against
# the file basename to avoid re-parsing on repeat runs.
if command -v aa-status >/dev/null 2>&1 \
&& aa-status --complaining 2>/dev/null | grep -qE "(^|/)veilor-${name}([[:space:]]|$)"; then
info "$name already in complain mode — skipping"
skipped=$((skipped + 1))
continue
fi
info "loading $name (complain mode)"
if aa-complain "$profile" >/dev/null 2>&1; then
ok "$name → complain"
loaded=$((loaded + 1))
else
warn "$name failed to load (parser may reject stub on this kernel)"
failed=$((failed + 1))
fi
done
echo "────────────────────────────────────────────────────────"
info "summary: loaded=$loaded skipped=$skipped failed=$failed"
ok "v0.6 AppArmor stub: complain-mode only — no enforcement, log-only"
exit 0

View file

@ -9,6 +9,50 @@ Entries are newest-first.
---
## 2026-05-06 · v0.5.32 · ISO build path moved to Forgejo
**Change:** Build host for the test ISO has moved off GitHub Actions
onto the Forgejo runner on nullstone. The hybrid VM test procedure in
`TESTING.md` is **unchanged** — the gum installer still drives every
step it can, the operator still types the LUKS + admin passwords
directly into the QEMU window. The only thing different is where the
ISO comes from and how the host log is captured.
**Practical deltas for testers:**
- ISO download: from the Forgejo `ci-latest` rolling release at
<https://git.s8n.ru/veilor-org/veilor-os/releases/tag/ci-latest>.
The tag is force-replaced on each successful `build-iso.yml` run, so
always re-download — don't rely on a cached copy.
- Re-flash to USB / virtio-blk image via Etcher / `dd`**unchanged**.
Same `sha256sum -c` step; same image format.
- virtio-9p host log capture is now **active by default** in
`test/run-vm.sh`. This replaces the broken virtio-serial path
flagged by Agent 6 in the 2026-05-05 wave; Anaconda logs land in the
host-side mount automatically once the VM boots, no manual `tail -f`
on a broken serial console.
- Build host for the record: forgejo-runner on nullstone, runner label
`ubuntu-24.04`, image `catthehacker/ubuntu:act-24.04`. Reproducibility
is unchanged from the GH Actions ubuntu-24.04 base — the act image
matches GHA's runner image to within package versions.
**Why:** GitHub mirror was disabled 2026-05-06 (repo is now
private-by-default on Forgejo); GH Actions builds would just stop
producing artifacts. Moving CI in-house onto nullstone keeps the
test/release loop intact and removes the external dependency for
private-build cycles. Documenting the change here so a future tester
reading TESTING.md doesn't waste time hunting an artifact in a
GitHub run that never happened.
**Files touched in this entry:**
- `test/METHOD-CHANGELOG.md` — this entry.
`test/TESTING.md` itself is **not** edited — the procedure prose still
applies verbatim. Only the build host and the URL where the ISO lives
changed.
---
## 2026-05-05 · v0.5.27 · TESTING.md created
**Change:** First version of the canonical procedure document.

View file

@ -208,12 +208,20 @@ echo " Seed : ${SEED_ISO:-<none>}"
# We've lost logs three times in a row to anaconda failures + tmpfs
# reboots. Wiring this up so future failures auto-capture.
ANACONDA_LOG="$TEST_DIR/anaconda-vm-$(date +%Y%m%d-%H%M%S).log"
ANACONDA_LOG_DIR="$TEST_DIR/test-runs/$(date +%Y%m%d-%H%M%S)"
mkdir -p "$ANACONDA_LOG_DIR"
ANACONDA_LOG_ARGS=(
# Belt: virtio-serial (anaconda's setupVirtio rsyslog forward, fragile —
# depends on rsyslog being installed in the live ISO).
-chardev "file,id=anaclog,path=$ANACONDA_LOG"
-device virtio-serial-pci,id=vs1
-device "virtserialport,chardev=anaclog,bus=vs1.0,name=org.fedoraproject.anaconda.log.0"
# Braces: virtio-9p host directory share. veilor-installer mounts this
# at /mnt/hostlogs and rsyncs /tmp/*.log there post-anaconda.
-virtfs "local,path=$ANACONDA_LOG_DIR,mount_tag=hostlogs,security_model=mapped-xattr,id=hostlogs"
)
echo " AnaLog: $ANACONDA_LOG"
echo " AnaLog : $ANACONDA_LOG"
echo " HostFS : $ANACONDA_LOG_DIR (9p tag: hostlogs)"
echo " Mode : ${SECBOOT:+secboot}${SECBOOT:-stock UEFI}"
echo " Inject: ${HOST_PUBKEY:+yes}${HOST_PUBKEY:-no (no host pubkey)}"

View file

@ -0,0 +1,142 @@
# Test run — v0.5.32
- **Date:** 2026-05-06
- **ISO:** `veilor-os-43-20260506-HHMMSS.iso` (sha256: `TBD — fill in once A1 reports the build artifact`)
- **Tester:** A1 (build) + operator (P) + A5 (report scribe)
- **Build host:** forgejo-runner on nullstone (runner label `ubuntu-24.04`,
image `catthehacker/ubuntu:act-24.04`); first ISO produced off the
Forgejo build pipeline after the GH Actions mirror was disabled
2026-05-06.
- **Environment:** VM (qemu/q35/ovmf, 4 vCPU, 4 GiB RAM, virtio-vga,
virtio-9p host log mount). Real-hardware run is a separate report —
this file is the VM run only.
---
## Result
**Pending A1 build.** Operator + A5 fill in pass/fail per-step once
the actual VM test is walked through against the v0.5.32 ISO. Until
the ISO sha256 lands here, treat every row in the per-step table as
unverified.
One-line summary (write here once known): _TBD_.
---
## Regressions vs previous run
(v0.5.31 was the last tagged release; compare against any pass-with-issues
notes from that test run if a report exists. Empty otherwise — fill in
during the actual test walkthrough.)
- _TBD_
---
## Per-step results
Walk `test/TESTING.md` step-by-step. Mark each pass/fail with a brief
note when failed. Until the test runs, every row is `⏳ pending`.
| # | Step | Result | Notes |
|----|-----------------------------------|--------|-------|
| 1 | Live boot to installer banner | ⏳ pending | |
| 2 | Installer menu render | ⏳ pending | |
| 3 | Disk picker | ⏳ pending | |
| 4 | LUKS + admin passwords | ⏳ pending | Operator types directly into QEMU window — plymouth ignores synthesised keys. |
| 5 | Locale | ⏳ pending | |
| 6 | Confirm | ⏳ pending | |
| 7 | Anaconda transaction | ⏳ pending | |
| 8 | Reboot | ⏳ pending | |
| 9 | GRUB single veilor-os entry | ⏳ pending | |
| 10 | LUKS unlock prompt | ⏳ pending | |
| 11 | First boot → SDDM → KDE | ⏳ pending | |
| 12 | Hardening checks | ⏳ pending | |
---
## Hardening verification
```text
$ getenforce
TBD
$ systemctl is-active fail2ban usbguard tuned auditd firewalld
TBD
$ cat /proc/cmdline
TBD — must include rd.luks.uuid=luks-... and the v0.5.32 cmdline set.
$ lsblk -f
TBD
$ systemctl is-enabled veilor-firstboot.service
TBD — must report enabled with WantedBy=graphical.target (blocker #2).
$ nft list ruleset | grep -i tailscale
TBD — tailscale0 must be in the trusted zone (blocker #5).
$ cat /etc/skel/.config/kdeglobals 2>/dev/null | head
TBD — branding must be present (blocker #6).
$ ls /var/log/anaconda/host-9p-mount/
TBD — virtio-9p Anaconda log capture (blocker #7).
```
Paste real output. If any service is inactive, any cmdline arg is
missing, or any blocker artifact is absent, raise as a Regression
above.
---
## Findings
The 7 v0.5.32 blocker fixes from the
[2026-05-05 9-agent research wave](../../docs/research/2026-05-05-agent-wave/README.md)
land in this build. Each is listed here as an **expected behaviour**
the tester must observe — if any of these regress, log it under
Regressions above.
1. **Suspend/resume wifi survives lid-close.** `kernel.modules_disabled=1`
no longer fires before the wifi module reloads on resume. Test:
suspend the VM (or lid-close on real HW), wake, reconnect to the
same network without manual `modprobe`.
2. **`veilor-firstboot.service` is `WantedBy=graphical.target`.** The
first-boot admin password flow must run on real installs, not just
on multi-user.target boots. Test: fresh install boots straight to
the TTY password prompt before SDDM lights up.
3. **Kernel-upgrade does not drift GRUB.** First `dnf upgrade kernel`
must leave the system bootable — `grub2-mkconfig` is wired into the
kernel-install hook. Test: install, run `sudo dnf upgrade kernel`,
reboot, system comes up.
4. **USBGuard rules are id-based, not hash + parent-hash.** Mirrors the
onyx dock-replug fix in `feedback_usbguard_dock.md`. Test:
unplug/replug a known device — it stays allowed. The hash variant
re-blocks on every replug; the id variant must not.
5. **firewalld trusts `tailscale0`.** The interface is in the trusted
zone out-of-the-box. Test: bring tailscale up, ping a peer in the
mesh — no firewall mods required.
6. **`/etc/skel/` carries veilor branding.** New users get the black
colour scheme, Konsole profile, and Plasma layout on first login.
Test: `useradd test`; log in as `test`; KDE comes up branded, no
white flash, Fira Code system font.
7. **virtio-9p Anaconda log capture is active by default.**
`test/run-vm.sh` mounts a host directory into the VM; Anaconda logs
land there during install. Replaces the broken virtio-serial path
from earlier runs. Test: run install in VM; host-side mount has
`program.log`, `storage.log`, `packaging.log` populated.
Free-form notes from the actual walkthrough — cosmetic glitches, slow
paths, surprising behaviour — append below.
- _TBD — fill in during the operator-driven VM run._
---
## Action items for next release
(Empty until the test exposes something. PRs / commits opened during
the run go here.)
- [ ] _TBD_