Compare commits

..

1 Commits

Author SHA1 Message Date
DavHau
fa1a25de34 install: fix torify package not available 2025-08-12 22:47:06 +07:00
151 changed files with 5057 additions and 1930 deletions

View File

@@ -19,7 +19,8 @@ jobs:
uses: Mic92/update-flake-inputs-gitea@main
with:
# Exclude private flakes and update-clan-core checks flake
exclude-patterns: "checks/impure/flake.nix"
exclude-patterns: "devFlake/private/flake.nix,checks/impure/flake.nix"
auto-merge: true
gitea-token: ${{ secrets.CI_BOT_TOKEN }}
github-token: ${{ secrets.CI_BOT_GITHUB_TOKEN }}

View File

@@ -0,0 +1,40 @@
name: "Update private flake inputs"
on:
repository_dispatch:
workflow_dispatch:
schedule:
- cron: "0 3 * * *" # Run daily at 3 AM
jobs:
update-private-flake:
runs-on: nix
steps:
- uses: actions/checkout@v4
with:
submodules: true
- name: Update private flake inputs
run: |
# Update the private flake lock file
cd devFlake/private
nix flake update
cd ../..
# Update the narHash
bash ./devFlake/update-private-narhash
- name: Create pull request
env:
CI_BOT_TOKEN: ${{ secrets.CI_BOT_TOKEN }}
run: |
export GIT_AUTHOR_NAME=clan-bot GIT_AUTHOR_EMAIL=clan-bot@clan.lol GIT_COMMITTER_NAME=clan-bot GIT_COMMITTER_EMAIL=clan-bot@clan.lol
# Check if there are any changes
if ! git diff --quiet; then
git add devFlake/private/flake.lock devFlake/private.narHash
git commit -m "Update dev flake"
# Use shared PR creation script
export PR_BRANCH="update-dev-flake"
export PR_TITLE="Update dev flake"
export PR_BODY="This PR updates the dev flake inputs and corresponding narHash."
else
echo "No changes detected in dev flake inputs"
fi

View File

@@ -0,0 +1,2 @@
nixosModules/clanCore/vars/.* @lopter
pkgs/clan-cli/clan_cli/(secrets|vars)/.* @lopter

View File

@@ -104,7 +104,6 @@ in
nixos-test-user-firewall-nftables = self.clanLib.test.containerTest ./user-firewall/nftables.nix nixosTestArgs;
service-dummy-test = import ./service-dummy-test nixosTestArgs;
wireguard = import ./wireguard nixosTestArgs;
service-dummy-test-from-flake = import ./service-dummy-test-from-flake nixosTestArgs;
};

View File

@@ -2,6 +2,7 @@
config,
self,
lib,
privateInputs,
...
}:
{
@@ -84,7 +85,7 @@
# Some distros like to automount disks with spaces
machine.succeed('mkdir -p "/mnt/with spaces" && mkfs.ext4 /dev/vdc && mount /dev/vdc "/mnt/with spaces"')
machine.succeed("clan flash write --debug --flake ${self.checks.x86_64-linux.clan-core-for-checks} --yes --disk main /dev/vdc test-flash-machine-${pkgs.hostPlatform.system}")
machine.succeed("clan flash write --debug --flake ${privateInputs.clan-core-for-checks} --yes --disk main /dev/vdc test-flash-machine-${pkgs.hostPlatform.system}")
'';
} { inherit pkgs self; };
};

View File

@@ -208,7 +208,7 @@
# Prepare test flake and Nix store
flake_dir = prepare_test_flake(
temp_dir,
"${self.checks.x86_64-linux.clan-core-for-checks}",
"${privateInputs.clan-core-for-checks}",
"${closureInfo}"
)
@@ -272,7 +272,7 @@
# Prepare test flake and Nix store
flake_dir = prepare_test_flake(
temp_dir,
"${self.checks.x86_64-linux.clan-core-for-checks}",
"${privateInputs.clan-core-for-checks}",
"${closureInfo}"
)

View File

@@ -1,5 +1,6 @@
{
self,
privateInputs,
...
}:
{
@@ -54,7 +55,7 @@
testScript = ''
start_all()
actual.fail("cat /etc/testfile")
actual.succeed("env CLAN_DIR=${self.checks.x86_64-linux.clan-core-for-checks} clan machines morph test-morph-template --i-will-be-fired-for-using-this --debug --name test-morph-machine")
actual.succeed("env CLAN_DIR=${privateInputs.clan-core-for-checks} clan machines morph test-morph-template --i-will-be-fired-for-using-this --debug --name test-morph-machine")
assert actual.succeed("cat /etc/testfile") == "morphed"
'';
} { inherit pkgs self; };

View File

@@ -1,115 +0,0 @@
{
pkgs,
nixosLib,
clan-core,
lib,
...
}:
nixosLib.runTest (
{ ... }:
let
machines = [
"controller1"
"controller2"
"peer1"
"peer2"
"peer3"
];
in
{
imports = [
clan-core.modules.nixosTest.clanTest
];
hostPkgs = pkgs;
name = "wireguard";
clan = {
directory = ./.;
modules."@clan/wireguard" = import ../../clanServices/wireguard/default.nix;
inventory = {
machines = lib.genAttrs machines (_: { });
instances = {
/*
wg-test-one
controller2 controller1
peer2 peer1 peer3
*/
wg-test-one = {
module.name = "@clan/wireguard";
module.input = "self";
roles.controller.machines."controller1".settings = {
endpoint = "192.168.1.1";
};
roles.controller.machines."controller2".settings = {
endpoint = "192.168.1.2";
};
roles.peer.machines = {
peer1.settings.controller = "controller1";
peer2.settings.controller = "controller2";
peer3.settings.controller = "controller1";
};
};
# TODO: Will this actually work with conflicting ports? Can we re-use interfaces?
#wg-test-two = {
# module.name = "@clan/wireguard";
# roles.controller.machines."controller1".settings = {
# endpoint = "192.168.1.1";
# port = 51922;
# };
# roles.peer.machines = {
# peer1 = { };
# };
#};
};
};
};
testScript = ''
start_all()
# Show all addresses
machines = [peer1, peer2, peer3, controller1, controller2]
for m in machines:
m.systemctl("start network-online.target")
for m in machines:
m.wait_for_unit("network-online.target")
m.wait_for_unit("systemd-networkd.service")
print("\n\n" + "="*60)
print("STARTING PING TESTS")
print("="*60)
for m1 in machines:
for m2 in machines:
if m1 != m2:
print(f"\n--- Pinging from {m1.name} to {m2.name}.wg-test-one ---")
m1.wait_until_succeeds(f"ping -c1 {m2.name}.wg-test-one >&2")
'';
}
)

View File

@@ -1,6 +0,0 @@
[
{
"publickey": "age1rnkc2vmrupy9234clyu7fpur5kephuqs3v7qauaw5zeg00jqjdasefn3cc",
"type": "age"
}
]

View File

@@ -1,6 +0,0 @@
[
{
"publickey": "age1t2hhg99d4p2yymuhngcy5ccutp8mvu7qwvg5cdhck303h9e7ha9qnlt635",
"type": "age"
}
]

View File

@@ -1,6 +0,0 @@
[
{
"publickey": "age1jts52rzlqcwjc36jkp56a7fmjn3czr7kl9ta2spkfzhvfama33sqacrzzd",
"type": "age"
}
]

View File

@@ -1,6 +0,0 @@
[
{
"publickey": "age12nqnp0zd435ckp5p0v2fv4p2x4cvur2mnxe8use2sx3fgy883vaq4ae75e",
"type": "age"
}
]

View File

@@ -1,6 +0,0 @@
[
{
"publickey": "age1sglr4zp34drjfydzeweq43fz3uwpul3hkh53lsfa9drhuzwmkqyqn5jegp",
"type": "age"
}
]

View File

@@ -1,15 +0,0 @@
{
"data": "ENC[AES256_GCM,data:zDF0RiBqaawpg+GaFkuLPomJ01Xu+lgY5JfUzaIk2j03XkCzIf8EMrmn6pRtBP3iUjPBm+gQSTQk6GHTONrixA5hRNyETV+UgQw=,iv:zUUCAGZ0cz4Tc2t/HOjVYNsdnrAOtid/Ns5ak7rnyCk=,tag:z43WtNSue4Ddf7AVu21IKA==,type:str]",
"sops": {
"age": [
{
"recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg",
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBlY1NEdjAzQm5RMFZWY3BJ\nclp6c01FdlZFK3dOSDB4cHc1NTdwMXErMFJFCnIrRVFNZEFYOG1rVUhFd2xsbTJ2\nVkJHNmdOWXlOcHJoQ0QzM1VyZmxmcGcKLS0tIFk1cEx4dFdvNGRwK1FWdDZsb1lR\nV2d1RFZtNzZqVFdtQ1FzNStEcEgyUUkKx8tkxqJz/Ko3xgvhvd6IYiV/lRGmrY13\nUZpYWR9tsQwZAR9dLjCyVU3JRuXeGB1unXC1CO0Ff3R0A/PuuRHh+g==\n-----END AGE ENCRYPTED FILE-----\n"
}
],
"lastmodified": "2025-08-13T09:19:37Z",
"mac": "ENC[AES256_GCM,data:8RGOUhZ2LGmC9ugULwHDgdMrtdo9vzBm3BJmL4XTuNJKm0NlKfgNLi1E4n9DMQ+kD4hKvcwbiUcwSGE8jZD6sm7Sh3bJi/HZCoiWm/O/OIzstli2NNDBGvQBgyWZA5H+kDjZ6aEi6icNWIlm5gsty7KduABnf5B3p0Bn5Uf5Bio=,iv:sGZp0XF+mgocVzAfHF8ATdlSE/5zyz5WUSRMJqNeDQs=,tag:ymYVBRwF5BOSAu5ONU2qKw==,type:str]",
"unencrypted_suffix": "_unencrypted",
"version": "3.10.2"
}
}

View File

@@ -1 +0,0 @@
../../../users/admin

View File

@@ -1,15 +0,0 @@
{
"data": "ENC[AES256_GCM,data:dHM7zWzqnC1QLRKYpbI2t63kOFnSaQy6ur9zlkLQf17Q03CNrqUsZtdEbwMnLR3llu7eVMhtvVRkXjEkvn3leb9HsNFmtk/DP70=,iv:roEZsBFqRypM106O5sehTzo7SySOJUJgAR738rTtOo8=,tag:VDd9/6uU0SAM7pWRLIUhUQ==,type:str]",
"sops": {
"age": [
{
"recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg",
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBKTEVYUmVGbUtOcHZ4cnc3\nKzNETnlxaVRKYTI3eWVHdEoyc3l2SnhsZ1J3CnB2RnZrOXM5Uml6TThDUlZjY25J\nbkJ6eUZ2ckN1NWpNUU9IaE93UDJQdlEKLS0tIC95ZDhkU0R1VHhCdldxdW4zSmps\nN3NqL1cvd05hRTRPdDA3R2pzNUFFajgKS+DJH14fH9AvEAa3PoUC1jEqKAzTmExN\nl32FeHTHbGMo1PKeaFm+Eg0WSpAmFE7beBunc5B73SW30ok6x4FcQw==\n-----END AGE ENCRYPTED FILE-----\n"
}
],
"lastmodified": "2025-08-13T09:19:47Z",
"mac": "ENC[AES256_GCM,data:77EnuBQyguvkCtobUg8/6zoLHjmeGDrSBZuIXOZBMxdbJjzhRg++qxQjuu6t0FoWATtz7u4Y3/jzUMGffr/N5HegqSq0D2bhv7AqJwBiVaOwd80fRTtM+YiP/zXsCk52Pj/Gadapg208bDPQ1BBDOyz/DrqZ7w//j+ARJjAnugI=,iv:IuTDmJKZEuHXJXjxrBw0gP2t6vpxAYEqbtpnVbavVCY=,tag:4EnpX6rOamtg1O+AaEQahQ==,type:str]",
"unencrypted_suffix": "_unencrypted",
"version": "3.10.2"
}
}

View File

@@ -1 +0,0 @@
../../../users/admin

View File

@@ -1,15 +0,0 @@
{
"data": "ENC[AES256_GCM,data:wcSsqxTKiMAnzPwxs5DNjcSdLyjVQ9UOrZxfSbOkVfniwx6F7xz6dLNhaDq7MHQ0vRWpg28yNs7NHrp52bYFnb/+eZsis46WiCw=,iv:B4t1lvS2gC601MtsmZfEiEulLWvSGei3/LSajwFS9Vs=,tag:hnRXlZyYEFfLJUrw1SqbSQ==,type:str]",
"sops": {
"age": [
{
"recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg",
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAybUgya2VEdzMvRG1hdkpu\nM2pGNmcyVmcvYVZ1ZjJlY3A1bXFUUUtkMTI0CmJoRFZmejZjN2UxUXNuc1k5WnE2\nNmxIcnpNQ1lJZ3ZKSmhtSlVURXJTSUUKLS0tIGU4Wi9yZ3VYekJkVW9pNWFHblFk\na0gzbTVKUWdSam1sVjRUaUlTdVd5YWMKntRc9yb9VPOTMibp8QM5m57DilP01N/X\nPTQaw8oI40znnHdctTZz7S+W/3Te6sRnkOhFyalWmsKY0CWg/FELlA==\n-----END AGE ENCRYPTED FILE-----\n"
}
],
"lastmodified": "2025-08-13T09:19:58Z",
"mac": "ENC[AES256_GCM,data:8nq+ugkUJxE24lUIySySs/cAF8vnfqr936L/5F0O1QFwNrbpPmKRXkuwa6u0V+187L2952Id20Fym4ke59f3fJJsF840NCKDwDDZhBZ20q9GfOqIKImEom/Nzw6D0WXQLUT3w8EMyJ/F+UaJxnBNPR6f6+Kx4YgStYzCcA6Ahzg=,iv:VBPktEz7qwWBBnXE+xOP/EUVy7/AmNCHPoK56Yt/ZNc=,tag:qXONwOLFAlopymBEf5p4Sw==,type:str]",
"unencrypted_suffix": "_unencrypted",
"version": "3.10.2"
}
}

View File

@@ -1 +0,0 @@
../../../users/admin

View File

