chown only a lock file swarmctl just created and refuse a hard-linked one
This commit is contained in:
parent
be744d45b9
commit
6b93839290
3 changed files with 149 additions and 34 deletions
|
|
@ -999,14 +999,69 @@ const USERS_LOCK_WAIT: Duration = Duration::from_secs(10);
|
|||
///
|
||||
/// The lock file is a sibling of `users_file`, not `users_file` itself,
|
||||
/// because `write_atomic` replaces that inode on every write. The directory
|
||||
/// belongs to a less-privileged uid, so the lock file is opened `O_NOFOLLOW`
|
||||
/// (a planted symlink fails the open) and `O_NONBLOCK` (a planted FIFO cannot
|
||||
/// hang it), and must be a regular file. A file created here is handed to the
|
||||
/// directory's owner by descriptor so that uid can open it too.
|
||||
/// belongs to a less-privileged uid, so see [`open_lock_file`] for how the
|
||||
/// open resists a planted link. Only a file this call created is handed to
|
||||
/// the directory's owner (`fchown` by descriptor), so that uid can open it
|
||||
/// too; an existing entry is never chowned.
|
||||
fn lock_users_file(users_file: &Path) -> Result<File> {
|
||||
lock_users_file_within(users_file, USERS_LOCK_WAIT)
|
||||
}
|
||||
|
||||
/// Open `<users_file>.lock`, reporting whether this call created it.
|
||||
///
|
||||
/// `O_EXCL` first, so a file that is ours is told apart from one that was
|
||||
/// already there. An existing entry is reopened read-only: `flock` needs no
|
||||
/// write access, and a lock file left behind owned by root (a crash between
|
||||
/// create and `fchown`) stays openable by the bridge. An existing entry must
|
||||
/// be a regular file with one link; a hard link planted by the directory's
|
||||
/// owner to some other file is refused.
|
||||
fn open_lock_file(lock_path: &Path) -> Result<(File, bool)> {
|
||||
let nofollow = (rustix::fs::OFlags::NOFOLLOW | rustix::fs::OFlags::NONBLOCK)
|
||||
.bits()
|
||||
.cast_signed();
|
||||
for _ in 0..2 {
|
||||
// Mode 0644 so the lock stays readable by the bridge if we die before
|
||||
// `fchown`; the directory is 0700, so nobody else reaches it.
|
||||
match fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o644)
|
||||
.custom_flags(nofollow)
|
||||
.open(lock_path)
|
||||
{
|
||||
Ok(file) => return Ok((file, true)),
|
||||
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {}
|
||||
Err(e) => return Err(e).with_context(|| format!("creating {}", lock_path.display())),
|
||||
}
|
||||
match fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.custom_flags(nofollow)
|
||||
.open(lock_path)
|
||||
{
|
||||
Ok(file) => {
|
||||
let meta = file
|
||||
.metadata()
|
||||
.with_context(|| format!("stat {}", lock_path.display()))?;
|
||||
if !meta.is_file() {
|
||||
bail!("{} is not a regular file", lock_path.display());
|
||||
}
|
||||
if meta.nlink() != 1 {
|
||||
bail!(
|
||||
"{} has {} hard links; refusing to lock a file that is not ours",
|
||||
lock_path.display(),
|
||||
meta.nlink()
|
||||
);
|
||||
}
|
||||
return Ok((file, false));
|
||||
}
|
||||
// Removed between the two opens: create it again.
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => {}
|
||||
Err(e) => return Err(e).with_context(|| format!("opening {}", lock_path.display())),
|
||||
}
|
||||
}
|
||||
bail!("{} keeps changing under us", lock_path.display())
|
||||
}
|
||||
|
||||
fn lock_users_file_within(users_file: &Path, wait: Duration) -> Result<File> {
|
||||
let dir = users_file
|
||||
.parent()
|
||||
|
|
@ -1019,23 +1074,9 @@ fn lock_users_file_within(users_file: &Path, wait: Duration) -> Result<File> {
|
|||
lock_name.push(".lock");
|
||||
let lock_path = dir.join(lock_name);
|
||||
|
||||
let flags = rustix::fs::OFlags::NOFOLLOW | rustix::fs::OFlags::NONBLOCK;
|
||||
let file = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.mode(0o600)
|
||||
.custom_flags(flags.bits().cast_signed())
|
||||
.open(&lock_path)
|
||||
.with_context(|| format!("opening {}", lock_path.display()))?;
|
||||
let meta = file
|
||||
.metadata()
|
||||
.with_context(|| format!("stat {}", lock_path.display()))?;
|
||||
if !meta.is_file() {
|
||||
bail!("{} is not a regular file", lock_path.display());
|
||||
}
|
||||
let dir_meta = fs::metadata(dir).with_context(|| format!("stat {}", dir.display()))?;
|
||||
if (meta.uid(), meta.gid()) != (dir_meta.uid(), dir_meta.gid()) {
|
||||
let (file, created) = open_lock_file(&lock_path)?;
|
||||
if created {
|
||||
let dir_meta = fs::metadata(dir).with_context(|| format!("stat {}", dir.display()))?;
|
||||
std::os::unix::fs::fchown(&file, Some(dir_meta.uid()), Some(dir_meta.gid()))
|
||||
.with_context(|| format!("setting owner on {}", lock_path.display()))?;
|
||||
}
|
||||
|
|
@ -1533,6 +1574,56 @@ mod tests {
|
|||
fs::remove_dir_all(dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_existing_lock_file_is_opened_not_created_or_chowned() {
|
||||
let paths = locked_users_fixture("existing", "0");
|
||||
let lock_path = paths.users_file.with_file_name("users.yml.lock");
|
||||
|
||||
let (_, created) = open_lock_file(&lock_path).expect("first open");
|
||||
assert!(created, "a missing lock file is created by this call");
|
||||
let (_, created) = open_lock_file(&lock_path).expect("second open");
|
||||
assert!(
|
||||
!created,
|
||||
"an existing lock file must be reported as not ours"
|
||||
);
|
||||
|
||||
fs::remove_dir_all(paths.users_file.parent().unwrap()).ok();
|
||||
}
|
||||
|
||||
/// The directory's owner can plant a hard link; a root-side `fchown` on it
|
||||
/// would re-own the link's target.
|
||||
#[test]
|
||||
fn the_users_lock_refuses_a_hard_link_planted_at_the_lock_path() {
|
||||
let paths = locked_users_fixture("hardlink", "0");
|
||||
let dir = paths.users_file.parent().unwrap();
|
||||
let target = dir.join("victim");
|
||||
fs::write(&target, "keep").expect("victim");
|
||||
fs::hard_link(&target, dir.join("users.yml.lock")).expect("plant");
|
||||
let before = fs::metadata(&target).expect("stat");
|
||||
|
||||
let err = lock_users_file(&paths.users_file).expect_err("a linked lock path is refused");
|
||||
assert!(err.to_string().contains("hard links"), "{err}");
|
||||
let after = fs::metadata(&target).expect("stat");
|
||||
assert_eq!((after.uid(), after.gid()), (before.uid(), before.gid()));
|
||||
assert_eq!(fs::read_to_string(&target).expect("read"), "keep");
|
||||
|
||||
fs::remove_dir_all(dir).ok();
|
||||
}
|
||||
|
||||
/// A lock file left behind without write access (root-owned after a crash
|
||||
/// before `fchown`) can still be locked: `flock` needs only a read fd.
|
||||
#[test]
|
||||
fn a_read_only_lock_file_can_still_be_locked() {
|
||||
let paths = locked_users_fixture("readonly", "0");
|
||||
let lock_path = paths.users_file.with_file_name("users.yml.lock");
|
||||
fs::write(&lock_path, "").expect("seed");
|
||||
fs::set_permissions(&lock_path, Permissions::from_mode(0o444)).expect("chmod");
|
||||
|
||||
lock_users_file(&paths.users_file).expect("lock a read-only lock file");
|
||||
|
||||
fs::remove_dir_all(paths.users_file.parent().unwrap()).ok();
|
||||
}
|
||||
|
||||
/// Both adds load the file before either publishes (each spends 0.5s in
|
||||
/// authelia), so without the lock the second rename drops the first user.
|
||||
#[test]
|
||||
|
|
|
|||
Loading…
Reference in a new issue