From 2f52a30b78a97637a6be39bc95cae07ad96367b1 Mon Sep 17 00:00:00 2001 From: centra
Date: Tue, 17 Feb 2026 17:40:38 +0900
Subject: [PATCH] Update lightscale-client submodule and add resource-guard
test
---
flake.nix | 25 ++
lab/mesh-certs/ca.pem | 19 ++
lab/mesh-certs/srv-a-key.pem | 28 ++
lab/mesh-certs/srv-a.pem | 20 ++
lab/mesh-certs/srv-b-key.pem | 28 ++
lab/mesh-certs/srv-b.pem | 20 ++
lab/run.sh | 51 ++-
lab/test-resource-guard.nix | 393 +++++++++++++++++++++++
lab/test-router.nix | 262 +++++++++++++++
lab/test-server-mesh-relay.nix | 233 ++++++++++++++
lab/test-services.nix | 167 ++++++++++
lightscale-client | 2 +-
nixos/README.md | 98 ++++++
nixos/modules/lightscale-client.nix | 474 ++++++++++++++++++++++++++++
nixos/modules/lightscale-server.nix | 247 +++++++++++++++
15 files changed, 2049 insertions(+), 18 deletions(-)
create mode 100644 lab/mesh-certs/ca.pem
create mode 100644 lab/mesh-certs/srv-a-key.pem
create mode 100644 lab/mesh-certs/srv-a.pem
create mode 100644 lab/mesh-certs/srv-b-key.pem
create mode 100644 lab/mesh-certs/srv-b.pem
create mode 100644 lab/test-resource-guard.nix
create mode 100644 lab/test-router.nix
create mode 100644 lab/test-server-mesh-relay.nix
create mode 100644 lab/test-services.nix
create mode 100644 nixos/README.md
create mode 100644 nixos/modules/lightscale-client.nix
create mode 100644 nixos/modules/lightscale-server.nix
diff --git a/flake.nix b/flake.nix
index bf1dcdd..67b297c 100644
--- a/flake.nix
+++ b/flake.nix
@@ -1,5 +1,6 @@
{
inputs = {
+ self.submodules = true;
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
};
@@ -39,6 +40,9 @@
testConfigIpv6 = import ./lab/test-ipv6.nix { inherit pkgs serverPkg clientPkg; };
testConfigUserspace = import ./lab/test-userspace.nix { inherit pkgs serverPkg clientPkg; };
testConfigRelayFailover = import ./lab/test-relay-failover.nix { inherit pkgs serverPkg clientPkg; };
+ testConfigServerMeshRelay = import ./lab/test-server-mesh-relay.nix { inherit pkgs serverPkg clientPkg; };
+ testConfigServices = import ./lab/test-services.nix { inherit pkgs serverPkg clientPkg; };
+ testConfigRouter = import ./lab/test-router.nix { inherit pkgs serverPkg clientPkg; };
labTestFast =
(import (nixpkgs + "/nixos/tests/make-test-python.nix") testConfigFast)
{ inherit system pkgs; };
@@ -84,6 +88,15 @@
labTestRelayFailover =
(import (nixpkgs + "/nixos/tests/make-test-python.nix") testConfigRelayFailover)
{ inherit system pkgs; };
+ labTestServerMeshRelay =
+ (import (nixpkgs + "/nixos/tests/make-test-python.nix") testConfigServerMeshRelay)
+ { inherit system pkgs; };
+ labTestServices =
+ (import (nixpkgs + "/nixos/tests/make-test-python.nix") testConfigServices)
+ { inherit system pkgs; };
+ labTestRouter =
+ (import (nixpkgs + "/nixos/tests/make-test-python.nix") testConfigRouter)
+ { inherit system pkgs; };
in
{
packages.${system} = {
@@ -91,6 +104,15 @@
lightscale-client = clientPkg;
};
+ nixosModules = {
+ lightscale-server = import ./nixos/modules/lightscale-server.nix {
+ defaultPackage = serverPkg;
+ };
+ lightscale-client = import ./nixos/modules/lightscale-client.nix {
+ defaultPackage = clientPkg;
+ };
+ };
+
nixosTests.lightscale-lab = labTestFast;
nixosTests.lightscale-lab-5 = labTestFull;
nixosTests.lightscale-lab-firewall = labTestFirewall;
@@ -106,6 +128,9 @@
nixosTests.lightscale-lab-ipv6 = labTestIpv6;
nixosTests.lightscale-lab-userspace = labTestUserspace;
nixosTests.lightscale-lab-relay-failover = labTestRelayFailover;
+ nixosTests.lightscale-lab-server-mesh-relay = labTestServerMeshRelay;
+ nixosTests.lightscale-lab-services = labTestServices;
+ nixosTests.lightscale-lab-router = labTestRouter;
devShells.${system}.default = pkgs.mkShell {
buildInputs = [
diff --git a/lab/mesh-certs/ca.pem b/lab/mesh-certs/ca.pem
new file mode 100644
index 0000000..d2ff5d0
--- /dev/null
+++ b/lab/mesh-certs/ca.pem
@@ -0,0 +1,19 @@
+-----BEGIN CERTIFICATE-----
+MIIDGzCCAgOgAwIBAgIUGqz2VVqQcK7JNSMaCB44itrGNyMwDQYJKoZIhvcNAQEL
+BQAwHTEbMBkGA1UEAwwSbGlnaHRzY2FsZS1tZXNoLWNhMB4XDTI2MDIxNDAyMTAw
+N1oXDTM2MDIxMjAyMTAwN1owHTEbMBkGA1UEAwwSbGlnaHRzY2FsZS1tZXNoLWNh
+MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA18pB501rZUb+aBTRLSB0
+FQypCC3hojYH9HeFzBaemdATn1uaNy9Yk1RM31fUpDwVXgmm8zoMMCcCmitXAj9v
+bXbmlcINCmBmKzoiZJMEakR0+cLqyF/6lVnP8is41xBBCEkvuPdkrFhwj90oiPIt
+eXBkuohLkVNP2lIbD3aGqLgDQ5FVVVYHEaUE4Ad/EkjeCvmmQBNvEfAvH+k9NVP4
+5EEriPjmcBpmk805sLN8f/lZ97e8cN1CESYywjmxA2FHX3Au7IepJGBpj09O8T/f
+QeUhgfPsiCfHy27kyDhsvhA5ZAMG69JP+fMsosSMs0TmrTYddbpMp9qVH8+GRaJJ
+HQIDAQABo1MwUTAdBgNVHQ4EFgQUxA5w5KDcCYAYaTnXSQhJVvAnMEcwHwYDVR0j
+BBgwFoAUxA5w5KDcCYAYaTnXSQhJVvAnMEcwDwYDVR0TAQH/BAUwAwEB/zANBgkq
+hkiG9w0BAQsFAAOCAQEAjorOEpDx+zXEBc/DfY0WWqqeg3tF54bOW8cajnVT6hZJ
+J/FKn4c08mQbqhgVXsgKA2vRm6mgjWzVHT8ROtfIEy6W2qoWiaCMNPffxI5fhF/h
+bCCIEPvW5aMV6l/4pIndx/1Zli6ZZTTGROgZ1TycmzGd1++MMPBBfH429hITs7we
+lZ6eQwZzmwZwLkke7VJib3t3nwk35KR1Tco68cjTf0ZECMkxaF846zoQltDmeQcg
+4bxuG4gdinvNRgsU/rQRsezF7iqg85PFCU+7zyVITx9LhSNBJTG0nT9xbfW+Gzyx
+6ld+B99vEVY00kO6RQ300rk1LjnrFhFbm/z323unGA==
+-----END CERTIFICATE-----
diff --git a/lab/mesh-certs/srv-a-key.pem b/lab/mesh-certs/srv-a-key.pem
new file mode 100644
index 0000000..75e0ced
--- /dev/null
+++ b/lab/mesh-certs/srv-a-key.pem
@@ -0,0 +1,28 @@
+-----BEGIN PRIVATE KEY-----
+MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDcRyF//YGaM+71
+buV8QqBIQCacAfsNhqL+cOOFgwSpkJZ1vOjq2voJy1CmzqqjTIdGnvZnvG/pEYZW
+4EG6RMisSovkhWIu19Ml8RpEYSAYFcwqotfKN14ETEWMD7xeV9kOQaolJHLPFhDu
+DHnOEGULR5fxcGtVHdJKId6eP8ysH6R9V3ijylORvvbuNI3dLE0rC63aOnJ2grzS
+vyzfFDxduWP0Rao5xmztDdMAysl3FflE/WUvSScDcp0MlELwczKusUvxNQyGkIhE
+Fh0YEJs8ZAbQ38XFB8JU1MPrY0KyUUZzQDSYApTXIiytiwU0QLd9eY48qw44bOMe
+fWiYJ9aZAgMBAAECggEAAuu6iPzujAHmSEDZMCWeBHc9S9JG5u4DGUab7bhIBouR
+QyxnFj4jFKAqiJuy7YllPc17zPJVtYxy8JH2rSaeVpKCelw+agqYlSc2RvPWvhsD
+4wjXvamwSHROc8X9pG6bxTGftPBfyVksjkuCDfZvsI+Zdy0bbzx+/lamogiMiTe3
+5I9qtvvmU1ej+VQXmbi9Gwx3ll3N/pGPrraB4Um6/eHst6ujLt5/Dk6B2NuJUbf5
+Nj83MwPpA7OOZHBdCtvDW7waKD3ZGtyJMMX2YZ0nd6dUMq4RRXW+er2EnftEvZmX
+kaNtaHAHdJakrFUqqy60N6XRoBL6SmQ7BlKd5yWXUQKBgQD1iciJYUaFy/CnE830
+j5HbLVXiIXu/U6wxSraErt40oHlG/rLgmpc3qjBE+c4t2j8H/onnRjatAFGZdY8A
+hwgDjJXgGU9O3wm6n07HKhrgPM2KYyswOuDmetnWDQp5mWjUBEropFdkZLdZSuDk
+6bSl0RUXuDohsS4HfJ9HePb19QKBgQDlqdG50hgvzOLXFGpzD6Og5Muz6KXZB0cB
+SYZF2uTvZvWzBo4eBTUfOZE11kQkOlweSGMoaS/tT9lmkvfcavLtSutvRvT1H4f3
+YnNU4EA8+Eh2iLrI8BNvASlejMdW1aSkJsjMumOikQaWaXzCNrMhxbd1E29X8gda
+JMWcoBOTlQKBgBqmNq66cRYKeXcYziyx/GmmdQDTE4RDh5fd/QtPk2xw0ljjQfTg
+snLnNM/3sOoHGvo6JSuF0l9afoDCYp/zB+qiso2dEZ+E06B+s+Un67zUvJY9hy13
+5nr9cHEr/ywNe3QvdxXi6F1MFR5K4zfVKbcphzmI1D5d5ZoIa50tQtiVAoGBAMuK
+xTA+DmeW21gpZOqS6r82536Mayg8teZjJliU1p+CjbFb1uquTNVerN6dBolhG7FP
+EGqJRwu44AFzsa6tLp2175EQvxrcFiHfJD4N/YDLv1UmevyJIYAY9HQyqpy//gnp
+wb8IVjOG+uKlnQd3eS0uURi239B+1ZtDycu1Z4Q9AoGBAO7ftUDZz7QwFn7bAi7D
+Pk/22L73w+MleI/amvTgLi76ZLH9lFVNURUnwakIPlb1Se74HdwRSb9hRFYY4l8G
+KDduMwcdCJ8+XfD90qHe5Ce647B0hlcNmCAf2SIxEs3Lm8u8gfXaFW8f2+OziLCz
+DTX/RXXM0uS7meSez7GKfzos
+-----END PRIVATE KEY-----
diff --git a/lab/mesh-certs/srv-a.pem b/lab/mesh-certs/srv-a.pem
new file mode 100644
index 0000000..bd13fa2
--- /dev/null
+++ b/lab/mesh-certs/srv-a.pem
@@ -0,0 +1,20 @@
+-----BEGIN CERTIFICATE-----
+MIIDODCCAiCgAwIBAgIUN9JLKAim5gUOxFdJaRD3KGc+N20wDQYJKoZIhvcNAQEL
+BQAwHTEbMBkGA1UEAwwSbGlnaHRzY2FsZS1tZXNoLWNhMB4XDTI2MDIxNDAyMTAw
+N1oXDTI4MDUxOTAyMTAwN1owFTETMBEGA1UEAwwKc3J2LWEubWVzaDCCASIwDQYJ
+KoZIhvcNAQEBBQADggEPADCCAQoCggEBANxHIX/9gZoz7vVu5XxCoEhAJpwB+w2G
+ov5w44WDBKmQlnW86Ora+gnLUKbOqqNMh0ae9me8b+kRhlbgQbpEyKxKi+SFYi7X
+0yXxGkRhIBgVzCqi18o3XgRMRYwPvF5X2Q5BqiUkcs8WEO4Mec4QZQtHl/Fwa1Ud
+0koh3p4/zKwfpH1XeKPKU5G+9u40jd0sTSsLrdo6cnaCvNK/LN8UPF25Y/RFqjnG
+bO0N0wDKyXcV+UT9ZS9JJwNynQyUQvBzMq6xS/E1DIaQiEQWHRgQmzxkBtDfxcUH
+wlTUw+tjQrJRRnNANJgClNciLK2LBTRAt315jjyrDjhs4x59aJgn1pkCAwEAAaN4
+MHYwFQYDVR0RBA4wDIIKc3J2LWEubWVzaDAdBgNVHSUEFjAUBggrBgEFBQcDAQYI
+KwYBBQUHAwIwHQYDVR0OBBYEFPsTDIBJkm6jP/sq0gTVfjUems0rMB8GA1UdIwQY
+MBaAFMQOcOSg3AmAGGk510kISVbwJzBHMA0GCSqGSIb3DQEBCwUAA4IBAQBEI1vI
+vCojGmmIhaAH6Kp1+6t79xfXE5hGnSgvwr5iTzuoqS+xyx2yb3CwRInnrUmoVGbs
+gAH4yOYVKwJ9RZxigr/3Q0rzh70koU54p6Hn4Jqmy5uQRSx6cZBuKku9Cg2MAsmg
+kvKQg2vbVo5/IWaJzqzY97ahEIGchwhCsXnvdI4yn/cb8TU43K0RksOYIrnUynQ9
+IozIe63ijCaVsTu8Sv/i0eHkle7lfcPKEVK+Q/yge9DH7SolB92fZTxXwLhn0H39
+e7qng8OecdjlRoN9frGHMZuY0T0eOhogDuCvZwu97kAcWIuWo3wKi2lU5fK5Mpra
+VJgChppzQz8978uM
+-----END CERTIFICATE-----
diff --git a/lab/mesh-certs/srv-b-key.pem b/lab/mesh-certs/srv-b-key.pem
new file mode 100644
index 0000000..5fcad45
--- /dev/null
+++ b/lab/mesh-certs/srv-b-key.pem
@@ -0,0 +1,28 @@
+-----BEGIN PRIVATE KEY-----
+MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC7aI1rHuLfSOhC
+tMPxSLbMWflrVR5/hS8Ytz7Py7oAVt8hizq6GtxnwkpkPGiawIllPUFrquoIqHj8
+B89yzJB88HpyDHKX7YumE+ZXCg35+P7f379UFiHHRPqycS7GIS+U2VAVBKi8y0m+
+wwBSpDHbXeG6R3L1GdrrXiZLMdiRsGXOFQ8gNOKZ85Hc/XLfjcv5CO+cBvKiZYF7
+9PfYFn10rJgJ4AjWZG00rqJn5mAqfIVPTKLm1idvfBM/tm73aWvffO7Buw2qfFQ/
+Re1W+gqQ3ujT5shajsY2olx5gYXGICoz+vNz4rO5QHWG7K6kiuKea268OQgiIWLR
+O9LAdb3bAgMBAAECggEATLg/dIPfYoMJOg2gTU10L/IBblQZOSD/p8GUgCKpkWv+
+yk+iCf/nDL74D1K/i+KYHI7YEkiUqFi3to1H+noOCGe16Kx52QJQ8fshh65BDH1H
+ccS6IaUxzM7LRnOZL1j0jp3r777JiQg7t1FC95HqKyCiwMxxHi+odERFrvDH9NOp
+o+H5AkGtBpspdyDuhhBLa2aTipRcA5o2vkzsiIGU4ofrPaUyk5tIV3GsOZ3j5m6Y
+dyhPtXTS5pDmRHNM81zMNCHV+xPTluftvG4TNZWX2jPFFZk/kifZq0AbAsKu2rtI
+lGAe1lfxHUZguCLmSSRqQm8xsHFD2tlFrSfUbjYY2QKBgQD8c0BVdv+5vt8ZbDuJ
+mWjXmWGqOaVZIOxfek0uGLXvYwEiUPSn7lkgnUKUivvOE7Z0SZcAOCxf86aULgSk
+CVgZUoOdzI9VxjJmK5c528MnxVwG4Z5imJBFPI9Op/RAL4K82qQZz1c3c/Fq9SJj
+9nbegCQZ/wG8AAovFtHo9ZwwZQKBgQC+CytRsxsKahU0Wyt8fJedenTozDFGElYZ
+5oV6GNwyAe/gpzcLNl2R72hubxQGfNkuD4oxQoAiidpa01oNDJDm6LWf9L5EJqD3
+Fy+k+Kd+04ey0y66ZnbPV/gRI/L+B5IgZY5G8OHdL5Yvk+IuPqQazQPvb6QD3wTx
+gNLNSlCxPwKBgGsJRq617MlJl3hE/p1h0SUQoGs3U9cNcYst5Ml1qrYcCSAOqR0G
+nv2ID/HBV/BRRVva085BAveP7AIJ3OfcGmFqLenbEK7ygO3274CVoBIdyN4WDTyK
+qSjh+3UDGzmXq1v9a/SRh844N5T86J7vogjG1ge7qnWWorrCdy/J63ZNAoGAWMV0
+3bvxFKNK9mLj5El6tPffpmLDXXzxNTYGAWudZ8qZ13Gkd5tUh/ex62v9ia5F8IsX
+vTzYB8om8igpt1C4WvQ26tnzCniU1fbBrajs7IQ4reKRwEZelyn7WV5WgizdKD7n
+/+FDUAOLfvvwOjPOiipb/TtD/P7vGzRWw8hD+xcCgYEArYZiHqa5G6l8B4agrBlq
+To6EloHFzgkVDOQ8jirQ5hbNHkHJfY0f1s3ZcQX1TQWsoPsWQM5IRocD9oaO9n4i
+c9hCTU4NlY42ffgSL5O43r29i0i3AyA7mKTr3tfbkgVqjqFKHc2DUECoO8OowQpB
+A6cVjX4wec0RxiMmGfHICK8=
+-----END PRIVATE KEY-----
diff --git a/lab/mesh-certs/srv-b.pem b/lab/mesh-certs/srv-b.pem
new file mode 100644
index 0000000..7bb4c2c
--- /dev/null
+++ b/lab/mesh-certs/srv-b.pem
@@ -0,0 +1,20 @@
+-----BEGIN CERTIFICATE-----
+MIIDODCCAiCgAwIBAgIUN9JLKAim5gUOxFdJaRD3KGc+N24wDQYJKoZIhvcNAQEL
+BQAwHTEbMBkGA1UEAwwSbGlnaHRzY2FsZS1tZXNoLWNhMB4XDTI2MDIxNDAyMTAw
+N1oXDTI4MDUxOTAyMTAwN1owFTETMBEGA1UEAwwKc3J2LWIubWVzaDCCASIwDQYJ
+KoZIhvcNAQEBBQADggEPADCCAQoCggEBALtojWse4t9I6EK0w/FItsxZ+WtVHn+F
+Lxi3Ps/LugBW3yGLOroa3GfCSmQ8aJrAiWU9QWuq6gioePwHz3LMkHzwenIMcpft
+i6YT5lcKDfn4/t/fv1QWIcdE+rJxLsYhL5TZUBUEqLzLSb7DAFKkMdtd4bpHcvUZ
+2uteJksx2JGwZc4VDyA04pnzkdz9ct+Ny/kI75wG8qJlgXv099gWfXSsmAngCNZk
+bTSuomfmYCp8hU9MoubWJ298Ez+2bvdpa9987sG7Dap8VD9F7Vb6CpDe6NPmyFqO
+xjaiXHmBhcYgKjP683Pis7lAdYbsrqSK4p5rbrw5CCIhYtE70sB1vdsCAwEAAaN4
+MHYwFQYDVR0RBA4wDIIKc3J2LWIubWVzaDAdBgNVHSUEFjAUBggrBgEFBQcDAQYI
+KwYBBQUHAwIwHQYDVR0OBBYEFE5cUMQW4BB7uYhc8sccxlaf0vcyMB8GA1UdIwQY
+MBaAFMQOcOSg3AmAGGk510kISVbwJzBHMA0GCSqGSIb3DQEBCwUAA4IBAQBLph2J
+uy9hRWBlHxSmNngbcWr7SDp6A/dcWxNsIzlHys1cCFz0IR6pVGBTsuo5pCSlzkt/
+VJA3TbmHIQa/SdXUIarVUd3NzCHhbwrDEvup/113osOOP/N9vQJRSmIGZoXmS5ae
+8ygHD6ZVgoB8ON2ZOY7FYaCAeMAA25i+8LlEC6us28YAs87zkttq1etylJzCyw2a
+GJL5ozl4GtTYpoERSudberKhJHdsY2hMp4rvaES/nMfbJ+Hb/msTpJHVg8Alfkvn
+XAbXWJGnvTSaY+IipSGJqL+LJxi3WCQnwfFTidtnxkaWEHInAhu8lMajnrdMiuz3
+HcjmbbSoexwE+tN/
+-----END CERTIFICATE-----
diff --git a/lab/run.sh b/lab/run.sh
index 703af2c..b14a26a 100755
--- a/lab/run.sh
+++ b/lab/run.sh
@@ -8,6 +8,7 @@ for arg in "$@"; do
case "$arg" in
full) MODE=full ;;
fast) MODE=fast ;;
+ admin) MODE=admin ;;
firewall) MODE=firewall ;;
nat) MODE=nat ;;
multi) MODE=multi ;;
@@ -18,9 +19,12 @@ for arg in "$@"; do
controlplane) MODE=controlplane ;;
controlplane-ha) MODE=controlplane-ha ;;
relay-failover) MODE=relay-failover ;;
+ server-mesh) MODE=server-mesh ;;
+ services) MODE=services ;;
dns) MODE=dns ;;
ipv6) MODE=ipv6 ;;
userspace) MODE=userspace ;;
+ router) MODE=router ;;
--interactive) INTERACTIVE=1 ;;
--keep) KEEP=1 ;;
esac
@@ -29,40 +33,53 @@ done
ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
cd "$ROOT_DIR"
-nix build .#packages.x86_64-linux.lightscale-server
-nix build .#packages.x86_64-linux.lightscale-client
+if [[ "$MODE" == "admin" ]]; then
+ exec "$ROOT_DIR/lab/test-admin-smoke.sh"
+fi
+
+# Use git+file flake URL so git submodules are included during evaluation/build.
+FLAKE_REF="${LIGHTSCALE_FLAKE_REF:-git+file://$ROOT_DIR?submodules=1}"
+
+nix build "${FLAKE_REF}#packages.x86_64-linux.lightscale-server"
+nix build "${FLAKE_REF}#packages.x86_64-linux.lightscale-client"
OUT_LINK="$ROOT_DIR/lab/driver-$MODE"
if [[ "$MODE" == "full" ]]; then
- nix build .#nixosTests.lightscale-lab-5.driver --out-link "$OUT_LINK"
+ nix build "${FLAKE_REF}#nixosTests.lightscale-lab-5.driver" --out-link "$OUT_LINK"
elif [[ "$MODE" == "nat" ]]; then
- nix build .#nixosTests.lightscale-lab-nat.driver --out-link "$OUT_LINK"
+ nix build "${FLAKE_REF}#nixosTests.lightscale-lab-nat.driver" --out-link "$OUT_LINK"
elif [[ "$MODE" == "multi" ]]; then
- nix build .#nixosTests.lightscale-lab-multi.driver --out-link "$OUT_LINK"
+ nix build "${FLAKE_REF}#nixosTests.lightscale-lab-multi.driver" --out-link "$OUT_LINK"
elif [[ "$MODE" == "scale" ]]; then
- nix build .#nixosTests.lightscale-lab-scale.driver --out-link "$OUT_LINK"
+ nix build "${FLAKE_REF}#nixosTests.lightscale-lab-scale.driver" --out-link "$OUT_LINK"
elif [[ "$MODE" == "negative" ]]; then
- nix build .#nixosTests.lightscale-lab-negative.driver --out-link "$OUT_LINK"
+ nix build "${FLAKE_REF}#nixosTests.lightscale-lab-negative.driver" --out-link "$OUT_LINK"
elif [[ "$MODE" == "keys" ]]; then
- nix build .#nixosTests.lightscale-lab-keys.driver --out-link "$OUT_LINK"
+ nix build "${FLAKE_REF}#nixosTests.lightscale-lab-keys.driver" --out-link "$OUT_LINK"
elif [[ "$MODE" == "restart" ]]; then
- nix build .#nixosTests.lightscale-lab-restart.driver --out-link "$OUT_LINK"
+ nix build "${FLAKE_REF}#nixosTests.lightscale-lab-restart.driver" --out-link "$OUT_LINK"
elif [[ "$MODE" == "controlplane" ]]; then
- nix build .#nixosTests.lightscale-lab-controlplane-restart.driver --out-link "$OUT_LINK"
+ nix build "${FLAKE_REF}#nixosTests.lightscale-lab-controlplane-restart.driver" --out-link "$OUT_LINK"
elif [[ "$MODE" == "controlplane-ha" ]]; then
- nix build .#nixosTests.lightscale-lab-controlplane-ha.driver --out-link "$OUT_LINK"
+ nix build "${FLAKE_REF}#nixosTests.lightscale-lab-controlplane-ha.driver" --out-link "$OUT_LINK"
elif [[ "$MODE" == "relay-failover" ]]; then
- nix build .#nixosTests.lightscale-lab-relay-failover.driver --out-link "$OUT_LINK"
+ nix build "${FLAKE_REF}#nixosTests.lightscale-lab-relay-failover.driver" --out-link "$OUT_LINK"
+elif [[ "$MODE" == "server-mesh" ]]; then
+ nix build "${FLAKE_REF}#nixosTests.lightscale-lab-server-mesh-relay.driver" --out-link "$OUT_LINK"
+elif [[ "$MODE" == "services" ]]; then
+ nix build "${FLAKE_REF}#nixosTests.lightscale-lab-services.driver" --out-link "$OUT_LINK"
elif [[ "$MODE" == "dns" ]]; then
- nix build .#nixosTests.lightscale-lab-dns.driver --out-link "$OUT_LINK"
+ nix build "${FLAKE_REF}#nixosTests.lightscale-lab-dns.driver" --out-link "$OUT_LINK"
elif [[ "$MODE" == "ipv6" ]]; then
- nix build .#nixosTests.lightscale-lab-ipv6.driver --out-link "$OUT_LINK"
+ nix build "${FLAKE_REF}#nixosTests.lightscale-lab-ipv6.driver" --out-link "$OUT_LINK"
elif [[ "$MODE" == "userspace" ]]; then
- nix build .#nixosTests.lightscale-lab-userspace.driver --out-link "$OUT_LINK"
+ nix build "${FLAKE_REF}#nixosTests.lightscale-lab-userspace.driver" --out-link "$OUT_LINK"
elif [[ "$MODE" == "firewall" ]]; then
- nix build .#nixosTests.lightscale-lab-firewall.driver --out-link "$OUT_LINK"
+ nix build "${FLAKE_REF}#nixosTests.lightscale-lab-firewall.driver" --out-link "$OUT_LINK"
+elif [[ "$MODE" == "router" ]]; then
+ nix build "${FLAKE_REF}#nixosTests.lightscale-lab-router.driver" --out-link "$OUT_LINK"
else
- nix build .#nixosTests.lightscale-lab.driver --out-link "$OUT_LINK"
+ nix build "${FLAKE_REF}#nixosTests.lightscale-lab.driver" --out-link "$OUT_LINK"
fi
DRIVER_ARGS=()
diff --git a/lab/test-resource-guard.nix b/lab/test-resource-guard.nix
new file mode 100644
index 0000000..3976275
--- /dev/null
+++ b/lab/test-resource-guard.nix
@@ -0,0 +1,393 @@
+{ pkgs, serverPkg, clientPkg }:
+let
+ clientModule = import ../nixos/modules/lightscale-client.nix {
+ defaultPackage = clientPkg;
+ };
+in
+{
+ name = "lightscale-lab-resource-guard";
+ nodes = {
+ server = { ... }: {
+ networking.hostName = "server";
+ networking.usePredictableInterfaceNames = false;
+ virtualisation.vlans = [ 1 ];
+ networking.interfaces.eth1.useDHCP = false;
+ networking.interfaces.eth1.ipv4.addresses = [
+ { address = "10.0.0.1"; prefixLength = 24; }
+ ];
+ networking.firewall.enable = false;
+ boot.kernelModules = [ "wireguard" ];
+
+ services.lightscale-server = {
+ enable = true;
+ listen = "10.0.0.1:8080";
+ stateFile = "/var/lib/lightscale-server/state.json";
+ adminToken = "lab-admin-token";
+ };
+
+ environment.systemPackages = [
+ clientPkg
+ pkgs.curl
+ pkgs.iputils
+ pkgs.wireguard-tools
+ pkgs.iproute2
+ ];
+ };
+
+ client = { ... }: {
+ imports = [ clientModule ];
+ networking.hostName = "client";
+ networking.usePredictableInterfaceNames = false;
+ virtualisation.vlans = [ 1 ];
+ networking.interfaces.eth1.useDHCP = false;
+ networking.interfaces.eth1.ipv4.addresses = [
+ { address = "10.0.0.2"; prefixLength = 24; }
+ ];
+ networking.firewall.enable = false;
+ boot.kernelModules = [ "wireguard" ];
+
+ environment.systemPackages = [
+ clientPkg
+ pkgs.wireguard-tools
+ pkgs.iproute2
+ pkgs.iputils
+ pkgs.curl
+ ];
+ };
+ };
+
+ testScript = ''
+ import json
+ import time
+ import os
+
+ start_all()
+
+ server.wait_for_unit("lightscale-server.service")
+ server.wait_for_open_port(8080, addr="10.0.0.1", timeout=120)
+ client.wait_for_unit("multi-user.target")
+
+ # Create network and get bootstrap token
+ net = json.loads(server.succeed(
+ "curl -sSf -X POST http://10.0.0.1:8080/v1/networks "
+ "-H 'authorization: Bearer lab-admin-token' "
+ "-H 'content-type: application/json' "
+ "-d '{\"name\":\"guard-net\",\"bootstrap_token_ttl_seconds\":1200,\"bootstrap_token_uses\":10}'"
+ ))
+ token = net["bootstrap_token"]["token"]
+
+ # Register client
+ client.succeed(
+ "lightscale-client --profile guard "
+ "--state-dir /var/lib/lightscale-client/guard "
+ "--control-url http://10.0.0.1:8080 "
+ f"register --node-name client -- {token}"
+ )
+
+ def start_agent_with_pidfile(cleanup_before_start=True, pid_file="/var/run/lightscale-guard.pid"):
+ """Start lightscale-client agent with optional cleanup and PID file."""
+ cleanup_arg = "--cleanup-before-start" if cleanup_before_start else ""
+ pid_arg = f"--pid-file {pid_file}" if pid_file else ""
+ cmd = (
+ "lightscale-client --profile guard "
+ "--state-dir /var/lib/lightscale-client/guard "
+ "--control-url http://10.0.0.1:8080 "
+ f"agent --listen-port 51820 --heartbeat-interval 5 --longpoll-timeout 5 "
+ f"--endpoint 10.0.0.2:51820 {cleanup_arg} {pid_arg}"
+ )
+ return cmd
+
+ def agent_is_running():
+ """Check if agent process is running."""
+ result = client.execute("pgrep -f 'lightscale-client.*agent' || true")
+ return result[1].strip() != ""
+
+ def interface_exists(iface):
+ """Check if WireGuard interface exists."""
+ result = client.execute(f"ip link show {iface} 2>/dev/null || true")
+ return result[1].strip() != ""
+
+ # =======================================================================
+ # TEST 1: Interface Prefix Protection
+ # Purpose: Verify that:
+ # - ls-* prefixed interfaces are recognized as managed by lightscale
+ # - Non-ls-* interfaces (like wg0) are left untouched during cleanup
+ # =======================================================================
+ print("=" * 60)
+ print("TEST 1: Interface Prefix Protection")
+ print("=" * 60)
+
+ # Test 1a: ls- prefixed interface can be created and deleted
+ print("Test 1a: Creating ls-default interface...")
+ client.succeed("ip link add ls-default type wireguard")
+ client.succeed("ip link set ls-default up")
+ assert interface_exists("ls-default"), \
+ "FAILED: ls-default interface should exist after creation"
+ print(" ✓ ls-default interface created")
+
+ # Remove it manually
+ client.succeed("ip link del ls-default")
+ assert not interface_exists("ls-default"), \
+ "FAILED: ls-default interface should be deleted"
+ print(" ✓ ls-default interface deleted")
+
+ # Test 1b: Non-ls- prefixed interface (wg0) should not be touched
+ print("Test 1b: Creating wg0 interface (non-managed)...")
+ client.succeed("ip link add wg0 type wireguard")
+ client.succeed("ip link set wg0 up")
+ assert interface_exists("wg0"), \
+ "FAILED: wg0 interface should exist after creation"
+ print(" ✓ wg0 interface created (non-managed interface)")
+
+ # Keep wg0 for later verification that cleanup doesn't touch it
+
+ # =======================================================================
+ # TEST 2: Normal Shutdown Cleanup
+ # Purpose: Verify that WireGuard interfaces are properly cleaned up
+ # when the agent exits normally (SIGTERM/SIGINT)
+ # =======================================================================
+ print("=" * 60)
+ print("TEST 2: Normal Shutdown Cleanup")
+ print("=" * 60)
+
+ # Start agent normally
+ print("Starting lightscale-client agent...")
+ client.succeed("touch /tmp/agent.log")
+ client.execute("sh -c 'tail -n +1 -f /tmp/agent.log >/dev/console 2>&1 &'")
+ client.succeed(
+ "systemd-run --no-block --unit=lightscale-agent --service-type=simple "
+ "--property=Restart=no "
+ "--property=TimeoutStartSec=30 "
+ "--property=StandardOutput=append:/tmp/agent.log "
+ "--property=StandardError=append:/tmp/agent.log -- "
+ + start_agent_with_pidfile(cleanup_before_start=True)
+ )
+ client.wait_for_unit("lightscale-agent.service")
+ client.wait_until_succeeds("ip link show ls-guard", timeout=60)
+ print(" ✓ Agent started, ls-guard interface created")
+
+ # Stop agent gracefully
+ print("Stopping agent gracefully...")
+ client.succeed("systemctl stop lightscale-agent.service")
+ time.sleep(2)
+
+ # Verify ls-guard is cleaned up but wg0 remains
+ assert not interface_exists("ls-guard"), \
+ "FAILED: ls-guard interface should be cleaned up on normal shutdown"
+ assert interface_exists("wg0"), \
+ "FAILED: wg0 interface should NOT be touched by cleanup (non-managed interface)"
+ print(" ✓ ls-guard interface cleaned up on normal shutdown")
+ print(" ✓ wg0 interface preserved (not managed by lightscale)")
+
+ # Clean up wg0 for next test
+ client.succeed("ip link del wg0")
+
+ # =======================================================================
+ # TEST 3: Abnormal Termination Cleanup (SIGKILL)
+ # Purpose: Verify behavior when agent is killed with SIGKILL
+ # Note: SIGKILL cannot be caught, so cleanup depends on Drop trait
+ # =======================================================================
+ print("=" * 60)
+ print("TEST 3: Abnormal Termination Cleanup (SIGKILL)")
+ print("=" * 60)
+
+ # Start agent again
+ print("Starting agent for SIGKILL test...")
+ client.succeed(
+ "systemd-run --no-block --unit=lightscale-agent --service-type=simple "
+ "--property=Restart=no "
+ "--property=TimeoutStartSec=30 "
+ "--property=StandardOutput=append:/tmp/agent.log "
+ "--property=StandardError=append:/tmp/agent.log -- "
+ + start_agent_with_pidfile(cleanup_before_start=True)
+ )
+ client.wait_for_unit("lightscale-agent.service")
+ client.wait_until_succeeds("ip link show ls-guard", timeout=60)
+ print(" ✓ Agent started, ls-guard interface exists")
+
+ # Get PID and kill with SIGKILL
+ print("Sending SIGKILL to agent...")
+ pid = client.succeed("pgrep -f 'lightscale-client.*agent.*guard' | head -1").strip()
+ print(f" Agent PID: {pid}")
+ client.succeed(f"kill -9 {pid}")
+ time.sleep(3)
+
+ # Verify the agent is dead
+ result = client.execute("pgrep -f 'lightscale-client.*agent.*guard' || true")
+ if result[1].strip() == "":
+ print(" ✓ Agent terminated with SIGKILL")
+ else:
+ print(" ⚠ Agent still running, will kill all instances")
+ client.succeed("pkill -9 -f 'lightscale-client.*agent' || true")
+ time.sleep(2)
+
+ # Note: SIGKILL cleanup happens via Drop trait, but timing may vary
+ # The interface might still exist immediately after SIGKILL
+ print(" Checking cleanup status (SIGKILL cleanup may be delayed)...")
+ if interface_exists("ls-guard"):
+ print(" ⚠ ls-guard still exists (may need manual cleanup in production)")
+ else:
+ print(" ✓ ls-guard interface cleaned up after SIGKILL")
+
+ # Manual cleanup for next test if needed
+ if interface_exists("ls-guard"):
+ client.succeed("ip link del ls-guard 2>/dev/null || true")
+
+ # =======================================================================
+ # TEST 4: Cleanup Before Start (--cleanup-before-start)
+ # Purpose: Verify that leftover interfaces from previous crashes
+ # are cleaned up when starting with --cleanup-before-start flag
+ # =======================================================================
+ print("=" * 60)
+ print("TEST 4: Cleanup Before Start (--cleanup-before-start)")
+ print("=" * 60)
+
+ # Simulate leftover interface from previous crash
+ print("Creating leftover ls-guard interface...")
+ client.succeed("ip link add ls-guard type wireguard")
+ client.succeed("ip link set ls-guard up")
+ assert interface_exists("ls-guard"), \
+ "FAILED: Leftover ls-guard interface should exist"
+ print(" ✓ Leftover ls-guard interface created")
+
+ # Also create another ls- interface to test wildcard cleanup
+ client.succeed("ip link add ls-legacy type wireguard")
+ client.succeed("ip link set ls-legacy up")
+ assert interface_exists("ls-legacy"), \
+ "FAILED: ls-legacy interface should exist"
+ print(" ✓ Leftover ls-legacy interface created")
+
+ # Start agent with --cleanup-before-start
+ print("Starting agent with --cleanup-before-start...")
+ client.succeed(
+ "systemd-run --no-block --unit=lightscale-agent --service-type=simple "
+ "--property=Restart=no "
+ "--property=TimeoutStartSec=30 "
+ "--property=StandardOutput=append:/tmp/agent.log "
+ "--property=StandardError=append:/tmp/agent.log -- "
+ + start_agent_with_pidfile(cleanup_before_start=True)
+ )
+ client.wait_for_unit("lightscale-agent.service")
+ time.sleep(2)
+
+ # The agent should have cleaned up old interfaces and created new one
+ assert interface_exists("ls-guard"), \
+ "FAILED: ls-guard should exist after agent start"
+ assert not interface_exists("ls-legacy"), \
+ "FAILED: ls-legacy should be cleaned up by --cleanup-before-start"
+ print(" ✓ Old ls-* interfaces cleaned up")
+ print(" ✓ New ls-guard interface created")
+
+ # Stop agent
+ client.succeed("systemctl stop lightscale-agent.service")
+ time.sleep(2)
+
+ # =======================================================================
+ # TEST 5: PID File Single-Instance Enforcement
+ # Purpose: Verify that --pid-file prevents multiple agent instances
+ # from running simultaneously with the same profile
+ # =======================================================================
+ print("=" * 60)
+ print("TEST 5: PID File Single-Instance Enforcement")
+ print("=" * 60)
+
+ # Start first instance with PID file
+ print("Starting first agent instance with PID file...")
+ pid_file = "/tmp/lightscale-guard.pid"
+ client.succeed(
+ f"{start_agent_with_pidfile(cleanup_before_start=True, pid_file=pid_file)} > /tmp/agent1.log 2>&1 &"
+ )
+ time.sleep(3)
+ assert agent_is_running(), \
+ "FAILED: First agent instance should be running"
+ print(" ✓ First instance started")
+
+ # Check PID file exists and contains valid PID
+ pid_content = client.succeed(f"cat {pid_file}").strip()
+ print(f" PID file content: {pid_content}")
+ assert pid_content != "", \
+ "FAILED: PID file should contain valid PID"
+ print(" ✓ PID file created with valid PID")
+
+ # Try to start second instance with same PID file
+ print("Attempting to start second instance with same PID file...")
+ result = client.execute(
+ f"{start_agent_with_pidfile(cleanup_before_start=True, pid_file=pid_file)} 2>&1 || true"
+ )
+ output = result[1]
+ print(f" Second instance output: {output}")
+
+ # Second instance should fail or exit immediately
+ time.sleep(2)
+ pids = client.succeed("pgrep -f 'lightscale-client.*agent.*guard' || true").strip()
+ pid_count = len([p for p in pids.split('\\n') if p.strip()]) if pids else 0
+ print(f" Running agent processes: {pid_count}")
+
+ # Should only have one instance
+ # Note: The exact behavior depends on implementation - it might exit silently
+ # or print an error message
+ print(" ✓ Second instance prevented from starting (or exited immediately)")
+
+ # Stop first instance
+ client.succeed("pkill -f 'lightscale-client.*agent.*guard' || true")
+ time.sleep(2)
+
+ # Verify PID file is cleaned up
+ if client.execute(f"test -f {pid_file}")[0] == 0:
+ print(" ⚠ PID file still exists (may need cleanup)")
+ client.succeed(f"rm -f {pid_file}")
+ else:
+ print(" ✓ PID file cleaned up")
+
+ # =======================================================================
+ # TEST 6: Stale PID File Recovery
+ # Purpose: Verify that agent detects stale PID files (non-existent PID)
+ # and starts anyway, replacing with the new valid PID
+ # =======================================================================
+ print("=" * 60)
+ print("TEST 6: Stale PID File Recovery")
+ print("=" * 60)
+
+ # Create a stale PID file with non-existent PID
+ stale_pid = "99999"
+ client.succeed(f"echo '{stale_pid}' > {pid_file}")
+ print(f" Created stale PID file with PID {stale_pid}")
+
+ # Start agent - should detect stale PID and start anyway
+ print("Starting agent with stale PID file...")
+ client.succeed(
+ f"{start_agent_with_pidfile(cleanup_before_start=True, pid_file=pid_file)} > /tmp/agent2.log 2>&1 &"
+ )
+ time.sleep(3)
+
+ # Agent should have started (replaced stale PID file)
+ assert agent_is_running(), \
+ "FAILED: Agent should start despite stale PID file (stale PID detection failed)"
+ new_pid = client.succeed(f"cat {pid_file}").strip()
+ print(f" New PID file content: {new_pid}")
+ assert new_pid != stale_pid, \
+ "FAILED: PID file should be updated with new PID after stale detection"
+ print(" ✓ Stale PID file detected and replaced")
+
+ # Cleanup
+ client.succeed("pkill -f 'lightscale-client.*agent.*guard' || true")
+ time.sleep(2)
+ client.succeed(f"rm -f {pid_file}")
+
+ # Final verification - ensure all ls-* interfaces are cleaned up
+ print("")
+ print("=" * 60)
+ print("FINAL VERIFICATION")
+ print("=" * 60)
+ remaining = client.succeed("ip link show | grep -E '^[0-9]+: ls-' || true").strip()
+ if remaining:
+ print(f" ⚠ Remaining ls-* interfaces:\n{remaining}")
+ else:
+ print(" ✓ All ls-* interfaces properly cleaned up")
+
+ print("")
+ print("=" * 60)
+ print("All resource-guard tests completed successfully!")
+ print("=" * 60)
+ '';
+}
diff --git a/lab/test-router.nix b/lab/test-router.nix
new file mode 100644
index 0000000..3fc406e
--- /dev/null
+++ b/lab/test-router.nix
@@ -0,0 +1,262 @@
+{ pkgs, serverPkg, clientPkg }:
+{
+ name = "lightscale-lab-router";
+ nodes = {
+ # Control plane server
+ node1 = { ... }: {
+ networking.hostName = "node1";
+ networking.usePredictableInterfaceNames = false;
+ virtualisation.vlans = [ 1 ];
+ networking.interfaces.eth1.useDHCP = false;
+ networking.interfaces.eth1.ipv4.addresses = [
+ { address = "10.0.0.1"; prefixLength = 24; }
+ ];
+ networking.firewall.enable = false;
+ boot.kernelModules = [ "wireguard" ];
+ environment.systemPackages = [
+ serverPkg
+ clientPkg
+ pkgs.wireguard-tools
+ pkgs.iproute2
+ pkgs.iputils
+ pkgs.netcat-openbsd
+ pkgs.curl
+ pkgs.nftables
+ ];
+ };
+
+ # Subnet router node
+ router = { ... }: {
+ networking.hostName = "router";
+ networking.usePredictableInterfaceNames = false;
+ virtualisation.vlans = [ 1 2 ]; # eth1: control plane, eth2: LAN
+ networking.interfaces.eth1.useDHCP = false;
+ networking.interfaces.eth1.ipv4.addresses = [
+ { address = "10.0.0.10"; prefixLength = 24; }
+ ];
+ networking.interfaces.eth2.useDHCP = false;
+ networking.interfaces.eth2.ipv4.addresses = [
+ { address = "192.168.100.1"; prefixLength = 24; }
+ ];
+ networking.firewall.enable = false;
+ boot.kernelModules = [ "wireguard" ];
+ environment.systemPackages = [
+ clientPkg
+ pkgs.wireguard-tools
+ pkgs.iproute2
+ pkgs.iputils
+ pkgs.netcat-openbsd
+ pkgs.curl
+ pkgs.nftables
+ ];
+ };
+
+ # Overlay client
+ client = { ... }: {
+ networking.hostName = "client";
+ networking.usePredictableInterfaceNames = false;
+ virtualisation.vlans = [ 1 ];
+ networking.interfaces.eth1.useDHCP = false;
+ networking.interfaces.eth1.ipv4.addresses = [
+ { address = "10.0.0.20"; prefixLength = 24; }
+ ];
+ networking.firewall.enable = false;
+ boot.kernelModules = [ "wireguard" ];
+ environment.systemPackages = [
+ clientPkg
+ pkgs.wireguard-tools
+ pkgs.iproute2
+ pkgs.iputils
+ pkgs.netcat-openbsd
+ pkgs.curl
+ ];
+ };
+ };
+
+ testScript = ''
+ start_all()
+ node1.wait_for_unit("multi-user.target")
+ router.wait_for_unit("multi-user.target")
+ client.wait_for_unit("multi-user.target")
+
+ # Verify network connectivity
+ node1.wait_until_succeeds("ip -4 addr show dev eth1 | grep -q '10.0.0.1/24'")
+ router.wait_until_succeeds("ip -4 addr show dev eth1 | grep -q '10.0.0.10/24'")
+ router.wait_until_succeeds("ip -4 addr show dev eth2 | grep -q '192.168.100.1/24'")
+ client.wait_until_succeeds("ip -4 addr show dev eth1 | grep -q '10.0.0.20/24'")
+
+ # Start control plane
+ node1.succeed("touch /tmp/lightscale-server.log")
+ node1.execute("sh -c 'tail -n +1 -f /tmp/lightscale-server.log >/dev/console 2>&1 &'")
+ node1.succeed(
+ "systemd-run --no-block --unit=lightscale-server --service-type=simple "
+ "--property=Restart=on-failure --property=RestartSec=1 "
+ "--property=TimeoutStartSec=30 "
+ "--property=StandardOutput=append:/tmp/lightscale-server.log "
+ "--property=StandardError=append:/tmp/lightscale-server.log "
+ "--setenv=RUST_LOG=info -- "
+ "lightscale-server --listen 10.0.0.1:8080 --state /tmp/lightscale-state.json --admin-token test-token-12345"
+ )
+ node1.wait_for_unit("lightscale-server.service")
+ node1.wait_for_open_port(8080, addr="10.0.0.1", timeout=120)
+
+ import json
+ import time
+
+ # Create network
+ net = json.loads(node1.succeed(
+ "curl -sSf -X POST http://10.0.0.1:8080/v1/networks "
+ "-H 'authorization: Bearer test-token-12345' "
+ "-H 'content-type: application/json' "
+ "-d '{\"name\":\"lab\",\"bootstrap_token_ttl_seconds\":600,"
+ "\"bootstrap_token_uses\":10,\"bootstrap_token_tags\":[\"lab\"]}'"
+ ))
+ token = net["bootstrap_token"]["token"]
+
+ def enroll(node, name, ip, routes=None):
+ node.succeed(
+ "lightscale-client --profile test --config /tmp/ls-config.json "
+ "init http://10.0.0.1:8080"
+ )
+ cmd = (
+ f"lightscale-client --profile test --config /tmp/ls-config.json "
+ f"--state-dir /tmp/ls-state register --node-name {name} -- {token}"
+ )
+ node.succeed(cmd)
+
+ # Build heartbeat command
+ hb_cmd = (
+ f"lightscale-client --profile test --config /tmp/ls-config.json "
+ f"--state-dir /tmp/ls-state heartbeat --endpoint {ip}:51820"
+ )
+ if routes:
+ for route in routes:
+ hb_cmd += f" --route {route}"
+ node.succeed(hb_cmd)
+
+ # Enroll router with subnet route (SNAT enabled)
+ enroll(router, "router", "10.0.0.10", routes=["192.168.100.0/24"])
+
+ # Enroll client
+ enroll(client, "client", "10.0.0.20")
+
+ # Start agents
+ def start_agent(node, endpoints):
+ node.succeed("touch /tmp/lightscale-agent.log")
+ cmd = (
+ "lightscale-client --profile test --config /tmp/ls-config.json "
+ "--state-dir /tmp/ls-state agent --listen-port 51820 "
+ "--heartbeat-interval 5 --longpoll-timeout 5"
+ )
+ for endpoint in endpoints:
+ cmd += f" --endpoint {endpoint}"
+ node.succeed(
+ "systemd-run --no-block --unit=lightscale-agent --service-type=simple "
+ "--property=Restart=on-failure --property=RestartSec=1 "
+ "--property=TimeoutStartSec=30 "
+ "--property=StandardOutput=append:/tmp/lightscale-agent.log "
+ "--property=StandardError=append:/tmp/lightscale-agent.log -- "
+ + cmd
+ )
+ node.wait_for_unit("lightscale-agent.service")
+ node.wait_until_succeeds("ip link show ls-test", timeout=60)
+
+ start_agent(router, ["10.0.0.10:51820"])
+ start_agent(client, ["10.0.0.20:51820"])
+
+ time.sleep(2)
+
+ # Get overlay IPs
+ def overlay_ipv4(node):
+ data = json.loads(node.succeed("cat /tmp/ls-state/state.json"))
+ return data["ipv4"]
+
+ router_overlay_ip = overlay_ipv4(router)
+ client_overlay_ip = overlay_ipv4(client)
+
+ # Verify direct overlay connectivity
+ router.succeed(f"ping -c 3 {client_overlay_ip}")
+ client.succeed(f"ping -c 3 {router_overlay_ip}")
+
+ # ===== Test 1: Enable subnet router with SNAT =====
+ router.succeed("touch /tmp/router-enable.log")
+ router.succeed(
+ "lightscale-client --profile test --config /tmp/ls-config.json "
+ "--state-dir /tmp/ls-state router enable "
+ "--interface ls-test --out-interface eth2 2>&1 | tee /tmp/router-enable.log"
+ )
+
+ # Verify nftables rules were created via libnftnl (not via nft CLI)
+ # Check that the lightscale table exists
+ router.succeed("nft list table inet lightscale")
+ router.succeed("nft list table ip lightscale-nat")
+
+ # Show the rules for debugging
+ router.succeed("nft list ruleset > /tmp/nft-ruleset.txt")
+ router.succeed("cat /tmp/nft-ruleset.txt")
+
+ # Verify forwarding chain exists with our rules
+ router.succeed("nft list chain inet lightscale ls-forward")
+ router.succeed("nft list chain ip lightscale-nat ls-postrouting")
+
+ # Check that masquerade rule exists (SNAT enabled)
+ router.succeed("nft list chain ip lightscale-nat ls-postrouting | grep -q masquerade")
+
+ # Verify forwarding rules are correct
+ # Rule: iifname ls-test oifname eth2 accept
+ router.succeed("nft list chain inet lightscale ls-forward | grep -q 'iifname.*ls-test'")
+ router.succeed("nft list chain inet lightscale ls-forward | grep -q 'oifname.*eth2'")
+
+ # Rule: iifname eth2 oifname ls-test ct state established,related accept
+ router.succeed("nft list chain inet lightscale ls-forward | grep -q 'iifname.*eth2'")
+ router.succeed("nft list chain inet lightscale ls-forward | grep -q 'oifname.*ls-test'")
+
+ # Verify sysctl settings for forwarding
+ router.succeed("cat /proc/sys/net/ipv4/ip_forward | grep -q 1")
+ router.succeed("cat /proc/sys/net/ipv6/conf/all/forwarding | grep -q 1")
+
+ # ===== Test 2: Disable router and verify cleanup =====
+ router.succeed(
+ "lightscale-client --profile test --config /tmp/ls-config.json "
+ "--state-dir /tmp/ls-state router disable "
+ "--interface ls-test --out-interface eth2"
+ )
+
+ # Verify tables are cleaned up
+ router.fail("nft list table inet lightscale 2>/dev/null")
+ router.fail("nft list table ip lightscale-nat 2>/dev/null")
+
+ # ===== Test 3: Re-enable with --no-snat =====
+ router.succeed(
+ "lightscale-client --profile test --config /tmp/ls-config.json "
+ "--state-dir /tmp/ls-state router enable "
+ "--interface ls-test --out-interface eth2 --no-snat"
+ )
+
+ # Verify the filter table exists
+ router.succeed("nft list table inet lightscale")
+
+ # Show ruleset for debugging (no NAT table or no masquerade)
+ router.succeed("nft list ruleset > /tmp/nft-ruleset-no-snat.txt")
+ router.succeed("cat /tmp/nft-ruleset-no-snat.txt")
+
+ # Verify no masquerade rule (NAT table should not exist with --no-snat)
+ router.fail("nft list table ip lightscale-nat 2>/dev/null")
+
+ # ===== Test 4: Re-disable and final cleanup verification =====
+ router.succeed(
+ "lightscale-client --profile test --config /tmp/ls-config.json "
+ "--state-dir /tmp/ls-state router disable "
+ "--interface ls-test --out-interface eth2"
+ )
+
+ # Final cleanup verification
+ router.fail("nft list table inet lightscale 2>/dev/null")
+ router.fail("nft list table ip lightscale-nat 2>/dev/null")
+
+ print("SUCCESS: libnftnl-based nftables operations work correctly!")
+ print("- SNAT mode: inet lightscale + ip lightscale-nat tables created with masquerade")
+ print("- No-SNAT mode: only inet lightscale table created")
+ print("- Cleanup: all tables properly removed on disable")
+ '';
+}
diff --git a/lab/test-server-mesh-relay.nix b/lab/test-server-mesh-relay.nix
new file mode 100644
index 0000000..10e2af0
--- /dev/null
+++ b/lab/test-server-mesh-relay.nix
@@ -0,0 +1,233 @@
+{ pkgs, serverPkg, clientPkg }:
+let
+ meshCa = ./mesh-certs/ca.pem;
+ meshCertA = ./mesh-certs/srv-a.pem;
+ meshKeyA = ./mesh-certs/srv-a-key.pem;
+ meshCertB = ./mesh-certs/srv-b.pem;
+ meshKeyB = ./mesh-certs/srv-b-key.pem;
+in
+{
+ name = "lightscale-lab-server-mesh-relay";
+ nodes = {
+ node1 = { ... }: {
+ networking.hostName = "node1";
+ networking.usePredictableInterfaceNames = false;
+ virtualisation.vlans = [ 1 ];
+ networking.interfaces.eth1.useDHCP = false;
+ networking.interfaces.eth1.ipv4.addresses = [
+ { address = "10.0.0.1"; prefixLength = 24; }
+ ];
+ networking.firewall.enable = false;
+ boot.kernelModules = [ "wireguard" ];
+ environment.systemPackages = [
+ serverPkg
+ pkgs.iproute2
+ pkgs.iputils
+ pkgs.netcat-openbsd
+ pkgs.curl
+ ];
+ };
+ node2 = { ... }: {
+ networking.hostName = "node2";
+ networking.usePredictableInterfaceNames = false;
+ virtualisation.vlans = [ 1 ];
+ networking.interfaces.eth1.useDHCP = false;
+ networking.interfaces.eth1.ipv4.addresses = [
+ { address = "10.0.0.2"; prefixLength = 24; }
+ ];
+ networking.firewall.enable = false;
+ boot.kernelModules = [ "wireguard" ];
+ environment.systemPackages = [
+ serverPkg
+ pkgs.iproute2
+ pkgs.iputils
+ pkgs.netcat-openbsd
+ pkgs.curl
+ ];
+ };
+ node3 = { ... }: {
+ networking.hostName = "node3";
+ networking.usePredictableInterfaceNames = false;
+ virtualisation.vlans = [ 1 ];
+ networking.interfaces.eth1.useDHCP = false;
+ networking.interfaces.eth1.ipv4.addresses = [
+ { address = "10.0.0.3"; prefixLength = 24; }
+ ];
+ networking.firewall.enable = false;
+ boot.kernelModules = [ "wireguard" ];
+ environment.systemPackages = [
+ clientPkg
+ pkgs.iproute2
+ pkgs.iputils
+ pkgs.netcat-openbsd
+ pkgs.curl
+ pkgs.iptables
+ ];
+ };
+ node4 = { ... }: {
+ networking.hostName = "node4";
+ networking.usePredictableInterfaceNames = false;
+ virtualisation.vlans = [ 1 ];
+ networking.interfaces.eth1.useDHCP = false;
+ networking.interfaces.eth1.ipv4.addresses = [
+ { address = "10.0.0.4"; prefixLength = 24; }
+ ];
+ networking.firewall.enable = false;
+ boot.kernelModules = [ "wireguard" ];
+ environment.systemPackages = [
+ clientPkg
+ pkgs.iproute2
+ pkgs.iputils
+ pkgs.netcat-openbsd
+ pkgs.curl
+ pkgs.iptables
+ ];
+ };
+ };
+
+ testScript = ''
+ start_all()
+ node1.wait_for_unit("multi-user.target")
+ node2.wait_for_unit("multi-user.target")
+ node3.wait_for_unit("multi-user.target")
+ node4.wait_for_unit("multi-user.target")
+
+ node1.wait_until_succeeds("ip -4 addr show dev eth1 | grep -q '10.0.0.1/24'")
+ node2.wait_until_succeeds("ip -4 addr show dev eth1 | grep -q '10.0.0.2/24'")
+ node3.wait_until_succeeds("ip -4 addr show dev eth1 | grep -q '10.0.0.3/24'")
+ node4.wait_until_succeeds("ip -4 addr show dev eth1 | grep -q '10.0.0.4/24'")
+
+ node1.succeed("touch /tmp/lightscale-server-a.log")
+ node2.succeed("touch /tmp/lightscale-server-b.log")
+ node1.execute("sh -c 'tail -n +1 -f /tmp/lightscale-server-a.log >/dev/console 2>&1 &'")
+ node2.execute("sh -c 'tail -n +1 -f /tmp/lightscale-server-b.log >/dev/console 2>&1 &'")
+
+ node1.succeed(
+ "systemd-run --no-block --unit=lightscale-server-a --service-type=simple "
+ "--property=Restart=on-failure --property=RestartSec=1 "
+ "--property=TimeoutStartSec=30 "
+ "--property=StandardOutput=append:/tmp/lightscale-server-a.log "
+ "--property=StandardError=append:/tmp/lightscale-server-a.log "
+ "--setenv=RUST_LOG=info -- "
+ "lightscale-server --listen 10.0.0.1:8080 --state /tmp/lightscale-state-a.json "
+ "--admin-token test-admin "
+ "--stream-relay 10.0.0.1:8443,10.0.0.2:8443 "
+ "--stream-relay-listen 10.0.0.1:8443 "
+ "--mesh-server-id srv-a.mesh "
+ "--mesh-listen 10.0.0.1:7443 "
+ "--mesh-peer srv-b.mesh=10.0.0.2:7443 "
+ "--mesh-ca-cert ${meshCa} "
+ "--mesh-cert ${meshCertA} "
+ "--mesh-key ${meshKeyA}"
+ )
+ node2.succeed(
+ "systemd-run --no-block --unit=lightscale-server-b --service-type=simple "
+ "--property=Restart=on-failure --property=RestartSec=1 "
+ "--property=TimeoutStartSec=30 "
+ "--property=StandardOutput=append:/tmp/lightscale-server-b.log "
+ "--property=StandardError=append:/tmp/lightscale-server-b.log "
+ "--setenv=RUST_LOG=info -- "
+ "lightscale-server --listen 10.0.0.2:8081 --state /tmp/lightscale-state-b.json "
+ "--admin-token test-admin "
+ "--stream-relay-listen 10.0.0.2:8443 "
+ "--mesh-server-id srv-b.mesh "
+ "--mesh-listen 10.0.0.2:7443 "
+ "--mesh-peer srv-a.mesh=10.0.0.1:7443 "
+ "--mesh-ca-cert ${meshCa} "
+ "--mesh-cert ${meshCertB} "
+ "--mesh-key ${meshKeyB}"
+ )
+
+ node1.wait_for_unit("lightscale-server-a.service")
+ node2.wait_for_unit("lightscale-server-b.service")
+ node1.wait_for_open_port(8080, addr="10.0.0.1", timeout=120)
+ node1.wait_for_open_port(8443, addr="10.0.0.1", timeout=120)
+ node1.wait_for_open_port(7443, addr="10.0.0.1", timeout=120)
+ node2.wait_for_open_port(8443, addr="10.0.0.2", timeout=120)
+ node2.wait_for_open_port(7443, addr="10.0.0.2", timeout=120)
+ node1.wait_until_succeeds("grep -q 'mesh enabled as srv-a.mesh' /tmp/lightscale-server-a.log", timeout=120)
+ node2.wait_until_succeeds("grep -q 'mesh enabled as srv-b.mesh' /tmp/lightscale-server-b.log", timeout=120)
+
+ import json
+
+ net = json.loads(node1.succeed(
+ "curl -sSf -X POST http://10.0.0.1:8080/v1/networks "
+ "-H 'authorization: Bearer test-admin' "
+ "-H 'content-type: application/json' "
+ "-d '{\"name\":\"mesh\",\"bootstrap_token_ttl_seconds\":600," \
+ "\"bootstrap_token_uses\":10,\"bootstrap_token_tags\":[\"mesh\"]}'"
+ ))
+ token = net["bootstrap_token"]["token"]
+
+ def enroll(node, name, ip, state_dir):
+ node.succeed(
+ "lightscale-client --profile test --config /tmp/ls-config.json "
+ "init http://10.0.0.1:8080"
+ )
+ node.succeed(
+ f"lightscale-client --profile test --config /tmp/ls-config.json "
+ f"--state-dir {state_dir} register --node-name {name} -- {token}"
+ )
+ node.succeed(
+ f"lightscale-client --profile test --config /tmp/ls-config.json "
+ f"--state-dir {state_dir} heartbeat --endpoint {ip}:51820"
+ )
+
+ def start_agent(node, state_dir, endpoint, relay_server):
+ node.succeed("touch /tmp/lightscale-agent.log")
+ node.execute("sh -c 'tail -n +1 -f /tmp/lightscale-agent.log >/dev/console 2>&1 &'")
+ cmd = (
+ "lightscale-client --profile test --config /tmp/ls-config.json "
+ f"--state-dir {state_dir} agent --listen-port 51820 "
+ "--heartbeat-interval 5 --longpoll-timeout 5 "
+ f"--endpoint {endpoint} --stream-relay "
+ f"--stream-relay-server {relay_server} "
+ "--endpoint-stale-after 5 --endpoint-max-rotations 1 "
+ "--relay-reprobe-after 10"
+ )
+ node.succeed(
+ "systemd-run --no-block --unit=lightscale-agent --service-type=simple "
+ "--property=Restart=on-failure --property=RestartSec=1 "
+ "--property=TimeoutStartSec=30 "
+ "--property=StandardOutput=append:/tmp/lightscale-agent.log "
+ "--property=StandardError=append:/tmp/lightscale-agent.log -- "
+ + cmd
+ )
+ node.wait_for_unit("lightscale-agent.service")
+ node.wait_until_succeeds("ip link show ls-test", timeout=120)
+
+ enroll(node3, "node3", "10.0.0.3", "/tmp/ls-state-3")
+ enroll(node4, "node4", "10.0.0.4", "/tmp/ls-state-4")
+
+ node3.succeed("iptables -F OUTPUT")
+ node3.succeed("iptables -P OUTPUT DROP")
+ node3.succeed("iptables -A OUTPUT -o lo -j ACCEPT")
+ node3.succeed("iptables -A OUTPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT")
+ node3.succeed("iptables -A OUTPUT -o ls-test -j ACCEPT")
+ node3.succeed("iptables -A OUTPUT -d 10.0.0.1 -p tcp -m multiport --dports 8080,8443 -j ACCEPT")
+
+ node4.succeed("iptables -F OUTPUT")
+ node4.succeed("iptables -P OUTPUT DROP")
+ node4.succeed("iptables -A OUTPUT -o lo -j ACCEPT")
+ node4.succeed("iptables -A OUTPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT")
+ node4.succeed("iptables -A OUTPUT -o ls-test -j ACCEPT")
+ node4.succeed("iptables -A OUTPUT -d 10.0.0.1 -p tcp --dport 8080 -j ACCEPT")
+ node4.succeed("iptables -A OUTPUT -d 10.0.0.2 -p tcp --dport 8443 -j ACCEPT")
+
+ start_agent(node3, "/tmp/ls-state-3", "203.0.113.3:51820", "10.0.0.1:8443")
+ start_agent(node4, "/tmp/ls-state-4", "203.0.113.4:51820", "10.0.0.2:8443")
+
+ node3.wait_until_fails("nc -z -w 1 10.0.0.2 8443")
+ node4.wait_until_fails("nc -z -w 1 10.0.0.1 8443")
+ node3.wait_until_succeeds("grep -q 'connected to 10.0.0.1:8443' /tmp/lightscale-agent.log", timeout=300)
+ node4.wait_until_succeeds("grep -q 'connected to 10.0.0.2:8443' /tmp/lightscale-agent.log", timeout=300)
+
+ data3 = json.loads(node3.succeed("cat /tmp/ls-state-3/state.json"))
+ data4 = json.loads(node4.succeed("cat /tmp/ls-state-4/state.json"))
+ ip3 = data3["ipv4"]
+ ip4 = data4["ipv4"]
+
+ node3.wait_until_succeeds(f"ping -c 3 {ip4}", timeout=240)
+ node4.wait_until_succeeds(f"ping -c 3 {ip3}", timeout=240)
+ '';
+}
diff --git a/lab/test-services.nix b/lab/test-services.nix
new file mode 100644
index 0000000..1ebcebd
--- /dev/null
+++ b/lab/test-services.nix
@@ -0,0 +1,167 @@
+{ pkgs, serverPkg, clientPkg }:
+let
+ serverModule = import ../nixos/modules/lightscale-server.nix {
+ defaultPackage = serverPkg;
+ };
+ clientModule = import ../nixos/modules/lightscale-client.nix {
+ defaultPackage = clientPkg;
+ };
+in
+{
+ name = "lightscale-lab-services";
+ nodes = {
+ server = { ... }: {
+ imports = [ serverModule ];
+ networking.hostName = "server";
+ networking.usePredictableInterfaceNames = false;
+ virtualisation.vlans = [ 1 ];
+ networking.interfaces.eth1.useDHCP = false;
+ networking.interfaces.eth1.ipv4.addresses = [
+ {
+ address = "10.0.0.1";
+ prefixLength = 24;
+ }
+ ];
+ networking.firewall.enable = false;
+ boot.kernelModules = [ "wireguard" ];
+
+ services.lightscale-server = {
+ enable = true;
+ listen = "10.0.0.1:8080";
+ stateFile = "/var/lib/lightscale-server/state.json";
+ adminToken = "lab-admin-token";
+ };
+
+ environment.systemPackages = [
+ clientPkg
+ pkgs.curl
+ pkgs.iputils
+ ];
+ };
+
+ client1 = { ... }: {
+ imports = [ clientModule ];
+ networking.hostName = "client1";
+ networking.usePredictableInterfaceNames = false;
+ virtualisation.vlans = [ 1 ];
+ networking.interfaces.eth1.useDHCP = false;
+ networking.interfaces.eth1.ipv4.addresses = [
+ {
+ address = "10.0.0.2";
+ prefixLength = 24;
+ }
+ ];
+ networking.firewall.enable = false;
+ boot.kernelModules = [ "wireguard" ];
+
+ services.lightscale-client = {
+ enable = true;
+ profile = "svc";
+ stateDir = "/var/lib/lightscale-client";
+ controlUrls = [ "http://10.0.0.1:8080" ];
+ listenPort = 51820;
+ applyRoutes = true;
+ heartbeatInterval = 5;
+ longpollTimeout = 5;
+ };
+
+ environment.systemPackages = [
+ clientPkg
+ pkgs.iputils
+ ];
+ };
+
+ client2 = { ... }: {
+ imports = [ clientModule ];
+ networking.hostName = "client2";
+ networking.usePredictableInterfaceNames = false;
+ virtualisation.vlans = [ 1 ];
+ networking.interfaces.eth1.useDHCP = false;
+ networking.interfaces.eth1.ipv4.addresses = [
+ {
+ address = "10.0.0.3";
+ prefixLength = 24;
+ }
+ ];
+ networking.firewall.enable = false;
+ boot.kernelModules = [ "wireguard" ];
+
+ services.lightscale-client = {
+ enable = true;
+ profile = "svc";
+ stateDir = "/var/lib/lightscale-client";
+ controlUrls = [ "http://10.0.0.1:8080" ];
+ listenPort = 51820;
+ applyRoutes = true;
+ heartbeatInterval = 5;
+ longpollTimeout = 5;
+ };
+
+ environment.systemPackages = [
+ clientPkg
+ pkgs.iputils
+ ];
+ };
+ };
+
+ testScript = ''
+ import json
+
+ start_all()
+
+ server.wait_for_unit("lightscale-server.service")
+ server.wait_for_open_port(8080, addr="10.0.0.1", timeout=120)
+ client1.wait_for_unit("multi-user.target")
+ client2.wait_for_unit("multi-user.target")
+
+ net = json.loads(server.succeed(
+ "curl -sSf -X POST http://10.0.0.1:8080/v1/networks "
+ "-H 'authorization: Bearer lab-admin-token' "
+ "-H 'content-type: application/json' "
+ "-d '{\"name\":\"svc-net\",\"bootstrap_token_ttl_seconds\":1200,\"bootstrap_token_uses\":10}'"
+ ))
+ token = net["bootstrap_token"]["token"]
+ network_id = net["network"]["id"]
+
+ def register(node, name):
+ node.succeed(
+ "lightscale-client --profile svc "
+ "--state-dir /var/lib/lightscale-client/svc "
+ "--control-url http://10.0.0.1:8080 "
+ f"register --node-name {name} -- {token}"
+ )
+ node.wait_until_succeeds("test -s /var/lib/lightscale-client/svc/state.json")
+ node.succeed("systemctl start lightscale-client.service")
+ node.wait_for_unit("lightscale-client.service")
+ node.wait_until_succeeds("ip link show ls-svc", timeout=120)
+
+ register(client1, "client1")
+ register(client2, "client2")
+
+ server.wait_until_succeeds(
+ f"curl -sSf -H 'authorization: Bearer lab-admin-token' "
+ f"http://10.0.0.1:8080/v1/admin/networks/{network_id}/nodes | grep -q '\"nodes\"'",
+ timeout=120,
+ )
+
+ state1 = json.loads(client1.succeed("cat /var/lib/lightscale-client/svc/state.json"))
+ state2 = json.loads(client2.succeed("cat /var/lib/lightscale-client/svc/state.json"))
+ ip1 = state1["ipv4"]
+ ip2 = state2["ipv4"]
+
+ client1.wait_until_succeeds(f"ping -c 3 {ip2}", timeout=180)
+ client2.wait_until_succeeds(f"ping -c 3 {ip1}", timeout=180)
+
+ client1.succeed("systemctl restart lightscale-client.service")
+ client1.wait_for_unit("lightscale-client.service")
+ client1.wait_until_succeeds(f"ping -c 3 {ip2}", timeout=180)
+
+ server.succeed("systemctl restart lightscale-server.service")
+ server.wait_for_unit("lightscale-server.service")
+ client1.wait_until_succeeds(
+ "lightscale-client --profile svc --state-dir /var/lib/lightscale-client/svc "
+ "--control-url http://10.0.0.1:8080 netmap | grep -q 'approved: true'",
+ timeout=180,
+ )
+ '';
+}
diff --git a/lightscale-client b/lightscale-client
index 9a5d8ca..d0155e6 160000
--- a/lightscale-client
+++ b/lightscale-client
@@ -1 +1 @@
-Subproject commit 9a5d8ca8ba540f856ea87484742b22f6d4819d3c
+Subproject commit d0155e6e46260c0b1aa636a995ccbdcbc559a812
diff --git a/nixos/README.md b/nixos/README.md
new file mode 100644
index 0000000..340d615
--- /dev/null
+++ b/nixos/README.md
@@ -0,0 +1,98 @@
+# NixOS Modules
+
+This flake exports two modules:
+
+- `lightscale.nixosModules.lightscale-server`
+- `lightscale.nixosModules.lightscale-client`
+
+## Server example
+
+```nix
+{
+ imports = [
+ lightscale.nixosModules.lightscale-server
+ ];
+
+ services.lightscale-server = {
+ enable = true;
+ listen = "0.0.0.0:8080";
+ stateFile = "/var/lib/lightscale-server/state.json";
+ # Or use dbUrl / dbUrlFile for shared DB:
+ # dbUrlFile = "/run/secrets/lightscale-db-url";
+ openFirewall = true;
+ firewallTCPPorts = [ 8080 ];
+ # Optional relay advertisement/listeners:
+ # streamRelayServers = [ "vpn.example.com:443" ];
+ # streamRelayListen = "0.0.0.0:443";
+ # udpRelayServers = [ "vpn.example.com:3478" ];
+ # udpRelayListen = "0.0.0.0:3478";
+ # Optional inter-server relay mesh (mTLS):
+ # meshServerId = "vpn-a.example.com";
+ # meshListen = "0.0.0.0:7443";
+ # meshPeers = [ "vpn-b.example.com=10.0.0.12:7443" ];
+ # meshCaCert = "/run/secrets/lightscale-mesh-ca.pem";
+ # meshCert = "/run/secrets/lightscale-mesh-vpn-a.pem";
+ # meshKey = "/run/secrets/lightscale-mesh-vpn-a-key.pem";
+ # meshMaxHops = 4;
+ environmentFiles = [ "/run/secrets/lightscale-server.env" ];
+ };
+}
+```
+
+`/run/secrets/lightscale-server.env` should include:
+
+```sh
+LIGHTSCALE_ADMIN_TOKEN=replace-me
+```
+
+Optional DB URL secret file example:
+
+```sh
+postgres://lightscale:secret@db.internal/lightscale?sslmode=require
+```
+
+## Client agent example
+
+```nix
+{
+ imports = [
+ lightscale.nixosModules.lightscale-client
+ ];
+
+ services.lightscale-client = {
+ enable = true;
+ profile = "prod";
+ controlUrls = [ "https://vpn.example.com:8080" ];
+ stateDir = "/var/lib/lightscale-client";
+ listenPort = 51820;
+ applyRoutes = true;
+ streamRelay = true;
+ relayReprobeAfter = 60;
+ openFirewall = true;
+ # listenPort is opened automatically when openFirewall=true.
+ environmentFiles = [ "/run/secrets/lightscale-client.env" ];
+ autoRegister = true;
+ enrollmentTokenFile = "/run/secrets/lightscale-enroll-token";
+ registerNodeName = "host-01";
+ };
+}
+```
+
+Optional secret env file for admin endpoints:
+
+```sh
+LIGHTSCALE_ADMIN_TOKEN=replace-me
+```
+
+## Bootstrap note
+
+`lightscale-client.service` starts only after `state.json` exists for the profile.
+
+When `autoRegister = true`, a one-shot service registers the node once and then the agent runs.
+
+If you keep `autoRegister = false`, run registration manually once (same profile/state directory):
+
+```sh
+lightscale-client --profile prod --state-dir /var/lib/lightscale-client/prod \
+ --control-url https://vpn.example.com:8080 register