Merge remote-tracking branch 'origin/main' into rework-installation

This commit is contained in:
Jörg Thalheim
2024-08-21 13:33:27 +02:00
196 changed files with 10069 additions and 2432 deletions

1
.gitignore vendored
View File

@@ -1,4 +1,5 @@
.direnv
**/.nixos-test-history
***/.hypothesis
out.log
.coverage.*

View File

@@ -0,0 +1,22 @@
{ ... }:
{
perSystem =
{ self', pkgs, ... }:
{
checks.devshell =
pkgs.runCommand "check-devshell-not-depends-on-clan-cli"
{
exportReferencesGraph = [
"graph"
self'.devShells.default
];
}
''
if grep -q "${self'.packages.clan-cli}" ./graph; then
echo "devshell depends on clan-cli, which is not allowed";
exit 1;
fi
mkdir $out
'';
};
}

View File

@@ -1,10 +1,11 @@
{ self, ... }:
{
imports = [
./impure/flake-module.nix
./backups/flake-module.nix
./installation/flake-module.nix
./devshell/flake-module.nix
./flash/flake-module.nix
./impure/flake-module.nix
./installation/flake-module.nix
];
perSystem =
{
@@ -40,10 +41,11 @@
secrets = import ./secrets nixosTestArgs;
container = import ./container nixosTestArgs;
deltachat = import ./deltachat nixosTestArgs;
matrix-synapse = import ./matrix-synapse nixosTestArgs;
zt-tcp-relay = import ./zt-tcp-relay nixosTestArgs;
borgbackup = import ./borgbackup nixosTestArgs;
matrix-synapse = import ./matrix-synapse nixosTestArgs;
mumble = import ./mumble nixosTestArgs;
syncthing = import ./syncthing nixosTestArgs;
zt-tcp-relay = import ./zt-tcp-relay nixosTestArgs;
postgresql = import ./postgresql nixosTestArgs;
wayland-proxy-virtwl = import ./wayland-proxy-virtwl nixosTestArgs;
};

146
checks/mumble/default.nix Normal file
View File

@@ -0,0 +1,146 @@
(import ../lib/test-base.nix) (
{ ... }:
let
common =
{ self, pkgs, ... }:
{
imports = [
self.clanModules.mumble
self.nixosModules.clanCore
(self.inputs.nixpkgs + "/nixos/tests/common/x11.nix")
{
clan.core.clanDir = ./.;
environment.systemPackages = [ pkgs.killall ];
services.murmur.sslKey = "/etc/mumble-key";
services.murmur.sslCert = "/etc/mumble-cert";
clan.core.facts.services.mumble.secret."mumble-key".path = "/etc/mumble-key";
clan.core.facts.services.mumble.public."mumble-cert".path = "/etc/mumble-cert";
}
];
};
in
{
name = "mumble";
enableOCR = true;
nodes.peer1 =
{ ... }:
{
imports = [
common
{
clan.core.machineName = "peer1";
environment.etc = {
"mumble-key".source = ./peer_1/peer_1_test_key;
"mumble-cert".source = ./peer_1/peer_1_test_cert;
};
systemd.tmpfiles.settings."vmsecrets" = {
"/etc/secrets/mumble-key" = {
C.argument = "${./peer_1/peer_1_test_key}";
z = {
mode = "0400";
user = "murmur";
};
};
"/etc/secrets/mumble-cert" = {
C.argument = "${./peer_1/peer_1_test_cert}";
z = {
mode = "0400";
user = "murmur";
};
};
};
services.murmur.sslKey = "/etc/mumble-key";
services.murmur.sslCert = "/etc/mumble-cert";
clan.core.facts.services.mumble.secret."mumble-key".path = "/etc/mumble-key";
clan.core.facts.services.mumble.public."mumble-cert".path = "/etc/mumble-cert";
}
];
};
nodes.peer2 =
{ ... }:
{
imports = [
common
{
clan.core.machineName = "peer2";
environment.etc = {
"mumble-key".source = ./peer_2/peer_2_test_key;
"mumble-cert".source = ./peer_2/peer_2_test_cert;
};
systemd.tmpfiles.settings."vmsecrets" = {
"/etc/secrets/mumble-key" = {
C.argument = "${./peer_2/peer_2_test_key}";
z = {
mode = "0400";
user = "murmur";
};
};
"/etc/secrets/mumble-cert" = {
C.argument = "${./peer_2/peer_2_test_cert}";
z = {
mode = "0400";
user = "murmur";
};
};
};
}
];
};
testScript = ''
start_all()
with subtest("Waiting for x"):
peer1.wait_for_x()
peer2.wait_for_x()
with subtest("Waiting for murmur"):
peer1.wait_for_unit("murmur.service")
peer2.wait_for_unit("murmur.service")
with subtest("Starting Mumble"):
# starting mumble is blocking
peer1.execute("mumble >&2 &")
peer2.execute("mumble >&2 &")
with subtest("Wait for Mumble"):
peer1.wait_for_window(r"^Mumble$")
peer2.wait_for_window(r"^Mumble$")
with subtest("Wait for certificate creation"):
peer1.wait_for_window(r"^Mumble$")
peer1.sleep(3) # mumble is slow to register handlers
peer1.send_chars("\n")
peer1.send_chars("\n")
peer2.wait_for_window(r"^Mumble$")
peer2.sleep(3) # mumble is slow to register handlers
peer2.send_chars("\n")
peer2.send_chars("\n")
with subtest("Wait for server connect"):
peer1.wait_for_window(r"^Mumble Server Connect$")
peer2.wait_for_window(r"^Mumble Server Connect$")
with subtest("Check validity of server certificates"):
peer1.execute("killall .mumble-wrapped")
peer1.sleep(1)
peer1.execute("mumble mumble://peer2 >&2 &")
peer1.wait_for_window(r"^Mumble$")
peer1.sleep(3) # mumble is slow to register handlers
peer1.send_chars("\n")
peer1.send_chars("\n")
peer1.wait_for_text("Connected.")
peer2.execute("killall .mumble-wrapped")
peer2.sleep(1)
peer2.execute("mumble mumble://peer1 >&2 &")
peer2.wait_for_window(r"^Mumble$")
peer2.sleep(3) # mumble is slow to register handlers
peer2.send_chars("\n")
peer2.send_chars("\n")
peer2.wait_for_text("Connected.")
'';
}
)

View File

@@ -0,0 +1,22 @@
-----BEGIN CERTIFICATE-----
MIIDazCCAlOgAwIBAgIUCUjfNkF0CDhTKbO3nNczcsCW4qEwDQYJKoZIhvcNAQEL
BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM
GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yNDA2MjcwOTM2NDZaFw0yNDA3
MjcwOTM2NDZaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw
HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEB
AQUAA4IBDwAwggEKAoIBAQDCcdZEJvXJIeOKO5pF5XUFvUeJtCCiwfWvWS662bxc
R/5MZucRLqfTNYo9aBv4NITw5kxZsTaaubmS4zSGQoTEAVzqzVdi3a/gNvsdVLb+
7CivpmweLllX/OGsTL0kHPEI+74AYiTBjXfdWV1Y5T1tuwc3G8ATrguQ33Uo5vvF
vcqsbTKcRZC0pB9O/nn4q03GsRdvlpaKakIhjMpRG/uZ3u7wtbyZ+WqjsjxZNfnY
aMyPoaipFqX1v+L7GKlOj2NpyEZFVVwa2ZqhVSYXyDfpAWQFznwKGzD5mjtcyKym
gnv/5LwrpH4Xj+JMt48hN+rPnu5vfXT8Y4KnID30OQW7AgMBAAGjUzBRMB0GA1Ud
DgQWBBQBBO8Wp975pAGioMjkaxANAVInfzAfBgNVHSMEGDAWgBQBBO8Wp975pAGi
oMjkaxANAVInfzAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAg
F40MszTZXpR/A1z9B1CcXH47tNK67f8bCMR2dhvXODbpatwSihyxhQjtLb5R6kYH
5Yq/B4yrh303j0CXaobCQ4nQH7zI7fhViww+TzW7vDhgM7ueEyyXrqCXt6JY8avg
TuvIRtJSeWSQJ5aLNaYqmiwMf/tj9W3BMDpctGyLqu1WTSrbpYa9mA5Vudud70Yz
DgZ/aqHilB07cVNqzVYZzRZ56WJlTjGzVevRgnHZqPiZNVrU13H6gtWa3r8aV4Gj
i4F663eRAttj166cRgfl1QqpSG2IprNyV9UfuS2LlUaVNT3y0idawiJ4HhaA8pGB
ZqMUUkA4DSucb6xxEcTK
-----END CERTIFICATE-----

View File

@@ -0,0 +1 @@
AGE-SECRET-KEY-1UCXEUJH6JXF8LFKWFHDM4N9AQE2CCGQZGXLUNV4TKR5KY0KC8FDQ2TY4NX

View File

@@ -0,0 +1,14 @@
-----BEGIN CERTIFICATE-----
MIICHTCCAaKgAwIBAgIIT2gZuvqVFP0wCgYIKoZIzj0EAwIwSjESMBAGA1UEChMJ
U3luY3RoaW5nMSAwHgYDVQQLExdBdXRvbWF0aWNhbGx5IEdlbmVyYXRlZDESMBAG
A1UEAxMJc3luY3RoaW5nMB4XDTIzMTIwNjAwMDAwMFoXDTQzMTIwMTAwMDAwMFow
SjESMBAGA1UEChMJU3luY3RoaW5nMSAwHgYDVQQLExdBdXRvbWF0aWNhbGx5IEdl
bmVyYXRlZDESMBAGA1UEAxMJc3luY3RoaW5nMHYwEAYHKoZIzj0CAQYFK4EEACID
YgAEBAr1CsciwCa0vi7eC6xxuSGijY3txbjtsyFanec/fge4oJBD3rVpaLKFETb3
TvHHsuvblzElcP483MEVq6FMUoxwuL9CzTtpJrRhtwSmAs8AHLFu8irVn8sZjgkL
sXMho1UwUzAOBgNVHQ8BAf8EBAMCBaAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsG
AQUFBwMCMAwGA1UdEwEB/wQCMAAwFAYDVR0RBA0wC4IJc3luY3RoaW5nMAoGCCqG
SM49BAMCA2kAMGYCMQDbrtLgfcyMMIkNQn+PJe9DHYAqj8C47LQcWuIY/nekhOu0
aUfKctEAwyBtI60Y5zcCMQCEdgD/6CNBh7Qqq3z3CKPhlrpxHtCO5tNw17k0jfdH
haCwJInHZvZgclHk4EtFpTw=
-----END CERTIFICATE-----

View File

@@ -0,0 +1,6 @@
-----BEGIN EC PRIVATE KEY-----
MIGkAgEBBDA14Nqo17Xs/xRLGH2KLuyzjKp4eW9iWFobVNM93RZZbECT++W3XcQc
cEc5WVtiPmWgBwYFK4EEACKhZANiAAQECvUKxyLAJrS+Lt4LrHG5IaKNje3FuO2z
IVqd5z9+B7igkEPetWlosoURNvdO8cey69uXMSVw/jzcwRWroUxSjHC4v0LNO2km
tGG3BKYCzwAcsW7yKtWfyxmOCQuxcyE=
-----END EC PRIVATE KEY-----

View File

@@ -0,0 +1,22 @@
-----BEGIN CERTIFICATE-----
MIIDazCCAlOgAwIBAgIUfENbTtH5nr7giuawwQpDYqUpWJswDQYJKoZIhvcNAQEL
BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM
GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yNDA2MjcwOTQxNDNaFw0yNDA3
MjcwOTQxNDNaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw
HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEB
AQUAA4IBDwAwggEKAoIBAQCfP6cZhCs9jOnWqyQP12vrOOxlBrWofYZFf9amUA24
AfE7oGcSfkylanmkxzvGqQkhgLAvkHZj/GEvHujKyy8PgcEGP+pwmsfWNQMvU0Dz
j3syjWOTi3eIC/3DoUnHlWCT2qCil/bjqxgU1l7fO/OXUlq5kyvIjln7Za4sUHun
ixe/m96Er6l8a4Mh2pxh2C5pkLCvulkQhjjGG+R6MccH8wwQwmLg5oVBkFEZrnRE
pnRKBI0DvA+wk1aJFAPOI4d8Q5T7o/MyxH3f8TYGHqbeMQFCKwusnlWPRtrNdaIc
gaLvSpR0LVlroXGu8tYmRpvHPByoKGDbgVvO0Bwx8fmRAgMBAAGjUzBRMB0GA1Ud
DgQWBBR7r+mQWNUZ0TpQNwrwjgxgngvOjTAfBgNVHSMEGDAWgBR7r+mQWNUZ0TpQ
NwrwjgxgngvOjTAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQCO
7B4s6uQEGE8jg3CQgy76oU/D8sazGcP8+/E4JLHSc0Nj49w4ztSpkOVk2HyEtzbm
uR3TreIw+SfqpbiOI/ivVNDbEBsb/vEeq7qPzDH1Bi72plHZNRVhNGGV5rd7ibga
TkfXHKPM9yt8ffffHHiu1ROvb8gg2B6JbQwboU4hvvmmorW7onyTFSYEzZVdNSpv
pUtKPldxYjTnLlbsJdXC4xyCC4PrJt2CC0n0jsWfICJ77LMxIxTODh8oZNjbPg6r
RdI7U/DsD+R072DjbIcrivvigotJM+jihzz5inZwbO8o0WQOHAbJLIG3C3BnRW3A
Ek4u3+HXZMl5a0LGJ76u
-----END CERTIFICATE-----

View File

@@ -0,0 +1,14 @@
-----BEGIN CERTIFICATE-----
MIICHjCCAaOgAwIBAgIJAKbMWefkf1rVMAoGCCqGSM49BAMCMEoxEjAQBgNVBAoT
CVN5bmN0aGluZzEgMB4GA1UECxMXQXV0b21hdGljYWxseSBHZW5lcmF0ZWQxEjAQ
BgNVBAMTCXN5bmN0aGluZzAeFw0yMzEyMDYwMDAwMDBaFw00MzEyMDEwMDAwMDBa
MEoxEjAQBgNVBAoTCVN5bmN0aGluZzEgMB4GA1UECxMXQXV0b21hdGljYWxseSBH
ZW5lcmF0ZWQxEjAQBgNVBAMTCXN5bmN0aGluZzB2MBAGByqGSM49AgEGBSuBBAAi
A2IABFZTMt4RfsfBue0va7QuNdjfXMI4HfZzJCEcG+b9MtV7FlDmwMKX5fgGykD9
FBbC7yiza3+xCobdMb5bakz1qYJ7nUFCv1mwSDo2eNM+/XE+rJmlre8NwkwGmvzl
h1uhyqNVMFMwDgYDVR0PAQH/BAQDAgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggr
BgEFBQcDAjAMBgNVHRMBAf8EAjAAMBQGA1UdEQQNMAuCCXN5bmN0aGluZzAKBggq
hkjOPQQDAgNpADBmAjEAwzhsroN6R4/quWeXj6dO5gt5CfSTLkLee6vrcuIP5i1U
rZvJ3OKQVmmGG6IWYe7iAjEAyuq3X2wznaqiw2YK3IDI4qVeYWpCUap0fwRNq7/x
4dC4k+BOzHcuJOwNBIY/bEuK
-----END CERTIFICATE-----

View File

@@ -0,0 +1,6 @@
-----BEGIN EC PRIVATE KEY-----
MIGkAgEBBDCXHGpvumKjjDRxB6SsjZOb7duw3w+rdlGQCJTIvRThLjD6zwjnyImi
7c3PD5nWtLqgBwYFK4EEACKhZANiAARWUzLeEX7HwbntL2u0LjXY31zCOB32cyQh
HBvm/TLVexZQ5sDCl+X4BspA/RQWwu8os2t/sQqG3TG+W2pM9amCe51BQr9ZsEg6
NnjTPv1xPqyZpa3vDcJMBpr85Ydboco=
-----END EC PRIVATE KEY-----

View File

@@ -0,0 +1 @@
AGE-SECRET-KEY-1UCXEUJH6JXF8LFKWFHDM4N9AQE2CCGQZGXLUNV4TKR5KY0KC8FDQ2TY4NX

View File

@@ -0,0 +1,22 @@
-----BEGIN CERTIFICATE-----
MIIDazCCAlOgAwIBAgIUCUjfNkF0CDhTKbO3nNczcsCW4qEwDQYJKoZIhvcNAQEL
BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM
GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yNDA2MjcwOTM2NDZaFw0yNDA3
MjcwOTM2NDZaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw
HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEB
AQUAA4IBDwAwggEKAoIBAQDCcdZEJvXJIeOKO5pF5XUFvUeJtCCiwfWvWS662bxc
R/5MZucRLqfTNYo9aBv4NITw5kxZsTaaubmS4zSGQoTEAVzqzVdi3a/gNvsdVLb+
7CivpmweLllX/OGsTL0kHPEI+74AYiTBjXfdWV1Y5T1tuwc3G8ATrguQ33Uo5vvF
vcqsbTKcRZC0pB9O/nn4q03GsRdvlpaKakIhjMpRG/uZ3u7wtbyZ+WqjsjxZNfnY
aMyPoaipFqX1v+L7GKlOj2NpyEZFVVwa2ZqhVSYXyDfpAWQFznwKGzD5mjtcyKym
gnv/5LwrpH4Xj+JMt48hN+rPnu5vfXT8Y4KnID30OQW7AgMBAAGjUzBRMB0GA1Ud
DgQWBBQBBO8Wp975pAGioMjkaxANAVInfzAfBgNVHSMEGDAWgBQBBO8Wp975pAGi
oMjkaxANAVInfzAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAg
F40MszTZXpR/A1z9B1CcXH47tNK67f8bCMR2dhvXODbpatwSihyxhQjtLb5R6kYH
5Yq/B4yrh303j0CXaobCQ4nQH7zI7fhViww+TzW7vDhgM7ueEyyXrqCXt6JY8avg
TuvIRtJSeWSQJ5aLNaYqmiwMf/tj9W3BMDpctGyLqu1WTSrbpYa9mA5Vudud70Yz
DgZ/aqHilB07cVNqzVYZzRZ56WJlTjGzVevRgnHZqPiZNVrU13H6gtWa3r8aV4Gj
i4F663eRAttj166cRgfl1QqpSG2IprNyV9UfuS2LlUaVNT3y0idawiJ4HhaA8pGB
ZqMUUkA4DSucb6xxEcTK
-----END CERTIFICATE-----

View File

@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDCcdZEJvXJIeOK
O5pF5XUFvUeJtCCiwfWvWS662bxcR/5MZucRLqfTNYo9aBv4NITw5kxZsTaaubmS
4zSGQoTEAVzqzVdi3a/gNvsdVLb+7CivpmweLllX/OGsTL0kHPEI+74AYiTBjXfd
WV1Y5T1tuwc3G8ATrguQ33Uo5vvFvcqsbTKcRZC0pB9O/nn4q03GsRdvlpaKakIh
jMpRG/uZ3u7wtbyZ+WqjsjxZNfnYaMyPoaipFqX1v+L7GKlOj2NpyEZFVVwa2Zqh
VSYXyDfpAWQFznwKGzD5mjtcyKymgnv/5LwrpH4Xj+JMt48hN+rPnu5vfXT8Y4Kn
ID30OQW7AgMBAAECggEAGVKn+/Iy+kG+l2cRvV6XseqnoWhjA69M5swviMgIfuAl
Xx/boeI4mwoS+dJQKi/0zEbB1MB+gwIDB/0s/vs0vS4MQswBQG/skr+2TmiU+Hgb
CF0dIYUZv5rAbScFTumx/mCCqxwc+1QIMzyLKqOYL203EFc92ZJGEVT4th321haZ
8Wd+dllcYAb7BbEeBhCrTqRe9T3zt5reZgtZTquTF5hGm8EAyBp6rLjZK7dyZ9dd
gyIsDbWgPC9vkRc6x/eANn70hgDbYOuoXwAP/qIFnWLL1Zzy8LKUyOsSgQ91S3S3
Il4Lt6lEyU3+61MsCYss7jDoP/7REEjz5h6gfxlFSQKBgQD9u8nhHuwte4/d9VNU
rhSBW9h8IJzwPif/eS8vh9VaS2SjR2dDCcHg6rGYKnexeEzUcx56aQMA+p3nRJwy
Uwnx5BfEWs9FO6yPR8VEI0a2sBp+hoWKJX/Lvat+QCs6IFuGmlQpczD7/RYAkhG4
mwyt/ymqzjukb9mFaeYIltOfPwKBgQDELnkH1ChTUH5u3HgDoelFbzR18okz6dxH
urMbfZMAl8W5h2zAvHsAX5qxyHHankOUsiH2y3BrAgqQtTuIA2a5W7j+yHBkYiEZ
EUNeI9YNA0KU+wwZpVVvRGUsRB5SUBo5LlcSYmX/V32f0oU5Np44i0vjl3Ju8esx
2MLfj1A2hQKBgQDCxtZZZ0h8Pb8Z7wpSFfQNvXi5CLwQvFYuClQLk6VXVErkAJsn
XiUjyGYeXnNVm/i2mcyKwXQZ20k90HBrPU2ED8mi5Ob5ya5Uqw6mmMHe2d7sw81d
WB37RBWSrCXC0DYSZQQ4cYHn3sd2Fqtd4EBijV7qDLjCKU582OdKLqYzNwKBgH31
UKQkJZgIkIThbPT4GewI0GgCRvFb76DmUGUQJTg2Oi86siq1WUwOFiabie5RuxZX
oNLyH8W008/BbO2RMX1FVOvRCciJ8LJFkTl6TM6iDzfUUBqPOuFryoG3Yrh60btw
81rMbqyZIgFhi0QGu2OWnC0Oadyt2tJwV/5t55R5AoGBAPspZttDmOzVkAJDSn9Z
iByYt1KmwBQ6l7LpFg33a7ds9zWqW4+i6r0PzXvSewf/z69L0cAywSk5CaJJjDso
dTlNMqwux01wd6V+nQGR871xnsOg+qzgJ565TJZelWgRmNRUooi4DMp5POJA33xp
rqAISUfW0w2S+q7/5Lm0QiJE
-----END PRIVATE KEY-----

View File

@@ -0,0 +1,22 @@
-----BEGIN CERTIFICATE-----
MIIDazCCAlOgAwIBAgIUfENbTtH5nr7giuawwQpDYqUpWJswDQYJKoZIhvcNAQEL
BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM
GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yNDA2MjcwOTQxNDNaFw0yNDA3
MjcwOTQxNDNaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw
HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEB
AQUAA4IBDwAwggEKAoIBAQCfP6cZhCs9jOnWqyQP12vrOOxlBrWofYZFf9amUA24
AfE7oGcSfkylanmkxzvGqQkhgLAvkHZj/GEvHujKyy8PgcEGP+pwmsfWNQMvU0Dz
j3syjWOTi3eIC/3DoUnHlWCT2qCil/bjqxgU1l7fO/OXUlq5kyvIjln7Za4sUHun
ixe/m96Er6l8a4Mh2pxh2C5pkLCvulkQhjjGG+R6MccH8wwQwmLg5oVBkFEZrnRE
pnRKBI0DvA+wk1aJFAPOI4d8Q5T7o/MyxH3f8TYGHqbeMQFCKwusnlWPRtrNdaIc
gaLvSpR0LVlroXGu8tYmRpvHPByoKGDbgVvO0Bwx8fmRAgMBAAGjUzBRMB0GA1Ud
DgQWBBR7r+mQWNUZ0TpQNwrwjgxgngvOjTAfBgNVHSMEGDAWgBR7r+mQWNUZ0TpQ
NwrwjgxgngvOjTAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQCO
7B4s6uQEGE8jg3CQgy76oU/D8sazGcP8+/E4JLHSc0Nj49w4ztSpkOVk2HyEtzbm
uR3TreIw+SfqpbiOI/ivVNDbEBsb/vEeq7qPzDH1Bi72plHZNRVhNGGV5rd7ibga
TkfXHKPM9yt8ffffHHiu1ROvb8gg2B6JbQwboU4hvvmmorW7onyTFSYEzZVdNSpv
pUtKPldxYjTnLlbsJdXC4xyCC4PrJt2CC0n0jsWfICJ77LMxIxTODh8oZNjbPg6r
RdI7U/DsD+R072DjbIcrivvigotJM+jihzz5inZwbO8o0WQOHAbJLIG3C3BnRW3A
Ek4u3+HXZMl5a0LGJ76u
-----END CERTIFICATE-----

View File

@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCfP6cZhCs9jOnW
qyQP12vrOOxlBrWofYZFf9amUA24AfE7oGcSfkylanmkxzvGqQkhgLAvkHZj/GEv
HujKyy8PgcEGP+pwmsfWNQMvU0Dzj3syjWOTi3eIC/3DoUnHlWCT2qCil/bjqxgU
1l7fO/OXUlq5kyvIjln7Za4sUHunixe/m96Er6l8a4Mh2pxh2C5pkLCvulkQhjjG
G+R6MccH8wwQwmLg5oVBkFEZrnREpnRKBI0DvA+wk1aJFAPOI4d8Q5T7o/MyxH3f
8TYGHqbeMQFCKwusnlWPRtrNdaIcgaLvSpR0LVlroXGu8tYmRpvHPByoKGDbgVvO
0Bwx8fmRAgMBAAECggEACAkjOnNj5zA0IIP0RuRc6rqtmw9ynTTwUJN51lyVxKI8
dQDMEq/S2En+J2VyS7z92/XtbgkBIFx83u7VWl5UWpj2j4UsJFB7IwD7zyiJT4D+
+3cM/kX8Wx4XyQZbfbm47N0MXAgFCkn45hxHH0acLReXwmN9wxoDyl7AIjZRdwvG
Qq0rnOnIc8kkkew7L6AiFwQS8b77eyzua3d6moKXN9hU/kfiJ6YUFG/WLe0pmQA1
HbF27YghfeLnYUt50oDuX6jF6CzQhflchWVq/wn8/cxEpg/RMicWE8ulrTk7o27l
JwCrHrhYEBsPuZO4mxX/DHrAMmhTeFjLaV5bQlz0PQKBgQDgRPSOEixYnKz9iPs/
EDTlji5LA3Rm6TytRCNsjYY6Trw60KcvYqwyDUCiEjruvOQ9mqgBiQm1VHSalrG3
RcbVfpEMouyZbEwmTjS8KdOi5x4Z6AX+4yWDN31jX3b8sktgbxV/HRdg3sA3q7MJ
vExTUuoXg57W+FepIZ+XlhSoQwKBgQC1x6UMAlAeW45/yUUm/LFRcCgb/bdCQx+e
hSb8w3jdvVoNWgx1j7RsjjFKaZUnseK3qQvVfCm4Qjvlz6MpKDxslaUYuR162Ku0
e153z/xc7XRoXyPyPLdGZFlWii30jirB7ZqPdyz6mwlWwqdImNerbUqdFt9R8bId
pYsyHB5zmwKBgBjYCq9iW/9E+/TqI8sMpI95fK9app5v4AThs3rnAqOa7Ucmrh6V
s7Wnui06D8U6r54Tb+EbqTOpM3Gcl/tRg4FLEA5yTfuA/76Ok1D04Tj+mVsNVPyz
dQhgMUe835WGusroA12df2V/x5NjNeYyMdJZMQ2ByyrNQAjAbMmCGq+5AoGBAIj8
ERFysMOfxUvg9b7CkDFJrsAhOzew86P2vYGfIHchGTqUkG0LRTDFGrnzxNXsBGjY
+DUB40Kajx7IkTETxC0jvA1ceq23l/VjPrZVQt0YiC+a+rCyNn7SYkyHxsfTVr9b
ea0BZyDXMntyJrPbkjL6Ik8tDE9pLwuOU84ISJ5fAoGAZ2+Ams/VhdZj/wpRpMky
K4jtS4nzbCmJzzTa6vdVV7Kjer5kFxSFFqMrS/FtJ/RxHeHvxdze9dfGu9jIdTKK
vSzbyQdHFfZgRkmAKfcoN9u567z7Oc74AQ9UgFEGdEVFQUbfWOevmr8KIPt8nDQK
J9HuVfILi1kH0jzDd/64TvA=
-----END PRIVATE KEY-----

View File

@@ -0,0 +1,6 @@
---
description = "A dynamic DNS service to update domain IPs"
---

View File

@@ -0,0 +1,265 @@
{
config,
pkgs,
lib,
...
}:
let
name = "dyndns";
cfg = config.clan.${name};
# We dedup secrets if they have the same provider + base domain
secret_id = opt: "${name}-${opt.provider}-${opt.domain}";
secret_path =
opt: config.clan.core.facts.services."${secret_id opt}".secret."${secret_id opt}".path;
# We check that a secret has not been set in extraSettings.
extraSettingsSafe =
opt:
if (builtins.hasAttr opt.secret_field_name opt.extraSettings) then
throw "Please do not set ${opt.secret_field_name} in extraSettings, it is automatically set by the dyndns module."
else
opt.extraSettings;
/*
We go from:
{home.example.com:{value:{domain:example.com,host:home, provider:namecheap}}}
To:
{settings: [{domain: example.com, host: home, provider: namecheap, password: dyndns-namecheap-example.com}]}
*/
service_config = {
settings = builtins.catAttrs "value" (
builtins.attrValues (
lib.mapAttrs (_: opt: {
value =
(extraSettingsSafe opt)
// {
domain = opt.domain;
provider = opt.provider;
}
// {
"${opt.secret_field_name}" = secret_id opt;
};
}) cfg.settings
)
);
};
secret_generator = _: opt: {
name = secret_id opt;
value = {
secret.${secret_id opt} = { };
generator.prompt = "Dyndns passphrase for ${secret_id opt}";
generator.script = ''
echo "$prompt_value" > $secrets/${secret_id opt}
'';
};
};
in
{
options.clan.${name} = {
user = lib.mkOption {
type = lib.types.str;
default = name;
description = "User to run the service as";
};
group = lib.mkOption {
type = lib.types.str;
default = name;
description = "Group to run the service as";
};
server = {
enable = lib.mkEnableOption "dyndns webserver";
domain = lib.mkOption {
type = lib.types.str;
description = "Domain to serve the webservice on";
};
port = lib.mkOption {
type = lib.types.int;
default = 54805;
description = "Port to listen on";
};
};
period = lib.mkOption {
type = lib.types.int;
default = 5;
description = "Domain update period in minutes";
};
settings = lib.mkOption {
type = lib.types.attrsOf (
lib.types.submodule (
{ ... }:
{
options = {
provider = lib.mkOption {
example = "namecheap";
type = lib.types.str;
description = "The dyndns provider to use";
};
domain = lib.mkOption {
type = lib.types.str;
example = "example.com";
description = "The top level domain to update.";
};
secret_field_name = lib.mkOption {
example = [
"password"
"api_key"
];
type = lib.types.enum [
"password"
"token"
"api_key"
];
default = "password";
description = "The field name for the secret";
};
# TODO: Ideally we would create a gigantic list of all possible settings / types
# optimally we would have a way to generate the options from the source code
extraSettings = lib.mkOption {
type = lib.types.attrsOf lib.types.str;
default = { };
description = ''
Extra settings for the provider.
Provider specific settings: https://github.com/qdm12/ddns-updater#configuration
'';
};
};
}
)
);
default = [ ];
description = "Configuration for which domains to update";
};
};
imports = [
(lib.mkRemovedOptionModule [
"clan"
"dyndns"
"enable"
] "Just define clan.dyndns.settings to enable it")
];
config = lib.mkMerge [
(lib.mkIf (cfg.settings != { }) {
clan.core.facts.services = lib.mapAttrs' secret_generator cfg.settings;
users.groups.${cfg.group} = { };
users.users.${cfg.user} = {
group = cfg.group;
isSystemUser = true;
description = "User for ${name} service";
home = "/var/lib/${name}";
createHome = true;
};
networking.firewall.allowedTCPPorts = lib.mkIf cfg.server.enable [
80
443
];
services.nginx = lib.mkIf cfg.server.enable {
enable = true;
virtualHosts = {
"${cfg.server.domain}" = {
forceSSL = true;
enableACME = true;
locations."/" = {
proxyPass = "http://localhost:${toString cfg.server.port}";
};
};
};
};
systemd.services.${name} = {
path = [ ];
description = "Dynamic DNS updater";
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
environment = {
MYCONFIG = "${builtins.toJSON service_config}";
SERVER_ENABLED = if cfg.server.enable then "yes" else "no";
PERIOD = "${toString cfg.period}m";
LISTENING_ADDRESS = ":${toString cfg.server.port}";
};
serviceConfig =
let
pyscript = pkgs.writers.writePyPy3Bin "test.py" { libraries = [ ]; } ''
import json
from pathlib import Path
import os
cred_dir = Path(os.getenv("CREDENTIALS_DIRECTORY"))
config_str = os.getenv("MYCONFIG")
def get_credential(name):
secret_p = cred_dir / name
with open(secret_p, 'r') as f:
return f.read().strip()
config = json.loads(config_str)
print(f"Config: {config}")
for attrset in config["settings"]:
if "password" in attrset:
attrset['password'] = get_credential(attrset['password'])
elif "token" in attrset:
attrset['token'] = get_credential(attrset['token'])
elif "api_key" in attrset:
attrset['api_key'] = get_credential(attrset['api_key'])
else:
raise ValueError(f"Missing secret field in {attrset}")
# create directory data if it does not exist
data_dir = Path('data')
data_dir.mkdir(mode=0o770, exist_ok=True)
# Write the config with secrets back
config_path = data_dir / 'config.json'
with open(config_path, 'w') as f:
f.write(json.dumps(config, indent=4))
# Set file permissions to read and write
# only by the user and group
config_path.chmod(0o660)
# Set file permissions to read
# and write only by the user and group
for file in data_dir.iterdir():
file.chmod(0o660)
'';
in
{
ExecStartPre = lib.getExe pyscript;
ExecStart = lib.getExe pkgs.ddns-updater;
LoadCredential = lib.mapAttrsToList (_: opt: "${secret_id opt}:${secret_path opt}") cfg.settings;
User = cfg.user;
Group = cfg.group;
NoNewPrivileges = true;
PrivateTmp = true;
ProtectSystem = "strict";
ReadOnlyPaths = "/";
PrivateDevices = "yes";
ProtectKernelModules = "yes";
ProtectKernelTunables = "yes";
WorkingDirectory = "/var/lib/${name}";
ReadWritePaths = [
"/proc/self"
"/var/lib/${name}"
];
Restart = "always";
RestartSec = 60;
};
};
})
];
}