@@ -1,15 +0,0 @@
{
"data": "ENC[AES256_GCM,data:4d3ri0EsDmWRtA8vzvpPRLMsSp4MIMKwvtn0n0pRY05uBPXs3KcjnweMPIeTE1nIhqnMR2o2MfLah5TCPpaFax9+wxIt74uacbg=,iv:0LBAldTC/hN4QLCxgXTl6d9UB8WmUTnj4sD2zHQuG2w=,tag:zr/RhG/AU4g9xj9l2BprKw==,type:str]",
"sops": {
"age": [
{
"recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg",
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBvV0JnZDhlU1piU1g2cng0\ncytKOEZ6WlZlNGRGUjV3MmVMd2Nzc0ZwelgwCjBGdThCUGlXbVFYdnNoZWpJZ3Vm\nc2xkRXhxS09vdzltSVoxLzhFSVduak0KLS0tIE5DRjJ6cGxiVlB1eElHWXhxN1pJ\nYWtIMDMvb0Z6akJjUzlqeEFsNHkxL2cKpghv/QegnXimeqd9OPFouGM//jYvoVmw\n2d4mLT2JSMkEhpfGcqb6vswhdJfCiKuqr2B4bqwAnPMaykhsm8DFRQ==\n-----END AGE ENCRYPTED FILE-----\n"
}
],
"lastmodified": "2025-08-13T09:20:08Z",
"mac": "ENC[AES256_GCM,data:BzlQVAJ7HzcxNPKB3JhabqRX/uU0EElj172YecjmOflHnzz/s9xgfdAfJK/c53hXlX4LtGPnubH7a8jOolRq98zmZeBYE27+WLs2aN7Ufld6mYk90/i7u4CqR+Fh2Kfht04SlUJCjnS5A9bTPwU9XGRHJ0BiOhzTuSMUJTRaPRM=,iv:L50K5zc1o99Ix9nP0pb9PRH+VIN2yvq7JqKeVHxVXmc=,tag:XFLkSCsdbTPxbasDYYxcFQ==,type:str]",
"unencrypted_suffix": "_unencrypted",
"version": "3.10.2"
}
}

View File

@@ -1 +0,0 @@
../../../users/admin

View File

@@ -1,15 +0,0 @@
{
"data": "ENC[AES256_GCM,data:qfLm6+g1vYnESCik9uyBeKsY6Ju2Gq3arnn2I8HHNO67Ri5BWbOQTvtz7WT8/q94RwVjv8SGeJ/fsJSpwLSrJSbqTZCPAnYwzzQ=,iv:PnA9Ao8RRELNhNQYbaorstc0KaIXRU7h3+lgDCXZFHk=,tag:VeLgYQYwqthYihIoQTwYiA==,type:str]",
"sops": {
"age": [
{
"recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg",
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBNWVVQaDJFd0N3WHptRC9Z\nZTgxTWh5bnU1SkpqRWRXZnhPaFhpSVJmVEhrCjFvdHFYenNWaFNrdXlha09iS2xj\nOTZDcUNkcHkvTDUwNjM4Z3gxUkxreUEKLS0tIE5oY3Q2bWhsb2FSQTVGTWVSclJw\nWllrelRwT3duYjJJbTV0d3FwU1VuNlkK2eN3fHFX/sVUWom8TeZC9fddqnSCsC1+\nJRCZsG46uHDxqLcKIfdFWh++2t16XupQYk3kn+NUR/aMc3fR32Uwjw==\n-----END AGE ENCRYPTED FILE-----\n"
}
],
"lastmodified": "2025-08-13T09:20:18Z",
"mac": "ENC[AES256_GCM,data:nUwsPcP1bsDjAHFjQ1NlVkTwyZY4B+BpzNkMx9gl0rE14j425HVLtlhlLndhRp+XMpnDldQppLAAtSdzMsrw8r5efNgTRl7cu4Fy/b9cHt84k7m0aou5lrGus9SV1bM7/fzC9Xm7CSXBcRzyDGVsKC6UBl1rx+ybh7HyAN05XSo=,iv:It57H+zUUNPkoN1D8sYwyZx5zIFIga7mydhGUHYBCGE=,tag:mBQdYqUpjPknbYa13qESyw==,type:str]",
"unencrypted_suffix": "_unencrypted",
"version": "3.10.2"
}
}

View File

@@ -1 +0,0 @@
../../../users/admin

View File

@@ -1,4 +0,0 @@
{
"publickey": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg",
"type": "age"
}

View File

@@ -1 +0,0 @@
../../../../../../sops/machines/controller1

View File

@@ -1,19 +0,0 @@
{
"data": "ENC[AES256_GCM,data:noe913+28JWkoDkGGMu++cc1+j5NPDoyIhWixdsowoiVO3cTWGkZ88SUGO5D,iv:ynYMljwqMcBdk8RpVcw/2Jflg2RCF28r4fKUgIAF8B4=,tag:+TsXDJgfUhKgg4iQVXKKlQ==,type:str]",
"sops": {
"age": [
{
"recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg",
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBhYVRReTZBQ05GYmVBVjhS\nNXM5aFlhVzZRaVl6UHl6S3JnMC9Sb1dwZ1ZjCmVuS2dEVExYZWROVklUZWFCSnM2\nZnlxbVNseTM2c0Q0TjhsT3NzYmtqREUKLS0tIHBRTFpvVGt6d1cxZ2lFclRsUVhZ\nZDlWaG9PcXVrNUZKaEgxWndjUDVpYjgKt0eOhAgcYdkg9JSEakx4FjChLTn3pis+\njOkuGd4JfXMKcwC7vJV5ygQBxzVJSBw+RucP7sYCBPK0m8Voj94ntw==\n-----END AGE ENCRYPTED FILE-----\n"
},
{
"recipient": "age1rnkc2vmrupy9234clyu7fpur5kephuqs3v7qauaw5zeg00jqjdasefn3cc",
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB6MFJqNHNraG9DSnJZMFdz\ndU8zVXNTamxROFd1dWtuK2RiekhPdHhleVhFCi8zNWJDNXJMRUlDdjc4Q0UycTIz\nSGFGSmdnNU0wZWlDaTEwTzBqWjh6SFkKLS0tIEJOdjhOMDY2TUFLb3RPczNvMERx\nYkpSeW5VOXZvMlEvdm53MDE3aUFTNjgKyelSTjrTIR9I3rJd3krvzpsrKF1uGs4J\n4MtmQj0/3G+zPYZVBx7b3HF6B3f1Z7LYh05+z7nCnN/duXyPnDjNcg==\n-----END AGE ENCRYPTED FILE-----\n"
}
],
"lastmodified": "2025-08-13T09:19:37Z",
"mac": "ENC[AES256_GCM,data:+DmIkPG/H6tCtf8CvB98E1QFXv08QfTcCB3CRsi+XWnIRBkryRd/Au9JahViHMdK7MED8WNf84NWTjY2yH4y824/DjI8XXNMF1iVMo0CqY42xbVHtUuhXrYeT+c8CyEw+M6zfy1jC0+Bm3WQWgagz1G6A9SZk3D2ycu0N08+axA=,iv:kwBjTYebIy5i2hagAajSwwuKnSkrM9GyrnbeQXB2e/w=,tag:EgKJ5gVGYj1NGFUduxLGfg==,type:str]",
"unencrypted_suffix": "_unencrypted",
"version": "3.10.2"
}
}

View File

@@ -1 +0,0 @@
../../../../../../sops/users/admin

View File

@@ -1 +0,0 @@
lQfR7GhivN87XoXruTGOPjVPhNu1Brt//wyc3pdwE20=

View File

@@ -1 +0,0 @@
7470bb5c79df224a9b7f5a2259acd2e46db763c27e24cb3416c8b591cb328077

View File

@@ -1 +0,0 @@
../../../../../../sops/machines/controller2

View File

@@ -1,19 +0,0 @@
{
"data": "ENC[AES256_GCM,data:2kehACgvNgoYGPwnW7p86BR0yUu689Chth6qZf9zoJtuTY9ATS68dxDyBc5S,iv:qb2iDUtExegTeN3jt6SA8RnU61W5GDDhn56QXiQT4gw=,tag:pSGPICX5p6qlZ1WMVoIEYQ==,type:str]",
"sops": {
"age": [
{
"recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg",
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBSTTR5TDY4RE9VYmlCK1dL\nWkVRcVZqVDlsbmQvUlJmdzF2b1Z1S0k3NngwCkFWNzRVaERtSmFsd0o2aFJOb0ZX\nSU9yUnVaNi9IUjJWeGRFcEpDUXo5WkEKLS0tIEczNkxiYnJsTWRoLzFhQVF1M21n\nWnZEdGV1N2N5d1FZQkJUQ1IrdGFLblkKPTpha2bxS8CCAMXWTDKX/WOcdvggaP3Y\nqewyahDNzb4ggP+LNKp55BtwFjdvoPoq4BpYOOgMRbQMMk+H1o9WFw==\n-----END AGE ENCRYPTED FILE-----\n"
},
{
"recipient": "age1t2hhg99d4p2yymuhngcy5ccutp8mvu7qwvg5cdhck303h9e7ha9qnlt635",
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBYcEZ6Tzk3M0pkV0tOdTBj\nenF2a0tHNnhBa0NrazMwV1VBbXBZR3pzSHpvCnBZOEU0VlFHS1FHcVpTTDdPczVV\nV0RFSlZ0VmIzWGoydEdKVXlIUE9OOEkKLS0tIFZ0cWVBR1loeVlWa2c4U3oweXE2\ncm1ja0JCS3U5Nk41dlAzV2NabDc2bDQKdgCDNnpRZlFPnEGlX6fo0SQX4yOB+E6r\ntnSwofR3xxZvkyme/6JJU5qBZXyCXEAhKMRkFyvJANXzMJAUo/Osow==\n-----END AGE ENCRYPTED FILE-----\n"
}
],
"lastmodified": "2025-08-13T09:19:48Z",
"mac": "ENC[AES256_GCM,data:e3EkL8vwRhLsec83Zi9DE3PKT+4RwgiffpN4QHcJKTgmDW6hzizWc5kAxbNWGJ9Qqe6sso2KY7tc+hg1lHEsmzjCbg153p8h+7lVI2XT6adi/CS8WZ2VpeL+0X9zDQCjqHmrESZAYFBdkLqO4jucdf0Pc3CKKD+N3BDDTwSUvHM=,iv:xvR7dJL8sdYen00ovrYT8PNxhB9XxSWDSRz1IK23I/o=,tag:OyhAvllBgfAp3eGeNpR/Nw==,type:str]",
"unencrypted_suffix": "_unencrypted",
"version": "3.10.2"
}
}

View File

@@ -1 +0,0 @@
../../../../../../sops/users/admin

View File

@@ -1 +0,0 @@
5Z7gbLFbXpEFfomW2pKyZBpZN5xvUtiqrIL0GVfNtQ8=

View File

@@ -1 +0,0 @@
c3672fdb9fb31ddaf6572fc813cf7a8fe50488ef4e9d534c62d4f29da60a1a99

View File

@@ -1 +0,0 @@
../../../../../../sops/machines/peer1

View File

@@ -1,19 +0,0 @@
{
"data": "ENC[AES256_GCM,data:b+akw85T3D9xc75CPLHucR//k7inpxKDvgpR8tCNKwNDRVjVHjcABhfZNLXW,iv:g11fZE8UI0MVh9GKdjR6leBlxa4wN7ZubozXG/VlBbw=,tag:0YkzWCW3zJ3Mt3br/jmTYw==,type:str]",
"sops": {
"age": [
{
"recipient": "age1jts52rzlqcwjc36jkp56a7fmjn3czr7kl9ta2spkfzhvfama33sqacrzzd",
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBXWkJUR0pIa2xOSEw2dThm\nYlNuOHZCVW93Wkc5LzE4YmpUTHRkZlk3ckc4CnN4M3ZRMWNFVitCT3FyWkxaR0di\nb0NmSXFhRHJmTWg0d05OcWx1LytscEEKLS0tIEtleTFqU3JrRjVsdHpJeTNuVUhF\nWEtnOVlXVXRFamFSak5ia2F2b0JiTzAKlhOBZvZ4AN+QqAYQXvd6YNmgVS4gtkWT\nbV3bLNTgwtrDtet9NDHM8vdF+cn5RZxwFfgmTbDEow6Zm8EXfpxj/g==\n-----END AGE ENCRYPTED FILE-----\n"
},
{
"recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg",
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB6YVYyQkZqMTJYQTlyRG5Y\nbnJ2UkE1TS9FZkpSa2tQbk1hQjViMi9OcGk0CjFaZUdjU3JtNzh0bDFXdTdUVW4x\nanFqZHZjZjdzKzA2MC8vTWh3Uy82UGcKLS0tIDhyOFl3UGs3czdoMlpza3UvMlB1\nSE90MnpGc05sSCtmVWg0UVNVdmRvN2MKHlCr4U+7bsoYb+2fgT4mEseZCEjxrtLu\n55sR/4YH0vqMnIBnLTSA0e+WMrs3tQfseeJM5jY/ZNnpec1LbxkGTg==\n-----END AGE ENCRYPTED FILE-----\n"
}
],
"lastmodified": "2025-08-13T09:19:58Z",
"mac": "ENC[AES256_GCM,data:gEoEC9D2Z7k5F8egaY1qPXT5/96FFVsyofSBivQ28Ir/9xHX2j40PAQrYRJUWsk/GAUMOyi52Wm7kPuacw+bBcdtQ0+MCDEmjkEnh1V83eZ/baey7iMmg05uO92MYY5o4e7ZkwzXoAeMCMcfO0GqjNvsYJHF1pSNa+UNDj+eflw=,iv:dnIYpvhAdvUDe9md53ll42krb0sxcHy/toqGc7JFxNA=,tag:0WkZU7GeKMD1DQTYaI+1dg==,type:str]",
"unencrypted_suffix": "_unencrypted",
"version": "3.10.2"
}
}

View File

@@ -1 +0,0 @@
../../../../../../sops/users/admin

View File

@@ -1 +0,0 @@
juK7P/92N2t2t680aLIRobHc3ts49CsZBvfZOyIKpUc=

View File

@@ -1 +0,0 @@
b36142569a74a0de0f9b229f2a040ae33a22d53bef5e62aa6939912d0cda05ba

View File

@@ -1 +0,0 @@
../../../../../../sops/machines/peer2

View File

@@ -1,19 +0,0 @@
{
"data": "ENC[AES256_GCM,data:apX2sLwtq6iQgLJslFwiRMNBUe0XLzLQbhKfmb2pKiJG7jGNHUgHJz3Ls4Ca,iv:HTDatm3iD5wACTkkd3LdRNvJfnfg75RMtn9G6Q7Fqd4=,tag:Mfehlljnes5CFD1NJdk27A==,type:str]",
"sops": {
"age": [
{
"recipient": "age12nqnp0zd435ckp5p0v2fv4p2x4cvur2mnxe8use2sx3fgy883vaq4ae75e",
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBVZzFyMUZsd2V2VWxOUmhP\nZE8yZTc4Q0RkZisxR25NemR1TzVDWmJZVjBVClA1MWhsU0xzSG16aUx3cWFWKzlG\nSkxrT09OTkVqLzlWejVESE1QWHVJaFkKLS0tIGxlaGVuWU43RXErNTB3c3FaUnM3\nT0N5M253anZkbnFkZWw2VHA0eWhxQW8Kd1PMtEX1h0Hd3fDLMi++gKJkzPi9FXUm\n+uYhx+pb+pJM+iLkPwP/q6AWC7T0T4bHfekkdzxrbsKMi73x/GrOiw==\n-----END AGE ENCRYPTED FILE-----\n"
},
{
"recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg",
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBqVzRIMWdlNjVwTURyMFkv\nSUhiajZkZVNuWklRYit6cno4UzNDa2szOFN3CkQ2TWhHb25pbmR1MlBsRXNLL2lx\ncVZ3c3BsWXN2aS9UUVYvN3I4S0xUSmMKLS0tIE5FV0U5aXVUZk9XL0U0Z2ZSNGd5\nbU9zY3IvMlpSNVFLYkRNQUpUYVZOWFUK7j4Otzb8CJTcT7aAj9/irxHEDXh1HkTg\nzz7Ho8/ZncNtaCVHlHxjTgVW9d5aIx8fSsV9LRCFwHMtNzvwj1Nshg==\n-----END AGE ENCRYPTED FILE-----\n"
}
],
"lastmodified": "2025-08-13T09:20:08Z",
"mac": "ENC[AES256_GCM,data:e7WNVEz78noHBiz6S3A6qNfop+yBXB3rYN0k4GvaQKz3b99naEHuqIF8Smzzt4XrbbiPKu2iLa5ddLBlqqsi32UQUB8JS9TY7hvW8ol+jpn0VxusGCXW9ThdDEsM/hXiPyr331C73zTvbOYI1hmcGMlJL9cunVRO9rkMtEqhEfo=,iv:6zt7wjIs1y5xDHNK+yLOwoOuUpY7/dOGJGT6UWAFeOg=,tag:gzFTgoxhoLzUV0lvzOhhfg==,type:str]",
"unencrypted_suffix": "_unencrypted",
"version": "3.10.2"
}
}

