Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## Unreleased

- Dynamic allocation ranges for normal UIDs/GIDs are now configurable via
`normalUidRange`/`normalGidRange` (default: 1000 to 29999), mirroring
`UID_MIN`/`UID_MAX`/`GID_MIN`/`GID_MAX` from login.defs.
- Added a JSON schema that specifies the configuration format.

## 1.0.1
Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,21 @@ The config file is specified in a [provided JSON
schema](./userborn.schema.json) which you can use to see available options and
to validate your config.

#### Normal ID Ranges

Normal UIDs/GIDs are dynamically allocated from 1000 to 29999 (inclusive) by
default. `normalUidRange`/`normalGidRange` change this, e.g. to keep statically
assigned IDs (such as for NFS) outside of dynamic allocation:

```json
{
"normalUidRange": { "min": 30000, "max": 39999 },
"normalGidRange": { "min": 30000, "max": 39999 }
}
```

System IDs are always allocated from 1 to 999; `min` must be at least 1000.

### Environment Variables

- `USERBORN_MUTABLE_USERS`: Set this to the string `true` if you want to enable
Expand Down
77 changes: 75 additions & 2 deletions rust/userborn/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use std::{
path::Path,
};

use anyhow::{Context, Result};
use anyhow::{Context, Result, bail};
use serde::Deserialize;