View File

@@ -4,18 +4,23 @@
borgbackup = ./borgbackup;
borgbackup-static = ./borgbackup-static;
deltachat = ./deltachat;
dyndns = ./dyndns;
ergochat = ./ergochat;
garage = ./garage;
golem-provider = ./golem-provider;
iwd = ./iwd;
localbackup = ./localbackup;
localsend = ./localsend;
single-disk = ./single-disk;
matrix-synapse = ./matrix-synapse;
moonlight = ./moonlight;
mumble = ./mumble;
packages = ./packages;
postgresql = ./postgresql;
root-password = ./root-password;
single-disk = ./single-disk;
sshd = ./sshd;
sunshine = ./sunshine;
static-hosts = ./static-hosts;
sunshine = ./sunshine;
syncthing = ./syncthing;
syncthing-static-peers = ./syncthing-static-peers;
thelounge = ./thelounge;

View File

@@ -0,0 +1,10 @@
---
description = "S3-compatible object store for small self-hosted geo-distributed deployments"
---
This module generates garage specific keys automatically.
When using garage in a distributed deployment the `rpc_key` between connected instances must be shared.
This is currently still a manual process.
Options: [NixosModuleOptions](https://search.nixos.org/options?channel=unstable&size=50&sort=relevance&type=packages&query=garage)
Documentation: https://garagehq.deuxfleurs.fr/

View File

@@ -0,0 +1,33 @@
{ config, pkgs, ... }:
{
systemd.services.garage.serviceConfig = {
LoadCredential = [
"rpc_secret_path:${config.clan.core.vars.generators.garage.files.rpc_secret.path}"
"admin_token_path:${config.clan.core.vars.generators.garage.files.admin_token.path}"
"metrics_token_path:${config.clan.core.vars.generators.garage.files.metrics_token.path}"
];
Environment = [
"GARAGE_ALLOW_WORLD_READABLE_SECRETS=true"
"GARAGE_RPC_SECRET_FILE=%d/rpc_secret_path"
"GARAGE_ADMIN_TOKEN_FILE=%d/admin_token_path"
"GARAGE_METRICS_TOKEN_FILE=%d/metrics_token_path"
];
};
clan.core.vars.generators.garage = {
files.rpc_secret = { };
files.admin_token = { };
files.metrics_token = { };
runtimeInputs = [
pkgs.coreutils
pkgs.openssl
];
script = ''
openssl rand -hex -out $out/rpc_secret 32
openssl rand -base64 -out $out/admin_token 32
openssl rand -base64 -out $out/metrics_token 32
'';
};
clan.core.state.garage.folders = [ config.services.garage.settings.metadata_dir ];
}

View File

@@ -0,0 +1,7 @@
---
description = "Golem Provider for the Golem Network, an open-source and decentralized platform where everyone can use and share each other's computing power without relying on centralized entities like cloud computing corporations"
---
By running a golem provider your machine's compute resources are offered via the golem network which will allow other members to execute compute tasks on your machine. If this happens, you will be compensated with GLM, an ERC20 token.
More about golem providers: https://docs.golem.network/docs/golem/overview

View File

@@ -0,0 +1,34 @@
{ config, pkgs, ... }:
let
cfg = config.clan.golem-provider;
yagna = pkgs.callPackage ../../pkgs/yagna { };
accountFlag = if cfg.account != null then "--account ${cfg.account}" else "";
in
{
imports = [ ./interface.nix ];
users.users.golem = {
isSystemUser = true;
home = "/var/lib/golem";
group = "golem";
createHome = true;
};
users.groups.golem = { };
environment.systemPackages = [ yagna ];
systemd.services.golem-provider = {
description = "Golem Provider";
wantedBy = [ "multi-user.target" ];
after = [ "network-online.target" ];
serviceConfig = {
Type = "simple";
ExecStart = "${yagna}/bin/golemsp run --no-interactive ${accountFlag}";
Restart = "always";
RestartSec = "5";
User = "golem";
Group = "golem";
};
};
}

View File

@@ -0,0 +1,20 @@
{ lib, ... }:
let
inherit (lib) mkOption;
inherit (lib.types) nullOr str;
in
{
options.clan.golem-provider = {
account = mkOption {
type = nullOr str;
description = ''
Ethereum address for payouts.
Leave empty to automatically generate a new address upon first start.
'';
default = null;
};
};
}

View File

@@ -0,0 +1,4 @@
{ ... }:
{
imports = [ ../. ];
}

View File

@@ -0,0 +1,6 @@
---
description = "Automatically provisions wifi credentials"
---

View File

@@ -0,0 +1,83 @@
{ lib, config, ... }:
let
cfg = config.clan.iwd;
secret_path = ssid: config.clan.core.facts.services."iwd.${ssid}".secret."iwd.${ssid}".path;
secret_generator = name: value: {
name = "iwd.${value.ssid}";
value =
let
secret_name = "iwd.${value.ssid}";
in
{
secret.${secret_name} = { };
generator.prompt = "Wifi password for '${value.ssid}'";
generator.script = ''
config="
[Security]
Passphrase=$prompt_value
"
echo "$config" > $secrets/${secret_name}
'';
};
};
in
{
options.clan.iwd = {
networks = lib.mkOption {
type = lib.types.attrsOf (
lib.types.submodule (
{ name, ... }:
{
options = {
ssid = lib.mkOption {
type = lib.types.strMatching "^[a-zA-Z0-9._-]+$";
default = name;
description = "The name of the wifi network";
};
};
}
)
);
default = { };
description = "Wifi networks to predefine";
};
};
imports = [
(lib.mkRemovedOptionModule [
"clan"
"iwd"
"enable"
] "Just define clan.iwd.networks to enable it")
];
config = lib.mkMerge [
(lib.mkIf (cfg.networks != { }) {
# Systemd tmpfiles rule to create /var/lib/iwd/example.psk file
systemd.tmpfiles.rules = lib.mapAttrsToList (
_: value: "C /var/lib/iwd/${value.ssid}.psk 0600 root root - ${secret_path value.ssid}"
) cfg.networks;
clan.core.facts.services = lib.mapAttrs' secret_generator cfg.networks;
# TODO: restart the iwd.service if something changes
})
{
# disable wpa supplicant
networking.wireless.enable = false;
# Use iwd instead of wpa_supplicant. It has a user friendly CLI
networking.wireless.iwd = {
enable = true;
settings = {
Network = {
EnableIPv6 = true;
RoutePriorityOffset = 300;
};
Settings.AutoConnect = true;
};
};
}
];
}

View File

@@ -169,6 +169,11 @@ in
];
};
networking.firewall.allowedTCPPorts = [
80
443
];
services.nginx = {
enable = true;
virtualHosts = {

View File

@@ -0,0 +1,14 @@
---
description = "Open Source, Low Latency, High Quality Voice Chat."
categories = ["chat", "voice"]
---
The mumble clan module gives you:
- True low latency voice communication.
- Secure, authenticated encryption.
- Free software.
- Backed by a large and active open-source community.
This all set up in a way that allows peer-to-peer hosting.
Every machine inside the clan can be a host for mumble,
and thus it doesn't matter who in the network is online - as long as two people are online they are able to chat with each other.

View File

@@ -0,0 +1,104 @@
{
lib,
config,
pkgs,
...
}:
let
clanDir = config.clan.core.clanDir;
machineDir = clanDir + "/machines/";
machinesFileSet = builtins.readDir machineDir;
machines = lib.mapAttrsToList (name: _: name) machinesFileSet;
machineJson = builtins.toJSON machines;
certificateMachinePath = machines: machineDir + "/${machines}" + "/facts/mumble-cert";
certificatesUnchecked = builtins.map (
machine:
let
fullPath = certificateMachinePath machine;
in
if builtins.pathExists fullPath then machine else null
) machines;
certificate = lib.filter (machine: machine != null) certificatesUnchecked;
machineCert = builtins.map (
machine: (lib.nameValuePair machine (builtins.readFile (certificateMachinePath machine)))
) certificate;
machineCertJson = builtins.toJSON machineCert;
in
{
options.clan.services.mumble = {
user = lib.mkOption {
type = lib.types.str;
default = "alice";
description = "The user mumble should be set up for.";
};
};
config = {
services.murmur = {
enable = true;
logDays = -1;
registerName = config.clan.core.machineName;
openFirewall = true;
bonjour = true;
sslKey = config.clan.core.facts.services.mumble.secret.mumble-key.path;
sslCert = config.clan.core.facts.services.mumble.public.mumble-cert.path;
};
clan.core.state.mumble.folders = [
"/var/lib/mumble"
"/var/lib/murmur"
];
systemd.tmpfiles.rules = [
"d '/var/lib/mumble' 0770 '${config.clan.services.mumble.user}' 'users' - -"
];
environment.systemPackages =
let
mumbleCfgDir = "/var/lib/mumble";
mumbleDatabasePath = "${mumbleCfgDir}/mumble.sqlite";
mumbleCfgPath = "/var/lib/mumble/mumble_settings.json";
populate-channels = pkgs.writers.writePython3 "mumble-populate-channels" {
libraries = [
pkgs.python3Packages.cryptography
pkgs.python3Packages.pyopenssl
];
flakeIgnore = [
# We don't live in the dark ages anymore.
# Languages like Python that are whitespace heavy will overrun
# 79 characters..
"E501"
];
} (builtins.readFile ./mumble-populate-channels.py);
mumble = pkgs.writeShellScriptBin "mumble" ''
set -xeu
mkdir -p ${mumbleCfgDir}
pushd "${mumbleCfgDir}"
XDG_DATA_HOME=${mumbleCfgDir}
XDG_DATA_DIR=${mumbleCfgDir}
${populate-channels} --ensure-config '${mumbleCfgPath}' --db-location ${mumbleDatabasePath}
echo ${machineCertJson}
${populate-channels} --machines '${machineJson}' --username ${config.clan.core.machineName} --db-location ${mumbleDatabasePath}
${populate-channels} --servers '${machineCertJson}' --username ${config.clan.core.machineName} --db-location ${mumbleDatabasePath} --cert True
${pkgs.mumble}/bin/mumble --config ${mumbleCfgPath} "$@"
popd
'';
in
[ mumble ];
clan.core.facts.services.mumble = {
secret.mumble-key = { };
public.mumble-cert = { };
generator.path = [
pkgs.coreutils
pkgs.openssl
];
generator.script = ''
openssl genrsa -out $secrets/mumble-key 2048
openssl req -new -x509 -key $secrets/mumble-key -out $facts/mumble-cert
'';
};
};
}

View File

@@ -0,0 +1,249 @@
import argparse
import json
import os
import sqlite3
def ensure_config(path: str, db_path: str) -> None:
# Default JSON structure if the file doesn't exist
default_json = {
"misc": {
"audio_wizard_has_been_shown": True,
"database_location": db_path,
"viewed_server_ping_consent_message": True,
},
"settings_version": 1,
}
# Check if the file exists
if os.path.exists(path):
with open(path) as file:
data = json.load(file)
else:
data = default_json
# Create the file with default JSON structure
with open(path, "w") as file:
json.dump(data, file, indent=4)
# TODO: make sure to only update the diff
updated_data = {**default_json, **data}
# Write the modified JSON object back to the file
with open(path, "w") as file:
json.dump(updated_data, file, indent=4)
def initialize_database(db_location: str) -> None:
"""
Initializes the database. If the database or the servers table does not exist, it creates them.
:param db_location: The path to the SQLite database
"""
conn = sqlite3.connect(db_location)
try:
cursor = conn.cursor()
# Create the servers table if it doesn't exist
cursor.execute("""
CREATE TABLE IF NOT EXISTS servers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
hostname TEXT NOT NULL,
port INTEGER NOT NULL,
username TEXT NOT NULL,
password TEXT NOT NULL,
url TEXT
)
""")
# Commit the changes
conn.commit()
except sqlite3.Error as e:
print(f"An error occurred while initializing the database: {e}")
finally:
conn.close()
def initialize_certificates(
db_location: str, hostname: str, port: str, digest: str
) -> None:
# Connect to the SQLite database
conn = sqlite3.connect(db_location)
try:
# Create a cursor object
cursor = conn.cursor()
# TODO: check if cert already there
# if server_check(cursor, name, hostname):
# print(
# f"Server with name '{name}' and hostname '{hostname}' already exists."
# )
# return
# SQL command to insert data into the servers table
insert_query = """
INSERT INTO cert (hostname, port, digest)
VALUES (?, ?, ?)
"""
# Data to be inserted
data = (hostname, port, digest)
# Execute the insert command with the provided data
cursor.execute(insert_query, data)
# Commit the changes
conn.commit()
print("Data has been successfully inserted.")
except sqlite3.Error as e:
print(f"An error occurred: {e}")
finally:
# Close the connection
conn.close()
pass
def calculate_digest(cert: str) -> str:
from cryptography import x509
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes
cert = cert.strip()
cert = cert.encode("utf-8")
cert = x509.load_pem_x509_certificate(cert, default_backend())
digest = cert.fingerprint(hashes.SHA1()).hex()
return digest
def server_check(cursor: str, name: str, hostname: str) -> bool:
"""
Check if a server with the given name and hostname already exists.
:param cursor: The database cursor
:param name: The name of the server
:param hostname: The hostname of the server
:return: True if the server exists, False otherwise
"""
check_query = """
SELECT 1 FROM servers WHERE name = ? AND hostname = ?
"""
cursor.execute(check_query, (name, hostname))
return cursor.fetchone() is not None
def insert_server(
name: str,
hostname: str,
port: str,
username: str,
password: str,
url: str,
db_location: str,
) -> None:
"""
Inserts a new server record into the servers table.
:param name: The name of the server
:param hostname: The hostname of the server
:param port: The port number
:param username: The username
:param password: The password
:param url: The URL
"""
# Connect to the SQLite database
conn = sqlite3.connect(db_location)
try:
# Create a cursor object
cursor = conn.cursor()
if server_check(cursor, name, hostname):
print(
f"Server with name '{name}' and hostname '{hostname}' already exists."
)
return
# SQL command to insert data into the servers table
insert_query = """
INSERT INTO servers (name, hostname, port, username, password, url)
VALUES (?, ?, ?, ?, ?, ?)
"""
# Data to be inserted
data = (name, hostname, port, username, password, url)
# Execute the insert command with the provided data
cursor.execute(insert_query, data)
# Commit the changes
conn.commit()
print("Data has been successfully inserted.")
except sqlite3.Error as e:
print(f"An error occurred: {e}")
finally:
# Close the connection
conn.close()
if __name__ == "__main__":
port = 64738
password = ""
url = None
parser = argparse.ArgumentParser(
prog="initialize_mumble",
)
subparser = parser.add_subparsers(dest="certificates")
# cert_parser = subparser.add_parser("certificates")
parser.add_argument("--cert")
parser.add_argument("--digest")
parser.add_argument("--machines")
parser.add_argument("--servers")
parser.add_argument("--username")
parser.add_argument("--db-location")
parser.add_argument("--ensure-config")
args = parser.parse_args()
print(args)
if args.ensure_config:
ensure_config(args.ensure_config, args.db_location)
print("Initialized config")
exit(0)
if args.servers:
print(args.servers)
servers = json.loads(f"{args.servers}")
db_location = args.db_location
for server in servers:
digest = calculate_digest(server.get("value"))
name = server.get("name")
initialize_certificates(db_location, name, port, digest)
print("Initialized certificates")
exit(0)
initialize_database(args.db_location)
# Insert the server into the database
print(args.machines)
machines = json.loads(f"{args.machines}")
print(machines)
print(list(machines))
for machine in list(machines):
print(f"Inserting {machine}.")
insert_server(
machine,
machine,
port,
args.username,
password,
url,
args.db_location,
)

View File

@@ -0,0 +1,42 @@
{ pkgs, self, ... }:
pkgs.nixosTest {
name = "mumble";
nodes.peer1 =
{ ... }:
{
imports = [
self.nixosModules.mumble
self.inputs.clan-core.nixosModules.clanCore
{
config = {
clan.core.machineName = "peer1";
clan.core.clanDir = ./.;
documentation.enable = false;
};
}
];
};
nodes.peer2 =
{ ... }:
{
imports = [
self.nixosModules.mumble
self.inputs.clan-core.nixosModules.clanCore
{
config = {
clan.core.machineName = "peer2";
clan.core.clanDir = ./.;
documentation.enable = false;
};
}
];
};
testScript = ''
start_all()
'';
}

View File

@@ -38,6 +38,7 @@
];
shellHook = ''
echo -e "${ansiEscapes.green}switch to another dev-shell using: select-shell${ansiEscapes.reset}"
export PROJECT_ROOT=$(git rev-parse --show-toplevel)
'';
};
};

