Compare commits

..

2 Commits

Author SHA1 Message Date
a-kenji
c7c400f51f wip 2024-04-24 10:42:34 +02:00
a-kenji
ea6bd8f41d checks 2024-04-24 10:42:34 +02:00
29 changed files with 122 additions and 735 deletions

View File

@@ -63,9 +63,7 @@
};
};
nodes.client = {
environment.systemPackages = [
self.packages.${pkgs.system}.clan-cli
] ++ self.packages.${pkgs.system}.clan-cli.runtimeDependencies;
environment.systemPackages = [ self.packages.${pkgs.system}.clan-cli ];
environment.etc."install-closure".source = "${closureInfo}/store-paths";
virtualisation.memorySize = 2048;
nix.settings = {

View File

@@ -10,19 +10,19 @@ validation:
unrecognized_links: warn
markdown_extensions:
- admonition
- attr_list
- footnotes
- meta
- plantuml_markdown
- pymdownx.emoji:
emoji_index: !!python/name:material.extensions.emoji.twemoji
emoji_generator: !!python/name:material.extensions.emoji.to_svg
- pymdownx.tasklist:
custom_checkbox: true
- pymdownx.superfences
- pymdownx.tabbed:
alternate_style: true
- footnotes
- meta
- admonition
- pymdownx.details
- pymdownx.highlight:
use_pygments: true

View File

@@ -1,17 +1,11 @@
{ pkgs, module-docs, ... }:
let
uml-c4 = pkgs.python3Packages.plantuml-markdown.override { plantuml = pkgs.plantuml-c4; };
in
pkgs.stdenv.mkDerivation {
name = "clan-documentation";
src = ../.;
nativeBuildInputs =
[
pkgs.python3
uml-c4
]
[ pkgs.python3 ]
++ (with pkgs.python3Packages; [
mkdocs
mkdocs-material

View File

@@ -1,324 +0,0 @@
## Secrets (CLI Reference)
#### Adding Secrets (set)
```bash
clan secrets set mysecret
> Paste your secret:
```
!!! note
As you type your secret won't be displayed. Press Enter to save the secret.
#### List all Secrets (list)
```bash
clan secrets list
```
#### Assigning Access (set)
By default, secrets are encrypted for your key. To specify which users and machines can access a secret:
```bash
clan secrets set --machine <machine1> --machine <machine2> --user <user1> --user <user2> <secret_name>
```
#### Displaying Secrets (get)
```bash
clan secrets get mysecret
```
#### Rename
TODO
#### Remove
TODO
#### import-sops
TODO
### Users (Reference)
Learn how to manage users and allowing access to existing secrets.
#### list user
Lists all added users
```bash
clan secrets user list
```
``` {.console, title="Example output", .no-copy}
jon
sara
```
!!! Question "Who can execute this command?"
Everyone - completely public.
#### add user
add a user
```bash
clan secrets users add {username} {public-key}
```
!!! Note
Changes can be trusted by maintainer review in version control.
#### get user
get a user public key
```bash
clan secrets users get {username}
```
``` {.console, title="Example output", .no-copy}
age1zk8uzrte55wkg9lkqxu5x6twsj2ja4lehegks0cw4mkg6jv37d9qsjpt44
```
#### remove user
remove a user
```bash
clan secrets users remove {username}
```
!!! Note
Changes can be trusted by maintainer review in version control.
#### add-secret user
Grants the user (`username`) access to the secret (`secret_name`)
```bash
clan secrets users add-secret {username} {secret_name}
```
!!! Note
Requires the executor of the command to have access to the secret (`secret_name`).
#### remove-secret user
remove the user (`username`) from accessing the secret (`secret_name`)
!!! Danger "Make sure at least one person has access."
It might still be possible for the machine to access the secret. (See [machines](#machines))
We highly recommend to use version control such as `git` which allows you to rollback secrets in case anything gets messed up.
```bash
clan secrets users remove-secret {username} {secret_name}
```
!!! Question "Who can execute this command?"
Requires the executor of the command to have access to the secret (`secret_name`).
### Machines (Reference)
- [list](): list machines
- [add](): add a machine
- [get](): get a machine public key
- [remove](): remove a machine
- [add-secret](): allow a machine to access a secret
- [remove-secret](): remove a machine's access to a secret
#### List machine
New machines in Clan come with age keys stored in `./sops/machines/<machine_name>`. To list these machines:
```bash
clan secrets machines list
```
#### Add machine
For clan machines the machine key is generated automatically on demand if none exists.
```bash
clan secrets machines add <machine_name> <age_key>
```
If you already have a device key and want to add it manually, see: [How to obtain a remote key](#obtain-remote-keys-manually)
#### get machine
TODO
#### remove machine
TODO
#### add-secret machine
TODO
#### remove-secret machine
TODO
### Groups (Reference)
The Clan-CLI makes it easy to manage access by allowing you to create groups.
- [list](): list groups
- [add-user](): add a user to group
- [remove-user](): remove a user from group
- [add-machine](): add a machine to group
- [remove-machine](): remove a machine from group
- [add-secret](): allow a user to access a secret
- [remove-secret](): remove a group's access to a secret
#### List Groups
```bash
clan secrets groups list
```
#### add-user
Assign users to a new group, e.g., `admins`:
```bash
clan secrets groups add-user admins <username>
```
!!! info
The group is created if no such group existed before.
The user must exist in beforehand (See: [users](#users-reference))
```{.console, .no-copy}
.
├── flake.nix
. ...
└── sops
├── groups
│ └── admins
│ └── users
│ └── <username> -> ../../../users/<username>
```
#### remove-user
TODO
#### add-machine
TODO
#### remove-machine
TODO
#### add-secret
```bash
clan secrets groups add-secret <group_name> <secret_name>
```
#### remove-secret
TODO
### Key (Reference)
- [generate]() generate age key
- [show]() show age public key
- [update]() re-encrypt all secrets with current keys (useful when changing keys)
#### generate
TODO
#### show
TODO
#### update
TODO
## Further
Secrets in the repository follow this structure:
```{.console, .no-copy}
sops/
├── secrets/
│ └── <secret_name>/
│ ├── secret
│ └── users/
│ └── <your_username>/
```
The content of the secret is stored encrypted inside the `secret` file under `mysecret`.
By default, secrets are encrypted with your key to ensure readability.
### Obtain remote keys manually
To fetch a **SSH host key** from a preinstalled system:
```bash
ssh-keyscan <domain_name> | nix shell nixpkgs#ssh-to-age -c ssh-to-age
```
!!! Success
This command converts the SSH key into an age key on the fly. Since this is the format used by the clan secrets backend.
Once added the **SSH host key** enables seamless integration of existing machines with clan.
Then add the key by executing:
```bash
clan secrets machines add <machine_name> <age_key>
```
See also: [Machine reference](#machines-reference)
### NixOS integration
A NixOS machine will automatically import all secrets that are encrypted for the
current machine. At runtime it will use the host key to decrypt all secrets into
an in-memory, non-persistent filesystem using [sops-nix](https://github.com/Mic92/sops-nix).
In your nixos configuration you can get a path to secrets like this `config.sops.secrets.<name>.path`. For example:
```nix
{ config, ...}: {
sops.secrets.my-password.neededForUsers = true;
users.users.mic92 = {
isNormalUser = true;
passwordFile = config.sops.secrets.my-password.path;
};
}
```
See the [readme](https://github.com/Mic92/sops-nix) of sops-nix for more
examples.
### Migration: Importing existing sops-based keys / sops-nix
`clan secrets` stores each secret in a single file, whereas [sops](https://github.com/Mic92/sops-nix) commonly allows to put all secrets in a yaml or json document.
If you already happened to use sops-nix, you can migrate by using the `clan secrets import-sops` command by importing these files:
```bash
% clan secrets import-sops --prefix matchbox- --group admins --machine matchbox nixos/matchbox/secrets/secrets.yaml
```
This will create secrets for each secret found in `nixos/matchbox/secrets/secrets.yaml` in a `./sops` folder of your repository.
Each member of the group `admins` in this case will be able to decrypt the secrets with their respective key.
Since our clan secret module will auto-import secrets that are encrypted for a particular nixos machine,
you can now remove `sops.secrets.<secrets> = { };` unless you need to specify more options for the secret like owner/group of the secret file.

View File

@@ -148,12 +148,3 @@ Adding or configuring a new machine requires two simple steps:
**All facts are automatically initialized.**
If you need additional help see our [facts chapter](./secrets.md)
---
## Whats next?
- [Deploying](machines.md): Deploying a Machine configuration
- [Secrets](secrets.md): Learn about secrets and facts
---

View File

@@ -62,7 +62,10 @@ clan machines install my-machine <target_host>
## What's next ?
- [**Update a Machine**](#update-your-machines): Learn how to update an existing machine?
- [**Configure a Private Network**](./networking.md): Configuring a secure mesh network.
Coming Soon:
- **Join Your Machines in a Private Network:**: Stay tuned for steps on linking all your machines into a secure mesh network with Clan.
---

View File

@@ -5,28 +5,16 @@ This guide provides detailed instructions for configuring
outlined steps to set up a machine as a VPN controller (`<CONTROLLER>`) and to
include a new machine into the VPN.
## Concept
By default all machines within one clan are connected via a chosen network technology.
```
Clan
Node A
<-> (zerotier / mycelium / ...)
Node B
```
If you select multiple network technologies at the same time. e.g. (zerotier + yggdrassil)
You must choose one of them as primary network and the machines are always connected via the primary network.
## 1. Set-Up the VPN Controller
## 1. Setting Up the VPN Controller
The VPN controller is initially essential for providing configuration to new
peers. Once addresses are allocated, the controller's continuous operation is not essential.
### Instructions
1. **Designate a Machine**: Label a machine as the VPN controller in the clan,
referred to as `<CONTROLLER>` henceforth in this guide.
2. **Add Configuration**: Input the following configuration to the NixOS
1. **Add Configuration**: Input the following configuration to the NixOS
configuration of the controller machine:
```nix
clan.networking.zerotier.controller = {
@@ -34,16 +22,18 @@ peers. Once addresses are allocated, the controller's continuous operation is no
public = true;
};
```
3. **Update the Controller Machine**: Execute the following:
1. **Update the Controller Machine**: Execute the following:
```bash
$ clan machines update <CONTROLLER>
```
Your machine is now operational as the VPN controller.
## 2. Add Machines to the VPN
## 2. Integrating a New Machine to the VPN
To introduce a new machine to the VPN, adhere to the following steps:
### Instructions:
1. **Update Configuration**: On the new machine, incorporate the following to its
configuration, substituting `<CONTROLLER>` with the controller machine name:
```nix
@@ -56,25 +46,22 @@ To introduce a new machine to the VPN, adhere to the following steps:
$ clan machines update <NEW_MACHINE>
```
Replace `<NEW_MACHINE>` with the designated new machine name.
!!! Note "For Private Networks"
1. **Retrieve the ZeroTier ID**: On the `new_machine`, execute:
```bash
$ sudo zerotier-cli info
```
Example Output:
```{.console, .no-copy}
200 info d2c71971db 1.12.1 OFFLINE
```
, where `d2c71971db` is the ZeroTier ID.
2. **Authorize the New Machine on the Controller**: On the controller machine,
execute:
```bash
$ sudo zerotier-members allow <ID>
```
Substitute `<ID>` with the ZeroTier ID obtained previously.
2. **Verify Connection**: On the `new_machine`, re-execute:
1. **Retrieve the ZeroTier ID**: On the `new_machine`, execute:
```bash
$ sudo zerotier-cli info
```
Example Output:
```{.console, .no-copy}
200 info d2c71971db 1.12.1 OFFLINE
```
, where `d2c71971db` is the ZeroTier ID.
1. **Authorize the New Machine on the Controller**: On the controller machine,
execute:
```bash
$ sudo zerotier-members allow <ID>
```
Substitute `<ID>` with the ZeroTier ID obtained previously.
1. **Verify Connection**: On the `new_machine`, re-execute:
```bash
$ sudo zerotier-cli info
```
@@ -87,11 +74,22 @@ To introduce a new machine to the VPN, adhere to the following steps:
The new machine is now part of the VPN, and the ZeroTier
configuration on NixOS within the Clan project is complete.
## Further
## Decision
We chose zerotier because in our tests it was the easiest solution to bootstrap. You can selfhost a controller and the controller doesn't need to be globally reachable.
Currently you can only use **Zerotier** as networking technology because this is the first network stack we aim to support.
In the future we plan to add additional network technologies like tinc, head/tailscale, yggdrassil and mycelium.
We chose zerotier because in our tests it was a straight forwards solution to bootstrap.
It allows you to selfhost a controller and the controller doesn't need to be globally reachable.
Which made it a good fit for starting the project.
## Specification
By default all machines within one clan are connected via the chosen network technology.
```
Clan
Node A
<-> (zerotier / mycelium / ...)
Node B
```
If you select multiple network technologies at the same time. e.g. (zerotier + yggdrassil)
One of them is the primary network and the above statement holds for the primary network.

View File

@@ -6,7 +6,7 @@ Clan utilizes the [sops](https://github.com/getsops/sops) format and integrates
This documentation will guide you through managing secrets with the Clan CLI
## Initializing Secrets (Quickstart)
## 1. Initializing Secrets
### Create Your Master Keypair
@@ -28,11 +28,11 @@ Generated age private key at '/home/joerg/.config/sops/age/keys.txt' for your us
Also add your age public key to the repository with 'clan secrets users add YOUR_USER age1wkth7uhpkl555g40t8hjsysr20drq286netu8zptw50lmqz7j95sw2t3l7' (replace YOUR_USER with your actual username)
```
!!! warning
!!! warning
Make sure to keep a safe backup of the private key you've just created.
If it's lost, you won't be able to get to your secrets anymore because they all need the master key to be unlocked.
!!! note
!!! note
It's safe to add any secrets created by the clan CLI and placed in your repository to version control systems like `git`.
### Add Your Public Key
@@ -41,7 +41,7 @@ Also add your age public key to the repository with 'clan secrets users add YOUR
clan secrets users add <your_username> <your_public_key>
```
!!! note
!!! note
Choose the same username as on your Setup/Source Machine that you use to control the deployment with.
Once run this will create the following files:
@@ -53,137 +53,6 @@ sops/
└── key.json
```
---
> If you followed the quickstart tutorial all necessary secrets are initialized at this point.
- Continue with [deploying machines](./machines.md)
- Learn about the [basics concept](#concept) of clan secrets
---
## Concept
The secrets system conceptually knows two different entities:
- **Machine**: consumes secrets
- **User**: manages access to secrets
**A Users** Can add or revoke machines' access to secrets.
**A machine** Can decrypt secrets that where encrypted specifically for that machine.
!!! Danger
**Always make sure at least one _User_ has access to a secret**. Otherwise you could lock yourself out from accessing the secret.
### Inherited implications
By default clan uses [sops](https://github.com/getsops/sops) through [sops-nix](https://github.com/Mic92/sops-nix) for managing its secrets which inherits some implications that are important to understand:
- **Public/Private keys**: Entities are identified via their public keys. Each Entity can use their respective private key to decrypt a secret.
- **Public keys are stored**: All Public keys are stored inside the repository
- **Secrets are stored Encrypted**: secrets are stored inside the repository encrypted with the respective public keys
- **Secrets are deployed encrypted**: Fully encrypted secrets are deployed to machines at deployment time.
- **Secrets are decrypted by sops on-demand**: Each machine decrypts its secrets at runtime and stores them at an ephemeral location.
- **Machine key-pairs are auto-generated**: When a machine is created **no user-interaction is required** to setup public/private key-pairs.
- **secrets are re-encrypted**: In case machines, users or groups are modified secrets get re-encrypted on demand.
!!! Important
After revoking access to a secret you should also change the underlying secret. i.e. change the API key, or the password.
---
### Machine and user keys
The following diagrams illustrates how a user can provide a secret (i.e. a Password).
- By using the **Clan CLI** a user encrypts the password with both the **User public-key** and the **machine's public-key**
- The *Machine* can decrypt the password with its private-key on demand.
- The *User* is able to decrypt the password to make changes to it.
```plantuml
@startuml
!include C4_Container.puml
Person(user, "User", "Someone who manages secrets")
ContainerDb(secret, "Secret")
Container(machine, "Machine", "A Machine. i.e. Needs the Secret for a given Service." )
Rel_R(user, secret, "Encrypt", "", "Pubkeys: User, Machine")
Rel_L(secret, user, "Decrypt", "", "user privkey")
Rel_R(secret, machine, "Decrypt", "", "machine privkey" )
@enduml
```
### Groups
It is possible to create semantic groups to make access control more convenient.
#### User groups
Here we illustrate how machine groups work.
Common use cases:
- **Shared Management**: Access among multiple users. I.e. a subset of secrets/machines that have two admins
```plantuml
@startuml
!include C4_Container.puml
System_Boundary(c1, "Group") {
Person(user1, "User A", "has access")
Person(user2, "User B", "has access")
}
ContainerDb(secret, "Secret")
Container(machine, "Machine", "A Machine. i.e. Needs the Secret for a given Service." )
Rel_R(c1, secret, "Encrypt", "", "Pubkeys: User A, User B, Machine")
Rel_R(secret, machine, "Decrypt", "", "machine privkey" )
@enduml
```
<!-- TODO: See also [Groups Reference](#groups-reference) -->
---
#### Machine groups
Here we illustrate how machine groups work.
Common use cases:
- **Shared secrets**: Among multiple machines such as Wifi passwords
```plantuml
@startuml
!include C4_Container.puml
!include C4_Deployment.puml
Person(user, "User", "Someone who manages secrets")
ContainerDb(secret, "Secret")
System_Boundary(c1, "Group") {
Container(machine1, "Machine A", "Both machines need the same secret" )
Container(machine2, "Machine B", "Both machines need the same secret" )
}
Rel_R(user, secret, "Encrypt", "", "Pubkeys: machine A, machine B, User")
Rel(secret, c1, "Decrypt", "", "Both machine A or B can decrypt using their private key" )
@enduml
```
<!-- TODO: See also [Groups Reference](#groups-reference) -->
---
## 2. Adding Machine Keys
New machines in Clan come with age keys stored in `./sops/machines/<machine_name>`. To list these machines:

View File

@@ -16,7 +16,6 @@ class SecretStore(SecretStoreBase):
# no need to generate keys if we don't manage secrets
if not hasattr(self.machine, "facts_data"):
return
if not self.machine.facts_data:
return

View File

@@ -29,8 +29,6 @@ def commit_files(
repo_dir: Path,
commit_message: str | None = None,
) -> None:
if not file_paths:
return
# check that the file is in the git repository
for file_path in file_paths:
if not Path(file_path).resolve().is_relative_to(repo_dir.resolve()):

View File

@@ -29,13 +29,5 @@ def register_parser(parser: argparse.ArgumentParser) -> None:
list_parser = subparser.add_parser("list", help="List machines")
register_list_parser(list_parser)
install_parser = subparser.add_parser(
"install",
help="Install a machine",
description="""
Install a configured machine over the network.
The target must be a Linux based system reachable via SSH.
Installing a machine means overwriting the target's disk.
""",
)
install_parser = subparser.add_parser("install", help="Install a machine")
register_install_parser(install_parser)

View File

@@ -38,6 +38,8 @@ def install_nixos(
cmd = [
"nixos-anywhere",
"--debug",
"--copy-password",
"--flake",
f"{machine.flake}#{machine.name}",
"--no-reboot",
@@ -54,7 +56,8 @@ def install_nixos(
run(
nix_shell(
["nixpkgs#nixos-anywhere"],
# ["nixpkgs#sshpass", "/home/kenji/git/nix-projects/nixos-anywhere"],
["nixpkgs#sshpass", "nixpkgs#nixos-anywhere"],
cmd,
),
log=Log.BOTH,
@@ -117,6 +120,18 @@ def register_install_parser(parser: argparse.ArgumentParser) -> None:
parser.add_argument(
"target_host",
type=str,
nargs="?",
help="ssh address to install to in the form of user@host:2222",
)
group = parser.add_mutually_exclusive_group(required=False)
group.add_argument(
"-j",
"--json",
help="specify the json file for ssh data (generated by starting the clan installer)",
)
group.add_argument(
"-P",
"--png",
help="specify the json file for ssh data as the qrcode image (generated by starting the clan installer)",
)
parser.set_defaults(func=install_command)

View File

@@ -33,13 +33,10 @@ def list_objects(path: Path, is_valid: Callable[[str], bool]) -> list[str]:
return objs
def remove_object(path: Path, name: str) -> list[Path]:
paths_to_commit = []
def remove_object(path: Path, name: str) -> None:
try:
shutil.rmtree(path / name)
paths_to_commit.append(path / name)
except FileNotFoundError:
raise ClanError(f"{name} not found in {path}")
if not os.listdir(path):
os.rmdir(path)
return paths_to_commit

View File

@@ -2,8 +2,6 @@ import argparse
import os
from pathlib import Path
from clan_cli.git import commit_files
from ..errors import ClanError
from ..machines.types import machine_name_type, validate_hostname
from . import secrets
@@ -89,21 +87,19 @@ def list_directory(directory: Path) -> str:
return msg
def update_group_keys(flake_dir: Path, group: str) -> list[Path]:
updated_paths = []
def update_group_keys(flake_dir: Path, group: str) -> None:
for secret_ in secrets.list_secrets(flake_dir):
secret = sops_secrets_folder(flake_dir) / secret_
if (secret / "groups" / group).is_symlink():
updated_paths += update_keys(
update_keys(
secret,
list(sorted(secrets.collect_keys_for_path(secret))),
)
return updated_paths
def add_member(
flake_dir: Path, group_folder: Path, source_folder: Path, name: str
) -> list[Path]:
) -> None:
source = source_folder / name
if not source.exists():
msg = f"{name} does not exist in {source_folder}: "
@@ -118,7 +114,7 @@ def add_member(
)
os.remove(user_target)
user_target.symlink_to(os.path.relpath(source, user_target.parent))
return update_group_keys(flake_dir, group_folder.parent.name)
update_group_keys(flake_dir, group_folder.parent.name)
def remove_member(flake_dir: Path, group_folder: Path, name: str) -> None:
@@ -140,14 +136,9 @@ def remove_member(flake_dir: Path, group_folder: Path, name: str) -> None:
def add_user(flake_dir: Path, group: str, name: str) -> None:
updated_files = add_member(
add_member(
flake_dir, users_folder(flake_dir, group), sops_users_folder(flake_dir), name
)
commit_files(
updated_files,
flake_dir,
f"Add user {name} to group {group}",
)
def add_user_command(args: argparse.Namespace) -> None:
@@ -163,17 +154,12 @@ def remove_user_command(args: argparse.Namespace) -> None:
def add_machine(flake_dir: Path, group: str, name: str) -> None:
updated_files = add_member(
add_member(
flake_dir,
machines_folder(flake_dir, group),
sops_machines_folder(flake_dir),
name,
)
commit_files(
updated_files,
flake_dir,
f"Add machine {name} to group {group}",
)
def add_machine_command(args: argparse.Namespace) -> None:
@@ -203,14 +189,7 @@ def add_secret_command(args: argparse.Namespace) -> None:
def remove_secret(flake_dir: Path, group: str, name: str) -> None:
updated_paths = secrets.disallow_member(
secrets.groups_folder(flake_dir, name), group
)
commit_files(
updated_paths,
flake_dir,
f"Remove group {group} from secret {name}",
)
secrets.disallow_member(secrets.groups_folder(flake_dir, name), group)
def remove_secret_command(args: argparse.Namespace) -> None:

View File

@@ -1,8 +1,6 @@
import argparse
from pathlib import Path
from clan_cli.git import commit_files
from .. import tty
from ..errors import ClanError
from .secrets import update_secrets
@@ -13,7 +11,9 @@ def generate_key() -> str:
path = default_sops_key_path()
if path.exists():
raise ClanError(f"Key already exists at {path}")
priv_key, pub_key = generate_private_key(out_file=path)
priv_key, pub_key = generate_private_key()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(priv_key)
return pub_key
@@ -38,7 +38,7 @@ def show_command(args: argparse.Namespace) -> None:
def update_command(args: argparse.Namespace) -> None:
flake_dir = Path(args.flake)
commit_files(update_secrets(flake_dir), flake_dir, "Updated secrets with new keys.")
update_secrets(flake_dir)
def register_key_parser(parser: argparse.ArgumentParser) -> None:

View File

@@ -21,19 +21,14 @@ def add_machine(flake_dir: Path, name: str, key: str, force: bool) -> None:
paths.extend(update_secrets(flake_dir, filter_secrets=filter_machine_secrets))
commit_files(
paths,
[path],
flake_dir,
f"Add machine {name} to secrets",
)
def remove_machine(flake_dir: Path, name: str) -> None:
removed_paths = remove_object(sops_machines_folder(flake_dir), name)
commit_files(
removed_paths,
flake_dir,
f"Remove machine {name}",
)
remove_object(sops_machines_folder(flake_dir), name)
def get_machine(flake_dir: Path, name: str) -> str:
@@ -54,27 +49,20 @@ def list_machines(flake_dir: Path) -> list[str]:
def add_secret(flake_dir: Path, machine: str, secret: str) -> None:
paths = secrets.allow_member(
path = secrets.allow_member(
secrets.machines_folder(flake_dir, secret),
sops_machines_folder(flake_dir),
machine,
)
commit_files(
paths,
[path],
flake_dir,
f"Add {machine} to secret",
)
def remove_secret(flake_dir: Path, machine: str, secret: str) -> None:
updated_paths = secrets.disallow_member(
secrets.machines_folder(flake_dir, secret), machine
)
commit_files(
updated_paths,
flake_dir,
f"Remove {machine} from secret {secret}",
)
secrets.disallow_member(secrets.machines_folder(flake_dir, secret), machine)
def list_command(args: argparse.Namespace) -> None:

View File

@@ -85,7 +85,7 @@ def encrypt_secret(
files_to_commit = []
for user in add_users:
files_to_commit.extend(
files_to_commit.append(
allow_member(
users_folder(flake_dir, secret.name),
sops_users_folder(flake_dir),
@@ -95,7 +95,7 @@ def encrypt_secret(
)
for machine in add_machines:
files_to_commit.extend(
files_to_commit.append(
allow_member(
machines_folder(flake_dir, secret.name),
sops_machines_folder(flake_dir),
@@ -105,7 +105,7 @@ def encrypt_secret(
)
for group in add_groups:
files_to_commit.extend(
files_to_commit.append(
allow_member(
groups_folder(flake_dir, secret.name),
sops_groups_folder(flake_dir),
@@ -118,7 +118,7 @@ def encrypt_secret(
if key.pubkey not in keys:
keys.add(key.pubkey)
files_to_commit.extend(
files_to_commit.append(
allow_member(
users_folder(flake_dir, secret.name),
sops_users_folder(flake_dir),
@@ -180,7 +180,7 @@ def list_directory(directory: Path) -> str:
def allow_member(
group_folder: Path, source_folder: Path, name: str, do_update_keys: bool = True
) -> list[Path]:
) -> Path:
source = source_folder / name
if not source.exists():
msg = f"Cannot encrypt {group_folder.parent.name} for '{name}' group. '{name}' group does not exist in {source_folder}: "
@@ -196,18 +196,15 @@ def allow_member(
os.remove(user_target)
user_target.symlink_to(os.path.relpath(source, user_target.parent))
changed = [user_target]
if do_update_keys:
changed.extend(
update_keys(
group_folder.parent,
list(sorted(collect_keys_for_path(group_folder.parent))),
)
update_keys(
group_folder.parent,
list(sorted(collect_keys_for_path(group_folder.parent))),
)
return changed
return user_target
def disallow_member(group_folder: Path, name: str) -> list[Path]:
def disallow_member(group_folder: Path, name: str) -> None:
target = group_folder / name
if not target.exists():
msg = f"{name} does not exist in group in {group_folder}: "
@@ -228,7 +225,7 @@ def disallow_member(group_folder: Path, name: str) -> list[Path]:
if len(os.listdir(group_folder.parent)) == 0:
os.rmdir(group_folder.parent)
return update_keys(
update_keys(
target.parent.parent, list(sorted(collect_keys_for_path(group_folder.parent)))
)

View File

@@ -34,7 +34,7 @@ def get_public_key(privkey: str) -> str:
return res.stdout.strip()
def generate_private_key(out_file: Path | None = None) -> tuple[str, str]:
def generate_private_key() -> tuple[str, str]:
cmd = nix_shell(["nixpkgs#age"], ["age-keygen"])
try:
proc = run(cmd)
@@ -50,9 +50,6 @@ def generate_private_key(out_file: Path | None = None) -> tuple[str, str]:
raise ClanError("Could not find public key in age-keygen output")
if not private_key:
raise ClanError("Could not find private key in age-keygen output")
if out_file:
out_file.parent.mkdir(parents=True, exist_ok=True)
out_file.write_text(res)
return private_key, pubkey
except subprocess.CalledProcessError as e:
raise ClanError("Failed to generate private sops key") from e

View File

@@ -32,12 +32,7 @@ def add_user(flake_dir: Path, name: str, key: str, force: bool) -> None:
def remove_user(flake_dir: Path, name: str) -> None:
removed_paths = remove_object(sops_users_folder(flake_dir), name)
commit_files(
removed_paths,
flake_dir,
f"Remove user {name}",
)
remove_object(sops_users_folder(flake_dir), name)
def get_user(flake_dir: Path, name: str) -> str:
@@ -57,25 +52,13 @@ def list_users(flake_dir: Path) -> list[str]:
def add_secret(flake_dir: Path, user: str, secret: str) -> None:
updated_paths = secrets.allow_member(
secrets.allow_member(
secrets.users_folder(flake_dir, secret), sops_users_folder(flake_dir), user
)
commit_files(
updated_paths,
flake_dir,
f"Add {user} to secret",
)
def remove_secret(flake_dir: Path, user: str, secret: str) -> None:
updated_paths = secrets.disallow_member(
secrets.users_folder(flake_dir, secret), user
)
commit_files(
updated_paths,
flake_dir,
f"Remove {user} from secret",
)
secrets.disallow_member(secrets.users_folder(flake_dir, secret), user)
def list_command(args: argparse.Namespace) -> None:

View File

@@ -27,7 +27,6 @@
qemu,
gnupg,
e2fsprogs,
mandown,
mypy,
clan-core-path,
}:
@@ -121,7 +120,6 @@ python3.pkgs.buildPythonApplication {
nativeBuildInputs = [
setuptools
installShellFiles
mandown
];
propagatedBuildInputs = pythonDependencies;
@@ -129,7 +127,7 @@ python3.pkgs.buildPythonApplication {
# Define and expose the tests and checks to run in CI
passthru.tests =
(lib.mapAttrs' (n: lib.nameValuePair "clan-dep-${n}") runtimeDependenciesAsSet)
// {
// rec {
clan-pytest-without-core =
runCommand "clan-pytest-without-core"
{ nativeBuildInputs = [ pythonWithTestDeps ] ++ testDependencies; }
@@ -180,8 +178,6 @@ python3.pkgs.buildPythonApplication {
<(${argcomplete}/bin/register-python-argcomplete --shell bash clan)
installShellCompletion --fish --name clan.fish \
<(${argcomplete}/bin/register-python-argcomplete --shell fish clan)
mandown $src/man/clan.md > ./clan.1
installManPage ./clan.1
'';
# Clean up after the package to avoid leaking python packages into a devshell

View File

@@ -42,7 +42,6 @@
packages = {
clan-cli = pkgs.python3.pkgs.callPackage ./default.nix {
inherit (inputs) nixpkgs;
mandown = pkgs.mandown;
clan-core-path = clanCoreWithVendoredDeps;
};
default = self'.packages.clan-cli;

View File

@@ -1,7 +0,0 @@
# Manpages for the clan cli
Run:
```
nix run nixpkgs#manpage [.md] > [manpage.1]
```
to convert a markdown page into a man page.

View File

@@ -1,51 +0,0 @@
## NAME
clan - the clan cli tool
## SYNOPSIS
clan [OPTION...] [SUBCOMMAND]
## DESCRIPTION
**clan** is the cli for managing and deploying various nixos configurations in a unified coherent way.
Secrets and passwords can be provisioned, set and retrieved.
Machines can be remotely installed through the **install** subcommand.
For more overview please refer to the reference material at docs.clan.lol.
## OPTIONS
### -h, --help
Output a help message and exit.
### --debug
Enable debug logging.
### --option name value
Nix option to set.
### --flake=FLAKE
Path to the flake where the clan resides in, can be a remote flake, or local.
## SUBCOMMANDS
### clan-backups(1)
Manage backups of clan machines
### clan-flakes(1)
Create a clan flake inside the current directory
### clan-config(1)
Set nixos-configuration
### clan-ssh(1)
SSH to a remote machine
### clan-secrets(1)
Manage secrets
### clan-facts(1)
Manage facts
### clan-machines(1)
Manage machines and their configuration
### clan-vms(1)
Manage virtual machines
### clan-history(1)
Manage history
### clan-flash(1)
Flash machines to usb sticks or into isos

View File

@@ -175,18 +175,6 @@ def test_flake(
monkeypatch: pytest.MonkeyPatch, temporary_home: Path
) -> Iterator[FlakeForTest]:
yield from create_flake(monkeypatch, temporary_home, "test_flake")
# check that git diff on ./sops is empty
if (temporary_home / "test_flake" / "sops").exists():
git_proc = sp.run(
["git", "diff", "--exit-code", "./sops"],
cwd=temporary_home / "test_flake",
stderr=sp.PIPE,
)
if git_proc.returncode != 0:
log.error(git_proc.stderr.decode())
raise Exception(
"git diff on ./sops is not empty. This should not happen as all changes should be committed"
)
@pytest.fixture

View File

@@ -1,6 +1,6 @@
import logging
from collections.abc import Callable
from typing import Any, Generic, TypeVar
from typing import Any, ClassVar, Generic, TypeVar
import gi
@@ -24,6 +24,10 @@ class GKVStore(GObject.GObject, Gio.ListModel, Generic[K, V]):
This class could be optimized by having the objects remember their position in the list.
"""
__gsignals__: ClassVar = {
"is_ready": (GObject.SignalFlags.RUN_FIRST, None, []),
}
def __init__(self, gtype: type[V], key_gen: Callable[[V], K]) -> None:
super().__init__()
self.gtype = gtype

View File

@@ -43,11 +43,8 @@ class EmptySplash(Gtk.Box):
join_button = Gtk.Button(label="Join")
join_button.connect("clicked", self._on_join, join_entry)
join_entry.connect("activate", lambda e: self._on_join(join_button, e))
clamp = Adw.Clamp()
clamp.set_maximum_size(400)
clamp.set_margin_bottom(40)
vbox.append(empty_label)
hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
hbox.append(join_entry)

View File

@@ -1,7 +1,7 @@
import logging
from collections.abc import Callable
from pathlib import Path
from typing import Any, ClassVar
from typing import Any
import gi
from clan_cli.clan_uri import ClanURI
@@ -15,7 +15,7 @@ from clan_vm_manager.views.logs import Logs
gi.require_version("GObject", "2.0")
gi.require_version("Gtk", "4.0")
from gi.repository import Gio, GLib, GObject
from gi.repository import Gio, GLib
log = logging.getLogger(__name__)
@@ -25,18 +25,10 @@ class VMStore(GKVStore):
super().__init__(VMObject, lambda vm: vm.data.flake.flake_attr)
class Emitter(GObject.GObject):
__gsignals__: ClassVar = {
"is_ready": (GObject.SignalFlags.RUN_FIRST, None, []),
}
class ClanStore:
_instance: "None | ClanStore" = None
_clan_store: GKVStore[str, VMStore]
_emitter: Emitter
# set the vm that is outputting logs
# build logs are automatically streamed to the logs-view
_logging_vm: VMObject | None = None
@@ -52,16 +44,9 @@ class ClanStore:
cls._clan_store = GKVStore(
VMStore, lambda store: store.first().data.flake.flake_url
)
cls._emitter = Emitter()
return cls._instance
def emit(self, signal: str) -> None:
self._emitter.emit(signal)
def connect(self, signal: str, cb: Callable[(...), Any]) -> None:
self._emitter.connect(signal, cb)
def set_logging_vm(self, ident: str) -> VMObject | None:
vm = self.get_vm(ClanURI(f"clan://{ident}"))
if vm is not None:

View File

@@ -74,11 +74,11 @@ class ClanList(Gtk.Box):
self.join_boxed_list.add_css_class("join-list")
self.append(self.join_boxed_list)
clan_store = ClanStore.use()
clan_store = ClanStore.use().clan_store
clan_store.connect("is_ready", self.display_splash)
self.group_list = create_boxed_list(
model=clan_store.clan_store, render_row=self.render_group_row
model=clan_store, render_row=self.render_group_row
)
self.group_list.add_css_class("group-list")
self.append(self.group_list)
@@ -338,7 +338,7 @@ class ClanList(Gtk.Box):
def on_after_join(self, source: JoinValue) -> None:
ToastOverlay.use().add_toast_unique(
SuccessToast(f"Updated {source.url.machine.name}").toast,
SuccessToast(f"Added/updated {source.url.machine.name}").toast,
"success.join",
)
# If the join request list is empty disable the shadow artefact

View File

@@ -1,5 +1,6 @@
import logging
import threading
from collections.abc import Callable
import gi
from clan_cli.history.list import list_history
@@ -41,7 +42,9 @@ class MainWindow(Adw.ApplicationWindow):
self.tray_icon: TrayIcon = TrayIcon(app)
# Initialize all ClanStore
threading.Thread(target=self._populate_vms).start()
threading.Thread(
target=self._populate_vms, args=[self._set_clan_store_ready]
).start()
# Initialize all views
stack_view = ViewStack.use().view
@@ -65,17 +68,16 @@ class MainWindow(Adw.ApplicationWindow):
self.connect("destroy", self.on_destroy)
def _set_clan_store_ready(self) -> bool:
ClanStore.use().emit("is_ready")
return GLib.SOURCE_REMOVE
def _set_clan_store_ready(self) -> None:
ClanStore.use().clan_store.emit("is_ready")
def _populate_vms(self) -> None:
def _populate_vms(self, done: Callable[[], None]) -> None:
# Execute `clan flakes add <path>` to democlan for this to work
# TODO: Make list_history a generator function
for entry in list_history():
GLib.idle_add(ClanStore.use().create_vm_task, entry)
GLib.idle_add(self._set_clan_store_ready)
GLib.idle_add(done)
def kill_vms(self) -> None:
log.debug("Killing all VMs")