View File

@@ -1 +0,0 @@
../../../../../../sops/users/admin

View File

@@ -1 +0,0 @@
XI9uSaQRDBCb82cMnGzGJcbqRfDG/IXZobyeL+kV03k=

View File

@@ -1 +0,0 @@
360f9fce4a984eb87ce2a673eb5341ecb89c0f62126548d45ef25ff5243dd646

View File

@@ -1 +0,0 @@
../../../../../../sops/machines/peer3

View File

@@ -1,19 +0,0 @@
{
"data": "ENC[AES256_GCM,data:Gluvjes/3oH5YsDq00JDJyJgoEFcj56smioMArPSt309MDGExYX2QsCzeO1q,iv:oBBJRDdTj/1dWEvzhdFKQ2WfeCKyavKMLmnMbqnU5PM=,tag:2WNFxKz2dWyVcybpm5N4iw==,type:str]",
"sops": {
"age": [
{
"recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg",
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBtQWpjRmhZTFdPa2VSZkFN\nbUczMlY5bDBmMTdoMy8xcWxMaXpWVitMZGdjCnRWb2Y3eGpHU1hmNHRJVFBqbU5w\nVEZGdUIrQXk0U0dUUEZ6bE5EMFpTRHMKLS0tIGpYSmZmQThJUTlvTHpjc05ZVlM4\nQWhTOWxnUHZnYlJ3czE3ZUJ0L3ozWTQK3a7N0Zpzo4sUezYveqvKR49RUdJL23eD\n+cK5lk2xbtj+YHkeG+dg7UlHfDaicj0wnFH1KLuWmNd1ONa6eQp3BQ==\n-----END AGE ENCRYPTED FILE-----\n"
},
{
"recipient": "age1sglr4zp34drjfydzeweq43fz3uwpul3hkh53lsfa9drhuzwmkqyqn5jegp",
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA3a2FOWlVsSkdnendrYmUz\ndEpuL1hZSWNFTUtDYm14S3V1aW9KS3hsazJRCkp2SkFFbi9hbGJpNks1MlNTL0s5\nTk5pcUMxaEJobkcvWmRGeU9jMkdNdzAKLS0tIDR6M0Y5eE1ETHJJejAzVW1EYy9v\nZCtPWHJPUkhuWnRzSGhMUUtTa280UmMKXvtnxyop7PmRvTOFkV80LziDjhGh93Pf\nYwhD/ByD/vMmr21Fd6PVHOX70FFT30BdnMc1/wt7c/0iAw4w4GoQsA==\n-----END AGE ENCRYPTED FILE-----\n"
}
],
"lastmodified": "2025-08-13T09:20:18Z",
"mac": "ENC[AES256_GCM,data:3nXMTma0UYXCco+EM8UW45cth7DVMboFBKyesL86GmaG6OlTkA2/25AeDrtSVO13a5c2jC6yNFK5dE6pSe5R9f0BoDF7d41mgc85zyn+LGECNWKC6hy6gADNSDD6RRuV1S3FisFQl1F1LD8LiSWmg/XNMZzChNlHYsCS8M+I84g=,iv:pu5VVXAVPmVoXy0BJ+hq5Ar8R0pZttKSYa4YS+dhDNc=,tag:xp1S/4qExnxMTGwhfLJrkA==,type:str]",
"unencrypted_suffix": "_unencrypted",
"version": "3.10.2"
}
}

View File

@@ -1 +0,0 @@
../../../../../../sops/users/admin

View File

@@ -1 +0,0 @@
t6qN4VGLR+VMhrBDNKQEXZVyRsEXs1/nGFRs5DI82F8=

View File

@@ -1 +0,0 @@
e3facc99b73fe029d4c295f71829a83f421f38d82361cf412326398175da162a

View File

@@ -1,217 +0,0 @@
# Wireguard VPN Service
This service provides a Wireguard-based VPN mesh network with automatic IPv6 address allocation and routing between clan machines.
## Overview
The wireguard service creates a secure mesh network between clan machines using two roles:
- **Controllers**: Machines with public endpoints that act as connection points and routers
- **Peers**: Machines that connect through controllers to access the network
## Requirements
- Controllers must have a publicly accessible endpoint (domain name or static IP)
- Peers must be in networks where UDP traffic is not blocked (uses port 51820 by default, configurable)
## Features
- Automatic IPv6 address allocation using ULA (Unique Local Address) prefixes
- Full mesh connectivity between all machines
- Automatic key generation and distribution
- IPv6 forwarding on controllers for inter-peer communication
- Support for multiple controllers for redundancy
## Network Architecture
### IPv6 Address Allocation
- Base network: `/40` ULA prefix (deterministically generated from instance name)
- Controllers: Each gets a `/56` subnet from the base `/40`
- Peers: Each gets a unique 64-bit host suffix that is used in ALL controller subnets
### Addressing Design
- Each peer generates a unique host suffix (e.g., `:8750:a09b:0:1`)
- This suffix is appended to each controller's `/56` prefix to create unique addresses
- Example: peer1 with suffix `:8750:a09b:0:1` gets:
- `fd51:19c1:3b:f700:8750:a09b:0:1` in controller1's subnet
- `fd51:19c1:c1:aa00:8750:a09b:0:1` in controller2's subnet
- Controllers allow each peer's `/96` subnet for routing flexibility
### Connectivity
- Peers use a single WireGuard interface with multiple IPs (one per controller subnet)
- Controllers connect to ALL other controllers and ALL peers on a single interface
- Controllers have IPv6 forwarding enabled to route traffic between peers
- All traffic between peers flows through controllers
- Symmetric routing is maintained as each peer has consistent IPs across all controllers
### Example Network Topology
```mermaid
graph TB
subgraph Controllers
C1[controller1<br/>endpoint: vpn1.example.com<br/>fd51:19c1:3b:f700::/56]
C2[controller2<br/>endpoint: vpn2.example.com<br/>fd51:19c1:c1:aa00::/56]
end
subgraph Peers
P1[peer1<br/>designated: controller1]
P2[peer2<br/>designated: controller2]
P3[peer3<br/>designated: controller1]
end
%% Controllers connect to each other
C1 <--> C2
%% All peers connect to all controllers
P1 <--> C1
P1 <--> C2
P2 <--> C1
P2 <--> C2
P3 <--> C1
P3 <--> C2
%% Peer-to-peer traffic flows through controllers
P1 -.->|via controllers| P3
P1 -.->|via controllers| P2
P2 -.->|via controllers| P3
classDef controller fill:#f9f,stroke:#333,stroke-width:4px
classDef peer fill:#bbf,stroke:#333,stroke-width:2px
class C1,C2 controller
class P1,P2,P3 peer
```
## Configuration
### Basic Setup with Single Controller
```nix
# In your flake.nix or inventory
{
services.wireguard.server1 = {
roles.controller = {
# Public endpoint where this controller can be reached
endpoint = "vpn.example.com";
# Optional: Change the UDP port (default: 51820)
port = 51820;
};
};
services.wireguard.laptop1 = {
roles.peer = {
# No configuration needed if only one controller exists
};
};
}
```
### Multiple Controllers Setup
```nix
{
services.wireguard.server1 = {
roles.controller = {
endpoint = "vpn1.example.com";
};
};
services.wireguard.server2 = {
roles.controller = {
endpoint = "vpn2.example.com";
};
};
services.wireguard.laptop1 = {
roles.peer = {
# Must specify which controller subnet is exposed as the default in /etc/hosts, when multiple controllers exist
controller = "server1";
};
};
}
```
### Advanced Options
### Automatic Hostname Resolution
The wireguard service automatically adds entries to `/etc/hosts` for all machines in the network. Each machine is accessible via its hostname in the format `<machine-name>.<instance-name>`.
For example, with an instance named `vpn`:
- `server1.vpn` - resolves to server1's IPv6 address
- `laptop1.vpn` - resolves to laptop1's IPv6 address
This allows machines to communicate using hostnames instead of IPv6 addresses:
```bash
# Ping another machine by hostname
ping6 server1.vpn
# SSH to another machine
ssh user@laptop1.vpn
```
## Troubleshooting
### Check Wireguard Status
```bash
sudo wg show
```
### Verify IP Addresses
```bash
ip addr show dev <instance-name>
```
### Check Routing
```bash
ip -6 route show dev <instance-name>
```
### Interface Fails to Start: "Address already in use"
If you see this error in your logs:
```
wireguard: Could not bring up interface, ignoring: Address already in use
```
This means the configured port (default: 51820) is already in use by another service or wireguard instance. Solutions:
1. **Check for conflicting wireguard instances:**
```bash
sudo wg show
sudo ss -ulnp | grep 51820
```
2. **Use a different port:**
```nix
services.wireguard.myinstance = {
roles.controller = {
endpoint = "vpn.example.com";
port = 51821; # Use a different port
};
};
```
3. **Ensure unique ports across multiple instances:**
If you have multiple wireguard instances on the same machine, each must use a different port.
### Key Management
Keys are automatically generated and stored in the clan vars system. To regenerate keys:
```bash
# Regenerate keys for a specific machine and instance
clan vars generate --service wireguard-keys-<instance-name> --regenerate --machine <machine-name>
# Apply the new keys
clan machines update <machine-name>
```
## Security Considerations
- All traffic is encrypted using Wireguard's modern cryptography
- Private keys never leave the machines they're generated on
- Public keys are distributed through the clan vars system
- Controllers must have publicly accessible endpoints
- Firewall rules are automatically configured for the Wireguard ports

View File

