Compare commits

..

5 Commits

Author SHA1 Message Date
55ed1bf5a9 fix(shell): show groups in long listings 2026-08-03 09:55:48 -04:00
ad2e6f5f4a refactor(ssh): centralize fleet access 2026-08-02 22:45:56 -04:00
2738061b5d fix(pikachu): configure gitea ssh remote 2026-08-02 21:45:33 -04:00
ffc9b331ea feat(pikachu): manage ssh keys 2026-08-02 21:34:20 -04:00
729c8fdd5f feat(ssh): add host aliases 2026-08-02 21:06:39 -04:00
9 changed files with 302 additions and 108 deletions

View File

@@ -19,6 +19,12 @@ creation_rules:
- *admin - *admin
- *neogaia - *neogaia
- path_regex: secrets/pikachu\.yaml$
key_groups:
- age:
- *admin
- *pikachu
# Material common to every machine, so it is stored once rather than per host. # Material common to every machine, so it is stored once rather than per host.
- path_regex: secrets/shared\.yaml$ - path_regex: secrets/shared\.yaml$
key_groups: key_groups:

View File

@@ -59,7 +59,10 @@ in
users.users.${user.name} = { users.users.${user.name} = {
isNormalUser = true; isNormalUser = true;
description = user.description; description = user.description;
extraGroups = [ "wheel" ]; extraGroups = [
"wheel"
"storage"
];
}; };
# The shared write group. # The shared write group.

View File

@@ -1,5 +1,4 @@
{ {
config,
inputs, inputs,
pkgs, pkgs,
... ...
@@ -39,9 +38,6 @@
modules.ssh.hostKeys.sopsFile = ../../secrets/neogaia.yaml; modules.ssh.hostKeys.sopsFile = ../../secrets/neogaia.yaml;
modules.ssh.userKey.sopsFile = ../../secrets/neogaia.yaml; modules.ssh.userKey.sopsFile = ../../secrets/neogaia.yaml;
# A machine the operator works from, so it admits the workstation keys alone.
modules.ssh.authorizedKeys = config.modules.ssh.workstationKeys;
modules.toolkit.enable = true; modules.toolkit.enable = true;
# The walking-skeleton guest, enabled like any module: proves the guest path # The walking-skeleton guest, enabled like any module: proves the guest path

View File

@@ -1,8 +1,4 @@
{ { pkgs, ... }:
config,
pkgs,
...
}:
# pikachu — AZW ME Pro server. # pikachu — AZW ME Pro server.
# Disk layout is in ./disk.nix. # Disk layout is in ./disk.nix.
# `fileSystems` for the root disk are derived from it. # `fileSystems` for the root disk are derived from it.
@@ -41,8 +37,8 @@
}; };
modules.ssh.enable = true; modules.ssh.enable = true;
modules.ssh.hostKeys.restore = false; modules.ssh.hostKeys.sopsFile = ../../secrets/pikachu.yaml;
modules.ssh.authorizedKeys = config.modules.ssh.workstationKeys; modules.ssh.userKey.sopsFile = ../../secrets/pikachu.yaml;
modules.git.enable = true; modules.git.enable = true;
modules.toolkit.enable = true; modules.toolkit.enable = true;

View File

@@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKljRf4pJO+pqEqjpPz08gOYq3g1PpxvE66xVw7uMEnA root@pikachu

View File

