git.delta.rocks / jrsonnet / refs/commits / d866c975e1c7

difftreelog

feat system build

Yaroslav Bolyukin2020-11-14.patch.diff
in: trunk

24 files changed

added.gitignorediffbeforeafterboth
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+target
+result
+example
addedCargo.lockdiffbeforeafterboth

no changes

addedCargo.tomldiffbeforeafterboth
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,23 @@
+[package]
+name = "fleet"
+description = "NixOS configuration management"
+version = "0.1.0"
+authors = ["Yaroslav Bolyukin <iam@lach.pw>"]
+edition = "2018"
+
+# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+
+[dependencies]
+anyhow = "1.0.34"
+clap = { version = "3.0.0-beta.2", features = ["derive", "suggestions", "color"] }
+log = "0.4.11"
+env_logger = "0.8.1"
+
+serde = { version = "1.0.117", features = ["derive"] }
+serde_json = "1.0.59"
+
+time = { version = "0.2.22", features = ["serde"] }
+
+lockfile = "0.2.2"
+toml = "0.5"
+tempfile = "3.1.0"
addedREADME.mddiffbeforeafterboth
--- /dev/null
+++ b/README.md
@@ -0,0 +1,8 @@
+# fleet
+
+Early prototype stage
+
+## Advantages over existing configuration systems (NixOps/Morph)
+
+- Modules can configure multiple hosts at once (I.e for wireguard/kubernetes installation)
+- Secrets can be securely stored in Git (No one except target hosts can decrypt them)
addedflake.lockdiffbeforeafterboth
--- /dev/null
+++ b/flake.lock
@@ -0,0 +1,26 @@
+{
+  "nodes": {
+    "nixpkgs": {
+      "locked": {
+        "lastModified": 1605344435,
+        "narHash": "sha256-Xx66M/eTwLc97sge6y210qMBZe2qwrpSqWagfEAOF0M=",
+        "owner": "nixos",
+        "repo": "nixpkgs",
+        "rev": "d67b00e8f0b378b1700e12f5b8e68c0706839c9a",
+        "type": "github"
+      },
+      "original": {
+        "owner": "nixos",
+        "repo": "nixpkgs",
+        "type": "github"
+      }
+    },
+    "root": {
+      "inputs": {
+        "nixpkgs": "nixpkgs"
+      }
+    }
+  },
+  "root": "root",
+  "version": 7
+}
addedflake.nixdiffbeforeafterboth
--- /dev/null
+++ b/flake.nix
@@ -0,0 +1,10 @@
+{
+  description = "NixOS configuration management";
+
+  inputs = {
+    nixpkgs.url = "github:nixos/nixpkgs";
+  };
+  outputs = { self, nixpkgs }: with nixpkgs.lib; rec {
+    lib = import ./lib;
+  };
+}
addedlib/default.nixdiffbeforeafterboth
--- /dev/null
+++ b/lib/default.nix
@@ -0,0 +1,45 @@
+{
+  fleetConfiguration = { common ? { modules = []; }, hosts, nixpkgs }@args:
+    rec {
+      root = nixpkgs.lib.evalModules {
+        modules = [
+          (
+            { ... }: {
+              config = {
+                inherit hosts;
+                # Secret data is available only via fleet build-systems
+                secrets = if builtins?getEnv then
+                  let
+                    stringData = builtins.getEnv "SECRET_DATA";
+                  in
+                    if stringData != "" then (builtins.fromJSON stringData) else {}
+                else {};
+              };
+
+            }
+          )
+        ] ++ common.modules ++ import ./modules/modules.nix {
+          pkgs = nixpkgs;
+          lib = nixpkgs.lib;
+        };
+
+        specialArgs = {
+          fleet = import ./lib/fleetLib.nix {
+            inherit nixpkgs hosts;
+          };
+        };
+      };
+      configuredHosts = root.config.hosts;
+      configuredSecrets = root.config.secrets;
+      configuredSystems = listToAttrs (
+        map (
+          name: {
+            inherit name; value = nixpkgs.lib.nixosSystem {
+            system = configuredHosts.${name}.system;
+            modules = configuredHosts.${name}.modules;
+          };
+          }
+        ) (builtins.attrNames hosts)
+      ); #nixpkgs.lib.nixosSystem {}
+    };
+}
addedlib/fleetLib.nixdiffbeforeafterboth
--- /dev/null
+++ b/lib/fleetLib.nix
@@ -0,0 +1,52 @@
+# Shared functions for fleet configuration, available as `fleet` module argument
+{ nixpkgs, hosts }: with nixpkgs.lib; rec {
+  mkSecret = let
+    system = builtins.currentSystem;
+    pkgs = import nixpkgs { inherit system; };
+    keys = builtins.getEnv "RAGE_KEYS";
+    encryptCmd = "rage ${keys} -a";
+    impuritySource = builtins.getEnv "IMPURITY_SOURCE";
+  in
+    f: let
+      data = f { inherit pkgs encryptCmd; };
+    in
+      builtins.derivation {
+        inherit system;
+        name = "secret";
+
+        builder = "${pkgs.bash}/bin/bash";
+        args = [
+          (
+            pkgs.writeTextFile {
+              name = "./build-${impuritySource}.sh";
+              text = data.script;
+              executable = true;
+            }
+          )
+        ];
+
+        PATH = "${pkgs.coreutils}/bin:${pkgs.rage}/bin${builtins.concatStringsSep "" (builtins.map (n: ":${n}/bin") data.utils)}";
+      };
+  # Modules can't register hosts because of infinite recursion
+  hostNames = attrNames hosts;
+  hostsToAttrs = f: listToAttrs (
+    map (name: { inherit name; value = f name; }) hostNames
+  );
+  hostsCartesian = remove null (
+    unique (
+      crossLists (
+        a: b: if a == b then
+          null
+        else
+          hostsPair a b
+      ) [ hostNames hostNames ]
+    )
+  );
+  hostsPair = this: other: let
+    sorted = sort (a: b: a < b) [ this other ];
+  in
+    {
+      a = elemAt sorted 0;
+      b = elemAt sorted 1;
+    };
+}
addedmodules/modules.nixdiffbeforeafterboth
--- /dev/null
+++ b/modules/modules.nix
@@ -0,0 +1,8 @@
+{ pkgs
+, lib
+, check ? true
+}:
+with lib; [
+  ./networking/wireguard
+  ./root.nix
+]
addedmodules/networking/wireguard/default.nixdiffbeforeafterboth
--- /dev/null
+++ b/modules/networking/wireguard/default.nix
@@ -0,0 +1,101 @@
+{ config, lib, nixpkgs, fleet, ... }: with lib; with fleet; let
+  cfg = config.networking.wireguard;
+  genWgKey = { owners }: {
+    inherit owners;
+    generator = mkSecret (
+      { pkgs, encryptCmd }: {
+        utils = [ pkgs.wireguard-tools ];
+        script = ''
+          key=$(wg genkey)
+          pub=$(echo $key | wg pubkey)
+
+          mkdir -p $out
+          echo $key | ${encryptCmd} >$out/key
+          echo $pub >$out/pub_key
+        '';
+      }
+    );
+  };
+  genWgPsk = { owners }: {
+    inherit owners;
+    generator = mkSecret (
+      { pkgs, encryptCmd }: {
+        utils = [ pkgs.wireguard-tools ];
+        script = ''
+          key=$(wg genpsk)
+
+          mkdir -p $out
+          echo $key | ${encryptCmd} >$out/key
+        '';
+      }
+    );
+  };
+
+  hostKeys = listToAttrs (
+    map (
+      hostName: {
+        name = "wg-key-${hostName}";
+        value = genWgKey {
+          owners = [ hostName ];
+        };
+      }
+    )
+      hostNames
+  );
+  psks = listToAttrs (
+    map (
+      { a, b }: {
+        name = "wg-psk-${a}-${b}";
+        value = genWgPsk {
+          owners = [ a b ];
+        };
+      }
+    )
+      hostsCartesian
+  );
+in
+{
+  options.networking.wireguard = with types; {
+    enable = mkEnableOption "wireguard";
+    interface = mkOption {
+      type = str;
+      description = "Interface name for wireguard network";
+      default = "fleet";
+    };
+    port = mkOption {
+      type = int;
+      description = "Port, on which wireguard interface should listen";
+      default = 51871;
+    };
+    allowedIPs = mkOption {
+      type = attrsOf (listOf str);
+      description = "Per host allowed ips";
+    };
+  };
+  config = mkIf cfg.enable {
+    secrets =
+      (hostKeys // psks);
+    hosts = hostsToAttrs (
+      hostName: {
+        modules = [
+          {
+            networking.wireguard.enable = true;
+            networking.wireguard.interfaces.fleetwg = {
+              privateKeyFile = "/run/secrets/wg-key-${hostName}";
+              peers = map (
+                peer: let
+                  pair = hostsPair hostName peer;
+                in
+                  {
+                    publicKey = config.secrets."wg-key-${peer}".data.key;
+                    presharedKey = "/run/secrets/wg-psk-${pair.a}-${pair.b}";
+                    allowedIPs = cfg.allowedIPs.${peer};
+                  }
+              ) hostNames;
+            };
+          }
+        ];
+      }
+    );
+  };
+}
addedmodules/networking/wireguard/wgbuilder.shdiffbeforeafterboth
--- /dev/null
+++ b/modules/networking/wireguard/wgbuilder.sh
@@ -0,0 +1,7 @@
+#!/bin/sh
+key=$($WG genkey)
+pub=$(echo $key | $WG pubkey)
+
+$COREUTILS/bin/mkdir -p $out
+echo $key | $RAGE $recipients >$out/key
+echo $pub >$out/pub_key
addedmodules/root.nixdiffbeforeafterboth
--- /dev/null
+++ b/modules/root.nix
@@ -0,0 +1,68 @@
+{ lib, ... }: with lib;
+let
+  secret = with types; {
+    options = {
+      owners = mkOption {
+        type = listOf str;
+        description = ''
+          List of hosts to encrypt secret for
+
+          Secrets would be decrypted and stored to /run/secrets/$\{name} on owners
+        '';
+      };
+      generator = mkOption {
+        type = types.package;
+        description = "Derivation to execute for secret generation";
+      };
+      expireIn = mkOption {
+        type = nullOr int;
+        description = "Time in hours, in which this secret should be regenerated";
+        default = null;
+      };
+      data = mkOption {
+        type = attrsOf anything;
+        description = "Generated secret data, do not set it yourself";
+        default = {};
+      };
+    };
+  };
+  host = with types; {
+    options = {
+      modules = mkOption {
+        type = listOf anything;
+        description = "List of nixos modules";
+        default = [];
+      };
+      network = mkOption {
+        type = submodule {
+          options = {
+            fleetIp = {
+              type = str;
+              description = "Ip which is available to all hosts in fleet";
+            };
+          };
+        };
+        description = "Network definition of host";
+      };
+      system = mkOption {
+        type = str;
+        description = "Type of system";
+      };
+    };
+  };
+in
+{
+  options = with types; {
+    hosts = mkOption {
+      type = attrsOf (submodule host);
+      default = {};
+      description = "Configurations of individual hosts";
+    };
+    secrets = mkOption {
+      type = attrsOf (submodule secret);
+      default = {};
+      description = "Secrets";
+    };
+  };
+  config = {};
+}
addedrustfmt.tomldiffbeforeafterboth
--- /dev/null
+++ b/rustfmt.toml
@@ -0,0 +1 @@
+hard_tabs = true
addedsrc/cmds/build_systems.rsdiffbeforeafterboth
--- /dev/null
+++ b/src/cmds/build_systems.rs
@@ -0,0 +1,32 @@
+use crate::{
+	db::{keys::list_hosts, secret::SecretDb, Db, DbData},
+	nix::{NixBuild, NixCopy, HOSTS_ATTRIBUTE, SYSTEMS_ATTRIBUTE},
+};
+use anyhow::Result;
+use clap::Clap;
+use log::info;
+
+#[derive(Clap)]
+pub struct BuildSystems {}
+
+impl BuildSystems {
+	pub fn run(self) -> Result<()> {
+		let db = Db::new(".fleet")?;
+		let hosts = list_hosts()?;
+		let data = SecretDb::open(&db)?.generate_nix_data()?;
+
+		for host in hosts.iter() {
+			info!("Building host {}", host);
+			let path = NixBuild::new(format!(
+				"{}.{}.config.system.build.toplevel",
+				SYSTEMS_ATTRIBUTE, host,
+			))
+			.env("SECRET_DATA".into(), data.clone())
+			.run()?;
+			info!("{:?}", path.path());
+			NixCopy::new(path.path().to_owned()).to(format!("ssh://root@{}", host))?;
+			std::thread::sleep_ms(9999999)
+		}
+		Ok(())
+	}
+}
addedsrc/cmds/fetch_keys.rsdiffbeforeafterboth
--- /dev/null
+++ b/src/cmds/fetch_keys.rs
@@ -0,0 +1,44 @@
+use crate::db::{
+	keys::{list_hosts, KeyDb},
+	Db, DbData,
+};
+use anyhow::Result;
+use clap::Clap;
+use log::info;
+
+#[derive(Clap)]
+pub struct FetchKeys {
+	/// Fetch if already exists the following hosts
+	#[clap(short = 'f', long)]
+	force_hosts: Vec<String>,
+	/// If true - remove orphaned keys
+	#[clap(long)]
+	cleanup: bool,
+}
+
+impl FetchKeys {
+	pub fn run(self) -> Result<()> {
+		let db = Db::new(".fleet")?;
+		let hosts = list_hosts()?;
+		let mut keys = KeyDb::open(&db)?;
+		for host in hosts.iter() {
+			let force = self.force_hosts.contains(&host);
+			keys.ensure_key_loaded(host, force)?;
+		}
+		let orphans: Vec<_> = hosts.iter().filter(|h| !keys.has_key(h)).cloned().collect();
+		if !orphans.is_empty() {
+			if self.cleanup {
+				info!("Removed orphan host keys:");
+			} else {
+				info!("Orphan host keys found, run with --cleanup to remove them from db:");
+			}
+			for key in orphans {
+				info!("- {}", key);
+				if self.cleanup {
+					keys.remove_key(&key)
+				}
+			}
+		}
+		Ok(())
+	}
+}
addedsrc/cmds/generate_secrets.rsdiffbeforeafterboth
--- /dev/null
+++ b/src/cmds/generate_secrets.rs
@@ -0,0 +1,51 @@
+use std::collections::HashSet;
+
+use anyhow::Result;
+use clap::Clap;
+use log::info;
+
+use crate::db::{
+	keys::KeyDb,
+	secret::{list_secrets, SecretDb},
+	Db, DbData,
+};
+
+#[derive(Clap)]
+pub struct GenerateSecrets {
+	/// If set - remove orphaned secrets
+	#[clap(long)]
+	cleanup: bool,
+}
+
+impl GenerateSecrets {
+	pub fn run(self) -> Result<()> {
+		let db = Db::new(".fleet")?;
+		let mut secrets = SecretDb::open(&db)?;
+
+		let defined_secrets = list_secrets()?;
+		for (secret, data) in defined_secrets.iter() {
+			let keys = KeyDb::open(&db)?;
+			secrets.ensure_generated(&keys, &secret, &data)?;
+		}
+		let key_names = defined_secrets
+			.keys()
+			.filter(|s| !secrets.has_secret(s))
+			.cloned()
+			.collect::<HashSet<_>>();
+		if !key_names.is_empty() {
+			if self.cleanup {
+				info!("Removed orphan secrets:");
+			} else {
+				info!("Orphan secrets found, run with --cleanup to remove them from db:");
+			}
+			for key in key_names {
+				info!("- {}", key);
+				if self.cleanup {
+					secrets.remove_secret(&key)
+				}
+			}
+		}
+
+		Ok(())
+	}
+}
addedsrc/cmds/mod.rsdiffbeforeafterboth
--- /dev/null
+++ b/src/cmds/mod.rs
@@ -0,0 +1,3 @@
+pub mod build_systems;
+pub mod fetch_keys;
+pub mod generate_secrets;
addedsrc/command.rsdiffbeforeafterboth
--- /dev/null
+++ b/src/command.rs
@@ -0,0 +1,34 @@
+use std::{
+	ffi::OsStr,
+	process::{Command, Stdio},
+};
+
+use anyhow::{Context, Result};
+use serde::Deserialize;
+
+pub struct CommandOutput(pub Vec<u8>);
+impl CommandOutput {
+	pub fn into_json<'d, T: Deserialize<'d>>(&'d self) -> Result<T> {
+		let str = self.as_str().ok();
+		Ok(serde_json::from_slice(&self.0).with_context(|| format!("{:?}", str))?)
+	}
+	pub fn as_str(&self) -> Result<&str> {
+		Ok(std::str::from_utf8(&self.0)?)
+	}
+}
+
+pub fn ssh_command<I, S>(host: impl AsRef<OsStr>, command: I) -> Result<CommandOutput>
+where
+	I: IntoIterator<Item = S>,
+	S: AsRef<OsStr>,
+{
+	let out = Command::new("ssh")
+		.stderr(Stdio::inherit())
+		.arg(host)
+		.args(command)
+		.output()?;
+	if !out.status.success() {
+		anyhow::bail!("command failed");
+	}
+	Ok(CommandOutput(out.stdout))
+}
addedsrc/db/db.rsdiffbeforeafterboth
--- /dev/null
+++ b/src/db/db.rs
@@ -0,0 +1,122 @@
+//! Small .toml based readable data store
+
+use anyhow::{Context, Result};
+use serde::{de::DeserializeOwned, Serialize};
+use std::{
+	cell::Cell,
+	collections::HashSet,
+	io::Write,
+	ops::{Deref, DerefMut},
+	path::Path,
+	path::PathBuf,
+	sync::{Arc, Mutex},
+};
+
+struct DbInternal {
+	root: PathBuf,
+	locked_paths: HashSet<PathBuf>,
+	_lockfile: lockfile::Lockfile,
+}
+
+pub trait DbData: DeserializeOwned + Serialize + Default {
+	const DB_NAME: &'static str;
+
+	fn open(db: &Db) -> Result<DbFile<Self>> {
+		db.db::<Self>()
+	}
+}
+
+#[derive(Clone)]
+pub struct Db(Arc<Mutex<DbInternal>>);
+impl Db {
+	pub fn new(root: impl AsRef<Path>) -> Result<Self> {
+		let root: &Path = root.as_ref();
+		std::fs::create_dir_all(&root).context("db root")?;
+		let mut lockfile = root.to_owned();
+		lockfile.push(".lock");
+		let lockfile = lockfile::Lockfile::create(lockfile).context("db lock")?;
+		Ok(Db(Arc::new(Mutex::new(DbInternal {
+			root: root.to_owned(),
+			locked_paths: HashSet::new(),
+			_lockfile: lockfile,
+		}))))
+	}
+
+	pub fn db<T: DbData>(&self) -> Result<DbFile<T>> {
+		let name = T::DB_NAME;
+		assert!(!name.contains("/") && !name.contains("\\"));
+		let mut db = self.0.lock().unwrap();
+		let mut data_path = db.root.clone();
+		data_path.push(format!("{}.toml", name));
+
+		if !db.locked_paths.insert(data_path.clone()) {
+			anyhow::bail!("file is already open");
+		}
+
+		let data = if data_path.exists() {
+			let raw_data = std::fs::read(&data_path).context("reading file")?;
+			toml::from_slice(&raw_data).context("parsing file")?
+		} else {
+			T::default()
+		};
+
+		Ok(DbFile {
+			db: self.clone(),
+			root: db.root.clone(),
+			path: data_path,
+			data,
+			dirty: Cell::new(false),
+		})
+	}
+}
+
+pub struct DbFile<T: DbData> {
+	db: Db,
+	root: PathBuf,
+	path: PathBuf,
+	data: T,
+	dirty: Cell<bool>,
+}
+
+impl<T: DbData> Deref for DbFile<T> {
+	type Target = T;
+
+	fn deref(&self) -> &Self::Target {
+		&self.data
+	}
+}
+
+impl<T: DbData> DerefMut for DbFile<T> {
+	fn deref_mut(&mut self) -> &mut Self::Target {
+		self.dirty.set(true);
+		&mut self.data
+	}
+}
+
+impl<T: DbData> DbFile<T> {
+	pub fn write(&self) -> Result<()> {
+		if !self.dirty.get() {
+			return Ok(());
+		}
+		let mut temp = tempfile::Builder::new()
+			.prefix("~")
+			.suffix(".toml")
+			.tempfile_in(&self.root)?;
+		let mut out = String::new();
+		let mut serializer = toml::Serializer::new(&mut out);
+		serializer.pretty_array(true).pretty_string(true);
+		self.data.serialize(&mut serializer)?;
+		temp.write_all(&out.as_bytes())?;
+		temp.persist(&self.path)?;
+		self.dirty.set(false);
+		Ok(())
+	}
+}
+
+impl<T: DbData> Drop for DbFile<T> {
+	fn drop(&mut self) {
+		let mut db = self.db.0.lock().unwrap();
+		self.write().unwrap();
+		db.locked_paths.remove(&self.path);
+	}
+}
addedsrc/db/keys.rsdiffbeforeafterboth
--- /dev/null
+++ b/src/db/keys.rs
@@ -0,0 +1,62 @@
+use std::collections::BTreeMap;
+
+use anyhow::Result;
+use log::*;
+
+use crate::{
+	command::ssh_command,
+	nix::{NixEval, HOSTS_ATTRIBUTE},
+};
+
+use serde::{Deserialize, Serialize};
+
+use super::db::DbData;
+
+pub fn list_hosts() -> Result<Vec<String>> {
+	Ok(NixEval::new(HOSTS_ATTRIBUTE.into())
+		.apply("builtins.attrNames".into())
+		.run_json()?)
+}
+
+#[derive(Serialize, Deserialize, Default)]
+pub struct KeyDb {
+	host_keys: BTreeMap<String, String>,
+}
+impl DbData for KeyDb {
+	const DB_NAME: &'static str = "keys";
+}
+
+impl KeyDb {
+	pub fn fetch_key(&mut self, host: &str) -> Result<()> {
+		info!("Fetching key for {}", host);
+		let key = ssh_command(host, &["cat", "/etc/ssh/ssh_host_ed25519_key.pub"])?
+			.as_str()?
+			.trim()
+			.to_owned();
+		self.host_keys.insert(host.to_owned(), key);
+		Ok(())
+	}
+
+	pub fn ensure_key_loaded(&mut self, host: &str, force: bool) -> Result<()> {
+		if !self.host_keys.contains_key(host) || force {
+			self.fetch_key(host)?;
+		}
+		Ok(())
+	}
+
+	pub fn get_host_key(&self, host: &str) -> Result<String> {
+		Ok(self
+			.host_keys
+			.get(host)
+			.ok_or_else(|| anyhow::anyhow!("no host key for {}", host))?
+			.to_owned())
+	}
+
+	pub fn has_key(&self, key: &str) -> bool {
+		self.host_keys.contains_key(key)
+	}
+
+	pub fn remove_key(&mut self, host: &str) {
+		self.host_keys.remove(host);
+	}
+}
addedsrc/db/mod.rsdiffbeforeafterboth
--- /dev/null
+++ b/src/db/mod.rs
@@ -0,0 +1,5 @@
+mod db;
+pub mod keys;
+pub mod secret;
+
+pub use db::*;
addedsrc/db/secret.rsdiffbeforeafterboth
--- /dev/null
+++ b/src/db/secret.rs
@@ -0,0 +1,211 @@
+use crate::nix::{NixBuild, NixEval, SECRETS_ATTRIBUTE};
+use anyhow::{bail, Result};
+use log::info;
+use serde::{Deserialize, Deserializer, Serialize, Serializer};
+use std::{
+	collections::{BTreeMap, BTreeSet, HashMap},
+	time::Instant,
+	time::SystemTime,
+};
+use time::{Duration, PrimitiveDateTime};
+
+use super::{db::DbData, keys::KeyDb};
+
+#[derive(Serialize, Deserialize, Debug)]
+pub struct SecretListData {
+	pub owners: BTreeSet<String>,
+	#[serde(rename = "expireIn")]
+	renew_in: Option<u64>,
+}
+pub fn list_secrets() -> Result<HashMap<String, SecretListData>> {
+	NixEval::new(format!("{}", SECRETS_ATTRIBUTE))
+		.apply(
+			r#"
+				s: (builtins.mapAttrs (n: {owners, expireIn, ...}: {
+					inherit owners expireIn;
+				}) s)
+			"#
+			.into(),
+		)
+		.run_json()
+}
+
+struct ReadableDate(PrimitiveDateTime);
+impl Serialize for ReadableDate {
+	fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+	where
+		S: Serializer,
+	{
+		serializer.serialize_str(&self.0.to_string())
+	}
+}
+impl<'de> Deserialize<'de> for ReadableDate {
+	fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+	where
+		D: Deserializer<'de>,
+	{
+		Ok(Self(
+			PrimitiveDateTime::parse(String::deserialize(deserializer)?, "%F %T").unwrap(),
+		))
+	}
+}
+impl From<PrimitiveDateTime> for ReadableDate {
+	fn from(d: PrimitiveDateTime) -> Self {
+		Self(d)
+	}
+}
+impl From<ReadableDate> for PrimitiveDateTime {
+	fn from(d: ReadableDate) -> Self {
+		d.0
+	}
+}
+
+#[derive(serde::Serialize, serde::Deserialize)]
+struct SecretData {
+	created_at: ReadableDate,
+	renew_at: Option<ReadableDate>,
+	owners: BTreeSet<String>,
+
+	public_data: BTreeMap<String, String>,
+	private_files: BTreeMap<String, String>,
+}
+impl SecretData {
+	fn should_renew(&self) -> bool {
+		if let Some(renew_at) = &self.renew_at {
+			let now: PrimitiveDateTime = SystemTime::now().into();
+			renew_at.0 <= now
+		} else {
+			false
+		}
+	}
+	fn is_valid(&self, data: &SecretListData) -> bool {
+		self.owners == data.owners
+	}
+}
+
+#[derive(serde::Serialize, serde::Deserialize)]
+struct NixDataValue {
+	data: BTreeMap<String, String>,
+}
+
+#[derive(serde::Serialize, serde::Deserialize)]
+struct NixData {
+	secrets: BTreeMap<String, NixDataValue>,
+}
+
+#[derive(serde::Serialize, serde::Deserialize, Default)]
+pub struct SecretDb {
+	secrets: BTreeMap<String, SecretData>,
+}
+impl DbData for SecretDb {
+	const DB_NAME: &'static str = "secrets";
+}
+
+impl SecretDb {
+	// Secrets are generated on machine running fleet command
+	pub fn generate_secret(
+		&mut self,
+		keys: &KeyDb,
+		secret: &str,
+		data: &SecretListData,
+	) -> Result<()> {
+		let mut rage_keys = String::new();
+		for (i, owner) in data.owners.iter().enumerate() {
+			if i != 0 {
+				rage_keys.push(' ');
+			}
+			rage_keys.push_str("--recipient \"");
+			rage_keys.push_str(&keys.get_host_key(&owner)?);
+			rage_keys.push('"')
+		}
+		let created_at: PrimitiveDateTime = SystemTime::now().into();
+		let renew_at = data
+			.renew_in
+			.map(|hours| created_at + Duration::hours(hours as i64));
+		let built = NixBuild::new(format!("{}.{}.generator", SECRETS_ATTRIBUTE, secret))
+			.env("RAGE_KEYS".into(), rage_keys)
+			.env("IMPURITY_SOURCE".into(), format!("{:?}", Instant::now()))
+			.run()?;
+		let path = built.path().to_owned();
+		let mut secret_data = SecretData {
+			created_at: created_at.into(),
+			renew_at: renew_at.map(|v| v.into()),
+			owners: data.owners.clone(),
+			public_data: BTreeMap::new(),
+			private_files: BTreeMap::new(),
+		};
+		for file in std::fs::read_dir(path)? {
+			let entry = file?;
+			if !entry.file_type()?.is_file() {
+				bail!("Secret generator should produce files, not directories");
+			}
+			let name = entry.file_name();
+			let name = name
+				.to_str()
+				.ok_or(anyhow::anyhow!("file name should be utf-8"))?;
+			let value = String::from_utf8(std::fs::read(entry.path())?)?;
+			if let Some(name) = name.strip_prefix("pub_") {
+				secret_data.public_data.insert(name.into(), value);
+			} else {
+				secret_data.private_files.insert(name.into(), value);
+			}
+		}
+		self.secrets.insert(secret.into(), secret_data);
+		Ok(())
+	}
+	pub fn need_to_generate(&self, secret: &str, data: &SecretListData) -> Result<bool> {
+		let secret = self.secrets.get(secret);
+		if secret.is_none() {
+			return Ok(true);
+		}
+		let secret = secret.unwrap();
+
+		if secret.should_renew() {
+			return Ok(true);
+		}
+
+		if !secret.is_valid(&data) {
+			return Ok(true);
+		}
+
+		Ok(false)
+	}
+	pub fn ensure_generated(
+		&mut self,
+		keys: &KeyDb,
+		secret: &str,
+		data: &SecretListData,
+	) -> Result<()> {
+		if self.need_to_generate(secret, data)? {
+			info!("Generating secret {}", secret);
+			self.generate_secret(keys, secret, data)?;
+		}
+
+		Ok(())
+	}
+	pub fn generate_nix_data(&self) -> Result<String> {
+		let mut out = BTreeMap::new();
+		for (host, secrets) in &self.secrets {
+			out.insert(
+				host.to_owned(),
+				NixDataValue {
+					data: secrets
+						.public_data
+						.clone()
+						.iter()
+						.map(|(k, v)| (k.to_owned(), v.trim().to_owned()))
+						.collect(),
+				},
+			);
+		}
+		Ok(serde_json::to_string(&out)?)
+	}
+
+	pub fn has_secret(&self, secret: &str) -> bool {
+		self.secrets.contains_key(secret)
+	}
+
+	pub fn remove_secret(&mut self, secret: &str) {
+		self.secrets.remove(secret);
+	}
+}
addedsrc/main.rsdiffbeforeafterboth
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,34 @@
+pub mod command;
+
+pub mod cmds;
+pub mod db;
+pub mod nix;
+
+use anyhow::Result;
+use clap::Clap;
+use cmds::{build_systems::BuildSystems, fetch_keys::FetchKeys, generate_secrets::GenerateSecrets};
+
+#[derive(Clap)]
+#[clap(version = "1.0", author = "CertainLach <iam@lach.pw>")]
+enum Opts {
+	/// Fetch encryption (ssh) public keys from remote hosts
+	FetchKeys(FetchKeys),
+	/// Force generation of missing secrets
+	GenerateSecrets(GenerateSecrets),
+	/// Prepare systems for deployments
+	BuildSystems(BuildSystems),
+}
+
+fn main() -> Result<()> {
+	env_logger::Builder::new()
+		.filter_level(log::LevelFilter::Info)
+		.init();
+	let opts = Opts::parse();
+
+	match opts {
+		Opts::FetchKeys(c) => c.run()?,
+		Opts::BuildSystems(c) => c.run()?,
+		Opts::GenerateSecrets(c) => c.run()?,
+	};
+	Ok(())
+}
addedsrc/nix.rsdiffbeforeafterboth
--- /dev/null
+++ b/src/nix.rs
@@ -0,0 +1,172 @@
+use std::{
+	collections::HashMap,
+	ffi::OsStr,
+	path::PathBuf,
+	process::{Command, Stdio},
+};
+
+use anyhow::Result;
+use serde::de::DeserializeOwned;
+
+use crate::command::CommandOutput;
+
+pub const HOSTS_ATTRIBUTE: &str = ".#fleetConfigurations.default.configuredHosts";
+pub const SECRETS_ATTRIBUTE: &str = ".#fleetConfigurations.default.configuredSecrets";
+pub const SYSTEMS_ATTRIBUTE: &str = ".#fleetConfigurations.default.configuredSystems";
+
+pub struct NixCopy {
+	closure: PathBuf,
+}
+impl NixCopy {
+	pub fn new(closure: PathBuf) -> Self {
+		Self { closure }
+	}
+	fn run_internal(&self, f: impl Fn(&mut Command)) -> Result<CommandOutput> {
+		let mut cmd = Command::new("nix");
+		cmd.stderr(Stdio::inherit())
+			.arg("copy")
+			.arg("--substitute-on-destination")
+			.arg(&self.closure);
+		f(&mut cmd);
+
+		let out = cmd.output()?;
+		if !out.status.success() {
+			anyhow::bail!("nix copy failed");
+		}
+		Ok(CommandOutput(out.stdout))
+	}
+	pub fn from(&self, from: impl AsRef<OsStr>) -> Result<()> {
+		let from = from.as_ref();
+		self.run_internal(|cmd| {
+			cmd.arg("--from").arg(from);
+		})?;
+		Ok(())
+	}
+	pub fn to(&self, to: impl AsRef<OsStr>) -> Result<()> {
+		let to = to.as_ref();
+		self.run_internal(|cmd| {
+			cmd.arg("--to").arg(to);
+		})?;
+		Ok(())
+	}
+}
+
+pub struct NixBuild {
+	attribute: String,
+	impure: bool,
+	env: HashMap<String, String>,
+}
+
+impl NixBuild {
+	pub fn new(attribute: String) -> Self {
+		Self {
+			attribute,
+			impure: false,
+			env: HashMap::new(),
+		}
+	}
+	pub fn env(&mut self, name: String, value: String) -> &mut Self {
+		self.impure = true;
+		self.env.insert(name, value);
+		self
+	}
+	pub fn run(&self) -> Result<tempfile::TempDir> {
+		let dir = tempfile::tempdir()?;
+		std::fs::remove_dir(dir.path())?;
+		let mut cmd = Command::new("nix");
+		cmd.stderr(Stdio::inherit())
+			.arg("build")
+			.arg(&self.attribute)
+			.arg("--no-link")
+			.arg("--out-link")
+			.arg(dir.path());
+		if self.impure {
+			cmd.arg("--impure");
+		}
+		if !self.env.is_empty() {
+			cmd.envs(&self.env);
+		}
+
+		let out = cmd.output()?;
+		if !out.status.success() {
+			anyhow::bail!("nix eval failed");
+		}
+		Ok(dir)
+	}
+}
+
+#[derive(Default)]
+pub struct NixEval {
+	attribute: String,
+	impure: bool,
+	apply: Option<String>,
+	env: HashMap<String, String>,
+}
+
+impl NixEval {
+	pub fn new(attribute: String) -> Self {
+		Self {
+			attribute,
+			..Default::default()
+		}
+	}
+	pub fn impure(&mut self) -> &mut Self {
+		self.impure = true;
+		self
+	}
+	/// This is the only and impure way to pass something to flake
+	/// - https://github.com/NixOS/nix/issues/3949
+	/// - https://github.com/NixOS/nixpkgs/issues/101101
+	pub fn env(&mut self, name: String, value: String) -> &mut Self {
+		self.impure = true;
+		self.env.insert(name, value);
+		self
+	}
+	pub fn apply(&mut self, apply: String) -> &mut Self {
+		self.apply = Some(apply);
+		self
+	}
+	fn run_internal(&self, f: impl Fn(&mut Command)) -> Result<CommandOutput> {
+		let mut cmd = Command::new("nix");
+		cmd.stderr(Stdio::inherit())
+			.arg("eval")
+			.arg("--show-trace")
+			.arg(&self.attribute);
+		if let Some(apply) = &self.apply {
+			cmd.arg("--apply").arg(apply);
+		};
+		if self.impure {
+			cmd.arg("--impure");
+		}
+		if !self.env.is_empty() {
+			cmd.envs(&self.env);
+		}
+		f(&mut cmd);
+
+		let out = cmd.output()?;
+		if !out.status.success() {
+			anyhow::bail!("nix eval failed");
+		}
+		Ok(CommandOutput(out.stdout))
+	}
+	pub fn run(&self) -> Result<String> {
+		Ok(self.run_internal(|_cmd| {})?.as_str()?.to_owned())
+	}
+	pub fn run_json<T: DeserializeOwned>(&self) -> Result<T> {
+		Ok(serde_json::from_slice(
+			&self
+				.run_internal(|cmd| {
+					cmd.arg("--json");
+				})?
+				.0,
+		)?)
+	}
+	pub fn run_raw(&self) -> Result<String> {
+		Ok(self
+			.run_internal(|cmd| {
+				cmd.arg("--raw");
+			})?
+			.as_str()?
+			.to_owned())
+	}
+}