View File

@@ -45,6 +45,7 @@ nav:
- Configure: getting-started/configure.md
- Secrets & Facts: getting-started/secrets.md
- Deploy Machine: getting-started/deploy.md
- Disk Encryption: getting-started/disk-encryption.md
- Mesh VPN: getting-started/mesh-vpn.md
- Backup & Restore: getting-started/backups.md
- Flake-parts: getting-started/flake-parts.md
@@ -54,15 +55,20 @@ nav:
- Reference:
- reference/index.md
- Clan Modules:
- reference/clanModules/index.md
- reference/clanModules/borgbackup-static.md
- reference/clanModules/borgbackup.md
- reference/clanModules/deltachat.md
- reference/clanModules/dyndns.md
- reference/clanModules/ergochat.md
- reference/clanModules/garage.md
- reference/clanModules/golem-provider.md
- reference/clanModules/index.md
- reference/clanModules/iwd.md
- reference/clanModules/localbackup.md
- reference/clanModules/localsend.md
- reference/clanModules/matrix-synapse.md
- reference/clanModules/moonlight.md
- reference/clanModules/mumble.md
- reference/clanModules/packages.md
- reference/clanModules/postgresql.md
- reference/clanModules/root-password.md
@@ -81,7 +87,6 @@ nav:
- CLI:
- reference/cli/index.md
- reference/cli/backups.md
- reference/cli/config.md
- reference/cli/facts.md
- reference/cli/flakes.md
- reference/cli/flash.md

View File

@@ -1,9 +1,16 @@
{% extends "base.html" %}
{% block extrahead %}
<meta property="og:title" content="Clan - Documentation, Blog & Getting Started Guide" />
<meta property="og:description" content="Documentation for Clan. The peer-to-peer machine deployment framework." />
<meta property="og:image" content="https://clan.lol/static/dark-favicon/128x128.png" />
{% extends "base.html" %} {% block extrahead %}
<meta
property="og:title"
content="Clan - Documentation, Blog & Getting Started Guide"
/>
<meta
property="og:description"
content="Documentation for Clan. The peer-to-peer machine deployment framework."
/>
<meta
property="og:image"
content="https://clan.lol/static/dark-favicon/128x128.png"
/>
<meta property="og:url" content="https://docs.clan.lol" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="Clan" />

View File

@@ -219,6 +219,7 @@ This is useful for machines that are not always online or are not part of the re
## What's next ?
- [**Disk Encryption**](./disk-encryption.md): Configure disk encryption with remote decryption
- [**Mesh VPN**](./mesh-vpn.md): Configuring a secure mesh network.
---

View File