@@ -0,0 +1 @@
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCy/riwm7dflA3mT+3a0/2CIoS2LbAsK/vn35kOoNeuzn0yhiF+imexP6tkB3S2t+H5ybRzkbbuNZcynFfeCqthFc8kvbdCnt8Diqoeg96fZ6ecvh5QE5yH9op8534EySetZ/exakFLnF+6EiWMuWUW3DFwsc2kcgDJObqSE8gTx/d7JK953MiTFmSJBFyg1RtQ3ZnMT+iCrvY2dyCLQai7VeF8koVKF2c0leAq2Hc75rb/L9md8MoJa64iPiz7hwTCin3xoFyaY/5hNVvyqFd5PivgR69gLdJkuVsUYO2mJzhur8cYmJD+pGjJ0U45hyE9TMrCFjeJHHuvSt3+2kph62wv95jLNk0WmMlwgyunISxENCSVVtNYdBMXhUh8VhEAW17QpVUg9EnPvxOdTKEjrvfOZYASWUa51JKbgBgexVgFbxdjDZR88DZa31AVBts/cx/59gXTUahFXMYLdZgssx+5uibZQWnvCyfUV9WLbfmK1lgL6hzReg1VkQ87iGr6skjtQYemJxRaFNA1+Q5f3kmG3KncuK/594a3qXYP4gC6A2blf8om1YZ4aXXh6f+GFKLjoEw1vvM2rJ+rjzfymwDX+pxVQ9L13OEtVZc9Ez76pOkbm1hqdbL0gY45+0cpxodhWV0wMQJBDXL1MHP8qcs+/vw0GxVK5l1SnWBGlw== root@pikachu

View File

@@ -52,9 +52,9 @@ in
]; ];
shellAliases = { shellAliases = {
ls = "eza -al --color=always --group-directories-first --icons=always"; ls = "eza -alg --color=always --group-directories-first --icons=always";
la = "eza -a --color=always --group-directories-first --icons=always"; la = "eza -a --color=always --group-directories-first --icons=always";
ll = "eza -l --color=always --group-directories-first --icons=always"; ll = "eza -lg --color=always --group-directories-first --icons=always";
lt = "eza -aT -I '.git' --color=always --group-directories-first --icons=always"; lt = "eza -aT -I '.git' --color=always --group-directories-first --icons=always";
"l." = "eza -a | grep -e '^\\.'"; "l." = "eza -a | grep -e '^\\.'";
}; };

View File