@@ -1,456 +0,0 @@
/*
There are two roles: peers and controllers:
- Every controller has an endpoint set
- There can be multiple peers
- There has to be one or more controllers
- Peers connect to ALL controllers (full mesh)
- If only one controller exists, peers automatically use it for IP allocation
- If multiple controllers exist, peers must specify which controller's subnet to use
- Controllers have IPv6 forwarding enabled, every peer and controller can reach
everyone else, via extra controller hops if necessary
Example:
controller2 controller1
peer2 peer1 peer3
Network Architecture:
IPv6 Address Allocation:
- Base network: /40 ULA prefix (generated from instance name)
- Controllers: Each gets a /56 subnet from the base /40
- Peers: Each gets a unique host suffix that is used in ALL controller subnets
Address Assignment:
- Each peer generates a unique 64-bit host suffix (e.g., :8750:a09b:0:1)
- This suffix is appended to each controller's /56 prefix
- Example: peer1 with suffix :8750:a09b:0:1 gets:
- fd51:19c1:3b:f700:8750:a09b:0:1 in controller1's subnet
- fd51:19c1:c1:aa00:8750:a09b:0:1 in controller2's subnet
Peers: Use a SINGLE interface that:
- Connects to ALL controllers
- Has multiple IPs, one in each controller's subnet (with /56 prefix)
- Routes to each controller's /56 subnet via that controller
- allowedIPs: Each controller's /56 subnet
- No routing conflicts due to unique IPs per subnet
Controllers: Use a SINGLE interface that:
- Connects to ALL peers and ALL other controllers
- Gets a /56 subnet from the base /40 network
- Has IPv6 forwarding enabled for routing between peers
- allowedIPs:
- For peers: A /96 range containing the peer's address in this controller's subnet
- For other controllers: The controller's /56 subnet
*/
{ ... }:
let
# Shared module for extraHosts configuration
extraHostsModule =
{
instanceName,
settings,
roles,
config,
lib,
...
}:
{
networking.extraHosts =
let
domain = if settings.domain == null then instanceName else settings.domain;
# Controllers use their subnet's ::1 address
controllerHosts = lib.mapAttrsToList (
name: _value:
let
prefix = builtins.readFile (
config.clan.core.settings.directory
+ "/vars/per-machine/${name}/wireguard-network-${instanceName}/prefix/value"
);
# Controller IP is always ::1 in their subnet
ip = prefix + "::1";
in
"${ip} ${name}.${domain}"
) roles.controller.machines;
# Peers use their suffix in their designated controller's subnet only
peerHosts = lib.mapAttrsToList (
peerName: peerValue:
let
peerSuffix = builtins.readFile (
config.clan.core.settings.directory
+ "/vars/per-machine/${peerName}/wireguard-network-${instanceName}/suffix/value"
);
# Determine designated controller
designatedController =
if (builtins.length (builtins.attrNames roles.controller.machines) == 1) then
(builtins.head (builtins.attrNames roles.controller.machines))
else
peerValue.settings.controller;
controllerPrefix = builtins.readFile (
config.clan.core.settings.directory
+ "/vars/per-machine/${designatedController}/wireguard-network-${instanceName}/prefix/value"
);
peerIP = controllerPrefix + ":" + peerSuffix;
in
"${peerIP} ${peerName}.${domain}"
) roles.peer.machines;
in
builtins.concatStringsSep "\n" (controllerHosts ++ peerHosts);
};
# Shared interface options
sharedInterface =
{ lib, ... }:
{
options.port = lib.mkOption {
type = lib.types.int;
example = 51820;
default = 51820;
description = ''
Port for the wireguard interface
'';
};
options.domain = lib.mkOption {
type = lib.types.nullOr lib.types.str;
defaultText = lib.literalExpression "instanceName";
default = null;
description = ''
Domain suffix to use for hostnames in /etc/hosts.
Defaults to the instance name.
'';
};
};
in
{
_class = "clan.service";
manifest.name = "clan-core/wireguard";
manifest.description = "Wireguard-based VPN mesh network with automatic IPv6 address allocation";
manifest.categories = [
"System"
"Network"
];
manifest.readme = builtins.readFile ./README.md;
# Peer options and configuration
roles.peer = {
interface =
{ lib, ... }:
{
imports = [ sharedInterface ];
options.controller = lib.mkOption {
type = lib.types.str;
example = "controller1";
description = ''
Machinename of the controller to attach to
'';
};
};
perInstance =
{
instanceName,
settings,
roles,
machine,
...
}:
{
# Set default domain to instanceName
# Peers connect to all controllers
nixosModule =
{
config,
pkgs,
lib,
...
}:
{
imports = [
(extraHostsModule {
inherit
instanceName
settings
roles
config
lib
;
})
];
# Network allocation generator for this peer - generates host suffix
clan.core.vars.generators."wireguard-network-${instanceName}" = {
files.suffix.secret = false;
runtimeInputs = with pkgs; [
python3
];
# Invalidate on hostname changes
validation.hostname = machine.name;
script = ''
${pkgs.python3}/bin/python3 ${./ipv6_allocator.py} "$out" "${instanceName}" peer "${machine.name}"
'';
};
# Single wireguard interface with multiple IPs
networking.wireguard.interfaces."${instanceName}" = {
ips =
# Get this peer's suffix
let
peerSuffix =
config.clan.core.vars.generators."wireguard-network-${instanceName}".files.suffix.value;
in
# Create an IP in each controller's subnet
lib.mapAttrsToList (
ctrlName: _:
let
controllerPrefix = builtins.readFile (
config.clan.core.settings.directory
+ "/vars/per-machine/${ctrlName}/wireguard-network-${instanceName}/prefix/value"
);
peerIP = controllerPrefix + ":" + peerSuffix;
in
"${peerIP}/56"
) roles.controller.machines;
privateKeyFile =
config.clan.core.vars.generators."wireguard-keys-${instanceName}".files."privatekey".path;
# Connect to all controllers
peers = lib.mapAttrsToList (name: value: {
publicKey = (
builtins.readFile (
config.clan.core.settings.directory
+ "/vars/per-machine/${name}/wireguard-keys-${instanceName}/publickey/value"
)
);
# Allow each controller's /56 subnet
allowedIPs = [
"${
builtins.readFile (
config.clan.core.settings.directory
+ "/vars/per-machine/${name}/wireguard-network-${instanceName}/prefix/value"
)
}::/56"
];
endpoint = "${value.settings.endpoint}:${toString value.settings.port}";
persistentKeepalive = 25;
}) roles.controller.machines;
};
};
};
};
# Controller options and configuration
roles.controller = {
interface =
{ lib, ... }:
{
imports = [ sharedInterface ];
options.endpoint = lib.mkOption {
type = lib.types.str;
example = "vpn.clan.lol";
description = ''
Endpoint where the controller can be reached
'';
};
};
perInstance =
{
settings,
instanceName,
roles,
machine,
...
}:
{
# Controllers connect to all peers and other controllers
nixosModule =
{
config,
pkgs,
lib,
...
}:
let
allOtherControllers = lib.filterAttrs (name: _v: name != machine.name) roles.controller.machines;
allPeers = roles.peer.machines;
in
{
imports = [
(extraHostsModule {
inherit
instanceName
settings
roles
config
lib
;
})
];
# Network allocation generator for this controller
clan.core.vars.generators."wireguard-network-${instanceName}" = {
files.prefix.secret = false;
runtimeInputs = with pkgs; [
python3
];
# Invalidate on network or hostname changes
validation.hostname = machine.name;
script = ''
${pkgs.python3}/bin/python3 ${./ipv6_allocator.py} "$out" "${instanceName}" controller "${machine.name}"
'';
};
# Enable ip forwarding, so wireguard peers can reach eachother
boot.kernel.sysctl."net.ipv6.conf.all.forwarding" = 1;
networking.firewall.allowedUDPPorts = [ settings.port ];
# Single wireguard interface
networking.wireguard.interfaces."${instanceName}" = {
listenPort = settings.port;
ips = [
# Controller uses ::1 in its /56 subnet but with /40 prefix for proper routing
"${config.clan.core.vars.generators."wireguard-network-${instanceName}".files.prefix.value}::1/40"
];
privateKeyFile =
config.clan.core.vars.generators."wireguard-keys-${instanceName}".files."privatekey".path;
# Connect to all peers and other controllers
peers = lib.mapAttrsToList (
name: value:
if allPeers ? ${name} then
# For peers: they now have our entire /56 subnet
{
publicKey = (
builtins.readFile (
config.clan.core.settings.directory
+ "/vars/per-machine/${name}/wireguard-keys-${instanceName}/publickey/value"
)
);
# Allow the peer's /96 range in ALL controller subnets
allowedIPs = lib.mapAttrsToList (
ctrlName: _:
let
controllerPrefix = builtins.readFile (
config.clan.core.settings.directory
+ "/vars/per-machine/${ctrlName}/wireguard-network-${instanceName}/prefix/value"
);
peerSuffix = builtins.readFile (
config.clan.core.settings.directory
+ "/vars/per-machine/${name}/wireguard-network-${instanceName}/suffix/value"
);
in
"${controllerPrefix}:${peerSuffix}/96"
) roles.controller.machines;
persistentKeepalive = 25;
}
else
# For other controllers: use their /56 subnet
{
publicKey = (
builtins.readFile (
config.clan.core.settings.directory
+ "/vars/per-machine/${name}/wireguard-keys-${instanceName}/publickey/value"
)
);
allowedIPs = [
"${
builtins.readFile (
config.clan.core.settings.directory
+ "/vars/per-machine/${name}/wireguard-network-${instanceName}/prefix/value"
)
}::/56"
];
endpoint = "${value.settings.endpoint}:${toString value.settings.port}";
persistentKeepalive = 25;
}
) (allPeers // allOtherControllers);
};
};
};
};
# Maps over all machines and produces one result per machine, regardless of role
perMachine =
{ instances, machine, ... }:
{
nixosModule =
{ pkgs, lib, ... }:
let
# Check if this machine has conflicting roles across all instances
machineRoleConflicts = lib.flatten (
lib.mapAttrsToList (
instanceName: instanceInfo:
let
isController =
instanceInfo.roles ? controller && instanceInfo.roles.controller.machines ? ${machine.name};
isPeer = instanceInfo.roles ? peer && instanceInfo.roles.peer.machines ? ${machine.name};
in
lib.optional (isController && isPeer) {
inherit instanceName;
machineName = machine.name;
}
) instances
);
in
{
# Add assertions for role conflicts
assertions = lib.forEach machineRoleConflicts (conflict: {
assertion = false;
message = ''
Machine '${conflict.machineName}' cannot have both 'controller' and 'peer' roles in the wireguard instance '${conflict.instanceName}'.
A machine must be either a controller or a peer, not both.
'';
});
# Generate keys for each instance where this machine participates
clan.core.vars.generators = lib.mapAttrs' (
name: _instanceInfo:
lib.nameValuePair "wireguard-keys-${name}" {
files.publickey.secret = false;
files.privatekey = { };
runtimeInputs = with pkgs; [
wireguard-tools
];
script = ''
wg genkey > $out/privatekey
wg pubkey < $out/privatekey > $out/publickey
'';
}
) instances;
};
};
}

View File

@@ -1,7 +0,0 @@
{ lib, ... }:
let
module = lib.modules.importApply ./default.nix { };
in
{
clan.modules.wireguard = module;
}

View File

@@ -1,135 +0,0 @@
#!/usr/bin/env python3
"""
IPv6 address allocator for WireGuard networks.
Network layout:
- Base network: /40 ULA prefix (fd00::/8 + 32 bits from hash)
- Controllers: Each gets a /56 subnet from the base /40 (256 controllers max)
- Peers: Each gets a /96 subnet from their controller's /56
"""
import hashlib
import ipaddress
import sys
from pathlib import Path
def hash_string(s: str) -> str:
"""Generate SHA256 hash of string."""
return hashlib.sha256(s.encode()).hexdigest()
def generate_ula_prefix(instance_name: str) -> ipaddress.IPv6Network:
"""
Generate a /40 ULA prefix from instance name.
Format: fd{32-bit hash}/40
This gives us fd00:0000:0000::/40 through fdff:ffff:ff00::/40
"""
h = hash_string(instance_name)
# For /40, we need 32 bits after 'fd' (8 hex chars)
# But only the first 32 bits count for the network prefix
# The last 8 bits of the 40-bit prefix must be 0
prefix_bits = int(h[:8], 16)
# Mask to ensure we only use the first 32 bits for /40
# This gives us addresses like fd28:387a::/40
prefix_bits = prefix_bits & 0xFFFFFF00 # Clear last 8 bits
# Format as IPv6 address
prefix = f"fd{prefix_bits:08x}"
prefix_formatted = f"{prefix[:4]}:{prefix[4:8]}::/40"
network = ipaddress.IPv6Network(prefix_formatted)
return network
def generate_controller_subnet(
base_network: ipaddress.IPv6Network, controller_name: str
) -> ipaddress.IPv6Network:
"""
Generate a /56 subnet for a controller from the base /40 network.
We have 16 bits (40 to 56) to allocate controller subnets.
This allows for 65,536 possible controller subnets.
"""
h = hash_string(controller_name)
# Take 16 bits from hash for the controller subnet ID
controller_id = int(h[:4], 16)
# Create the controller subnet by adding the controller ID to the base network
# The controller subnet is at base_prefix:controller_id::/56
base_int = int(base_network.network_address)
controller_subnet_int = base_int | (controller_id << (128 - 56))
controller_subnet = ipaddress.IPv6Network((controller_subnet_int, 56))
return controller_subnet
def generate_peer_suffix(peer_name: str) -> str:
"""
Generate a unique 64-bit host suffix for a peer.
This suffix will be used in all controller subnets to create unique addresses.
Format: :xxxx:xxxx:xxxx:xxxx (64 bits)
"""
h = hash_string(peer_name)
# Take 64 bits (16 hex chars) from hash for the host suffix
suffix_bits = h[:16]
# Format as IPv6 suffix without leading colon
suffix = f"{suffix_bits[0:4]}:{suffix_bits[4:8]}:{suffix_bits[8:12]}:{suffix_bits[12:16]}"
return suffix
def main() -> None:
if len(sys.argv) < 4:
print(
"Usage: ipv6_allocator.py <output_dir> <instance_name> <controller|peer> <machine_name>"
)
sys.exit(1)
output_dir = Path(sys.argv[1])
instance_name = sys.argv[2]
node_type = sys.argv[3]
# Generate base /40 network
base_network = generate_ula_prefix(instance_name)
if node_type == "controller":
if len(sys.argv) < 5:
print("Controller name required")
sys.exit(1)
controller_name = sys.argv[4]
subnet = generate_controller_subnet(base_network, controller_name)
# Extract clean prefix from subnet (e.g. "fd51:19c1:3b:f700::/56" -> "fd51:19c1:3b:f700")
prefix_str = str(subnet).split("/")[0].rstrip(":")
while prefix_str.endswith(":"):
prefix_str = prefix_str.rstrip(":")
# Write file
(output_dir / "prefix").write_text(prefix_str)
elif node_type == "peer":
if len(sys.argv) < 5:
print("Peer name required")
sys.exit(1)
peer_name = sys.argv[4]
# Generate the peer's host suffix
suffix = generate_peer_suffix(peer_name)
# Write file
(output_dir / "suffix").write_text(suffix)
else:
print(f"Unknown node type: {node_type}")
sys.exit(1)
if __name__ == "__main__":
main()

6
devFlake/flake.lock generated
View File

@@ -3,10 +3,10 @@
"clan-core-for-checks": {
"flake": false,
"locked": {
"lastModified": 1755093452,
"narHash": "sha256-NKBss7QtNnOqYVyJmYCgaCvYZK0mpQTQc9fLgE1mGyk=",
"lastModified": 1755008427,
"narHash": "sha256-obvJA4HxWsxPA7gfT0guabiz4xCn7xQgSbcDbPPkl9w=",
"ref": "main",
"rev": "7e97734797f0c6bd3c2d3a51cf54a2a6b371c222",
"rev": "ff979eba616d5f1d303881d08bdea3ddefdd79da",
"shallow": true,
"type": "git",
"url": "https://git.clan.lol/clan/clan-core"

View File

@@ -92,6 +92,7 @@ nav:
- Services:
- Overview:
- reference/clanServices/index.md
- reference/clanServices/admin.md
- reference/clanServices/borgbackup.md
- reference/clanServices/data-mesher.md
@@ -108,7 +109,6 @@ nav:
- reference/clanServices/trusted-nix-caches.md
- reference/clanServices/users.md
- reference/clanServices/wifi.md
- reference/clanServices/wireguard.md
- reference/clanServices/zerotier.md
- API: reference/clanServices/clan-service-author-interface.md

6
flake.lock generated
View File

@@ -146,11 +146,11 @@
]
},
"locked": {
"lastModified": 1754988908,
"narHash": "sha256-t+voe2961vCgrzPFtZxha0/kmFSHFobzF00sT8p9h0U=",
"lastModified": 1754328224,
"narHash": "sha256-glPK8DF329/dXtosV7YSzRlF4n35WDjaVwdOMEoEXHA=",
"owner": "Mic92",
"repo": "sops-nix",
"rev": "3223c7a92724b5d804e9988c6b447a0d09017d48",
"rev": "49021900e69812ba7ddb9e40f9170218a7eca9f4",
"type": "github"
},
"original": {

View File

@@ -59,6 +59,8 @@
"pkgs/clan-cli/clan_cli/tests/data/password-store/.gpg-id"
"pkgs/clan-cli/clan_cli/tests/data/ssh_host_ed25519_key"
"pkgs/clan-cli/clan_cli/tests/data/sshd_config"
"pkgs/clan-vm-manager/.vscode/lhebendanz.weaudit"
"pkgs/clan-vm-manager/bin/clan-vm-manager"
"clanServices/hello-world/default.nix"
"sops/secrets/test-backup-age.key/secret"
];
@@ -113,7 +115,21 @@
];
extraPythonPaths = [ "../clan-cli" ];
};
};
}
// (
if pkgs.stdenv.isLinux then
{
"clan-vm-manager" = {
directory = "pkgs/clan-vm-manager";
extraPythonPackages = self'.packages.clan-vm-manager.externalTestDeps ++ [
(pkgs.python3.withPackages (ps: self'.packages.clan-cli.devshellPyDeps ps))
];
extraPythonPaths = [ "../clan-cli" ];
};
}
else
{ }
);
treefmt.programs.ruff.check = true;
treefmt.programs.ruff.format = true;
};

View File