@@ -0,0 +1,301 @@
## Setting up Encryption with Remote Decryption in NixOS
This guide provides an example setup for a single-disk ZFS system with native encryption, accessible for decryption remotely. This configuration only applies to `systemd-boot` enabled systems and requires UEFI booting.
For a mirrored disk setup, add `mode = "mirror";` to `zroot`. Under the `disk` option, provide the additional disk identifier, e.g., `y = mirrorBoot /dev/disk/by-id/<second_disk_id>`.
Replace the disk `nvme-eui.002538b931b59865` with your own.
Below is the configuration for `disko.nix`
```nix
{ lib, ... }:
let
mirrorBoot = idx: {
type = "disk";
device = "/dev/disk/by-id/${idx}";
content = {
type = "gpt";
partitions = {
boot = {
size = "1M";
type = "EF02"; # for grub MBR
priority = 1;
};
ESP = lib.mkIf (idx == "nvme-eui.002538b931b59865") {
size = "1G";
type = "EF00";
content = {
type = "filesystem";
format = "vfat";
mountpoint = "/boot";
mountOptions = [ "nofail" ];
};
};
zfs = {
size = "100%";
content = {
type = "zfs";
pool = "zroot";
};
};
};
};
};
in
{
boot.loader.systemd-boot.enable = true;
disko.devices = {
disk = {
x = mirrorBoot "nvme-eui.002538b931b59865";
};
zpool = {
zroot = {
type = "zpool";
rootFsOptions = {
compression = "lz4";
acltype = "posixacl";
xattr = "sa";
"com.sun:auto-snapshot" = "true";
mountpoint = "none";
};
datasets = {
"root" = {
type = "zfs_fs";
options = {
mountpoint = "none";
encryption = "aes-256-gcm";
keyformat = "passphrase";
keylocation = "file:///tmp/secret.key";
};
};
"root/nixos" = {
type = "zfs_fs";
options.mountpoint = "/";
mountpoint = "/";
};
"root/home" = {
type = "zfs_fs";
options.mountpoint = "/home";
mountpoint = "/home";
};
"root/tmp" = {
type = "zfs_fs";
mountpoint = "/tmp";
options = {
mountpoint = "/tmp";
sync = "disabled";
};
};
};
};
};
};
}
```
Add this to networking.nix and **replace** the `default` values as well as the name `gchq-local` and `networking.hostId` with your own.
```nix
{ config, lib, ... }:
{
options = {
networking.gchq-local.ipv4.address = lib.mkOption {
type = lib.types.str;
default = "192.168.178.177";
};
networking.gchq-local.ipv4.cidr = lib.mkOption {
type = lib.types.str;
default = "24";
};
networking.gchq-local.ipv4.gateway = lib.mkOption {
type = lib.types.str;
default = "192.168.178.1";
};
networking.gchq-local.ipv6.address = lib.mkOption {
type = lib.types.str;
default = "2003:100:6701:d500:fbbc:40fb:cff3:3b87";
};
networking.gchq-local.ipv6.cidr = lib.mkOption {
type = lib.types.str;
default = "64";
};
networking.gchq-local.ipv6.gateway = lib.mkOption {
type = lib.types.str;
default = "fe80::3ea6:2fff:feef:3435";
};
};
config = {
networking.dhcpcd.enable = false;
networking.nameservers = [ "127.0.0.1" ];
networking.hostId = "a76ebcca"; # Needs to be unique for each host
# The '10' in the network name is the priority, so this will be the first network to be configured
systemd.network.networks."10-eth" = {
matchConfig.Type = "ether";
addresses = [
{
Address=config.networking.gchq-local.ipv4.address + "/" + config.networking.gchq-local.ipv4.cidr;
}
{
Address=config.networking.gchq-local.ipv6.address + "/" + config.networking.gchq-local.ipv6.cidr;
}
];
DHCP = "yes";
};
};
}
```
Put this into initrd.nix and add your pubkey to `authorizedKeys`.
Replace `kernelModules` with the ethernet module loaded one on your system.
```nix
{config, pkgs, ...}:
{
boot.initrd.systemd = {
enable = true;
network.networks."10-eth" = config.systemd.network.networks."10-eth";
};
boot.initrd.network = {
enable = true;
ssh = {
enable = true;
port = 7172;
authorizedKeys = [ "<yourkey>" ];
hostKeys = [
"/var/lib/initrd-ssh-key"
];
};
};
boot.initrd.availableKernelModules = [
"xhci_pci"
];
# Check the network card by running `lspci -k` on the target machine
boot.initrd.kernelModules = [ "r8169" ];
}
```
### Step 1: Copying SSH Public Key
Before starting the installation process, ensure that the SSH public key is copied to the NixOS installer.
1. Copy your public SSH key to the installer, if it has not been copied already:
```bash
ssh-copy-id -o PreferredAuthentications=password -o PubkeyAuthentication=no root@nixos-installer.local
```
### Step 1.5: Prepare Secret Key and Clear Disk Data
1. Access the installer using SSH:
```bash
ssh root@nixos-installer.local
```
2. Create a `secret.key` file in `/tmp` using `nano` or another text editor:
```bash
nano /tmp/secret.key
```
3. Discard the old disk partition data:
```bash
blkdiscard /dev/disk/by-id/nvme-eui.002538b931b59865
```
4. Run the `clan` machine installation with the following command:
```bash
clan machines install gchq-local root@nixos-installer --yes --no-reboot
```
### Step 2: ZFS Pool Import and System Installation
1. SSH into the installer once again:
```bash
ssh root@nixos-installer.local
```
2. Perform the following commands on the remote installation environment:
```bash
zpool import zroot
zfs set keystate=prompt zroot/root
zfs load-key zroot/root
zfs set mountpoint=/mnt zroot/root/nixos
mount /dev/nvme0n1p2 /mnt/boot
```
3. Disconnect from the SSH session:
```bash
CTRL+D
```
4. Securely copy your local `initrd_rsa_key` to the installer's `/mnt` directory:
```bash
scp ~/.ssh/initrd_rsa_key root@nixos-installer.local:/mnt/var/lib/initrd-ssh-key
```
5. SSH back into the installer:
```bash
ssh root@nixos-installer.local
```
6. Navigate to the `/mnt` directory, enter the `nixos-enter` environment, and then exit:
```bash
cd /mnt
nixos-enter
realpath /run/current-system
exit
```
7. Run the `nixos-install` command with the appropriate system path `<SYS_PATH>`:
```bash
nixos-install --no-root-passwd --no-channel-copy --root /mnt --system <SYS_PATH>
```
8. After the installation process, unmount `/mnt/boot`, change the ZFS mountpoint, and reboot the system:
```bash
umount /mnt/boot
cd /
zfs set mountpoint=/ zroot/root/nixos
reboot
```
9. Perform a hard reboot of the machine and remove the USB stick.
### Step 3: Accessing the Initial Ramdisk (initrd) Environment
1. SSH into the initrd environment using the `initrd_rsa_key` and provided port:
```bash
ssh -p 7172 root@192.168.178.141
```
2. Run the `systemd-tty-ask-password-agent` utility to query a password:
```bash
systemd-tty-ask-password-agent --query
```
After completing these steps, your NixOS should be successfully installed and ready for use.
**Note:** Replace `root@nixos-installer.local` and `192.168.178.141` with the appropriate user and IP addresses for your setup. Also, adjust `<SYS_PATH>` to reflect the correct system path for your environment.

View File