/// # User
Expand Down Expand Up @@ -89,6 +89,12 @@ pub struct Group {
#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
#[serde(rename_all = "camelCase")]
pub struct Config {
/// Range for dynamically allocated normal UIDs (login.defs `UID_MIN`/`UID_MAX`).
#[serde(default)]
pub normal_uid_range: IdRange,
/// Range for dynamically allocated normal GIDs (login.defs `GID_MIN`/`GID_MAX`).
#[serde(default)]
pub normal_gid_range: IdRange,
/// Users to manage.
#[serde(default)]
pub users: Vec<User>,
Expand All @@ -97,6 +103,41 @@ pub struct Config {
pub groups: Vec<Group>,
}

/// The lowest ID considered "normal" (i.e. not a system ID).
pub const NORMAL_ID_MIN: u32 = 1000;

/// Inclusive range from which normal IDs are dynamically allocated.
#[derive(Deserialize, Debug, Clone, Copy)]
#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
pub struct IdRange {
pub min: u32,
pub max: u32,
}

impl Default for IdRange {
fn default() -> Self {
Self {
min: NORMAL_ID_MIN,
max: 29999,
}
}
}

impl IdRange {
pub fn validate(self) -> Result<()> {
if self.min > self.max {
bail!("Invalid ID range: min ({}) > max ({})", self.min, self.max);
}
if self.min < NORMAL_ID_MIN {
bail!(
"Invalid ID range: min ({}) must be at least {NORMAL_ID_MIN}",
self.min
);
}
Ok(())
}
}

/// Range of subordiate IDs to create.
#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
Expand All @@ -111,7 +152,16 @@ impl Config {
pub fn from_file(path: impl AsRef<Path>) -> Result<Self> {
let contents = fs::read(&path)
.with_context(|| format!("Failed to read {}", path.as_ref().display()))?;
serde_json::from_slice(&contents).context("Failed to parse config")
let config: Self = serde_json::from_slice(&contents).context("Failed to parse config")?;
config
.normal_uid_range
.validate()
.context("normalUidRange")?;
config
.normal_gid_range
.validate()
.context("normalGidRange")?;
Ok(config)
}

#[must_use]
Expand All @@ -132,6 +182,8 @@ mod tests {
#[test]
fn config() -> Result<()> {
let value = serde_json::json!({
"normalUidRange": { "min": 30000, "max": 39999 },
"normalGidRange": { "min": 40000, "max": 49999 },
"users": [
{
"isNormal": true,
Expand Down Expand Up @@ -170,4 +222,25 @@ mod tests {
serde_json::from_value::<Config>(value)?;
Ok(())
}

#[test]
fn validate_range() {
assert!(IdRange::default().validate().is_ok());
assert!(
IdRange {
min: 999,
max: 2000
}
.validate()
.is_err()
);
assert!(
IdRange {
min: 2000,
max: 1999
}
.validate()
.is_err()
);
}
}
11 changes: 8 additions & 3 deletions rust/userborn/src/group.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use std::collections::{BTreeMap, BTreeSet};

use anyhow::{Result, bail};

use crate::{fs::FromBuffer, id};
use crate::{config, fs::FromBuffer, id};

#[derive(Clone)]
pub struct Entry {
Expand Down Expand Up @@ -138,10 +138,15 @@ impl Group {
/// Allocate a new (i.e. unused) GID.
///
/// Returns `Err` if it cannot allocate a new GID because all in the range are already used.
pub fn allocate_gid(&self, is_normal: bool, reserved_gids: &BTreeSet<u32>) -> Result<u32> {
pub fn allocate_gid(
&self,
is_normal: bool,
reserved_gids: &BTreeSet<u32>,
normal_range: config::IdRange,
) -> Result<u32> {
let mut allocated_gids = self.entries.keys().copied().collect::<BTreeSet<u32>>();
allocated_gids.extend(reserved_gids);
id::allocate(&allocated_gids, is_normal)
id::allocate(&allocated_gids, is_normal, normal_range)
}

pub fn contains_gid(&self, gid: u32) -> bool {
Expand Down
44 changes: 39 additions & 5 deletions rust/userborn/src/id.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,28 @@ use std::collections::BTreeSet;

use anyhow::{Result, bail};

use crate::config::{IdRange, NORMAL_ID_MIN};

/// Allocate a new UID/GID.
///
/// Normal users/groups get an ID in the range from 1000 to 29999 (inclusive).
/// Normal users/groups get an ID from `normal_range` (by default 1000 to 29999 inclusive).
///
/// System users/groups get an ID in the range from 1 to 999 (inclusive).
///
/// Fails if there are no unused IDs in the respective ranges.
pub fn allocate(already_allocated_ids: &BTreeSet<u32>, is_normal: bool) -> Result<u32> {
pub fn allocate(
already_allocated_ids: &BTreeSet<u32>,
is_normal: bool,
normal_range: IdRange,
) -> Result<u32> {
if is_normal {
for candidate in 1000u32..30000 {
for candidate in normal_range.min..=normal_range.max {
if !already_allocated_ids.contains(&candidate) {
return Ok(candidate);
}
}
} else {
for candidate in (1u32..1000).rev() {
for candidate in (1u32..NORMAL_ID_MIN).rev() {
if !already_allocated_ids.contains(&candidate) {
return Ok(candidate);
}
Expand All @@ -34,9 +40,23 @@ mod tests {
already_allocated_ids: impl IntoIterator<Item = u32>,
is_normal: bool,
expected: u32,
) -> Result<()> {
check_allocate_id_in_range(
already_allocated_ids,
is_normal,
IdRange::default(),
expected,
)
}

fn check_allocate_id_in_range(
already_allocated_ids: impl IntoIterator<Item = u32>,
is_normal: bool,
normal_range: IdRange,
expected: u32,
) -> Result<()> {
let uids = already_allocated_ids.into_iter().collect::<BTreeSet<u32>>();
let allocated = allocate(&uids, is_normal)?;
let allocated = allocate(&uids, is_normal, normal_range)?;
assert_eq!(allocated, expected);
Ok(())
}
Expand All @@ -56,4 +76,18 @@ mod tests {
assert!(check_allocate_id(999..30000, true, 1).is_err());
Ok(())
}

#[test]
fn allocate_uid_custom_range() -> Result<()> {
let range = IdRange {
min: 30000,
max: 30001,
};
check_allocate_id_in_range([1000], true, range, 30000)?;
check_allocate_id_in_range([30000], true, range, 30001)?;
assert!(check_allocate_id_in_range([30000, 30001], true, range, 0).is_err());
// The custom range only applies to normal IDs.
check_allocate_id_in_range([], false, range, 999)?;
Ok(())
}
}
Loading
Loading