diff --git a/.pipelines/templates/stages/testing_functional/functional-testing.yml b/.pipelines/templates/stages/testing_functional/functional-testing.yml index 45a06b8d0..2c847909b 100644 --- a/.pipelines/templates/stages/testing_functional/functional-testing.yml +++ b/.pipelines/templates/stages/testing_functional/functional-testing.yml @@ -100,12 +100,6 @@ stages: - template: ../common_tasks/update-protoc.yml - - template: ../common_tasks/download-osmodifier.yml - parameters: - tridentSourceDirectory: $(TRIDENT_SOURCE_DIR) - osModifierBranch: ${{ parameters.osModifierBranch }} - targetArchitecture: amd64 - - bash: | set -eux diff --git a/.pipelines/templates/stages/trident_rpms/build-source.yml b/.pipelines/templates/stages/trident_rpms/build-source.yml index 305504299..f8e2e407b 100644 --- a/.pipelines/templates/stages/trident_rpms/build-source.yml +++ b/.pipelines/templates/stages/trident_rpms/build-source.yml @@ -101,12 +101,6 @@ stages: - template: ../common_tasks/cargo-auth.yml parameters: cargoConfigPath: $(TRIDENT_SOURCE_DIR)/.cargo/config.toml - - template: ../common_tasks/download-osmodifier.yml - parameters: - tridentSourceDirectory: $(TRIDENT_SOURCE_DIR) - targetArchitecture: ${{ parameters.targetArchitecture }} - osModifierBranch: ${{ parameters.osModifierBranch }} - osModifierBuildType: ${{ parameters.osModifierBuildType }} - template: release.yml parameters: targetArchitecture: ${{ parameters.targetArchitecture }} @@ -144,12 +138,6 @@ stages: set -eux sudo systemctl start docker displayName: Start Docker - - template: ../common_tasks/download-osmodifier.yml - parameters: - tridentSourceDirectory: $(TRIDENT_SOURCE_DIR) - targetArchitecture: ${{ parameters.targetArchitecture }} - osModifierBranch: ${{ parameters.osModifierBranch }} - osModifierBuildType: ${{ parameters.osModifierBuildType }} - template: release.yml parameters: targetArchitecture: ${{ parameters.targetArchitecture }} diff --git a/.pipelines/templates/stages/validate_makefile/dev-build.yml b/.pipelines/templates/stages/validate_makefile/dev-build.yml index 20244d87d..21420c9bb 100644 --- a/.pipelines/templates/stages/validate_makefile/dev-build.yml +++ b/.pipelines/templates/stages/validate_makefile/dev-build.yml @@ -85,19 +85,6 @@ stages: steps: - template: ../common_tasks/checkout_trident.yml - template: ../common_tasks/avoid-pypi-usage.yml - - bash: | - set -eux - make artifacts/osmodifier - rm -rf artifacts/osmodifier - displayName: Invoke make artifacts/osmodifier - workingDirectory: $(TRIDENT_SOURCE_DIR) - - - template: ../common_tasks/download-osmodifier.yml - parameters: - tridentSourceDirectory: $(TRIDENT_SOURCE_DIR) - osModifierBuildType: dev - osModifierBranch: ${{ parameters.osModifierBranch }} - targetArchitecture: amd64 - script: | set -eux diff --git a/Cargo.lock b/Cargo.lock index 2814f53ba..0b2e7ab13 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1862,6 +1862,23 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "osmodifier" +version = "0.1.0" +dependencies = [ + "anyhow", + "indoc", + "inventory", + "log", + "nix", + "osutils", + "pytest", + "pytest_gen", + "serde", + "tempfile", + "trident_api", +] + [[package]] name = "osutils" version = "0.1.0" @@ -3433,6 +3450,7 @@ dependencies = [ "netplan-types", "nix", "oci-client", + "osmodifier", "osutils", "procfs", "prost-types", diff --git a/Cargo.toml b/Cargo.toml index 34df5dfbb..f96d75320 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ default-members = ["crates/trident"] members = [ "crates/docbuilder", "crates/trident-acl-agent", + "crates/osmodifier", "crates/osutils", "crates/pytest_gen", "crates/pytest", diff --git a/Makefile b/Makefile index 65c0aa904..96734bf1e 100644 --- a/Makefile +++ b/Makefile @@ -142,25 +142,8 @@ target/release/trident target/release/trident-acl-agent: .cargo/config | version TRIDENT_VERSION="$(LOCAL_BUILD_TRIDENT_VERSION)" \ cargo build --release --features dangerous-options,grpc-preview -p trident -p trident-acl-agent -TOOLKIT_DIR="azure-linux-image-tools/toolkit" -AZL_TOOLS_OUT_DIR="$(TOOLKIT_DIR)/out/tools" ARTIFACTS_DIR="artifacts" -# Build OSModifier from a local clone of azure-linux-image-tools. -# Make sure the repo has been cloned manually, via: -# -# git clone https://github.com/microsoft/azure-linux-image-tools - -artifacts/osmodifier: packaging/docker/Dockerfile-osmodifier.azl3 - @docker build -t trident/osmodifier-build:latest \ - -f packaging/docker/Dockerfile-osmodifier.azl3 \ - . - @mkdir -p "$(ARTIFACTS_DIR)" - @id=$$(docker create trident/osmodifier-build:latest) && \ - docker cp -q $$id:/work/azure-linux-image-tools/toolkit/out/tools/osmodifier $@ || \ - docker rm -v $$id - @touch $@ - .PHONY: azl3-builder-image clean-azl3-builder-image build-azl3 azl3-builder-image: @echo "Checking for local image $(AZL3_BUILDER_IMAGE)..." @@ -185,7 +168,7 @@ target/azl3/release/trident target/azl3/release/trident-acl-agent: version-vars cargo build --color always --target-dir target/azl3 --release --features dangerous-options,grpc-preview -p trident -p trident-acl-agent # This will do a proper build on azl3, exactly as the pipelines would, with the custom registry and all. -bin/trident-rpms-azl3.tar.gz: packaging/docker/Dockerfile.full packaging/systemd/*.service packaging/rpm/trident.spec artifacts/osmodifier packaging/selinux-policy-trident/* version-vars +bin/trident-rpms-azl3.tar.gz: packaging/docker/Dockerfile.full packaging/systemd/*.service packaging/rpm/trident.spec packaging/selinux-policy-trident/* version-vars $(eval CARGO_REGISTRIES_BMP_PUBLICPACKAGES_TOKEN := $(shell az account get-access-token --query "join(' ', ['Bearer', accessToken])" --output tsv)) @mkdir -p bin/ @@ -207,7 +190,7 @@ bin/trident-rpms-azl3.tar.gz: packaging/docker/Dockerfile.full packaging/systemd @tar xf $@ -C bin/ # This one does a fast trick-build where we build locally and inject the binary into the container to add it to the RPM. -bin/trident-rpms.tar.gz: packaging/docker/Dockerfile.azl3 packaging/systemd/*.service packaging/rpm/trident.spec artifacts/osmodifier target/release/trident target/release/trident-acl-agent packaging/selinux-policy-trident/* +bin/trident-rpms.tar.gz: packaging/docker/Dockerfile.azl3 packaging/systemd/*.service packaging/rpm/trident.spec target/release/trident target/release/trident-acl-agent packaging/selinux-policy-trident/* @mkdir -p bin/ @if [ ! -f bin/trident ] || ! cmp -s target/release/trident bin/trident; then \ cp target/release/trident bin/trident; \ @@ -390,7 +373,7 @@ functional-test: artifacts/trident-functest.qcow2 # A target for pipelines that skips all setup and building steps that are not # required in the pipeline environment. .PHONY: functional-test-core -functional-test-core: artifacts/osmodifier build-functional-test-cc generate-functional-test-manifest artifacts/trident-functest.qcow2 bin/virtdeploy +functional-test-core: build-functional-test-cc generate-functional-test-manifest artifacts/trident-functest.qcow2 bin/virtdeploy python3 -u -m \ pytest --color=yes \ --log-level=INFO \ @@ -407,7 +390,7 @@ functional-test-core: artifacts/osmodifier build-functional-test-cc generate-fun --build-output $(BUILD_OUTPUT) .PHONY: patch-functional-test -patch-functional-test: artifacts/osmodifier build-functional-test-cc generate-functional-test-manifest +patch-functional-test: build-functional-test-cc generate-functional-test-manifest python3 -u -m \ pytest --color=yes \ --log-level=INFO \ @@ -566,16 +549,14 @@ RUN_NETLAUNCH_TRIDENT_BIN ?= $(if $(filter yes,$(IS_UBUNTU_24_OR_NEWER)),target/ RUN_NETLAUNCH_LAUNCHER_BIN ?= $(if $(filter yes,$(IS_UBUNTU_24_OR_NEWER)),target/azl3/release/trident-acl-agent,target/release/trident-acl-agent) .PHONY: run-netlaunch run-netlaunch-stream -run-netlaunch: $(NETLAUNCH_CONFIG) $(TRIDENT_CONFIG) $(NETLAUNCH_ISO) bin/netlaunch validate artifacts/osmodifier $(RUN_NETLAUNCH_TRIDENT_BIN) $(RUN_NETLAUNCH_LAUNCHER_BIN) +run-netlaunch: $(NETLAUNCH_CONFIG) $(TRIDENT_CONFIG) $(NETLAUNCH_ISO) bin/netlaunch validate $(RUN_NETLAUNCH_TRIDENT_BIN) $(RUN_NETLAUNCH_LAUNCHER_BIN) @echo "Using trident binary: $(RUN_NETLAUNCH_TRIDENT_BIN)" @mkdir -p artifacts/test-image @cp $(RUN_NETLAUNCH_TRIDENT_BIN) artifacts/test-image/trident @cp $(RUN_NETLAUNCH_LAUNCHER_BIN) artifacts/test-image/trident-acl-agent - @cp artifacts/osmodifier artifacts/test-image/ @bin/netlaunch \ --trident-binary $(RUN_NETLAUNCH_TRIDENT_BIN) \ --launcher-binary $(RUN_NETLAUNCH_LAUNCHER_BIN) \ - --osmodifier-binary artifacts/osmodifier \ --rcp-agent-mode cli \ --iso $(NETLAUNCH_ISO) \ $(if $(NETLAUNCH_PORT),--port $(NETLAUNCH_PORT)) \ @@ -587,15 +568,13 @@ run-netlaunch: $(NETLAUNCH_CONFIG) $(TRIDENT_CONFIG) $(NETLAUNCH_ISO) bin/netlau --trace-file trident-metrics.jsonl \ $(if $(LOG_TRACE),--log-trace) -run-netlaunch-stream: $(NETLAUNCH_CONFIG) $(TRIDENT_CONFIG) $(NETLAUNCH_ISO) bin/netlaunch artifacts/osmodifier $(RUN_NETLAUNCH_TRIDENT_BIN) +run-netlaunch-stream: $(NETLAUNCH_CONFIG) $(TRIDENT_CONFIG) $(NETLAUNCH_ISO) bin/netlaunch $(RUN_NETLAUNCH_TRIDENT_BIN) @echo "Using trident binary: $(RUN_NETLAUNCH_TRIDENT_BIN)" @mkdir -p artifacts/test-image @cp $(RUN_NETLAUNCH_TRIDENT_BIN) artifacts/test-image/trident - @cp artifacts/osmodifier artifacts/test-image/ @bin/netlaunch \ --stream-image \ --trident-binary $(RUN_NETLAUNCH_TRIDENT_BIN) \ - --osmodifier-binary artifacts/osmodifier \ --rcp-agent-mode cli \ --iso $(NETLAUNCH_ISO) \ $(if $(NETLAUNCH_PORT),--port $(NETLAUNCH_PORT)) \ diff --git a/crates/docbuilder/src/host_config/storage_rules/partitions.rs b/crates/docbuilder/src/host_config/storage_rules/partitions.rs index d102c11c6..8c6cbb93e 100644 --- a/crates/docbuilder/src/host_config/storage_rules/partitions.rs +++ b/crates/docbuilder/src/host_config/storage_rules/partitions.rs @@ -5,7 +5,7 @@ use crate::markdown::table::MdTable; use super::{get_part_types, RuleDefinition}; pub(super) fn valid_mount_paths() -> RuleDefinition { - let mut table = MdTable::new(vec!["Mount Path", "Valid Mount Paths"]); + let mut table = MdTable::new(vec!["Partition Type", "Valid Mount Paths"]); for pt in get_part_types() { table.add_row(vec![ diff --git a/crates/osmodifier/Cargo.toml b/crates/osmodifier/Cargo.toml new file mode 100644 index 000000000..d71ded1cf --- /dev/null +++ b/crates/osmodifier/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "osmodifier" +version = "0.1.0" +edition = "2021" +publish = false +license = "MIT" +description = "OS modifier library - applies OS configuration changes (users, hostname, services, modules, boot config, SELinux)" + +[dependencies] +anyhow = { workspace = true } +inventory = { workspace = true } +log = { workspace = true } +nix = { workspace = true } +serde = { workspace = true } +tempfile = { workspace = true } + +pytest = { path = "../pytest" } +pytest_gen = { path = "../pytest_gen" } +trident_api = { path = "../trident_api" } +osutils = { path = "../osutils" } + +[dev-dependencies] +indoc = { workspace = true } + +[features] +functional-test = [] diff --git a/crates/osmodifier/README.md b/crates/osmodifier/README.md new file mode 100644 index 000000000..1197d63d9 --- /dev/null +++ b/crates/osmodifier/README.md @@ -0,0 +1,314 @@ +# osmodifier + +Native Rust port of the OS modifier functionality from +[azure-linux-image-tools](https://github.com/microsoft/azure-linux-image-tools). + +Trident calls osmodifier functions directly as a library crate instead of +serializing config to YAML, writing a temp file, and exec'ing the Go binary. + +## Port Origin + +The initial port was made on **2026-05-11** (commit `ba55580`) from the +azure-linux-image-tools repository. The Go code spans three packages under +`toolkit/tools/`: + +| Go package | Purpose | +|------------|---------| +| `osmodifier/` | CLI entry point | +| `osmodifierapi/` | Configuration types and validation | +| `pkg/osmodifierlib/` | Core modification logic | +| `pkg/imagecustomizerlib/` | Shared helpers (users, hostname, services, modules) | + +## File Mapping + +Each Rust source file and the Go file(s) it was ported from: + +| Rust file | Go source(s) | Go commit | Date | +|-----------|--------------|-----------|------| +| `lib.rs` | `pkg/osmodifierlib/osmodifier.go`, `pkg/osmodifierlib/modifierutils.go` | `f4de1a0` | 2026-03-17 | +| `config.rs` | `osmodifierapi/os.go`, `osmodifierapi/overlay.go`, `osmodifierapi/verity.go`, `osmodifierapi/identifiedpartition.go` | `8bd4ef3` | 2025-09-02 | +| `users.rs` | `pkg/imagecustomizerlib/customizeusers.go` | `8bd4ef3` | 2025-09-02 | +| `hostname.rs` | `pkg/imagecustomizerlib/customizehostname.go` | `8bd4ef3` | 2025-09-02 | +| `modules.rs` | `pkg/imagecustomizerlib/kernelmoduleutils.go` | `8bd4ef3` | 2025-09-02 | +| `services.rs` | `pkg/imagecustomizerlib/customizeservices.go` | `dc90945` | 2026-03-31 | +| `selinux.rs` | `pkg/osmodifierlib/modifierutils.go` (SELinux functions) | `f4de1a0` | 2026-03-17 | +| `default_grub.rs` | `pkg/osmodifierlib/modifydefaultgrub.go` | `f4de1a0` | 2026-03-17 | +| `grub_cfg.rs` | `pkg/osmodifierlib/modifydefaultgrub.go`, `pkg/osmodifierlib/modifierutils.go` | `f4de1a0` | 2026-03-17 | + +All Go paths are relative to `toolkit/tools/` in the azure-linux-image-tools +repository. The Go commit column is the latest commit touching that file at the +time of the port. + +## Key Differences from the Go Implementation + +### Library instead of binary + +The Go osmodifier is a standalone CLI binary invoked via `exec`. The Rust +version is a library crate exposing three public functions: + +```rust +osmodifier::modify_os(&ctx, &config)?; // replaces: osmodifier --config-file +osmodifier::modify_boot(&ctx, &boot_config)?; // replaces: osmodifier --config-file (boot subset) +osmodifier::update_default_grub(&ctx)?; // replaces: osmodifier --update-grub +``` + +**Reasoning:** Eliminates YAML serialization round-trips, temp file I/O, and +process spawning overhead. Errors propagate as native Rust `Result` types +instead of being parsed from stderr. + +### No chroot / safechroot + +The Go code uses `safechroot` to enter a chroot environment before making +modifications. The Rust version assumes it is already running inside the +chroot (trident manages the chroot lifecycle at a higher level). File +operations use `OsModifierContext` for path resolution; system tool +invocations (`useradd`, `usermod`, etc.) run directly against `/`. + +**Reasoning:** Trident always chroots into newroot before calling osmodifier. +Duplicating chroot enter/exit here would conflict with the outer chroot +management and add unnecessary complexity. + +### Simplified grub.cfg parsing + +The Go code uses a full grub tokenizer (`internal/grub/grubtokenizer.go`) to +parse grub.cfg, which handles quoting, escaping, variable expansion, and +semicolons as line separators. The Rust port uses simpler string-based parsing +with keyword matching (`first_word()` + `extract_quoted_title()`). + +**Reasoning:** The Rust osmodifier only parses grub.cfg files generated by +`grub2-mkconfig`, which have a predictable structure. The full tokenizer is +needed by the Go image customizer to modify grub.cfg in-place (rewriting search +commands, kernel paths, etc.), but the osmodifier only reads grub.cfg to +extract boot arguments — it never modifies grub.cfg directly. The simpler +parser handles this read-only use case without porting ~500 lines of tokenizer +code. + +**Limitation:** The parser does not track brace depth or handle nested blocks. +This matches Go's `FindNonRecoveryLinuxLine` which also does not track braces. +The parser assumes `menuentry` blocks are top-level and non-nested, which holds +for grub2-mkconfig output. + +### Inlined imagecustomizerlib logic + +The Go osmodifier delegates user, hostname, service, and module management to +`imagecustomizerlib`, a shared library also used by the image customizer tool. +The Rust port inlines this logic into dedicated modules (`users.rs`, +`hostname.rs`, `services.rs`, `modules.rs`). + +**Reasoning:** Trident only needs the osmodifier subset of imagecustomizerlib. +Porting the full shared library would pull in unnecessary dependencies. Inlining +keeps the crate self-contained and avoids coupling to Go-side refactors in the +shared library. + +### Secure password handling + +The Go code sets passwords via `useradd -p `, which exposes the password +hash in `/proc//cmdline`. The Rust version uses `chpasswd -e` with the +hash passed via stdin. + +**Reasoning:** Defense in depth. Any process on the system can read +`/proc/cmdline`, making the hash visible during user creation. Passing it via +stdin keeps the hash out of the process argument list. + +### Atomic file writes + +The Rust code uses `tempfile::NamedTempFile::persist()` for all writes to +sensitive files (`/etc/shadow`, `/etc/passwd`). The Go code writes directly. + +**Reasoning:** Atomic rename prevents partial writes from corrupting critical +auth files if the process is interrupted mid-write. + +### Startup command validation + +The Rust code validates that startup commands do not contain colons or newlines +before writing to `/etc/passwd`. The Go code does not perform this validation. + +**Reasoning:** `/etc/passwd` is colon-delimited and newline-separated. A +malicious or malformed startup command containing these characters could corrupt +the passwd file or inject additional entries. + +### Split boot configuration API + +The Go binary handles OS and boot modifications in a single `--config-file` +invocation. The Rust version splits this into `modify_os()` and `modify_boot()` +with separate config types (`OSModifierConfig` and `BootConfig`). + +**Reasoning:** OS modifications (users, hostname, services) and boot +modifications (SELinux, overlays, verity) happen at different stages of the +Trident image build pipeline. Separating them avoids passing unused +configuration and makes the call sites clearer. + +### System tool access via Dependency enum + +External tool invocations use the trident `osutils::Dependency` enum instead +of calling `std::process::Command` directly. This provides consistent binary +resolution (via `which`), structured error reporting, and a centralized +inventory of runtime dependencies. + +| Dependency variant | Used in | +|--------------------|---------| +| `Systemctl` | `services.rs` — enable/disable services | +| `Grub2Mkconfig` | `grub_cfg.rs` — regenerate GRUB config | +| `Id` | `users.rs` — check if a user exists | +| `Useradd` | `users.rs` — create new users | +| `Usermod` | `users.rs` — modify groups | +| `Chown` | `users.rs` — set file ownership | + +Two tools still use `std::process::Command` directly because the Dependency +`Command` wrapper does not yet support stdin piping: + +- **`openssl passwd`** (`hash_password`) — reads plaintext from stdin +- **`chpasswd -e`** (`set_password_via_chpasswd`) — reads `user:hash` from stdin + +## Keeping the Port in Sync + +The Rust port must track behavioral changes in the Go source. This section +provides everything needed to identify and apply upstream changes. + +### Checking for upstream changes + +From the azure-linux-image-tools repo, diff each Go source against the commit +recorded in the File Mapping table: + +```bash +# Example: check for changes to modifydefaultgrub.go since the port +cd azure-linux-image-tools +git log --oneline f4de1a0..HEAD -- toolkit/tools/pkg/osmodifierlib/modifydefaultgrub.go + +# Diff all ported files at once +git diff f4de1a0..HEAD -- \ + toolkit/tools/pkg/osmodifierlib/osmodifier.go \ + toolkit/tools/pkg/osmodifierlib/modifierutils.go \ + toolkit/tools/pkg/osmodifierlib/modifydefaultgrub.go \ + toolkit/tools/osmodifierapi/os.go \ + toolkit/tools/osmodifierapi/overlay.go \ + toolkit/tools/osmodifierapi/verity.go \ + toolkit/tools/osmodifierapi/identifiedpartition.go + +git diff 8bd4ef3..HEAD -- \ + toolkit/tools/pkg/imagecustomizerlib/customizeusers.go \ + toolkit/tools/pkg/imagecustomizerlib/customizehostname.go \ + toolkit/tools/pkg/imagecustomizerlib/kernelmoduleutils.go + +git diff dc90945..HEAD -- \ + toolkit/tools/pkg/imagecustomizerlib/customizeservices.go +``` + +After syncing, update the Go commit and date columns in the File Mapping table. + +### Function mapping + +The Rust port does not mirror Go's function names 1:1. This table maps Go +functions to their Rust equivalents: + +| Go function | Go file | Rust function | Rust file | +|-------------|---------|---------------|-----------| +| `doModifications()` | `modifierutils.go` | `modify_os()` | `lib.rs` | +| `modifyDefaultGrub()` | `modifydefaultgrub.go` | `update_default_grub()` | `lib.rs` | +| `extractValuesFromGrubConfig()` | `modifydefaultgrub.go` | `extract_boot_args_from_grub_cfg()` | `grub_cfg.rs` | +| `FindNonRecoveryLinuxLine()` | `grubcfgutils.go` | `find_non_recovery_linux_lines()` | `grub_cfg.rs` | +| `ParseCommandLineArgs()` | `grubcfgutils.go` | inline `split_whitespace` + `split_once('=')` | `grub_cfg.rs` | +| `UpdateKernelCommandLineArgs()` | `bootcustomizer.go` | `DefaultGrub::update_cmdline_args()` | `default_grub.rs` | +| `AddKernelCommandLine()` | `bootcustomizer.go` | `DefaultGrub::add_extra_cmdline()` | `default_grub.rs` | +| `SetRootDevice()` | `bootcustomizer.go` | `DefaultGrub::set_variable("GRUB_DEVICE", ...)` | `default_grub.rs` | +| `AddOrUpdateUsers()` | `customizeusers.go` | `users::add_or_update_users()` | `users.rs` | +| `UpdateHostname()` | `customizehostname.go` | `hostname::update()` | `hostname.rs` | +| `EnableOrDisableServices()` | `customizeservices.go` | `services::configure()` | `services.rs` | +| `IsServiceEnabled()` | `internal/systemd/systemd.go` | inline in `disable_service()` | `services.rs` | +| `LoadOrDisableModules()` | `kernelmoduleutils.go` | `modules::configure()` | `modules.rs` | +| `updateModulesOptions()` | `kernelmoduleutils.go` | `update_options()` | `modules.rs` | +| `UpdateSELinuxCommandLineForEMU()` | `grubcfgutils.go` | `selinux::update_grub_cmdline()` | `selinux.rs` | +| `UpdateSELinuxModeInConfigFile()` | `grubcfgutils.go` | `selinux::update_config_file()` | `selinux.rs` | +| `updateGrubConfigForOverlay()` | `modifierutils.go` | inline in `modify_boot()` | `lib.rs` | +| `updateDefaultGrubForVerity()` | `modifierutils.go` | inline in `modify_boot()` | `lib.rs` | + +### Go code NOT ported (out of scope) + +These Go functions/packages are used by osmodifier but are handled differently +in the Rust version and should NOT be ported: + +| Go code | Reason not ported | +|---------|-------------------| +| `internal/grub/grubtokenizer.go` | Full grub tokenizer — Rust uses simplified string parsing (see "Simplified grub.cfg parsing" above) | +| `internal/safechroot/` | Chroot management — trident handles chroot at a higher level | +| `osmodifier/main.go` | CLI entry point — Rust is a library, not a binary | +| `BootCustomizer` struct | Go's boot config orchestrator — Rust uses `DefaultGrub` struct directly | +| `DistroHandler` | Distro detection — trident assumes Azure Linux | +| `ReadGrub2ConfigFile()` | Distro-specific grub.cfg path lookup — Rust hardcodes the two standard paths | + +### What to watch for in upstream changes + +| Change type | Where to look | Impact | +|-------------|---------------|--------| +| New config fields | `osmodifierapi/*.go` | Add to `config.rs` structs | +| New modification steps | `modifierutils.go` `doModifications()` | Add to `lib.rs` `modify_os()` or `modify_boot()` | +| New grub sync args | `modifydefaultgrub.go` `grubArgs` | Add to `grub_cfg.rs` `SYNC_ARG_NAMES` | +| GRUB parsing changes | `grubcfgutils.go` | Update `grub_cfg.rs` — check if the change requires tokenizer features | +| New systemd/module logic | `customizeservices.go`, `kernelmoduleutils.go` | Update `services.rs` or `modules.rs` | +| SELinux changes | `modifierutils.go` (SELinux functions) | Update `selinux.rs` | +| New user fields | `customizeusers.go` | Update `users.rs` and `config.rs` `MICUser` | +| Validation changes | `osmodifierapi/*.go` `IsValid()` | Note: Rust does NOT port Go's `IsValid()` validation — trident validates at a different layer | + +### Intentional divergences (do not "fix") + +These behavioral differences from Go are deliberate and should be preserved: + +- **No chroot**: Rust assumes it runs inside chroot already +- **Secure password handling**: `chpasswd -e` via stdin (Go uses `useradd -p`) +- **Atomic file writes**: `tempfile::persist()` for `/etc/shadow`, `/etc/passwd` +- **Startup command validation**: Rejects `:` and `\n` (Go does not validate) +- **Split API**: `modify_os()` / `modify_boot()` instead of single entry point +- **No `IsValid()` validation**: Config validation happens elsewhere in trident +- **No file-based passwords or SSH key paths**: Not needed in trident's context + +## Port Fidelity Fixes (2026-05-20) + +A fidelity audit compared each Rust file against its Go source and fixed +several behavioral divergences introduced during the initial port: + +### grub_cfg.rs + +- **Removed regex dependency.** The initial port used `regex::Regex` for + menuentry and linux-line detection. Replaced with string-based keyword + matching (`first_word()` + `extract_quoted_title()`) which more closely + mirrors Go's tokenizer-based approach. +- **Case-sensitive recovery detection.** The initial port used + `.to_lowercase().contains("recovery")`. Go uses case-sensitive + `strings.Contains(line.Tokens[1].RawContent, "recovery")`. Fixed to match. +- **Recovery detection scoped to title only.** The initial fix checked the + entire line after `menuentry` for "recovery", which could false-positive on + `--class recovery-icon`. Now extracts the quoted title token specifically, + matching Go's check of `Tokens[1].RawContent`. +- **Strict single-entry enforcement.** The initial port silently picked the + first non-recovery linux line when multiple existed. Go errors if the count + is not exactly 1. Fixed to match. + +### services.rs + +- **Service existence check.** The initial port treated any non-zero exit from + `systemctl is-enabled` as "not enabled, skip disable". Go's + `systemd.IsServiceEnabled` distinguishes "disabled" (exit 1, stdout + "disabled") from "error" (exit 1, other output). Fixed to match — now errors + on non-existent services instead of silently succeeding. + +### modules.rs + +- **Inherit mode with disabled module.** The initial port silently skipped + options when a module was disabled in Inherit mode. Go errors in this case. + Fixed to match. +- **Option merge semantics.** The initial port replaced all options on update. + Go's `updateModulesOptions` preserves existing options not in the new map, + only updating/adding specified keys. Fixed to match. +- **Bare option preservation.** Options without `=` (e.g., `nomodeset`) were + silently dropped during merging. Fixed to preserve them. +- **Duplicate option line cleanup.** If multiple `options ` lines + existed for the same module, only the first was updated and the rest left + stale. Fixed to merge all matching lines into one. + +### selinux.rs + +- **Removed regex dependency.** Replaced `regex::Regex` for `SELINUX=` line + matching with simple `starts_with` check. +- **First-match-only replacement.** The initial string-based replacement + modified all `SELINUX=` lines. Go's `re.replace()` only replaces the first + match. Fixed to match. diff --git a/crates/osmodifier/src/config.rs b/crates/osmodifier/src/config.rs new file mode 100644 index 000000000..87e0f4035 --- /dev/null +++ b/crates/osmodifier/src/config.rs @@ -0,0 +1,142 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Configuration types for OS modifier operations. +//! +//! These types were originally in `osutils::osmodifier` and are the Rust +//! equivalents of the Go `osmodifierapi` types. + +use serde::{Deserialize, Serialize}; +use trident_api::config::{KernelCommandLine, Module, Selinux, Services}; + +/// OS modification configuration. +/// +/// Covers users, hostname, modules, services, kernel command line, and SELinux. +#[derive(Serialize, Deserialize, Default, Debug)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct OSModifierConfig { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub users: Vec, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hostname: Option, + + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub modules: Vec, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub services: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub kernel_command_line: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub selinux: Option, +} + +/// Password type for user configuration. +/// +/// Go has 5 variants (PlainText, Hashed, PlainTextFile, HashedFile, plus +/// locked-via-empty). This crate only needs 3 because trident passes +/// passwords via the API config, never as file paths. File-path variants +/// are not supported. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum PasswordType { + Locked, + PlainText, + Hashed, +} + +/// User password configuration. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct MICPassword { + #[serde(rename = "type")] + pub password_type: PasswordType, + pub value: String, +} + +/// User configuration in the MIC (Microsoft Image Customizer) format. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct MICUser { + pub name: String, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub uid: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub password: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub password_expires_days: Option, + + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub ssh_public_keys: Vec, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub primary_group: Option, + + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub secondary_groups: Vec, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub startup_command: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub home_directory: Option, +} + +/// Overlay filesystem configuration. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Overlay { + pub lower_dir: String, + pub upper_dir: String, + pub work_dir: String, + pub partition: IdentifiedPartition, +} + +/// A partition identified by an ID string. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct IdentifiedPartition { + pub id: String, +} + +/// dm-verity configuration. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Verity { + pub id: String, + pub name: String, + pub data_device: String, + pub hash_device: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub corruption_option: Option, +} + +/// Corruption handling behavior for dm-verity. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub enum CorruptionOption { + IoError, + Ignore, + Panic, + Restart, +} + +/// Boot-specific configuration (overlays, verity, SELinux, root device). +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct BootConfig { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub selinux: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub overlays: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub verity: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub root_device: Option, +} diff --git a/crates/osmodifier/src/default_grub.rs b/crates/osmodifier/src/default_grub.rs new file mode 100644 index 000000000..730cfecd3 --- /dev/null +++ b/crates/osmodifier/src/default_grub.rs @@ -0,0 +1,410 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! /etc/default/grub parser and writer. +//! +//! Parses the shell-variable format used by GRUB's default configuration file. +//! Supports reading, modifying, and writing back while preserving comments and +//! ordering. + +use std::{fs, path::PathBuf}; + +use anyhow::{Context, Error}; +use log::{debug, trace}; + +use crate::OsModifierContext; + +const DEFAULT_GRUB_PATH: &str = "/etc/default/grub"; + +/// Represents a parsed /etc/default/grub file. +pub struct DefaultGrub { + /// Original lines of the file, with modifications applied in-place. + lines: Vec, + /// Path to the file on disk. + path: PathBuf, +} + +impl DefaultGrub { + /// Read and parse /etc/default/grub. + pub fn read(ctx: &OsModifierContext) -> Result { + let path = ctx.path(DEFAULT_GRUB_PATH); + debug!("Reading default grub from '{}'", path.display()); + + let content = fs::read_to_string(&path) + .with_context(|| format!("Failed to read '{}'", path.display()))?; + + trace!("Default grub content:\n{content}"); + + let lines = content.lines().map(String::from).collect(); + Ok(Self { lines, path }) + } + + /// Write the (possibly modified) config back to disk. + pub fn write(&self) -> Result<(), Error> { + let mut content = self.lines.join("\n"); + content.push('\n'); + + debug!("Writing default grub to '{}'", self.path.display()); + trace!("Default grub content to write:\n{content}"); + + fs::write(&self.path, &content) + .with_context(|| format!("Failed to write '{}'", self.path.display())) + } + + /// Get the value of a variable (e.g., "GRUB_CMDLINE_LINUX"). + /// Returns the unquoted value. + pub fn get_variable(&self, name: &str) -> Option { + let prefix = format!("{name}="); + for line in &self.lines { + let trimmed = line.trim(); + if trimmed.starts_with(&prefix) { + let value = &trimmed[prefix.len()..]; + return Some(unquote(value)); + } + } + None + } + + /// Set a variable value. If the variable exists, update it in place. + /// If not, append it. + pub fn set_variable(&mut self, name: &str, value: &str) { + let prefix = format!("{name}="); + let new_line = format!("{name}=\"{value}\""); + + for line in &mut self.lines { + if line.trim().starts_with(&prefix) { + *line = new_line; + return; + } + } + + // Not found — append + self.lines.push(new_line); + } + + /// Update kernel command line args in GRUB_CMDLINE_LINUX. + /// + /// `old_keys` specifies which arg names to remove (matched by prefix + /// before `=`). `new_args` are the replacement args to insert. + /// + /// This matches the Go `UpdateKernelCommandLineArgs` behavior. + pub fn update_cmdline_args( + &mut self, + old_keys: &[&str], + new_args: &[String], + ) -> Result<(), Error> { + let current = self.get_variable("GRUB_CMDLINE_LINUX").unwrap_or_default(); + + let mut args: Vec = current + .split_whitespace() + .filter(|arg| { + let arg_name = arg.split('=').next().unwrap_or(arg); + !old_keys.contains(&arg_name) + }) + .map(String::from) + .collect(); + + args.extend(new_args.iter().cloned()); + + let new_value = args.join(" "); + self.set_variable("GRUB_CMDLINE_LINUX", &new_value); + + Ok(()) + } + + /// Add extra command line arguments to GRUB_CMDLINE_LINUX_DEFAULT. + /// + /// Appends args to the `_DEFAULT` variable (not `GRUB_CMDLINE_LINUX`) so + /// they apply only to the default boot entry, not recovery entries. + /// This matches Go's `addExtraCommandLineToDefaultGrubFile` behavior. + /// + /// Args are appended without dedup, matching Go's text-insert approach. + /// GRUB evaluates later args after earlier ones, so intentional overrides + /// (e.g., an extra `foo=new` overriding an existing `foo=old`) work. + pub fn add_extra_cmdline(&mut self, extra: &[String]) { + if extra.is_empty() { + return; + } + + let current = self + .get_variable("GRUB_CMDLINE_LINUX_DEFAULT") + .unwrap_or_default(); + + let mut args: Vec = if current.is_empty() { + Vec::new() + } else { + current.split_whitespace().map(String::from).collect() + }; + + args.extend(extra.iter().cloned()); + + let new_value = args.join(" "); + self.set_variable("GRUB_CMDLINE_LINUX_DEFAULT", &new_value); + } +} + +/// Remove surrounding quotes (single or double) from a value string. +fn unquote(s: &str) -> String { + let s = s.trim(); + if (s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')) { + s[1..s.len() - 1].to_string() + } else { + s.to_string() + } +} + +/// Add extra kernel command line args to /etc/default/grub. +pub fn add_extra_cmdline(ctx: &OsModifierContext, extra: &[String]) -> Result<(), Error> { + let mut grub = DefaultGrub::read(ctx)?; + grub.add_extra_cmdline(extra); + grub.write() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_unquote() { + assert_eq!(unquote(r#""hello world""#), "hello world"); + assert_eq!(unquote("'hello'"), "hello"); + assert_eq!(unquote("noquotes"), "noquotes"); + assert_eq!(unquote(""), ""); + } + + #[test] + fn test_get_set_variable() { + let mut grub = DefaultGrub { + lines: vec![ + "# Comment".to_string(), + r#"GRUB_CMDLINE_LINUX="selinux=1 enforcing=1""#.to_string(), + r#"GRUB_DEVICE="/dev/sda2""#.to_string(), + ], + path: PathBuf::from("/etc/default/grub"), + }; + + assert_eq!( + grub.get_variable("GRUB_CMDLINE_LINUX"), + Some("selinux=1 enforcing=1".to_string()) + ); + assert_eq!( + grub.get_variable("GRUB_DEVICE"), + Some("/dev/sda2".to_string()) + ); + assert_eq!(grub.get_variable("NONEXISTENT"), None); + + grub.set_variable("GRUB_DEVICE", "/dev/sdb1"); + assert_eq!( + grub.get_variable("GRUB_DEVICE"), + Some("/dev/sdb1".to_string()) + ); + + grub.set_variable("NEW_VAR", "new_value"); + assert_eq!(grub.get_variable("NEW_VAR"), Some("new_value".to_string())); + } + + #[test] + fn test_update_cmdline_args() { + let mut grub = DefaultGrub { + lines: vec![ + r#"GRUB_CMDLINE_LINUX="quiet selinux=1 enforcing=1 rd.overlayfs=old""#.to_string(), + ], + path: PathBuf::from("/etc/default/grub"), + }; + + grub.update_cmdline_args(&["selinux", "enforcing"], &["selinux=0".to_string()]) + .unwrap(); + + let result = grub.get_variable("GRUB_CMDLINE_LINUX").unwrap(); + assert!(result.contains("quiet")); + assert!(result.contains("rd.overlayfs=old")); + assert!(result.contains("selinux=0")); + assert!(!result.contains("enforcing=1")); + assert!(!result.contains("selinux=1")); + } + + #[test] + fn test_update_cmdline_args_empty_initial() { + let mut grub = DefaultGrub { + lines: vec![r#"GRUB_CMDLINE_LINUX="""#.to_string()], + path: PathBuf::from("/etc/default/grub"), + }; + + grub.update_cmdline_args(&[], &["selinux=1".to_string()]) + .unwrap(); + + let result = grub.get_variable("GRUB_CMDLINE_LINUX").unwrap(); + assert_eq!(result, "selinux=1"); + } + + #[test] + fn test_update_cmdline_args_missing_variable() { + // If GRUB_CMDLINE_LINUX doesn't exist, it should be created + let mut grub = DefaultGrub { + lines: vec!["GRUB_TIMEOUT=0".to_string()], + path: PathBuf::from("/etc/default/grub"), + }; + + grub.update_cmdline_args(&[], &["selinux=1".to_string()]) + .unwrap(); + + let result = grub.get_variable("GRUB_CMDLINE_LINUX").unwrap(); + assert_eq!(result, "selinux=1"); + // Original variable should be preserved + assert_eq!(grub.get_variable("GRUB_TIMEOUT"), Some("0".to_string())); + } + + #[test] + fn test_add_extra_cmdline_basic() { + let mut grub = DefaultGrub { + lines: vec![r#"GRUB_CMDLINE_LINUX="quiet""#.to_string()], + path: PathBuf::from("/etc/default/grub"), + }; + + grub.add_extra_cmdline(&["console=tty0".to_string(), "loglevel=3".to_string()]); + + // Should write to GRUB_CMDLINE_LINUX_DEFAULT, not GRUB_CMDLINE_LINUX + let result = grub.get_variable("GRUB_CMDLINE_LINUX_DEFAULT").unwrap(); + assert!(result.contains("console=tty0")); + assert!(result.contains("loglevel=3")); + // GRUB_CMDLINE_LINUX should be unchanged + assert_eq!( + grub.get_variable("GRUB_CMDLINE_LINUX"), + Some("quiet".to_string()) + ); + } + + #[test] + fn test_add_extra_cmdline_appends_to_existing_default() { + let mut grub = DefaultGrub { + lines: vec![ + r#"GRUB_CMDLINE_LINUX="quiet""#.to_string(), + r#"GRUB_CMDLINE_LINUX_DEFAULT="rd.auto=1""#.to_string(), + ], + path: PathBuf::from("/etc/default/grub"), + }; + + grub.add_extra_cmdline(&["console=tty0".to_string()]); + + let result = grub.get_variable("GRUB_CMDLINE_LINUX_DEFAULT").unwrap(); + assert!(result.contains("rd.auto=1"), "Existing args preserved"); + assert!(result.contains("console=tty0"), "New arg appended"); + } + + #[test] + fn test_add_extra_cmdline_no_dedup() { + // Go does not dedup — intentional overrides must be allowed + let mut grub = DefaultGrub { + lines: vec![r#"GRUB_CMDLINE_LINUX_DEFAULT="selinux=1""#.to_string()], + path: PathBuf::from("/etc/default/grub"), + }; + + grub.add_extra_cmdline(&["selinux=0".to_string()]); + + let result = grub.get_variable("GRUB_CMDLINE_LINUX_DEFAULT").unwrap(); + assert!( + result.contains("selinux=0"), + "Override arg should be appended" + ); + } + + #[test] + fn test_add_extra_cmdline_empty_initial() { + let mut grub = DefaultGrub { + lines: vec![], + path: PathBuf::from("/etc/default/grub"), + }; + + grub.add_extra_cmdline(&["console=tty0".to_string()]); + + let result = grub.get_variable("GRUB_CMDLINE_LINUX_DEFAULT").unwrap(); + assert_eq!(result, "console=tty0"); + } + + #[test] + fn test_comments_preserved() { + let mut grub = DefaultGrub { + lines: vec![ + "# This is a comment".to_string(), + r#"GRUB_TIMEOUT=5"#.to_string(), + "# Another comment".to_string(), + r#"GRUB_CMDLINE_LINUX="quiet""#.to_string(), + ], + path: PathBuf::from("/etc/default/grub"), + }; + + grub.set_variable("GRUB_TIMEOUT", "0"); + + assert_eq!(grub.lines[0], "# This is a comment"); + assert_eq!(grub.lines[2], "# Another comment"); + assert_eq!(grub.get_variable("GRUB_TIMEOUT"), Some("0".to_string())); + } + + #[test] + fn test_write_round_trip() { + let tmp = tempfile::tempdir().unwrap(); + let grub_path = tmp.path().join("etc/default"); + fs::create_dir_all(&grub_path).unwrap(); + fs::write( + grub_path.join("grub"), + "GRUB_TIMEOUT=5\nGRUB_CMDLINE_LINUX=\"quiet selinux=1\"\n", + ) + .unwrap(); + + let ctx = OsModifierContext { + root: tmp.path().to_path_buf(), + }; + + // Read, modify, write + let mut grub = DefaultGrub::read(&ctx).unwrap(); + grub.set_variable("GRUB_TIMEOUT", "0"); + grub.write().unwrap(); + + // Read again and verify + let grub2 = DefaultGrub::read(&ctx).unwrap(); + assert_eq!(grub2.get_variable("GRUB_TIMEOUT"), Some("0".to_string())); + assert_eq!( + grub2.get_variable("GRUB_CMDLINE_LINUX"), + Some("quiet selinux=1".to_string()) + ); + } + + #[test] + fn test_single_quoted_value() { + let grub = DefaultGrub { + lines: vec!["GRUB_DEVICE='/dev/sda1'".to_string()], + path: PathBuf::from("/etc/default/grub"), + }; + + assert_eq!( + grub.get_variable("GRUB_DEVICE"), + Some("/dev/sda1".to_string()) + ); + } + + #[test] + fn test_real_world_azl3_default_grub() { + // Modeled after the AZL 3.0 /etc/default/grub + let grub = DefaultGrub { + lines: vec![ + r#"GRUB_TIMEOUT=0"#.to_string(), + r#"GRUB_DISTRIBUTOR="AzureLinux""#.to_string(), + r#"GRUB_DISABLE_SUBMENU=y"#.to_string(), + r#"GRUB_TERMINAL_OUTPUT="console""#.to_string(), + r#"GRUB_CMDLINE_LINUX=" rd.auto=1 net.ifnames=0 lockdown=integrity ""# + .to_string(), + r#"GRUB_CMDLINE_LINUX_DEFAULT=" $kernelopts""#.to_string(), + ], + path: PathBuf::from("/etc/default/grub"), + }; + + let cmdline = grub.get_variable("GRUB_CMDLINE_LINUX").unwrap(); + assert!(cmdline.contains("rd.auto=1")); + assert!(cmdline.contains("lockdown=integrity")); + + assert_eq!( + grub.get_variable("GRUB_DISTRIBUTOR"), + Some("AzureLinux".to_string()) + ); + } +} diff --git a/crates/osmodifier/src/grub_cfg.rs b/crates/osmodifier/src/grub_cfg.rs new file mode 100644 index 000000000..c8f8ba359 --- /dev/null +++ b/crates/osmodifier/src/grub_cfg.rs @@ -0,0 +1,600 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! grub.cfg parsing and grub2-mkconfig execution. +//! +//! Used by the `update_default_grub` flow to extract boot args from the +//! generated grub.cfg and sync them back to /etc/default/grub. +//! +//! The parsing logic mirrors the Go implementation's `FindNonRecoveryLinuxLine` +//! and `ParseCommandLineArgs` from imagecustomizerlib/grubcfgutils.go. +//! The Go code uses a full grub tokenizer; this port uses simpler string-based +//! parsing that matches the behavior for grub2-mkconfig-generated output. + +use std::fs; + +use anyhow::{bail, Context, Error}; +use log::{debug, info, trace}; +use osutils::dependencies::Dependency; + +use crate::OsModifierContext; + +/// Possible grub.cfg locations, tried in order. +const GRUB_CFG_PATHS: &[&str] = &["/boot/grub2/grub.cfg", "/boot/grub/grub.cfg"]; + +/// The grub.cfg args we want to extract for syncing to /etc/default/grub. +const SYNC_ARG_NAMES: &[&str] = &["rd.overlayfs", "roothash", "root", "selinux", "enforcing"]; + +/// Extract boot arguments from the generated grub.cfg. +/// +/// Returns a tuple of (args_to_sync, optional_root_device). +/// `args_to_sync` contains entries like `["selinux=1", "rd.overlayfs=..."]`. +/// `root_device` is extracted separately because it maps to GRUB_DEVICE +/// rather than GRUB_CMDLINE_LINUX. +/// +/// Mirrors Go `extractValuesFromGrubConfig` in modifydefaultgrub.go. +pub fn extract_boot_args_from_grub_cfg( + ctx: &OsModifierContext, +) -> Result<(Vec, Option), Error> { + let grub_cfg_path = find_grub_cfg(ctx)?; + let content = fs::read_to_string(&grub_cfg_path) + .with_context(|| format!("Failed to read '{}'", grub_cfg_path.display()))?; + + trace!("grub.cfg content:\n{content}"); + + // Find the non-recovery linux command lines. + // Go expects exactly one; error otherwise. + let linux_lines = find_non_recovery_linux_lines(&content)?; + if linux_lines.len() != 1 { + bail!( + "expected 1 non-recovery linux line, found {}", + linux_lines.len() + ); + } + let linux_line = &linux_lines[0]; + debug!("Found linux line: {linux_line}"); + + // Parse args from the linux line (skip first token which is the kernel path). + let args_str = linux_line + .split_whitespace() + .skip(1) // skip kernel path (e.g., /boot/vmlinuz) + .collect::>(); + + let mut values = Vec::new(); + let mut root_device = None; + + for token in &args_str { + let (name, value) = match token.split_once('=') { + Some((n, v)) => (n, Some(v)), + None => (*token, None), + }; + + if SYNC_ARG_NAMES.contains(&name) { + if let Some(v) = value { + // Skip variable references (e.g., root=$rootdevice). Go's + // ParseCommandLineArgs detects VAR_EXPANSION tokens and clears + // the value; we match by skipping the token entirely. + if v.starts_with('$') { + trace!("Skipping variable reference: {token}"); + continue; + } + if name == "root" { + root_device = Some(v.to_string()); + } else { + values.push(format!("{name}={v}")); + } + } + } + } + + Ok((values, root_device)) +} + +/// Find the grub.cfg file on the filesystem. +fn find_grub_cfg(ctx: &OsModifierContext) -> Result { + for path in GRUB_CFG_PATHS { + let full = ctx.path(path); + if full.exists() { + return Ok(full); + } + } + bail!("Could not find grub.cfg at any of: {:?}", GRUB_CFG_PATHS) +} + +/// Return the first whitespace-delimited word from a line, or None if the +/// line is empty / whitespace-only. +fn first_word(line: &str) -> Option<&str> { + line.split_whitespace().next() +} + +/// Extract the quoted title from the text after the `menuentry` keyword. +/// Handles both single and double quotes. Returns the content between the +/// first pair of matching quotes, or None if no quoted string is found. +/// This mirrors Go's check of `line.Tokens[1].RawContent` — specifically +/// the title token, not the entire line. +fn extract_quoted_title(after_menuentry: &str) -> Option<&str> { + let s = after_menuentry.trim(); + let quote = s.chars().next()?; + if quote != '\'' && quote != '"' { + return None; + } + let inner = &s[1..]; + let end = inner.find(quote)?; + Some(&inner[..end]) +} + +/// Find the linux command lines from non-recovery menuentry blocks in grub.cfg. +/// +/// Mirrors Go `FindNonRecoveryLinuxLine` in grubcfgutils.go: +/// - Iterates tokenized lines looking for `menuentry` keyword as first token. +/// - Checks the second token (title) for "recovery" (case-sensitive, matching Go). +/// - Collects `linux` lines from non-recovery menuentries. +/// - Returns all matches; caller decides whether to require exactly one. +/// +/// **Brace tracking:** This parser does not track `{`/`}` brace depth to +/// detect menuentry block boundaries. This matches the Go implementation, +/// which also relies solely on the `menuentry` keyword to advance state. +/// In theory, a stray `linux` line after a menuentry's closing `}` would be +/// incorrectly captured. In practice this is not a concern because: +/// 1. `grub.cfg` is machine-generated by `grub2-mkconfig` — `linux` lines +/// only appear inside menuentry blocks. +/// 2. Trident never hand-edits grub.cfg; it only reads the generated output. +/// +/// Find `linux` directive lines from non-recovery menuentries in grub.cfg. +/// +/// **Known limitation (matches Go):** this parser does not track `submenu { ... }` +/// block nesting. On systems with multiple kernels, `grub2-mkconfig` produces a +/// top-level menuentry plus a `submenu 'Advanced options ...'` containing +/// additional menuentries. Both Go's `FindNonRecoveryLinuxLine` and this function +/// will find >1 linux line, causing the caller (`extract_boot_args_from_grub_cfg`) +/// to bail with "expected 1, found N". This is acceptable because AZL images +/// built by trident have exactly one kernel installed. +fn find_non_recovery_linux_lines(content: &str) -> Result, Error> { + let mut in_menuentry = false; + let mut linux_lines = Vec::new(); + + for line in content.lines() { + let keyword = match first_word(line) { + Some(w) => w, + None => continue, + }; + + if keyword == "menuentry" { + in_menuentry = true; + // Go checks: strings.Contains(line.Tokens[1].RawContent, "recovery") + // The second token is the quoted title string. Extract just the title + // to avoid false positives on class names like "--class recovery-icon". + let after_keyword = line[line.find("menuentry").unwrap() + "menuentry".len()..].trim(); + if let Some(title) = extract_quoted_title(after_keyword) { + if title.contains("recovery") { + in_menuentry = false; + } + } + } else if in_menuentry && keyword == "linux" { + // Capture everything after the "linux" keyword. + let after_linux = line[line.find("linux").unwrap() + "linux".len()..].trim(); + if !after_linux.is_empty() { + linux_lines.push(after_linux.to_string()); + } + } + } + + if linux_lines.is_empty() { + bail!("no linux line found in non-recovery menuentry"); + } + + Ok(linux_lines) +} + +/// Run grub2-mkconfig to regenerate the GRUB configuration. +pub fn run_grub_mkconfig(ctx: &OsModifierContext) -> Result<(), Error> { + let grub_cfg_path = find_grub_cfg(ctx)?; + + info!("Running grub2-mkconfig -o '{}'", grub_cfg_path.display()); + + Dependency::Grub2Mkconfig + .cmd() + .arg("-o") + .arg(&grub_cfg_path) + .run_and_check() + .context("Failed to execute grub2-mkconfig")?; + + debug!("grub2-mkconfig completed successfully"); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + // --------------------------------------------------------------- + // Helper: write a grub.cfg in a temp dir and call the public API + // --------------------------------------------------------------- + fn extract_from_grub_cfg_str(content: &str) -> Result<(Vec, Option), Error> { + let tmp = tempdir().unwrap(); + let grub_dir = tmp.path().join("boot/grub2"); + std::fs::create_dir_all(&grub_dir).unwrap(); + std::fs::write(grub_dir.join("grub.cfg"), content).unwrap(); + let ctx = OsModifierContext { + root: tmp.path().to_path_buf(), + }; + extract_boot_args_from_grub_cfg(&ctx) + } + + // ======================= find_non_recovery_linux_lines ======================= + + #[test] + fn test_basic_non_recovery_with_recovery_entry() { + let grub_cfg = indoc::indoc! {r#" + set timeout=5 + menuentry 'Azure Linux' --class azurelinux { + linux /boot/vmlinuz root=/dev/sda2 selinux=1 enforcing=1 rd.overlayfs=/a,/b,/c,/dev/sda3 + initrd /boot/initrd.img + } + menuentry 'Azure Linux (recovery)' --class azurelinux { + linux /boot/vmlinuz root=/dev/sda2 single + initrd /boot/initrd.img + } + "#}; + + let lines = find_non_recovery_linux_lines(grub_cfg).unwrap(); + assert_eq!(lines.len(), 1); + let result = &lines[0]; + assert!(result.contains("root=/dev/sda2")); + assert!(result.contains("selinux=1")); + assert!(result.contains("rd.overlayfs=")); + assert!(!result.contains("single")); + } + + #[test] + fn test_single_non_recovery_entry() { + let grub_cfg = indoc::indoc! {r#" + menuentry 'Linux' { + linux /boot/vmlinuz root=/dev/sda1 + } + "#}; + + let lines = find_non_recovery_linux_lines(grub_cfg).unwrap(); + assert_eq!(lines.len(), 1); + assert!(lines[0].contains("root=/dev/sda1")); + } + + #[test] + fn test_no_linux_line_errors() { + let grub_cfg = "set timeout=5\n"; + assert!(find_non_recovery_linux_lines(grub_cfg).is_err()); + } + + #[test] + fn test_only_recovery_entries_errors() { + let grub_cfg = indoc::indoc! {r#" + menuentry 'Linux (recovery)' { + linux /boot/vmlinuz root=/dev/sda1 single + } + "#}; + assert!(find_non_recovery_linux_lines(grub_cfg).is_err()); + } + + #[test] + fn test_recovery_detection_is_case_sensitive() { + // Go uses case-sensitive "recovery" check. + // "Recovery" (capital R) does NOT contain lowercase "recovery". + let grub_cfg = indoc::indoc! {r#" + menuentry 'Linux Recovery Mode' { + linux /boot/vmlinuz root=/dev/sda1 single + } + menuentry 'Linux' { + linux /boot/vmlinuz root=/dev/sda2 + } + "#}; + + let lines = find_non_recovery_linux_lines(grub_cfg).unwrap(); + assert_eq!( + lines.len(), + 2, + "uppercase 'Recovery' should not be filtered" + ); + } + + #[test] + fn test_multiple_non_recovery_entries() { + let grub_cfg = indoc::indoc! {r#" + menuentry 'Linux A' { + linux /boot/vmlinuz root=/dev/sda1 + } + menuentry 'Linux B' { + linux /boot/vmlinuz root=/dev/sda2 + } + "#}; + + let lines = find_non_recovery_linux_lines(grub_cfg).unwrap(); + assert_eq!(lines.len(), 2); + } + + #[test] + fn test_linux_line_captures_full_args() { + let grub_cfg = indoc::indoc! {r#" + menuentry 'Linux' { + linux /boot/vmlinuz root=/dev/sda2 selinux=1 + } + "#}; + + let lines = find_non_recovery_linux_lines(grub_cfg).unwrap(); + assert!(lines[0].starts_with("/boot/vmlinuz")); + assert!(lines[0].contains("selinux=1")); + } + + #[test] + fn test_tab_indented_grub_cfg() { + // Real grub.cfg uses tabs, not spaces + let grub_cfg = "menuentry 'Linux' {\n\tlinux /boot/vmlinuz root=/dev/sda2 selinux=1\n\tinitrd /boot/initrd.img\n}\n"; + + let lines = find_non_recovery_linux_lines(grub_cfg).unwrap(); + assert_eq!(lines.len(), 1); + assert!(lines[0].contains("root=/dev/sda2")); + } + + #[test] + fn test_double_quoted_menuentry_title() { + let grub_cfg = indoc::indoc! {r#" + menuentry "Azure Linux" { + linux /boot/vmlinuz root=/dev/sda1 + } + menuentry "Azure Linux (recovery)" { + linux /boot/vmlinuz root=/dev/sda1 single + } + "#}; + + let lines = find_non_recovery_linux_lines(grub_cfg).unwrap(); + assert_eq!(lines.len(), 1); + assert!(!lines[0].contains("single")); + } + + #[test] + fn test_recovery_in_class_not_in_title() { + // "recovery" in --class flag should NOT filter the entry. + // Go checks only the title token (Tokens[1].RawContent). + let grub_cfg = indoc::indoc! {r#" + menuentry 'Azure Linux' --class recovery-icon { + linux /boot/vmlinuz root=/dev/sda1 + } + "#}; + + let lines = find_non_recovery_linux_lines(grub_cfg).unwrap(); + assert_eq!( + lines.len(), + 1, + "recovery in class name should not filter the entry" + ); + } + + #[test] + fn test_real_world_azl2_grub_cfg() { + // Modeled after the AZL 2.0 grub.cfg which uses $variables + let grub_cfg = indoc::indoc! {r#" + set timeout=0 + set bootprefix=/boot + search -n -u 33beac00-b378-4b0c-b0cb-d5dcebf2cf57 -s + + load_env -f $bootprefix/mariner.cfg + + set rootdevice=PARTUUID=c17c558b-068b-459c-92cb-f218d14b44a1 + + menuentry "CBL-Mariner" { + linux $bootprefix/$mariner_linux rd.auto=1 root=$rootdevice $mariner_cmdline lockdown=integrity selinux=0 $systemd_cmdline $kernelopts + if [ -f $bootprefix/$mariner_initrd ]; then + initrd $bootprefix/$mariner_initrd + fi + } + "#}; + + let lines = find_non_recovery_linux_lines(grub_cfg).unwrap(); + assert_eq!(lines.len(), 1); + // The linux line should capture the full args including $variables + assert!(lines[0].contains("selinux=0")); + assert!(lines[0].contains("root=$rootdevice")); + } + + #[test] + fn test_extract_skips_variable_references() { + // root=$rootdevice should NOT produce a GRUB_DEVICE write + let grub_cfg = indoc::indoc! {r#" + menuentry "CBL-Mariner" { + linux /boot/vmlinuz root=$rootdevice selinux=0 + } + "#}; + + let (args, root_device) = extract_from_grub_cfg_str(grub_cfg).unwrap(); + assert_eq!( + root_device, None, + "Variable reference root=$rootdevice should be skipped" + ); + assert!( + args.contains(&"selinux=0".to_string()), + "Non-variable args should still be captured" + ); + } + + #[test] + fn test_menuentry_without_linux_line() { + // A menuentry that has no linux command — should not contribute a result + let grub_cfg = indoc::indoc! {r#" + menuentry 'Empty Entry' { + set gfxpayload=keep + } + menuentry 'Real Entry' { + linux /boot/vmlinuz root=/dev/sda1 + } + "#}; + + let lines = find_non_recovery_linux_lines(grub_cfg).unwrap(); + assert_eq!(lines.len(), 1); + assert!(lines[0].contains("root=/dev/sda1")); + } + + #[test] + fn test_linux_outside_menuentry_ignored() { + // A "linux" keyword outside any menuentry should be ignored + let grub_cfg = indoc::indoc! {r#" + linux /boot/stray-vmlinuz root=/dev/stray + menuentry 'Linux' { + linux /boot/vmlinuz root=/dev/sda1 + } + "#}; + + let lines = find_non_recovery_linux_lines(grub_cfg).unwrap(); + // The stray linux line outside menuentry should be captured because + // our parser doesn't track brace depth — it just looks for the + // menuentry keyword to set state. The stray line comes before any + // menuentry, so in_menuentry is false. Only the one inside should match. + assert_eq!(lines.len(), 1); + assert!(lines[0].contains("root=/dev/sda1")); + } + + // ======================= extract_boot_args_from_grub_cfg ======================= + + #[test] + fn test_extract_args_basic() { + let grub_cfg = indoc::indoc! {r#" + menuentry 'Azure Linux' { + linux /boot/vmlinuz root=/dev/sda2 selinux=1 enforcing=1 rd.overlayfs=/a,/b,/c,/dev/sda3 + } + "#}; + + let (args, root_device) = extract_from_grub_cfg_str(grub_cfg).unwrap(); + + assert_eq!(root_device, Some("/dev/sda2".to_string())); + assert!(args.contains(&"selinux=1".to_string())); + assert!(args.contains(&"enforcing=1".to_string())); + assert!(args.iter().any(|a| a.starts_with("rd.overlayfs="))); + // root should NOT be in args (it goes to root_device) + assert!(!args.iter().any(|a| a.starts_with("root="))); + } + + #[test] + fn test_extract_args_no_root() { + let grub_cfg = indoc::indoc! {r#" + menuentry 'Linux' { + linux /boot/vmlinuz selinux=1 + } + "#}; + + let (args, root_device) = extract_from_grub_cfg_str(grub_cfg).unwrap(); + + assert_eq!(root_device, None); + assert!(args.contains(&"selinux=1".to_string())); + } + + #[test] + fn test_extract_args_ignores_unknown_args() { + let grub_cfg = indoc::indoc! {r#" + menuentry 'Linux' { + linux /boot/vmlinuz quiet root=/dev/sda1 loglevel=3 selinux=1 splash + } + "#}; + + let (args, root_device) = extract_from_grub_cfg_str(grub_cfg).unwrap(); + + assert_eq!(root_device, Some("/dev/sda1".to_string())); + assert_eq!(args, vec!["selinux=1"]); + // quiet, loglevel, splash should NOT appear + } + + #[test] + fn test_extract_errors_on_multiple_non_recovery_entries() { + // Go expects exactly 1 non-recovery linux line + let grub_cfg = indoc::indoc! {r#" + menuentry 'Linux A' { + linux /boot/vmlinuz root=/dev/sda1 + } + menuentry 'Linux B' { + linux /boot/vmlinuz root=/dev/sda2 + } + "#}; + + let result = extract_from_grub_cfg_str(grub_cfg); + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!( + err_msg.contains("expected 1"), + "Error should mention expecting 1 line, got: {err_msg}" + ); + } + + #[test] + fn test_extract_args_with_roothash() { + let grub_cfg = indoc::indoc! {r#" + menuentry 'Linux' { + linux /boot/vmlinuz root=/dev/mapper/root roothash=abc123 selinux=1 enforcing=1 + } + "#}; + + let (args, root_device) = extract_from_grub_cfg_str(grub_cfg).unwrap(); + + assert_eq!(root_device, Some("/dev/mapper/root".to_string())); + assert!(args.contains(&"roothash=abc123".to_string())); + assert!(args.contains(&"selinux=1".to_string())); + assert!(args.contains(&"enforcing=1".to_string())); + } + + #[test] + fn test_extract_args_empty_result() { + // No sync-worthy args + let grub_cfg = indoc::indoc! {r#" + menuentry 'Linux' { + linux /boot/vmlinuz quiet loglevel=3 + } + "#}; + + let (args, root_device) = extract_from_grub_cfg_str(grub_cfg).unwrap(); + assert!(args.is_empty()); + assert_eq!(root_device, None); + } + + #[test] + fn test_extract_missing_grub_cfg_errors() { + let tmp = tempdir().unwrap(); + let ctx = OsModifierContext { + root: tmp.path().to_path_buf(), + }; + assert!(extract_boot_args_from_grub_cfg(&ctx).is_err()); + } + + #[test] + fn test_extract_finds_grub2_path() { + let tmp = tempdir().unwrap(); + let grub_dir = tmp.path().join("boot/grub2"); + std::fs::create_dir_all(&grub_dir).unwrap(); + std::fs::write( + grub_dir.join("grub.cfg"), + "menuentry 'L' {\n\tlinux /vmlinuz root=/dev/sda1\n}\n", + ) + .unwrap(); + let ctx = OsModifierContext { + root: tmp.path().to_path_buf(), + }; + + let (_, root) = extract_boot_args_from_grub_cfg(&ctx).unwrap(); + assert_eq!(root, Some("/dev/sda1".to_string())); + } + + #[test] + fn test_extract_finds_grub_fallback_path() { + let tmp = tempdir().unwrap(); + // Only /boot/grub/ (not grub2/) + let grub_dir = tmp.path().join("boot/grub"); + std::fs::create_dir_all(&grub_dir).unwrap(); + std::fs::write( + grub_dir.join("grub.cfg"), + "menuentry 'L' {\n\tlinux /vmlinuz root=/dev/sdb1\n}\n", + ) + .unwrap(); + let ctx = OsModifierContext { + root: tmp.path().to_path_buf(), + }; + + let (_, root) = extract_boot_args_from_grub_cfg(&ctx).unwrap(); + assert_eq!(root, Some("/dev/sdb1".to_string())); + } +} diff --git a/crates/osmodifier/src/hostname.rs b/crates/osmodifier/src/hostname.rs new file mode 100644 index 000000000..4a1d3b2d2 --- /dev/null +++ b/crates/osmodifier/src/hostname.rs @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Hostname management — writes /etc/hostname. + +use std::fs; + +use anyhow::{Context, Error}; +use log::debug; + +use crate::OsModifierContext; + +const HOSTNAME_PATH: &str = "/etc/hostname"; + +/// Write the hostname to /etc/hostname. +pub fn update(ctx: &OsModifierContext, hostname: &str) -> Result<(), Error> { + let path = ctx.path(HOSTNAME_PATH); + debug!("Writing hostname '{}' to '{}'", hostname, path.display()); + fs::write(&path, hostname) + .with_context(|| format!("Failed to write hostname to '{}'", path.display())) +} + +#[cfg_attr(not(test), allow(unused_imports, dead_code))] +mod functional_test { + use super::*; + use tempfile::tempdir; + + use pytest_gen::functional_test; + + use crate::OsModifierContext; + + #[functional_test(feature = "core")] + fn test_update_hostname() { + let tmp = tempdir().unwrap(); + fs::create_dir_all(tmp.path().join("etc")).unwrap(); + let ctx = OsModifierContext { + root: tmp.path().to_path_buf(), + }; + + update(&ctx, "my-test-host").unwrap(); + + let content = fs::read_to_string(tmp.path().join("etc/hostname")).unwrap(); + assert_eq!(content.trim(), "my-test-host"); + } + + #[functional_test(feature = "core")] + fn test_update_hostname_overwrites() { + let tmp = tempdir().unwrap(); + fs::create_dir_all(tmp.path().join("etc")).unwrap(); + let ctx = OsModifierContext { + root: tmp.path().to_path_buf(), + }; + + update(&ctx, "first-host").unwrap(); + update(&ctx, "second-host").unwrap(); + + let content = fs::read_to_string(tmp.path().join("etc/hostname")).unwrap(); + assert_eq!(content.trim(), "second-host"); + } +} diff --git a/crates/osmodifier/src/lib.rs b/crates/osmodifier/src/lib.rs new file mode 100644 index 000000000..f32cd1022 --- /dev/null +++ b/crates/osmodifier/src/lib.rs @@ -0,0 +1,409 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! OS modifier library — applies OS configuration changes to the filesystem. +//! +//! This crate replaces the external Go `osmodifier` binary with native Rust +//! implementations. All operations target paths under a configurable root +//! directory (defaulting to `/`). + +pub mod config; +mod default_grub; +mod grub_cfg; +mod hostname; +mod modules; +mod selinux; +mod services; +mod users; + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Error}; +use log::{debug, info}; + +pub use config::*; + +/// Execution context for OS modifier operations. +/// +/// All filesystem paths are resolved relative to `root`. In production, +/// `root` is always `/` — both the chroot'd boot path and the MOS +/// configuration path use `OsModifierContext::default()`. The non-`/` +/// option exists for unit tests that operate on a temp directory. +pub struct OsModifierContext { + /// Root directory for all filesystem operations. + pub root: PathBuf, +} + +impl Default for OsModifierContext { + fn default() -> Self { + Self { + root: PathBuf::from("/"), + } + } +} + +impl OsModifierContext { + /// Resolve a path relative to the context root. + pub fn path(&self, p: impl AsRef) -> PathBuf { + if self.root == Path::new("/") { + p.as_ref().to_path_buf() + } else { + let p = p.as_ref(); + let stripped = p.strip_prefix("/").unwrap_or(p); + self.root.join(stripped) + } + } +} + +/// Apply OS modifications: users, hostname, services, modules, kernel command +/// line, and SELinux. +/// +/// This replaces the Go `osmodifier --config-file` codepath for +/// [`OSModifierConfig`]. +/// +/// **Caller invariant:** `modify_os` writes to `/etc/default/grub` and runs +/// `grub2-mkconfig` when `extra_command_line` is present. On UKI systems this +/// is a no-op but wasteful. Callers must gate this function behind a +/// bootloader-type check (trident's boot subsystem does this — see +/// `boot/mod.rs` which returns early for UKI before calling `modify_boot`). +pub fn modify_os(ctx: &OsModifierContext, config: &OSModifierConfig) -> Result<(), Error> { + debug!("Applying OS modifications"); + + if !config.users.is_empty() { + info!("Configuring users"); + users::add_or_update_users(ctx, &config.users).context("Failed to configure users")?; + } + + if let Some(ref name) = config.hostname { + if !name.is_empty() { + info!("Setting hostname to '{name}'"); + hostname::update(ctx, name).context("Failed to update hostname")?; + } + } + + if let Some(ref services) = config.services { + if !services.enable.is_empty() || !services.disable.is_empty() { + info!("Configuring services"); + services::configure(ctx, services).context("Failed to configure services")?; + } + } + + if !config.modules.is_empty() { + info!("Configuring kernel modules"); + modules::configure(ctx, &config.modules).context("Failed to configure kernel modules")?; + } + + // For UKI images, SELinux mode is set via the config file directly (not + // via kernel cmdline). The osconfig subsystem handles this case by + // including selinux in the OSModifierConfig. The UKI vs GRUB dispatch + // is implicit via the caller — see the caller invariant on this function. + // If UKI-awareness needs to become explicit inside this crate, consider + // a `BootTarget` enum (precedent: `osutils/src/bootloaders.rs`). + if let Some(ref selinux_cfg) = config.selinux { + if let Some(ref mode) = selinux_cfg.mode { + info!("Updating SELinux config file to mode '{mode:?}'"); + selinux::update_config_file(ctx, mode) + .context("Failed to update SELinux config file")?; + } + } + + // Extra kernel command line args are appended to /etc/default/grub and + // grub.cfg is regenerated. Note: modify_boot() also writes to + // /etc/default/grub for boot-specific config (overlays, verity, etc.). + if let Some(ref kcl) = config.kernel_command_line { + if !kcl.extra_command_line.is_empty() { + info!("Adding extra kernel command line arguments"); + default_grub::add_extra_cmdline(ctx, &kcl.extra_command_line) + .context("Failed to add extra kernel command line")?; + grub_cfg::run_grub_mkconfig(ctx).context("Failed to regenerate GRUB config")?; + } + } + + Ok(()) +} + +/// Sync current grub.cfg values back to /etc/default/grub and regenerate. +/// +/// This replaces the Go `osmodifier --update-grub` codepath: +/// 1. Reads the generated grub.cfg +/// 2. Extracts overlayfs, verity, root, selinux, enforcing args +/// 3. Stamps those values into /etc/default/grub +/// 4. Runs grub2-mkconfig to regenerate +pub fn update_default_grub(ctx: &OsModifierContext) -> Result<(), Error> { + info!("Syncing grub.cfg values to /etc/default/grub"); + + let (args, root_device) = grub_cfg::extract_boot_args_from_grub_cfg(ctx) + .context("Failed to extract boot args from grub.cfg")?; + + let mut default_grub = default_grub::DefaultGrub::read(ctx)?; + + default_grub.update_cmdline_args( + &["rd.overlayfs", "roothash", "root", "selinux", "enforcing"], + &args, + )?; + + if let Some(root) = root_device { + default_grub.set_variable("GRUB_DEVICE", &root); + } + + default_grub.write()?; + + grub_cfg::run_grub_mkconfig(ctx) + .context("Failed to regenerate GRUB config after updating defaults")?; + + info!("Successfully updated default grub"); + Ok(()) +} + +/// Apply boot-specific modifications: SELinux, overlays, verity, root device. +/// +/// This replaces the Go `osmodifier --config-file` codepath for +/// [`BootConfig`]. Updates /etc/default/grub and regenerates via +/// grub2-mkconfig. +pub fn modify_boot(ctx: &OsModifierContext, config: &BootConfig) -> Result<(), Error> { + info!("Applying boot configuration modifications"); + + let mut default_grub = default_grub::DefaultGrub::read(ctx)?; + let mut changed = false; + + if let Some(ref selinux_cfg) = config.selinux { + if let Some(ref mode) = selinux_cfg.mode { + debug!("Updating SELinux in boot config"); + selinux::update_grub_cmdline(ctx, &mut default_grub, mode)?; + selinux::update_config_file(ctx, mode) + .context("Failed to update SELinux config file")?; + changed = true; + } + } + + if !config.overlays.is_empty() { + debug!("Updating overlays in boot config"); + let mut overlay_configs = Vec::new(); + for overlay in &config.overlays { + overlay_configs.push(format!( + "{},{},{},{}", + overlay.lower_dir, overlay.upper_dir, overlay.work_dir, overlay.partition.id, + )); + } + let concatenated = overlay_configs.join(" "); + default_grub + .update_cmdline_args(&["rd.overlayfs"], &[format!("rd.overlayfs={concatenated}")])?; + changed = true; + } + + if let Some(ref verity) = config.verity { + debug!("Updating verity in boot config"); + let corruption_opt = verity + .corruption_option + .as_ref() + .map(format_corruption_option) + .unwrap_or_default(); + + let new_args = vec![ + "rd.systemd.verity=1".to_string(), + format!("systemd.verity_root_data={}", verity.data_device), + format!("systemd.verity_root_hash={}", verity.hash_device), + format!("systemd.verity_root_options={corruption_opt}"), + ]; + default_grub.update_cmdline_args( + &[ + "rd.systemd.verity", + "systemd.verity_root_data", + "systemd.verity_root_hash", + "systemd.verity_root_options", + ], + &new_args, + )?; + changed = true; + } + + if let Some(ref root_device) = config.root_device { + debug!("Setting root device to '{root_device}'"); + default_grub.set_variable("GRUB_DEVICE", root_device); + changed = true; + } + + if changed { + default_grub.write()?; + grub_cfg::run_grub_mkconfig(ctx) + .context("Failed to regenerate GRUB config after boot modifications")?; + } + + Ok(()) +} + +fn format_corruption_option(opt: &CorruptionOption) -> String { + match opt { + CorruptionOption::IoError => String::new(), + CorruptionOption::Ignore => "ignore-corruption".to_string(), + CorruptionOption::Panic => "panic-on-corruption".to_string(), + CorruptionOption::Restart => "restart-on-corruption".to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn path_default_root_returns_absolute_path_unchanged() { + let ctx = OsModifierContext::default(); + assert_eq!(ctx.path("/etc/hostname"), PathBuf::from("/etc/hostname")); + } + + #[test] + fn path_default_root_returns_relative_path_unchanged() { + let ctx = OsModifierContext::default(); + assert_eq!(ctx.path("relative/file"), PathBuf::from("relative/file")); + } + + #[test] + fn path_custom_root_strips_leading_slash_and_joins() { + let ctx = OsModifierContext { + root: PathBuf::from("/tmp/testroot"), + }; + assert_eq!( + ctx.path("/etc/hostname"), + PathBuf::from("/tmp/testroot/etc/hostname") + ); + } + + #[test] + fn path_custom_root_joins_relative_path_directly() { + let ctx = OsModifierContext { + root: PathBuf::from("/tmp/testroot"), + }; + assert_eq!( + ctx.path("relative/file"), + PathBuf::from("/tmp/testroot/relative/file") + ); + } + + #[test] + fn path_custom_root_nested_absolute_path() { + let ctx = OsModifierContext { + root: PathBuf::from("/tmp/testroot"), + }; + assert_eq!( + ctx.path("/usr/lib/systemd/system"), + PathBuf::from("/tmp/testroot/usr/lib/systemd/system") + ); + } + + #[test] + fn path_custom_root_single_slash() { + let ctx = OsModifierContext { + root: PathBuf::from("/tmp/testroot"), + }; + // A bare "/" should resolve to the root itself + assert_eq!(ctx.path("/"), PathBuf::from("/tmp/testroot")); + } +} + +#[cfg_attr(not(test), allow(unused_imports, dead_code))] +mod functional_test { + use super::*; + use std::collections::HashMap; + use std::fs; + use tempfile::tempdir; + + use pytest_gen::functional_test; + use trident_api::config::{LoadMode, Module, Services}; + + #[functional_test(feature = "core")] + fn test_modify_os_hostname_and_modules() { + let tmp = tempdir().unwrap(); + fs::create_dir_all(tmp.path().join("etc")).unwrap(); + + let ctx = OsModifierContext { + root: tmp.path().to_path_buf(), + }; + + let config = OSModifierConfig { + hostname: Some("integration-test-host".to_string()), + modules: vec![Module { + name: "vfio_pci".to_string(), + load_mode: LoadMode::Always, + options: HashMap::new(), + }], + ..Default::default() + }; + + modify_os(&ctx, &config).unwrap(); + + // Verify hostname + let hostname = fs::read_to_string(tmp.path().join("etc/hostname")).unwrap(); + assert_eq!(hostname.trim(), "integration-test-host"); + + // Verify module loaded + let load_conf = + fs::read_to_string(tmp.path().join("etc/modules-load.d/modules-load.conf")).unwrap(); + assert!( + load_conf.contains("vfio_pci"), + "Expected vfio_pci in modules-load.conf" + ); + } + + #[functional_test(feature = "core")] + fn test_modify_os_empty_config() { + let tmp = tempdir().unwrap(); + let ctx = OsModifierContext { + root: tmp.path().to_path_buf(), + }; + + let config = OSModifierConfig::default(); + + // Empty config should be a no-op + modify_os(&ctx, &config).unwrap(); + } + + #[functional_test(feature = "core")] + fn test_modify_os_with_services() { + let tmp = tempdir().unwrap(); + fs::create_dir_all(tmp.path().join("etc")).unwrap(); + + // Create a minimal systemd tree with a synthetic service + let unit_dir = tmp.path().join("usr/lib/systemd/system"); + fs::create_dir_all(&unit_dir).unwrap(); + fs::create_dir_all( + tmp.path() + .join("etc/systemd/system/multi-user.target.wants"), + ) + .unwrap(); + fs::write( + unit_dir.join("test-integration.service"), + "[Unit]\nDescription=Test\n\n[Service]\nType=oneshot\nExecStart=/bin/true\n\n[Install]\nWantedBy=multi-user.target\n", + ) + .unwrap(); + + let ctx = OsModifierContext { + root: tmp.path().to_path_buf(), + }; + + let config = OSModifierConfig { + hostname: Some("svc-test-host".to_string()), + services: Some(Services { + enable: vec!["test-integration.service".to_string()], + disable: vec![], + }), + ..Default::default() + }; + + modify_os(&ctx, &config).unwrap(); + + // Verify hostname + let hostname = fs::read_to_string(tmp.path().join("etc/hostname")).unwrap(); + assert_eq!(hostname.trim(), "svc-test-host"); + + // Verify service enabled — symlink may be dangling (target is absolute /usr/... + // but only exists under the temp root), so check is_symlink() not exists() + let symlink = tmp + .path() + .join("etc/systemd/system/multi-user.target.wants/test-integration.service"); + assert!( + symlink.is_symlink(), + "Service should be enabled (symlink at {})", + symlink.display() + ); + } +} diff --git a/crates/osmodifier/src/modules.rs b/crates/osmodifier/src/modules.rs new file mode 100644 index 000000000..ce46c95fe --- /dev/null +++ b/crates/osmodifier/src/modules.rs @@ -0,0 +1,428 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Kernel module configuration — write modules-load.d and modprobe.d configs. + +use std::fs; + +use anyhow::{bail, Context, Error}; +use log::debug; + +use trident_api::config::{LoadMode, Module}; + +use crate::OsModifierContext; + +const MODULES_LOAD_DIR: &str = "/etc/modules-load.d"; +const MODULES_LOAD_CONF: &str = "/etc/modules-load.d/modules-load.conf"; +const MODPROBE_DIR: &str = "/etc/modprobe.d"; +const MODPROBE_DISABLED_CONF: &str = "/etc/modprobe.d/modules-disabled.conf"; +const MODPROBE_OPTIONS_CONF: &str = "/etc/modprobe.d/module-options.conf"; + +/// Configure kernel modules by writing modules-load.d and modprobe.d files. +pub fn configure(ctx: &OsModifierContext, modules: &[Module]) -> Result<(), Error> { + // Read existing configs (or start fresh) + let load_path = ctx.path(MODULES_LOAD_CONF); + let disabled_path = ctx.path(MODPROBE_DISABLED_CONF); + let options_path = ctx.path(MODPROBE_OPTIONS_CONF); + + let mut load_lines = read_config_lines(ctx, MODULES_LOAD_CONF)?; + let mut disabled_lines = read_config_lines(ctx, MODPROBE_DISABLED_CONF)?; + let mut options_lines = read_config_lines(ctx, MODPROBE_OPTIONS_CONF)?; + + for module in modules { + match module.load_mode { + LoadMode::Always => { + debug!("Module '{}': set to always load", module.name); + // Remove from blacklist if present + remove_blacklist(&mut disabled_lines, &module.name); + // Add to auto-load list if not present + if !load_lines.iter().any(|l| l.trim() == module.name) { + load_lines.push(module.name.clone()); + } + // Set options if provided + if !module.options.is_empty() { + update_options(&mut options_lines, &module.name, &module.options); + } + } + LoadMode::Auto => { + debug!("Module '{}': set to auto", module.name); + // Remove from blacklist if present + remove_blacklist(&mut disabled_lines, &module.name); + // Set options if provided + if !module.options.is_empty() { + update_options(&mut options_lines, &module.name, &module.options); + } + } + LoadMode::Disable => { + debug!("Module '{}': set to disabled", module.name); + if !module.options.is_empty() { + bail!( + "Module '{}' is disabled but has options set — this is not allowed", + module.name + ); + } + // Remove from auto-load list. This is an intentional fidelity + // fix vs Go — Go only adds the blacklist entry and leaves any + // existing load entry intact, producing contradictory state + // when transitioning Always→Disable. + load_lines.retain(|l| l.trim() != module.name); + // Add to blacklist if not present + let blacklist_entry = format!("blacklist {}", module.name); + if !disabled_lines.iter().any(|l| l.trim() == blacklist_entry) { + disabled_lines.push(blacklist_entry); + } + } + LoadMode::Inherit => { + debug!("Module '{}': inherit (update options only)", module.name); + // Go errors if a disabled module has options in Inherit/Default mode. + if !module.options.is_empty() { + let is_disabled = disabled_lines + .iter() + .any(|l| l.trim() == format!("blacklist {}", module.name)); + if is_disabled { + bail!( + "Module '{}' is disabled but has options set — \ + specify auto or always as loadMode to override setting in base image", + module.name + ); + } + update_options(&mut options_lines, &module.name, &module.options); + } + } + } + } + + // Write out the config files + ensure_dir(ctx, MODULES_LOAD_DIR)?; + ensure_dir(ctx, MODPROBE_DIR)?; + + write_config(&load_path, &load_lines)?; + write_config(&disabled_path, &disabled_lines)?; + write_config(&options_path, &options_lines)?; + + Ok(()) +} + +fn read_config_lines(ctx: &OsModifierContext, path: &str) -> Result, Error> { + let full = ctx.path(path); + match fs::read_to_string(&full) { + Ok(s) => Ok(s.lines().map(String::from).collect()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()), + Err(e) => Err(e).with_context(|| format!("Failed to read '{}'", full.display())), + } +} + +fn remove_blacklist(lines: &mut Vec, module_name: &str) { + let entry = format!("blacklist {module_name}"); + lines.retain(|l| l.trim() != entry); +} + +fn update_options( + lines: &mut Vec, + module_name: &str, + options: &std::collections::HashMap, +) { + let prefix = format!("options {module_name} "); + let bare = format!("options {module_name}"); + + // Collect all existing options from ALL matching lines for this module, + // then remove those lines. This avoids leaving stale duplicate lines. + let mut existing_opts: Vec = Vec::new(); + lines.retain(|line| { + if line.starts_with(&prefix) || line.trim() == bare { + // Collect existing option fields from this line + let fields: Vec<&str> = line.split_whitespace().collect(); + for field in fields.iter().skip(2) { + existing_opts.push(field.to_string()); + } + false // remove this line + } else { + true // keep + } + }); + + // Build the merged options line. + let mut seen = std::collections::HashSet::new(); + let mut new_line = format!("options {module_name}"); + + // Preserve existing options, updating values from the new map. + for field in &existing_opts { + if let Some((key, _)) = field.split_once('=') { + if let Some(new_val) = options.get(key) { + new_line.push_str(&format!(" {key}={new_val}")); + seen.insert(key.to_string()); + } else { + new_line.push_str(&format!(" {field}")); + } + } else { + // Preserve bare options (no '='), e.g. "nomodeset" + new_line.push_str(&format!(" {field}")); + } + } + + // Append new options not already in the line. + for (key, val) in options { + if !seen.contains(key.as_str()) { + new_line.push_str(&format!(" {key}={val}")); + } + } + + lines.push(new_line); +} + +fn ensure_dir(ctx: &OsModifierContext, path: &str) -> Result<(), Error> { + let full = ctx.path(path); + fs::create_dir_all(&full) + .with_context(|| format!("Failed to create directory '{}'", full.display())) +} + +fn write_config(path: &std::path::Path, lines: &[String]) -> Result<(), Error> { + let content = if lines.is_empty() { + String::new() + } else { + let mut s = lines.join("\n"); + s.push('\n'); + s + }; + fs::write(path, &content) + .with_context(|| format!("Failed to write config to '{}'", path.display())) +} + +#[cfg_attr(not(test), allow(unused_imports, dead_code))] +mod functional_test { + use super::*; + use std::collections::HashMap; + use tempfile::tempdir; + + use pytest_gen::functional_test; + use trident_api::config::LoadMode; + + use crate::OsModifierContext; + + fn make_ctx(tmp: &tempfile::TempDir) -> OsModifierContext { + OsModifierContext { + root: tmp.path().to_path_buf(), + } + } + + #[functional_test(feature = "core")] + fn test_configure_modules_always_load() { + let tmp = tempdir().unwrap(); + let ctx = make_ctx(&tmp); + + let modules = vec![Module { + name: "br_netfilter".to_string(), + load_mode: LoadMode::Always, + options: HashMap::new(), + }]; + + configure(&ctx, &modules).unwrap(); + + let load_conf = + fs::read_to_string(tmp.path().join("etc/modules-load.d/modules-load.conf")).unwrap(); + assert!( + load_conf.contains("br_netfilter"), + "Expected br_netfilter in modules-load.conf, got: {load_conf}" + ); + } + + #[functional_test(feature = "core")] + fn test_configure_modules_disable() { + let tmp = tempdir().unwrap(); + let ctx = make_ctx(&tmp); + + let modules = vec![Module { + name: "floppy".to_string(), + load_mode: LoadMode::Disable, + options: HashMap::new(), + }]; + + configure(&ctx, &modules).unwrap(); + + let disabled_conf = + fs::read_to_string(tmp.path().join("etc/modprobe.d/modules-disabled.conf")).unwrap(); + assert!( + disabled_conf.contains("blacklist floppy"), + "Expected 'blacklist floppy' in modules-disabled.conf, got: {disabled_conf}" + ); + + // Should NOT appear in modules-load.conf + let load_conf = + fs::read_to_string(tmp.path().join("etc/modules-load.d/modules-load.conf")).unwrap(); + assert!( + !load_conf.contains("floppy"), + "floppy should not be in modules-load.conf" + ); + } + + #[functional_test(feature = "core")] + fn test_configure_modules_with_options() { + let tmp = tempdir().unwrap(); + let ctx = make_ctx(&tmp); + + let mut opts = HashMap::new(); + opts.insert("num_vfs".to_string(), "4".to_string()); + + let modules = vec![Module { + name: "ixgbevf".to_string(), + load_mode: LoadMode::Always, + options: opts, + }]; + + configure(&ctx, &modules).unwrap(); + + let options_conf = + fs::read_to_string(tmp.path().join("etc/modprobe.d/module-options.conf")).unwrap(); + assert!( + options_conf.contains("options ixgbevf num_vfs=4"), + "Expected module options line, got: {options_conf}" + ); + } + + #[functional_test(feature = "core", negative = true)] + fn test_configure_modules_disable_with_options_fails() { + let tmp = tempdir().unwrap(); + let ctx = make_ctx(&tmp); + + let mut opts = HashMap::new(); + opts.insert("bad".to_string(), "option".to_string()); + + let modules = vec![Module { + name: "floppy".to_string(), + load_mode: LoadMode::Disable, + options: opts, + }]; + + let result = configure(&ctx, &modules); + assert!( + result.is_err(), + "Disabling a module with options should fail" + ); + } + + #[functional_test(feature = "core")] + fn test_configure_modules_idempotent() { + let tmp = tempdir().unwrap(); + let ctx = make_ctx(&tmp); + + let modules = vec![Module { + name: "br_netfilter".to_string(), + load_mode: LoadMode::Always, + options: HashMap::new(), + }]; + + // Apply twice + configure(&ctx, &modules).unwrap(); + configure(&ctx, &modules).unwrap(); + + let load_conf = + fs::read_to_string(tmp.path().join("etc/modules-load.d/modules-load.conf")).unwrap(); + let count = load_conf.matches("br_netfilter").count(); + assert_eq!(count, 1, "Module should appear exactly once, got {count}"); + } + + #[functional_test(feature = "core")] + fn test_configure_modules_disable_removes_from_load() { + let tmp = tempdir().unwrap(); + let ctx = make_ctx(&tmp); + + // First enable + let enable = vec![Module { + name: "br_netfilter".to_string(), + load_mode: LoadMode::Always, + options: HashMap::new(), + }]; + configure(&ctx, &enable).unwrap(); + + // Then disable + let disable = vec![Module { + name: "br_netfilter".to_string(), + load_mode: LoadMode::Disable, + options: HashMap::new(), + }]; + configure(&ctx, &disable).unwrap(); + + let load_conf = + fs::read_to_string(tmp.path().join("etc/modules-load.d/modules-load.conf")).unwrap(); + assert!( + !load_conf.contains("br_netfilter"), + "Disabled module should be removed from modules-load.conf" + ); + + let disabled_conf = + fs::read_to_string(tmp.path().join("etc/modprobe.d/modules-disabled.conf")).unwrap(); + assert!( + disabled_conf.contains("blacklist br_netfilter"), + "Disabled module should appear in blacklist" + ); + } + + #[functional_test(feature = "core", negative = true)] + fn test_configure_modules_inherit_disabled_with_options_fails() { + let tmp = tempdir().unwrap(); + let ctx = make_ctx(&tmp); + + // First disable the module + let disable = vec![Module { + name: "floppy".to_string(), + load_mode: LoadMode::Disable, + options: HashMap::new(), + }]; + configure(&ctx, &disable).unwrap(); + + // Then try Inherit with options — should fail (matches Go behavior) + let mut opts = HashMap::new(); + opts.insert("bad".to_string(), "option".to_string()); + + let inherit = vec![Module { + name: "floppy".to_string(), + load_mode: LoadMode::Inherit, + options: opts, + }]; + + let result = configure(&ctx, &inherit); + assert!( + result.is_err(), + "Inherit mode with options on a disabled module should fail" + ); + } + + #[functional_test(feature = "core")] + fn test_configure_modules_options_preserve_existing() { + let tmp = tempdir().unwrap(); + let ctx = make_ctx(&tmp); + + // First set with option A + let mut opts1 = HashMap::new(); + opts1.insert("opt_a".to_string(), "1".to_string()); + + let modules1 = vec![Module { + name: "testmod".to_string(), + load_mode: LoadMode::Always, + options: opts1, + }]; + configure(&ctx, &modules1).unwrap(); + + // Then update with option B only — option A should be preserved + let mut opts2 = HashMap::new(); + opts2.insert("opt_b".to_string(), "2".to_string()); + + let modules2 = vec![Module { + name: "testmod".to_string(), + load_mode: LoadMode::Always, + options: opts2, + }]; + configure(&ctx, &modules2).unwrap(); + + let options_conf = + fs::read_to_string(tmp.path().join("etc/modprobe.d/module-options.conf")).unwrap(); + assert!( + options_conf.contains("opt_a=1"), + "Existing option should be preserved, got: {options_conf}" + ); + assert!( + options_conf.contains("opt_b=2"), + "New option should be added, got: {options_conf}" + ); + } +} diff --git a/crates/osmodifier/src/selinux.rs b/crates/osmodifier/src/selinux.rs new file mode 100644 index 000000000..828e79cb0 --- /dev/null +++ b/crates/osmodifier/src/selinux.rs @@ -0,0 +1,203 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! SELinux management — update /etc/selinux/config and GRUB cmdline args. + +use std::fs; + +use anyhow::{bail, Context, Error}; +use log::debug; +use trident_api::config::SelinuxMode; + +use crate::{default_grub::DefaultGrub, OsModifierContext}; + +const SELINUX_CONFIG_PATH: &str = "/etc/selinux/config"; + +/// Update the SELinux mode in /etc/selinux/config. +/// +/// Called from two paths that do NOT overlap at runtime: +/// - `modify_os` → for UKI images (SELinux set via config file only) +/// - `modify_boot` → for GRUB images (SELinux set in both config file and cmdline) +/// +/// Idempotent — safe if both paths were ever called, but the caller invariants +/// on `modify_os`/`modify_boot` prevent this. +pub fn update_config_file(ctx: &OsModifierContext, mode: &SelinuxMode) -> Result<(), Error> { + let path = ctx.path(SELINUX_CONFIG_PATH); + + if !path.exists() { + bail!( + "SELinux config file not found at '{}'. \ + Ensure the selinux-policy package is installed.", + path.display() + ); + } + + let content = fs::read_to_string(&path) + .with_context(|| format!("Failed to read '{}'", path.display()))?; + + let selinux_value = match mode { + SelinuxMode::Enforcing => "enforcing", + SelinuxMode::Permissive => "permissive", + SelinuxMode::Disabled => "disabled", + }; + + // Replace the first SELINUX= line only (matching Go's re.replace first-match). + let new_line = format!("SELINUX={selinux_value}"); + let mut found = false; + let new_content: String = content + .lines() + .map(|line| { + if !found && line.trim_start().starts_with("SELINUX=") { + found = true; + new_line.clone() + } else { + line.to_string() + } + }) + .collect::>() + .join("\n") + + "\n"; + + let new_content = if found { + new_content + } else { + // Append if not present + format!("{content}\nSELINUX={selinux_value}\n") + }; + + debug!( + "Updating SELinux config at '{}' to '{selinux_value}'", + path.display() + ); + fs::write(&path, new_content).with_context(|| format!("Failed to write '{}'", path.display())) +} + +/// Update SELinux kernel command line args in the default GRUB config. +/// +/// This sets the `selinux` and `enforcing` args in GRUB_CMDLINE_LINUX, +/// matching the Go `UpdateSELinuxCommandLineForEMU` behavior. +pub fn update_grub_cmdline( + _ctx: &OsModifierContext, + default_grub: &mut DefaultGrub, + mode: &SelinuxMode, +) -> Result<(), Error> { + let new_args = match mode { + SelinuxMode::Enforcing => vec!["security=selinux".to_string(), "selinux=1".to_string()], + SelinuxMode::Permissive => vec![ + "security=selinux".to_string(), + "selinux=1".to_string(), + "enforcing=0".to_string(), + ], + SelinuxMode::Disabled => vec!["selinux=0".to_string()], + }; + + default_grub.update_cmdline_args(&["security", "selinux", "enforcing"], &new_args) +} + +#[cfg_attr(not(test), allow(unused_imports, dead_code))] +mod functional_test { + use super::*; + use std::fs; + use tempfile::tempdir; + + use pytest_gen::functional_test; + + use crate::OsModifierContext; + + #[functional_test(feature = "core")] + fn test_update_selinux_config_enforcing() { + let tmp = tempdir().unwrap(); + let etc = tmp.path().join("etc/selinux"); + fs::create_dir_all(&etc).unwrap(); + fs::write( + etc.join("config"), + "SELINUX=permissive\nSELINUXTYPE=targeted\n", + ) + .unwrap(); + + let ctx = OsModifierContext { + root: tmp.path().to_path_buf(), + }; + + update_config_file(&ctx, &SelinuxMode::Enforcing).unwrap(); + + let content = fs::read_to_string(etc.join("config")).unwrap(); + assert!( + content.contains("SELINUX=enforcing"), + "Expected SELINUX=enforcing, got: {content}" + ); + // Original SELINUXTYPE should be preserved + assert!( + content.contains("SELINUXTYPE=targeted"), + "SELINUXTYPE should be preserved" + ); + } + + #[functional_test(feature = "core")] + fn test_update_selinux_config_disabled() { + let tmp = tempdir().unwrap(); + let etc = tmp.path().join("etc/selinux"); + fs::create_dir_all(&etc).unwrap(); + fs::write(etc.join("config"), "SELINUX=enforcing\n").unwrap(); + + let ctx = OsModifierContext { + root: tmp.path().to_path_buf(), + }; + + update_config_file(&ctx, &SelinuxMode::Disabled).unwrap(); + + let content = fs::read_to_string(etc.join("config")).unwrap(); + assert!( + content.contains("SELINUX=disabled"), + "Expected SELINUX=disabled, got: {content}" + ); + } + + #[functional_test(feature = "core", negative = true)] + fn test_update_selinux_config_missing_file() { + let tmp = tempdir().unwrap(); + let ctx = OsModifierContext { + root: tmp.path().to_path_buf(), + }; + + let result = update_config_file(&ctx, &SelinuxMode::Enforcing); + assert!( + result.is_err(), + "Should fail when SELinux config is missing" + ); + } + + #[functional_test(feature = "core")] + fn test_update_selinux_grub_cmdline_enforcing() { + let tmp = tempdir().unwrap(); + let etc = tmp.path().join("etc/default"); + fs::create_dir_all(&etc).unwrap(); + fs::write(etc.join("grub"), "GRUB_CMDLINE_LINUX=\"quiet selinux=0\"\n").unwrap(); + + let ctx = OsModifierContext { + root: tmp.path().to_path_buf(), + }; + + let mut grub = DefaultGrub::read(&ctx).unwrap(); + update_grub_cmdline(&ctx, &mut grub, &SelinuxMode::Enforcing).unwrap(); + grub.write().unwrap(); + + let content = fs::read_to_string(etc.join("grub")).unwrap(); + assert!( + content.contains("security=selinux"), + "Expected security=selinux in grub, got: {content}" + ); + assert!( + content.contains("selinux=1"), + "Expected selinux=1 in grub, got: {content}" + ); + assert!( + !content.contains("enforcing=1"), + "Enforcing mode should NOT add enforcing=1 (matches Go Enforcing, not ForceEnforcing)" + ); + assert!( + !content.contains("selinux=0"), + "Old selinux=0 should be removed" + ); + } +} diff --git a/crates/osmodifier/src/services.rs b/crates/osmodifier/src/services.rs new file mode 100644 index 000000000..a3e646139 --- /dev/null +++ b/crates/osmodifier/src/services.rs @@ -0,0 +1,180 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Service management — enable and disable systemd services. + +use anyhow::{bail, Context, Error}; +use log::debug; +use osutils::dependencies::Dependency; + +use trident_api::config::Services; + +use crate::OsModifierContext; + +/// Enable and disable the requested systemd services. +pub fn configure(ctx: &OsModifierContext, services: &Services) -> Result<(), Error> { + for service in &services.enable { + enable_service(ctx, service)?; + } + + for service in &services.disable { + disable_service(ctx, service)?; + } + + Ok(()) +} + +fn enable_service(ctx: &OsModifierContext, service: &str) -> Result<(), Error> { + debug!("Enabling service '{service}'"); + let root = ctx.root.to_str().context("Root path is not valid UTF-8")?; + + Dependency::Systemctl + .cmd() + .args(["--root", root, "enable", service]) + .run_and_check() + .with_context(|| format!("Failed to enable service '{service}'"))?; + + Ok(()) +} + +fn disable_service(ctx: &OsModifierContext, service: &str) -> Result<(), Error> { + // Go uses `systemd.IsServiceEnabled` as an existence check before disabling. + // `systemctl is-enabled` returns: + // enabled: exit 0, stdout = "enabled" + // disabled: exit 1, stdout = "disabled" + // error: exit 1, stdout = "" (e.g., service doesn't exist) + // Go errors on the third case; proceeds to disable for both enabled and disabled. + let root = ctx.root.to_str().context("Root path is not valid UTF-8")?; + + let check = Dependency::Systemctl + .cmd() + .args(["--root", root, "is-enabled", service]) + .output() + .with_context(|| format!("Failed to check if service '{service}' is enabled"))?; + + if !check.success() { + let stdout = check.output(); + if stdout.trim() != "disabled" { + bail!("Failed to check if service '{service}' is enabled (service may not exist)"); + } + } + + debug!("Disabling service '{service}'"); + Dependency::Systemctl + .cmd() + .args(["--root", root, "disable", service]) + .run_and_check() + .with_context(|| format!("Failed to disable service '{service}'"))?; + + Ok(()) +} + +#[cfg_attr(not(test), allow(unused_imports, dead_code))] +mod functional_test { + use super::*; + use std::fs; + use tempfile::tempdir; + + use pytest_gen::functional_test; + use trident_api::config::Services; + + use crate::OsModifierContext; + + /// Create a minimal systemd tree with a synthetic service unit. + fn setup_systemd_root(tmp: &std::path::Path) { + let unit_dir = tmp.join("usr/lib/systemd/system"); + fs::create_dir_all(&unit_dir).unwrap(); + + // systemctl --root needs these directories + fs::create_dir_all(tmp.join("etc/systemd/system/multi-user.target.wants")).unwrap(); + + fs::write( + unit_dir.join("test-osmodifier.service"), + "[Unit]\nDescription=Test Service\n\n[Service]\nType=oneshot\nExecStart=/bin/true\n\n[Install]\nWantedBy=multi-user.target\n", + ) + .unwrap(); + } + + #[functional_test(feature = "core")] + fn test_enable_service() { + let tmp = tempdir().unwrap(); + setup_systemd_root(tmp.path()); + + let ctx = OsModifierContext { + root: tmp.path().to_path_buf(), + }; + + let services = Services { + enable: vec!["test-osmodifier.service".to_string()], + disable: vec![], + }; + + configure(&ctx, &services).unwrap(); + + // Verify the symlink was created — may be dangling since target is absolute + let wants_dir = tmp + .path() + .join("etc/systemd/system/multi-user.target.wants"); + let service_link = wants_dir.join("test-osmodifier.service"); + assert!( + service_link.is_symlink(), + "Expected service symlink at {}", + service_link.display(), + ); + } + + #[functional_test(feature = "core")] + fn test_disable_service() { + let tmp = tempdir().unwrap(); + setup_systemd_root(tmp.path()); + + let ctx = OsModifierContext { + root: tmp.path().to_path_buf(), + }; + + // Enable first + let enable = Services { + enable: vec!["test-osmodifier.service".to_string()], + disable: vec![], + }; + configure(&ctx, &enable).unwrap(); + + // Then disable + let disable = Services { + enable: vec![], + disable: vec!["test-osmodifier.service".to_string()], + }; + configure(&ctx, &disable).unwrap(); + + // Verify the symlink was removed + let symlink_path = tmp + .path() + .join("etc/systemd/system/multi-user.target.wants/test-osmodifier.service"); + assert!( + !symlink_path.is_symlink(), + "Symlink should be removed after disable" + ); + } + + #[functional_test(feature = "core")] + fn test_disable_already_disabled_service() { + let tmp = tempdir().unwrap(); + setup_systemd_root(tmp.path()); + + let ctx = OsModifierContext { + root: tmp.path().to_path_buf(), + }; + + // Disable without enabling first — should succeed (idempotent) + let services = Services { + enable: vec![], + disable: vec!["test-osmodifier.service".to_string()], + }; + + let result = configure(&ctx, &services); + assert!( + result.is_ok(), + "Disabling an already-disabled service should succeed" + ); + } +} diff --git a/crates/osmodifier/src/users.rs b/crates/osmodifier/src/users.rs new file mode 100644 index 000000000..68fda4ab8 --- /dev/null +++ b/crates/osmodifier/src/users.rs @@ -0,0 +1,646 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! User management — create/update users, passwords, SSH keys, groups. + +use std::{fs, io::Write, os::unix::fs::PermissionsExt, path::Path, process::Command}; + +use anyhow::{bail, Context, Error}; +use log::{debug, info}; +use osutils::dependencies::Dependency; + +use crate::{ + config::{MICUser, PasswordType}, + OsModifierContext, +}; + +// Shadow file field indices (0-based, colon-delimited). +const SHADOW_FIELD_PASSWORD: usize = 1; +const SHADOW_FIELD_LAST_CHANGE: usize = 2; +const SHADOW_FIELD_EXPIRATION: usize = 7; +const SHADOW_TOTAL_FIELDS: usize = 9; + +// Passwd file field indices (0-based, colon-delimited). +const PASSWD_FIELD_HOME: usize = 5; +const PASSWD_FIELD_SHELL: usize = 6; + +// SSH permissions. +const SSH_DIR_MODE: u32 = 0o700; +const AUTHORIZED_KEYS_MODE: u32 = 0o600; + +/// Add or update all configured users. +pub fn add_or_update_users(ctx: &OsModifierContext, users: &[MICUser]) -> Result<(), Error> { + for user in users { + add_or_update_user(ctx, user) + .with_context(|| format!("Failed to configure user '{}'", user.name))?; + } + Ok(()) +} + +fn add_or_update_user(ctx: &OsModifierContext, user: &MICUser) -> Result<(), Error> { + // Hash the password if needed + let hashed_password = match &user.password { + Some(pwd) => match pwd.password_type { + PasswordType::PlainText => Some(hash_password(&pwd.value)?), + PasswordType::Hashed => { + validate_shadow_value(&pwd.value).context("Invalid hashed password value")?; + Some(pwd.value.clone()) + } + PasswordType::Locked => None, + }, + None => None, + }; + + let is_locked = user + .password + .as_ref() + .is_some_and(|p| p.password_type == PasswordType::Locked); + + let user_exists = check_user_exists(&user.name)?; + + if user_exists { + debug!("User '{}' already exists, updating", user.name); + if user.uid.is_some() { + bail!( + "Cannot change UID for existing user '{}'. \ + Remove the UID field or delete the user first.", + user.name + ); + } + if user.home_directory.is_some() { + bail!( + "Cannot change home directory for existing user '{}'. \ + Remove the home directory field or delete the user first.", + user.name + ); + } + + // Update password if provided + if let Some(ref hash) = hashed_password { + update_user_password(ctx, &user.name, hash)?; + } else if is_locked { + // Lock the account by writing a locked marker to /etc/shadow + lock_user_password(ctx, &user.name)?; + } + } else { + info!("Creating user '{}'", user.name); + create_user(user)?; + + // Set password after creation via chpasswd (avoids leaking hash in + // /proc/cmdline that useradd -p would cause). + if let Some(ref hash) = hashed_password { + set_password_via_chpasswd(&user.name, hash)?; + } else if is_locked { + // Explicitly lock rather than relying on useradd's default shadow + // entry (which happens to be `!!` on AZL but is not guaranteed). + lock_user_password(ctx, &user.name)?; + } + } + + // Set password expiry + if let Some(days) = user.password_expires_days { + set_password_expiry(ctx, &user.name, days)?; + } + + // Update groups (only run usermod -g for existing users — for new users + // the primary group was already set via useradd -g). + if let Some(ref primary) = user.primary_group { + if user_exists { + set_primary_group(&user.name, primary)?; + } + } + if !user.secondary_groups.is_empty() { + set_secondary_groups(&user.name, &user.secondary_groups)?; + } + + // SSH keys + if !user.ssh_public_keys.is_empty() { + write_ssh_keys(ctx, &user.name, &user.ssh_public_keys, user_exists)?; + } + + // Startup command + if let Some(ref cmd) = user.startup_command { + set_startup_command(ctx, &user.name, cmd)?; + } + + Ok(()) +} + +fn check_user_exists(username: &str) -> Result { + let output = Dependency::Id + .cmd() + .args(["-u", username]) + .output() + .with_context(|| format!("Failed to run 'id' for user '{username}'"))?; + + if output.success() { + return Ok(true); + } + + // Go's UserExists discriminates "no such user" from real errors. + // Only treat the expected "no such user" stderr as not-found; + // propagate everything else (permission denied, command-not-found, etc.). + let stderr = output.error_output().to_lowercase(); + if stderr.contains("no such user") { + return Ok(false); + } + + bail!( + "Unexpected error checking if user '{username}' exists: {}", + output.error_output() + ) +} + +/// Validate that a value is safe to write into /etc/shadow or pass to chpasswd. +/// Colons would corrupt the colon-delimited format; newlines would break line parsing. +fn validate_shadow_value(value: &str) -> Result<(), Error> { + if value.contains(':') { + bail!("Value contains ':' which would corrupt /etc/shadow format"); + } + if value.contains('\n') || value.contains('\r') { + bail!("Value contains newline which would corrupt /etc/shadow format"); + } + Ok(()) +} + +fn hash_password(plaintext: &str) -> Result { + // Use Dependency::Openssl to resolve the binary path for consistent + // detection, but use std::process::Command for stdin piping which + // the Dependency Command wrapper doesn't yet support. + let openssl_path = Dependency::Openssl + .path() + .context("openssl is required for password hashing")?; + + let mut child = Command::new(openssl_path) + .args(["passwd", "-6", "-stdin"]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .context("Failed to start openssl passwd")?; + + if let Some(mut stdin) = child.stdin.take() { + stdin + .write_all(plaintext.as_bytes()) + .context("Failed to write password to openssl stdin")?; + } + + let output = child + .wait_with_output() + .context("Failed to wait for openssl passwd")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + bail!("openssl passwd failed: {stderr}"); + } + + Ok(String::from_utf8(output.stdout) + .context("openssl passwd produced non-UTF-8 output")? + .trim() + .to_string()) +} + +fn create_user(user: &MICUser) -> Result<(), Error> { + let mut cmd = Dependency::Useradd.cmd(); + + cmd.arg("-m"); // Create home directory + + // Password is set separately via chpasswd to avoid leaking the hash + // through /proc/cmdline (useradd -p is world-readable). + + if let Some(uid) = user.uid { + cmd.arg("-u").arg(uid.to_string()); + } + + if let Some(ref home) = user.home_directory { + cmd.arg("-d").arg(home); + } + + if let Some(ref primary_group) = user.primary_group { + cmd.arg("-g").arg(primary_group); + } + + cmd.arg(&user.name); + + cmd.run_and_check() + .with_context(|| format!("Failed to create user '{}'", user.name))?; + + Ok(()) +} + +fn update_user_password(ctx: &OsModifierContext, username: &str, hash: &str) -> Result<(), Error> { + debug!("Updating password for user '{username}'"); + let shadow_path = ctx.path("/etc/shadow"); + + let content = fs::read_to_string(&shadow_path) + .with_context(|| format!("Failed to read '{}'", shadow_path.display()))?; + + let mut found = false; + let updated: Vec = content + .lines() + .map(|line| { + let fields: Vec<&str> = line.split(':').collect(); + if fields.len() >= 2 && fields[0] == username { + found = true; + let mut new_fields: Vec = fields.iter().map(|f| f.to_string()).collect(); + new_fields[SHADOW_FIELD_PASSWORD] = hash.to_string(); + new_fields.join(":") + } else { + line.to_string() + } + }) + .collect(); + + if !found { + bail!("User '{username}' not found in shadow file"); + } + + let mut result = updated.join("\n"); + if content.ends_with('\n') { + result.push('\n'); + } + + atomic_write_file(&shadow_path, &result) +} + +/// Set password on a newly created user via chpasswd -e (stdin), avoiding +/// leaking the hash through /proc/cmdline. +fn set_password_via_chpasswd(username: &str, hash: &str) -> Result<(), Error> { + // Use Dependency::Chpasswd to resolve the binary path for consistent + // detection, but use std::process::Command for stdin piping which + // the Dependency Command wrapper doesn't yet support. + let chpasswd_path = Dependency::Chpasswd + .path() + .context("chpasswd is required for setting user passwords")?; + + debug!("Setting password for new user '{username}' via chpasswd"); + let input = format!("{username}:{hash}\n"); + + let mut child = Command::new(chpasswd_path) + .arg("-e") + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .context("Failed to start chpasswd")?; + + if let Some(mut stdin) = child.stdin.take() { + stdin + .write_all(input.as_bytes()) + .context("Failed to write to chpasswd stdin")?; + } + + let output = child + .wait_with_output() + .context("Failed to wait for chpasswd")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + bail!("chpasswd failed for '{username}': {stderr}"); + } + Ok(()) +} + +/// Lock a user's password by writing the locked marker into /etc/shadow. +/// +/// Uses `*` (not `!`) because Azure Linux's sshd is built with `UsePAM=no`, +/// where `!` means "fully disabled including SSH key login" but `*` means +/// "password disabled, SSH key login still works." Matches Go's +/// `UpdateUserPassword` behavior. +fn lock_user_password(ctx: &OsModifierContext, username: &str) -> Result<(), Error> { + debug!("Locking password for user '{username}'"); + update_user_password(ctx, username, "*") +} + +fn set_password_expiry(ctx: &OsModifierContext, username: &str, days: u64) -> Result<(), Error> { + debug!("Setting password expiry for '{username}' to {days} days"); + + // Validate range matching Go's PasswordExpiresDaysIsValid (upper bound only; + // trident's API uses u64 so -1 "never expires" is not reachable here). + const UPPER_BOUND: u64 = 99999; + if days > UPPER_BOUND { + bail!("invalid value for password_expires_days ({days}), must be <= {UPPER_BOUND}"); + } + + let shadow_path = ctx.path("/etc/shadow"); + + let content = fs::read_to_string(&shadow_path) + .with_context(|| format!("Failed to read '{}'", shadow_path.display()))?; + + // Shadow field indices (0-based): + // 0=login, 1=password, 2=lastChange, 3=minAge, 4=maxAge, + // 5=warnPeriod, 6=inactivity, 7=expiration, 8=reserved + + let mut found = false; + let mut parse_err: Option = None; + let updated: Vec = content + .lines() + .map(|line| { + let fields: Vec<&str> = line.split(':').collect(); + if !fields.is_empty() && fields[0] == username { + if fields.len() != SHADOW_TOTAL_FIELDS { + parse_err = Some(format!( + "invalid shadow entry for user '{}': expected {} fields, found {}", + username, + SHADOW_TOTAL_FIELDS, + fields.len() + )); + return line.to_string(); + } + found = true; + let mut new_fields: Vec = fields.iter().map(|f| f.to_string()).collect(); + + // Ensure lastChange field is populated + if new_fields[SHADOW_FIELD_LAST_CHANGE].is_empty() { + match days_since_unix_epoch() { + Ok(d) => new_fields[SHADOW_FIELD_LAST_CHANGE] = d.to_string(), + Err(e) => { + parse_err = Some(format!("{e:#}")); + return line.to_string(); + } + } + } + let last_change: i64 = match new_fields[SHADOW_FIELD_LAST_CHANGE].parse() { + Ok(v) => v, + Err(_) => { + parse_err = Some(format!( + "failed to parse lastChange field '{}' for user '{username}'", + new_fields[SHADOW_FIELD_LAST_CHANGE] + )); + return line.to_string(); + } + }; + + // Set account expiration date (field 7) = lastChange + days. + // Note: Go's Chage() comment says "chage -M" (max password age, field 4) + // but actually writes to the expiration field (field 7). We match Go's + // actual behavior, not its misleading comment. See installutils.go:643. + new_fields[SHADOW_FIELD_EXPIRATION] = (last_change + days as i64).to_string(); + new_fields.join(":") + } else { + line.to_string() + } + }) + .collect(); + + if let Some(err) = parse_err { + bail!("{err}"); + } + + if !found { + bail!("User '{username}' not found in shadow file for password expiry"); + } + + let mut result = updated.join("\n"); + if content.ends_with('\n') { + result.push('\n'); + } + + atomic_write_file(&shadow_path, &result) +} + +/// Return the number of days since the Unix epoch (1970-01-01). +fn days_since_unix_epoch() -> Result { + use std::time::{SystemTime, UNIX_EPOCH}; + let secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .context("System clock is set before the Unix epoch")? + .as_secs() as i64; + Ok(secs / 86400) +} + +fn set_primary_group(username: &str, group: &str) -> Result<(), Error> { + debug!("Setting primary group for '{username}' to '{group}'"); + Dependency::Usermod + .cmd() + .args(["-g", group, username]) + .run_and_check() + .with_context(|| format!("Failed to set primary group for '{username}'"))?; + + Ok(()) +} + +fn set_secondary_groups(username: &str, groups: &[String]) -> Result<(), Error> { + let groups_str = groups.join(","); + debug!("Setting secondary groups for '{username}' to '{groups_str}'"); + Dependency::Usermod + .cmd() + .args(["-a", "-G", &groups_str, username]) + .run_and_check() + .with_context(|| format!("Failed to set secondary groups for '{username}'"))?; + + Ok(()) +} + +fn write_ssh_keys( + ctx: &OsModifierContext, + username: &str, + keys: &[String], + include_existing: bool, +) -> Result<(), Error> { + // Determine home directory + let home = get_home_dir(ctx, username)?; + let ssh_dir = home.join(".ssh"); + let auth_keys_path = ssh_dir.join("authorized_keys"); + + debug!( + "Writing {} SSH key(s) for '{username}' to '{}'", + keys.len(), + auth_keys_path.display() + ); + + // Create .ssh directory + fs::create_dir_all(&ssh_dir) + .with_context(|| format!("Failed to create '{}'", ssh_dir.display()))?; + + // Set directory permissions to 0700 + fs::set_permissions(&ssh_dir, fs::Permissions::from_mode(SSH_DIR_MODE)) + .with_context(|| format!("Failed to set permissions on '{}'", ssh_dir.display()))?; + + // For existing users, preserve existing authorized_keys (matching Go's + // ProvisionUserSSHCerts which passes userExists as includeExistingKeys). + let mut all_keys: Vec = Vec::new(); + if include_existing { + match fs::read_to_string(&auth_keys_path) { + Ok(existing) => { + for line in existing.lines() { + if !line.is_empty() { + all_keys.push(line.to_string()); + } + } + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + // No existing keys — that's fine + } + Err(e) => { + return Err(e) + .with_context(|| format!("Failed to read '{}'", auth_keys_path.display())); + } + } + } + + all_keys.extend(keys.iter().cloned()); + + // Write authorized_keys + let content = all_keys.join("\n") + "\n"; + fs::write(&auth_keys_path, &content) + .with_context(|| format!("Failed to write '{}'", auth_keys_path.display()))?; + + // Set file permissions to 0600 + fs::set_permissions( + &auth_keys_path, + fs::Permissions::from_mode(AUTHORIZED_KEYS_MODE), + ) + .with_context(|| { + format!( + "Failed to set permissions on '{}'", + auth_keys_path.display() + ) + })?; + + // Set ownership to the user + set_ownership(ctx, username, &ssh_dir)?; + set_ownership(ctx, username, &auth_keys_path)?; + + Ok(()) +} + +fn get_home_dir(ctx: &OsModifierContext, username: &str) -> Result { + let passwd_path = ctx.path("/etc/passwd"); + let content = fs::read_to_string(&passwd_path) + .with_context(|| format!("Failed to read '{}'", passwd_path.display()))?; + + for line in content.lines() { + let fields: Vec<&str> = line.split(':').collect(); + if fields.len() >= 6 && fields[0] == username { + return Ok(ctx.path(fields[PASSWD_FIELD_HOME])); + } + } + + bail!("Could not find home directory for user '{username}' in /etc/passwd") +} + +fn set_ownership(_ctx: &OsModifierContext, username: &str, path: &Path) -> Result<(), Error> { + let path_str = path.to_str().context("Failed to convert path to string")?; + + // Use "username:" (trailing colon, no group) so chown sets the group to + // the user's login group rather than assuming a same-named group exists. + Dependency::Chown + .cmd() + .args([&format!("{username}:"), path_str]) + .run_and_check() + .with_context(|| format!("Failed to chown '{}'", path.display()))?; + + Ok(()) +} + +fn set_startup_command(ctx: &OsModifierContext, username: &str, cmd: &str) -> Result<(), Error> { + debug!("Setting startup command for '{username}' to '{cmd}'"); + + // Validate: colons would corrupt the colon-delimited /etc/passwd format + if cmd.contains(':') { + bail!("Startup command for user '{username}' contains ':' which would corrupt /etc/passwd"); + } + if cmd.contains('\n') || cmd.contains('\r') { + bail!("Startup command for user '{username}' contains a newline"); + } + + let passwd_path = ctx.path("/etc/passwd"); + + let content = fs::read_to_string(&passwd_path) + .with_context(|| format!("Failed to read '{}'", passwd_path.display()))?; + + let mut found = false; + let updated: Vec = content + .lines() + .map(|line| { + let fields: Vec<&str> = line.split(':').collect(); + if fields.len() >= 7 && fields[0] == username { + found = true; + let mut new_fields: Vec = fields.iter().map(|f| f.to_string()).collect(); + new_fields[PASSWD_FIELD_SHELL] = cmd.to_string(); + new_fields.join(":") + } else { + line.to_string() + } + }) + .collect(); + + if !found { + bail!("User '{username}' not found in /etc/passwd"); + } + + let mut result = updated.join("\n"); + if content.ends_with('\n') { + result.push('\n'); + } + + atomic_write_file(&passwd_path, &result) +} + +/// Atomically write a file by writing to a temp file and renaming. +/// This prevents corruption from crashes mid-write. +/// +/// Preserves permissions and uid/gid ownership from the original file. +/// Note: SELinux labels and extended attributes are not preserved because +/// osmodifier runs inside the target root before SELinux enforcement. +fn atomic_write_file(path: &std::path::Path, content: &str) -> Result<(), Error> { + use std::io::Write as IoWrite; + use std::os::unix::fs::MetadataExt; + + let parent = path.parent().context("Cannot determine parent directory")?; + + let mut tmp = tempfile::NamedTempFile::new_in(parent) + .with_context(|| format!("Failed to create temp file in '{}'", parent.display()))?; + + tmp.write_all(content.as_bytes()) + .with_context(|| format!("Failed to write temp file for '{}'", path.display()))?; + + tmp.flush() + .with_context(|| format!("Failed to flush temp file for '{}'", path.display()))?; + + // fsync the temp file before rename to ensure data is on disk. Without + // this, a power loss between rename and dirty-page flush could leave the + // file zero-length (e.g., /etc/shadow → locked out of all accounts). + tmp.as_file() + .sync_all() + .with_context(|| format!("Failed to fsync temp file for '{}'", path.display()))?; + + // Preserve ownership and permissions from the original file if it exists. + // Ownership must be set before permissions because chown can clear + // setuid/setgid bits. + if let Ok(metadata) = fs::metadata(path) { + use std::os::fd::AsFd; + nix::unistd::fchown( + tmp.as_file().as_fd(), + Some(nix::unistd::Uid::from_raw(metadata.uid())), + Some(nix::unistd::Gid::from_raw(metadata.gid())), + ) + .with_context(|| { + format!( + "Failed to set ownership on temp file for '{}'", + path.display() + ) + })?; + + fs::set_permissions(tmp.path(), metadata.permissions()).with_context(|| { + format!( + "Failed to set permissions on temp file for '{}'", + path.display() + ) + })?; + } + + tmp.persist(path) + .with_context(|| format!("Failed to atomically replace '{}'", path.display()))?; + + // Sync parent directory to ensure the rename (directory entry update) is + // durable. Without this, the old file could reappear after power loss. + if let Some(parent) = path.parent() { + if let Ok(dir) = std::fs::File::open(parent) { + let _ = dir.sync_all(); + } + } + + Ok(()) +} diff --git a/crates/osutils/src/dependencies.rs b/crates/osutils/src/dependencies.rs index f9cec1b64..e5e36c6ee 100644 --- a/crates/osutils/src/dependencies.rs +++ b/crates/osutils/src/dependencies.rs @@ -89,6 +89,8 @@ impl DependencyResultExt for Result> { #[strum(serialize_all = "lowercase")] pub enum Dependency { Blkid, + Chown, + Chpasswd, Cryptsetup, Dd, Df, @@ -98,6 +100,9 @@ pub enum Dependency { Efibootmgr, Eject, Findmnt, + #[strum(serialize = "grub2-mkconfig")] + Grub2Mkconfig, + Id, Iptables, Journalctl, Losetup, @@ -111,6 +116,7 @@ pub enum Dependency { Mount, Mountpoint, Netplan, + Openssl, Partx, Resize2fs, Setfiles, @@ -118,6 +124,8 @@ pub enum Dependency { Swapoff, Swapon, Systemctl, + Useradd, + Usermod, #[strum(serialize = "systemd-confext")] SystemdConfext, #[strum(serialize = "systemd-cryptenroll")] diff --git a/crates/osutils/src/grub.rs b/crates/osutils/src/grub.rs index 8277c7bf1..92782bbf7 100644 --- a/crates/osutils/src/grub.rs +++ b/crates/osutils/src/grub.rs @@ -4,12 +4,10 @@ use std::{ }; use anyhow::{bail, Context, Error}; -use log::{debug, trace}; +use log::trace; use regex::Regex; use uuid::Uuid; -use trident_api::config::SelinuxMode; - /// Represents the GRUB configuration file. Support simple validation and /// retrieving and updating values. Temporary solution until we switch to more /// structured configuration. @@ -57,35 +55,6 @@ impl GrubConfig { Ok(()) } - /// Lazy approach at setting SELinux to permissive - /// - /// Will be removed in the future - /// TODO(6775): re-enable selinux - pub fn set_selinux_mode(&mut self, mode: SelinuxMode) { - if !self.contents.contains("selinux=1") { - // If "selinux=1" is not found, handle accordingly - debug!( - "selinux setting not found in kernel command line, skipping selinux mode change" - ); - return; - } - - match mode { - SelinuxMode::Disabled => { - debug!("Setting SELinux to disabled"); - self.contents = self.contents.replace("selinux=1", "selinux=0"); - } - SelinuxMode::Permissive => { - debug!("Setting SELinux to permissive"); - self.contents = self.contents.replace("selinux=1", "selinux=1 enforcing=0"); - } - SelinuxMode::Enforcing => { - debug!("Setting SELinux to enforcing"); - self.contents = self.contents.replace("selinux=1", "selinux=1 enforcing=1"); - } - } - } - /// Find the linux command line in the GRUB config. fn find_linux_command_line(&self) -> Result<&str, Error> { let re = Regex::new(LINUX_COMMAND_LINE_PATTERN)?; diff --git a/crates/osutils/src/lib.rs b/crates/osutils/src/lib.rs index e2a11dc82..2ebb435f8 100644 --- a/crates/osutils/src/lib.rs +++ b/crates/osutils/src/lib.rs @@ -26,7 +26,6 @@ pub mod mkinitrd; pub mod mount; pub mod mountpoint; pub mod netplan; -pub mod osmodifier; pub mod osrelease; pub mod overlay; pub mod path; diff --git a/crates/osutils/src/osmodifier.rs b/crates/osutils/src/osmodifier.rs deleted file mode 100644 index 79f9fcdf4..000000000 --- a/crates/osutils/src/osmodifier.rs +++ /dev/null @@ -1,193 +0,0 @@ -use std::{io::Write, path::Path, process::Command}; - -use anyhow::{Context, Error}; -use log::{debug, trace, warn}; -use serde::{Deserialize, Serialize}; -use tempfile::NamedTempFile; - -use trident_api::config::{KernelCommandLine, Module, Selinux, Services}; - -use crate::{exe::RunAndCheck, osmodifier}; - -#[derive(Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub struct OSModifierConfig { - #[serde(skip_serializing_if = "Vec::is_empty")] - pub users: Vec, - - #[serde(skip_serializing_if = "Option::is_none")] - pub hostname: Option, - - #[serde(skip_serializing_if = "Vec::is_empty")] - pub modules: Vec, - - #[serde(skip_serializing_if = "Option::is_none")] - pub services: Option, - - #[serde(skip_serializing_if = "Option::is_none")] - pub kernel_command_line: Option, - - #[serde(skip_serializing_if = "Option::is_none")] - pub selinux: Option, -} - -impl OSModifierConfig { - pub fn call_os_modifier(&self, os_modifier_path: &Path) -> Result<(), Error> { - let os_modifier_config_yaml = - serde_yaml::to_string(&self).context("Failed to serialize OS modifier config")?; - - if os_modifier_config_yaml.is_empty() { - // Should never get here, but in case the OS modifier config is empty, return early - // without calling binary - warn!("OS modifier config is empty. OS modifier will not be called."); - return Ok(()); - } - - debug!("Calling OS modifier"); - trace!( - "Calling OS modifier with the following config:\n{}", - os_modifier_config_yaml - ); - let mut config_file = NamedTempFile::new().context("Failed to create a temporary file")?; - config_file - .write_all(os_modifier_config_yaml.as_bytes()) - .and_then(|_| config_file.flush()) - .context("Failed to write OS modifier config to temporary file and flush")?; - osmodifier::run(os_modifier_path, config_file.path()) - .context("Failed to run OS modifier")?; - Ok(()) - } -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -pub struct MICServices { - pub services: Services, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] -#[serde(rename_all = "kebab-case")] -pub enum PasswordType { - Locked, - PlainText, - Hashed, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct MICPassword { - #[serde(rename = "type")] - pub password_type: PasswordType, - pub value: String, -} - -/// A helper struct to convert user into MIC's user format -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct MICUser { - pub name: String, - - #[serde(skip_serializing_if = "Option::is_none")] - pub uid: Option, - - #[serde(skip_serializing_if = "Option::is_none")] - pub password: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub password_expires_days: Option, - - #[serde(skip_serializing_if = "Vec::is_empty")] - pub ssh_public_keys: Vec, - - #[serde(skip_serializing_if = "Option::is_none")] - pub primary_group: Option, - - #[serde(skip_serializing_if = "Vec::is_empty")] - pub secondary_groups: Vec, - - #[serde(skip_serializing_if = "Option::is_none")] - pub startup_command: Option, - - #[serde(skip_serializing_if = "Option::is_none")] - pub home_directory: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct Overlay { - pub lower_dir: String, - pub upper_dir: String, - pub work_dir: String, - pub partition: IdentifiedPartition, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct IdentifiedPartition { - pub id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct Verity { - pub id: String, - pub name: String, - pub data_device: String, - pub hash_device: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub corruption_option: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -/// Specifies the behavior in case of detected corruption. -pub enum CorruptionOption { - /// Default setting. Fails the I/O operation with an I/O error. - IoError, - - /// Ignores the corruption and continues operation. - Ignore, - - /// Causes the system to panic. This will print errors and try restarting the system - /// upon detecting corruption. - Panic, - - /// Attempts to restart the system upon detecting corruption. - Restart, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct BootConfig { - #[serde(skip_serializing_if = "Option::is_none")] - pub selinux: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub overlays: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - pub verity: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub root_device: Option, -} - -pub fn run(os_modifier_path: &Path, config_file: &Path) -> Result<(), Error> { - // Run the OS modifier with the configuration file - Command::new(os_modifier_path) - .arg("--config-file") - .arg(config_file) - .arg("--log-level=debug") - .run_and_check() - .context(format!( - "Failed to run OS modifier with config file {}", - config_file.display() - ))?; - - Ok(()) -} - -pub fn update_grub(os_modifier_path: &Path) -> Result<(), Error> { - Command::new(os_modifier_path.to_str().unwrap()) - .arg("--update-grub") - .arg("--log-level=debug") - .run_and_check() - .context("Failed to run OS modifier to update GRUB config") -} diff --git a/crates/trident/Cargo.toml b/crates/trident/Cargo.toml index 86582d537..f3ed56ce3 100644 --- a/crates/trident/Cargo.toml +++ b/crates/trident/Cargo.toml @@ -70,6 +70,7 @@ tonic-middleware = { workspace = true } # Local Crate Dependencies sysdefs = { path = "../sysdefs" } +osmodifier = { path = "../osmodifier" } osutils = { path = "../osutils" } trident_api = { path = "../trident_api" } trident-proto = { path = "../trident-proto", features = ["server", "log"] } diff --git a/crates/trident/src/engine/boot/grub.rs b/crates/trident/src/engine/boot/grub.rs index 879eac1ad..b345f5c31 100644 --- a/crates/trident/src/engine/boot/grub.rs +++ b/crates/trident/src/engine/boot/grub.rs @@ -1,15 +1,14 @@ -use std::{fs, io::Write, path::Path}; +use std::{fs, path::Path}; use anyhow::{bail, Context, Error}; use log::{debug, info, trace}; -use tempfile::NamedTempFile; use uuid::Uuid; +use osmodifier::{BootConfig, IdentifiedPartition, OsModifierContext, Overlay, Verity}; use osutils::{ blkid, grub::GrubConfig, grub_mkconfig::GrubMkConfigScript, - osmodifier::{self, BootConfig, IdentifiedPartition, Overlay, Verity}, osrelease::{AzureLinuxRelease, Distro}, }; use trident_api::{ @@ -36,7 +35,7 @@ fn update_grub_config_esp(grub_config_path: &Path, boot_fs_uuid: &Uuid) -> Resul grub_config.write() } -pub(super) fn update_configs(ctx: &EngineContext, os_modifier_path: &Path) -> Result<(), Error> { +pub(super) fn update_configs(ctx: &EngineContext) -> Result<(), Error> { // Get the root block device path let root_device_path = ctx .get_root_block_device_path() @@ -66,12 +65,7 @@ pub(super) fn update_configs(ctx: &EngineContext, os_modifier_path: &Path) -> Re // Update GRUB config on the boot device (volume holding /boot) match ctx.host_os_release.get_distro() { Distro::AzureLinux(AzureLinuxRelease::AzL3) => { - update_grub_config_azl3( - ctx, - os_modifier_path, - &root_device_path, - &boot_grub_config_path, - )?; + update_grub_config_azl3(ctx, &root_device_path, &boot_grub_config_path)?; } d => bail!("Unsupported distro for GRUB config update: {d:?}"), @@ -94,7 +88,6 @@ pub(super) fn update_configs(ctx: &EngineContext, os_modifier_path: &Path) -> Re /// Updates the GRUB config for Azure Linux 3.0 using OS modifier. fn update_grub_config_azl3( ctx: &EngineContext, - os_modifier_path: &Path, root_device_path: &Path, boot_grub_config_path: &Path, ) -> Result<(), Error> { @@ -122,7 +115,8 @@ fn update_grub_config_azl3( grub_config ); - osmodifier::update_grub(os_modifier_path)?; + let osmod_ctx = OsModifierContext::default(); + osmodifier::update_default_grub(&osmod_ctx)?; let updated_grub_config = fs::read_to_string(boot_grub_config_path)?; trace!( @@ -225,27 +219,8 @@ fn update_grub_config_azl3( root_device: Some(root_device_str.to_string()), }; - let boot_config_yaml = serde_yaml::to_string(&config).context("Failed to serialize to YAML")?; - - // Create a temporary file and write the config to it - let mut tmpfile = NamedTempFile::new().context("Failed to create a temporary file")?; - tmpfile - .write_all(boot_config_yaml.as_bytes()) - .context(format!( - "Failed to write boot config to temporary file at {:?}", - tmpfile.path() - ))?; - tmpfile.flush().context(format!( - "Failed to flush temporary file at {:?}", - tmpfile.path() - ))?; - - osmodifier::run(os_modifier_path, tmpfile.path()).with_context(|| { - format!( - "Failed to run OS modifier to update GRUB config with temporary config file at {:?}", - tmpfile.path() - ) - })?; + osmodifier::modify_boot(&osmod_ctx, &config) + .context("Failed to apply boot configuration modifications")?; debug!("Finished updating GRUB config for Azure Linux 3.0 with OS modifier"); @@ -262,10 +237,7 @@ pub(crate) mod functional_test { use const_format::formatcp; use maplit::btreemap; - use crate::{ - engine::{boot::get_update_esp_dir_name, storage::raid}, - OS_MODIFIER_BINARY_PATH, - }; + use crate::engine::{boot::get_update_esp_dir_name, storage::raid}; use osutils::{ block_devices, @@ -601,7 +573,7 @@ pub(crate) mod functional_test { let _a = setup_mock_grub_configs(ctx); - update_configs(ctx, Path::new(OS_MODIFIER_BINARY_PATH)) + update_configs(ctx) } #[functional_test(feature = "helpers")] @@ -673,7 +645,7 @@ pub(crate) mod functional_test { let _a = setup_mock_grub_configs(&ctx); - update_configs(&ctx, Path::new(OS_MODIFIER_BINARY_PATH)).unwrap(); + update_configs(&ctx).unwrap(); } #[functional_test(feature = "helpers")] @@ -760,7 +732,7 @@ pub(crate) mod functional_test { let _a = setup_mock_grub_configs(&ctx); - update_configs(&ctx, Path::new(OS_MODIFIER_BINARY_PATH)).unwrap(); + update_configs(&ctx).unwrap(); } #[functional_test(feature = "helpers")] @@ -817,7 +789,7 @@ pub(crate) mod functional_test { let _a = setup_mock_grub_configs(&ctx); - let result = update_configs(&ctx, Path::new(ROOT_MOUNT_POINT_PATH)); + let result = update_configs(&ctx); assert_eq!( result.unwrap_err().to_string(), "Failed to get UUID for path '/dev/sdb2', received ''" @@ -878,7 +850,7 @@ pub(crate) mod functional_test { let _a = setup_mock_grub_configs(&ctx); - let result = update_configs(&ctx, Path::new(ROOT_MOUNT_POINT_PATH)); + let result = update_configs(&ctx); assert_eq!(result.unwrap_err().to_string(), "Root device path is none"); } diff --git a/crates/trident/src/engine/boot/mod.rs b/crates/trident/src/engine/boot/mod.rs index b076b2324..097abc38b 100644 --- a/crates/trident/src/engine/boot/mod.rs +++ b/crates/trident/src/engine/boot/mod.rs @@ -1,5 +1,3 @@ -use std::path::Path; - use log::debug; use strum::IntoEnumIterator; @@ -12,7 +10,7 @@ use trident_api::{ status::AbVolumeSelection, }; -use crate::{engine::Subsystem, OS_MODIFIER_NEWROOT_PATH}; +use crate::engine::Subsystem; use super::EngineContext; @@ -40,8 +38,7 @@ impl Subsystem for BootSubsystem { return Ok(()); } - grub::update_configs(ctx, Path::new(OS_MODIFIER_NEWROOT_PATH)) - .structured(ServicingError::UpdateGrubConfigs)?; + grub::update_configs(ctx).structured(ServicingError::UpdateGrubConfigs)?; Ok(()) } diff --git a/crates/trident/src/engine/newroot.rs b/crates/trident/src/engine/newroot.rs index d039e45de..e21d1fa96 100644 --- a/crates/trident/src/engine/newroot.rs +++ b/crates/trident/src/engine/newroot.rs @@ -22,8 +22,6 @@ use trident_api::{ BlockDeviceId, }; -use crate::{OS_MODIFIER_BINARY_PATH, OS_MODIFIER_NEWROOT_PATH}; - /// NewrootMount represents all the necessary mounting points for newroot and /// the nested execmount to exit the chroot jail. It is also responsible for /// unmounting them in the correct order. NewrootMount provides information for: @@ -82,21 +80,7 @@ impl NewrootMount { newroot_mount.mount_tmpfs("/tmp")?; newroot_mount.mount_tmpfs("/run")?; - if Path::new(OS_MODIFIER_BINARY_PATH).exists() { - // Bind mount the execroot binary to the newroot - debug!("Bind mounting osmodifier binary into newroot"); - let mount_path = path::join_relative(newroot_mount.path(), OS_MODIFIER_NEWROOT_PATH); - - fs::write(&mount_path, b"").structured(ServicingError::MountExecrootBinary)?; - - MountBuilder::default() - .flags(MountFlags::BIND) - .mount(OS_MODIFIER_BINARY_PATH, &mount_path) - .structured(ServicingError::MountExecrootBinary)?; - newroot_mount.mounts.push(mount_path); - } else { - debug!("Skipping bind mount of osmodifier binary into newroot"); - } + // OS modifier is now a library — no binary bind mount needed. Ok(newroot_mount) } diff --git a/crates/trident/src/lib.rs b/crates/trident/src/lib.rs index 1b9596b7f..23d07a2fa 100644 --- a/crates/trident/src/lib.rs +++ b/crates/trident/src/lib.rs @@ -89,12 +89,6 @@ const TRIDENT_BINARY_PATH: &str = "/usr/bin/trident"; /// Launcher binary path. const LAUNCHER_BINARY_PATH: &str = "/usr/bin/launcher"; -/// OS Modifier (EMU) binary path. -const OS_MODIFIER_BINARY_PATH: &str = "/usr/bin/osmodifier"; - -/// Path to OS Modifier on the newroot. -const OS_MODIFIER_NEWROOT_PATH: &str = "/tmp/osmodifier"; - /// Path to the Trident background log for the current servicing. pub const TRIDENT_BACKGROUND_LOG_PATH: &str = "/var/log/trident-full.log"; diff --git a/crates/trident/src/subsystems/osconfig/mod.rs b/crates/trident/src/subsystems/osconfig/mod.rs index 823c34cf3..aa5205e14 100644 --- a/crates/trident/src/subsystems/osconfig/mod.rs +++ b/crates/trident/src/subsystems/osconfig/mod.rs @@ -3,19 +3,17 @@ use std::{fs, path::Path}; use anyhow::Context; use log::{debug, error, info, warn}; -use osutils::{osmodifier::OSModifierConfig, path}; +use osmodifier::{OSModifierConfig, OsModifierContext}; +use osutils::path; use trident_api::{ config::{ManagementOs, Services, SshMode}, constants::internal_params::DISABLE_HOSTNAME_CARRY_OVER, - error::{ExecutionEnvironmentMisconfigurationError, ReportError, ServicingError, TridentError}, + error::{ReportError, ServicingError, TridentError}, is_default, status::ServicingType, }; -use crate::{ - engine::{EngineContext, Subsystem, RUNS_ON_ALL}, - OS_MODIFIER_BINARY_PATH, OS_MODIFIER_NEWROOT_PATH, -}; +use crate::engine::{EngineContext, Subsystem, RUNS_ON_ALL}; mod users; @@ -125,17 +123,8 @@ impl Subsystem for OsConfigSubsystem { Ok(ServicingType::NoActiveServicing) } - fn validate_host_config(&self, ctx: &EngineContext) -> Result<(), TridentError> { - // If the os-modifier binary is required but not present, return an error. - if os_changes_required(ctx) && !Path::new(OS_MODIFIER_BINARY_PATH).exists() { - return Err(TridentError::new( - ExecutionEnvironmentMisconfigurationError::FindOSModifierBinary { - binary_path: OS_MODIFIER_BINARY_PATH.to_string(), - config: self.name().to_string(), - }, - )); - } - + fn validate_host_config(&self, _ctx: &EngineContext) -> Result<(), TridentError> { + // OS modifier is now a library crate, no external binary needed. Ok(()) } @@ -262,8 +251,7 @@ impl OsConfigSubsystem { os_modifier_config.selinux = Some(ctx.spec.os.selinux.clone()); } - os_modifier_config - .call_os_modifier(Path::new(OS_MODIFIER_NEWROOT_PATH)) + osmodifier::modify_os(&OsModifierContext::default(), &os_modifier_config) .structured(ServicingError::RunOsModifier) } @@ -289,8 +277,7 @@ impl OsConfigSubsystem { services: Some(services), ..Default::default() }; - return os_modifier_config - .call_os_modifier(Path::new(OS_MODIFIER_BINARY_PATH)) + return osmodifier::modify_os(&OsModifierContext::default(), &os_modifier_config) .structured(ServicingError::RunOsModifier); } Ok(()) @@ -314,16 +301,10 @@ impl Subsystem for MosConfigSubsystem { return Ok(()); } - // If the os-modifier binary is required but not present, return an error. - if mos_config_requires_os_modifier(&ctx.spec.management_os) - && !Path::new(OS_MODIFIER_BINARY_PATH).exists() - { - return Err(TridentError::new( - ExecutionEnvironmentMisconfigurationError::FindOSModifierBinary { - binary_path: OS_MODIFIER_BINARY_PATH.to_string(), - config: self.name().to_string(), - }, - )); + // OS modifier is now a library crate, no external binary needed. + if mos_config_requires_os_modifier(&ctx.spec.management_os) { + // Validation still passes — we just don't need to check for a binary. + debug!("MOS config requires OS modifications (now handled by library)"); } Ok(()) @@ -339,10 +320,6 @@ impl Subsystem for MosConfigSubsystem { return Ok(()); } - // Get the path to the os-modifier binary. We've already validated that - // it exists when required in 'validate_host_config'. - let os_modifier_path = Path::new(OS_MODIFIER_BINARY_PATH); - if !ctx.spec.management_os.users.is_empty() { info!("Setting up users for management OS"); let os_modifier_config = OSModifierConfig { @@ -350,8 +327,7 @@ impl Subsystem for MosConfigSubsystem { .structured(ServicingError::SetUpUsers)?, ..Default::default() }; - os_modifier_config - .call_os_modifier(os_modifier_path) + osmodifier::modify_os(&OsModifierContext::default(), &os_modifier_config) .structured(ServicingError::RunOsModifier)?; // If the config enables SSH for any MOS user, then we changed the @@ -700,8 +676,6 @@ mod tests { mod functional_test { use super::*; - use sys_mount::{MountBuilder, MountFlags, Unmount, UnmountFlags}; - use pytest_gen::functional_test; use trident_api::config::{HostConfiguration, Os}; @@ -728,14 +702,7 @@ mod functional_test { }; assert!(os_changes_required(&ctx)); - fs::write(OS_MODIFIER_NEWROOT_PATH, b"").unwrap(); - let _mount = MountBuilder::default() - .flags(MountFlags::BIND) - .mount(OS_MODIFIER_BINARY_PATH, OS_MODIFIER_NEWROOT_PATH) - .unwrap() - .into_unmount_drop(UnmountFlags::empty()); - - // Configure OsConfig subsystem + // Configure OsConfig subsystem (osmodifier is now a library, no binary mount needed) let mut os_config_subsystem = OsConfigSubsystem::default(); let _ = os_config_subsystem.configure(&ctx); @@ -769,14 +736,8 @@ mod functional_test { }; assert!(os_changes_required(&ctx)); - fs::write(OS_MODIFIER_NEWROOT_PATH, b"").unwrap(); - let _mount = MountBuilder::default() - .flags(MountFlags::BIND) - .mount(OS_MODIFIER_BINARY_PATH, OS_MODIFIER_NEWROOT_PATH) - .unwrap() - .into_unmount_drop(UnmountFlags::empty()); - // Configure OsConfig subsystem and set prev_hostname parameter + // (osmodifier is now a library, no binary mount needed) let mut os_config_subsystem = OsConfigSubsystem { prev_hostname: Some("carry-over-hostname".into()), }; diff --git a/crates/trident/src/subsystems/osconfig/users.rs b/crates/trident/src/subsystems/osconfig/users.rs index c0d4d1046..4bba5247e 100644 --- a/crates/trident/src/subsystems/osconfig/users.rs +++ b/crates/trident/src/subsystems/osconfig/users.rs @@ -6,7 +6,7 @@ use std::{ use anyhow::{bail, Context, Error}; use log::{debug, warn}; -use osutils::osmodifier::{MICPassword, MICUser, PasswordType}; +use osmodifier::{MICPassword, MICUser, PasswordType}; use trident_api::config::{Password, SshMode, User}; const SSHD_CONFIG_FILE: &str = "/etc/ssh/sshd_config"; diff --git a/crates/trident_api/schemas/host-config-schema.json b/crates/trident_api/schemas/host-config-schema.json index df33fed61..0ccf65581 100644 --- a/crates/trident_api/schemas/host-config-schema.json +++ b/crates/trident_api/schemas/host-config-schema.json @@ -1368,7 +1368,7 @@ }, { "title": "Permissive", - "description": "Set SELinux to permissive. The mode is set by appending `selinux=1 enforcing=0` to the kernel command line.", + "description": "Set SELinux to permissive. The mode is set by appending `security=selinux selinux=1 enforcing=0` to the kernel command line and setting `SELINUX=permissive` in `/etc/selinux/config`.", "type": "string", "enum": [ "permissive" @@ -1376,7 +1376,7 @@ }, { "title": "Enforcing", - "description": "Set SELinux to enforcing. The mode is set by appending `selinux=1 enforcing=1` to the kernel command line.", + "description": "Set SELinux to enforcing. The mode is set by appending `security=selinux selinux=1` to the kernel command line and setting `SELINUX=enforcing` in `/etc/selinux/config`. The `security=selinux` arg tells the kernel which LSM to activate. The enforcing/permissive decision is controlled by the config file, allowing runtime changes via `setenforce`.", "type": "string", "enum": [ "enforcing" diff --git a/crates/trident_api/src/config/host/os/mod.rs b/crates/trident_api/src/config/host/os/mod.rs index d8b7923cb..3a56c39bd 100644 --- a/crates/trident_api/src/config/host/os/mod.rs +++ b/crates/trident_api/src/config/host/os/mod.rs @@ -124,14 +124,18 @@ pub enum SelinuxMode { /// # Permissive /// - /// Set SELinux to permissive. The mode is set by appending `selinux=1 - /// enforcing=0` to the kernel command line. + /// Set SELinux to permissive. The mode is set by appending `security=selinux + /// selinux=1 enforcing=0` to the kernel command line and setting + /// `SELINUX=permissive` in `/etc/selinux/config`. Permissive, /// # Enforcing /// - /// Set SELinux to enforcing. The mode is set by appending `selinux=1 - /// enforcing=1` to the kernel command line. + /// Set SELinux to enforcing. The mode is set by appending `security=selinux + /// selinux=1` to the kernel command line and setting `SELINUX=enforcing` in + /// `/etc/selinux/config`. The `security=selinux` arg tells the kernel which + /// LSM to activate. The enforcing/permissive decision is controlled by the + /// config file, allowing runtime changes via `setenforce`. Enforcing, } diff --git a/docs/Reference/Host-Configuration/API-Reference/SelinuxMode.md b/docs/Reference/Host-Configuration/API-Reference/SelinuxMode.md index 56b6a50c3..f2a02acf4 100644 --- a/docs/Reference/Host-Configuration/API-Reference/SelinuxMode.md +++ b/docs/Reference/Host-Configuration/API-Reference/SelinuxMode.md @@ -25,7 +25,7 @@ Set SELinux to disabled. The mode is set by appending `selinux=0` to the kernel ### Permissive -Set SELinux to permissive. The mode is set by appending `selinux=1 enforcing=0` to the kernel command line. +Set SELinux to permissive. The mode is set by appending `security=selinux selinux=1 enforcing=0` to the kernel command line and setting `SELINUX=permissive` in `/etc/selinux/config`. | Characteristic | Value | | -------------- | ------------ | @@ -34,7 +34,7 @@ Set SELinux to permissive. The mode is set by appending `selinux=1 enforcing=0` ### Enforcing -Set SELinux to enforcing. The mode is set by appending `selinux=1 enforcing=1` to the kernel command line. +Set SELinux to enforcing. The mode is set by appending `security=selinux selinux=1` to the kernel command line and setting `SELINUX=enforcing` in `/etc/selinux/config`. The `security=selinux` arg tells the kernel which LSM to activate. The enforcing/permissive decision is controlled by the config file, allowing runtime changes via `setenforce`. | Characteristic | Value | | -------------- | ----------- | diff --git a/docs/Reference/Host-Configuration/Storage-Rules.md b/docs/Reference/Host-Configuration/Storage-Rules.md index d67873b53..c39e7a146 100644 --- a/docs/Reference/Host-Configuration/Storage-Rules.md +++ b/docs/Reference/Host-Configuration/Storage-Rules.md @@ -239,21 +239,21 @@ The following table lists the expected mount points for each partition type, as defined in the [Discoverable Partition Specification (DPS)](https://uapi-group.org/specifications/specs/discoverable_partitions_specification/): -| Mount Path | Valid Mount Paths | -| ------------- | -------------------------------- | -| esp | `/boot` or `/efi` or `/boot/efi` | -| root | `/` | -| swap | None | -| root-verity | None | -| home | `/home` | -| var | `/var` | -| usr | `/usr` | -| usr-verity | None | -| tmp | `/var/tmp` | -| linux-generic | Any path | -| srv | `/srv` | -| xbootldr | `/boot` | -| unknown | Any path | +| Partition Type | Valid Mount Paths | +| --------------------------------------------- | -------------------------------- | +| esp | `/boot` or `/efi` or `/boot/efi` | +| root | `/` | +| swap | None | +| root-verity | None | +| home | `/home` | +| var | `/var` | +| usr | `/usr` | +| usr-verity | None | +| tmp | `/var/tmp` | +| linux-generic | Any path | +| srv | `/srv` | +| xbootldr | `/boot` | +| unknown(00000000-0000-0000-0000-000000000000) | Any path | ## Partition Type Matching Hash Partition diff --git a/packaging/docker/Dockerfile.azl3 b/packaging/docker/Dockerfile.azl3 index bca35ff22..d2356694f 100644 --- a/packaging/docker/Dockerfile.azl3 +++ b/packaging/docker/Dockerfile.azl3 @@ -10,7 +10,6 @@ COPY packaging/rpm/trident.spec . COPY packaging ./packaging COPY bin/trident ./target/release/trident COPY bin/trident-acl-agent ./target/release/trident-acl-agent -COPY artifacts/osmodifier /usr/src/azl/SOURCES/osmodifier ARG TRIDENT_VERSION=dev-build ARG RPM_VER=0.1.0 diff --git a/packaging/docker/Dockerfile.full b/packaging/docker/Dockerfile.full index 078ed812d..beef29797 100644 --- a/packaging/docker/Dockerfile.full +++ b/packaging/docker/Dockerfile.full @@ -8,7 +8,6 @@ WORKDIR /work COPY packaging/rpm/trident.spec . COPY packaging ./packaging -COPY artifacts/osmodifier /usr/src/azl/SOURCES/osmodifier COPY .cargo/config.toml ./.cargo/config.toml COPY Cargo.toml . diff --git a/packaging/docker/Dockerfile.full.public b/packaging/docker/Dockerfile.full.public index c95141552..c4a469bc4 100644 --- a/packaging/docker/Dockerfile.full.public +++ b/packaging/docker/Dockerfile.full.public @@ -8,7 +8,6 @@ WORKDIR /work COPY trident.spec . COPY packaging ./packaging -COPY artifacts/osmodifier /usr/src/azl/SOURCES/osmodifier COPY .cargo/config ./.cargo/config COPY Cargo.toml . diff --git a/packaging/docker/Dockerfile.runtime b/packaging/docker/Dockerfile.runtime index 514cc2104..7cd1fd3c1 100644 --- a/packaging/docker/Dockerfile.runtime +++ b/packaging/docker/Dockerfile.runtime @@ -22,9 +22,6 @@ RUN tdnf -y install \ RUN \ --mount=type=bind,source=./bin/RPMS,target=/trident \ - if [ -n "$(find /trident/x86_64 -name 'azurelinux-image-tools-osmodifier-*.rpm')" ]; then \ - tdnf install -y /trident/x86_64/azurelinux-image-tools-osmodifier-*.rpm; \ - fi && \ tdnf install -y \ /trident/x86_64/trident-0*.rpm && \ tdnf install -y \ diff --git a/packaging/rpm/trident.spec b/packaging/rpm/trident.spec index eec9158e3..8c37d04a9 100644 --- a/packaging/rpm/trident.spec +++ b/packaging/rpm/trident.spec @@ -34,8 +34,6 @@ Source0: https://github.com/microsoft/trident/archive/refs/tags/v%{versio # tar -czf %%{name}-%%{version}-cargo.tar.gz vendor/ # Source1: %{name}-%{version}-cargo.tar.gz -%else -Source1: osmodifier %endif BuildRequires: openssl-devel @@ -45,9 +43,8 @@ BuildRequires: systemd-units BuildRequires: rust %if %{undefined rpm_ver} -# For distro build, require cargo to build and osmodifier +# For distro build, require cargo to build BuildRequires: cargo -Requires: azurelinux-image-tools-osmodifier %endif Requires: e2fsprogs @@ -55,6 +52,8 @@ Requires: util-linux Requires: dosfstools Requires: efibootmgr Requires: lsof +Requires: openssl +Requires: shadow-utils Requires: systemd >= 255 Requires: systemd-udev Requires: (%{name}-selinux if selinux-policy-%{selinuxtype}) @@ -74,6 +73,8 @@ Suggests: veritysetup Suggests: ntfs-3g # For creating NTFS filesystems Suggests: ntfsprogs +# For GRUB boot configuration (os.kernelCommandLine, boot config) +Suggests: grub2-tools %description @@ -83,10 +84,6 @@ and its dependencies for managing the lifecycle of Azure Linux hosts. %files %{_bindir}/%{name} %dir /etc/%{name} -%if %{defined rpm_ver} -# For Trident repo build, package osmodifier included via `Source1` -%{_bindir}/osmodifier -%endif %{_unitdir}/%{name}d.service %{_unitdir}/%{name}d.socket @@ -283,11 +280,6 @@ cargo test --all --no-fail-fast -- --skip test_run_systemd_check --skip test_pre %endif %install -%if %{defined rpm_ver} -# For Trident repo build, package osmodifier included via `Source1`. -# Distro RPM will use distro osmodifier RPM via Requires directive. -install -D -m 755 %{SOURCE1} %{buildroot}%{_bindir}/osmodifier -%endif install -D -m 755 target/release/%{name} %{buildroot}/%{_bindir}/%{name} install -D -m 755 target/release/%{name}-acl-agent %{buildroot}/%{_bindir}/%{name}-acl-agent diff --git a/tests/functional_tests/conftest.py b/tests/functional_tests/conftest.py index bfaba0b0b..400b5ebec 100644 --- a/tests/functional_tests/conftest.py +++ b/tests/functional_tests/conftest.py @@ -33,9 +33,6 @@ FT_BASE_IMAGE = TRIDENT_REPO_DIR_PATH / "artifacts" / "trident-functest.qcow2" -"""Target location of the osmodifier binary in the test host.""" -OS_MODIFIER_BIN_TARGET_PATH = Path("/usr/bin/osmodifier") - def pytest_addoption(parser): """Defines additional command line options for the tests.""" @@ -72,13 +69,6 @@ def pytest_addoption(parser): help="Force upload of tests even if no change was detected.", ) - parser.addoption( - "--osmodifier", - help="Path to the osmodifier binary to copy into the test host.", - default=TRIDENT_REPO_DIR_PATH / "artifacts" / "osmodifier", - type=Path, - ) - def pytest_collect_file(file_path: Path, parent: Collector) -> Optional[Collector]: """Creates a custom collector for ft.json.""" @@ -355,12 +345,7 @@ def vm(request, ssh_key_path, known_hosts_path) -> SshNode: known_hosts_path=known_hosts_path, ) - # Upload OS modifier binary to the VM. - osmodifier_path = request.config.getoption("--osmodifier") - logging.info(f"Copying osmodifier from {osmodifier_path} to VM") - ssh_node.copy(osmodifier_path, Path("osmodifier")) - ssh_node.execute("chmod +x osmodifier") - ssh_node.execute(f"sudo mv osmodifier {OS_MODIFIER_BIN_TARGET_PATH}") + # OS modifier is now compiled into the trident binary — no separate upload needed. if build_output: upload_test_binaries(build_output, force_upload, ssh_node)