@@ -124,7 +124,7 @@ rec {
]
)
}" \
${pkgs.runtimeShell} -x "${genInfo.finalScript}"
${pkgs.runtimeShell} ${genInfo.finalScript}
# Verify expected outputs were created
${lib.concatStringsSep "\n" (

View File

@@ -22,7 +22,7 @@ export interface ButtonProps
startIcon?: IconVariant;
endIcon?: IconVariant;
class?: string;
loading?: boolean;
onAction?: Action;
}
const iconSizes: Record<Size, string> = {
@@ -40,12 +40,31 @@ export const Button = (props: ButtonProps) => {
"startIcon",
"endIcon",
"class",
"loading",
"onAction",
]);
const size = local.size || "default";
const hierarchy = local.hierarchy || "primary";
const [loading, setLoading] = createSignal(false);
const onClick = async () => {
if (!local.onAction) {
console.error("this should not be possible");
return;
}
setLoading(true);
try {
await local.onAction();
} catch (error) {
console.error("Error while executing action", error);
}
setLoading(false);
};
const iconSize = iconSizes[local.size || "default"];
const loadingClass =
@@ -62,19 +81,16 @@ export const Button = (props: ButtonProps) => {
hierarchy,
{
icon: local.icon,
loading: props.loading,
loading: loading(),
ghost: local.ghost,
},
)}
onClick={props.onClick}
onClick={local.onAction ? onClick : undefined}
{...other}
>
<Loader
hierarchy={hierarchy}
class={cx({
[idleClass]: !props.loading,
[loadingClass]: props.loading,
})}
class={cx({ [idleClass]: !loading(), [loadingClass]: loading() })}
/>
{local.startIcon && (

View File

@@ -318,13 +318,8 @@ export const useMachineGenerators = (
],
queryFn: async () => {
const call = client.fetch("get_generators", {
machine: {
name: machineName,
flake: {
identifier: clanUri,
},
},
full_closure: true, // TODO: Make this configurable
base_dir: clanUri,
machine_name: machineName,
// TODO: Make this configurable
include_previous_values: true,
});

View File

@@ -99,12 +99,8 @@ const welcome = (props: {
}) => {
const navigate = useNavigate();
const [loading, setLoading] = createSignal(false);
const selectFolder = async () => {
setLoading(true);
const uri = await selectClanFolder();
setLoading(false);
navigateToClan(navigate, uri);
};
@@ -152,12 +148,7 @@ const welcome = (props: {
</Typography>
<Divider orientation="horizontal" />
</div>
<Button
hierarchy="primary"
ghost={true}
loading={loading()}
onClick={selectFolder}
>
<Button hierarchy="primary" ghost={true} onAction={selectFolder}>
Select folder
</Button>
</div>

View File

@@ -72,7 +72,7 @@ const mockFetcher: Fetcher = <K extends OperationNames>(
],
},
],
run_generators: null,
run_generators: true,
get_machine_hardware_summary: {
hardware_config: "nixos-facter",
},

View File

@@ -4,7 +4,6 @@ import {
createForm,
FieldValues,
getError,
getValue,
SubmitHandler,
valiForm,
} from "@modular-forms/solid";
@@ -14,7 +13,7 @@ import { getStepStore, useStepper } from "@/src/hooks/stepper";
import { InstallSteps, InstallStoreType, PromptValues } from "../install";
import { TextInput } from "@/src/components/Form/TextInput";
import { Alert } from "@/src/components/Alert/Alert";
import { createSignal, For, Match, Show, Switch } from "solid-js";
import { For, Match, Show, Switch } from "solid-js";
import { Divider } from "@/src/components/Divider/Divider";
import { Orienter } from "@/src/components/Form/Orienter";
import { Button } from "@/src/components/Button/Button";
@@ -30,7 +29,6 @@ import {
import { useClanURI } from "@/src/hooks/clan";
import { useApiClient } from "@/src/hooks/ApiClient";
import { ProcessMessage, useNotifyOrigin } from "@/src/hooks/notify";
import { Loader } from "@/src/components/Loader/Loader";
export const InstallHeader = (props: { machineName: string }) => {
return (
@@ -60,9 +58,8 @@ const ConfigureAddress = () => {
},
});
const [isReachable, setIsReachable] = createSignal<string | null>(null);
const client = useApiClient();
const clanUri = useClanURI();
// TODO: push values to the parent form Store
const handleSubmit: SubmitHandler<ConfigureAdressForm> = async (
values,
@@ -75,24 +72,6 @@ const ConfigureAddress = () => {
stepSignal.next();
};
const tryReachable = async () => {
const address = getValue(formStore, "targetHost");
if (!address) {
return;
}
const call = client.fetch("check_machine_ssh_login", {
remote: {
address,
},
});
const result = await call.result;
console.log("SSH login check result:", result);
if (result.status === "success") {
setIsReachable(address);
}
};
return (
<Form onSubmit={handleSubmit} class="h-full">
<StepLayout
@@ -119,28 +98,12 @@ const ConfigureAddress = () => {
)}
</Field>
</Fieldset>
<Button
disabled={!getValue(formStore, "targetHost")}
endIcon="ArrowRight"
onClick={tryReachable}
hierarchy="secondary"
>
Test Connection
</Button>
</div>
}
footer={
<div class="flex justify-between">
<BackButton />
<NextButton
type="submit"
disabled={
!isReachable() ||
isReachable() !== getValue(formStore, "targetHost")
}
>
Next
</NextButton>
<NextButton type="submit">Next</NextButton>
</div>
}
/>
@@ -194,18 +157,15 @@ const CheckHardware = () => {
Hardware Report
</Typography>
<Button
disabled={hardwareQuery.isLoading}
hierarchy="secondary"
startIcon="Report"
onClick={handleUpdateSummary}
class="flex gap-3"
loading={hardwareQuery.isFetching}
>
Update hardware report
</Button>
</Orienter>
<Divider orientation="horizontal" />
<Show when={hardwareQuery.isLoading}>Loading...</Show>
<Show when={hardwareQuery.data}>
{(d) => (
<Alert
@@ -548,12 +508,8 @@ const InstallSummary = () => {
const runGenerators = client.fetch("run_generators", {
all_prompt_values: store.install.promptValues,
machine: {
name: store.install.machineName,
flake: {
identifier: clanUri,
},
},
base_dir: clanUri,
machine_name: store.install.machineName,
});
set("install", (s) => ({
@@ -589,16 +545,13 @@ const InstallSummary = () => {
<StepLayout
body={
<div class="flex flex-col gap-4">
<Fieldset legend="Machine">
<Fieldset legend="Address Configuration">
<Orienter orientation="horizontal">
<Display label="Name" value={store.install.machineName} />
</Orienter>
<Divider orientation="horizontal" />
<Orienter orientation="horizontal">
<Display label="Address" value={store.install.targetHost} />
{/* TOOD: Display the values emited from previous steps */}
<Display label="Target" value="flash-installer.local" />
</Orienter>
</Fieldset>
<Fieldset legend="Disk">
<Fieldset legend="Disk Configuration">
<Orienter orientation="horizontal">
<Display label="Disk Schema" value="Single" />
</Orienter>

View File

@@ -6,9 +6,9 @@ from tempfile import TemporaryDirectory
from clan_lib.flake import require_flake
from clan_lib.machines.machines import Machine
from clan_lib.ssh.host import Host
from clan_lib.ssh.upload import upload
from clan_cli.completions import add_dynamic_completer, complete_machines
from clan_cli.ssh.upload import upload
log = logging.getLogger(__name__)

View File

@@ -1,8 +1,6 @@
import argparse
import json
import logging
import sys
from contextlib import ExitStack
from pathlib import Path
from typing import get_args
@@ -10,7 +8,6 @@ from clan_lib.errors import ClanError
from clan_lib.flake import require_flake
from clan_lib.machines.install import BuildOn, InstallOptions, run_machine_install
from clan_lib.machines.machines import Machine
from clan_lib.network.qr_code import read_qr_image, read_qr_json
from clan_lib.ssh.host_key import HostKeyCheck
from clan_lib.ssh.remote import Remote
@@ -20,6 +17,7 @@ from clan_cli.completions import (
complete_target_host,
)
from clan_cli.machines.hardware import HardwareConfig
from clan_cli.ssh.deploy_info import DeployInfo, find_reachable_host, ssh_command_parse
log = logging.getLogger(__name__)
@@ -29,71 +27,80 @@ def install_command(args: argparse.Namespace) -> None:
flake = require_flake(args.flake)
# Only if the caller did not specify a target_host via args.target_host
# Find a suitable target_host that is reachable
with ExitStack() as stack:
remote: Remote
if args.target_host:
# TODO add network support here with either --network or some url magic
remote = Remote.from_ssh_uri(
machine_name=args.machine, address=args.target_host
)
elif args.png:
data = read_qr_image(Path(args.png))
qr_code = read_qr_json(data, args.flake)
remote = stack.enter_context(qr_code.get_best_remote())
elif args.json:
json_file = Path(args.json)
if json_file.is_file():
data = json.loads(json_file.read_text())
else:
data = json.loads(args.json)
target_host_str = args.target_host
deploy_info: DeployInfo | None = (
ssh_command_parse(args) if target_host_str is None else None
)
qr_code = read_qr_json(data, args.flake)
remote = stack.enter_context(qr_code.get_best_remote())
use_tor = False
if deploy_info:
host = find_reachable_host(deploy_info)
if host is None or host.socks_port:
use_tor = True
target_host_str = deploy_info.tor.target
else:
msg = "No --target-host, --json or --png data provided"
raise ClanError(msg)
target_host_str = host.target
machine = Machine(name=args.machine, flake=flake)
if args.host_key_check:
remote.override(host_key_check=args.host_key_check)
if args.password:
password = args.password
elif deploy_info and deploy_info.addrs[0].password:
password = deploy_info.addrs[0].password
else:
password = None
if machine._class_ == "darwin":
msg = "Installing macOS machines is not yet supported"
raise ClanError(msg)
machine = Machine(name=args.machine, flake=flake)
host_key_check = args.host_key_check
if not args.yes:
while True:
ask = (
input(f"Install {args.machine} to {remote.target}? [y/N] ")
.strip()
.lower()
)
if ask == "y":
break
if ask == "n" or ask == "":
return None
print(
f"Invalid input '{ask}'. Please enter 'y' for yes or 'n' for no."
)
if target_host_str is not None:
target_host = Remote.from_ssh_uri(
machine_name=machine.name, address=target_host_str
).override(host_key_check=host_key_check)
else:
target_host = machine.target_host().override(host_key_check=host_key_check)
if args.identity_file:
remote = remote.override(private_key=args.identity_file)
if args.identity_file:
target_host = target_host.override(private_key=args.identity_file)
if args.password:
remote = remote.override(password=args.password)
if machine._class_ == "darwin":
msg = "Installing macOS machines is not yet supported"
raise ClanError(msg)
return run_machine_install(
InstallOptions(
machine=machine,
kexec=args.kexec,
phases=args.phases,
debug=args.debug,
no_reboot=args.no_reboot,
build_on=args.build_on if args.build_on is not None else None,
update_hardware_config=HardwareConfig(args.update_hardware_config),
),
target_host=remote,
if not args.yes:
while True:
ask = (
input(f"Install {args.machine} to {target_host.target}? [y/N] ")
.strip()
.lower()
)
if ask == "y":
break
if ask == "n" or ask == "":
return None
print(f"Invalid input '{ask}'. Please enter 'y' for yes or 'n' for no.")
if args.identity_file:
target_host = target_host.override(private_key=args.identity_file)
if password:
target_host = target_host.override(password=password)
if use_tor:
target_host = target_host.override(
socks_port=9050, socks_wrapper=["torify"]
)
return run_machine_install(
InstallOptions(
machine=machine,
kexec=args.kexec,
phases=args.phases,
debug=args.debug,
no_reboot=args.no_reboot,
build_on=args.build_on if args.build_on is not None else None,
update_hardware_config=HardwareConfig(args.update_hardware_config),
),
target_host=target_host,
)
except KeyboardInterrupt:
log.warning("Interrupted by user")
sys.exit(1)

View File

@@ -16,9 +16,6 @@ def overview_command(args: argparse.Namespace) -> None:
for peer_name, peer in network["peers"].items():
print(f"\t{peer_name}: {'[OFFLINE]' if not peer else f'[{peer}]'}")
if not overview:
print("No networks found.")
def register_overview_parser(parser: argparse.ArgumentParser) -> None:
parser.set_defaults(func=overview_command)

View File

@@ -16,8 +16,8 @@ def ping_command(args: argparse.Namespace) -> None:
networks = networks_from_flake(flake)
if not networks:
print("No networks found")
return
print("No networks found in the flake")
# If network is specified, only check that network
if network_name:
networks_to_check = [(network_name, networks[network_name])]

View File

@@ -1,14 +1,17 @@
import argparse
import contextlib
import json
import logging
from contextlib import ExitStack
import textwrap
from dataclasses import dataclass
from pathlib import Path
from typing import get_args
from typing import Any, get_args
from clan_lib.cmd import run
from clan_lib.errors import ClanError
from clan_lib.machines.machines import Machine
from clan_lib.network.network import get_best_remote
from clan_lib.network.qr_code import read_qr_image, read_qr_json
from clan_lib.network.tor.lib import spawn_tor
from clan_lib.nix import nix_shell
from clan_lib.ssh.remote import HostKeyCheck, Remote
from clan_cli.completions import (
@@ -19,57 +22,180 @@ from clan_cli.completions import (
log = logging.getLogger(__name__)
def get_tor_remote(remotes: list[Remote]) -> Remote:
"""Get the Remote configured for SOCKS5 proxy (Tor)."""
tor_remotes = [r for r in remotes if r.socks_port]
@dataclass
class DeployInfo:
addrs: list[Remote]
if not tor_remotes:
msg = "No socks5 proxy address provided, please provide a socks5 proxy address."
@property
def tor(self) -> Remote:
"""Return a list of Remote objects that are configured for SOCKS5 proxy."""
addrs = [addr for addr in self.addrs if addr.socks_port]
if not addrs:
msg = "No socks5 proxy address provided, please provide a socks5 proxy address."
raise ClanError(msg)
if len(addrs) > 1:
msg = "Multiple socks5 proxy addresses provided, expected only one."
raise ClanError(msg)
return addrs[0]
def overwrite_remotes(
self,
host_key_check: HostKeyCheck | None = None,
private_key: Path | None = None,
ssh_options: dict[str, str] | None = None,
) -> "DeployInfo":
"""Return a new DeployInfo with all Remotes overridden with the given host_key_check."""
return DeployInfo(
addrs=[
addr.override(
host_key_check=host_key_check,
private_key=private_key,
ssh_options=ssh_options,
)
for addr in self.addrs
]
)
@staticmethod
def from_json(data: dict[str, Any], host_key_check: HostKeyCheck) -> "DeployInfo":
addrs = []
password = data.get("pass")
for addr in data.get("addrs", []):
if isinstance(addr, str):
remote = Remote.from_ssh_uri(
machine_name="clan-installer",
address=addr,
).override(host_key_check=host_key_check, password=password)
addrs.append(remote)
else:
msg = f"Invalid address format: {addr}"
raise ClanError(msg)
if tor_addr := data.get("tor"):
remote = Remote.from_ssh_uri(
machine_name="clan-installer",
address=tor_addr,
).override(
host_key_check=host_key_check,
socks_port=9050,
socks_wrapper=["torify"],
password=password,
)
addrs.append(remote)
return DeployInfo(addrs=addrs)
@staticmethod
def from_qr_code(picture_file: Path, host_key_check: HostKeyCheck) -> "DeployInfo":
cmd = nix_shell(
["zbar"],
[
"zbarimg",
"--quiet",
"--raw",
str(picture_file),
],
)
res = run(cmd)
data = res.stdout.strip()
return DeployInfo.from_json(json.loads(data), host_key_check=host_key_check)
def find_reachable_host(deploy_info: DeployInfo) -> Remote | None:
# If we only have one address, we have no choice but to use it.
if len(deploy_info.addrs) == 1:
return deploy_info.addrs[0]
for addr in deploy_info.addrs:
with contextlib.suppress(ClanError):
addr.check_machine_ssh_reachable()
return addr
return None
def ssh_shell_from_deploy(
deploy_info: DeployInfo, command: list[str] | None = None
) -> None:
if command and len(command) == 1 and command[0].count(" ") > 0:
msg = (
textwrap.dedent("""
It looks like you quoted the remote command.
The first argument should be the command to run, not a quoted string.
""")
.lstrip("\n")
.rstrip("\n")
)
raise ClanError(msg)
if len(tor_remotes) > 1:
msg = "Multiple socks5 proxy addresses provided, expected only one."
if host := find_reachable_host(deploy_info):
host.interactive_ssh(command)
return
log.info("Could not reach host via clearnet 'addrs'")
log.info(f"Trying to reach host via tor '{deploy_info}'")
tor_addrs = [addr for addr in deploy_info.addrs if addr.socks_port]
if not tor_addrs:
msg = "No tor address provided, please provide a tor address."
raise ClanError(msg)
return tor_remotes[0]
with spawn_tor():
for tor_addr in tor_addrs:
log.info(f"Trying to reach host via tor address: {tor_addr}")
with contextlib.suppress(ClanError):
tor_addr.check_machine_ssh_reachable()
log.info(
"Host reachable via tor address, starting interactive ssh session."
)
tor_addr.interactive_ssh(command)
return
log.error("Could not reach host via tor address.")
def ssh_command_parse(args: argparse.Namespace) -> DeployInfo | None:
host_key_check = args.host_key_check
deploy = None
if args.json:
json_file = Path(args.json)
if json_file.is_file():
data = json.loads(json_file.read_text())
return DeployInfo.from_json(data, host_key_check)
data = json.loads(args.json)
deploy = DeployInfo.from_json(data, host_key_check)
elif args.png:
deploy = DeployInfo.from_qr_code(Path(args.png), host_key_check)
elif hasattr(args, "machine") and args.machine:
machine = Machine(args.machine, args.flake)
target = machine.target_host().override(
command_prefix=machine.name, host_key_check=host_key_check
)
deploy = DeployInfo(addrs=[target])
else:
return None
ssh_options = None
if hasattr(args, "ssh_option") and args.ssh_option:
for name, value in args.ssh_option:
ssh_options = {}
ssh_options[name] = value
deploy = deploy.overwrite_remotes(ssh_options=ssh_options)
return deploy
def ssh_command(args: argparse.Namespace) -> None:
with ExitStack() as stack:
remote: Remote
if hasattr(args, "machine") and args.machine:
machine = Machine(args.machine, args.flake)
remote = stack.enter_context(get_best_remote(machine))
elif args.png:
data = read_qr_image(Path(args.png))
qr_code = read_qr_json(data, args.flake)
remote = stack.enter_context(qr_code.get_best_remote())
elif args.json:
json_file = Path(args.json)
if json_file.is_file():
data = json.loads(json_file.read_text())
else:
data = json.loads(args.json)
qr_code = read_qr_json(data, args.flake)
remote = stack.enter_context(qr_code.get_best_remote())
else:
msg = "No MACHINE, --json or --png data provided"
raise ClanError(msg)
# Convert ssh_option list to dictionary
ssh_options = {}
if args.ssh_option:
for name, value in args.ssh_option:
ssh_options[name] = value
remote = remote.override(
host_key_check=args.host_key_check, ssh_options=ssh_options
)
if args.remote_command:
remote.interactive_ssh(args.remote_command)
else:
remote.interactive_ssh()
deploy_info = ssh_command_parse(args)
if not deploy_info:
msg = "No MACHINE, --json or --png data provided"
raise ClanError(msg)
ssh_shell_from_deploy(deploy_info, args.remote_command)
def register_parser(parser: argparse.ArgumentParser) -> None:

View File

@@ -3,17 +3,15 @@ from pathlib import Path
import pytest
from clan_lib.cmd import RunOpts, run
from clan_lib.flake import Flake
from clan_lib.network.qr_code import read_qr_image, read_qr_json
from clan_lib.nix import nix_shell
from clan_lib.ssh.remote import Remote
from clan_cli.ssh.deploy_info import DeployInfo, find_reachable_host
from clan_cli.tests.fixtures_flakes import ClanFlake
from clan_cli.tests.helpers import cli
@pytest.mark.with_core
def test_qrcode_scan(temp_dir: Path, flake: ClanFlake) -> None:
def test_qrcode_scan(temp_dir: Path) -> None:
data = '{"pass":"scabbed-defender-headlock","tor":"qjeerm4r6t55hcfum4pinnvscn5njlw2g3k7ilqfuu7cdt3ahaxhsbid.onion","addrs":["192.168.122.86"]}'
img_path = temp_dir / "qrcode.png"
cmd = nix_shell(
@@ -27,93 +25,63 @@ def test_qrcode_scan(temp_dir: Path, flake: ClanFlake) -> None:
run(cmd, RunOpts(input=data.encode()))
# Call the qrcode_scan function
json_data = read_qr_image(img_path)
qr_code = read_qr_json(json_data, Flake(str(flake.path)))
deploy_info = DeployInfo.from_qr_code(img_path, "none")
# Check addresses
addresses = qr_code.addresses
assert len(addresses) >= 2 # At least direct and tor
host = deploy_info.addrs[0]
assert host.address == "192.168.122.86"
assert host.user == "root"
assert host.password == "scabbed-defender-headlock"
# Find direct connection
direct_remote = None
for addr in addresses:
if addr.network.module_name == "clan_lib.network.direct":
direct_remote = addr.remote
break
assert direct_remote is not None
assert direct_remote.address == "192.168.122.86"
assert direct_remote.user == "root"
assert direct_remote.password == "scabbed-defender-headlock"
# Find tor connection
tor_remote = None
for addr in addresses:
if addr.network.module_name == "clan_lib.network.tor":
tor_remote = addr.remote
break
assert tor_remote is not None
tor_host = deploy_info.addrs[1]
assert (
tor_remote.address
tor_host.address
== "qjeerm4r6t55hcfum4pinnvscn5njlw2g3k7ilqfuu7cdt3ahaxhsbid.onion"
)
assert tor_host.socks_port == 9050
assert tor_host.password == "scabbed-defender-headlock"
assert tor_host.user == "root"
assert (
tor_host.address
== "qjeerm4r6t55hcfum4pinnvscn5njlw2g3k7ilqfuu7cdt3ahaxhsbid.onion"
)
assert tor_remote.socks_port == 9050
assert tor_remote.password == "scabbed-defender-headlock"
assert tor_remote.user == "root"
def test_from_json(temp_dir: Path) -> None:
def test_from_json() -> None:
data = '{"pass":"scabbed-defender-headlock","tor":"qjeerm4r6t55hcfum4pinnvscn5njlw2g3k7ilqfuu7cdt3ahaxhsbid.onion","addrs":["192.168.122.86"]}'
flake = Flake(str(temp_dir))
qr_code = read_qr_json(json.loads(data), flake)
deploy_info = DeployInfo.from_json(json.loads(data), "none")
# Check addresses
addresses = qr_code.addresses
assert len(addresses) >= 2 # At least direct and tor
host = deploy_info.addrs[0]
assert host.password == "scabbed-defender-headlock"
assert host.address == "192.168.122.86"
# Find direct connection
direct_remote = None
for addr in addresses:
if addr.network.module_name == "clan_lib.network.direct":
direct_remote = addr.remote
break
assert direct_remote is not None
assert direct_remote.password == "scabbed-defender-headlock"
assert direct_remote.address == "192.168.122.86"
# Find tor connection
tor_remote = None
for addr in addresses:
if addr.network.module_name == "clan_lib.network.tor":
tor_remote = addr.remote
break
assert tor_remote is not None
tor_host = deploy_info.addrs[1]
assert (
tor_remote.address
tor_host.address
== "qjeerm4r6t55hcfum4pinnvscn5njlw2g3k7ilqfuu7cdt3ahaxhsbid.onion"
)
assert tor_host.socks_port == 9050
assert tor_host.password == "scabbed-defender-headlock"
assert tor_host.user == "root"
assert (
tor_host.address
== "qjeerm4r6t55hcfum4pinnvscn5njlw2g3k7ilqfuu7cdt3ahaxhsbid.onion"
)
assert tor_remote.socks_port == 9050
assert tor_remote.password == "scabbed-defender-headlock"
assert tor_remote.user == "root"
# TODO: This test needs to be updated to use get_best_remote from clan_lib.network.network
# @pytest.mark.with_core
# def test_find_reachable_host(hosts: list[Remote]) -> None:
# host = hosts[0]
#
# uris = ["172.19.1.2", host.ssh_url()]
# remotes = [Remote.from_ssh_uri(machine_name="some", address=uri) for uri in uris]
#
# assert remotes[0].address == "172.19.1.2"
#
# remote = find_reachable_host(remotes=remotes)
#
# assert remote is not None
# assert remote.ssh_url() == host.ssh_url()
@pytest.mark.with_core
def test_find_reachable_host(hosts: list[Remote]) -> None:
host = hosts[0]
uris = ["172.19.1.2", host.ssh_url()]
remotes = [Remote.from_ssh_uri(machine_name="some", address=uri) for uri in uris]
deploy_info = DeployInfo(addrs=remotes)
assert deploy_info.addrs[0].address == "172.19.1.2"
remote = find_reachable_host(deploy_info=deploy_info)
assert remote is not None
assert remote.ssh_url() == host.ssh_url()
@pytest.mark.with_core

View File

@@ -1,8 +1,8 @@
from pathlib import Path
import pytest
from clan_cli.ssh.upload import upload
from clan_lib.ssh.remote import Remote
from clan_lib.ssh.upload import upload
@pytest.mark.with_core

View File

@@ -699,7 +699,8 @@ def test_api_set_prompts(
monkeypatch.chdir(flake.path)
run_generators(
machine=Machine(name="my_machine", flake=Flake(str(flake.path))),
machine_name="my_machine",
base_dir=flake.path,
generators=["my_generator"],
all_prompt_values={
"my_generator": {
@@ -713,7 +714,8 @@ def test_api_set_prompts(
assert store.exists(my_generator, "prompt1")
assert store.get(my_generator, "prompt1").decode() == "input1"
run_generators(
machine=Machine(name="my_machine", flake=Flake(str(flake.path))),
machine_name="my_machine",
base_dir=flake.path,
generators=["my_generator"],
all_prompt_values={
"my_generator": {
@@ -723,9 +725,8 @@ def test_api_set_prompts(
)
assert store.get(my_generator, "prompt1").decode() == "input2"
machine = Machine(name="my_machine", flake=Flake(str(flake.path)))
generators = get_generators(
machine=machine, full_closure=True, include_previous_values=True
machine_name="my_machine", base_dir=flake.path, include_previous_values=True
)
# get_generators should bind the store
assert generators[0].files[0]._store is not None

View File

@@ -8,6 +8,7 @@ from dataclasses import dataclass, field
from functools import cached_property
from pathlib import Path
from tempfile import TemporaryDirectory
from typing import TYPE_CHECKING
from clan_cli.completions import (
add_dynamic_completer,
@@ -22,7 +23,6 @@ from clan_lib.errors import ClanError
from clan_lib.flake import Flake, require_flake
from clan_lib.git import commit_files
from clan_lib.machines.list import list_full_machines
from clan_lib.machines.machines import Machine
from clan_lib.nix import nix_config, nix_shell, nix_test_store
from .check import check_vars
@@ -32,6 +32,10 @@ from .var import Var
log = logging.getLogger(__name__)
if TYPE_CHECKING:
from clan_lib.flake import Flake
from clan_lib.machines.machines import Machine
@dataclass(frozen=True)
class GeneratorKey:
@@ -353,11 +357,6 @@ def _execute_generator(
if not secret_file.is_file():
msg = f"did not generate a file for '{file.name}' when running the following command:\n"
msg += str(final_script)
# list all files in the output directory
if tmpdir_out.is_dir():
msg += "\nOutput files:\n"
for f in tmpdir_out.iterdir():
msg += f" - {f.name}\n"
raise ClanError(msg)
if file.secret:
file_path = secret_vars_store.set(
@@ -423,25 +422,12 @@ def _get_previous_value(
return None
@API.register
def get_generators(
machine: Machine,
def _get_closure(
machine: "Machine",
generator_name: str | None,
full_closure: bool,
generator_name: str | None = None,
include_previous_values: bool = False,
) -> list[Generator]:
"""
Get generators for a machine, with optional closure computation.
Args:
machine: The machine to get generators for.
full_closure: If True, include all dependency generators. If False, only include missing ones.
generator_name: Name of a specific generator to get, or None for all generators.
include_previous_values: If True, populate prompts with their previous values.
Returns:
List of generators based on the specified selection and closure mode.
"""
from . import graph
vars_generators = Generator.get_machine_generators(machine.name, machine.flake)
@@ -503,7 +489,7 @@ def _generate_vars_for_machine(
generators: list[Generator],
all_prompt_values: dict[str, dict[str, str]],
no_sandbox: bool = False,
) -> None:
) -> bool:
_ensure_healthy(machine=machine, generators=generators)
for generator in generators:
if check_can_migrate(machine, generator):
@@ -517,15 +503,42 @@ def _generate_vars_for_machine(
prompt_values=all_prompt_values.get(generator.name, {}),
no_sandbox=no_sandbox,
)
return True
@API.register
def get_generators(
machine_name: str,
base_dir: Path,
include_previous_values: bool = False,
) -> list[Generator]:
"""
Get the list of generators for a machine, optionally with previous values.
If `full_closure` is True, it returns the full closure of generators.
If `include_previous_values` is True, it includes the previous values for prompts.
Args:
machine_name (str): The name of the machine.
base_dir (Path): The base directory of the flake.
Returns:
list[Generator]: A list of generators for the machine.
"""
return Generator.get_machine_generators(
machine_name,
Flake(str(base_dir)),
include_previous_values,
)
@API.register
def run_generators(
machine: Machine,
machine_name: str,
all_prompt_values: dict[str, dict[str, str]],
base_dir: Path,
generators: list[str] | None = None,
no_sandbox: bool = False,
) -> None:
) -> bool:
"""Run the specified generators for a machine.
Args:
machine_name (str): The name of the machine.
@@ -540,19 +553,21 @@ def run_generators(
ClanError: If the machine or generator is not found, or if there are issues with
executing the generator.
"""
from clan_lib.machines.machines import Machine
machine = Machine(name=machine_name, flake=Flake(str(base_dir)))
if not generators:
generator_objects = Generator.get_machine_generators(
machine.name, machine.flake
machine_name, machine.flake
)
else:
generators_set = set(generators)
generator_objects = [
g
for g in Generator.get_machine_generators(machine.name, machine.flake)
for g in Generator.get_machine_generators(machine_name, machine.flake)
if g.name in generators_set
]
_generate_vars_for_machine(
return _generate_vars_for_machine(
machine=machine,
generators=generator_objects,
all_prompt_values=all_prompt_values,
@@ -565,14 +580,14 @@ def create_machine_vars_interactive(
generator_name: str | None,
regenerate: bool,
no_sandbox: bool = False,
) -> None:
generators = get_generators(machine, regenerate, generator_name)
) -> bool:
generators = _get_closure(machine, generator_name, regenerate)
if len(generators) == 0:
return
return False
all_prompt_values = {}
for generator in generators:
all_prompt_values[generator.name] = _ask_prompts(generator)
_generate_vars_for_machine(
return _generate_vars_for_machine(
machine,
generators,
all_prompt_values,
@@ -586,26 +601,30 @@ def generate_vars(
regenerate: bool = False,
no_sandbox: bool = False,
) -> None:
was_regenerated = False
for machine in machines:
errors = []
try:
create_machine_vars_interactive(
was_regenerated |= create_machine_vars_interactive(
machine,
generator_name,
regenerate,
no_sandbox=no_sandbox,
)
machine.info("All vars are up to date")
except Exception as exc:
errors += [(machine, exc)]
if len(errors) == 1:
raise errors[0][1]
if len(errors) > 1:
msg = f"Failed to generate vars for {len(errors)} hosts:"
msg = f"Failed to generate facts for {len(errors)} hosts:"
for machine, error in errors:
msg += f"\n{machine}: {error}"
raise ClanError(msg) from errors[0][1]
if not was_regenerated and len(machines) > 0:
for machine in machines:
machine.info("All vars are already up to date")
def generate_command(args: argparse.Namespace) -> None:
flake = require_flake(args.flake)

View File

@@ -1,5 +1,6 @@
import argparse
import logging
from pathlib import Path
from clan_cli.completions import add_dynamic_completer, complete_machines
from clan_lib.flake import Flake, require_flake
@@ -19,7 +20,7 @@ def get_machine_vars(base_dir: str, machine_name: str) -> list[Var]:
all_vars = []
generators = get_generators(machine=machine, full_closure=True)
generators = get_generators(base_dir=Path(base_dir), machine_name=machine_name)
for generator in generators:
for var in generator.files:
if var.secret:

View File

@@ -6,11 +6,11 @@ from collections.abc import Iterable
from pathlib import Path
from tempfile import TemporaryDirectory
from clan_cli.ssh.upload import upload
from clan_cli.vars._types import StoreBase
from clan_cli.vars.generate import Generator, Var
from clan_lib.flake import Flake
from clan_lib.ssh.host import Host
from clan_lib.ssh.upload import upload
log = logging.getLogger(__name__)

View File

@@ -22,13 +22,13 @@ from clan_cli.secrets.secrets import (
has_secret,
)
from clan_cli.secrets.sops import load_age_plugins
from clan_cli.ssh.upload import upload
from clan_cli.vars._types import StoreBase
from clan_cli.vars.generate import Generator
from clan_cli.vars.var import Var
from clan_lib.errors import ClanError
from clan_lib.flake import Flake
from clan_lib.ssh.host import Host
from clan_lib.ssh.upload import upload
@dataclass

View File

@@ -4,10 +4,12 @@ import sys
import urllib.parse
from enum import Enum
from pathlib import Path
from typing import Protocol
from typing import TYPE_CHECKING, Protocol
from clan_lib.errors import ClanError
from clan_lib.flake import Flake
if TYPE_CHECKING:
from clan_lib.flake import Flake
log = logging.getLogger(__name__)

View File

@@ -43,7 +43,7 @@ enable_inhibition() {
disable_inhibition() {
local devices=("$@")
local rules_dir="/run/udev/rules.d"
for device in "${devices[@]}"; do
local devpath="$device"
local rule_file="$rules_dir/90-udisks-inhibit-${devpath//\//_}.rules"

View File

@@ -187,13 +187,12 @@ def run_machine_install(opts: InstallOptions, target_host: Remote) -> None:
cmd.append(target_host.target)
if target_host.socks_port:
# nix copy does not support socks5 proxy, use wrapper command
wrapper = target_host.socks_wrapper
wrapper_cmd = wrapper.cmd if wrapper else []
wrapper_packages = wrapper.packages if wrapper else []
wrapper_cmd = target_host.socks_wrapper or ["torify"]
cmd = nix_shell(
[
"nixos-anywhere",
*wrapper_packages,
"tor",
"torsocks",
],
[*wrapper_cmd, *cmd],
)

View File

@@ -3,19 +3,23 @@ import logging
from dataclasses import dataclass
from functools import cached_property
from pathlib import Path
from typing import Any, Literal
from typing import TYPE_CHECKING, Any, Literal
from clan_cli.facts import public_modules as facts_public_modules
from clan_cli.facts import secret_modules as facts_secret_modules
from clan_cli.vars._types import StoreBase
from clan_lib.api import API
from clan_lib.errors import ClanError
from clan_lib.flake import ClanSelectError, Flake
from clan_lib.nix_models.clan import InventoryMachine
from clan_lib.ssh.remote import Remote
log = logging.getLogger(__name__)
if TYPE_CHECKING:
pass
@dataclass(frozen=True)
class Machine:
@@ -121,10 +125,15 @@ class Machine:
return self.flake.path
def target_host(self) -> Remote:
from clan_lib.network.network import get_best_remote
with get_best_remote(self) as remote:
return remote
remote = get_machine_host(self.name, self.flake, field="targetHost")
if remote is None:
msg = f"'targetHost' is not set for machine '{self.name}'"
raise ClanError(
msg,
description="See https://docs.clan.lol/guides/getting-started/update/#setting-the-target-host for more information.",
)
data = remote.data
return data
def build_host(self) -> Remote | None:
"""

View File

@@ -19,10 +19,12 @@ class NetworkTechnology(NetworkTechnologyBase):
"""Direct connections are always 'running' as they don't require a daemon"""
return True
def ping(self, remote: Remote) -> None | float:
def ping(self, peer: Peer) -> None | float:
if self.is_running():
try:
# Parse the peer's host address to create a Remote object, use peer here since we don't have the machine_name here
remote = Remote.from_ssh_uri(machine_name="peer", address=peer.host)
# Use the existing SSH reachability check
now = time.time()
@@ -31,7 +33,7 @@ class NetworkTechnology(NetworkTechnologyBase):
return (time.time() - now) * 1000
except ClanError as e:
log.debug(f"Error checking peer {remote}: {e}")
log.debug(f"Error checking peer {peer.host}: {e}")
return None
return None

View File

@@ -5,15 +5,16 @@ from collections.abc import Iterator
from contextlib import contextmanager
from dataclasses import dataclass
from functools import cached_property
from typing import Any
from typing import TYPE_CHECKING, Any
from clan_cli.vars.get import get_machine_var
from clan_lib.errors import ClanError
from clan_lib.flake import Flake
from clan_lib.import_utils import ClassSource, import_with_source
from clan_lib.machines.machines import Machine
from clan_lib.ssh.remote import Remote
if TYPE_CHECKING:
from clan_lib.ssh.remote import Remote
log = logging.getLogger(__name__)
@@ -50,7 +51,7 @@ class Peer:
.lstrip("\n")
)
raise ClanError(msg)
return var.value.decode().strip()
return var.value.decode()
msg = f"Unknown Var Type {self._host}"
raise ClanError(msg)
@@ -74,7 +75,7 @@ class Network:
return self.module.is_running()
def ping(self, peer: str) -> float | None:
return self.module.ping(self.remote(peer))
return self.module.ping(self.peers[peer])
def remote(self, peer: str) -> "Remote":
# TODO raise exception if peer is not in peers
@@ -94,7 +95,7 @@ class NetworkTechnologyBase(ABC):
pass
@abstractmethod
def ping(self, remote: "Remote") -> None | float:
def ping(self, peer: Peer) -> None | float:
pass
@contextmanager
@@ -107,18 +108,12 @@ def networks_from_flake(flake: Flake) -> dict[str, Network]:
# TODO more precaching, for example for vars
flake.precache(
[
"clan.?exports.instances.*.networking",
"clan.exports.instances.*.networking",
]
)
networks: dict[str, Network] = {}
networks_ = flake.select("clan.?exports.instances.*.networking")
if "exports" not in networks_:
msg = """You are not exporting the clan exports through your flake.
Please add exports next to clanInternals and nixosConfiguration into the global flake.
"""
log.warning(msg)
return {}
for network_name, network in networks_["exports"].items():
networks_ = flake.select("clan.exports.instances.*.networking")
for network_name, network in networks_.items():
if network:
peers: dict[str, Peer] = {}
for _peer in network["peers"].values():
@@ -133,103 +128,15 @@ def networks_from_flake(flake: Flake) -> dict[str, Network]:
return networks
@contextmanager
def get_best_remote(machine: "Machine") -> Iterator["Remote"]:
"""
Context manager that yields the best remote connection for a machine following this priority:
1. If machine has targetHost in inventory, return a direct connection
2. Return the highest priority network where machine is reachable
3. If no network works, try to get targetHost from machine nixos config
Args:
machine: Machine instance to connect to
Yields:
Remote object for connecting to the machine
Raises:
ClanError: If no connection method works
"""
# Step 1: Check if targetHost is set in inventory
inv_machine = machine.get_inv_machine()
target_host = inv_machine.get("deploy", {}).get("targetHost")
if target_host:
log.debug(f"Using targetHost from inventory for {machine.name}: {target_host}")
# Create a direct network with just this machine
try:
remote = Remote.from_ssh_uri(machine_name=machine.name, address=target_host)
yield remote
return
except Exception as e:
log.debug(f"Inventory targetHost not reachable for {machine.name}: {e}")
# Step 2: Try existing networks by priority
try:
networks = networks_from_flake(machine.flake)
sorted_networks = sorted(networks.items(), key=lambda x: -x[1].priority)
for network_name, network in sorted_networks:
if machine.name not in network.peers:
continue
# Check if network is running and machine is reachable
log.debug(f"trying to connect via {network_name}")
if network.is_running():
try:
ping_time = network.ping(machine.name)
if ping_time is not None:
log.info(
f"Machine {machine.name} reachable via {network_name} network"
)
yield network.remote(machine.name)
return
except Exception as e:
log.debug(f"Failed to reach {machine.name} via {network_name}: {e}")
else:
try:
log.debug(f"Establishing connection for network {network_name}")
with network.module.connection(network) as connected_network:
ping_time = connected_network.ping(machine.name)
if ping_time is not None:
log.info(
f"Machine {machine.name} reachable via {network_name} network after connection"
)
yield connected_network.remote(machine.name)
return
except Exception as e:
log.debug(
f"Failed to establish connection to {machine.name} via {network_name}: {e}"
)
except Exception as e:
log.debug(f"Failed to use networking modules to determine machines remote: {e}")
# Step 3: Try targetHost from machine nixos config
try:
target_host = machine.select('config.clan.core.networking."targetHost"')
if target_host:
log.debug(
f"Using targetHost from machine config for {machine.name}: {target_host}"
)
# Check if reachable
try:
remote = Remote.from_ssh_uri(
machine_name=machine.name, address=target_host
)
yield remote
return
except Exception as e:
log.debug(
f"Machine config targetHost not reachable for {machine.name}: {e}"
)
except Exception as e:
log.debug(f"Could not get targetHost from machine config: {e}")
# No connection method found
msg = f"Could not find any way to connect to machine '{machine.name}'. No targetHost configured and machine not reachable via any network."
raise ClanError(msg)
def get_best_network(machine_name: str, networks: dict[str, Network]) -> Network | None:
for network_name, network in sorted(
networks.items(), key=lambda network: -network[1].priority
):
if machine_name in network.peers:
if network.is_running() and network.ping(machine_name):
print(f"connecting via {network_name}")
return network
return None
def get_network_overview(networks: dict[str, Network]) -> dict:

View File

@@ -26,48 +26,46 @@ def test_networks_from_flake(mock_get_machine_var: MagicMock) -> None:
# Define the expected return value from flake.select
mock_networking_data = {
"exports": {
"vpn-network": {
"peers": {
"machine1": {
"name": "machine1",
"host": {
"var": {
"machine": "machine1",
"generator": "wireguard",
"file": "address",
}
},
},
"machine2": {
"name": "machine2",
"host": {
"var": {
"machine": "machine2",
"generator": "wireguard",
"file": "address",
}
},
"vpn-network": {
"peers": {
"machine1": {
"name": "machine1",
"host": {
"var": {
"machine": "machine1",
"generator": "wireguard",
"file": "address",
}
},
},
"module": "clan_lib.network.tor",
"priority": 1000,
},
"local-network": {
"peers": {
"machine1": {
"name": "machine1",
"host": {"plain": "10.0.0.10"},
},
"machine3": {
"name": "machine3",
"host": {"plain": "10.0.0.12"},
"machine2": {
"name": "machine2",
"host": {
"var": {
"machine": "machine2",
"generator": "wireguard",
"file": "address",
}
},
},
"module": "clan_lib.network.direct",
"priority": 500,
},
}
"module": "clan_lib.network.tor",
"priority": 1000,
},
"local-network": {
"peers": {
"machine1": {
"name": "machine1",
"host": {"plain": "10.0.0.10"},
},
"machine3": {
"name": "machine3",
"host": {"plain": "10.0.0.12"},
},
},
"module": "clan_lib.network.direct",
"priority": 500,
},
}
# Mock the select method
@@ -77,7 +75,7 @@ def test_networks_from_flake(mock_get_machine_var: MagicMock) -> None:
networks = networks_from_flake(flake)
# Verify the flake.select was called with the correct pattern
flake.select.assert_called_once_with("clan.?exports.instances.*.networking")
flake.select.assert_called_once_with("clan.exports.instances.*.networking")
# Verify the returned networks
assert len(networks) == 2

View File

@@ -1,166 +0,0 @@
import json
import logging
from collections.abc import Iterator
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from clan_lib.cmd import run
from clan_lib.errors import ClanError
from clan_lib.flake import Flake
from clan_lib.network.network import Network, Peer
from clan_lib.nix import nix_shell
from clan_lib.ssh.remote import Remote
from clan_lib.ssh.socks_wrapper import tor_wrapper
log = logging.getLogger(__name__)
@dataclass(frozen=True)
class RemoteWithNetwork:
network: Network
remote: Remote
@dataclass(frozen=True)
class QRCodeData:
addresses: list[RemoteWithNetwork]
@contextmanager
def get_best_remote(self) -> Iterator[Remote]:
for address in self.addresses:
try:
log.debug(f"Establishing connection via {address}")
with address.network.module.connection(
address.network
) as connected_network:
ping_time = connected_network.module.ping(address.remote)
if ping_time is not None:
log.info(f"reachable via {address} after connection")
yield address.remote
except Exception as e:
log.debug(f"Failed to establish connection via {address}: {e}")
def read_qr_json(qr_data: dict[str, Any], flake: Flake) -> QRCodeData:
"""
Parse QR code JSON contents and output a dict of networks with remotes.
Args:
qr_data: JSON data from QR code containing network information
flake: Flake instance for creating peers
Returns:
Dictionary mapping network type to dict with "network" and "remote" keys
Example input:
{
"pass": "password123",
"tor": "ssh://user@hostname.onion",
"addrs": ["ssh://user@192.168.1.100", "ssh://user@example.com"]
}
Example output:
{
"direct": {
"network": Network(...),
"remote": Remote(...)
},
"tor": {
"network": Network(...),
"remote": Remote(...)
}
}
"""
addresses: list[RemoteWithNetwork] = []
password = qr_data.get("pass")
# Process clearnet addresses
clearnet_addrs = qr_data.get("addrs", [])
if clearnet_addrs:
for addr in clearnet_addrs:
if isinstance(addr, str):
peer = Peer(name="installer", _host={"plain": addr}, flake=flake)
network = Network(
peers={"installer": peer},
module_name="clan_lib.network.direct",
priority=1000,
)
# Create the remote with password
remote = Remote.from_ssh_uri(
machine_name="installer",
address=addr,
).override(password=password)
addresses.append(RemoteWithNetwork(network=network, remote=remote))
else:
msg = f"Invalid address format: {addr}"
raise ClanError(msg)
# Process tor address
if tor_addr := qr_data.get("tor"):
peer = Peer(name="installer-tor", _host={"plain": tor_addr}, flake=flake)
network = Network(
peers={"installer-tor": peer},
module_name="clan_lib.network.tor",
priority=500,
)
# Create the remote with password and tor settings
remote = Remote.from_ssh_uri(
machine_name="installer-tor",
address=tor_addr,
).override(
password=password,
socks_port=9050,
socks_wrapper=tor_wrapper,
)
addresses.append(RemoteWithNetwork(network=network, remote=remote))
return QRCodeData(addresses=addresses)
def read_qr_image(image_path: Path) -> dict[str, Any]:
"""
Parse a QR code image and extract the JSON data.
Args:
image_path: Path to the QR code image file
Returns:
Parsed JSON data from the QR code
Raises:
ClanError: If the QR code cannot be read or contains invalid JSON
"""
if not image_path.exists():
msg = f"QR code image file not found: {image_path}"
raise ClanError(msg)
cmd = nix_shell(
["zbar"],
[
"zbarimg",
"--quiet",
"--raw",
str(image_path),
],
)
try:
res = run(cmd)
data = res.stdout.strip()
if not data:
msg = f"No QR code found in image: {image_path}"
raise ClanError(msg)
return json.loads(data)
except json.JSONDecodeError as e:
msg = f"Invalid JSON in QR code: {e}"
raise ClanError(msg) from e
except Exception as e:
msg = f"Failed to read QR code from {image_path}: {e}"
raise ClanError(msg) from e

View File

@@ -8,8 +8,6 @@ from typing import TYPE_CHECKING
from clan_lib.errors import ClanError
from clan_lib.network import Network, NetworkTechnologyBase, Peer
from clan_lib.network.tor.lib import is_tor_running, spawn_tor
from clan_lib.ssh.remote import Remote
from clan_lib.ssh.socks_wrapper import tor_wrapper
if TYPE_CHECKING:
from clan_lib.ssh.remote import Remote
@@ -29,9 +27,11 @@ class NetworkTechnology(NetworkTechnologyBase):
"""Check if Tor is running by sending HTTP request to SOCKS port."""
return is_tor_running(self.proxy)
def ping(self, remote: Remote) -> None | float:
def ping(self, peer: Peer) -> None | float:
if self.is_running():
try:
remote = self.remote(peer)
# Use the existing SSH reachability check
now = time.time()
remote.check_machine_ssh_reachable()
@@ -39,7 +39,7 @@ class NetworkTechnology(NetworkTechnologyBase):
return (time.time() - now) * 1000
except ClanError as e:
log.debug(f"Error checking peer {remote}: {e}")
log.debug(f"Error checking peer {peer.host}: {e}")
return None
return None
@@ -58,5 +58,5 @@ class NetworkTechnology(NetworkTechnologyBase):
address=peer.host,
command_prefix=peer.name,
socks_port=self.proxy,
socks_wrapper=tor_wrapper,
socks_wrapper=["torify"],
)

View File

@@ -0,0 +1,74 @@
import re
import urllib.parse
from typing import TYPE_CHECKING
from clan_lib.errors import ClanError
if TYPE_CHECKING:
from clan_lib.ssh.remote import Remote
def parse_ssh_uri(
*,
machine_name: str,
address: str,
) -> "Remote":
"""
Parses an SSH URI into a Remote object.
The address can be in the form of:
- `ssh://[user@]hostname[:port]?option=value&option2=value2`
- `[user@]hostname[:port]`
The specification can be found here: https://www.ietf.org/archive/id/draft-salowey-secsh-uri-00.html
"""
if address.startswith("ssh://"):
# Strip the `ssh://` prefix if it exists
address = address[len("ssh://") :]
parts = address.split("?", maxsplit=1)
endpoint, maybe_options = parts if len(parts) == 2 else (parts[0], "")
parts = endpoint.split("@")
match len(parts):
case 2:
user, host_port = parts
case 1:
user, host_port = "root", parts[0]
case _:
msg = f"Invalid host, got `{address}` but expected something like `[user@]hostname[:port]`"
raise ClanError(msg)
# Make this check now rather than failing with a `ValueError`
# when looking up the port from the `urlsplit` result below:
if host_port.count(":") > 1 and not re.match(r".*\[.*]", host_port):
msg = f"Invalid hostname: {address}. IPv6 addresses must be enclosed in brackets , e.g. [::1]"
raise ClanError(msg)
options: dict[str, str] = {}
for o in maybe_options.split("&"):
if len(o) == 0:
continue
parts = o.split("=", maxsplit=1)
if len(parts) != 2:
msg = (
f"Invalid option in host `{address}`: option `{o}` does not have "
f"a value (i.e. expected something like `name=value`)"
)
raise ClanError(msg)
name, value = parts
options[name] = value
result = urllib.parse.urlsplit(f"//{host_port}")
if not result.hostname:
msg = f"Invalid host, got `{address}` but expected something like `[user@]hostname[:port]`"
raise ClanError(msg)
hostname = result.hostname
port = result.port
from clan_lib.ssh.remote import Remote
return Remote(
address=hostname,
user=user,
port=port,
command_prefix=machine_name,
ssh_options=options,
)

View File

@@ -1,11 +1,9 @@
import ipaddress
import logging
import os
import re
import shlex
import subprocess
import sys
import urllib.parse
from collections.abc import Iterator
from contextlib import contextmanager
from dataclasses import dataclass, field
@@ -19,7 +17,7 @@ from clan_lib.colors import AnsiColor
from clan_lib.errors import ClanError, indent_command # Assuming these are available
from clan_lib.nix import nix_shell
from clan_lib.ssh.host_key import HostKeyCheck, hostkey_to_ssh_opts
from clan_lib.ssh.socks_wrapper import SocksWrapper
from clan_lib.ssh.parse import parse_ssh_uri
from clan_lib.ssh.sudo_askpass_proxy import SudoAskpassProxy
if TYPE_CHECKING:
@@ -44,7 +42,7 @@ class Remote:
verbose_ssh: bool = False
ssh_options: dict[str, str] = field(default_factory=dict)
socks_port: int | None = None
socks_wrapper: SocksWrapper | None = None
socks_wrapper: list[str] | None = None
_control_path_dir: Path | None = None
_askpass_path: str | None = None
@@ -65,7 +63,7 @@ class Remote:
private_key: Path | None = None,
password: str | None = None,
socks_port: int | None = None,
socks_wrapper: SocksWrapper | None = None,
socks_wrapper: list[str] | None = None,
command_prefix: str | None = None,
port: int | None = None,
ssh_options: dict[str, str] | None = None,
@@ -109,7 +107,7 @@ class Remote:
Parse a deployment address and return a Remote object.
"""
return _parse_ssh_uri(machine_name=machine_name, address=address)
return parse_ssh_uri(machine_name=machine_name, address=address)
def run_local(
self,
@@ -469,69 +467,3 @@ class Remote:
from clan_lib.network.check import check_machine_ssh_login
return check_machine_ssh_login(self)
def _parse_ssh_uri(
*,
machine_name: str,
address: str,
) -> "Remote":
"""
Parses an SSH URI into a Remote object.
The address can be in the form of:
- `ssh://[user@]hostname[:port]?option=value&option2=value2`
- `[user@]hostname[:port]`
The specification can be found here: https://www.ietf.org/archive/id/draft-salowey-secsh-uri-00.html
"""
if address.startswith("ssh://"):
# Strip the `ssh://` prefix if it exists
address = address[len("ssh://") :]
parts = address.split("?", maxsplit=1)
endpoint, maybe_options = parts if len(parts) == 2 else (parts[0], "")
parts = endpoint.split("@")
match len(parts):
case 2:
user, host_port = parts
case 1:
user, host_port = "root", parts[0]
case _:
msg = f"Invalid host, got `{address}` but expected something like `[user@]hostname[:port]`"
raise ClanError(msg)
# Make this check now rather than failing with a `ValueError`
# when looking up the port from the `urlsplit` result below:
if host_port.count(":") > 1 and not re.match(r".*\[.*]", host_port):
msg = f"Invalid hostname: {address}. IPv6 addresses must be enclosed in brackets , e.g. [::1]"
raise ClanError(msg)
options: dict[str, str] = {}
for o in maybe_options.split("&"):
if len(o) == 0:
continue
parts = o.split("=", maxsplit=1)
if len(parts) != 2:
msg = (
f"Invalid option in host `{address}`: option `{o}` does not have "
f"a value (i.e. expected something like `name=value`)"
)
raise ClanError(msg)
name, value = parts
options[name] = value
result = urllib.parse.urlsplit(f"//{host_port}")
if not result.hostname:
msg = f"Invalid host, got `{address}` but expected something like `[user@]hostname[:port]`"
raise ClanError(msg)
hostname = result.hostname
port = result.port
from clan_lib.ssh.remote import Remote
return Remote(
address=hostname,
user=user,
port=port,
command_prefix=machine_name,
ssh_options=options,
)

View File

@@ -1,16 +0,0 @@
from dataclasses import dataclass
@dataclass(frozen=True)
class SocksWrapper:
"""Configuration for SOCKS proxy wrapper commands."""
# The command to execute for wrapping network connections through SOCKS (e.g., ["torify"])
cmd: list[str]
# Nix packages required to provide the wrapper command (e.g., ["tor", "torsocks"])
packages: list[str]
# Pre-configured Tor wrapper instance
tor_wrapper = SocksWrapper(cmd=["torify"], packages=["tor", "torsocks"])

View File

@@ -8,28 +8,21 @@ import sys
import termios
import threading
from pathlib import Path
from typing import Protocol
from typing import TYPE_CHECKING
from clan_lib.cmd import terminate_process
from clan_lib.errors import ClanError
if TYPE_CHECKING:
from clan_lib.ssh.remote import Remote
logger = logging.getLogger(__name__)
remote_script = (Path(__file__).parent / "sudo_askpass_proxy.sh").read_text()
class RemoteP(Protocol):
@property
def address(self) -> str: ...
@property
def target(self) -> str: ...
def ssh_cmd(self) -> list[str]: ...
class SudoAskpassProxy:
def __init__(self, host: "RemoteP", prompt_command: list[str]) -> None:
def __init__(self, host: "Remote", prompt_command: list[str]) -> None:
self.host = host
self.password_prompt_command = prompt_command
self.ssh_process: subprocess.Popen | None = None

View File

@@ -222,7 +222,7 @@ def test_clan_create_api(
# Invalidate cache because of new inventory
clan_dir_flake.invalidate_cache()
generators = get_generators(machine=machine, full_closure=True)
generators = get_generators(machine.name, machine.flake.path)
all_prompt_values = {}
for generator in generators:
prompt_values = {}
@@ -236,7 +236,8 @@ def test_clan_create_api(
all_prompt_values[generator.name] = prompt_values
run_generators(
machine=machine,
machine_name=machine.name,
base_dir=machine.flake.path,
generators=[gen.name for gen in generators],
all_prompt_values=all_prompt_values,
)

View File

@@ -0,0 +1,7 @@
# shellcheck shell=bash
source_up
watch_file flake-module.nix shell.nix default.nix
# Because we depend on nixpkgs sources, uploading to builders takes a long time
use flake .#clan-vm-manager --builders ''

1
pkgs/clan-vm-manager/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
**/.vscode

View File

@@ -0,0 +1,8 @@
{
"clientRemote": "",
"gitRemote": "",
"gitSha": "",
"treeEntries": [],
"auditedFiles": [],
"resolvedEntries": []
}

View File

@@ -0,0 +1,94 @@
# Clan VM Manager
Provides users with the simple functionality to manage their locally registered clans.
![app-preview](screenshots/image.png)
## Available commands
Run this application
```bash
./bin/clan-vm-manager
```
Join the default machine of a clan
```bash
./bin/clan-vm-manager [clan-uri]
```
Join a specific machine of a clan
```bash
./bin/clan-vm-manager [clan-uri]#[machine]
```
For more available commands see the developer section below.
## Developing this Application
### Debugging Style and Layout
```bash
# Enable the GTK debugger
gsettings set org.gtk.Settings.Debug enable-inspector-keybinding true
# Start the application with the debugger attached
GTK_DEBUG=interactive ./bin/clan-vm-manager --debug
```
Appending `--debug` flag enables debug logging printed into the console.
### Profiling
To activate profiling you can run
```bash
CLAN_CLI_PERF=1 ./bin/clan-vm-manager
```
### Library Components
> Note:
>
> we recognized bugs when starting some cli-commands through the integrated vs-code terminal.
> If encountering issues make sure to run commands in a regular os-shell.
lib-Adw has a demo application showing all widgets. You can run it by executing
```bash
adwaita-1-demo
```
GTK4 has a demo application showing all widgets. You can run it by executing
```bash
gtk4-widget-factory
```
To find available icons execute
```bash
gtk4-icon-browser
```
### Links
Here are some important documentation links related to the Clan VM Manager:
- [Adw PyGobject Reference](http://lazka.github.io/pgi-docs/index.html#Adw-1): This link provides the PyGObject reference documentation for the Adw library, which is used in the Clan VM Manager. It contains detailed information about the Adw widgets and their usage.
- [GTK4 PyGobject Reference](http://lazka.github.io/pgi-docs/index.html#Gtk-4.0): This link provides the PyGObject reference documentation for GTK4, the toolkit used for building the user interface of the Clan VM Manager. It includes information about GTK4 widgets, signals, and other features.
- [Adw Widget Gallery](https://gnome.pages.gitlab.gnome.org/libadwaita/doc/main/widget-gallery.html): This link showcases a widget gallery for Adw, allowing you to see the available widgets and their visual appearance. It can be helpful for designing the user interface of the Clan VM Manager.
- [Python + GTK3 Tutorial](https://python-gtk-3-tutorial.readthedocs.io/en/latest/textview.html): Although the Clan VM Manager uses GTK4, this tutorial for GTK3 can still be useful as it covers the basics of building GTK-based applications with Python. It includes examples and explanations for various GTK widgets, including text views.
- [GNOME Human Interface Guidelines](https://developer.gnome.org/hig/): This link provides the GNOME Human Interface Guidelines, which offer design and usability recommendations for creating GNOME applications. It covers topics such as layout, navigation, and interaction patterns.
## Error handling
> Error dialogs should be avoided where possible, since they are disruptive.
>
> For simple non-critical errors, toasts can be a good alternative.

View File

@@ -0,0 +1,13 @@
#!/usr/bin/env python3
import sys
from pathlib import Path
module_path = Path(__file__).parent.parent.absolute()
sys.path.insert(0, str(module_path))
sys.path.insert(0, str(module_path.parent / "clan_cli"))
from clan_vm_manager import main # NOQA
if __name__ == "__main__":
main()

Some files were not shown because too many files have changed in this diff Show More