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;