8 Commits

Author SHA1 Message Date
nikkuss 902e05cd06 fix yaml 2026-06-06 02:01:57 +04:00
nikkuss 650c6724f0 flip host and user 2026-06-06 01:14:08 +04:00
nikkuss 5af0faa0ee rework ui 2026-06-06 01:01:09 +04:00
nikkuss 9a385176cb rework 2026-06-06 00:53:26 +04:00
nikkuss 90eca4e469 fix manifest 2026-06-06 00:02:06 +04:00
nikkuss d8f2997e59 fix paths 2026-06-05 23:55:08 +04:00
nikkuss a69a7fcfeb cleanup flake 2026-06-05 23:51:30 +04:00
nikkuss 62dc2e6499 home-manager 2026-06-05 23:14:20 +04:00
5 changed files with 201 additions and 88 deletions
+32 -25
View File
@@ -1,5 +1,5 @@
{ {
description = "A very basic flake"; description = "sops-tui a TUI for managing SOPS-encrypted secrets";
inputs = { inputs = {
nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable"; nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable";
@@ -45,28 +45,23 @@
} }
); );
src = craneLib.cleanCargoSource ./.; src = craneLib.cleanCargoSource ./.;
commonArgs = { commonArgs = {
inherit src; inherit src;
strictDeps = true; strictDeps = true;
}; };
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
individualCrateArgs = commonArgs // {
inherit cargoArtifacts;
inherit (craneLib.crateNameFromCargoToml { inherit src; }) version;
};
fileSetForCrate = cargoArtifacts = craneLib.buildDepsOnly commonArgs;
crate:
lib.fileset.toSource { sops-tui = craneLib.buildPackage (
root = ./.; commonArgs
fileset = lib.fileset.unions [
./Cargo.toml
./Cargo.lock
];
};
server = craneLib.buildPackage (
individualCrateArgs
// { // {
inherit cargoArtifacts;
meta = {
description = "TUI for managing SOPS-encrypted secrets in NixOS/sops-nix workflows";
mainProgram = "sops-tui";
platforms = lib.platforms.unix;
};
} }
); );
in in
@@ -75,15 +70,27 @@
inherit system; inherit system;
overlays = [ inputs.rust-overlay.overlays.default ]; overlays = [ inputs.rust-overlay.overlays.default ];
}; };
packages = {
default = sops-tui;
inherit sops-tui;
};
checks = {
inherit sops-tui;
sops-tui-clippy = craneLib.cargoClippy (
commonArgs
// {
inherit cargoArtifacts;
cargoClippyExtraArgs = "--all-targets";
}
);
sops-tui-fmt = craneLib.cargoFmt { inherit src; };
};
devShells.default = craneLib.devShell { devShells.default = craneLib.devShell {
packages = with pkgs; [ inputsFrom = [ sops-tui ];
mold packages = with pkgs; [ sops ];
llvmPackages.clang
llvmPackages.lld
sea-orm-cli
watchexec
pnpm
];
}; };
}; };
}; };
+104 -26
View File
@@ -1,7 +1,6 @@
{ {
config, config,
lib, lib,
flake-parts-lib,
inputs, inputs,
den, den,
... ...
@@ -39,51 +38,81 @@ in
description = "A function that takes the NixOS configuration and returns a NixOS module to apply to the host based on its network configuration."; description = "A function that takes the NixOS configuration and returns a NixOS module to apply to the host based on its network configuration.";
}; };
}; };
homeManagerModule = {
default = mkOption {
type = types.functionTo types.attrs;
default = identity: { };
};
};
}; };
config = config =
let let
secrets = builtins.fromJSON (builtins.readFile "${inputs.self}/${cfg.secretsDir}/secrets.json"); secrets = builtins.fromJSON (builtins.readFile "${inputs.self}/${cfg.secretsDir}/secrets.json");
all_keys = lib.flatten (lib.concatAttrValues per_host_keys);
per_host_keys = lib.mergeAttrsList ( toKeyList =
lib.flatten ( v:
map ( if builtins.isString v then
x:
builtins.mapAttrs (
name: value:
let
v = value.sopsPublic or [ ];
type = builtins.typeOf v;
vv =
if type == "string" then
[ v ] [ v ]
else if type == "list" then else if builtins.isList v then
v v
else else
throw "Unexpected type ${type} for sopsPublic in host ${name}"; throw "Unexpected type ${builtins.typeOf v} for sopsPublic";
in
vv # Every host across every system: hostName -> [host pubkeys].
) x host_keys = lib.mergeAttrsList (
lib.flatten (
map (
perSystem:
lib.mapAttrsToList (_: host: { ${host.hostName} = toKeyList (host.sopsPublic or [ ]); }) perSystem
) (builtins.attrValues den.hosts) ) (builtins.attrValues den.hosts)
) )
); );
# Every user on every host is a home identity "<userName>@<hostName>".
home_keys = lib.mergeAttrsList (
lib.flatten (
map (
perSystem:
lib.mapAttrsToList (
_: host:
lib.mapAttrsToList (_: user: {
"${user.userName}@${host.hostName}" = toKeyList (user.sopsPublic or [ ]);
}) (host.users or { })
) perSystem
) (builtins.attrValues den.hosts)
)
);
# Replaces the old hand-maintained `homeIdentities` option.
homeIdentities = builtins.attrNames home_keys;
# A secret's `hosts` may target either a host or a home identity.
identity_keys = host_keys // home_keys;
all_host_keys = lib.flatten (lib.attrValues host_keys);
all_home_keys = lib.flatten (lib.attrValues home_keys);
secret_map = lib.mapAttrs ( secret_map = lib.mapAttrs (
name: value: name: value:
let let
sopskeys = lib.unique ( sopskeys = lib.unique (
lib.flatten (map (k: per_host_keys.${k}) value.hosts) lib.flatten (map (k: identity_keys.${k}) value.hosts)
++ (lib.optionals value.global all_keys) ++ (lib.optionals value.globalHosts all_host_keys)
++ (lib.optionals value.globalHomes all_home_keys)
++ cfg.masterKeys ++ cfg.masterKeys
); );
# Descriptive flag: is this secret consumed by a home-manager user?
# True when it is global to homes or targets any home identity.
home = value.globalHomes || lib.any (h: lib.elem h homeIdentities) value.hosts;
in in
{ {
inherit (value) inherit (value)
format format
neededForUsers neededForUsers
hosts hosts
global globalHosts
globalHomes
; ;
keys = value.keys or [ ]; keys = value.keys or [ ];
inherit sopskeys; inherit sopskeys home;
} }
) secrets; ) secrets;
@@ -98,7 +127,7 @@ in
lib.mapAttrsToList ( lib.mapAttrsToList (
name: value: name: value:
let let
hasHost = (lib.elem host value.hosts) || value.global; hasHost = (lib.elem host value.hosts) || value.globalHosts;
isYamlOrJson = value.format == "yaml" || value.format == "json"; isYamlOrJson = value.format == "yaml" || value.format == "json";
in in
( (
@@ -110,7 +139,8 @@ in
${name} = { ${name} = {
inherit (value) format neededForUsers; inherit (value) format neededForUsers;
sopsFile = inputs.self + "/${cfg.secretsDir}/${name}"; sopsFile = inputs.self + "/${cfg.secretsDir}/${name}";
}; }
// lib.optionalAttrs (isYamlOrJson && value.keys == [ ]) { key = ""; };
} }
else else
{ } { }
@@ -130,14 +160,57 @@ in
) secret_map ) secret_map
) )
); );
# Home-manager analog of sops_secrets_map. Takes a key identity (e.g.
# `laptop-home`) and deliberately omits `neededForUsers`, which the
# sops-nix home-manager module does not support (it only matters for
# decrypting before system users exist, a NixOS-only concern).
home_secrets_map =
identity:
lib.mkMerge (
lib.concatLists (
lib.mapAttrsToList (
name: value:
let
hasHost = (lib.elem identity value.hosts) || value.globalHomes;
isYamlOrJson = value.format == "yaml" || value.format == "json";
in
(
[
(
if hasHost && !(isYamlOrJson && value.keys != [ ]) then
{
${name} = {
inherit (value) format;
sopsFile = inputs.self + "/${cfg.secretsDir}/${name}";
}
// lib.optionalAttrs (isYamlOrJson && value.keys == [ ]) { key = ""; };
}
else
{ }
)
]
++ lib.optionals hasHost (
lib.map (v: {
"${name}-${v}" = {
inherit (value) format;
sopsFile = inputs.self + "/${cfg.secretsDir}/${name}";
key = v;
};
}) value.keys
)
)
) secret_map
)
);
in in
{ {
flake.secretsManifest = { flake.secretsManifest = {
secretsDir = cfg.secretsDir; secretsDir = cfg.secretsDir;
masterKeys = cfg.masterKeys; masterKeys = cfg.masterKeys;
hosts = lib.mapAttrs (_: keys: { inherit keys; }) per_host_keys; hosts = lib.mapAttrs (_: keys: { inherit keys; }) host_keys;
inherit homeIdentities;
secrets = secret_map; secrets = secret_map;
secrets_map = sops_secrets_map;
}; };
secrets.nixosModule = { secrets.nixosModule = {
default = host: { default = host: {
@@ -151,6 +224,11 @@ in
config.sops.secrets = sops_secrets_map config.networking.hostName; config.sops.secrets = sops_secrets_map config.networking.hostName;
}; };
}; };
secrets.homeManagerModule = {
default = identity: {
config.sops.secrets = home_secrets_map identity;
};
};
perSystem = perSystem =
{ pkgs, self', ... }: { pkgs, self', ... }:
let let
+3 -4
View File
@@ -1,12 +1,10 @@
use color_eyre::Report; use color_eyre::Report;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::{prelude::CrosstermBackend, widgets::ListState, DefaultTerminal, Terminal}; use ratatui::{prelude::CrosstermBackend, widgets::ListState, Terminal};
use ratatui_form::{Email, Form};
use std::{io, path::Path}; use std::{io, path::Path};
use crate::{ use crate::{
event::{AppEvent, Event, EventHandler}, event::{AppEvent, Event, EventHandler},
form::FormState,
manifest::{load_manifest, Manifest}, manifest::{load_manifest, Manifest},
}; };
@@ -34,7 +32,7 @@ impl App {
mut self, mut self,
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
) -> color_eyre::Result<()> { ) -> color_eyre::Result<()> {
let project_root = Path::new("/home/nikkuss/dotfiles-new"); let project_root = std::env::current_dir()?;
self.events.send(AppEvent::LoadManifest); self.events.send(AppEvent::LoadManifest);
while self.running { while self.running {
@@ -53,6 +51,7 @@ impl App {
AppEvent::LoadManifest => { AppEvent::LoadManifest => {
if !self.isloadingmanifest { if !self.isloadingmanifest {
let tx = self.events.clone_sender(); let tx = self.events.clone_sender();
let project_root = project_root.clone();
tokio::spawn(async move { tokio::spawn(async move {
let result = load_manifest(&project_root).await; let result = load_manifest(&project_root).await;
let _ = tx.send(Event::App(AppEvent::ManifestLoaded(result))); let _ = tx.send(Event::App(AppEvent::ManifestLoaded(result)));
+9 -1
View File
@@ -11,6 +11,8 @@ pub struct Manifest {
#[serde(rename = "masterKeys")] #[serde(rename = "masterKeys")]
pub master_keys: Vec<String>, pub master_keys: Vec<String>,
pub hosts: BTreeMap<String, HostInfo>, pub hosts: BTreeMap<String, HostInfo>,
#[serde(rename = "homeIdentities", default)]
pub home_identities: Vec<String>,
pub secrets: BTreeMap<String, SecretInfo>, pub secrets: BTreeMap<String, SecretInfo>,
} }
@@ -23,11 +25,17 @@ pub struct HostInfo {
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
pub struct SecretInfo { pub struct SecretInfo {
pub format: SecretFormat, pub format: SecretFormat,
pub global: bool, #[serde(rename = "globalHosts")]
pub global_hosts: bool,
#[serde(rename = "globalHomes")]
pub global_homes: bool,
pub hosts: Vec<String>, pub hosts: Vec<String>,
#[serde(rename = "neededForUsers")] #[serde(rename = "neededForUsers")]
pub needed_for_users: bool, pub needed_for_users: bool,
pub sopskeys: Vec<String>,
pub keys: Vec<String>, pub keys: Vec<String>,
#[serde(default)]
pub home: bool,
} }
#[derive(Debug, Clone, Deserialize, PartialEq)] #[derive(Debug, Clone, Deserialize, PartialEq)]
+49 -28
View File
@@ -81,42 +81,47 @@ fn render_secret_detail(state: &App, area: Rect, buf: &mut Buffer) {
])); ]));
// Scope // Scope
let scope = if secret.global { let scope = match (secret.global_hosts, secret.global_homes) {
"global".to_string() (true, true) => "global (hosts + homes)".to_string(),
} else { (true, false) => "global (hosts)".to_string(),
"host-specific".to_string() (false, true) => "global (homes)".to_string(),
(false, false) => "host-specific".to_string(),
}; };
lines.push(Line::from(vec![ lines.push(Line::from(vec![
Span::styled(" Scope: ", Style::default().fg(Color::DarkGray)), Span::styled(" Scope: ", Style::default().fg(Color::DarkGray)),
Span::styled(scope, Style::default().fg(Color::White)), Span::styled(scope, Style::default().fg(Color::White)),
])); ]));
if !secret.global { if !(secret.global_hosts && secret.global_homes) {
// Hosts // Hosts / identities. Home-manager identities are coloured distinctly
let hosts_str = if secret.hosts.is_empty() { // so it is obvious which targets are user (home) rather than system.
if secret.global { let mut host_spans: Vec<Span> = vec![Span::styled(
manifest " Hosts: ",
.hosts Style::default().fg(Color::DarkGray),
.keys() )];
.cloned() if secret.hosts.is_empty() {
.collect::<Vec<_>>() host_spans.push(Span::styled("none", Style::default().fg(Color::White)));
.join(", ")
} else { } else {
"none".to_string() for (i, identity) in secret.hosts.iter().enumerate() {
if i > 0 {
host_spans.push(Span::styled(", ", Style::default().fg(Color::DarkGray)));
} }
let is_home = manifest.home_identities.contains(identity);
let style = if is_home {
Style::default().fg(Color::Magenta)
} else { } else {
secret.hosts.join(", ") Style::default().fg(Color::White)
}; };
lines.push(Line::from(vec![ host_spans.push(Span::styled(identity.clone(), style));
Span::styled(" Hosts: ", Style::default().fg(Color::DarkGray)), }
Span::styled(hosts_str, Style::default().fg(Color::White)), }
])); lines.push(Line::from(host_spans));
} }
// Recipients // Recipients
lines.push(Line::from(vec![ lines.push(Line::from(vec![
Span::styled(" Recipients: ", Style::default().fg(Color::DarkGray)), Span::styled(" Recipients: ", Style::default().fg(Color::DarkGray)),
Span::styled( Span::styled(
format!("{} age keys", secret.keys.len()), format!("{} age keys", secret.sopskeys.len()),
Style::default().fg(Color::White), Style::default().fg(Color::White),
), ),
])); ]));
@@ -128,6 +133,14 @@ fn render_secret_detail(state: &App, area: Rect, buf: &mut Buffer) {
Span::styled("yes", Style::default().fg(Color::Yellow)), Span::styled("yes", Style::default().fg(Color::Yellow)),
])); ]));
} }
// Home-manager: shown when this secret is consumed by a home-manager user.
if secret.home {
lines.push(Line::from(vec![
Span::styled(" Home-mgr: ", Style::default().fg(Color::DarkGray)),
Span::styled("yes", Style::default().fg(Color::Magenta)),
]));
}
while lines.len() < 6 { while lines.len() < 6 {
lines.push(Line::from("")); lines.push(Line::from(""));
} }
@@ -140,7 +153,7 @@ fn render_secret_detail(state: &App, area: Rect, buf: &mut Buffer) {
.fg(Color::DarkGray) .fg(Color::DarkGray)
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
))); )));
for key in &secret.keys { for key in &secret.sopskeys {
let truncated = if key.len() > 20 { let truncated = if key.len() > 20 {
format!("{}...", &key[..20]) format!("{}...", &key[..20])
} else { } else {
@@ -151,14 +164,22 @@ fn render_secret_detail(state: &App, area: Rect, buf: &mut Buffer) {
let label = if manifest.master_keys.contains(key) { let label = if manifest.master_keys.contains(key) {
Span::styled(" (master)", Style::default().fg(Color::Magenta)) Span::styled(" (master)", Style::default().fg(Color::Magenta))
} else { } else {
// Try to find which host owns this key // Try to find which identity owns this key. Home identities are
let host_label = manifest // tagged and coloured distinctly from system identities.
match manifest
.hosts .hosts
.iter() .iter()
.find(|(_, info)| info.keys.contains(key)) .find(|(_, info)| info.keys.contains(key))
.map(|(name, _)| format!(" ({})", name)) {
.unwrap_or_default(); Some((name, _)) if manifest.home_identities.contains(name) => Span::styled(
Span::styled(host_label, Style::default().fg(Color::Cyan)) format!(" ({name}, home)"),
Style::default().fg(Color::Magenta),
),
Some((name, _)) => {
Span::styled(format!(" ({name})"), Style::default().fg(Color::Cyan))
}
None => Span::raw(""),
}
}; };
lines.push(Line::from(vec![ lines.push(Line::from(vec![
@@ -168,7 +189,7 @@ fn render_secret_detail(state: &App, area: Rect, buf: &mut Buffer) {
])); ]));
} }
let paragraph = Paragraph::new(lines).block(block).render(area, buf); Paragraph::new(lines).block(block).render(area, buf);
} }
fn render_placeholder(area: Rect, buf: &mut Buffer, title: &str, message: &str) { fn render_placeholder(area: Rect, buf: &mut Buffer, title: &str, message: &str) {