difftreelog
feat system build
in: trunk
24 files changed
.gitignorediffbeforeafterboth--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+target
+result
+example
Cargo.lockdiffbeforeafterbothno changes
Cargo.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"
README.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)
flake.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
+}
flake.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;
+ };
+}
lib/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 {}
+ };
+}
lib/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;
+ };
+}
modules/modules.nixdiffbeforeafterboth--- /dev/null
+++ b/modules/modules.nix
@@ -0,0 +1,8 @@
+{ pkgs
+, lib
+, check ? true
+}:
+with lib; [
+ ./networking/wireguard
+ ./root.nix
+]
modules/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;
+ };
+ }
+ ];
+ }
+ );
+ };
+}
modules/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
modules/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 = {};
+}
rustfmt.tomldiffbeforeafterboth--- /dev/null
+++ b/rustfmt.toml
@@ -0,0 +1 @@
+hard_tabs = true
src/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(())
+ }
+}
src/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(())
+ }
+}
src/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(())
+ }
+}
src/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;
src/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))
+}
src/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);
+ }
+}
src/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);
+ }
+}
src/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::*;
src/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);
+ }
+}
src/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(())
+}
src/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())
+ }
+}