move zerotier secret generation into nixos module

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

@@ -3,7 +3,7 @@ import sys
from types import ModuleType
from typing import Optional
from . import config, create, machines, secrets, webui, zerotier
from . import config, create, machines, secrets, webui
from .errors import ClanError
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")
webui.register_parser(parser_webui)
parser_zerotier = subparsers.add_parser("zerotier", help="create zerotier network")
zerotier.register_parser(parser_zerotier)
if argcomplete:
argcomplete.autocomplete(parser)

View File

@@ -4,7 +4,7 @@ import subprocess
import tempfile
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]:
@@ -82,20 +82,3 @@ def nix_shell(packages: list[str], cmd: list[str]) -> list[str]:
+ ["-c"]
+ 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

@@ -1,163 +0,0 @@
import argparse
import json
import socket
import subprocess
import time
import urllib.request
from contextlib import contextmanager
from pathlib import Path
from tempfile import TemporaryDirectory
from typing import Any, Iterator, Optional
from ..errors import ClanError
from ..nix import nix_shell, unfree_nix_shell
def try_bind_port(port: int) -> bool:
tcp = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
with tcp, udp:
try:
tcp.bind(("127.0.0.1", port))
udp.bind(("127.0.0.1", port))
return True
except OSError:
return False
def try_connect_port(port: int) -> bool:
sock = socket.socket(socket.AF_INET)
result = sock.connect_ex(("127.0.0.1", port))
sock.close()
return result == 0
def find_free_port(port_range: range) -> Optional[int]:
for port in port_range:
if try_bind_port(port):
return port
return None
class ZerotierController:
def __init__(self, port: int, home: Path) -> None:
self.port = port
self.home = home
self.authtoken = (home / "authtoken.secret").read_text()
self.secret = (home / "identity.secret").read_text()
def _http_request(
self,
path: str,
method: str = "GET",
headers: dict[str, str] = {},
data: Optional[dict[str, Any]] = None,
) -> dict[str, Any]:
body = None
headers = headers.copy()
if data is not None:
body = json.dumps(data).encode("ascii")
headers["Content-Type"] = "application/json"
headers["X-ZT1-AUTH"] = self.authtoken
url = f"http://127.0.0.1:{self.port}{path}"
req = urllib.request.Request(url, headers=headers, method=method, data=body)
resp = urllib.request.urlopen(req)
return json.load(resp)
def status(self) -> dict[str, Any]:
return self._http_request("/status")
def create_network(self, data: dict[str, Any] = {}) -> dict[str, Any]:
identity = (self.home / "identity.public").read_text()
node_id = identity.split(":")[0]
return self._http_request(
f"/controller/network/{node_id}______", method="POST", data=data
)
def get_network(self, id: str) -> dict[str, Any]:
return self._http_request(f"/controller/network/{id}")
@contextmanager
def zerotier_controller() -> Iterator[ZerotierController]:
# This check could be racy but it's unlikely in practice
controller_port = find_free_port(range(10000, 65535))
if controller_port is None:
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:
tempdir = Path(d)
home = tempdir / "zerotier-one"
home.mkdir()
cmd = nix_shell(
["fakeroot"],
[
"fakeroot",
"--",
zerotier_exe,
f"-p{controller_port}",
str(home),
],
)
with subprocess.Popen(cmd) as p:
try:
print(
f"wait for controller to be started on 127.0.0.1:{controller_port}...",
)
while not try_connect_port(controller_port):
status = p.poll()
if status is not None:
raise ClanError(
f"zerotier-one has been terminated unexpected with {status}"
)
time.sleep(0.1)
print()
yield ZerotierController(controller_port, home)
finally:
p.kill()
p.wait()
# TODO: allow merging more network configuration here
def create_network() -> dict:
with zerotier_controller() as controller:
network = controller.create_network()
return {
"secret": controller.secret,
"networkid": network["nwid"],
}
def main(args: argparse.Namespace) -> None:
zerotier = create_network()
outpath = Path(args.outpath)
outpath.mkdir(parents=True, exist_ok=True)
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:
parser.add_argument(
"--outpath", help="directory to put the secret file to", required=True
)
parser.set_defaults(func=main)

View File

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

View File

@@ -6,16 +6,11 @@
};
packages = {
clan-cli = pkgs.python3.pkgs.callPackage ./default.nix {
inherit (self'.packages) ui-assets zerotierone;
inherit (self'.packages) ui-assets;
inherit (inputs) nixpkgs;
};
clan-openapi = self'.packages.clan-cli.clan-openapi;
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;

View File

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

View File

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