Skip to content

Commit f7066fc

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 83a7c9f commit f7066fc

15 files changed

Lines changed: 860 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: 77 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")]
@@ -146,6 +147,11 @@ pub(crate) fn generator(root: &Dir, unit_dir: &Dir) -> Result<()> {
146147

147148
unit_enablement_impl(sysroot, unit_dir)?;
148149

150+
// The shadow sync runs on all ostree boots; shadow_sync_generator_impl
151+
// has its own guard that checks whether /etc/shadow exists.
152+
let updated = shadow_sync_generator_impl(root, unit_dir)?;
153+
tracing::trace!("Enabled shadow sync: {updated}");
154+
149155
// Only run for overlayfs roots (composefs mounts an overlay, regular or transient).
150156
let st = rustix::fs::fstatfs(root.as_fd())?;
151157
if st.f_type != libc::OVERLAYFS_SUPER_MAGIC {
@@ -166,6 +172,31 @@ pub(crate) fn generator(root: &Dir, unit_dir: &Dir) -> Result<()> {
166172
Ok(())
167173
}
168174

175+
/// Enable the statically-installed shadow sync unit by symlinking it into
176+
/// `sysinit.target.wants/` in the generator output directory.
177+
///
178+
/// The unit file itself lives at `/usr/lib/systemd/system/bootc-sysusers-shadow-sync.service`
179+
/// and is shipped with bootc; the generator only performs conditional enablement.
180+
///
181+
/// We check existence of `/etc/shadow` rather than writability because systemd
182+
/// sandboxes generators in a private read-only mount namespace (see
183+
/// `systemd.generator(7)`), so any writability check would always fail even
184+
/// though `/etc` will be on its own writable mount by the time the service
185+
/// actually runs. The caller already gates on the ostree-booted marker,
186+
/// which guarantees we are on a bootc system where `/etc` is writable at
187+
/// service-run time.
188+
#[context("shadow sync generator")]
189+
pub(crate) fn shadow_sync_generator_impl(root: &Dir, unit_dir: &Dir) -> Result<bool> {
190+
if !root.try_exists("etc/shadow")? {
191+
tracing::trace!("/etc/shadow not found, skipping shadow sync");
192+
return Ok(false);
193+
}
194+
195+
tracing::debug!("/etc/shadow found, enabling {SHADOW_SYNC_UNIT}");
196+
enable_unit(unit_dir, SHADOW_SYNC_UNIT, "sysinit.target")?;
197+
Ok(true)
198+
}
199+
169200
/// Parse /etc/fstab and check if the root mount is out of sync with the composefs
170201
/// state, and if so, fix it.
171202
fn generate_fstab_editor(unit_dir: &Dir) -> Result<()> {
@@ -417,5 +448,51 @@ UUID=341c4712-54e8-4839-8020-d94073b1dc8b /boot xfs defaul
417448

418449
Ok(())
419450
}
451+
452+
#[test]
453+
fn test_shadow_sync_no_shadow() -> Result<()> {
454+
// No /etc/shadow => should not enable (boot detection is caller's job)
455+
let tempdir = fixture()?;
456+
let unit_dir = &tempdir.open_dir("run/systemd/system")?;
457+
let generated = shadow_sync_generator_impl(&tempdir, unit_dir)?;
458+
assert!(!generated);
459+
assert_eq!(unit_dir.entries()?.count(), 0);
460+
Ok(())
461+
}
462+
463+
#[test]
464+
fn test_shadow_sync_enables_when_shadow_present() -> Result<()> {
465+
// /etc/shadow present => enables the static unit
466+
let tempdir = fixture()?;
467+
tempdir.atomic_write("etc/shadow", "root:*:18912:0:99999:7:::\n")?;
468+
let unit_dir = &tempdir.open_dir("run/systemd/system")?;
469+
let generated = shadow_sync_generator_impl(&tempdir, unit_dir)?;
470+
assert!(generated);
471+
// The generator creates a symlink in sysinit.target.wants/; check the
472+
// directory entry exists (symlink_contents creates an absolute-path symlink
473+
// that cap-std won't follow in a tempdir, so we check metadata directly).
474+
let wants = unit_dir.open_dir("sysinit.target.wants")?;
475+
let meta = wants.symlink_metadata(SHADOW_SYNC_UNIT)?;
476+
assert!(meta.is_symlink(), "expected symlink for {SHADOW_SYNC_UNIT}");
477+
Ok(())
478+
}
479+
480+
/// Verify that generator() enables the shadow sync unit on traditional
481+
/// ostree boots (tmpfs root, OSTREE_BOOTED marker present).
482+
#[test]
483+
fn test_generator_shadow_sync_on_non_composefs() -> Result<()> {
484+
let tempdir = fixture()?;
485+
tempdir.atomic_write(OSTREE_BOOTED, "")?;
486+
tempdir.atomic_write("etc/shadow", "root:*:18912:0:99999:7:::\n")?;
487+
let unit_dir = &tempdir.open_dir("run/systemd/system")?;
488+
generator(&tempdir, unit_dir)?;
489+
let wants = unit_dir.open_dir("sysinit.target.wants")?;
490+
let meta = wants.symlink_metadata(SHADOW_SYNC_UNIT)?;
491+
assert!(
492+
meta.is_symlink(),
493+
"shadow sync unit must be enabled on non-composefs ostree systems"
494+
);
495+
Ok(())
496+
}
420497
}
421498
}

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)