@@ -8,119 +8,298 @@ let
cfg = config.modules.ssh; cfg = config.modules.ssh;
user = config.user.name; user = config.user.name;
inherit (lib)
concatLists
concatStringsSep
elem
filter
genAttrs
hasAttr
imap0
listToAttrs
mapAttrs
mapAttrsToList
mkIf
mkMerge
mkOption
nameValuePair
optional
optionalAttrs
types
unique
;
hostKeySecret = type: "ssh-host-${type}-key"; hostKeySecret = type: "ssh-host-${type}-key";
userKeySecret = "ssh-user-ed25519-key"; userKeySecret = "ssh-user-ed25519-key";
targetType = types.submodule (
{ name, ... }:
{
options = {
hostName = mkOption {
type = types.str;
default = name;
description = ''
The network address OpenSSH connects to for this target.
'';
};
user = mkOption {
type = types.str;
default = config.user.name;
description = ''
The remote login name OpenSSH uses for this target.
'';
};
port = mkOption {
type = types.port;
default = 22;
description = ''
The TCP port OpenSSH uses for this target.
'';
};
aliases = mkOption {
type = types.listOf types.str;
default = [ name ];
description = ''
Host patterns written into the generated OpenSSH client block.
'';
};
clientKey = mkOption {
type = types.nullOr types.str;
default = null;
description = ''
The public key this target offers when it connects outward.
Other machines admit this key according to the host groups below.
'';
};
hostKeys = mkOption {
type = types.listOf types.str;
default = [ ];
description = ''
The public keys this target presents when it accepts inbound SSH.
These keys generate system-wide known-host entries.
'';
};
};
}
);
keyWithoutComment = key: concatStringsSep " " (lib.take 2 (lib.splitString " " key));
targetNamesIn = names: filter (name: hasAttr name cfg.targets) names;
workstationNames = targetNamesIn cfg.hosts.workstations;
serverNames = targetNamesIn cfg.hosts.servers;
clientKeysFor = names: filter (key: key != null) (map (name: cfg.targets.${name}.clientKey) names);
currentHost = config.networking.hostName;
isServer = elem currentHost cfg.hosts.servers;
isWorkstation = elem currentHost cfg.hosts.workstations;
defaultAuthorizedKeys = lib.flatten (
clientKeysFor workstationNames
++ optional isServer (clientKeysFor serverNames)
);
outboundTargetNames =
let
groupTargets =
if isServer then
[ "gitea" ] ++ cfg.hosts.servers
else if isWorkstation then
[ "gitea" ] ++ cfg.hosts.servers ++ cfg.hosts.workstations
else
[ "gitea" ];
in
filter (name: name != currentHost) (targetNamesIn groupTargets);
sshSettingsFor = name:
let
target = cfg.targets.${name};
in
{
header = "Host ${concatStringsSep " " target.aliases}";
HostName = target.hostName;
User = target.user;
}
// optionalAttrs (target.port != 22) { Port = target.port; };
knownHostNamesFor = target:
let
names = unique (target.aliases ++ [ target.hostName ]);
withPort = name: if target.port == 22 then name else "[${name}]:${toString target.port}";
in
map withPort names;
knownHosts = listToAttrs (
concatLists (
mapAttrsToList (
targetName: target:
imap0 (i: key:
nameValuePair "${targetName}-${toString i}" {
hostNames = knownHostNamesFor target;
publicKey = keyWithoutComment key;
}
) target.hostKeys
) cfg.targets
)
);
in in
{ {
options.modules.ssh = { options.modules.ssh = {
enable = lib.mkEnableOption "the OpenSSH daemon, with host keys restored from secrets"; enable = lib.mkEnableOption "the OpenSSH daemon, with host keys restored from secrets";
workstationKeys = lib.mkOption { hosts = {
type = lib.types.listOf lib.types.str; servers = mkOption {
default = [ type = types.listOf types.str;
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGxQ4kWsBo2OGYIPOkFe0vNEcB3yoJwAu0y9wrdQzALE alexion@neogaia" default = [ "pikachu" ];
]; description = ''
description = '' Hosts that serve durable services.
Client public keys of the machines the operator works from. They admit workstation keys and server keys, and they receive aliases for other servers and the forge.
'';
};
Every machine admits these, so any of them reaches the whole fleet. workstations = mkOption {
type = types.listOf types.str;
default = [ "neogaia" ];
description = ''
Hosts the operator works from.
Their keys are admitted by every host, and they receive aliases for the whole fleet and the forge.
'';
};
};
targets = mkOption {
type = types.attrsOf targetType;
default = {
gitea = {
hostName = "git.alexion.dev";
port = 2022;
user = "gitea";
aliases = [
"gitea"
"git.alexion.dev"
];
};
neogaia = {
hostName = "10.23.50.146";
clientKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGxQ4kWsBo2OGYIPOkFe0vNEcB3yoJwAu0y9wrdQzALE alexion@neogaia";
hostKeys = [
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJS+wp7K123+4BT6G4f954R6WyrbWveY7VlpoBUf6I5p neogaia"
"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCo2wWUKxyAS4J5TqbWf8glDhJvS5XmdRqFhMeJwG3pOB+4AccZ1T8LU7ZN+RjtRi3j2qXBJvIHuzhtQNtmT59TxocvfobYiqOgJpvVO5K6yD8ZoUJs6ziDkIduI9w9mdRIESoi+dBbVu8n24r61cKDVh+jWX+yjzkOcWcOzqDyQhhkjqblZ1WMAdujEMuEPvif1i2LCxStUaZqRGcx09m/ME2fYcaJrpuxxxvX2+CPJNicoo6Rx9i7ZjAoNuvH+jui4KT62DzlQtQtCl2CFUOM0gCPSa+MbNQ9elfHPvGzEcwOIMo2cuy9KURUkQu+sAgaG8S1PEniDDTecskHtuRdmPZawnQGpIhzo919Q6wUgjT8scK4mmSXRWmGmkMt0GNA2tfj5tDks6r5Q8XsYqtWs4rsOEvfmxVSdM771w+fqDBAil99Jsh0ksPK9+Bwgg8cMDzLLFDn8JA5y2G1HocMMom+u5DYKwPXEKnCILkasB8y24+O3PhSu1EuWw277w6EUEXvU03rCf0Ak/ULjxp9a00EGlloEwSmFI7Aub9XHDr87IdbGInEn+PMqyBYADiN+3h6nE2JO+nMa6i/CHdebmT+T7YJvuTKHD9sjFmQsYaghlq03DZrhHcm4hgUvE1dqGojHrhk/WgA3EWTWtK/+BP0Vy2jXaaz+qAx+EGnhQ== neogaia"
];
};
pikachu = {
hostName = "10.23.10.102";
clientKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINNqJIC6VRXyvrNf3n9su9KdPCikC3CjK/QrCK2reHdB alexion@pikachu";
hostKeys = [
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKljRf4pJO+pqEqjpPz08gOYq3g1PpxvE66xVw7uMEnA root@pikachu"
"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCy/riwm7dflA3mT+3a0/2CIoS2LbAsK/vn35kOoNeuzn0yhiF+imexP6tkB3S2t+H5ybRzkbbuNZcynFfeCqthFc8kvbdCnt8Diqoeg96fZ6ecvh5QE5yH9op8534EySetZ/exakFLnF+6EiWMuWUW3DFwsc2kcgDJObqSE8gTx/d7JK953MiTFmSJBFyg1RtQ3ZnMT+iCrvY2dyCLQai7VeF8koVKF2c0leAq2Hc75rb/L9md8MoJa64iPiz7hwTCin3xoFyaY/5hNVvyqFd5PivgR69gLdJkuVsUYO2mJzhur8cYmJD+pGjJ0U45hyE9TMrCFjeJHHuvSt3+2kph62wv95jLNk0WmMlwgyunISxENCSVVtNYdBMXhUh8VhEAW17QpVUg9EnPvxOdTKEjrvfOZYASWUa51JKbgBgexVgFbxdjDZR88DZa31AVBts/cx/59gXTUahFXMYLdZgssx+5uibZQWnvCyfUV9WLbfmK1lgL6hzReg1VkQ87iGr6skjtQYemJxRaFNA1+Q5f3kmG3KncuK/594a3qXYP4gC6A2blf8om1YZ4aXXh6f+GFKLjoEw1vvM2rJ+rjzfymwDX+pxVQ9L13OEtVZc9Ez76pOkbm1hqdbL0gY45+0cpxodhWV0wMQJBDXL1MHP8qcs+/vw0GxVK5l1SnWBGlw== root@pikachu"
];
};
};
description = ''
SSH targets known to the fleet.
The inventory holds connection details plus public keys used for authorization and host verification.
''; '';
}; };
serverKeys = lib.mkOption { extraAuthorizedKeys = mkOption {
type = lib.types.listOf lib.types.str; type = types.listOf types.str;
default = [ ]; default = [ ];
description = '' description = ''
Client public keys of the machines that serve. Additional client public keys admitted by this host.
Only other servers admit these, so one that is compromised reaches no
machine the operator works from.
''; '';
}; };
authorizedKeys = lib.mkOption { authorizedKeys = mkOption {
type = lib.types.listOf lib.types.str; type = types.nullOr (types.listOf types.str);
default = cfg.workstationKeys;
defaultText = lib.literalExpression "config.modules.ssh.workstationKeys";
description = ''
Client public keys this machine admits for the primary user, drawn from
the lists above.
A machine the operator works from takes the workstation keys. One that
serves takes both, so servers reach each other. The default admits the
workstation keys, since a machine admitting none is unreachable.
'';
};
hostKeys.restore = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Restore the host keys from secrets rather than letting the daemon
generate its own.
A machine with its own identity keeps its fingerprint across a reimage
by restoring committed keys. A guest carries no host identity, so it
turns this off and presents a self-generated key instead.
'';
};
hostKeys.sopsFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null; default = null;
description = '' description = ''
Encrypted file holding this host's SSH host private keys, one entry per Complete override for client public keys admitted by this host.
key type, named `ssh-host-<type>-key`. Required when `restore` is on. Leave null to derive access from `modules.ssh.hosts` and `modules.ssh.targets`.
These are the keys the daemon presents to identify itself to connecting
clients, not keys used to authenticate anyone to a remote server.
Restoring them from secrets rather than generating them keeps the host's
fingerprint across a reimage, so every client's `known_hosts` entry
stays valid.
''; '';
}; };
hostKeys.types = lib.mkOption { extraSettings = mkOption {
type = lib.types.listOf lib.types.str; type = types.attrsOf types.anything;
default = { };
description = ''
Additional OpenSSH client settings merged into the generated Home Manager configuration.
'';
};
hostKeys.restore = mkOption {
type = types.bool;
default = true;
description = ''
Restore the host keys from secrets rather than letting the daemon generate its own.
A machine with its own identity keeps its fingerprint across a reimage by restoring committed keys.
A guest carries no host identity, so it turns this off and presents a self-generated key instead.
'';
};
hostKeys.sopsFile = mkOption {
type = types.nullOr types.path;
default = null;
description = ''
Encrypted file holding this host's SSH host private keys, one entry per key type, named `ssh-host-<type>-key`.
Required when `restore` is on.
'';
};
hostKeys.types = mkOption {
type = types.listOf types.str;
default = [ default = [
"ed25519" "ed25519"
"rsa" "rsa"
]; ];
description = '' description = ''
Key types to restore, naming both the entries read from the encrypted Key types to restore, naming both the entries read from the encrypted file and the algorithms the daemon offers.
file and the algorithms the daemon offers. Dropping a type a client has
already pinned makes the host unrecognisable to it.
''; '';
}; };
userKey.sopsFile = lib.mkOption { userKey.sopsFile = mkOption {
type = lib.types.nullOr lib.types.path; type = types.nullOr types.path;
default = null; default = null;
description = '' description = ''
Encrypted file holding this machine's SSH client private key, under the Encrypted file holding this machine's SSH client private key, under the entry `ssh-user-ed25519-key`.
entry `ssh-user-ed25519-key`. Left unset on a machine that authenticates Left unset on a machine that authenticates to no remote server, such as a guest.
to no remote server, such as a guest.
This is the key the primary user offers to authenticate to a remote
server, not a key the daemon presents to identify this machine.
It belongs to this machine alone, so withdrawing its access does not
re-key any other.
''; '';
}; };
}; };
config = lib.mkIf cfg.enable ( config = mkIf cfg.enable (
lib.mkMerge [ mkMerge [
{ {
services.openssh.enable = true; services.openssh.enable = true;
# The primary user is the only account reachable over SSH. users.users.${user}.openssh.authorizedKeys.keys =
users.users.${user}.openssh.authorizedKeys.keys = cfg.authorizedKeys; if cfg.authorizedKeys != null then
cfg.authorizedKeys
else
defaultAuthorizedKeys ++ cfg.extraAuthorizedKeys;
programs.ssh.knownHosts = knownHosts;
home-manager.users.${user}.programs.ssh = {
enable = true;
enableDefaultConfig = false;
settings =
mapAttrs (name: _: sshSettingsFor name) (genAttrs outboundTargetNames (name: name))
// cfg.extraSettings;
};
} }
# A machine with its own identity restores its host keys from secrets. (mkIf cfg.hostKeys.restore {
(lib.mkIf cfg.hostKeys.restore {
assertions = [ assertions = [
{ {
assertion = cfg.hostKeys.sopsFile != null; assertion = cfg.hostKeys.sopsFile != null;
@@ -128,42 +307,27 @@ in
} }
]; ];
# The daemon reads its host keys once at startup, so a re-key has to sops.secrets = genAttrs (map hostKeySecret cfg.hostKeys.types) (_: {
# restart it to take effect.
sops.secrets = lib.genAttrs (map hostKeySecret cfg.hostKeys.types) (_: {
inherit (cfg.hostKeys) sopsFile; inherit (cfg.hostKeys) sopsFile;
mode = "0400"; mode = "0400";
restartUnits = [ "sshd.service" ]; restartUnits = [ "sshd.service" ];
}); });
# An empty list is what stops the daemon generating keys of its own.
services.openssh.hostKeys = [ ]; services.openssh.hostKeys = [ ];
services.openssh.extraConfig = lib.concatMapStrings ( services.openssh.extraConfig = concatStringsSep "" (
type: "HostKey ${config.sops.secrets.${hostKeySecret type}.path}\n" map (type: "HostKey ${config.sops.secrets.${hostKeySecret type}.path}\n") cfg.hostKeys.types
) cfg.hostKeys.types; );
}) })
# The client key the primary user offers to remote servers, present only on (mkIf (cfg.userKey.sopsFile != null) {
# a machine that has one.
(lib.mkIf (cfg.userKey.sopsFile != null) {
# The primary user is the only account that authenticates with this key,
# and the mode admits no other.
# The client rereads it per connection, so no unit restarts on a re-key.
sops.secrets.${userKeySecret} = { sops.secrets.${userKeySecret} = {
inherit (cfg.userKey) sopsFile; inherit (cfg.userKey) sopsFile;
mode = "0400"; mode = "0400";
owner = user; owner = user;
}; };
# The client reads the decrypted key where it is written, so no copy of it home-manager.users.${user}.programs.ssh.settings."*".IdentityFile =
# lives in the user's home to drift from the secret. config.sops.secrets.${userKeySecret}.path;
# Declaring no defaults of home-manager's own leaves every other directive
# at the one OpenSSH itself ships.
home-manager.users.${user}.programs.ssh = {
enable = true;
enableDefaultConfig = false;
settings."*".IdentityFile = config.sops.secrets.${userKeySecret}.path;
};
}) })
] ]
); );

