Merge pull request 'ssh-ca' (#2379) from ssh-ca into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/2379 Reviewed-by: kenji <aks.kenji@protonmail.com>
This commit is contained in:
@@ -24,7 +24,6 @@
|
||||
imports = [
|
||||
self.clanModules.borgbackup
|
||||
self.clanModules.localbackup
|
||||
self.clanModules.sshd
|
||||
];
|
||||
clan.core.networking.targetHost = "machine";
|
||||
networking.hostName = "machine";
|
||||
@@ -36,6 +35,16 @@
|
||||
machine.publicKey = builtins.readFile ../lib/ssh/pubkey;
|
||||
};
|
||||
|
||||
services.openssh = {
|
||||
enable = true;
|
||||
hostKeys = [
|
||||
{
|
||||
path = "/root/.ssh/id_ed25519";
|
||||
type = "ed25519";
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
users.users.root.openssh.authorizedKeys.keyFiles = [ ../lib/ssh/pubkey ];
|
||||
|
||||
systemd.tmpfiles.settings."vmsecrets" = {
|
||||
@@ -69,6 +78,8 @@
|
||||
};
|
||||
};
|
||||
clan.core.facts.secretStore = "vm";
|
||||
# TODO: set this backend as well, once we have implemented it.
|
||||
#clan.core.vars.settings.secretStore = "vm";
|
||||
|
||||
environment.systemPackages = [ self.packages.${pkgs.system}.clan-cli ];
|
||||
environment.etc.install-closure.source = "${closureInfo}/store-paths";
|
||||
@@ -122,7 +133,7 @@
|
||||
};
|
||||
};
|
||||
perSystem =
|
||||
{ nodes, pkgs, ... }:
|
||||
{ pkgs, ... }:
|
||||
{
|
||||
checks = pkgs.lib.mkIf (pkgs.stdenv.isLinux) {
|
||||
test-backups = (import ../lib/test-base.nix) {
|
||||
|
||||
@@ -13,6 +13,7 @@ in
|
||||
];
|
||||
documentation.enable = lib.mkDefault false;
|
||||
nix.settings.min-free = 0;
|
||||
system.stateVersion = lib.version;
|
||||
};
|
||||
|
||||
# to accept external dependencies such as disko
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
---
|
||||
description = "Enables secure remote access to the machine over ssh"
|
||||
description = "Enables secure remote access to the machine over ssh."
|
||||
categories = ["System"]
|
||||
features = [ "inventory" ]
|
||||
---
|
||||
|
||||
This module will setup the opensshd service.
|
||||
It will generate a host key for each machine
|
||||
|
||||
|
||||
## Roles
|
||||
|
||||
###
|
||||
|
||||
@@ -1,25 +1,6 @@
|
||||
{ config, pkgs, ... }:
|
||||
# Dont import this file
|
||||
# It is only here for backwards compatibility.
|
||||
# Dont author new modules with this file.
|
||||
{
|
||||
services.openssh.enable = true;
|
||||
services.openssh.settings.PasswordAuthentication = false;
|
||||
|
||||
services.openssh.hostKeys = [
|
||||
{
|
||||
path = config.clan.core.facts.services.openssh.secret."ssh.id_ed25519".path;
|
||||
type = "ed25519";
|
||||
}
|
||||
];
|
||||
|
||||
clan.core.facts.services.openssh = {
|
||||
secret."ssh.id_ed25519" = { };
|
||||
public."ssh.id_ed25519.pub" = { };
|
||||
generator.path = [
|
||||
pkgs.coreutils
|
||||
pkgs.openssh
|
||||
];
|
||||
generator.script = ''
|
||||
ssh-keygen -t ed25519 -N "" -f $secrets/ssh.id_ed25519
|
||||
mv $secrets/ssh.id_ed25519.pub $facts/ssh.id_ed25519.pub
|
||||
'';
|
||||
};
|
||||
imports = [ ./roles/server.nix ];
|
||||
}
|
||||
|
||||
6
clanModules/sshd/roles/client.nix
Normal file
6
clanModules/sshd/roles/client.nix
Normal file
@@ -0,0 +1,6 @@
|
||||
{ ... }:
|
||||
{
|
||||
imports = [
|
||||
../shared.nix
|
||||
];
|
||||
}
|
||||
68
clanModules/sshd/roles/server.nix
Normal file
68
clanModules/sshd/roles/server.nix
Normal file
@@ -0,0 +1,68 @@
|
||||
{
|
||||
config,
|
||||
pkgs,
|
||||
lib,
|
||||
...
|
||||
}:
|
||||
let
|
||||
stringSet = list: builtins.attrNames (builtins.groupBy lib.id list);
|
||||
|
||||
signArgs = builtins.concatStringsSep " " (
|
||||
builtins.map (domain: "-n ${lib.escapeShellArg "${config.clan.core.machineName}.${domain}"}") (
|
||||
stringSet config.clan.sshd.certificate.searchDomains
|
||||
)
|
||||
);
|
||||
cfg = config.clan.sshd;
|
||||
in
|
||||
{
|
||||
imports = [ ../shared.nix ];
|
||||
config = {
|
||||
services.openssh = {
|
||||
enable = true;
|
||||
settings.PasswordAuthentication = false;
|
||||
|
||||
settings.HostCertificate = lib.mkIf (
|
||||
cfg.certificate.searchDomains != [ ]
|
||||
) config.clan.core.vars.generators.openssh-cert.files."ssh.id_ed25519-cert.pub".path;
|
||||
|
||||
hostKeys = [
|
||||
{
|
||||
path = config.clan.core.vars.generators.openssh.files."ssh.id_ed25519".path;
|
||||
type = "ed25519";
|
||||
}
|
||||
];
|
||||
};
|
||||
clan.core.vars.generators.openssh = {
|
||||
files."ssh.id_ed25519" = { };
|
||||
files."ssh.id_ed25519.pub".secret = false;
|
||||
migrateFact = "openssh";
|
||||
runtimeInputs = [
|
||||
pkgs.coreutils
|
||||
pkgs.openssh
|
||||
];
|
||||
script = ''
|
||||
ssh-keygen -t ed25519 -N "" -f $out/ssh.id_ed25519
|
||||
'';
|
||||
};
|
||||
|
||||
clan.core.vars.generators.openssh-cert = lib.mkIf (cfg.certificate.searchDomains != [ ]) {
|
||||
files."ssh.id_ed25519-cert.pub".secret = false;
|
||||
dependencies = [
|
||||
"openssh"
|
||||
"openssh-ca"
|
||||
];
|
||||
runtimeInputs = [
|
||||
pkgs.openssh
|
||||
pkgs.jq
|
||||
];
|
||||
script = ''
|
||||
ssh-keygen \
|
||||
-s $in/openssh-ca/id_ed25519 \
|
||||
-I ${config.clan.core.machineName} \
|
||||
${builtins.toString signArgs} \
|
||||
$in/openssh/ssh.id_ed25519.pub
|
||||
mv $in/openssh/ssh.id_ed25519-cert.pub $out/ssh.id_ed25519-cert.pub
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
49
clanModules/sshd/shared.nix
Normal file
49
clanModules/sshd/shared.nix
Normal file
@@ -0,0 +1,49 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
{
|
||||
options = {
|
||||
clan.sshd.certificate = {
|
||||
# TODO: allow per-server domains that we than collect in the inventory
|
||||
#domains = lib.mkOption {
|
||||
# type = lib.types.listOf lib.types.str;
|
||||
# default = [ ];
|
||||
# example = [ "git.mydomain.com" ];
|
||||
# description = "List of domains to include in the certificate. This option will not prepend the machine name in front of each domain.";
|
||||
#};
|
||||
searchDomains = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
default = [ ];
|
||||
example = [ "mydomain.com" ];
|
||||
description = "List of domains to include in the certificate. This option will prepend the machine name in front of each domain before adding it to the certificate.";
|
||||
};
|
||||
};
|
||||
};
|
||||
config = {
|
||||
clan.core.vars.generators.openssh-ca =
|
||||
lib.mkIf (config.clan.sshd.certificate.searchDomains != [ ])
|
||||
{
|
||||
share = true;
|
||||
files.id_ed25519.deploy = false;
|
||||
files."id_ed25519.pub" = {
|
||||
deploy = false;
|
||||
secret = false;
|
||||
};
|
||||
runtimeInputs = [
|
||||
pkgs.openssh
|
||||
];
|
||||
script = ''
|
||||
ssh-keygen -t ed25519 -N "" -f $out/id_ed25519
|
||||
'';
|
||||
};
|
||||
|
||||
programs.ssh.knownHosts.ssh-ca = lib.mkIf (config.clan.sshd.certificate.searchDomains != [ ]) {
|
||||
certAuthority = true;
|
||||
extraHostNames = builtins.map (domain: "*.${domain}") config.clan.sshd.certificate.searchDomains;
|
||||
publicKey = config.clan.core.vars.generators.openssh-ca.files."id_ed25519.pub".value;
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILns3iEVA7MaN+K8qVRFywVOjBZsGyfRuBl26nGL/tXe nixbld@turingmachine
|
||||
@@ -297,6 +297,10 @@ def _check_can_migrate(
|
||||
if machine.secret_vars_store.exists(
|
||||
generator_name, fname, vars_generator["share"]
|
||||
):
|
||||
if vars_generator["deploy"]:
|
||||
machine.secret_vars_store.ensure_machine_has_access(
|
||||
generator_name, fname, vars_generator["share"]
|
||||
)
|
||||
return False
|
||||
else:
|
||||
if machine.public_vars_store.exists(
|
||||
@@ -400,7 +404,7 @@ def generate_vars(
|
||||
)
|
||||
machine.flush_caches()
|
||||
except Exception as exc:
|
||||
log.exception(f"Failed to generate facts for {machine.name}")
|
||||
log.error(f"Failed to generate facts for {machine.name}: {exc}") # noqa
|
||||
errors += [exc]
|
||||
if len(errors) > 0:
|
||||
msg = f"Failed to generate facts for {len(errors)} hosts. Check the logs above"
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
|
||||
class Error(Exception):
|
||||
@@ -20,25 +19,3 @@ def is_valid_age_key(secret_key: str) -> bool:
|
||||
return True
|
||||
msg = f"Invalid age key: {secret_key}"
|
||||
raise Error(msg)
|
||||
|
||||
|
||||
def is_valid_ssh_key(secret_key: str, ssh_pub: str) -> bool:
|
||||
# create tempfile and write secret_key to it
|
||||
with tempfile.NamedTemporaryFile() as temp:
|
||||
temp.write(secret_key.encode("utf-8"))
|
||||
temp.flush()
|
||||
# Run the ssh-keygen command with the -y flag to check the key format
|
||||
result = subprocess.run(
|
||||
["ssh-keygen", "-y", "-f", temp.name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
if result.stdout != ssh_pub:
|
||||
msg = f"Expected '{ssh_pub}' got '{result.stdout}' for ssh key: {secret_key}"
|
||||
raise Error(msg)
|
||||
return True
|
||||
msg = f"Invalid ssh key: {secret_key}"
|
||||
raise Error(msg)
|
||||
|
||||
@@ -9,7 +9,7 @@ from clan_cli.machines.machines import Machine
|
||||
from clan_cli.secrets.folders import sops_secrets_folder
|
||||
from fixtures_flakes import FlakeForTest
|
||||
from helpers import cli
|
||||
from helpers.validator import is_valid_age_key, is_valid_ssh_key
|
||||
from helpers.validator import is_valid_age_key
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from age_keys import KeyPair
|
||||
@@ -85,7 +85,6 @@ def test_generate_secret(
|
||||
assert store2.exists("", "password-hash")
|
||||
assert store2.exists("", "user-password")
|
||||
assert store2.exists("", "user-password-hash")
|
||||
assert store2.exists("", "ssh.id_ed25519")
|
||||
assert store2.exists("", "age.key")
|
||||
assert store2.exists("", "zerotier-identity-secret")
|
||||
|
||||
@@ -97,11 +96,6 @@ def test_generate_secret(
|
||||
assert age_secret.isprintable()
|
||||
assert is_valid_age_key(age_secret)
|
||||
|
||||
# Assert that the ssh key is valid
|
||||
ssh_secret = store2.get("", "ssh.id_ed25519").decode()
|
||||
ssh_pub = machine_get_fact(test_flake_with_core.path, "vm2", "ssh.id_ed25519.pub")
|
||||
assert is_valid_ssh_key(ssh_secret, ssh_pub)
|
||||
|
||||
# Assert that root-password is valid
|
||||
pwd_secret = store2.get("", "password").decode()
|
||||
assert pwd_secret.isprintable()
|
||||
|
||||
@@ -10,7 +10,6 @@ from clan_cli.nix import nix_shell
|
||||
from clan_cli.ssh import HostGroup
|
||||
from fixtures_flakes import ClanFlake
|
||||
from helpers import cli
|
||||
from helpers.validator import is_valid_ssh_key
|
||||
|
||||
|
||||
@pytest.mark.impure
|
||||
@@ -90,14 +89,8 @@ def test_upload_secret(
|
||||
assert store.exists("", "password-hash")
|
||||
assert store.exists("", "user-password")
|
||||
assert store.exists("", "user-password-hash")
|
||||
assert store.exists("", "ssh.id_ed25519")
|
||||
assert store.exists("", "zerotier-identity-secret")
|
||||
|
||||
# Assert that the ssh key is valid
|
||||
ssh_secret = store.get("", "ssh.id_ed25519").decode()
|
||||
ssh_pub = machine_get_fact(flake.path, "vm1", "ssh.id_ed25519.pub")
|
||||
assert is_valid_ssh_key(ssh_secret, ssh_pub)
|
||||
|
||||
# Assert that root-password is valid
|
||||
pwd_secret = store.get("", "password").decode()
|
||||
assert pwd_secret.isprintable()
|
||||
|
||||
4
sops/machines/test-backup/key.json
Executable file
4
sops/machines/test-backup/key.json
Executable file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"publickey": "age1ez6xlcxl5k2uekcjvsu5wjca29f0j3lml0kq8fnvnkugvnj4pyjsyzuc93",
|
||||
"type": "age"
|
||||
}
|
||||
20
sops/secrets/test-backup-age.key/secret
Normal file
20
sops/secrets/test-backup-age.key/secret
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"data": "ENC[AES256_GCM,data:4Rx8J1mQjaJTmpN5ZBWWKBdObgjd4qjuEWkmXHHWeBUPi0nvYL00vFmdu2LeJYEmZaEP4urEGe9TlACB8JoC6ahI9iuXCWnzzA4=,iv:wOWFcNDU8+ur8sZRxlMr+TlzZmbsvE5o/FfXnINBmag=,tag:jHf+rpnSca9T0ntN93wSaw==,type:str]",
|
||||
"sops": {
|
||||
"kms": null,
|
||||
"gcp_kms": null,
|
||||
"azure_kv": null,
|
||||
"hc_vault": null,
|
||||
"age": [
|
||||
{
|
||||
"recipient": "age17n64ahe3wesh8l8lj0zylf4nljdmqn28hvqns2g7hgm9mdkhlsvsjuvkxz",
|
||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBpNE9nbmpoanhERGxuVUFL\ndnFPaVI1eXNqNENjeHVNV0Z0Rmx6NDJXNWtFCjdlOXhiU3hTRWRWVjJGMXpUTGtE\naUd5VUNUc2RML29ZSkJYWEd0VnJOc1UKLS0tIG9FdlNsMk5ETmRzaGEzRmN4allC\ncUMzd2N3dW03N0VvWit1eE9OVVRFcWsKzocpuGOlf3kYxbUDvVHP7G27G5n8vWFg\n5Jjf4qaW+ioXpqD0moHVVygbXXB6zkfrJraMaC9Sccdl8eLJNWuE0g==\n-----END AGE ENCRYPTED FILE-----\n"
|
||||
}
|
||||
],
|
||||
"lastmodified": "2024-11-14T16:33:56Z",
|
||||
"mac": "ENC[AES256_GCM,data:7G6svIuQrI2O5JZ3thNH6om8n3sdoTukfRQqOZ5/x28/BuVyfybsPP2So5MPlT4/OGPvhBGVWhHZ9W201zXOrzAI5T+bR2uX6VDeISRMscniVehnuAwipwCSqkBYO+FfvGjMSh8/kyF20PJR/Ta28qTcO7FKph2BgcOjhl1A0dM=,iv:BpqQT+rRk5mdCLY0dMoKyevhkJZ/dczeQPVSn+qzA9I=,tag:/gxOqChMYz4M65Z1SIcMsg==,type:str]",
|
||||
"pgp": null,
|
||||
"unencrypted_suffix": "_unencrypted",
|
||||
"version": "3.9.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILnWhu8zt/mD+TlIKr8Req4c+BCgqYuDOcZfmzj6kflF nixbld@turingmachine
|
||||
@@ -0,0 +1 @@
|
||||
../../../../../../sops/machines/test-backup
|
||||
24
vars/per-machine/test-backup/openssh/ssh.id_ed25519/secret
Normal file
24
vars/per-machine/test-backup/openssh/ssh.id_ed25519/secret
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"data": "ENC[AES256_GCM,data:KYuyTnrrzYUFWh+mXiy2puoW4BftckADAroO4FzpH6OJ9Hd9wUEj0iGcOqkb/VDmwaJxw5L7rqBHNNiKFXRNPsybTUUUQHFx35TWpS+sX16Y9F3vXF/LS+7B7R+EioFD2WViPOFvUM/8SJwF87gtCs8r8P46YQhpfS01GgGlFiPyOB0S6l/2RzGn4oJyseZIR+rF7SGDigIvFYyHzkufIKnTyaA3dkS+wLwZZLDmkzv9iNeHMqSecUOPlvqPgfizvjV8exNjVlypXAhX2fePHZhC+DOLdUrFy0aLfY+tejfJNKwwcc3krNwT94c0cSNvgylYVPyoU308eAzaDZufjSmZhc+kYPSO8bwemoX6eBnDy7vd3UzAQ/VfmPKoBuL8KzjSk02wUC5/Q8su1Xk397O12SS3GhGm3BAzMOf/QgqZK7eQsyOX2iBefoZTQ4rIED/szp2++KU1BFppXpvK1sdvcLg3g9v5pKvTgMGh25rJe/LI+jYFlPMdGgv1tY1GI504SM8B8l8a/9Ti1b7apO6QrUeT3Q/0jpJu,iv:YWZDzxpnrkTOmDJ/+0iO55dtbvX+UjqGctDduF3c1Sk=,tag:Q7e/9+0ClQn5bCGxoW+lOQ==,type:str]",
|
||||
"sops": {
|
||||
"kms": null,
|
||||
"gcp_kms": null,
|
||||
"azure_kv": null,
|
||||
"hc_vault": null,
|
||||
"age": [
|
||||
{
|
||||
"recipient": "age17n64ahe3wesh8l8lj0zylf4nljdmqn28hvqns2g7hgm9mdkhlsvsjuvkxz",
|
||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBQY1AzdmJGWitIYWlEUVM2\nTkRNeVZRYXg5SXRqdlRSOG9NaE8wNTZrU1FBCkRPOGE0bEZsMHdCSUdXQVE1UmVT\nMk9ZSHNZY0FXRG5WQVFaLzY3Ry9GeVEKLS0tIENHM09SWUJqMFJZbjBzdmp4eXlo\nNGJ2QWZaR1NiKzNVQXRVUnJSc2JycG8K9czRLJeCJp8vmPUY339x8Lvux3WDbrdJ\nbp7ynECENrgyP+5CdxopUQMGWptdjTYVho3nKv5NL+rjUZkfagV8HA==\n-----END AGE ENCRYPTED FILE-----\n"
|
||||
},
|
||||
{
|
||||
"recipient": "age1ez6xlcxl5k2uekcjvsu5wjca29f0j3lml0kq8fnvnkugvnj4pyjsyzuc93",
|
||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA4QVRianYxZ01QWEFSckdl\nd0taZlBWbTlkZHVmd2RXdmxYUjV3K3hHNzBJClMyQktNNWYrQTZkY09GL2dmYkRp\nOGl4WG85OHgxUWdDZVV3R1lDdjRZK2MKLS0tIHN3Ykk0OXA2c0o0a0RkTTZoZHZU\nTWJac09PM1J6clk2bDZxQVFyc29KbG8KZjHj109+FTmldFcdbLEGwSULWt3fLDXf\nYkeWVRa0rB2OtDxLrE6eq4QC1uGZ7KyCwAVqcMyriRMkje3121FBTQ==\n-----END AGE ENCRYPTED FILE-----\n"
|
||||
}
|
||||
],
|
||||
"lastmodified": "2024-11-14T16:33:58Z",
|
||||
"mac": "ENC[AES256_GCM,data:vWP3TwsjWwWg+gByhdcGVRR08jnH+AS8cOiuNic8AN2ozGZMP0C6mc5EER44wZiGeBWPdoTjqv9tQld4Gc5sWKuZ6QziNIqV/4WBwLwOTRmpVkJNpivJpCTdVyzQKv43xVk/e/ED8wTG6X9M83IGhMX6tUw9XxbCuJiZw61N/6Q=,iv:PTiwi9l16uKGQtNWXovA0Gjzg45O+T75BPT35gOzZLM=,tag:3AXwu/Va7yMsYlpd9Ss6hw==,type:str]",
|
||||
"pgp": null,
|
||||
"unencrypted_suffix": "_unencrypted",
|
||||
"version": "3.9.1"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user