@@ -49,7 +49,7 @@ sudo umount /dev/sdb1
clan flash --flake git+https://git.clan.lol/clan/clan-core \
--ssh-pubkey $HOME/.ssh/id_ed25519.pub \
--keymap us \
--language en_US.utf-8 \
--language en_US.UTF-8 \
--disk main /dev/sd<X> \
flash-installer
```

View File

@@ -1,10 +1,10 @@
@font-face {
font-family: "Roboto";
src: url(./Roboto-Regular.ttf) format('truetype');
src: url(./Roboto-Regular.ttf) format("truetype");
}
@font-face {
font-family: "Fira Code";
src: url(./FiraCode-VF.ttf) format('truetype');
src: url(./FiraCode-VF.ttf) format("truetype");
}
:root {

52
flake.lock generated
View File

@@ -7,11 +7,11 @@
]
},
"locked": {
"lastModified": 1721417620,
"narHash": "sha256-6q9b1h8fI3hXg2DG6/vrKWCeG8c5Wj2Kvv22RCgedzg=",
"lastModified": 1723080788,
"narHash": "sha256-C5LbM5VMdcolt9zHeLQ0bYMRjUL+N+AL5pK7/tVTdes=",
"owner": "nix-community",
"repo": "disko",
"rev": "bec6e3cde912b8acb915fecdc509eda7c973fb42",
"rev": "ffc1f95f6c28e1c6d1e587b51a2147027a3e45ed",
"type": "github"
},
"original": {
@@ -27,11 +27,11 @@
]
},
"locked": {
"lastModified": 1719994518,
"narHash": "sha256-pQMhCCHyQGRzdfAkdJ4cIWiw+JNuWsTX7f0ZYSyz0VY=",
"lastModified": 1722555600,
"narHash": "sha256-XOQkdLafnb/p9ij77byFQjDf5m5QYl9b2REiVClC+x4=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "9227223f6d922fee3c7b190b2cc238a99527bbb7",
"rev": "8471fe90ad337a8074e957b69ca4d0089218391d",
"type": "github"
},
"original": {
@@ -48,11 +48,11 @@
]
},
"locked": {
"lastModified": 1721571445,
"narHash": "sha256-2MnlPVcNJZ9Nbu90kFyo7+lng366gswErP4FExfrUbc=",
"lastModified": 1724028934,
"narHash": "sha256-2M5dqS7UbAKfrO+1U+P/t5S2QIGbuGIsTNMYJzwB17g=",
"owner": "nix-community",
"repo": "nixos-images",
"rev": "accee005735844d57b411d9969c5d0aabc6a55f6",
"rev": "b733f0680a42cc01d6ad53896fb5ca40a66d5e79",
"type": "github"
},
"original": {
@@ -63,11 +63,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1721571961,
"narHash": "sha256-jfF4gpRUpTBY2OxDB0FRySsgNGOiuDckEtu7YDQom3Y=",
"lastModified": 1724137240,
"narHash": "sha256-VjbV/91spoYpl+fD7cK1asDhQIjJduP0lT+SgeXtcIc=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "4cc8b29327bed3d52b40041f810f49734298af46",
"rev": "d2fa2514f041934a6aa261c66dc44829251cffd3",
"type": "github"
},
"original": {
@@ -84,6 +84,7 @@
"nixos-images": "nixos-images",
"nixpkgs": "nixpkgs",
"sops-nix": "sops-nix",
"systems": "systems",
"treefmt-nix": "treefmt-nix"
}
},
@@ -95,11 +96,11 @@
"nixpkgs-stable": []
},
"locked": {
"lastModified": 1721531171,
"narHash": "sha256-AsvPw7T0tBLb53xZGcUC3YPqlIpdxoSx56u8vPCr6gU=",
"lastModified": 1723501126,
"narHash": "sha256-N9IcHgj/p1+2Pvk8P4Zc1bfrMwld5PcosVA0nL6IGdE=",
"owner": "Mic92",
"repo": "sops-nix",
"rev": "909e8cfb60d83321d85c8d17209d733658a21c95",
"rev": "be0eec2d27563590194a9206f551a6f73d52fa34",
"type": "github"
},
"original": {
@@ -108,6 +109,21 @@
"type": "github"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"treefmt-nix": {
"inputs": {
"nixpkgs": [
@@ -115,11 +131,11 @@
]
},
"locked": {
"lastModified": 1721458737,
"narHash": "sha256-wNXLQ/ATs1S4Opg1PmuNoJ+Wamqj93rgZYV3Di7kxkg=",
"lastModified": 1723808491,
"narHash": "sha256-rhis3qNuGmJmYC/okT7Dkc4M8CeUuRCSvW6kC2f3hBc=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "888bfb10a9b091d9ed2f5f8064de8d488f7b7c97",
"rev": "1d07739554fdc4f8481068f1b11d6ab4c1a4167a",
"type": "github"
},
"original": {

View File

@@ -14,12 +14,18 @@
nixos-images.inputs.nixos-stable.follows = "";
flake-parts.url = "github:hercules-ci/flake-parts";
flake-parts.inputs.nixpkgs-lib.follows = "nixpkgs";
systems.url = "github:nix-systems/default";
treefmt-nix.url = "github:numtide/treefmt-nix";
treefmt-nix.inputs.nixpkgs.follows = "nixpkgs";
};
outputs =
inputs@{ flake-parts, self, ... }:
inputs@{
flake-parts,
self,
systems,
...
}:
flake-parts.lib.mkFlake { inherit inputs; } (
{ ... }:
{
@@ -27,11 +33,7 @@
meta.name = "clan-core";
directory = self;
};
systems = [
"x86_64-linux"
"aarch64-linux"
"aarch64-darwin"
];
systems = import systems;
imports = [
./checks/flake-module.nix
./clanModules/flake-module.nix

View File

@@ -3,126 +3,34 @@ clan-core:
config,
lib,
flake-parts-lib,
inputs,
self,
inputs,
...
}:
let
inherit (lib) mkOption types;
buildClan = import ../lib/build-clan {
inherit lib clan-core;
inherit (inputs) nixpkgs;
};
cfg = config.clan;
inherit (lib) types;
in
{
imports = [
# TODO: figure out how to print the deprecation warning
# "${inputs.nixpkgs}/nixos/modules/misc/assertions.nix"
(lib.mkRenamedOptionModule
[
"clan"
"clanName"
]
[
"clan"
"meta"
"name"
]
)
(lib.mkRenamedOptionModule
[
"clan"
"clanIcon"
]
[
"clan"
"meta"
"icon"
]
)
options.clan = lib.mkOption {
type = types.submoduleWith {
specialArgs = {
inherit clan-core self;
inherit (inputs) nixpkgs;
};
modules = [
../lib/build-clan/interface.nix
../lib/build-clan/module.nix
];
options.clan = {
directory = mkOption {
type = types.path;
description = "The directory containing the clan subdirectory";
default = self; # default to the directory of the flake
};
specialArgs = mkOption {
type = types.attrsOf types.raw;
default = { };
description = "Extra arguments to pass to nixosSystem i.e. useful to make self available";
};
machines = mkOption {
type = types.attrsOf types.raw;
default = { };
description = "Allows to include machine-specific modules i.e. machines.\${name} = { ... }";
};
inventory = mkOption {
#type = types.submodule { imports = [ ../lib/inventory/build-inventory/interface.nix ]; };
type = types.attrsOf types.raw;
default = { };
description = ''
An abstract service layer for consistently configuring distributed services across machine boundaries.
See https://docs.clan.lol/concepts/inventory/ for more details.
'';
};
# Checks are performed in 'buildClan'
# Not everyone uses flake-parts
meta = {
name = lib.mkOption {
type = types.nullOr types.str;
default = null;
description = "Needs to be (globally) unique, as this determines the folder name where the flake gets downloaded to.";
};
icon = mkOption {
type = types.nullOr types.path;
default = null;
description = "A path to an icon to be used for the clan in the GUI";
};
description = mkOption {
type = types.nullOr types.str;
default = null;
description = "A short description of the clan";
};
};
pkgsForSystem = mkOption {
type = types.functionTo types.raw;
default = _system: null;
description = "A map from arch to pkgs, if specified this nixpkgs will be only imported once for each system.";
};
};
options.flake = flake-parts-lib.mkSubmoduleOptions {
clanInternals = lib.mkOption {
type = lib.types.submodule {
options = {
inventory = lib.mkOption { type = lib.types.attrsOf lib.types.unspecified; };
inventoryFile = lib.mkOption { type = lib.types.unspecified; };
clanModules = lib.mkOption { type = lib.types.attrsOf lib.types.path; };
source = lib.mkOption { type = lib.types.path; };
meta = lib.mkOption { type = lib.types.attrsOf lib.types.unspecified; };
all-machines-json = lib.mkOption { type = lib.types.attrsOf lib.types.unspecified; };
machines = lib.mkOption { type = lib.types.attrsOf (lib.types.attrsOf lib.types.unspecified); };
machinesFunc = lib.mkOption { type = lib.types.attrsOf (lib.types.attrsOf lib.types.unspecified); };
};
};
};
clanInternals = lib.mkOption { type = types.raw; };
};
config = {
flake = buildClan {
inherit (cfg)
directory
specialArgs
machines
pkgsForSystem
meta
inventory
;
};
flake.clanInternals = config.clan.clanInternals;
flake.nixosConfigurations = config.clan.nixosConfigurations;
};
_file = __curPos.file;
}

View File

@@ -11,7 +11,42 @@
treefmt.programs.nixfmt.enable = true;
treefmt.programs.nixfmt.package = pkgs.nixfmt-rfc-style;
treefmt.programs.deadnix.enable = true;
treefmt.settings.global.excludes = [
"*.png"
"*.jpeg"
"*.gitignore"
".vscode/*"
"*.toml"
"*.clan-flake"
"*.code-workspace"
"*.pub"
"*.typed"
"*.age"
"*.list"
"*.desktop"
];
treefmt.programs.prettier = {
enable = true;
includes = [
"*.cjs"
"*.css"
"*.html"
"*.js"
"*.json5"
"*.jsx"
"*.mdx"
"*.mjs"
"*.scss"
"*.ts"
"*.tsx"
"*.vue"
"*.yaml"
"*.yml"
];
# plugins = [
# "${self'.packages.prettier-plugin-tailwindcss}/lib/node_modules/prettier-plugin-tailwindcss/dist/index.mjs"
# ];
};
treefmt.programs.mypy.directories =
{
"pkgs/clan-cli" = {
@@ -20,8 +55,8 @@
};
"pkgs/clan-app" = {
extraPythonPackages =
# clan-app currently only exists on linux
(self'.packages.clan-app.externalTestDeps or [ ]) ++ self'.packages.clan-cli.testDependencies;
extraPythonPaths = [ "../clan-cli" ];
modules = [ "clan_app" ];
};
}
@@ -30,8 +65,8 @@
{
"pkgs/clan-vm-manager" = {
extraPythonPackages =
# # clan-app currently only exists on linux
self'.packages.clan-vm-manager.testDependencies;
self'.packages.clan-vm-manager.externalTestDeps ++ self'.packages.clan-cli.testDependencies;
extraPythonPaths = [ "../clan-cli" ];
modules = [ "clan_vm_manager" ];
};
}
@@ -41,53 +76,5 @@
treefmt.programs.ruff.check = true;
treefmt.programs.ruff.format = true;
# FIXME: currently broken in CI
#treefmt.settings.formatter.vale =
# let
# vocab = "cLAN";
# style = "Docs";
# config = pkgs.writeText "vale.ini" ''
# StylesPath = ${styles}
# Vocab = ${vocab}
# [*.md]
# BasedOnStyles = Vale, ${style}
# Vale.Terms = No
# '';
# styles = pkgs.symlinkJoin {
# name = "vale-style";
# paths = [
# accept
# headings
# ];
# };
# accept = pkgs.writeTextDir "config/vocabularies/${vocab}/accept.txt" ''
# Nix
# NixOS
# Nixpkgs
# clan.lol
# Clan
# monorepo
# '';
# headings = pkgs.writeTextDir "${style}/headings.yml" ''
# extends: capitalization
# message: "'%s' should be in sentence case"
# level: error
# scope: heading
# # $title, $sentence, $lower, $upper, or a pattern.
# match: $sentence
# '';
# in
# {
# command = "${pkgs.vale}/bin/vale";
# options = [ "--config=${config}" ];
# includes = [ "*.md" ];
# # TODO: too much at once, fix piecemeal
# excludes = [
# "docs/*"
# "clanModules/*"
# "pkgs/*"
# ];
# };
};
}

View File

@@ -1,306 +1,39 @@
## WARNING: Do not add core logic here.
## This is only a wrapper such that buildClan can be called as a function.
## Add any logic to ./module.nix
{
clan-core,
nixpkgs,
lib,
nixpkgs,
clan-core,
}:
{
directory, # The directory containing the machines subdirectory
specialArgs ? { }, # Extra arguments to pass to nixosSystem i.e. useful to make self available
machines ? { }, # allows to include machine-specific modules i.e. machines.${name} = { ... }
# DEPRECATED: use meta.name instead
clanName ? null, # Needs to be (globally) unique, as this determines the folder name where the flake gets downloaded to.
# DEPRECATED: use meta.icon instead
clanIcon ? null, # A path to an icon to be used for the clan, should be the same for all machines
meta ? { }, # A set containing clan meta: name :: string, icon :: string, description :: string
## Inputs
directory, # The directory containing the machines subdirectory # allows to include machine-specific modules i.e. machines.${name} = { ... }
# A map from arch to pkgs, if specified this nixpkgs will be only imported once for each system.
# This improves performance, but all nipxkgs.* options will be ignored.
pkgsForSystem ? (_system: null),
/*
Low level inventory configuration.
Overrides the services configuration.
*/
# deadnix: skip
inventory ? { },
}:
## Sepcial inputs (not passed to the module system as config)
specialArgs ? { }, # Extra arguments to pass to nixosSystem i.e. useful to make self available # A set containing clan meta: name :: string, icon :: string, description :: string
##
...
}@attrs:
let
# Internal inventory, this is the result of merging all potential inventory sources:
# - Default instances configured via 'services'
# - The inventory overrides
# - Machines that exist in inventory.machines
# - Machines explicitly configured via 'machines' argument
# - Machines that exist in the machines directory
# Checks on the module level:
# - Each service role must reference a valid machine after all machines are merged
clanToInventory =
config:
{ clanPath, inventoryPath }:
let
v = lib.attrByPath clanPath null config;
in
lib.optionalAttrs (v != null) (lib.setAttrByPath inventoryPath v);
mergedInventory =
(lib.evalModules {
modules = [
clan-core.lib.inventory.interface
{ inherit meta; }
(
if
builtins.pathExists "${directory}/inventory.json"
# Is recursively applied. Any explicit nix will override.
then
(builtins.fromJSON (builtins.readFile "${directory}/inventory.json"))
else
{ }
)
inventory
# Machines explicitly configured via 'machines' argument
{
# { ${name} :: meta // { name, tags } }
machines = lib.mapAttrs (
name: machineConfig:
(lib.attrByPath [
"clan"
"meta"
] { } machineConfig)
// {
# meta.name default is the attribute name of the machine
name = lib.mkDefault (
lib.attrByPath [
"clan"
"meta"
"name"
] name machineConfig
);
}
# tags
// (clanToInventory machineConfig {
clanPath = [
"clan"
"tags"
];
inventoryPath = [ "tags" ];
})
# system
// (clanToInventory machineConfig {
clanPath = [
"nixpkgs"
"hostPlatform"
];
inventoryPath = [ "system" ];
})
# deploy.targetHost
// (clanToInventory machineConfig {
clanPath = [
"clan"
"core"
"networking"
"targetHost"
];
inventoryPath = [
"deploy"
"targetHost"
];
})
) machines;
}
# Will be deprecated
{
machines =
lib.mapAttrs
(
name: _:
# Use mkForce to make sure users migrate to the inventory system.
# When the settings.json exists the evaluation will print the deprecation warning.
lib.mkForce {
inherit name;
system = (machineSettings name).nixpkgs.hostSystem or null;
}
)
(
lib.filterAttrs (
machineName: _: builtins.pathExists "${directory}/machines/${machineName}/settings.json"
) machinesDirs
);
}
# Deprecated interface
(if clanName != null then { meta.name = clanName; } else { })
(if clanIcon != null then { meta.icon = clanIcon; } else { })
];
}).config;
inherit (clan-core.lib.inventory) buildInventory;
# map from machine name to service configuration
# { ${machineName} :: Config }
serviceConfigs = buildInventory {
inventory = mergedInventory;
inherit directory;
eval = import ./eval.nix {
inherit
lib
nixpkgs
specialArgs
clan-core
;
self = directory;
};
machinesDirs = lib.optionalAttrs (builtins.pathExists "${directory}/machines") (
builtins.readDir (directory + /machines)
);
machineSettings =
machineName:
let
warn = lib.warn ''
The use of ./machines/<machine>/settings.json is deprecated.
If your settings.json is empty, you can safely remove it.
!!! Consider using the inventory system. !!!
File: ${directory + /machines/${machineName}/settings.json}
If there are still features missing in the inventory system, please open an issue on the clan-core repository.
'';
rest = builtins.removeAttrs attrs [ "specialArgs" ];
in
# CLAN_MACHINE_SETTINGS_FILE allows to override the settings file temporarily
# This is useful for doing a dry-run before writing changes into the settings.json
# Using CLAN_MACHINE_SETTINGS_FILE requires passing --impure to nix eval
if builtins.getEnv "CLAN_MACHINE_SETTINGS_FILE" != "" then
warn (builtins.fromJSON (builtins.readFile (builtins.getEnv "CLAN_MACHINE_SETTINGS_FILE")))
else
lib.optionalAttrs (builtins.pathExists "${directory}/machines/${machineName}/settings.json") (
warn (builtins.fromJSON (builtins.readFile (directory + /machines/${machineName}/settings.json)))
);
machineImports =
machineSettings: map (module: clan-core.clanModules.${module}) (machineSettings.clanImports or [ ]);
deprecationWarnings = [
(lib.warnIf (
clanName != null
) "clanName in buildClan is deprecated, please use meta.name instead." null)
(lib.warnIf (clanIcon != null) "clanIcon is deprecated, please use meta.icon instead" null)
];
# TODO: remove default system once we have a hardware-config mechanism
nixosConfiguration =
{
system ? "x86_64-linux",
name,
pkgs ? null,
extraConfig ? { },
}:
nixpkgs.lib.nixosSystem {
modules =
let
settings = machineSettings name;
in
(machineImports settings)
++ [
{
# Autoinclude configuration.nix and hardware-configuration.nix
imports = builtins.filter (p: builtins.pathExists p) [
"${directory}/machines/${name}/configuration.nix"
"${directory}/machines/${name}/hardware-configuration.nix"
eval {
imports = [
rest
# implementation
./module.nix
];
}
settings
clan-core.nixosModules.clanCore
extraConfig
(machines.${name} or { })
# Inherit the inventory assertions ?
{ inherit (mergedInventory) assertions; }
{ imports = serviceConfigs.${name} or { }; }
(
{
# Settings
clan.core.clanDir = directory;
# Inherited from clan wide settings
clan.core.clanName = meta.name or clanName;
clan.core.clanIcon = meta.icon or clanIcon;
# Machine specific settings
clan.core.machineName = name;
networking.hostName = lib.mkDefault name;
nixpkgs.hostPlatform = lib.mkDefault system;
# speeds up nix commands by using the nixpkgs from the host system (especially useful in VMs)
nix.registry.nixpkgs.to = {
type = "path";
path = lib.mkDefault nixpkgs;
};
}
// lib.optionalAttrs (pkgs != null) { nixpkgs.pkgs = lib.mkForce pkgs; }
)
];
specialArgs = {
inherit clan-core;
} // specialArgs;
};
allMachines = mergedInventory.machines or { };
supportedSystems = [
"x86_64-linux"
"aarch64-linux"
"riscv64-linux"
"x86_64-darwin"
"aarch64-darwin"
];
nixosConfigurations = lib.mapAttrs (name: _: nixosConfiguration { inherit name; }) allMachines;
# This instantiates nixos for each system that we support:
# configPerSystem = <system>.<machine>.nixosConfiguration
# We need this to build nixos secret generators for each system
configsPerSystem = builtins.listToAttrs (
builtins.map (
system:
lib.nameValuePair system (
lib.mapAttrs (
name: _:
nixosConfiguration {
inherit name system;
pkgs = pkgsForSystem system;
}
) allMachines
)
) supportedSystems
);
configsFuncPerSystem = builtins.listToAttrs (
builtins.map (
system:
lib.nameValuePair system (
lib.mapAttrs (
name: _: args:
nixosConfiguration (
args
// {
inherit name system;
pkgs = pkgsForSystem system;
}
)
) allMachines
)
) supportedSystems
);
in
builtins.deepSeq deprecationWarnings {
inherit nixosConfigurations;
clanInternals = {
inherit (clan-core) clanModules;
source = "${clan-core}";
meta = mergedInventory.meta;
inventory = mergedInventory;
inventoryFile = "${directory}/inventory.json";
# machine specifics
machines = configsPerSystem;
machinesFunc = configsFuncPerSystem;
all-machines-json = lib.mapAttrs (
system: configs:
nixpkgs.legacyPackages.${system}.writers.writeJSON "machines.json" (
lib.mapAttrs (_: m: m.config.system.clan.deployment.data) configs
)
) configsPerSystem;
};
}

19
lib/build-clan/eval.nix Normal file
View File

@@ -0,0 +1,19 @@
{
lib,
nixpkgs,
clan-core,
specialArgs ? { },
self,
}:
# Returns a function that takes self, which should point to the directory of the flake
module:
(lib.evalModules {
specialArgs = {
inherit self clan-core nixpkgs;
};
modules = [
./interface.nix
module
{ inherit specialArgs; }
];
}).config

View File

@@ -0,0 +1,41 @@
{ self, inputs, ... }:
let
inputOverrides = builtins.concatStringsSep " " (
builtins.map (input: " --override-input ${input} ${inputs.${input}}") (builtins.attrNames inputs)
);
in
{
perSystem =
{
pkgs,
lib,
system,
...
}:
# let
# in
{
# Run: nix-unit --extra-experimental-features flakes --flake .#legacyPackages.x86_64-linux.evalTests
legacyPackages.evalTests-build-clan = import ./tests.nix {
inherit lib;
inherit (inputs) nixpkgs;
clan-core = self;
buildClan = self.lib.buildClan;
};
checks = {
lib-build-clan-eval = pkgs.runCommand "tests" { nativeBuildInputs = [ pkgs.nix-unit ]; } ''
export HOME="$(realpath .)"
nix-unit --eval-store "$HOME" \
--extra-experimental-features flakes \
${inputOverrides} \
--flake ${self}#legacyPackages.${system}.evalTests-build-clan
touch $out
'';
};
};
}

View File

@@ -0,0 +1,74 @@
{ lib, self, ... }:
let
types = lib.types;
in
{
options = {
# Required options
directory = lib.mkOption {
type = types.path;
default = self;
description = "The directory containing the clan subdirectory";
};
specialArgs = lib.mkOption {
type = types.attrsOf types.raw;
default = { };
description = "Extra arguments to pass to nixosSystem i.e. useful to make self available";
};
# Optional
machines = lib.mkOption {
type = types.attrsOf types.deferredModule;
default = { };
};
inventory = lib.mkOption {
type = types.submodule { imports = [ ../inventory/build-inventory/interface.nix ]; };
};
# Meta
meta = lib.mkOption {
type = types.nullOr (
types.submodule {
options = {
name = lib.mkOption {
type = types.nullOr types.str;
description = "Needs to be (globally) unique, as this determines the folder name where the flake gets downloaded to.";
};
};
}
);
default = null;
};
pkgsForSystem = lib.mkOption {
type = types.functionTo (types.nullOr types.attrs);
default = _: null;
};
# Outputs
nixosConfigurations = lib.mkOption {
type = types.lazyAttrsOf types.raw;
default = { };
};
# flake.clanInternals
clanInternals = lib.mkOption {
# type = types.raw;
# ClanInternals
type = types.submodule {
options = {
# Those options are interfaced by the CLI
# We don't speficy the type here, for better performance.
inventory = lib.mkOption { type = lib.types.raw; };
inventoryFile = lib.mkOption { type = lib.types.raw; };
clanModules = lib.mkOption { type = lib.types.raw; };
source = lib.mkOption { type = lib.types.raw; };
meta = lib.mkOption { type = lib.types.raw; };
all-machines-json = lib.mkOption { type = lib.types.raw; };
machines = lib.mkOption { type = lib.types.raw; };
machinesFunc = lib.mkOption { type = lib.types.raw; };
};
};
};
};
}

228
lib/build-clan/module.nix Normal file
View File

@@ -0,0 +1,228 @@
{
config,
clan-core,
nixpkgs,
lib,
...
}:
let
inherit (config)
directory
machines
pkgsForSystem
specialArgs
;
inherit (config.clanInternals) inventory;
inherit (clan-core.lib.inventory) buildInventory;
# map from machine name to service configuration
# { ${machineName} :: Config }
serviceConfigs = (
buildInventory {
inherit inventory;
inherit directory;
}
);
machineSettings =
machineName:
let
warn = lib.warn ''
The use of ./machines/<machine>/settings.json is deprecated.
If your settings.json is empty, you can safely remove it.
!!! Consider using the inventory system. !!!
File: ${directory + /machines/${machineName}/settings.json}
If there are still features missing in the inventory system, please open an issue on the clan-core repository.
'';
in
# CLAN_MACHINE_SETTINGS_FILE allows to override the settings file temporarily
# This is useful for doing a dry-run before writing changes into the settings.json
# Using CLAN_MACHINE_SETTINGS_FILE requires passing --impure to nix eval
if builtins.getEnv "CLAN_MACHINE_SETTINGS_FILE" != "" then
warn (builtins.fromJSON (builtins.readFile (builtins.getEnv "CLAN_MACHINE_SETTINGS_FILE")))
else
lib.optionalAttrs (builtins.pathExists "${directory}/machines/${machineName}/settings.json") (
warn (builtins.fromJSON (builtins.readFile (directory + /machines/${machineName}/settings.json)))
);
machineImports =
machineSettings: map (module: clan-core.clanModules.${module}) (machineSettings.clanImports or [ ]);
# TODO: remove default system once we have a hardware-config mechanism
nixosConfiguration =
{
system ? "x86_64-linux",
name,
pkgs ? null,
extraConfig ? { },
}:
nixpkgs.lib.nixosSystem {
modules =
let
settings = machineSettings name;
in
(machineImports settings)
++ [
{
# Autoinclude configuration.nix and hardware-configuration.nix
imports = builtins.filter builtins.pathExists [
"${directory}/machines/${name}/configuration.nix"
"${directory}/machines/${name}/hardware-configuration.nix"
];
}
settings
clan-core.nixosModules.clanCore
extraConfig
(machines.${name} or { })
# Inherit the inventory assertions ?
# { inherit (mergedInventory) assertions; }
{ imports = serviceConfigs.${name} or [ ]; }
(
{
# Settings
clan.core.clanDir = directory;
# Inherited from clan wide settings
# TODO: remove these
clan.core.name = config.inventory.meta.name;
clan.core.icon = config.inventory.meta.icon;
# Machine specific settings
clan.core.machineName = name;
networking.hostName = lib.mkDefault name;
nixpkgs.hostPlatform = lib.mkDefault system;
# speeds up nix commands by using the nixpkgs from the host system (especially useful in VMs)
nix.registry.nixpkgs.to = {
type = "path";
path = lib.mkDefault nixpkgs;
};
}
// lib.optionalAttrs (pkgs != null) { nixpkgs.pkgs = lib.mkForce pkgs; }
)
];
specialArgs = {
inherit clan-core;
} // specialArgs;
};
# TODO: Will be deprecated
# We must migrate the tests, that create a settings.json to add a machine.
##################################################
testMachines =
lib.mapAttrs
(name: _: {
inherit name;
system = (machineSettings name).nixpkgs.hostSystem or null;
})
(
lib.filterAttrs (
machineName: _:
if builtins.pathExists "${directory}/machines/${machineName}/settings.json" then
lib.warn ''
The use of ./machines/<machine>/settings.json is deprecated.
If your settings.json is empty, you can safely remove it.
!!! Consider using the inventory system. !!!
File: ${directory + /machines/${machineName}/settings.json}
If there are still features missing in the inventory system, please open an issue on the clan-core repository.
'' true
else
false
) machinesDirs
);
machinesDirs = lib.optionalAttrs (builtins.pathExists "${directory}/machines") (
builtins.readDir (directory + /machines)
);
##################################################
allMachines = inventory.machines or { } // config.machines or { } // testMachines;
supportedSystems = [
"x86_64-linux"
"aarch64-linux"
"riscv64-linux"
"x86_64-darwin"
"aarch64-darwin"
];
nixosConfigurations = lib.mapAttrs (name: _: nixosConfiguration { inherit name; }) allMachines;
# This instantiates nixos for each system that we support:
# configPerSystem = <system>.<machine>.nixosConfiguration
# We need this to build nixos secret generators for each system
configsPerSystem = builtins.listToAttrs (
builtins.map (
system:
lib.nameValuePair system (
lib.mapAttrs (
name: _:
nixosConfiguration {
inherit name system;
pkgs = pkgsForSystem system;
}
) allMachines
)
) supportedSystems
);
configsFuncPerSystem = builtins.listToAttrs (
builtins.map (
system:
lib.nameValuePair system (
lib.mapAttrs (
name: _: args:
nixosConfiguration (
args
// {
inherit name system;
pkgs = pkgsForSystem system;
}
)
) allMachines
)
) supportedSystems
);
inventoryFile = "${directory}/inventory.json";
inventoryLoaded =
if builtins.pathExists inventoryFile then
(builtins.fromJSON (builtins.readFile inventoryFile))
else
{ };
in
{
imports = [
# Merge the inventory file
{ inventory = inventoryLoaded; }
# Merge the meta attributes from the buildClan function
{ inventory.meta = if config.meta != null then config.meta else { }; }
];
inherit nixosConfigurations;
clanInternals = {
inherit (clan-core) clanModules;
inherit inventoryFile;
inventory = config.inventory;
meta = config.inventory.meta;
source = "${clan-core}";
# machine specifics
machines = configsPerSystem;
machinesFunc = configsFuncPerSystem;
all-machines-json = lib.mapAttrs (
system: configs:
nixpkgs.legacyPackages.${system}.writers.writeJSON "machines.json" (
lib.mapAttrs (_: m: m.config.system.clan.deployment.data) configs
)
) configsPerSystem;
};
}

153
lib/build-clan/tests.nix Normal file
View File

@@ -0,0 +1,153 @@
{
lib,
nixpkgs,
clan-core,
buildClan,
...
}:
let
evalClan = import ./eval.nix {
inherit lib nixpkgs clan-core;
self = ./.;
};
in
#######
{
test_only_required =
let
config = evalClan {
meta.name = "test";
imports = [ ./module.nix ];
};
in
{
expr = config.inventory ? meta;
expected = true;
};
test_all_simple =
let
config = evalClan {
directory = ./.;
machines = { };
inventory = {
meta.name = "test";
};
pkgsForSystem = _system: { };
};
in
{
expr = config ? inventory;
expected = true;
};
test_outputs_clanInternals =
let
config = evalClan {
imports = [
# What the user needs to specif
{
directory = ./.;
inventory.meta.name = "test";
}
./module.nix
# Explicit output, usually defined by flake-parts
{ options.nixosConfigurations = lib.mkOption { type = lib.types.raw; }; }
];
};
in
{
expr = config.clanInternals.meta;
expected = {
description = null;
icon = null;
name = "test";
};
};
test_fn_simple =
let
result = buildClan {
directory = ./.;
meta.name = "test";
};
in
{
expr = result.clanInternals.meta;
expected = {
description = null;
icon = null;
name = "test";
};
};
test_fn_extensiv_meta =
let
result = buildClan {
directory = ./.;
meta.name = "test";
meta.description = "test";
meta.icon = "test";
inventory.meta.name = "superclan";
inventory.meta.description = "description";
inventory.meta.icon = "icon";
};
in
{
expr = result.clanInternals.meta;
expectedError = {
type = "ThrownError";
msg = "";
};
};
test_fn_clan_core =
let
result = buildClan {
directory = ../../.;
meta.name = "test-clan-core";
};
in
{
expr = builtins.attrNames result.nixosConfigurations;
expected = [ "test-inventory-machine" ];
};
test_buildClan_all_machines =
let
result = buildClan {
directory = ./.;
meta.name = "test";
inventory.machines.machine1.meta.name = "machine1";
machines.machine2 = { };
};
in
{
expr = builtins.attrNames result.nixosConfigurations;
expected = [
"machine1"
"machine2"
];
};
test_buildClan_specialArgs =
let
result = buildClan {
directory = ./.;
meta.name = "test";
specialArgs.foo = "dream2nix";
machines.machine2 =
{ foo, ... }:
{
networking.hostName = foo;
};
};
in
{
expr = result.nixosConfigurations.machine2.config.networking.hostName;
expected = "dream2nix";
};
}

View File

@@ -6,7 +6,7 @@
}:
{
evalClanModules = import ./eval-clan-modules { inherit clan-core nixpkgs lib; };
buildClan = import ./build-clan { inherit clan-core lib nixpkgs; };
buildClan = import ./build-clan { inherit lib nixpkgs clan-core; };
facts = import ./facts.nix { inherit lib; };
inventory = import ./inventory { inherit lib clan-core; };
jsonschema = import ./jsonschema { inherit lib; };

View File

@@ -12,7 +12,7 @@ let
imports = (import (pkgs.path + "/nixos/modules/module-list.nix")) ++ [
{
nixpkgs.hostPlatform = "x86_64-linux";
clan.core.clanName = "dummy";
clan.core.name = "dummy";
}
];
};

View File

@@ -8,6 +8,7 @@
imports = [
./jsonschema/flake-module.nix
./inventory/flake-module.nix
./build-clan/flake-module.nix
];
flake.lib = import ./default.nix {
inherit lib inputs;

View File

@@ -0,0 +1,77 @@
# Integrity validation of the inventory
{ config, lib, ... }:
{
# Assertion must be of type
# { assertion :: bool, message :: string, severity :: "error" | "warning" }
imports = [
# Check that each machine used in a service is defined in the top-level machines
{
assertions = lib.foldlAttrs (
ass1: serviceName: c:
ass1
++ lib.foldlAttrs (
ass2: instanceName: instanceConfig:
let
topLevelMachines = lib.attrNames config.machines;
# All machines must be defined in the top-level machines
assertions = lib.foldlAttrs (
assertions: roleName: role:
assertions
++ builtins.filter (a: !a.assertion) (
builtins.map (m: {
assertion = builtins.elem m topLevelMachines;
message = ''
Machine '${m}' is not defined in the inventory. This might still work, if the machine is defined via nix.
Defined in service: '${serviceName}' instance: '${instanceName}' role: '${roleName}'.
Inventory machines:
${builtins.concatStringsSep "\n" (map (n: "'${n}'") topLevelMachines)}
'';
severity = "warning";
}) role.machines
)
) [ ] instanceConfig.roles;
in
ass2 ++ assertions
) [ ] c
) [ ] config.services;
}
# Check that each tag used in a role is defined in at least one machines tags
{
assertions = lib.foldlAttrs (
ass1: serviceName: c:
ass1
++ lib.foldlAttrs (
ass2: instanceName: instanceConfig:
let
allTags = lib.foldlAttrs (
tags: _machineName: machine:
tags ++ machine.tags
) [ ] config.machines;
# All machines must be defined in the top-level machines
assertions = lib.foldlAttrs (
assertions: roleName: role:
assertions
++ builtins.filter (a: !a.assertion) (
builtins.map (m: {
assertion = builtins.elem m allTags;
message = ''
Tag '${m}' is not defined in the inventory.
Defined in service: '${serviceName}' instance: '${instanceName}' role: '${roleName}'.
Available tags:
${builtins.concatStringsSep "\n" (map (n: "'${n}'") allTags)}
'';
severity = "error";
}) role.tags
)
) [ ] instanceConfig.roles;
in
ass2 ++ assertions
) [ ] c
) [ ] config.services;
}
];
}

View File

@@ -1,10 +1,7 @@
# Generate partial NixOS configurations for every machine in the inventory
# This function is responsible for generating the module configuration for every machine in the inventory.
{ lib, clan-core }:
{ inventory, directory }:
let
machines = machinesFromInventory inventory;
resolveTags =
# Inventory, { machines :: [string], tags :: [string] }
{
@@ -45,8 +42,41 @@ let
machinesFromInventory :: Inventory -> { ${machine_name} :: NixOSConfiguration }
*/
machinesFromInventory =
# { client_1_machine = { tags = [ "backup" ]; }; client_2_machine = { tags = [ "backup" ]; }; not_used_machine = { }; }
getAllMachines =
inventory:
lib.foldlAttrs (
res: serviceName: serviceConfigs:
(lib.foldlAttrs (
res: instanceName: serviceConfig:
lib.foldlAttrs (
res: roleName: members:
let
resolved = resolveTags {
inherit
serviceName
instanceName
roleName
inventory
members
;
};
in
res
// builtins.listToAttrs (
builtins.map (m: {
name = m;
value = { };
}) resolved.machines
)
) res serviceConfig.roles
) res serviceConfigs)
) { } (inventory.services or { })
// inventory.machines or { };
buildInventory =
{ inventory, directory }:
# For every machine in the inventory, build a NixOS configuration
# For each machine generate config, forEach service, if the machine is used.
builtins.mapAttrs (
@@ -152,6 +182,8 @@ let
config.clan.core.networking.targetHost = machineConfig.deploy.targetHost;
})
]
) inventory.machines or { };
) (getAllMachines inventory);
in
machines
{
inherit buildInventory getAllMachines;
}

View File

@@ -51,6 +51,7 @@ let
};
in
{
imports = [ ./assertions.nix ];
options = {
assertions = lib.mkOption {
type = types.listOf types.unspecified;
@@ -126,39 +127,4 @@ in
);
};
};
# Smoke validation of the inventory
config.assertions =
let
# Inventory assertions
# - All referenced machines must exist in the top-level machines
serviceAssertions = lib.foldlAttrs (
ass1: serviceName: c:
ass1
++ lib.foldlAttrs (
ass2: instanceName: instanceConfig:
let
serviceMachineNames = lib.attrNames instanceConfig.machines;
topLevelMachines = lib.attrNames config.machines;
# All machines must be defined in the top-level machines
assertions = builtins.map (m: {
assertion = builtins.elem m topLevelMachines;
message = "${serviceName}.${instanceName}.machines.${m}. Should be one of [ ${builtins.concatStringsSep " | " topLevelMachines} ]";
}) serviceMachineNames;
in
ass2 ++ assertions
) [ ] c
) [ ] config.services;
# Machine assertions
# - A machine must define their host system
machineAssertions = map (
{ name, ... }:
{
assertion = true;
message = "Machine ${name} should define its host system in the inventory. ()";
}
) (lib.attrsToList (lib.filterAttrs (_n: v: v.system or null == null) config.machines));
in
machineAssertions ++ serviceAssertions;
}

View File

@@ -1,5 +1,5 @@
{ lib, clan-core }:
{
buildInventory = import ./build-inventory { inherit lib clan-core; };
inherit (import ./build-inventory { inherit lib clan-core; }) buildInventory;
interface = ./build-inventory/interface.nix;
}

View File

@@ -17,10 +17,12 @@ in
...
}:
let
buildInventory = import ./build-inventory {
inventory = (
import ./build-inventory {
clan-core = self;
inherit lib;
};
}
);
getSchema = import ./interface-to-schema.nix { inherit lib self; };
@@ -98,7 +100,7 @@ in
# Run: nix-unit --extra-experimental-features flakes --flake .#legacyPackages.x86_64-linux.evalTests
legacyPackages.evalTests-inventory = import ./tests {
inherit buildInventory;
inherit inventory;
clan-core = self;
};

View File

@@ -1,5 +1,37 @@
{ buildInventory, clan-core, ... }:
{ inventory, clan-core, ... }:
let
inherit (inventory) buildInventory getAllMachines;
in
{
test_get_all_used_machines = {
# Test that all machines are returned
expr = getAllMachines {
machines = {
machine_3 = {
tags = [ "tag_3" ];
};
};
services = {
borgbackup.instance_1 = {
roles.server.machines = [ "backup_server" ];
roles.client.machines = [
"client_1_machine"
"client_2_machine"
];
roles.client.tags = [ "tag_3" ];
};
};
};
expected = {
backup_server = { };
client_1_machine = { };
client_2_machine = { };
machine_3 = {
tags = [ "tag_3" ];
};
};
};
test_inventory_empty = {
# Empty inventory should return an empty module
expr = buildInventory {

View File

@@ -1,17 +1,16 @@
{ lib, pkgs, ... }:
{
# use latest kernel we can support to get more hardware support
boot.kernelPackages =
lib.mkForce
(pkgs.zfs.override { removeLinuxDRM = pkgs.hostPlatform.isAarch64; }).latestCompatibleLinuxPackages;
boot.zfs.removeLinuxDRM = lib.mkDefault pkgs.hostPlatform.isAarch64;
lib,
pkgs,
config,
...
}:
{
# If we also need zfs, we can use the unstable version as we otherwise don't have a new enough kernel version
boot.zfs.package = pkgs.zfsUnstable;
boot.kernelPackages = lib.mkIf config.boot.zfs.enabled (
lib.mkForce config.boot.zfs.package.latestCompatibleLinuxPackages
);
# Enable bcachefs support
boot.supportedFilesystems.bcachefs = lib.mkDefault true;
environment.systemPackages = with pkgs; [
bcachefs-tools
keyutils
];
}

View File

@@ -78,7 +78,7 @@
Each service can have a generator script which generates the secrets and facts.
The generator script is expected to generate all secrets and facts defined for this service.
A `service` does not need to ba analogous to a systemd service, it can be any group of facts and secrets that need to be generated together.
A `service` does not need to be analogous to a systemd service, it can be any group of facts and secrets that need to be generated together.
'';
default = { };
type = lib.types.attrsOf (

View File

@@ -6,5 +6,4 @@ in
options.clan.meta.name = lib.mkOption { type = lib.types.str; };
options.clan.meta.description = lib.mkOption { type = optStr; };
options.clan.meta.icon = lib.mkOption { type = optStr; };
options.clan.tags = lib.mkOption { type = lib.types.listOf lib.types.str; };
}

View File

@@ -1,11 +1,33 @@
{ lib, pkgs, ... }:
{
imports = [
(lib.mkRemovedOptionModule [
"clan"
"core"
"clanName"
] "clanName has been removed. Use clan.core.name instead.")
(lib.mkRemovedOptionModule [
"clan"
"core"
"clanIcon"
] "clanIcon has been removed. Use clan.core.icon instead.")
];
options.clan.core = {
clanName = lib.mkOption {
name = lib.mkOption {
type = lib.types.str;
description = ''
the name of the clan
'';
# Set by the flake, so it's read-only in the maschine
readOnly = true;
};
icon = lib.mkOption {
type = lib.types.nullOr lib.types.path;
description = ''
the location of the clan icon
'';
# Set by the flake, so it's read-only in the maschine
readOnly = true;
};
machineIcon = lib.mkOption {
type = lib.types.nullOr lib.types.path;
@@ -28,12 +50,6 @@
the location of the flake repo, used to calculate the location of facts and secrets
'';
};
clanIcon = lib.mkOption {
type = lib.types.nullOr lib.types.path;
description = ''
the location of the clan icon
'';
};
machineName = lib.mkOption {
type = lib.types.str;
default = "nixos";

View File

@@ -106,8 +106,6 @@
);
};
# defaults
config.clan.core.state.HOME.folders = [ "/home" ];
config.environment.systemPackages = lib.optional (config.clan.core.state != { }) (
pkgs.runCommand "state-commands" { } ''
${builtins.concatStringsSep "\n" (

View File

@@ -39,7 +39,12 @@ in
vars = {
generators = lib.flip lib.mapAttrs config.clan.core.vars.generators (
_name: generator: {
inherit (generator) dependencies finalScript prompts;
inherit (generator)
dependencies
finalScript
prompts
share
;
files = lib.flip lib.mapAttrs generator.files (_name: file: { inherit (file) secret; });
}
);

View File

@@ -168,6 +168,15 @@ in
internal = true;
visible = false;
};
share = {
description = ''
Whether the generated vars should be shared between machines.
Shared vars are only generated once, when the first machine using it is deployed.
Subsequent machines will re-use the already generated values.
'';
type = bool;
default = false;
};
};
})
);

View File

@@ -10,9 +10,17 @@ let
inherit (import ./funcs.nix { inherit lib; }) listVars;
varsDir = config.clan.core.clanDir + "/sops/vars";
varsDirMachines = config.clan.core.clanDir + "/sops/vars/per-machine";
varsDirShared = config.clan.core.clanDir + "/sops/vars/shared";
vars = listVars varsDir;
varsUnfiltered = (listVars varsDirMachines) ++ (listVars varsDirShared);
filterVars =
vars:
builtins.elem vars.machine [
config.clan.core.machineName
"shared"
];
vars = lib.filter filterVars varsUnfiltered;
in
{
@@ -33,7 +41,7 @@ in
flip map vars (secret: {
name = secret.id;
value = {
sopsFile = config.clan.core.clanDir + "/sops/vars/${secret.id}/secret";
sopsFile = secret.sopsFile;
format = "binary";
};
})

View File

@@ -23,6 +23,7 @@ rec {
generator = generator_name;
name = secret_name;
id = "${machine_name}/${generator_name}/${secret_name}";
sopsFile = "${varsDir}/${machine_name}/${generator_name}/${secret_name}/secret";
})
)
);

View File

@@ -20,6 +20,8 @@ let
./waypipe.nix
];
clan.core.state.HOME.folders = [ "/home" ];
clan.services.waypipe = {
inherit (config.clan.core.vm.inspect.waypipe) enable command;
};
@@ -39,18 +41,14 @@ let
boot.initrd.systemd.enable = true;
# currently needed for system.etc.overlay.enable
boot.kernelPackages = pkgs.linuxPackages_latest;
boot.initrd.systemd.storePaths = [
pkgs.util-linux
pkgs.e2fsprogs
];
boot.initrd.systemd.emergencyAccess = true;
# sysusers is faster than nixos's perl scripts
# and doesn't require state.
systemd.sysusers.enable = true;
# sysusers would be faster because it doesn't need perl, but it cannot create normal users
systemd.sysusers.enable = false;
users.mutableUsers = false;
users.allowNoPasswordLogin = true;
@@ -252,8 +250,8 @@ in
config = {
# for clan vm inspect
clan.core.vm.inspect = {
clan_name = config.clan.core.clanName;
machine_icon = config.clan.core.machineIcon or config.clan.core.clanIcon;
clan_name = config.clan.core.name;
machine_icon = config.clan.core.machineIcon or config.clan.core.icon;
machine_name = config.clan.core.machineName;
machine_description = config.clan.core.machineDescription;
memory_size = config.clan.virtualisation.memorySize;

View File

@@ -23,8 +23,8 @@ in
};
name = lib.mkOption {
type = lib.types.str;
default = config.clan.core.clanName;
defaultText = "config.clan.core.clanName";
default = config.clan.core.name;
defaultText = "config.clan.core.name";
description = ''
zerotier network name
'';
@@ -89,11 +89,7 @@ in
({
# Override license so that we can build zerotierone without
# having to re-import nixpkgs.
services.zerotierone.package = lib.mkDefault (
pkgs.zerotierone.overrideAttrs (_old: {
meta = { };
})
);
services.zerotierone.package = lib.mkDefault (pkgs.callPackage ../../../pkgs/zerotierone { });
})
(lib.mkIf ((facts.zerotier-ip.value or null) != null) {
environment.etc."zerotier/ip".text = facts.zerotier-ip.value;

View File

@@ -3,10 +3,12 @@
flake.nixosModules = {
hidden-ssh-announce.imports = [ ./hidden-ssh-announce.nix ];
bcachefs.imports = [ ./bcachefs.nix ];
zfs.imports = [ ./zfs.nix ];
installer.imports = [
./installer
self.nixosModules.hidden-ssh-announce
self.nixosModules.bcachefs
self.nixosModules.zfs
];
clanCore.imports = [
inputs.sops-nix.nixosModules.sops

16
nixosModules/zfs.nix Normal file
View File

@@ -0,0 +1,16 @@
{ lib, config, ... }:
{
# Use the same default hostID as the NixOS install ISO and nixos-anywhere.
# This allows us to import zfs pool without using a force import.
# ZFS has this as a safety mechanism for networked block storage (ISCSI), but
# in practice we found it causes more breakages like unbootable machines,
# while people using ZFS on ISCSI is quite rare.
networking.hostId = lib.mkDefault "8425e349";
services.zfs = lib.mkIf (config.boot.zfs.enabled) {
autoSnapshot.enable = true;
# defaults to 12, which is a bit much given how much data is written
autoSnapshot.monthly = lib.mkDefault 1;
autoScrub.enable = true;
};
}

View File

@@ -60,6 +60,10 @@ class ImplFunc(GObject.Object, Generic[P, B]):
return result
# TODO: Reimplement this such that it uses a multiprocessing.Array of type ctypes.c_char
# all fn arguments are serialized to json and passed to the new process over the Array
# the new process deserializes the json and calls the function
# the result is serialized to json and passed back to the main process over another Array
class MethodExecutor(threading.Thread):
def __init__(
self, function: Callable[..., Any], *args: Any, **kwargs: dict[str, Any]

View File

@@ -4,6 +4,8 @@ import gi
gi.require_version("Gtk", "4.0")
import logging
from pathlib import Path
from typing import Any
from clan_cli.api import ErrorDataClass, SuccessDataClass
from clan_cli.api.directory import FileRequest
@@ -14,10 +16,14 @@ from clan_app.api import ImplFunc
log = logging.getLogger(__name__)
def remove_none(_list: list) -> list:
return [i for i in _list if i is not None]
# This implements the abstract function open_file with one argument, file_request,
# which is a FileRequest object and returns a string or None.
class open_file(
ImplFunc[[FileRequest, str], SuccessDataClass[str | None] | ErrorDataClass]
ImplFunc[[FileRequest, str], SuccessDataClass[list[str] | None] | ErrorDataClass]
):
def __init__(self) -> None:
super().__init__()
@@ -27,7 +33,7 @@ class open_file(
try:
gfile = file_dialog.open_finish(task)
if gfile:
selected_path = gfile.get_path()
selected_path = remove_none([gfile.get_path()])
self.returns(
SuccessDataClass(
op_key=op_key, data=selected_path, status="success"
@@ -36,16 +42,39 @@ class open_file(
except Exception as e:
print(f"Error getting selected file or directory: {e}")
def on_file_select_multiple(
file_dialog: Gtk.FileDialog, task: Gio.Task
) -> None:
try:
gfiles: Any = file_dialog.open_multiple_finish(task)
if gfiles:
selected_paths = remove_none([gfile.get_path() for gfile in gfiles])
self.returns(
SuccessDataClass(
op_key=op_key, data=selected_paths, status="success"
)
)
else:
self.returns(
SuccessDataClass(op_key=op_key, data=None, status="success")
)
except Exception as e:
print(f"Error getting selected files: {e}")
def on_folder_select(file_dialog: Gtk.FileDialog, task: Gio.Task) -> None:
try:
gfile = file_dialog.select_folder_finish(task)
if gfile:
selected_path = gfile.get_path()
selected_path = remove_none([gfile.get_path()])
self.returns(
SuccessDataClass(
op_key=op_key, data=selected_path, status="success"
)
)
else:
self.returns(
SuccessDataClass(op_key=op_key, data=None, status="success")
)
except Exception as e:
print(f"Error getting selected directory: {e}")
@@ -53,12 +82,16 @@ class open_file(
try:
gfile = file_dialog.save_finish(task)
if gfile:
selected_path = gfile.get_path()
selected_path = remove_none([gfile.get_path()])
self.returns(
SuccessDataClass(
op_key=op_key, data=selected_path, status="success"
)
)
else:
self.returns(
SuccessDataClass(op_key=op_key, data=None, status="success")
)
except Exception as e:
print(f"Error getting selected file: {e}")
@@ -90,9 +123,21 @@ class open_file(
filters.append(file_filters)
dialog.set_filters(filters)
if file_request.initial_file:
p = Path(file_request.initial_file).expanduser()
f = Gio.File.new_for_path(str(p))
dialog.set_initial_file(f)
if file_request.initial_folder:
p = Path(file_request.initial_folder).expanduser()
f = Gio.File.new_for_path(str(p))
dialog.set_initial_folder(f)
# if select_folder
if file_request.mode == "select_folder":
dialog.select_folder(callback=on_folder_select)
if file_request.mode == "open_multiple_files":
dialog.open_multiple(callback=on_file_select_multiple)
elif file_request.mode == "open_file":
dialog.open(callback=on_file_select)
elif file_request.mode == "save":

View File

@@ -20,7 +20,6 @@ avatar {
margin-top: 1px;
margin-left: 2px;
margin-right: 2px;
}
.progress-bar {
@@ -40,7 +39,6 @@ avatar {
border-bottom: unset;
}
.vm-list {
margin-top: 25px;
margin-bottom: 25px;
@@ -58,7 +56,6 @@ searchbar {
margin-bottom: 25px;
}
.log-view {
margin-top: 12px;
font-family: monospace;

View File

@@ -0,0 +1,127 @@
import dataclasses
import logging
import multiprocessing as mp
import os
import signal
import sys
import traceback
from collections.abc import Callable
from pathlib import Path
from typing import Any
log = logging.getLogger(__name__)
# Kill the new process and all its children by sending a SIGTERM signal to the process group
def _kill_group(proc: mp.Process) -> None:
pid = proc.pid
if proc.is_alive() and pid:
os.killpg(pid, signal.SIGTERM)
else:
log.warning(f"Process '{proc.name}' with pid '{pid}' is already dead")
@dataclasses.dataclass(frozen=True)
class MPProcess:
name: str
proc: mp.Process
out_file: Path
# Kill the new process and all its children by sending a SIGTERM signal to the process group
def kill_group(self) -> None:
_kill_group(proc=self.proc)
def _set_proc_name(name: str) -> None:
if sys.platform != "linux":
return
import ctypes
# Define the prctl function with the appropriate arguments and return type
libc = ctypes.CDLL("libc.so.6")
prctl = libc.prctl
prctl.argtypes = [
ctypes.c_int,
ctypes.c_char_p,
ctypes.c_ulong,
ctypes.c_ulong,
ctypes.c_ulong,
]
prctl.restype = ctypes.c_int
# Set the process name to "my_process"
prctl(15, name.encode(), 0, 0, 0)
def _init_proc(
func: Callable,
out_file: Path,
proc_name: str,
on_except: Callable[[Exception, mp.process.BaseProcess], None] | None,
**kwargs: Any,
) -> None:
# Create a new process group
os.setsid()
# Open stdout and stderr
with open(out_file, "w") as out_fd:
os.dup2(out_fd.fileno(), sys.stdout.fileno())
os.dup2(out_fd.fileno(), sys.stderr.fileno())
# Print some information
pid = os.getpid()
gpid = os.getpgid(pid=pid)
# Set the process name
_set_proc_name(proc_name)
# Close stdin
sys.stdin.close()
linebreak = "=" * 5
# Execute the main function
print(linebreak + f" {func.__name__}:{pid} " + linebreak, file=sys.stderr)
try:
func(**kwargs)
except Exception as ex:
traceback.print_exc()
if on_except is not None:
on_except(ex, mp.current_process())
# Kill the new process and all its children by sending a SIGTERM signal to the process group
pid = os.getpid()
gpid = os.getpgid(pid=pid)
print(f"Killing process group pid={pid} gpid={gpid}", file=sys.stderr)
os.killpg(gpid, signal.SIGTERM)
sys.exit(1)
# Don't use a finally block here, because we want the exitcode to be set to
# 0 if the function returns normally
def spawn(
*,
out_file: Path,
on_except: Callable[[Exception, mp.process.BaseProcess], None] | None,
func: Callable,
**kwargs: Any,
) -> MPProcess:
# Decouple the process from the parent
if mp.get_start_method(allow_none=True) is None:
mp.set_start_method(method="forkserver")
# Set names
proc_name = f"MPExec:{func.__name__}"
# Start the process
proc = mp.Process(
target=_init_proc,
args=(func, out_file, proc_name, on_except),
name=proc_name,
kwargs=kwargs,
)
proc.start()
# Return the process
mp_proc = MPProcess(name=proc_name, proc=proc, out_file=out_file)
return mp_proc

View File

@@ -1,4 +1,3 @@
import dataclasses
import json
import logging
from typing import Any
@@ -116,10 +115,10 @@ class WebExecutor(GObject.Object):
# Introspect the function and create the expected dataclass from dict dynamically
# Depending on the introspected argument_type
arg_class = self.jschema_api.get_method_argtype(method_name, k)
if dataclasses.is_dataclass(arg_class):
# TODO: rename from_dict into something like construct_checked_value
# from_dict really takes Anything and returns an instance of the type/class
reconciled_arguments[k] = from_dict(arg_class, v)
else:
reconciled_arguments[k] = v
GLib.idle_add(fn_instance._async_run, reconciled_arguments)

View File

@@ -1,4 +1,5 @@
import logging
import os
import gi
from clan_cli.api import API
@@ -18,7 +19,7 @@ log = logging.getLogger(__name__)
class MainWindow(Adw.ApplicationWindow):
def __init__(self, config: ClanConfig) -> None:
super().__init__()
self.set_title("Clan Manager")
self.set_title("Clan App")
self.set_default_size(980, 850)
# Overlay for GTK side exclusive toasts
@@ -47,3 +48,4 @@ class MainWindow(Adw.ApplicationWindow):
def on_destroy(self, source: "Adw.ApplicationWindow") -> None:
log.debug("Destroying Adw.ApplicationWindow")
os._exit(0)

View File

@@ -36,10 +36,6 @@ disallow_untyped_calls = true
disallow_untyped_defs = true
no_implicit_optional = true
[[tool.mypy.overrides]]
module = "argcomplete.*"
ignore_missing_imports = true
[[tool.mypy.overrides]]
module = "clan_cli.*"
ignore_missing_imports = true

View File

@@ -65,6 +65,5 @@ mkShell {
export XDG_DATA_DIRS=${gtk4}/share/gsettings-schemas/gtk4-4.14.4:$XDG_DATA_DIRS
export XDG_DATA_DIRS=${gsettings-desktop-schemas}/share/gsettings-schemas/gsettings-desktop-schemas-46.0:$XDG_DATA_DIRS
'';
}

View File

@@ -2,6 +2,6 @@ import pytest
from helpers import cli
def test_help(capfd: pytest.CaptureFixture) -> None:
def test_help() -> None:
with pytest.raises(SystemExit):
cli.run(["clan-app", "--help"])

View File

@@ -10,8 +10,7 @@
"request": "launch",
"module": "clan_cli.webui",
"justMyCode": false,
"args": [ "--reload", "--no-open", "--log-level", "debug" ],
"args": ["--reload", "--no-open", "--log-level", "debug"]
},
{
"name": "Clan Cli VMs",
@@ -19,8 +18,7 @@
"request": "launch",
"module": "clan_cli",
"justMyCode": false,
"args": [ "vms" ],
"args": ["vms"]
}
]
}

View File

@@ -5,20 +5,18 @@ from pathlib import Path
from types import ModuleType
# These imports are unused, but necessary for @API.register to run once.
from clan_cli.api import directory, mdns_discovery, modules
from clan_cli.api import directory, disk, mdns_discovery, modules
from clan_cli.arg_actions import AppendOptionAction
from clan_cli.clan import show, update
# API endpoints that are not used in the cli.
__all__ = ["directory", "mdns_discovery", "modules", "update"]
__all__ = ["directory", "mdns_discovery", "modules", "update", "disk"]
from . import (
backups,
clan,
config,
facts,
flash,
flatpak,
history,
machines,
secrets,
@@ -178,18 +176,6 @@ For more detailed information, visit: {help_hyperlink("getting-started", "https:
clan.register_parser(parser_flake)
parser_config = subparsers.add_parser(
"config",
help="read a nixos configuration option",
description="read a nixos configuration option",
epilog=(
"""
"""
),
formatter_class=argparse.RawTextHelpFormatter,
)
config.register_parser(parser_config)
parser_ssh = subparsers.add_parser(
"ssh",
help="ssh to a remote machine",
@@ -408,8 +394,6 @@ def main() -> None:
if getattr(args, "debug", False):
setup_logging(logging.DEBUG, root_log_name=__name__.split(".")[0])
log.debug("Debug log activated")
if flatpak.is_flatpak():
log.debug("Running inside a flatpak sandbox")
else:
setup_logging(logging.INFO, root_log_name=__name__.split(".")[0])

View File

@@ -0,0 +1,13 @@
#!/usr/bin/env python3
import argparse
import json
from clan_cli.api import API
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Debug the API.")
args = parser.parse_args()
schema = API.to_json_schema()
print(json.dumps(schema, indent=4))

View File

@@ -12,24 +12,26 @@ from . import API
@dataclass
class FileFilter:
title: str | None
mime_types: list[str] | None
patterns: list[str] | None
suffixes: list[str] | None
title: str | None = field(default=None)
mime_types: list[str] | None = field(default=None)
patterns: list[str] | None = field(default=None)
suffixes: list[str] | None = field(default=None)
@dataclass
class FileRequest:
# Mode of the os dialog window
mode: Literal["open_file", "select_folder", "save"]
mode: Literal["open_file", "select_folder", "save", "open_multiple_files"]
# Title of the os dialog window
title: str | None = None
title: str | None = field(default=None)
# Pre-applied filters for the file dialog
filters: FileFilter | None = None
filters: FileFilter | None = field(default=None)
initial_file: str | None = field(default=None)
initial_folder: str | None = field(default=None)
@API.register_abstract
def open_file(file_request: FileRequest) -> str | None:
def open_file(file_request: FileRequest) -> list[str] | None:
"""
Abstract api method to open a file dialog window.
It must return the name of the selected file or None if no file was selected.
@@ -88,6 +90,8 @@ def get_directory(current_path: str) -> Directory:
@dataclass
class BlkInfo:
name: str
id_link: str
path: str
rm: str
size: str
ro: bool
@@ -103,21 +107,53 @@ class Blockdevices:
def blk_from_dict(data: dict) -> BlkInfo:
return BlkInfo(
name=data["name"],
path=data["path"],
rm=data["rm"],
size=data["size"],
ro=data["ro"],
mountpoints=data["mountpoints"],
type_=data["type"], # renamed here
type_=data["type"], # renamed
id_link=data["id-link"], # renamed
)
@dataclass
class BlockDeviceOptions:
hostname: str | None = None
keyfile: str | None = None
@API.register
def show_block_devices() -> Blockdevices:
def show_block_devices(options: BlockDeviceOptions) -> Blockdevices:
"""
Abstract api method to show block devices.
It must return a list of block devices.
"""
cmd = nix_shell(["nixpkgs#util-linux"], ["lsblk", "--json"])
keyfile = options.keyfile
remote = (
[
"ssh",
*(["-i", f"{keyfile}"] if keyfile else []),
# Disable strict host key checking
"-o StrictHostKeyChecking=no",
# Disable known hosts file
"-o UserKnownHostsFile=/dev/null",
f"{options.hostname}",
]
if options.hostname
else []
)
cmd = nix_shell(
["nixpkgs#util-linux", *(["nixpkgs#openssh"] if options.hostname else [])],
[
*remote,
"lsblk",
"--json",
"--output",
"PATH,NAME,RM,SIZE,RO,MOUNTPOINTS,TYPE,ID-LINK",
],
)
proc = run_no_stdout(cmd)
res = proc.stdout.strip()

View File

@@ -0,0 +1,65 @@
from clan_cli.api import API
from clan_cli.inventory import (
ServiceMeta,
ServiceSingleDisk,
ServiceSingleDiskRole,
ServiceSingleDiskRoleDefault,
SingleDiskConfig,
load_inventory_eval,
load_inventory_json,
save_inventory,
)
def get_instance_name(machine_name: str) -> str:
return f"{machine_name}-single-disk"
@API.register
def set_single_disk_uuid(
base_path: str,
machine_name: str,
disk_uuid: str,
) -> None:
"""
Set the disk UUID of single disk machine
"""
inventory = load_inventory_json(base_path)
instance_name = get_instance_name(machine_name)
single_disk_config: ServiceSingleDisk = ServiceSingleDisk(
meta=ServiceMeta(name=instance_name),
roles=ServiceSingleDiskRole(
default=ServiceSingleDiskRoleDefault(
config=SingleDiskConfig(device=disk_uuid)
)
),
)
inventory.services.single_disk[instance_name] = single_disk_config
save_inventory(
inventory,
base_path,
f"Set disk UUID: '{disk_uuid}' on machine: '{machine_name}'",
)
@API.register
def get_single_disk_uuid(
base_path: str,
machine_name: str,
) -> str | None:
"""
Get the disk UUID of single disk machine
"""
inventory = load_inventory_eval(base_path)
instance_name = get_instance_name(machine_name)
single_disk_config: ServiceSingleDisk = inventory.services.single_disk[
instance_name
]
return single_disk_config.roles.default.config.device

View File

@@ -3,13 +3,16 @@ import re
import tomllib
from dataclasses import dataclass
from pathlib import Path
from typing import Any, get_args, get_type_hints
from clan_cli.cmd import run_no_stdout
from clan_cli.errors import ClanCmdError, ClanError
from clan_cli.inventory import Inventory, load_inventory_json
from clan_cli.inventory import Inventory, load_inventory_json, save_inventory
from clan_cli.inventory.classes import Service
from clan_cli.nix import nix_eval
from . import API
from .serde import from_dict
@dataclass
@@ -153,3 +156,35 @@ def get_module_info(
@API.register
def get_inventory(base_path: str) -> Inventory:
return load_inventory_json(base_path)
@API.register
def set_service_instance(
base_path: str, module_name: str, instance_name: str, config: dict[str, Any]
) -> None:
"""
A function that allows to set any service instance in the inventory.
Takes any untyped dict. The dict is then checked and converted into the correct type using the type hints of the service.
If any conversion error occurs, the function will raise an error.
"""
service_keys = get_type_hints(Service).keys()
if module_name not in service_keys:
raise ValueError(
f"{module_name} is not a valid Service attribute. Expected one of {', '.join(service_keys)}."
)
inventory = load_inventory_json(base_path)
target_type = get_args(get_type_hints(Service)[module_name])[1]
module_instance_map: dict[str, Any] = getattr(inventory.services, module_name, {})
module_instance_map[instance_name] = from_dict(target_type, config)
setattr(inventory.services, module_name, module_instance_map)
save_inventory(
inventory, base_path, f"Update {module_name} instance {instance_name}"
)
# TODO: Add a check that rolls back the inventory if the service config is not valid or causes conflicts.

View File

@@ -29,17 +29,21 @@ Dependencies:
Note: This module assumes the presence of other modules and classes such as `ClanError` and `ErrorDetails` from the `clan_cli.errors` module.
"""
import dataclasses
import json
from dataclasses import dataclass, fields, is_dataclass
from pathlib import Path
from types import UnionType
from typing import (
Annotated,
Any,
Literal,
TypeVar,
Union,
get_args,
get_origin,
)
from pydantic import TypeAdapter, ValidationError
from pydantic_core import ErrorDetails
from clan_cli.errors import ClanError
@@ -64,7 +68,8 @@ def dataclass_to_dict(obj: Any, *, use_alias: bool = True) -> Any:
field.metadata.get("alias", field.name) if use_alias else field.name
): _to_dict(getattr(obj, field.name))
for field in fields(obj)
if not field.name.startswith("_") # type: ignore
if not field.name.startswith("_")
and getattr(obj, field.name) is not None # type: ignore
}
elif isinstance(obj, list | tuple):
return [_to_dict(item) for item in obj]
@@ -81,26 +86,169 @@ def dataclass_to_dict(obj: Any, *, use_alias: bool = True) -> Any:
T = TypeVar("T", bound=dataclass) # type: ignore
G = TypeVar("G") # type: ignore
def from_dict(t: type[T], data: Any) -> T:
"""
Dynamically instantiate a data class from a dictionary, handling nested data classes.
We use dataclasses. But the deserialization logic of pydantic takes a lot of complexity.
"""
adapter = TypeAdapter(t)
try:
return adapter.validate_python(
data,
def is_union_type(type_hint: type | UnionType) -> bool:
return (
type(type_hint) is UnionType
or isinstance(type_hint, UnionType)
or get_origin(type_hint) is Union
)
except ValidationError as e:
fst_error: ErrorDetails = e.errors()[0]
if not fst_error:
raise ClanError(msg=str(e))
msg = fst_error.get("msg")
loc = fst_error.get("loc")
field_path = "Unknown"
if loc:
field_path = str(loc)
raise ClanError(msg=msg, location=f"{t!s}: {field_path}", description=str(e))
def is_type_in_union(union_type: type | UnionType, target_type: type) -> bool:
if get_origin(union_type) is UnionType:
return any(issubclass(arg, target_type) for arg in get_args(union_type))
return union_type == target_type
def unwrap_none_type(type_hint: type | UnionType) -> type:
"""
Takes a type union and returns the first non-None type.
None | str
=>
str
"""
if is_union_type(type_hint):
# Return the first non-None type
return next(t for t in get_args(type_hint) if t is not type(None))
return type_hint # type: ignore
JsonValue = str | float | dict[str, Any] | list[Any] | None
def construct_value(t: type, field_value: JsonValue, loc: list[str] = []) -> Any:
"""
Construct a field value from a type hint and a field value.
"""
if t is None and field_value:
raise ClanError(f"Expected None but got: {field_value}", location=f"{loc}")
# If the field is another dataclass
# Field_value must be a dictionary
if is_dataclass(t) and isinstance(field_value, dict):
return construct_dataclass(t, field_value)
# If the field expects a path
# Field_value must be a string
elif is_type_in_union(t, Path):
if not isinstance(field_value, str):
raise ClanError(
f"Expected string, cannot construct pathlib.Path() from: {field_value} ",
location=f"{loc}",
)
return Path(field_value)
# Trivial values
elif t is str:
if not isinstance(field_value, str):
raise ClanError(f"Expected string, got {field_value}", location=f"{loc}")
return field_value
elif t is int and not isinstance(field_value, str):
return int(field_value) # type: ignore
elif t is float and not isinstance(field_value, str):
return float(field_value) # type: ignore
elif t is bool and isinstance(field_value, bool):
return field_value # type: ignore
# Union types construct the first non-None type
elif is_union_type(t):
# Unwrap the union type
t = unwrap_none_type(t)
# Construct the field value
return construct_value(t, field_value)
# Nested types
# list
# dict
elif get_origin(t) is list:
if not isinstance(field_value, list):
raise ClanError(f"Expected list, got {field_value}", location=f"{loc}")
return [construct_value(get_args(t)[0], item) for item in field_value]
elif get_origin(t) is dict and isinstance(field_value, dict):
return {
key: construct_value(get_args(t)[1], value)
for key, value in field_value.items()
}
elif get_origin(t) is Literal:
valid_values = get_args(t)
if field_value not in valid_values:
raise ClanError(
f"Expected one of {valid_values}, got {field_value}", location=f"{loc}"
)
return field_value
elif get_origin(t) is Annotated:
(base_type,) = get_args(t)
return construct_value(base_type, field_value)
# elif get_origin(t) is Union:
# Unhandled
else:
raise ClanError(f"Unhandled field type {t} with value {field_value}")
def construct_dataclass(t: type[T], data: dict[str, Any], path: list[str] = []) -> T:
"""
type t MUST be a dataclass
Dynamically instantiate a data class from a dictionary, handling nested data classes.
"""
if not is_dataclass(t):
raise ClanError(f"{t.__name__} is not a dataclass")
# Attempt to create an instance of the data_class#
field_values: dict[str, Any] = {}
required: list[str] = []
for field in fields(t):
if field.name.startswith("_"):
continue
# The first type in a Union
# str <- None | str | Path
field_type: type[Any] = unwrap_none_type(field.type) # type: ignore
data_field_name = field.metadata.get("alias", field.name)
if (
field.default is dataclasses.MISSING
and field.default_factory is dataclasses.MISSING
):
required.append(field.name)
# Populate the field_values dictionary with the field value
# if present in the data
if data_field_name in data:
field_value = data.get(data_field_name)
if field_value is None and (
field.type is None or is_type_in_union(field.type, type(None))
):
field_values[field.name] = None
else:
field_values[field.name] = construct_value(field_type, field_value)
# Check that all required field are present.
for field_name in required:
if field_name not in field_values:
formatted_path = " ".join(path)
raise ClanError(
f"Default value missing for: '{field_name}' in {t} {formatted_path}, got Value: {data}"
)
return t(**field_values) # type: ignore
def from_dict(t: type[G], data: dict[str, Any] | Any, path: list[str] = []) -> G:
if is_dataclass(t):
if not isinstance(data, dict):
raise ClanError(f"{data} is not a dict. Expected {t}")
return construct_dataclass(t, data, path) # type: ignore
else:
return construct_value(t, data, path)

View File

@@ -6,7 +6,7 @@ from ..clan_uri import FlakeId
from ..cmd import run
from ..dirs import machine_gcroot
from ..errors import ClanError
from ..machines.list import list_machines
from ..machines.list import list_nixos_machines
from ..machines.machines import Machine
from ..nix import nix_add_to_gcroots, nix_build, nix_config, nix_eval, nix_metadata
from ..vms.inspect import VmConfig, inspect_vm
@@ -40,7 +40,7 @@ def inspect_flake(flake_url: str | Path, machine_name: str) -> FlakeConfig:
system = config["system"]
# Check if the machine exists
machines = list_machines(flake_url, False)
machines: list[str] = list_nixos_machines(flake_url)
if machine_name not in machines:
raise ClanError(
f"Machine {machine_name} not found in {flake_url}. Available machines: {', '.join(machines)}"
@@ -57,7 +57,7 @@ def inspect_flake(flake_url: str | Path, machine_name: str) -> FlakeConfig:
# Get the Clan name
cmd = nix_eval(
[
f'{flake_url}#clanInternals.machines."{system}"."{machine_name}".config.clan.core.clanName'
f'{flake_url}#clanInternals.machines."{system}"."{machine_name}".config.clan.core.name'
]
)
res = run_cmd(cmd)
@@ -66,7 +66,7 @@ def inspect_flake(flake_url: str | Path, machine_name: str) -> FlakeConfig:
# Get the clan icon path
cmd = nix_eval(
[
f'{flake_url}#clanInternals.machines."{system}"."{machine_name}".config.clan.core.clanIcon'
f'{flake_url}#clanInternals.machines."{system}"."{machine_name}".config.clan.core.icon'
]
)
res = run_cmd(cmd)
@@ -79,9 +79,9 @@ def inspect_flake(flake_url: str | Path, machine_name: str) -> FlakeConfig:
cmd = nix_build(
[
f'{flake_url}#clanInternals.machines."{system}"."{machine_name}".config.clan.core.clanIcon'
f'{flake_url}#clanInternals.machines."{system}"."{machine_name}".config.clan.core.icon'
],
machine_gcroot(flake_url=str(flake_url)) / "clanIcon",
machine_gcroot(flake_url=str(flake_url)) / "icon",
)
run_cmd(cmd)

View File

@@ -4,12 +4,10 @@ import json
import logging
import os
import re
import sys
from pathlib import Path
from typing import Any, get_origin
from clan_cli.cmd import run
from clan_cli.completions import add_dynamic_completer, complete_machines
from clan_cli.dirs import machine_settings_file
from clan_cli.errors import ClanError
from clan_cli.git import commit_file
@@ -305,65 +303,3 @@ def set_option(
repo_dir=flake_dir,
commit_message=f"Set option {option_description}",
)
# takes a (sub)parser and configures it
def register_parser(
parser: argparse.ArgumentParser | None,
) -> None:
if parser is None:
parser = argparse.ArgumentParser(
description="Set or show NixOS options",
)
# inject callback function to process the input later
parser.set_defaults(func=get_option)
set_machine_action = parser.add_argument(
"--machine",
"-m",
help="Machine to configure",
type=str,
default="default",
)
add_dynamic_completer(set_machine_action, complete_machines)
parser.add_argument(
"--show-trace",
help="Show nix trace on evaluation error",
action="store_true",
)
parser.add_argument(
"--options-file",
help="JSON file with options",
type=Path,
)
parser.add_argument(
"--settings-file",
help="JSON file with settings",
type=Path,
)
parser.add_argument(
"--quiet",
help="Do not print the value",
action="store_true",
)
parser.add_argument(
"option",
help="Option to read or set (e.g. foo.bar)",
type=str,
)
def main(argv: list[str] | None = None) -> None:
if argv is None:
argv = sys.argv
parser = argparse.ArgumentParser()
register_parser(parser)
parser.parse_args(argv[1:])
if __name__ == "__main__":
main()

View File

@@ -18,7 +18,7 @@ def verify_machine_config(
) -> str | None:
"""
Verify that the machine evaluates successfully
Returns a tuple of (success, error_message)
Returns None, in case of success, or a String containing the error_message
"""
if config is None:
config = config_for_machine(flake_dir, machine_name)

View File

@@ -11,6 +11,8 @@ from clan_cli.errors import ClanError, ClanHttpError
from clan_cli.nix import nix_eval
# TODO: When moving the api to `clan-app`, the whole config module should be
# ported to the `clan-app`, because it is not used by the cli at all.
@API.register
def machine_schema(
flake_dir: Path,
@@ -88,7 +90,7 @@ def machine_schema(
# potentially the config might affect submodule options,
# therefore we need to import it
config
{{ clan.core.clanName = "fakeClan"; }}
{{ clan.core.name = "fakeClan"; }}
]
# add all clan modules specified via clanImports
++ (map (name: clan-core.clanModules.${{name}}) config.clanImports or []);

View File

@@ -105,13 +105,7 @@ def generate_service_facts(
)
files_to_commit = []
# store secrets
for secret in machine.facts_data[service]["secret"]:
if isinstance(secret, str):
# TODO: This is the old NixOS module, can be dropped everyone has updated.
secret_name = secret
groups = []
else:
secret_name = secret["name"]
for secret_name, secret in machine.facts_data[service]["secret"].items():
groups = secret.get("groups", [])
secret_file = secrets_dir / secret_name

View File

@@ -31,9 +31,11 @@ def upload_secrets(machine: Machine) -> None:
"rsync",
"-e",
" ".join(["ssh"] + ssh_cmd[2:]),
"-az",
"--recursive",
"--links",
"--times",
"--compress",
"--delete",
"--chown=root:root",
"--chmod=D700,F600",
f"{tempdir!s}/",
f"{host.user}@{host.host}:{machine.secrets_upload_directory}/",

View File

@@ -6,7 +6,7 @@ import os
import shutil
import textwrap
from collections.abc import Sequence
from dataclasses import dataclass
from dataclasses import dataclass, field
from pathlib import Path
from tempfile import TemporaryDirectory
from typing import Any
@@ -19,23 +19,118 @@ from .completions import add_dynamic_completer, complete_machines
from .errors import ClanError
from .facts.secret_modules import SecretStoreBase
from .machines.machines import Machine
from .nix import nix_shell
from .nix import nix_build, nix_shell
log = logging.getLogger(__name__)
@dataclass
class WifiConfig:
ssid: str
password: str
@dataclass
class SystemConfig:
language: str | None = field(default=None)
keymap: str | None = field(default=None)
ssh_keys_path: list[str] | None = field(default=None)
wifi_settings: list[WifiConfig] | None = field(default=None)
@API.register
def list_possible_keymaps() -> list[str]:
cmd = nix_build(["nixpkgs#kbd"])
result = run(cmd, log=Log.STDERR, error_msg="Failed to find kbdinfo")
keymaps_dir = Path(result.stdout.strip()) / "share" / "keymaps"
if not keymaps_dir.exists():
raise FileNotFoundError(f"Keymaps directory '{keymaps_dir}' does not exist.")
keymap_files = []
for root, _, files in os.walk(keymaps_dir):
for file in files:
if file.endswith(".map.gz"):
# Remove '.map.gz' ending
name_without_ext = file[:-7]
keymap_files.append(name_without_ext)
return keymap_files
@API.register
def list_possible_languages() -> list[str]:
cmd = nix_build(["nixpkgs#glibcLocales"])
result = run(cmd, log=Log.STDERR, error_msg="Failed to find glibc locales")
locale_file = Path(result.stdout.strip()) / "share" / "i18n" / "SUPPORTED"
if not locale_file.exists():
raise FileNotFoundError(f"Locale file '{locale_file}' does not exist.")
with locale_file.open() as f:
lines = f.readlines()
languages = []
for line in lines:
if line.startswith("#"):
continue
if "SUPPORTED-LOCALES" in line:
continue
# Split by '/' and take the first part
language = line.split("/")[0].strip()
languages.append(language)
return languages
@API.register
def flash_machine(
machine: Machine,
*,
mode: str,
disks: dict[str, str],
system_config: dict[str, Any],
system_config: SystemConfig,
dry_run: bool,
write_efi_boot_entries: bool,
debug: bool,
extra_args: list[str] = [],
) -> None:
system_config_nix: dict[str, Any] = {}
if system_config.wifi_settings:
wifi_settings = {}
for wifi in system_config.wifi_settings:
wifi_settings[wifi.ssid] = {"password": wifi.password}
system_config_nix["clan"] = {"iwd": {"networks": wifi_settings}}
if system_config.language:
if system_config.language not in list_possible_languages():
raise ClanError(
f"Language '{system_config.language}' is not a valid language. "
f"Run 'clan flash --list-languages' to see a list of possible languages."
)
system_config_nix["i18n"] = {"defaultLocale": system_config.language}
if system_config.keymap:
if system_config.keymap not in list_possible_keymaps():
raise ClanError(
f"Keymap '{system_config.keymap}' is not a valid keymap. "
f"Run 'clan flash --list-keymaps' to see a list of possible keymaps."
)
system_config_nix["console"] = {"keyMap": system_config.keymap}
if system_config.ssh_keys_path:
root_keys = []
for key_path in map(lambda x: Path(x), system_config.ssh_keys_path):
try:
root_keys.append(key_path.read_text())
except OSError as e:
raise ClanError(f"Cannot read SSH public key file: {key_path}: {e}")
system_config_nix["users"] = {
"users": {"root": {"openssh": {"authorizedKeys": {"keys": root_keys}}}}
}
secret_facts_module = importlib.import_module(machine.secret_facts_module)
secret_facts_store: SecretStoreBase = secret_facts_module.SecretStore(
machine=machine
@@ -58,7 +153,8 @@ def flash_machine(
raise ClanError(
"sudo is required to run disko-install as a non-root user"
)
disko_install.append("sudo")
wrapper = 'set -x; disko_install=$(command -v disko-install); exec sudo "$disko_install" "$@"'
disko_install.extend(["bash", "-c", wrapper])
disko_install.append("disko-install")
if write_efi_boot_entries:
@@ -76,7 +172,7 @@ def flash_machine(
disko_install.extend(
[
"--system-config",
json.dumps(system_config),
json.dumps(system_config_nix),
]
)
disko_install.extend(["--option", "dry-run", "true"])
@@ -94,15 +190,13 @@ class FlashOptions:
flake: FlakeId
machine: str
disks: dict[str, str]
ssh_keys_path: list[Path]
dry_run: bool
confirm: bool
debug: bool
mode: str
language: str
keymap: str
write_efi_boot_entries: bool
nix_options: list[str]
system_config: SystemConfig
class AppendDiskAction(argparse.Action):
@@ -126,17 +220,36 @@ def flash_command(args: argparse.Namespace) -> None:
flake=args.flake,
machine=args.machine,
disks=args.disk,
ssh_keys_path=args.ssh_pubkey,
dry_run=args.dry_run,
confirm=not args.yes,
debug=args.debug,
mode=args.mode,
system_config=SystemConfig(
language=args.language,
keymap=args.keymap,
ssh_keys_path=args.ssh_pubkey,
wifi_settings=None,
),
write_efi_boot_entries=args.write_efi_boot_entries,
nix_options=args.option,
)
if args.list_languages:
for language in list_possible_languages():
print(language)
return
if args.list_keymaps:
for keymap in list_possible_keymaps():
print(keymap)
return
if args.wifi:
opts.system_config.wifi_settings = [
WifiConfig(ssid=ssid, password=password)
for ssid, password in args.wifi.items()
]
machine = Machine(opts.machine, flake=opts.flake)
if opts.confirm and not opts.dry_run:
disk_str = ", ".join(f"{name}={device}" for name, device in opts.disks.items())
@@ -148,28 +261,11 @@ def flash_command(args: argparse.Namespace) -> None:
if ask != "y":
return
extra_config: dict[str, Any] = {}
if opts.ssh_keys_path:
root_keys = []
for key_path in opts.ssh_keys_path:
try:
root_keys.append(key_path.read_text())
except OSError as e:
raise ClanError(f"Cannot read SSH public key file: {key_path}: {e}")
extra_config["users"] = {
"users": {"root": {"openssh": {"authorizedKeys": {"keys": root_keys}}}}
}
if opts.keymap:
extra_config["console"] = {"keyMap": opts.keymap}
if opts.language:
extra_config["i18n"] = {"defaultLocale": opts.language}
flash_machine(
machine,
mode=opts.mode,
disks=opts.disks,
system_config=extra_config,
system_config=opts.system_config,
dry_run=opts.dry_run,
debug=opts.debug,
write_efi_boot_entries=opts.write_efi_boot_entries,
@@ -202,6 +298,15 @@ def register_parser(parser: argparse.ArgumentParser) -> None:
Mount is useful for updating an existing system without losing data.
"""
)
parser.add_argument(
"--wifi",
type=str,
nargs=2,
metavar=("ssid", "password"),
action=AppendDiskAction,
help="wifi network to connect to",
default={},
)
parser.add_argument(
"--mode",
type=str,
@@ -221,6 +326,18 @@ def register_parser(parser: argparse.ArgumentParser) -> None:
type=str,
help="system language",
)
parser.add_argument(
"--list-languages",
help="List possible languages",
default=False,
action="store_true",
)
parser.add_argument(
"--list-keymaps",
help="List possible keymaps",
default=False,
action="store_true",
)
parser.add_argument(
"--keymap",
type=str,

View File

@@ -1,16 +0,0 @@
import os
def is_flatpak() -> bool:
"""Check if the current process is running inside a flatpak sandbox."""
# FLATPAK_ID environment variable check
flatpak_env = "FLATPAK_ID" in os.environ
flatpak_file = False
try:
with open("/.flatpak-info"):
flatpak_file = True
except FileNotFoundError:
pass
return flatpak_env and flatpak_file

View File

@@ -7,7 +7,7 @@ import logging
from typing import Any
from clan_cli.clan.inspect import FlakeConfig, inspect_flake
from clan_cli.machines.list import list_machines
from clan_cli.machines.list import list_nixos_machines
from ..clan_uri import ClanURI
from ..dirs import user_history_file
@@ -72,7 +72,7 @@ def new_history_entry(url: str, machine: str) -> HistoryEntry:
def add_all_to_history(uri: ClanURI) -> list[HistoryEntry]:
history = list_history()
new_entries: list[HistoryEntry] = []
for machine in list_machines(uri.get_url()):
for machine in list_nixos_machines(uri.get_url()):
new_entry = _add_maschine_to_history_list(uri.get_url(), machine, history)
new_entries.append(new_entry)
write_history_file(history)

View File

@@ -32,6 +32,10 @@ from .classes import (
ServiceBorgbackupRoleClient,
ServiceBorgbackupRoleServer,
ServiceMeta,
ServiceSingleDisk,
ServiceSingleDiskRole,
ServiceSingleDiskRoleDefault,
SingleDiskConfig,
)
# Re export classes here
@@ -49,6 +53,11 @@ __all__ = [
"ServiceBorgbackupRole",
"ServiceBorgbackupRoleClient",
"ServiceBorgbackupRoleServer",
# Single Disk service
"ServiceSingleDisk",
"ServiceSingleDiskRole",
"ServiceSingleDiskRoleDefault",
"SingleDiskConfig",
]
@@ -82,6 +91,7 @@ def load_inventory_eval(flake_dir: str | Path) -> Inventory:
"--json",
]
)
proc = run_no_stdout(cmd)
try:

View File

@@ -6,7 +6,6 @@ from .delete import register_delete_parser
from .hardware import register_hw_generate
from .install import register_install_parser
from .list import register_list_parser
from .show import register_show_parser
from .update import register_update_parser
@@ -86,17 +85,6 @@ For more detailed information, visit: https://docs.clan.lol/getting-started/conf
)
register_hw_generate(generate_hw_parser)
show_parser = subparser.add_parser(
"show",
help="Show a machine",
epilog=(
"""
This subcommand shows the details of a machine managed by this clan like icon, description, etc
"""
),
)
register_show_parser(show_parser)
install_parser = subparser.add_parser(
"install",
help="Install a machine",

View File

@@ -31,6 +31,8 @@ def create_machine(flake: FlakeId, machine: Machine) -> None:
if machine.name in full_inventory.machines.keys():
raise ClanError(f"Machine with the name {machine.name} already exists")
print(f"Define machine {machine.name}", machine)
inventory.machines.update({machine.name: machine})
save_inventory(inventory, flake.path, f"Create machine {machine.name}")

View File

@@ -94,6 +94,7 @@ def generate_machine_hardware_info(
machine_name: str,
hostname: str | None = None,
password: str | None = None,
keyfile: str | None = None,
force: bool | None = False,
) -> HardwareInfo:
"""
@@ -117,15 +118,15 @@ def generate_machine_hardware_info(
[
*(["sshpass", "-p", f"{password}"] if password else []),
"ssh",
# Disable strict host key checking
"-o",
"StrictHostKeyChecking=no",
*(["-i", f"{keyfile}"] if keyfile else []),
# Disable known hosts file
"-o",
"UserKnownHostsFile=/dev/null",
"-p",
str(machine.target_host.port),
target_host,
"-o UserKnownHostsFile=/dev/null",
f"{hostname}",
"nixos-generate-config",
# Filesystems are managed by disko
"--no-filesystems",

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