Skip to main content

isoloom_render/
secrets.rs

1//! Secrets on a VM and on a Docker host. The generated set-up never holds a value: it waits
2//! for the runner (Isothrum's `isothrum up`) to deliver them over the machine's own connection
3//! (SSH), then puts each
4//! file in place. Delivery writes a root-only copy of each value under [`KEPT_DIR`] (it outlives
5//! a reboot, which empties `/run/secrets`) and the mark [`DELIVERED`] the set-up waits for.
6//!
7//! The order is the plan's `Secrets` step: after the machine's files, before what may read a
8//! secret (its `provision` steps, its `before` steps and its command). The set-up runs inside
9//! the target's own tool (`vagrant up`, `terraform apply`, cloud-init), so the step is a gate:
10//! it waits for the mark, and the runner delivers while the tool runs.
11
12use isoloom_plan::secrets::VM_DEFAULT_MODE;
13use isoloom_plan::{Environment, MachineShape, SecretFile};
14use isoloom_spec::model::{SecretSource, Spec, secret_variable};
15use isoloom_spec::shell::quote;
16
17use crate::boot;
18
19pub mod windows;
20
21/// Where a machine keeps the secrets delivered to it: root-only, on disk.
22pub const KEPT_DIR: &str = "/var/lib/isoloom/secrets";
23/// The mark delivery leaves once every secret is kept.
24pub const DELIVERED: &str = "/var/lib/isoloom/secrets/.delivered";
25/// Where a Terraform set-up that waits for its secrets says how to reach its machine, under
26/// its module: one file per machine, `<user>@<address>`. The runner reads it while Terraform
27/// runs (the module's outputs come only once every set-up is done) and delivers there.
28pub const REACH_DIR: &str = ".isoloom-reach";
29/// Seconds the set-up waits for the runner to deliver the secrets.
30pub const DELIVERY_TIMEOUT: u32 = 1800;
31/// The boot service putting the secrets back in place (`/run` is emptied at every boot). Its
32/// name sorts before `before` and `command`, the order OpenRC runs `/etc/local.d` in.
33pub const PLACE_SERVICE: &str = "apply-secrets";
34
35/// The script the runner runs as root on the machine. Its standard input holds one line per
36/// secret of `names`, in that order, each value in base64 (so any text fits on one line); the
37/// script keeps each one, marks the delivery, then puts `files` in place (so a run again
38/// refreshes them). Nothing of a value reaches its command line or its output.
39#[must_use]
40pub fn receive(names: &[String], files: &[SecretFile]) -> String {
41    let kept: String = names
42        .iter()
43        .map(|name| {
44            let kept = format!("{KEPT_DIR}/{name}");
45            format!("IFS= read -r v || [ -n \"$v\" ]; printf '%s\\n' \"$v\" | base64 -d > {kept}.new; mv -f {kept}.new {kept}\n")
46        })
47        .collect::<Vec<_>>()
48        .concat();
49    format!(
50        "set -e\numask 077\nmkdir -p {KEPT_DIR}\nchmod 700 {KEPT_DIR}\n{kept}unset v\ntouch {DELIVERED}\n{}",
51        place(files)
52    )
53}
54
55/// Each kept secret copied to its path, with its owner and mode (root and [`VM_DEFAULT_MODE`]
56/// unless the spec says), its folder made when missing.
57#[must_use]
58pub fn place(files: &[SecretFile]) -> String {
59    files
60        .iter()
61        .map(|f| {
62            format!(
63                "install -D -o {} -g {} -m {:04o} {KEPT_DIR}/{} {}\n",
64                f.uid.unwrap_or(0),
65                f.gid.unwrap_or(0),
66                f.mode.unwrap_or(VM_DEFAULT_MODE),
67                f.name,
68                quote(&f.path)
69            )
70        })
71        .collect::<Vec<_>>()
72        .concat()
73}
74
75/// Waits for the delivery mark, saying so once; fails after [`DELIVERY_TIMEOUT`] seconds with
76/// what to do. `who` names the machine in the messages.
77#[must_use]
78pub fn wait(who: &str) -> String {
79    let (waiting, missing) = messages(who);
80    let (waiting, missing) = (quote(&waiting), quote(&missing));
81    let tries = DELIVERY_TIMEOUT / 5;
82    format!(
83        "n=0; until [ -f {DELIVERED} ]; do [ \"$n\" -gt 0 ] || echo {waiting}; n=$((n+1)); if [ \"$n\" -gt {tries} ]; then echo {missing} >&2; exit 1; fi; sleep 5; done\n"
84    )
85}
86
87/// What the wait says while it waits, and when it gives up: who delivers (the runner; the
88/// target's own tool, Terraform, can't), and what to do.
89fn messages(who: &str) -> (String, String) {
90    (
91        format!("{who}: waiting for its secrets, which the runner (isothrum up) delivers"),
92        format!("{who}: its secrets never came; start the environment with the runner (isothrum up), which reads them from their stores and delivers them"),
93    )
94}
95
96/// A VM's `Secrets` step: the wait, then its files put in place now and at every boot.
97#[must_use]
98pub fn step(machine: &str, files: &[SecretFile]) -> String {
99    format!("set -e\n{}{}", wait(machine), placed(files))
100}
101
102/// The files put in place now and at every boot.
103fn placed(files: &[SecretFile]) -> String {
104    boot::at_boot(PLACE_SERVICE, "The machine's secrets, put in place", &place(files), boot::Trigger::Boot, &[])
105}
106
107/// A VM's `Secrets` step when the tool that runs it hands the values over itself (Vagrant's
108/// provisioner environment, filled from the host's when Vagrant runs): each of `values`, a
109/// secret's name and the variable holding its value in base64 (so a `$`, a quote or a line break
110/// in it is never read by the shell that sets the variable), kept as delivery keeps it and the
111/// delivery marked; then the files put in place now and at every boot. Nothing waits: the
112/// values are there. An empty or unset variable writes an empty value the first time, and keeps
113/// the value already kept after (a re-provision given no value again).
114#[must_use]
115pub fn from_environment(values: &[(String, String)], files: &[SecretFile]) -> String {
116    let kept = values
117        .iter()
118        .map(|(name, var)| {
119            let kept = format!("{KEPT_DIR}/{name}");
120            format!("v=\"$(printenv {var} || true)\"; if [ -n \"$v\" ] || [ ! -e {kept} ]; then printf '%s' \"$v\" | base64 -d > {kept}.new; mv -f {kept}.new {kept}; fi\n")
121        })
122        .collect::<Vec<_>>()
123        .concat();
124    format!(
125        "set -e\numask 077\nmkdir -p {KEPT_DIR}\nchmod 700 {KEPT_DIR}\n{kept}touch {DELIVERED}\n{}",
126        placed(files)
127    )
128}
129
130/// The variable the Compose file reads secret `name` from: an input's file, the input's own
131/// variable; a secret, `ISOLOOM_SECRET_<NAME>`.
132#[must_use]
133pub fn compose_variable(spec: &Spec, name: &str) -> String {
134    match spec.secrets.get(name) {
135        Some(SecretSource::Input { input }) => input.clone(),
136        _ => secret_variable(name),
137    }
138}
139
140/// On a Docker host, before `docker compose up`: the wait, then each secret of `names` in the
141/// variable the Compose file reads it from ([`compose_variable`]), its last line breaks kept.
142/// Nothing when there are none.
143#[must_use]
144pub fn host_environment(spec: &Spec, names: &[String]) -> String {
145    if names.is_empty() {
146        return String::new();
147    }
148    let read: String = names
149        .iter()
150        .map(|name| {
151            let var = compose_variable(spec, name);
152            format!("{var}=\"$(cat {KEPT_DIR}/{name}; printf x)\"; {var}=\"${{{var}%x}}\"; export {var}\n")
153        })
154        .collect::<Vec<_>>()
155        .concat();
156    wait("the Docker host") + &read
157}
158
159/// The secrets a Docker host gets: those its containers get, in the order the spec declares
160/// them.
161#[must_use]
162pub fn of_containers(env: &Environment) -> Vec<String> {
163    env.spec()
164        .secrets
165        .keys()
166        .filter(|name| {
167            env.machines
168                .values()
169                .any(|m| m.shape == MachineShape::Container && m.secrets.iter().any(|s| &s.name == *name))
170        })
171        .cloned()
172        .collect()
173}
174
175#[cfg(test)]
176mod tests;