27
secrets/pikachu.yaml Normal file
View File

@@ -0,0 +1,27 @@
ssh-host-ed25519-key: ENC[AES256_GCM,data:nxj7nMDzeqr0CitIjns9ugFh7Sur0pVpBr8SgWbxQMpIeNvBp+ugmnAxaUq2HRoYCo68uh/YKJZgMCBPSWrEyIhUX0rXmiZFmusrviJSXrYmy+n2XvLPjuv4vPbJEfjudRdXkHPazB3JJbeVRhGgK7y7smhNZlr8lwPGo0Ui0jVqWB4MPQRP6dLKVfV5shIusOqdhg7y2JSkZLYIjOBOJjknCxGmr5+vF2qwpDJQWEyzuJv2QJ48IlU7QyHF76//6xOchteL70/lyXI4gLiGl2fEczx3KKIXE+DNetKVAQxh4QHwzz9JK8qIJMYVry2qFdGGgF8eN7YEJYA60r1pL++1l8Uq++C1Bsc9jCO91ayQb+spXK0uM3G/ZJka7brRQbvcr54MJEyH6+0uBscXw/wMJ1EBX3Np6dumQhKuALnsAIXE89bKJg1wyPwaCy8uMZMcjQhvNkG0FPSBatHz2bB7bnNuNiXyT+scYyN0huE4Ccq7/H3bHWqbipV51YBH/XZKmjDiCwu3i0t5MHmp,iv:9P6Pa3hBe0/jbacjvzV3VGJJ4+OLRarEJEkLMOftqPw=,tag:zA6ewbj2ovP9+2WcAXa9qA==,type:str]
ssh-host-rsa-key: ENC[AES256_GCM,data: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,iv:vfjzz6U6ey9wGs6Ia3Vd5HaGBvpSq4akp4e3eLC/A3M=,tag:ar1+i09oOEusEuQnr44ASw==,type:str]
ssh-user-ed25519-key: ENC[AES256_GCM,data:uHZADD2tq8vqlOAOWYniEnj1sl2IUqepf4YvrmRX/0nTksVnoethW6Yb2VNTgu5FsKCxvO65qMZ9KYupDkw/+SCZP2VH4TyhByQSr4SYxrEiNOJLdvmL2VnOWlNNZKaswgBePQR7TBG4mSIiTXCjEAcxeHAhBxLPnwiFxK/Zd6yQhq61x1N+ZtOj0q0QpADP3vioUohdkE5ODoEOUUoQJkYpjdJl2j4PhjCq7yHMEZpeMkqjZhVX5+i8blBu43BM4bKS2tBIJW2IvPV9sHOH8/tsR6p5q0pnpcJAOyOquBSo+eR0egY+liXjSv+Vo/ZDPapl7rKs2SoJ5huUGFbWjxbXY2+t2SYyiVA8lufrVUu94RCwOOJ8UyGYzAGGrChUImVDBsgk/GWwpOvWxcqydF8d6mDdxK9Iek9HtYsS+IKXLrF8GAI9OLB9jKgHVZgyQYSnC146cwCLt99TOFVocE1lfzwpFQTwKvxkep5jBEWiyqRaRUngti0JuF0ih1BWo+LKqxIqq9+V3I39xM8PfTjgHiifnxj4wuhS,iv:32zDKpNPYMiP6Rx7L9TAhxrMwcmphIGni+0jcESoLqg=,tag:YWGE62WQNm2Cer+eG+S5Xg==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAyTTVLWHVaZ1NxbGEzcVBq
MmorSkhzQ2hSV01idWE4VXNyNkNvMmpkTHpzCnFDQ0l3a1o1OWhmdE9WTjlob3p4
Y3BzMlJmeks4ZllzbEY1MkVqRCtrNjgKLS0tIFVhQXpDRjRUSEViMUhPM0V2RGRt
NkRpV05YV2RaTGozTUNNM3U5dmJ3dWsKSzkXmVyPhwN58SjMYYL/YbPoHYtZ9goF
VYRLRnfU298/3R/1nLNqTlMk4MYQ6NaGf2jOYTyObGgJVccOHF6wig==
-----END AGE ENCRYPTED FILE-----
recipient: age1m0pk94ysjlw3lmf6pyuv5l5pepvdjss8w0vxjv90dq6ndp02tdgsdwdvue
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBGMDBPcHZ3YithYm5sM2Vh
NzJUVmpuT1FRdmw1bzI0a2p5NXFlZlRQN25NCmduQ1NKREVZT3Q5d0l4ZC9ZVUFU
all2UUZUMjZyaGxGMm5UOEQwcnNKS2sKLS0tIExPZmR5K05WZHRYRHZEeW1PRWhV
ZTZzTlhnYURJTDROTFpUTmFKc3ZkeXMKEWUCkVeHEt/Ay0lyAVjsqtLbu+pJOTJ5
dyjzy0/9Ui1IIXH36AImO5hiEZcrGePV2qPLLQc7ZLngC1jlJuEKJA==
-----END AGE ENCRYPTED FILE-----
recipient: age1wf5s0n0tgt6ld2ysgu9dc67mj8ylwecgl4utzg7hqwy3kut9zyms7aglmh
lastmodified: "2026-08-03T01:26:32Z"
mac: ENC[AES256_GCM,data:w4i6+dwN/t/SvFTrP/YiapxOY6ecT7+cJ0v/5lYMe4m1vL2w4mJw9ZrxWifcCcTEfaa766Wc1NE+GxGrBZtlORExbtQCa4tg2GuIQCRqTEc5WSqhYrgMb4elWuYe+r3/SlAf1ldNxz+cmLVtbDpSs96bvkGsKdw/i+q9n2URJYE=,iv:MFWgMtIdpn1v0T6FPhTgBMxi+6kzf2ZAnOGaisRT0d0=,tag:qBX32/zUHSPagXU+u8G8tA==,type:str]
unencrypted_suffix: _unencrypted
version: 3.13.3