move zerotier secret generation into nixos module
Some checks failed
checks-impure / test (pull_request) Failing after 7s
checks / test (pull_request) Successful in 23s

This commit is contained in:
Jörg Thalheim 2023-09-26 17:31:45 +02:00
parent 5d9ee64ddc
commit 74a3c85c29
15 changed files with 142 additions and 139 deletions

View File

@ -99,16 +99,16 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1695360818, "lastModified": 1695741452,
"narHash": "sha256-JlkN3R/SSoMTa+CasbxS1gq+GpGxXQlNZRUh9+LIy/0=", "narHash": "sha256-pDIQmCR0fyb6FKjvURaD6yC5YnE/+rxs5iFQQGgcoNE=",
"owner": "NixOS", "owner": "Mic92",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "e35dcc04a3853da485a396bdd332217d0ac9054f", "rev": "bc160df717ed1e9defe6044092ea66950976e3ed",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "NixOS", "owner": "Mic92",
"ref": "nixos-unstable", "ref": "fakeroot",
"repo": "nixpkgs", "repo": "nixpkgs",
"type": "github" "type": "github"
} }

View File

@ -2,7 +2,9 @@
description = "clan.lol base operating system"; description = "clan.lol base operating system";
inputs = { inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; #nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
# https://github.com/NixOS/nixpkgs/pull/257462
nixpkgs.url = "github:Mic92/nixpkgs/fakeroot";
floco.url = "github:aakropotkin/floco"; floco.url = "github:aakropotkin/floco";
floco.inputs.nixpkgs.follows = "nixpkgs"; floco.inputs.nixpkgs.follows = "nixpkgs";
disko.url = "github:nix-community/disko/party"; disko.url = "github:nix-community/disko/party";

View File

@ -9,6 +9,22 @@
one would have to define system.clan.generateSecrets and system.clan.uploadSecrets one would have to define system.clan.generateSecrets and system.clan.uploadSecrets
''; '';
}; };
options.clanCore.secretsDirectory = lib.mkOption {
type = lib.types.path;
description = ''
The directory where secrets are installed to. This is backend specific.
'';
};
options.clanCore.secretsPrefix = lib.mkOption {
type = lib.types.str;
default = "";
description = ''
Prefix for secrets. This is backend specific.
'';
};
options.clanCore.secrets = lib.mkOption { options.clanCore.secrets = lib.mkOption {
default = { }; default = { };
type = lib.types.attrsOf type = lib.types.attrsOf
@ -31,22 +47,33 @@
The script is expected to generate all secrets and facts defined in the module. The script is expected to generate all secrets and facts defined in the module.
''; '';
}; };
secrets = lib.mkOption { secrets =
type = lib.types.attrsOf (lib.types.submodule (secret: { let
options = { config' = config;
name = lib.mkOption { in
type = lib.types.str; lib.mkOption {
description = '' type = lib.types.attrsOf (lib.types.submodule ({ config, ... }: {
name of the secret options = {
''; name = lib.mkOption {
default = secret.config._module.args.name; type = lib.types.str;
description = ''
name of the secret
'';
default = config._module.args.name;
};
path = lib.mkOption {
type = lib.types.str;
description = ''
path to a secret which is generated by the generator
'';
default = "${config'.clanCore.secretsDirectory}/${config'.clanCore.secretsPrefix}${config.name}";
};
}; };
}; }));
})); description = ''
description = '' path where the secret is located in the filesystem
path where the secret is located in the filesystem '';
''; };
};
facts = lib.mkOption { facts = lib.mkOption {
type = lib.types.attrsOf (lib.types.submodule (fact: { type = lib.types.attrsOf (lib.types.submodule (fact: {
options = { options = {

View File

@ -3,14 +3,8 @@ let
passwordstoreDir = "\${PASSWORD_STORE_DIR:-$HOME/.password-store}"; passwordstoreDir = "\${PASSWORD_STORE_DIR:-$HOME/.password-store}";
in in
{ {
options.clan.password-store.targetDirectory = lib.mkOption {
type = lib.types.path;
default = "/etc/secrets";
description = ''
The directory where the password store is uploaded to.
'';
};
config = lib.mkIf (config.clanCore.secretStore == "password-store") { config = lib.mkIf (config.clanCore.secretStore == "password-store") {
clanCore.secretsDirectory = passwordstoreDir;
system.clan.generateSecrets = pkgs.writeScript "generate-secrets" '' system.clan.generateSecrets = pkgs.writeScript "generate-secrets" ''
#!/bin/sh #!/bin/sh
set -efu set -efu

View File

@ -3,6 +3,7 @@ let
secretsDir = config.clanCore.clanDir + "/sops/secrets"; secretsDir = config.clanCore.clanDir + "/sops/secrets";
groupsDir = config.clanCore.clanDir + "/sops/groups"; groupsDir = config.clanCore.clanDir + "/sops/groups";
# My symlink is in the nixos module detected as a directory also it works in the repl. Is this because of pure evaluation? # My symlink is in the nixos module detected as a directory also it works in the repl. Is this because of pure evaluation?
containsSymlink = path: containsSymlink = path:
builtins.pathExists path && (builtins.readFileType path == "directory" || builtins.readFileType path == "symlink"); builtins.pathExists path && (builtins.readFileType path == "directory" || builtins.readFileType path == "symlink");
@ -22,7 +23,10 @@ let
in in
{ {
config = lib.mkIf (config.clanCore.secretStore == "sops") { config = lib.mkIf (config.clanCore.secretStore == "sops") {
clanCore.secretsDirectory = "/run/secrets";
clanCore.secretsPrefix = config.clanCore.machineName + "-";
system.clan = { system.clan = {
generateSecrets = pkgs.writeScript "generate-secrets" '' generateSecrets = pkgs.writeScript "generate-secrets" ''
#!${pkgs.python3}/bin/python #!${pkgs.python3}/bin/python
import json import json

View File

@ -45,8 +45,8 @@ in
{ {
options.clan.networking.zerotier = { options.clan.networking.zerotier = {
networkId = lib.mkOption { networkId = lib.mkOption {
type = lib.types.nullOr lib.types.str; type = lib.types.str;
default = null; default = config.clanCore.secrets.zerotier.facts."zerotier-network-id".value;
description = '' description = ''
zerotier networking id zerotier networking id
''; '';
@ -63,6 +63,11 @@ in
}; };
}; };
config = lib.mkMerge [ config = lib.mkMerge [
({
# Override license so that we can build zerotierone without
# having to re-import nixpkgs.
services.zerotierone.package = lib.mkDefault (pkgs.zerotierone.overrideAttrs (_old: { meta = { }; }));
})
(lib.mkIf (cfg.networkId != null) { (lib.mkIf (cfg.networkId != null) {
systemd.network.networks.zerotier = { systemd.network.networks.zerotier = {
matchConfig.Name = "zt*"; matchConfig.Name = "zt*";
@ -85,24 +90,20 @@ in
# only the controller needs to have the key in the repo, the other clients can be dynamic # only the controller needs to have the key in the repo, the other clients can be dynamic
# we generate the zerotier code manually for the controller, since it's part of the bootstrap command # we generate the zerotier code manually for the controller, since it's part of the bootstrap command
clanCore.secrets.zerotier = { clanCore.secrets.zerotier = {
facts."zerotier.network.id" = { }; facts."zerotier-network-id" = { };
secrets."zerotier.identity.secret" = { }; secrets."zerotier-identity-secret" = { };
generator = '' generator = ''
TMPDIR=$(mktemp -d) export PATH=${lib.makeBinPath [ config.services.zerotierone.package pkgs.fakeroot ]}
trap 'rm -rf "$TMPDIR"' EXIT ${pkgs.python3.interpreter} ${./generate-network.py} "$facts/zerotier-network-id" "$secrets/zerotier-identity-secret"
${config.clanCore.clanPkgs.clan-cli}/bin/clan zerotier --outpath "$TMPDIR"
cp "$TMPDIR"/network.id "$facts"/zerotier.network.id
cp "$TMPDIR"/identity.secret "$secrets"/zerotier.identity.secret
''; '';
}; };
systemd.services.zerotierone.serviceConfig.ExecStartPre = [ systemd.services.zerotierone.serviceConfig.ExecStartPre = [
"+${pkgs.writeShellScript "init_zerotier" '' "+${pkgs.writeShellScript "init-zerotier" ''
cp /etc/secrets/zerotier.identity.secret /var/lib/zerotier-one/identity.secret cp ${config.clanCore.secrets.zerotier.secrets.zerotier-identity-secret.path} /var/lib/zerotier-one/identity.secret
ln -sfT ${pkgs.writeText "net.json" (builtins.toJSON networkConfig)} /var/lib/zerotier-one/controller.d/network/${cfg.networkId}.json ln -sfT ${pkgs.writeText "net.json" (builtins.toJSON networkConfig)} /var/lib/zerotier-one/controller.d/network/${cfg.networkId}.json
''}" ''}"
]; ];
}) })
]; ];
} }

View File

@ -9,8 +9,9 @@ from pathlib import Path
from tempfile import TemporaryDirectory from tempfile import TemporaryDirectory
from typing import Any, Iterator, Optional from typing import Any, Iterator, Optional
from ..errors import ClanError
from ..nix import nix_shell, unfree_nix_shell class ClanError(Exception):
pass
def try_bind_port(port: int) -> bool: def try_bind_port(port: int) -> bool:
@ -85,37 +86,17 @@ def zerotier_controller() -> Iterator[ZerotierController]:
if controller_port is None: if controller_port is None:
raise ClanError("cannot find a free port for zerotier controller") raise ClanError("cannot find a free port for zerotier controller")
cmd = unfree_nix_shell(
["bash", "zerotierone"], ["bash", "-c", "command -v zerotier-one"]
)
res = subprocess.run(
cmd,
check=True,
text=True,
stdout=subprocess.PIPE,
)
zerotier_exe = res.stdout.strip()
if zerotier_exe is None:
raise ClanError("cannot find zerotier-one executable")
if not zerotier_exe.startswith("/nix/store"):
raise ClanError(
f"zerotier-one executable needs to come from /nix/store: {zerotier_exe}"
)
with TemporaryDirectory() as d: with TemporaryDirectory() as d:
tempdir = Path(d) tempdir = Path(d)
home = tempdir / "zerotier-one" home = tempdir / "zerotier-one"
home.mkdir() home.mkdir()
cmd = nix_shell( cmd = [
["fakeroot"], "fakeroot",
[ "--",
"fakeroot", "zerotier-one",
"--", f"-p{controller_port}",
zerotier_exe, str(home),
f"-p{controller_port}", ]
str(home),
],
)
with subprocess.Popen(cmd) as p: with subprocess.Popen(cmd) as p:
try: try:
print( print(
@ -146,18 +127,16 @@ def create_network() -> dict:
} }
def main(args: argparse.Namespace) -> None: def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("network_id")
parser.add_argument("identity_secret")
args = parser.parse_args()
zerotier = create_network() zerotier = create_network()
outpath = Path(args.outpath) Path(args.network_id).write_text(zerotier["networkid"])
outpath.mkdir(parents=True, exist_ok=True) Path(args.identity_secret).write_text(zerotier["secret"])
with open(outpath / "network.id", "w+") as nwid_file:
nwid_file.write(zerotier["networkid"])
with open(outpath / "identity.secret", "w+") as secret_file:
secret_file.write(zerotier["secret"])
def register_parser(parser: argparse.ArgumentParser) -> None: if __name__ == "__main__":
parser.add_argument( main()
"--outpath", help="directory to put the secret file to", required=True
)
parser.set_defaults(func=main)

View File

@ -0,0 +1,35 @@
[tool.mypy]
python_version = "3.10"
warn_redundant_casts = true
disallow_untyped_calls = true
disallow_untyped_defs = true
no_implicit_optional = true
exclude = "clan_cli.nixpkgs"
[tool.ruff]
line-length = 88
select = [ "E", "F", "I", "U", "N"]
ignore = [ "E501" ]
[tool.black]
line-length = 88
target-version = [ "py310" ]
include = "\\.pyi?$"
exclude = '''
/(
\.git
| \.hg
| \.mypy_cache
| \.tox
| \.venv
| _build
| buck-out
| build
| dist
# The following are specific to Black, you probably don't want those.
| blib2to3
| tests/data
| profiling
)/
'''

View File

@ -3,7 +3,7 @@ import sys
from types import ModuleType from types import ModuleType
from typing import Optional from typing import Optional
from . import config, create, machines, secrets, webui, zerotier from . import config, create, machines, secrets, webui
from .errors import ClanError from .errors import ClanError
from .ssh import cli as ssh_cli from .ssh import cli as ssh_cli
@ -47,9 +47,6 @@ def create_parser(prog: Optional[str] = None) -> argparse.ArgumentParser:
parser_webui = subparsers.add_parser("webui", help="start webui") parser_webui = subparsers.add_parser("webui", help="start webui")
webui.register_parser(parser_webui) webui.register_parser(parser_webui)
parser_zerotier = subparsers.add_parser("zerotier", help="create zerotier network")
zerotier.register_parser(parser_zerotier)
if argcomplete: if argcomplete:
argcomplete.autocomplete(parser) argcomplete.autocomplete(parser)

View File

@ -4,7 +4,7 @@ import subprocess
import tempfile import tempfile
from typing import Any from typing import Any
from .dirs import nixpkgs_flake, nixpkgs_source, unfree_nixpkgs from .dirs import nixpkgs_flake, nixpkgs_source
def nix_command(flags: list[str]) -> list[str]: def nix_command(flags: list[str]) -> list[str]:
@ -82,20 +82,3 @@ def nix_shell(packages: list[str], cmd: list[str]) -> list[str]:
+ ["-c"] + ["-c"]
+ cmd + cmd
) )
def unfree_nix_shell(packages: list[str], cmd: list[str]) -> list[str]:
if os.environ.get("IN_NIX_SANDBOX"):
return cmd
return (
nix_command(
[
"shell",
"-f",
str(unfree_nixpkgs()),
]
)
+ packages
+ ["-c"]
+ cmd
)

View File

@ -16,7 +16,6 @@
, sops , sops
, stdenv , stdenv
, wheel , wheel
, zerotierone
, fakeroot , fakeroot
, rsync , rsync
, ui-assets , ui-assets
@ -49,7 +48,6 @@ let
runtimeDependencies = [ runtimeDependencies = [
bash bash
nix nix
zerotierone
fakeroot fakeroot
openssh openssh
sshpass sshpass
@ -77,10 +75,6 @@ let
''; '';
nixpkgs' = runCommand "nixpkgs" { nativeBuildInputs = [ nix ]; } '' nixpkgs' = runCommand "nixpkgs" { nativeBuildInputs = [ nix ]; } ''
mkdir $out mkdir $out
mkdir -p $out/unfree
cat > $out/unfree/default.nix <<EOF
import "${nixpkgs}" { config = { allowUnfree = true; overlays = []; }; }
EOF
cat > $out/flake.nix << EOF cat > $out/flake.nix << EOF
{ {
description = "dependencies for the clan-cli"; description = "dependencies for the clan-cli";

View File

@ -6,16 +6,11 @@
}; };
packages = { packages = {
clan-cli = pkgs.python3.pkgs.callPackage ./default.nix { clan-cli = pkgs.python3.pkgs.callPackage ./default.nix {
inherit (self'.packages) ui-assets zerotierone; inherit (self'.packages) ui-assets;
inherit (inputs) nixpkgs; inherit (inputs) nixpkgs;
}; };
clan-openapi = self'.packages.clan-cli.clan-openapi; clan-openapi = self'.packages.clan-cli.clan-openapi;
default = self'.packages.clan-cli; default = self'.packages.clan-cli;
# Override license so that we can build zerotierone without
# having to re-import nixpkgs.
zerotierone = pkgs.zerotierone.overrideAttrs (_old: { meta = { }; });
## End optional dependencies
}; };
checks = self'.packages.clan-cli.tests; checks = self'.packages.clan-cli.tests;

View File

@ -10,19 +10,13 @@
clan = clan-core.lib.buildClan { clan = clan-core.lib.buildClan {
directory = self; directory = self;
machines = { machines = {
vm1 = { modulesPath, ... }: { vm1 = { modulesPath, lib, ... }: {
imports = [ "${toString modulesPath}/virtualisation/qemu-vm.nix" ]; imports = [ "${toString modulesPath}/virtualisation/qemu-vm.nix" ];
clan.networking.deploymentAddress = "__CLAN_DEPLOYMENT_ADDRESS__"; clan.networking.deploymentAddress = "__CLAN_DEPLOYMENT_ADDRESS__";
sops.age.keyFile = "__CLAN_SOPS_KEY_PATH__"; sops.age.keyFile = "__CLAN_SOPS_KEY_PATH__";
system.stateVersion = lib.version;
clanCore.secrets.testpassword = { clan.networking.zerotier.controller.enable = true;
generator = ''
echo "secret1" > "$secrets/secret1"
echo "fact1" > "$facts/fact1"
'';
secrets.secret1 = { };
facts.fact1 = { };
};
}; };
}; };
}; };

View File

@ -24,15 +24,19 @@ def test_upload_secret(
cli.run(["secrets", "users", "add", "user1", age_keys[0].pubkey]) cli.run(["secrets", "users", "add", "user1", age_keys[0].pubkey])
cli.run(["secrets", "generate", "vm1"]) cli.run(["secrets", "generate", "vm1"])
has_secret("vm1-age.key") has_secret("vm1-age.key")
has_secret("vm1-secret1") has_secret("vm1-zerotier-identity-secret")
fact1 = machine_get_fact("vm1", "fact1") network_id = machine_get_fact("vm1", "zerotier-network-id")
assert fact1 == "fact1\n" assert len(network_id) == 16
age_key = sops_secrets_folder().joinpath("vm1-age.key").joinpath("secret") age_key = sops_secrets_folder().joinpath("vm1-age.key").joinpath("secret")
secret1 = sops_secrets_folder().joinpath("vm1-secret1").joinpath("secret") identity_secret = (
sops_secrets_folder()
.joinpath("vm1-zerotier-identity-secret")
.joinpath("secret")
)
age_key_mtime = age_key.lstat().st_mtime_ns age_key_mtime = age_key.lstat().st_mtime_ns
secret1_mtime = secret1.lstat().st_mtime_ns secret1_mtime = identity_secret.lstat().st_mtime_ns
# test idempotency # test idempotency
cli.run(["secrets", "generate", "vm1"]) cli.run(["secrets", "generate", "vm1"])
assert age_key.lstat().st_mtime_ns == age_key_mtime assert age_key.lstat().st_mtime_ns == age_key_mtime
assert secret1.lstat().st_mtime_ns == secret1_mtime assert identity_secret.lstat().st_mtime_ns == secret1_mtime

View File

@ -1,6 +0,0 @@
from clan_cli.zerotier import create_network
def test_create_network() -> None:
network = create_network()
assert network["networkid"]