Skip to content

Commit 1eb0567

Browse files
committed
generator: Fix orphaned /etc/shadow and /etc/gshadow entries before sysusers
There's a bit of a trap in the movement from nss-altfiles to systemd-sysusers; if users/groups migrate from the former to the latter, they may leave orphaned entires in the shadow files. systemd-sysusers then tries to create those users/groups at boot it finds them already in the shadow files and fatally errors. Add a generator which enables a unit detects this situation and cleans up the shadow entries. Now in practice: we probably should have made sure that nss-altfiles users don't have shadow entries at all, but that ship has sailed. Fixes: #1179 Assisted-by: OpenCode (Claude Sonnet 4.6) Signed-off-by: Colin Walters <walters@verbum.org>
1 parent ab50a32 commit 1eb0567

15 files changed

Lines changed: 879 additions & 33 deletions

File tree

crates/lib/src/cli.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -630,6 +630,9 @@ pub(crate) enum InternalsOpts {
630630
late_dir: Option<Utf8PathBuf>,
631631
},
632632
FixupEtcFstab,
633+
/// Remove orphaned and duplicate entries from /etc/shadow and /etc/gshadow
634+
/// before systemd-sysusers runs.
635+
SysusersSync,
633636
/// Should only be used by `make update-generated`
634637
PrintJsonSchema {
635638
#[clap(long)]
@@ -2094,6 +2097,7 @@ async fn run_from_opt(opt: Opt) -> Result<()> {
20942097
Ok(())
20952098
}
20962099
InternalsOpts::FixupEtcFstab => crate::deploy::fixup_etc_fstab(&root),
2100+
InternalsOpts::SysusersSync => crate::sysusers_cleanup::run(&root),
20972101
InternalsOpts::PrintJsonSchema { of } => {
20982102
let schema = match of {
20992103
SchemaType::Host => schema_for!(crate::spec::Host),

crates/lib/src/generator.rs

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ const FSTAB_ANACONDA_STAMP: &str = "Created by anaconda";
2020
pub(crate) const BOOTC_EDITED_STAMP: &str = "Updated by bootc-fstab-edit.service";
2121
const TRANSIENT_RELABEL_UNIT: &str = "bootc-early-overlay-relabel.service";
2222
const SYSINIT_TARGET: &str = "sysinit.target";
23+
const SHADOW_SYNC_UNIT: &str = "bootc-sysusers-shadow-sync.service";
2324

2425
/// Called when the root is read-only composefs to reconcile /etc/fstab
2526
#[context("bootc generator")]
@@ -134,6 +135,28 @@ pub(crate) fn generator(root: &Dir, unit_dir: &Dir) -> Result<()> {
134135
}
135136
}
136137

138+
// === Shadow sync unit: runs for ALL bootc systems (native composefs or ostree) ===
139+
// Must be before the ostree-booted guard because native composefs boots do
140+
// not write /run/ostree-booted, but still need the shadow sync to clean up
141+
// stale shadow/gshadow entries (the rechunk scenario). Gate on either a
142+
// composefs mount source (native composefs boot) or the ostree-booted marker.
143+
{
144+
let is_composefs = match bootc_mount::inspect_filesystem(camino::Utf8Path::new("/")) {
145+
Ok(fs) => {
146+
fs.source.starts_with("composefs:") || fs.source.starts_with("transient:composefs=")
147+
}
148+
Err(e) => {
149+
tracing::debug!("Could not inspect root filesystem: {e:#}");
150+
false
151+
}
152+
};
153+
let is_ostree = root.try_exists(OSTREE_BOOTED)?;
154+
if is_composefs || is_ostree {
155+
let updated = shadow_sync_generator_impl(root, unit_dir)?;
156+
tracing::trace!("Enabled shadow sync: {updated}");
157+
}
158+
}
159+
137160
// === Ostree-specific generator logic ===
138161
// Only run on ostree systems (native composefs boots skip below).
139162
if !root.try_exists(OSTREE_BOOTED)? {
@@ -166,6 +189,31 @@ pub(crate) fn generator(root: &Dir, unit_dir: &Dir) -> Result<()> {
166189
Ok(())
167190
}
168191

192+
/// Enable the statically-installed shadow sync unit by symlinking it into
193+
/// `sysinit.target.wants/` in the generator output directory.
194+
///
195+
/// The unit file itself lives at `/usr/lib/systemd/system/bootc-sysusers-shadow-sync.service`
196+
/// and is shipped with bootc; the generator only performs conditional enablement.
197+
///
198+
/// We check existence of `/etc/shadow` rather than writability because systemd
199+
/// sandboxes generators in a private read-only mount namespace (see
200+
/// `systemd.generator(7)`), so any writability check would always fail even
201+
/// though `/etc` will be on its own writable mount by the time the service
202+
/// actually runs. The caller already gates on the ostree-booted marker,
203+
/// which guarantees we are on a bootc system where `/etc` is writable at
204+
/// service-run time.
205+
#[context("shadow sync generator")]
206+
pub(crate) fn shadow_sync_generator_impl(root: &Dir, unit_dir: &Dir) -> Result<bool> {
207+
if !root.try_exists("etc/shadow")? {
208+
tracing::trace!("/etc/shadow not found, skipping shadow sync");
209+
return Ok(false);
210+
}
211+
212+
tracing::debug!("/etc/shadow found, enabling {SHADOW_SYNC_UNIT}");
213+
enable_unit(unit_dir, SHADOW_SYNC_UNIT, "sysinit.target")?;
214+
Ok(true)
215+
}
216+
169217
/// Parse /etc/fstab and check if the root mount is out of sync with the composefs
170218
/// state, and if so, fix it.
171219
fn generate_fstab_editor(unit_dir: &Dir) -> Result<()> {
@@ -417,5 +465,51 @@ UUID=341c4712-54e8-4839-8020-d94073b1dc8b /boot xfs defaul
417465

418466
Ok(())
419467
}
468+
469+
#[test]
470+
fn test_shadow_sync_no_shadow() -> Result<()> {
471+
// No /etc/shadow => should not enable (boot detection is caller's job)
472+
let tempdir = fixture()?;
473+
let unit_dir = &tempdir.open_dir("run/systemd/system")?;
474+
let generated = shadow_sync_generator_impl(&tempdir, unit_dir)?;
475+
assert!(!generated);
476+
assert_eq!(unit_dir.entries()?.count(), 0);
477+
Ok(())
478+
}
479+
480+
#[test]
481+
fn test_shadow_sync_enables_when_shadow_present() -> Result<()> {
482+
// /etc/shadow present => enables the static unit
483+
let tempdir = fixture()?;
484+
tempdir.atomic_write("etc/shadow", "root:*:18912:0:99999:7:::\n")?;
485+
let unit_dir = &tempdir.open_dir("run/systemd/system")?;
486+
let generated = shadow_sync_generator_impl(&tempdir, unit_dir)?;
487+
assert!(generated);
488+
// The generator creates a symlink in sysinit.target.wants/; check the
489+
// directory entry exists (symlink_contents creates an absolute-path symlink
490+
// that cap-std won't follow in a tempdir, so we check metadata directly).
491+
let wants = unit_dir.open_dir("sysinit.target.wants")?;
492+
let meta = wants.symlink_metadata(SHADOW_SYNC_UNIT)?;
493+
assert!(meta.is_symlink(), "expected symlink for {SHADOW_SYNC_UNIT}");
494+
Ok(())
495+
}
496+
497+
/// Verify that generator() enables the shadow sync unit on traditional
498+
/// ostree boots (tmpfs root, OSTREE_BOOTED marker present).
499+
#[test]
500+
fn test_generator_shadow_sync_on_non_composefs() -> Result<()> {
501+
let tempdir = fixture()?;
502+
tempdir.atomic_write(OSTREE_BOOTED, "")?;
503+
tempdir.atomic_write("etc/shadow", "root:*:18912:0:99999:7:::\n")?;
504+
let unit_dir = &tempdir.open_dir("run/systemd/system")?;
505+
generator(&tempdir, unit_dir)?;
506+
let wants = unit_dir.open_dir("sysinit.target.wants")?;
507+
let meta = wants.symlink_metadata(SHADOW_SYNC_UNIT)?;
508+
assert!(
509+
meta.is_symlink(),
510+
"shadow sync unit must be enabled on non-composefs ostree systems"
511+
);
512+
Ok(())
513+
}
420514
}
421515
}

crates/lib/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ mod reboot;
9494
pub mod spec;
9595
mod status;
9696
mod store;
97+
mod sysusers_cleanup;
9798
mod task;
9899
mod ukify;
99100
mod utils;

0 commit comments

Comments
 (0)