From 26ea9cc9449148345546c23e6d56487ef442b96b Mon Sep 17 00:00:00 2001 From: CodeMaster4711 Date: Sun, 8 Mar 2026 18:20:23 +0100 Subject: [PATCH 1/7] feat: new nix config --- nixos-node/flake.nix | 19 +- nixos-node/modules/node-configuration.nix | 11 +- nixos-node/modules/server-configuration.nix | 300 +++++++++++--------- 3 files changed, 191 insertions(+), 139 deletions(-) diff --git a/nixos-node/flake.nix b/nixos-node/flake.nix index e44f951..c544a22 100644 --- a/nixos-node/flake.nix +++ b/nixos-node/flake.nix @@ -21,6 +21,10 @@ }; csfDaemonModule = import ./modules/csf-daemon.nix; + + agentSpecialArgs = { + csf.agentPackage = csfAgentPkg; + }; in { nixosConfigurations = { @@ -29,9 +33,22 @@ modules = [ ./modules/iso-configuration.nix ]; }; + csf-node = nixpkgs.lib.nixosSystem { + inherit system; + specialArgs = agentSpecialArgs; + modules = [ + csfDaemonModule + ./modules/node-configuration.nix + ]; + }; + csf-server = nixpkgs.lib.nixosSystem { inherit system; - modules = [ ./modules/server-configuration.nix ]; + specialArgs = agentSpecialArgs; + modules = [ + csfDaemonModule + ./modules/server-configuration.nix + ]; }; }; diff --git a/nixos-node/modules/node-configuration.nix b/nixos-node/modules/node-configuration.nix index dbf4f10..ed7ccfb 100644 --- a/nixos-node/modules/node-configuration.nix +++ b/nixos-node/modules/node-configuration.nix @@ -1,4 +1,4 @@ -{ config, pkgs, lib, ... }: +{ config, pkgs, lib, csf, ... }: { system.stateVersion = "24.11"; @@ -22,6 +22,15 @@ services.openssh.enable = false; + services.csf-daemon = { + enable = true; + package = csf.agentPackage; + apiGateway = "http://gateway.csf.local:8000"; + registrationToken = ""; + heartbeatInterval = 60; + logLevel = "info"; + }; + nix = { settings = { experimental-features = [ "nix-command" "flakes" ]; diff --git a/nixos-node/modules/server-configuration.nix b/nixos-node/modules/server-configuration.nix index 89d1f58..51090a1 100644 --- a/nixos-node/modules/server-configuration.nix +++ b/nixos-node/modules/server-configuration.nix @@ -1,135 +1,89 @@ -{ config, pkgs, lib, ... }: +{ config, pkgs, lib, csf, ... }: +let + composeDir = "/etc/csf-core"; +in { - # System configuration - WICHTIG: Muss mit der ursprünglichen Installation übereinstimmen! system.stateVersion = "25.11"; - # Boot configuration boot = { loader.grub = { enable = true; device = "/dev/sda"; useOSProber = true; }; - - # Hardware-spezifische Einstellungen (von hardware-configuration.nix) initrd.availableKernelModules = [ "ata_piix" "uhci_hcd" "virtio_pci" "virtio_scsi" "sd_mod" "sr_mod" ]; - initrd.kernelModules = [ ]; - kernelModules = [ ]; - extraModulePackages = [ ]; + initrd.kernelModules = []; + kernelModules = []; + extraModulePackages = []; }; - # File Systems (von hardware-configuration.nix) fileSystems."/" = { device = "/dev/disk/by-uuid/e4b27226-e75f-4cef-9dec-fc0c6f2185ac"; fsType = "ext4"; }; - swapDevices = [ ]; + swapDevices = []; - # Platform nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux"; - # Networking networking = { - hostName = "nixos"; # Match existing hostname - - # NetworkManager aktivieren (wie auf dem Zielsystem) + hostName = "csf-node"; networkmanager.enable = true; - firewall = { enable = true; allowedTCPPorts = [ - 22 # SSH - 80 # HTTP - 443 # HTTPS - 8080 # Docker nginx test - 8000 # CSF-Core Backend + 22 + 8000 ]; }; }; - # Time zone - time.timeZone = "Europe/Berlin"; + time.timeZone = "UTC"; - # Locale settings - i18n.defaultLocale = "de_DE.UTF-8"; - i18n.extraLocaleSettings = { - LC_ADDRESS = "de_DE.UTF-8"; - LC_IDENTIFICATION = "de_DE.UTF-8"; - LC_MEASUREMENT = "de_DE.UTF-8"; - LC_MONETARY = "de_DE.UTF-8"; - LC_NAME = "de_DE.UTF-8"; - LC_NUMERIC = "de_DE.UTF-8"; - LC_PAPER = "de_DE.UTF-8"; - LC_TELEPHONE = "de_DE.UTF-8"; - LC_TIME = "de_DE.UTF-8"; - }; - - # Console keymap - console.keyMap = "de"; - - # X11 keymap - services.xserver.xkb = { - layout = "de"; - variant = ""; - }; - - # SSH Server für Remote-Zugriff services.openssh = { enable = true; settings = { - PermitRootLogin = "yes"; # Match existing config - PasswordAuthentication = true; # Match existing config + PermitRootLogin = "prohibit-password"; + PasswordAuthentication = false; }; }; - # Bestehenden User rootcsf übernehmen users.users.rootcsf = { isNormalUser = true; description = "rootcsf"; extraGroups = [ "networkmanager" "wheel" "docker" ]; - packages = with pkgs; []; }; - # Sudo ohne Passwort für wheel-Gruppe (für automatisiertes Deployment) security.sudo.wheelNeedsPassword = false; - # GnuPG Agent (wie auf dem Zielsystem) - programs.mtr.enable = true; - programs.gnupg.agent = { + virtualisation.docker = { enable = true; - enableSSHSupport = true; + enableOnBoot = true; }; - # Docker aktivieren - virtualisation.docker = { + services.csf-daemon = { enable = true; - enableOnBoot = true; + package = csf.agentPackage; + apiGateway = "http://localhost:8000"; + registrationToken = ""; + heartbeatInterval = 60; + logLevel = "info"; }; - # System packages environment.systemPackages = with pkgs; [ - # Docker tools docker-compose - - # System utilities curl wget vim htop git tmux - - # Debugging tools lsof - netcat - tcpdump ]; - # Docker Compose service for CSF-Core Backend - systemd.services.docker-compose-csf-backend = { - description = "Docker Compose CSF-Core Backend Service"; + systemd.services.csf-control-plane = { + description = "CSF Control Plane (Docker Compose)"; after = [ "docker.service" "network-online.target" ]; requires = [ "docker.service" ]; wants = [ "network-online.target" ]; @@ -138,30 +92,20 @@ serviceConfig = { Type = "oneshot"; RemainAfterExit = true; - WorkingDirectory = "/etc/csf-core"; - - # Start containers - ExecStart = "${pkgs.docker-compose}/bin/docker-compose up -d --remove-orphans"; - - # Stop containers gracefully - ExecStop = "${pkgs.docker-compose}/bin/docker-compose down"; - - # Timeout settings + WorkingDirectory = composeDir; + ExecStartPre = "${pkgs.docker}/bin/docker compose pull --quiet"; + ExecStart = "${pkgs.docker}/bin/docker compose up -d --remove-orphans"; + ExecStop = "${pkgs.docker}/bin/docker compose down"; TimeoutStartSec = "300"; TimeoutStopSec = "120"; }; }; - # Activation script to setup Docker Compose for CSF-Core Backend - system.activationScripts.docker-setup = { + system.activationScripts.csf-core-setup = { text = '' - # Create csf-core directory - mkdir -p /etc/csf-core - - # Create docker-compose.yml for CSF-Core Backend - cat > /etc/csf-core/docker-compose.yml <<'EOF' -version: '3.8' + mkdir -p ${composeDir} + cat > ${composeDir}/docker-compose.yml <<'COMPOSE' services: postgres: image: postgres:16-alpine @@ -172,8 +116,8 @@ services: POSTGRES_DB: csf_core volumes: - postgres_data:/var/lib/postgresql/data - ports: - - "5432:5432" + networks: + - csf-internal restart: unless-stopped healthcheck: test: ["CMD-SHELL", "pg_isready -U csf -d csf_core"] @@ -181,69 +125,151 @@ services: timeout: 5s retries: 5 - backend: - image: ghcr.io/cs-foundry/csf-core-backend:latest - container_name: csf-backend + etcd: + image: gcr.io/etcd-development/etcd:v3.5.21 + container_name: csf-etcd + command: + - etcd + - --advertise-client-urls=http://etcd:2379 + - --listen-client-urls=http://0.0.0.0:2379 + - --data-dir=/etcd-data + volumes: + - etcd_data:/etcd-data + networks: + - csf-internal + restart: unless-stopped + + api-gateway: + image: ghcr.io/cs-foundry/csf-ce-api-gateway:0.2.2-alpha.353 + container_name: csf-api-gateway + environment: + DATABASE_URL: postgres://csf:csfpassword@postgres:5432/csf_core + RUST_LOG: info + JWT_SECRET: change_me_in_production + RSA_KEY_SIZE: "4096" + REGISTRY_SERVICE_URL: http://registry:8001 + SCHEDULER_SERVICE_URL: http://scheduler:8002 + VOLUME_MANAGER_URL: http://volume-manager:8003 + FAILOVER_CONTROLLER_URL: http://failover-controller:8004 + SDN_CONTROLLER_URL: http://sdn-controller:8005 ports: - "8000:8000" + depends_on: + postgres: + condition: service_healthy + networks: + - csf-internal + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/api/system/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 15s + + registry: + image: ghcr.io/cs-foundry/csf-ce-registry:0.2.2-alpha.353 + container_name: csf-registry environment: - - RUST_LOG=debug - - DATABASE_URL=postgres://csf:csfpassword@postgres:5432/csf_core - - JWT_SECRET=supersecretkey_change_me_in_production - - FRONTEND_URL=http://localhost:3000 + DATABASE_URL: postgres://csf:csfpassword@postgres:5432/csf_core + ETCD_ENDPOINTS: http://etcd:2379 + REGISTRY_PORT: "8001" + RUST_LOG: info + SCHEDULER_SERVICE_URL: http://scheduler:8002 depends_on: postgres: condition: service_healthy + etcd: + condition: service_started + networks: + - csf-internal + restart: unless-stopped + + scheduler: + image: ghcr.io/cs-foundry/csf-ce-scheduler:0.2.2-alpha.353 + container_name: csf-scheduler + environment: + DATABASE_URL: postgres://csf:csfpassword@postgres:5432/csf_core + ETCD_ENDPOINTS: http://etcd:2379 + SCHEDULER_PORT: "8002" + RUST_LOG: info + depends_on: + postgres: + condition: service_healthy + etcd: + condition: service_started + networks: + - csf-internal + restart: unless-stopped + + volume-manager: + image: ghcr.io/cs-foundry/csf-ce-volume-manager:0.2.2-alpha.353 + container_name: csf-volume-manager + environment: + DATABASE_URL: postgres://csf:csfpassword@postgres:5432/csf_core + ETCD_ENDPOINTS: http://etcd:2379 + VOLUME_MANAGER_PORT: "8003" + RUST_LOG: info + volumes: + - /mnt/csf-volumes:/mnt/csf-volumes + depends_on: + postgres: + condition: service_healthy + etcd: + condition: service_started + networks: + - csf-internal + restart: unless-stopped + + failover-controller: + image: ghcr.io/cs-foundry/csf-ce-failover-controller:0.2.2-alpha.353 + container_name: csf-failover-controller + environment: + DATABASE_URL: postgres://csf:csfpassword@postgres:5432/csf_core + FAILOVER_CONTROLLER_PORT: "8004" + SCHEDULER_SERVICE_URL: http://scheduler:8002 + VOLUME_MANAGER_URL: http://volume-manager:8003 + RUST_LOG: info + depends_on: + postgres: + condition: service_healthy + scheduler: + condition: service_started + volume-manager: + condition: service_started + networks: + - csf-internal + restart: unless-stopped + + sdn-controller: + image: ghcr.io/cs-foundry/csf-ce-sdn-controller:0.2.2-alpha.353 + container_name: csf-sdn-controller + environment: + DATABASE_URL: postgres://csf:csfpassword@postgres:5432/csf_core + ETCD_URL: http://etcd:2379 + SDN_CONTROLLER_PORT: "8005" + RUST_LOG: info + depends_on: + postgres: + condition: service_healthy + etcd: + condition: service_started + networks: + - csf-internal restart: unless-stopped volumes: postgres_data: -EOF + etcd_data: - # Create test script - cat > /root/test-csf-backend.sh <<'EOF' -#!/bin/bash -echo "=== CSF-Core Backend Test ===" -echo "Hostname: $(hostname)" -echo "Date: $(date)" -echo "" -echo "Docker version:" -docker --version -echo "" -echo "Docker Compose version:" -docker-compose --version -echo "" -echo "Docker images:" -docker images -echo "" -echo "Running containers:" -docker ps -echo "" -echo "Docker Compose status:" -cd /etc/csf-core && docker-compose ps -echo "" -echo "=== Network Test ===" -echo "Testing backend API:" -curl -s http://localhost:8000/health || echo "Backend not responding" -echo "" -echo "Testing database connection:" -docker exec csf-postgres pg_isready -U csf -d csf_core || echo "Database not ready" -echo "" -echo "=== Test Complete ===" -EOF - chmod +x /root/test-csf-backend.sh +networks: + csf-internal: + driver: bridge +COMPOSE ''; deps = []; }; - # Automatic updates (optional, aber empfohlen) - system.autoUpgrade = { - enable = false; # Auf true setzen für automatische Updates - dates = "04:00"; - allowReboot = false; - }; - - # Nix settings nix = { settings = { experimental-features = [ "nix-command" "flakes" ]; From ddb75f3419e9dcc2fa3a724c96afb93f708893c7 Mon Sep 17 00:00:00 2001 From: CodeMaster4711 Date: Sun, 8 Mar 2026 18:24:06 +0100 Subject: [PATCH 2/7] fix: bump nixpkgs to 25.05 for Cargo 1.85/edition2024 support --- nixos-node/flake.nix | 2 +- nixos-node/modules/node-configuration.nix | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/nixos-node/flake.nix b/nixos-node/flake.nix index c544a22..d1d5e4c 100644 --- a/nixos-node/flake.nix +++ b/nixos-node/flake.nix @@ -2,7 +2,7 @@ description = "CSF NixOS Node Configuration"; inputs = { - nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11"; + nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05"; }; outputs = { self, nixpkgs }: diff --git a/nixos-node/modules/node-configuration.nix b/nixos-node/modules/node-configuration.nix index ed7ccfb..fe07c7f 100644 --- a/nixos-node/modules/node-configuration.nix +++ b/nixos-node/modules/node-configuration.nix @@ -1,7 +1,7 @@ { config, pkgs, lib, csf, ... }: { - system.stateVersion = "24.11"; + system.stateVersion = "25.05"; boot.loader.grub = { enable = true; From 2b6bc897531313710e9021533aafa3fc5d8ebdcf Mon Sep 17 00:00:00 2001 From: CodeMaster4711 Date: Sun, 8 Mar 2026 18:26:58 +0100 Subject: [PATCH 3/7] fix: pin rust 1.88.0 via rust-overlay for edition2024/time crate support --- nixos-node/flake.nix | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/nixos-node/flake.nix b/nixos-node/flake.nix index d1d5e4c..dbf719f 100644 --- a/nixos-node/flake.nix +++ b/nixos-node/flake.nix @@ -3,14 +3,29 @@ inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05"; + rust-overlay = { + url = "github:oxalica/rust-overlay"; + inputs.nixpkgs.follows = "nixpkgs"; + }; }; - outputs = { self, nixpkgs }: + outputs = { self, nixpkgs, rust-overlay }: let system = "x86_64-linux"; - pkgs = nixpkgs.legacyPackages.${system}; + pkgs = import nixpkgs { + inherit system; + overlays = [ rust-overlay.overlays.default ]; + }; + + rustToolchain = pkgs.rust-bin.stable."1.88.0".default.override { + extensions = [ "rust-src" ]; + targets = [ "x86_64-unknown-linux-gnu" ]; + }; - csfAgentPkg = pkgs.rustPlatform.buildRustPackage { + csfAgentPkg = (pkgs.makeRustPlatform { + cargo = rustToolchain; + rustc = rustToolchain; + }).buildRustPackage { pname = "csf-agent"; version = "0.2.2"; src = ../.; From 45952da7810a0b980e334d4eaf8f6493d994fb8e Mon Sep 17 00:00:00 2001 From: CodeMaster4711 Date: Sun, 8 Mar 2026 19:01:03 +0100 Subject: [PATCH 4/7] fix: single-node patroni+etcd, remove haproxy --- nixos-node/modules/server-configuration.nix | 105 +++++++++++--------- 1 file changed, 56 insertions(+), 49 deletions(-) diff --git a/nixos-node/modules/server-configuration.nix b/nixos-node/modules/server-configuration.nix index 51090a1..d7ebef6 100644 --- a/nixos-node/modules/server-configuration.nix +++ b/nixos-node/modules/server-configuration.nix @@ -32,10 +32,7 @@ in networkmanager.enable = true; firewall = { enable = true; - allowedTCPPorts = [ - 22 - 8000 - ]; + allowedTCPPorts = [ 22 8000 ]; }; }; @@ -71,6 +68,11 @@ in logLevel = "info"; }; + systemd.services.csf-daemon = { + after = lib.mkAfter [ "csf-control-plane.service" ]; + wants = [ "csf-control-plane.service" ]; + }; + environment.systemPackages = with pkgs; [ docker-compose curl @@ -96,7 +98,7 @@ in ExecStartPre = "${pkgs.docker}/bin/docker compose pull --quiet"; ExecStart = "${pkgs.docker}/bin/docker compose up -d --remove-orphans"; ExecStop = "${pkgs.docker}/bin/docker compose down"; - TimeoutStartSec = "300"; + TimeoutStartSec = "600"; TimeoutStopSec = "120"; }; }; @@ -107,24 +109,6 @@ in cat > ${composeDir}/docker-compose.yml <<'COMPOSE' services: - postgres: - image: postgres:16-alpine - container_name: csf-postgres - environment: - POSTGRES_USER: csf - POSTGRES_PASSWORD: csfpassword - POSTGRES_DB: csf_core - volumes: - - postgres_data:/var/lib/postgresql/data - networks: - - csf-internal - restart: unless-stopped - healthcheck: - test: ["CMD-SHELL", "pg_isready -U csf -d csf_core"] - interval: 10s - timeout: 5s - retries: 5 - etcd: image: gcr.io/etcd-development/etcd:v3.5.21 container_name: csf-etcd @@ -139,11 +123,46 @@ services: - csf-internal restart: unless-stopped + patroni: + image: ghcr.io/zalando/spilo-15:3.0-p1 + container_name: csf-patroni + hostname: patroni + environment: + PATRONI_NAME: patroni + PATRONI_SCOPE: postgres-csf + PATRONI_ETCD3_HOSTS: "etcd:2379" + PATRONI_ETCD3_PROTOCOL: http + PATRONI_POSTGRESQL_DATA_DIR: /home/postgres/pgdata + PATRONI_POSTGRESQL_LISTEN: "0.0.0.0:5432" + PATRONI_POSTGRESQL_CONNECT_ADDRESS: "patroni:5432" + PATRONI_REPLICATION_USERNAME: replicator + PATRONI_REPLICATION_PASSWORD: replpass + PATRONI_SUPERUSER_USERNAME: postgres + PATRONI_SUPERUSER_PASSWORD: postgrespass + PATRONI_RESTAPI_LISTEN: "0.0.0.0:8008" + PATRONI_RESTAPI_CONNECT_ADDRESS: "patroni:8008" + POSTGRES_USER: csf + POSTGRES_PASSWORD: csfpassword + POSTGRES_DB: csf_core + volumes: + - patroni_data:/home/postgres/pgdata + networks: + - csf-internal + depends_on: + - etcd + healthcheck: + test: ["CMD-SHELL", "curl -sf http://localhost:8008/health | grep -q running || exit 1"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 60s + restart: unless-stopped + api-gateway: image: ghcr.io/cs-foundry/csf-ce-api-gateway:0.2.2-alpha.353 container_name: csf-api-gateway environment: - DATABASE_URL: postgres://csf:csfpassword@postgres:5432/csf_core + DATABASE_URL: postgres://csf:csfpassword@patroni:5432/csf_core RUST_LOG: info JWT_SECRET: change_me_in_production RSA_KEY_SIZE: "4096" @@ -155,7 +174,7 @@ services: ports: - "8000:8000" depends_on: - postgres: + patroni: condition: service_healthy networks: - csf-internal @@ -165,22 +184,20 @@ services: interval: 30s timeout: 10s retries: 3 - start_period: 15s + start_period: 30s registry: image: ghcr.io/cs-foundry/csf-ce-registry:0.2.2-alpha.353 container_name: csf-registry environment: - DATABASE_URL: postgres://csf:csfpassword@postgres:5432/csf_core + DATABASE_URL: postgres://csf:csfpassword@patroni:5432/csf_core ETCD_ENDPOINTS: http://etcd:2379 REGISTRY_PORT: "8001" RUST_LOG: info SCHEDULER_SERVICE_URL: http://scheduler:8002 depends_on: - postgres: + patroni: condition: service_healthy - etcd: - condition: service_started networks: - csf-internal restart: unless-stopped @@ -189,15 +206,13 @@ services: image: ghcr.io/cs-foundry/csf-ce-scheduler:0.2.2-alpha.353 container_name: csf-scheduler environment: - DATABASE_URL: postgres://csf:csfpassword@postgres:5432/csf_core + DATABASE_URL: postgres://csf:csfpassword@patroni:5432/csf_core ETCD_ENDPOINTS: http://etcd:2379 SCHEDULER_PORT: "8002" RUST_LOG: info depends_on: - postgres: + patroni: condition: service_healthy - etcd: - condition: service_started networks: - csf-internal restart: unless-stopped @@ -206,17 +221,15 @@ services: image: ghcr.io/cs-foundry/csf-ce-volume-manager:0.2.2-alpha.353 container_name: csf-volume-manager environment: - DATABASE_URL: postgres://csf:csfpassword@postgres:5432/csf_core + DATABASE_URL: postgres://csf:csfpassword@patroni:5432/csf_core ETCD_ENDPOINTS: http://etcd:2379 VOLUME_MANAGER_PORT: "8003" RUST_LOG: info volumes: - /mnt/csf-volumes:/mnt/csf-volumes depends_on: - postgres: + patroni: condition: service_healthy - etcd: - condition: service_started networks: - csf-internal restart: unless-stopped @@ -225,18 +238,14 @@ services: image: ghcr.io/cs-foundry/csf-ce-failover-controller:0.2.2-alpha.353 container_name: csf-failover-controller environment: - DATABASE_URL: postgres://csf:csfpassword@postgres:5432/csf_core + DATABASE_URL: postgres://csf:csfpassword@patroni:5432/csf_core FAILOVER_CONTROLLER_PORT: "8004" SCHEDULER_SERVICE_URL: http://scheduler:8002 VOLUME_MANAGER_URL: http://volume-manager:8003 RUST_LOG: info depends_on: - postgres: + patroni: condition: service_healthy - scheduler: - condition: service_started - volume-manager: - condition: service_started networks: - csf-internal restart: unless-stopped @@ -245,22 +254,20 @@ services: image: ghcr.io/cs-foundry/csf-ce-sdn-controller:0.2.2-alpha.353 container_name: csf-sdn-controller environment: - DATABASE_URL: postgres://csf:csfpassword@postgres:5432/csf_core + DATABASE_URL: postgres://csf:csfpassword@patroni:5432/csf_core ETCD_URL: http://etcd:2379 SDN_CONTROLLER_PORT: "8005" RUST_LOG: info depends_on: - postgres: + patroni: condition: service_healthy - etcd: - condition: service_started networks: - csf-internal restart: unless-stopped volumes: - postgres_data: etcd_data: + patroni_data: networks: csf-internal: From 0e07850de14f5ac35098d4acaa9e2248a5fae7b5 Mon Sep 17 00:00:00 2001 From: CodeMaster4711 Date: Sun, 8 Mar 2026 19:23:25 +0100 Subject: [PATCH 5/7] fix: add patroni bootstrap script to create csf app user and database --- nixos-node/modules/server-configuration.nix | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/nixos-node/modules/server-configuration.nix b/nixos-node/modules/server-configuration.nix index d7ebef6..06d85af 100644 --- a/nixos-node/modules/server-configuration.nix +++ b/nixos-node/modules/server-configuration.nix @@ -141,11 +141,15 @@ services: PATRONI_SUPERUSER_PASSWORD: postgrespass PATRONI_RESTAPI_LISTEN: "0.0.0.0:8008" PATRONI_RESTAPI_CONNECT_ADDRESS: "patroni:8008" - POSTGRES_USER: csf - POSTGRES_PASSWORD: csfpassword - POSTGRES_DB: csf_core + SPILO_CONFIGURATION: | + bootstrap: + initdb: + - auth-host: md5 + - auth-local: trust + post_bootstrap: /etc/csf-bootstrap.sh volumes: - patroni_data:/home/postgres/pgdata + - /etc/csf-core/patroni-bootstrap.sh:/etc/csf-bootstrap.sh:ro networks: - csf-internal depends_on: @@ -273,6 +277,14 @@ networks: csf-internal: driver: bridge COMPOSE + + cat > ${composeDir}/patroni-bootstrap.sh <<'BOOTSTRAP' +#!/bin/bash +psql -U postgres -c "CREATE USER csf WITH PASSWORD 'csfpassword';" +psql -U postgres -c "CREATE DATABASE csf_core OWNER csf;" +psql -U postgres -c "GRANT ALL PRIVILEGES ON DATABASE csf_core TO csf;" +BOOTSTRAP + chmod +x ${composeDir}/patroni-bootstrap.sh ''; deps = []; }; From 1d64a1f205d642c5ae18d5fc67447735576247cc Mon Sep 17 00:00:00 2001 From: CodeMaster4711 Date: Sun, 8 Mar 2026 19:27:58 +0100 Subject: [PATCH 6/7] =?UTF-8?q?fix:=20master=20node=20auto-bootstrap=20?= =?UTF-8?q?=E2=80=94=20self-register=20agent=20via=20admin=20API=20on=20fi?= =?UTF-8?q?rst=20boot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- nixos-node/modules/csf-daemon.nix | 104 +++++++++++++++++++- nixos-node/modules/server-configuration.nix | 11 +-- 2 files changed, 105 insertions(+), 10 deletions(-) diff --git a/nixos-node/modules/csf-daemon.nix b/nixos-node/modules/csf-daemon.nix index d5ce327..aabec50 100644 --- a/nixos-node/modules/csf-daemon.nix +++ b/nixos-node/modules/csf-daemon.nix @@ -2,6 +2,8 @@ let cfg = config.services.csf-daemon; + tokenFile = "/var/lib/csf-daemon/bootstrap-token"; + credentialsFile = "/var/lib/csf-daemon/credentials"; in { options.services.csf-daemon = { @@ -21,7 +23,7 @@ in registrationToken = lib.mkOption { type = lib.types.str; default = ""; - description = "One-time registration token. Consumed on first boot, ignored thereafter."; + description = "Static one-time registration token. Leave empty when masterNode.enable = true."; }; heartbeatInterval = lib.mkOption { @@ -35,6 +37,21 @@ in default = "info"; description = "Log level for the daemon."; }; + + masterNode = { + enable = lib.mkEnableOption "automatic self-registration for the master control-plane node"; + + adminUsername = lib.mkOption { + type = lib.types.str; + default = "admin"; + description = "Admin username used to obtain a registration token."; + }; + + adminPassword = lib.mkOption { + type = lib.types.str; + description = "Admin password used to obtain a registration token."; + }; + }; }; config = lib.mkIf cfg.enable { @@ -52,20 +69,100 @@ in "d /var/lib/csf-daemon 0700 csf-daemon csf-daemon -" ]; + systemd.services.csf-bootstrap = lib.mkIf cfg.masterNode.enable { + description = "CSF Master Node Bootstrap (one-time self-registration)"; + after = [ "csf-control-plane.service" "network-online.target" ]; + wants = [ "network-online.target" ]; + wantedBy = [ "multi-user.target" ]; + + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + User = "root"; + }; + + script = '' + set -euo pipefail + + if [ -f ${credentialsFile} ]; then + echo "csf-bootstrap: agent already registered, skipping" + exit 0 + fi + + if [ -f ${tokenFile} ]; then + echo "csf-bootstrap: token already exists, skipping pre-register" + exit 0 + fi + + GATEWAY="${cfg.apiGateway}" + + echo "csf-bootstrap: waiting for api-gateway at $GATEWAY" + for i in $(seq 1 60); do + if ${pkgs.curl}/bin/curl -sf "$GATEWAY/api/system/health" > /dev/null 2>&1; then + break + fi + sleep 5 + done + + echo "csf-bootstrap: logging in as ${cfg.masterNode.adminUsername}" + JWT=$(${pkgs.curl}/bin/curl -sf -X POST "$GATEWAY/api/login" \ + -H "Content-Type: application/json" \ + -d '{"username":"${cfg.masterNode.adminUsername}","password":"${cfg.masterNode.adminPassword}"}' \ + | ${pkgs.jq}/bin/jq -r '.token') + + if [ -z "$JWT" ] || [ "$JWT" = "null" ]; then + echo "csf-bootstrap: login failed" + exit 1 + fi + + HOSTNAME=$(${pkgs.inetutils}/bin/hostname) + + echo "csf-bootstrap: pre-registering node $HOSTNAME" + TOKEN=$(${pkgs.curl}/bin/curl -sf -X POST "$GATEWAY/api/registry/admin/agents/pre-register" \ + -H "Authorization: Bearer $JWT" \ + -H "Content-Type: application/json" \ + -d "{\"name\":\"$HOSTNAME\",\"hostname\":\"$HOSTNAME\"}" \ + | ${pkgs.jq}/bin/jq -r '.token') + + if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then + echo "csf-bootstrap: pre-registration failed" + exit 1 + fi + + install -m 600 -o csf-daemon -g csf-daemon /dev/null ${tokenFile} + echo -n "$TOKEN" > ${tokenFile} + echo "csf-bootstrap: token written to ${tokenFile}" + ''; + }; + systemd.services.csf-daemon = { description = "CSF Local Daemon Agent"; - after = [ "network-online.target" ]; + after = [ "network-online.target" ] + ++ lib.optionals cfg.masterNode.enable [ "csf-bootstrap.service" ]; wants = [ "network-online.target" ]; wantedBy = [ "multi-user.target" ]; environment = { CSF_GATEWAY_URL = cfg.apiGateway; - CSF_REGISTRATION_TOKEN = cfg.registrationToken; CSF_HEARTBEAT_INTERVAL = toString cfg.heartbeatInterval; RUST_LOG = cfg.logLevel; + } // lib.optionalAttrs (cfg.registrationToken != "") { + CSF_REGISTRATION_TOKEN = cfg.registrationToken; }; serviceConfig = { + ExecStartPre = lib.mkIf cfg.masterNode.enable ( + "+${pkgs.writeShellScript "csf-daemon-prestart" '' + set -euo pipefail + TOKEN_ENV=/var/lib/csf-daemon/token.env + if [ -f ${tokenFile} ] && [ ! -f ${credentialsFile} ]; then + echo "CSF_REGISTRATION_TOKEN=$(cat ${tokenFile})" > "$TOKEN_ENV" + chmod 600 "$TOKEN_ENV" + chown csf-daemon:csf-daemon "$TOKEN_ENV" + fi + ''}" + ); + EnvironmentFile = lib.mkIf cfg.masterNode.enable "-/var/lib/csf-daemon/token.env"; ExecStart = "${cfg.package}/bin/csf-agent"; Restart = "always"; RestartSec = "5s"; @@ -73,7 +170,6 @@ in Group = "csf-daemon"; StateDirectory = "csf-daemon"; StateDirectoryMode = "0700"; - NoNewPrivileges = true; ProtectSystem = "strict"; ProtectHome = true; diff --git a/nixos-node/modules/server-configuration.nix b/nixos-node/modules/server-configuration.nix index 06d85af..366cbbe 100644 --- a/nixos-node/modules/server-configuration.nix +++ b/nixos-node/modules/server-configuration.nix @@ -63,14 +63,13 @@ in enable = true; package = csf.agentPackage; apiGateway = "http://localhost:8000"; - registrationToken = ""; heartbeatInterval = 60; logLevel = "info"; - }; - - systemd.services.csf-daemon = { - after = lib.mkAfter [ "csf-control-plane.service" ]; - wants = [ "csf-control-plane.service" ]; + masterNode = { + enable = true; + adminUsername = "admin"; + adminPassword = "change_me_in_production"; + }; }; environment.systemPackages = with pkgs; [ From b479ec65937ad0f4569346e2b4f921b0fd5d0a5f Mon Sep 17 00:00:00 2001 From: CodeMaster4711 Date: Sun, 8 Mar 2026 19:36:05 +0100 Subject: [PATCH 7/7] feat: cluster-wide bootstrap tokens --- .../api-gateway/src/routes/registry.rs | 43 ++++ .../registry/src/db/bootstrap_tokens.rs | 86 ++++++++ control-plane/registry/src/db/mod.rs | 1 + control-plane/registry/src/handlers/admin.rs | 46 +++++ control-plane/registry/src/handlers/agent.rs | 86 ++++---- control-plane/registry/src/main.rs | 4 + control-plane/registry/src/server.rs | 12 +- .../registry/src/services/bootstrap_tokens.rs | 185 ++++++++++++++++++ control-plane/registry/src/services/mod.rs | 1 + .../entity/src/entities/bootstrap_tokens.rs | 23 +++ .../shared/entity/src/entities/mod.rs | 2 + control-plane/shared/migration/src/lib.rs | 2 + .../m20260309_000000_add_bootstrap_tokens.rs | 49 +++++ nixos-node/modules/csf-daemon.nix | 99 +--------- nixos-node/modules/node-configuration.nix | 2 +- nixos-node/modules/server-configuration.nix | 6 +- 16 files changed, 510 insertions(+), 137 deletions(-) create mode 100644 control-plane/registry/src/db/bootstrap_tokens.rs create mode 100644 control-plane/registry/src/services/bootstrap_tokens.rs create mode 100644 control-plane/shared/entity/src/entities/bootstrap_tokens.rs create mode 100644 control-plane/shared/migration/src/m20260309_000000_add_bootstrap_tokens.rs diff --git a/control-plane/api-gateway/src/routes/registry.rs b/control-plane/api-gateway/src/routes/registry.rs index 4cb0573..4199626 100644 --- a/control-plane/api-gateway/src/routes/registry.rs +++ b/control-plane/api-gateway/src/routes/registry.rs @@ -778,6 +778,45 @@ async fn proxy_to_registry( } } +pub async fn create_bootstrap_token( + AuthenticatedUser(_claims): AuthenticatedUser, + State(state): State, + headers: HeaderMap, + body: String, +) -> Result)> { + let body_json: Option = serde_json::from_str(&body).ok(); + let header_map: Vec<(String, String)> = headers + .iter() + .filter_map(|(k, v)| v.to_str().ok().map(|val| (k.to_string(), val.to_string()))) + .collect(); + proxy_to_registry(&state, reqwest::Method::POST, "/admin/bootstrap-tokens", body_json, Some(header_map)).await +} + +pub async fn list_bootstrap_tokens( + AuthenticatedUser(_claims): AuthenticatedUser, + State(state): State, + headers: HeaderMap, +) -> Result)> { + let header_map: Vec<(String, String)> = headers + .iter() + .filter_map(|(k, v)| v.to_str().ok().map(|val| (k.to_string(), val.to_string()))) + .collect(); + proxy_to_registry(&state, reqwest::Method::GET, "/admin/bootstrap-tokens", None, Some(header_map)).await +} + +pub async fn revoke_bootstrap_token( + AuthenticatedUser(_claims): AuthenticatedUser, + State(state): State, + Path(id): Path, + headers: HeaderMap, +) -> Result)> { + let header_map: Vec<(String, String)> = headers + .iter() + .filter_map(|(k, v)| v.to_str().ok().map(|val| (k.to_string(), val.to_string()))) + .collect(); + proxy_to_registry(&state, reqwest::Method::POST, &format!("/admin/bootstrap-tokens/{}/revoke", id), None, Some(header_map)).await +} + /// Registry health check #[utoipa::path( get, @@ -811,6 +850,10 @@ pub fn registry_routes() -> Router { "/registry/admin/agents/pending/:id", post(delete_pending_agent), ) + // Admin routes - Bootstrap Token Management + .route("/registry/admin/bootstrap-tokens", post(create_bootstrap_token)) + .route("/registry/admin/bootstrap-tokens", get(list_bootstrap_tokens)) + .route("/registry/admin/bootstrap-tokens/:id/revoke", post(revoke_bootstrap_token)) // Admin routes - Token Management (DEPRECATED) .route("/registry/admin/tokens", post(create_token)) .route("/registry/admin/tokens", get(list_tokens)) diff --git a/control-plane/registry/src/db/bootstrap_tokens.rs b/control-plane/registry/src/db/bootstrap_tokens.rs new file mode 100644 index 0000000..ec7dbbf --- /dev/null +++ b/control-plane/registry/src/db/bootstrap_tokens.rs @@ -0,0 +1,86 @@ +use anyhow::Result; +use entity::bootstrap_tokens; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set, +}; +use uuid::Uuid; + +pub async fn create( + db: &DatabaseConnection, + token: String, + description: Option, + created_by: String, + expires_at: chrono::DateTime, + max_uses: i32, +) -> Result { + let model = bootstrap_tokens::ActiveModel { + id: Set(Uuid::new_v4()), + token: Set(token), + description: Set(description), + created_by: Set(created_by), + created_at: Set(chrono::Utc::now().naive_utc()), + expires_at: Set(expires_at.naive_utc()), + max_uses: Set(max_uses), + use_count: Set(0), + revoked: Set(false), + revoked_at: Set(None), + }; + Ok(model.insert(db).await?) +} + +pub async fn get_by_token( + db: &DatabaseConnection, + token: &str, +) -> Result> { + Ok(bootstrap_tokens::Entity::find() + .filter(bootstrap_tokens::Column::Token.eq(token)) + .one(db) + .await?) +} + +pub async fn increment_use_count(db: &DatabaseConnection, id: Uuid) -> Result<()> { + let mut model: bootstrap_tokens::ActiveModel = bootstrap_tokens::Entity::find_by_id(id) + .one(db) + .await? + .ok_or_else(|| anyhow::anyhow!("Bootstrap token not found"))? + .into(); + + let current = match &model.use_count { + sea_orm::ActiveValue::Unchanged(v) | sea_orm::ActiveValue::Set(v) => *v, + _ => 0, + }; + model.use_count = Set(current + 1); + model.update(db).await?; + Ok(()) +} + +pub async fn revoke(db: &DatabaseConnection, id: Uuid) -> Result<()> { + let mut model: bootstrap_tokens::ActiveModel = bootstrap_tokens::Entity::find_by_id(id) + .one(db) + .await? + .ok_or_else(|| anyhow::anyhow!("Bootstrap token not found"))? + .into(); + + model.revoked = Set(true); + model.revoked_at = Set(Some(chrono::Utc::now().naive_utc())); + model.update(db).await?; + Ok(()) +} + +pub async fn get_all_active( + db: &DatabaseConnection, +) -> Result> { + Ok(bootstrap_tokens::Entity::find() + .filter(bootstrap_tokens::Column::Revoked.eq(false)) + .all(db) + .await?) +} + +pub async fn delete_expired(db: &DatabaseConnection) -> Result { + let now = chrono::Utc::now().naive_utc(); + let result = bootstrap_tokens::Entity::delete_many() + .filter(bootstrap_tokens::Column::ExpiresAt.lt(now)) + .exec(db) + .await?; + Ok(result.rows_affected) +} diff --git a/control-plane/registry/src/db/mod.rs b/control-plane/registry/src/db/mod.rs index 75c731d..d0675a8 100644 --- a/control-plane/registry/src/db/mod.rs +++ b/control-plane/registry/src/db/mod.rs @@ -1,4 +1,5 @@ pub mod agents; pub mod api_keys; +pub mod bootstrap_tokens; pub mod certificates; pub mod tokens; diff --git a/control-plane/registry/src/handlers/admin.rs b/control-plane/registry/src/handlers/admin.rs index 360722a..25f5f05 100644 --- a/control-plane/registry/src/handlers/admin.rs +++ b/control-plane/registry/src/handlers/admin.rs @@ -3,6 +3,7 @@ use axum::{ http::StatusCode, response::Json, }; +use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::{ @@ -11,6 +12,18 @@ use crate::{ services::registry::PreRegisterParams, }; +#[derive(Debug, Deserialize)] +pub struct CreateBootstrapTokenRequest { + pub description: Option, + pub ttl_hours: Option, + pub max_uses: Option, +} + +#[derive(Debug, Serialize)] +pub struct RevokeBootstrapTokenResponse { + pub message: String, +} + pub async fn pre_register_agent( State(state): State, Json(request): Json, @@ -127,3 +140,36 @@ pub async fn get_statistics( ) -> Json { Json(state.agent_registry.get_statistics().await) } + +pub async fn create_bootstrap_token( + State(state): State, + Json(request): Json, +) -> Result, (StatusCode, Json)> { + let ttl_hours = request.ttl_hours.unwrap_or(24 * 30); + let max_uses = request.max_uses.unwrap_or(100); + + state + .bootstrap_token_manager + .create(request.description, "admin".to_string(), ttl_hours, max_uses) + .await + .map(Json) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e }))) +} + +pub async fn list_bootstrap_tokens( + State(state): State, +) -> Json> { + Json(state.bootstrap_token_manager.list().await) +} + +pub async fn revoke_bootstrap_token( + State(state): State, + Path(id): Path, +) -> Result, (StatusCode, Json)> { + state + .bootstrap_token_manager + .revoke(id) + .await + .map(|_| Json(RevokeBootstrapTokenResponse { message: format!("Bootstrap token {} revoked", id) })) + .map_err(|e| (StatusCode::NOT_FOUND, Json(ErrorResponse { error: e }))) +} diff --git a/control-plane/registry/src/handlers/agent.rs b/control-plane/registry/src/handlers/agent.rs index d04e6fd..7727626 100644 --- a/control-plane/registry/src/handlers/agent.rs +++ b/control-plane/registry/src/handlers/agent.rs @@ -15,52 +15,72 @@ pub async fn register_agent( State(state): State, Json(request): Json, ) -> Result, (StatusCode, Json)> { - let token_data = match state - .token_manager - .validate_and_consume_token(&request.registration_token) - .await - { - Ok(token) => token, - Err(e) => { + let agent_id = if crate::services::bootstrap_tokens::BootstrapTokenManager::is_bootstrap_token( + &request.registration_token, + ) { + if let Err(e) = state + .bootstrap_token_manager + .validate_and_use(&request.registration_token) + .await + { return Err(( StatusCode::UNAUTHORIZED, Json(ErrorResponse { - error: format!("Invalid registration token: {}", e), + error: format!("Invalid bootstrap token: {}", e), }), - )) + )); } - }; + uuid::Uuid::new_v4() + } else { + let token_data = match state + .token_manager + .validate_and_consume_token(&request.registration_token) + .await + { + Ok(token) => token, + Err(e) => { + return Err(( + StatusCode::UNAUTHORIZED, + Json(ErrorResponse { + error: format!("Invalid registration token: {}", e), + }), + )) + } + }; - if token_data.expected_name != request.name { - return Err(( - StatusCode::FORBIDDEN, - Json(ErrorResponse { - error: format!( - "Agent name mismatch. Expected '{}', got '{}'", - token_data.expected_name, request.name - ), - }), - )); - } + if token_data.expected_name != request.name { + return Err(( + StatusCode::FORBIDDEN, + Json(ErrorResponse { + error: format!( + "Agent name mismatch. Expected '{}', got '{}'", + token_data.expected_name, request.name + ), + }), + )); + } - if token_data.expected_hostname != request.hostname { - return Err(( - StatusCode::FORBIDDEN, - Json(ErrorResponse { - error: format!( - "Agent hostname mismatch. Expected '{}', got '{}'", - token_data.expected_hostname, request.hostname - ), - }), - )); - } + if token_data.expected_hostname != request.hostname { + return Err(( + StatusCode::FORBIDDEN, + Json(ErrorResponse { + error: format!( + "Agent hostname mismatch. Expected '{}', got '{}'", + token_data.expected_hostname, request.hostname + ), + }), + )); + } + + token_data.agent_id + }; let csr_pem = request.csr_pem.clone(); let agent = match state .agent_registry .register_agent(RegisterAgentParams { - agent_id: token_data.agent_id, + agent_id, name: request.name, hostname: request.hostname, os_type: request.os_type, diff --git a/control-plane/registry/src/main.rs b/control-plane/registry/src/main.rs index 9859715..79d0fc4 100644 --- a/control-plane/registry/src/main.rs +++ b/control-plane/registry/src/main.rs @@ -34,6 +34,7 @@ async fn main() -> anyhow::Result<()> { .expect("Failed to initialize PKI service"); let token_manager = Arc::new(services::tokens::TokenManager::new(db_conn.clone())); + let bootstrap_token_manager = Arc::new(services::bootstrap_tokens::BootstrapTokenManager::new(db_conn.clone())); let api_key_manager = Arc::new(services::api_keys::ApiKeyManager::new(db_conn.clone())); let agent_registry = Arc::new(services::registry::AgentRegistry::new(db_conn.clone())); @@ -52,6 +53,7 @@ async fn main() -> anyhow::Result<()> { let state = server::AppState { token_manager: token_manager.clone(), + bootstrap_token_manager: bootstrap_token_manager.clone(), api_key_manager: api_key_manager.clone(), agent_registry: agent_registry.clone(), pki_service: Arc::new(pki_service), @@ -63,11 +65,13 @@ async fn main() -> anyhow::Result<()> { let token_cleanup_handle = { let token_mgr = token_manager.clone(); + let bootstrap_mgr = bootstrap_token_manager.clone(); tokio::spawn(async move { let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(3600)); loop { interval.tick().await; token_mgr.cleanup_expired().await; + bootstrap_mgr.cleanup_expired().await; } }) }; diff --git a/control-plane/registry/src/server.rs b/control-plane/registry/src/server.rs index 1aa0239..fbdccc6 100644 --- a/control-plane/registry/src/server.rs +++ b/control-plane/registry/src/server.rs @@ -11,12 +11,19 @@ use std::sync::Arc; use crate::{ handlers::{admin, agent, pki}, metrics, - services::{api_keys::ApiKeyManager, pki::PkiService, registry::AgentRegistry, tokens::TokenManager}, + services::{ + api_keys::ApiKeyManager, + bootstrap_tokens::BootstrapTokenManager, + pki::PkiService, + registry::AgentRegistry, + tokens::TokenManager, + }, }; #[derive(Clone)] pub struct AppState { pub token_manager: Arc, + pub bootstrap_token_manager: Arc, pub api_key_manager: Arc, pub agent_registry: Arc, pub pki_service: Arc, @@ -42,6 +49,9 @@ pub fn create_router(state: AppState) -> Router { delete(admin::delete_pending_agent), ) .route("/admin/tokens", get(admin::list_tokens)) + .route("/admin/bootstrap-tokens", post(admin::create_bootstrap_token)) + .route("/admin/bootstrap-tokens", get(admin::list_bootstrap_tokens)) + .route("/admin/bootstrap-tokens/:id/revoke", post(admin::revoke_bootstrap_token)) .route("/admin/agents", get(admin::list_agents)) .route("/admin/agents/:agent_id", get(admin::get_agent)) .route("/admin/agents/:agent_id", delete(admin::deregister_agent)) diff --git a/control-plane/registry/src/services/bootstrap_tokens.rs b/control-plane/registry/src/services/bootstrap_tokens.rs new file mode 100644 index 0000000..5190062 --- /dev/null +++ b/control-plane/registry/src/services/bootstrap_tokens.rs @@ -0,0 +1,185 @@ +use chrono::{DateTime, Utc}; +use sea_orm::DatabaseConnection; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +const TOKEN_PREFIX: &str = "csf-bootstrap."; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BootstrapToken { + pub id: Uuid, + pub token: String, + pub description: Option, + pub created_by: String, + pub created_at: DateTime, + pub expires_at: DateTime, + pub max_uses: i32, + pub use_count: i32, + pub revoked: bool, +} + +pub struct BootstrapTokenManager { + db: DatabaseConnection, +} + +impl BootstrapTokenManager { + pub fn new(db: DatabaseConnection) -> Self { + Self { db } + } + + pub fn is_bootstrap_token(token: &str) -> bool { + token.starts_with(TOKEN_PREFIX) + } + + pub async fn create( + &self, + description: Option, + created_by: String, + ttl_hours: i64, + max_uses: i32, + ) -> Result { + let token = format!("{}{}", TOKEN_PREFIX, Uuid::new_v4().simple()); + let expires_at = Utc::now() + chrono::Duration::hours(ttl_hours); + + let model = crate::db::bootstrap_tokens::create( + &self.db, + token.clone(), + description.clone(), + created_by.clone(), + expires_at, + max_uses, + ) + .await + .map_err(|e| format!("Failed to create bootstrap token: {}", e))?; + + crate::log_info!( + "bootstrap_token_manager", + &format!( + "Created bootstrap token max_uses={} ttl_hours={} id={}", + max_uses, ttl_hours, model.id + ) + ); + + Ok(BootstrapToken { + id: model.id, + token: model.token, + description: model.description, + created_by: model.created_by, + created_at: model.created_at.and_utc(), + expires_at: model.expires_at.and_utc(), + max_uses: model.max_uses, + use_count: model.use_count, + revoked: model.revoked, + }) + } + + pub async fn validate_and_use(&self, token_str: &str) -> Result<(), String> { + let model = crate::db::bootstrap_tokens::get_by_token(&self.db, token_str) + .await + .map_err(|e| format!("Database error: {}", e))? + .ok_or_else(|| "Bootstrap token not found".to_string())?; + + if model.revoked { + crate::log_warn!( + "bootstrap_token_manager", + &format!("Rejected revoked bootstrap token id={}", model.id) + ); + return Err("Bootstrap token has been revoked".to_string()); + } + + if Utc::now().naive_utc() >= model.expires_at { + crate::log_warn!( + "bootstrap_token_manager", + &format!("Rejected expired bootstrap token id={}", model.id) + ); + return Err("Bootstrap token has expired".to_string()); + } + + if model.use_count >= model.max_uses { + crate::log_warn!( + "bootstrap_token_manager", + &format!( + "Rejected exhausted bootstrap token id={} use_count={} max_uses={}", + model.id, model.use_count, model.max_uses + ) + ); + return Err("Bootstrap token use limit reached".to_string()); + } + + crate::db::bootstrap_tokens::increment_use_count(&self.db, model.id) + .await + .map_err(|e| format!("Failed to increment use count: {}", e))?; + + crate::log_info!( + "bootstrap_token_manager", + &format!( + "Bootstrap token used id={} use_count={}/{}", + model.id, + model.use_count + 1, + model.max_uses + ) + ); + + Ok(()) + } + + pub async fn revoke(&self, id: Uuid) -> Result<(), String> { + crate::db::bootstrap_tokens::revoke(&self.db, id) + .await + .map_err(|e| format!("Failed to revoke token: {}", e))?; + + crate::log_info!( + "bootstrap_token_manager", + &format!("Bootstrap token revoked id={}", id) + ); + + Ok(()) + } + + pub async fn list(&self) -> Vec { + match crate::db::bootstrap_tokens::get_all_active(&self.db).await { + Ok(models) => models + .into_iter() + .map(|m| BootstrapToken { + id: m.id, + token: m.token, + description: m.description, + created_by: m.created_by, + created_at: m.created_at.and_utc(), + expires_at: m.expires_at.and_utc(), + max_uses: m.max_uses, + use_count: m.use_count, + revoked: m.revoked, + }) + .collect(), + Err(e) => { + crate::log_error!( + "bootstrap_token_manager", + &format!("Failed to list bootstrap tokens: {}", e) + ); + vec![] + } + } + } + + pub async fn cleanup_expired(&self) -> usize { + match crate::db::bootstrap_tokens::delete_expired(&self.db).await { + Ok(n) => { + if n > 0 { + crate::log_info!( + "bootstrap_token_manager", + &format!("Cleaned up {} expired bootstrap tokens", n) + ); + } + n as usize + } + Err(e) => { + crate::log_error!( + "bootstrap_token_manager", + &format!("Failed to cleanup expired bootstrap tokens: {}", e) + ); + 0 + } + } + } +} diff --git a/control-plane/registry/src/services/mod.rs b/control-plane/registry/src/services/mod.rs index 6fc0e3a..005823b 100644 --- a/control-plane/registry/src/services/mod.rs +++ b/control-plane/registry/src/services/mod.rs @@ -1,4 +1,5 @@ pub mod api_keys; +pub mod bootstrap_tokens; pub mod pki; pub mod registry; pub mod tokens; diff --git a/control-plane/shared/entity/src/entities/bootstrap_tokens.rs b/control-plane/shared/entity/src/entities/bootstrap_tokens.rs new file mode 100644 index 0000000..34aeec2 --- /dev/null +++ b/control-plane/shared/entity/src/entities/bootstrap_tokens.rs @@ -0,0 +1,23 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "bootstrap_tokens")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub token: String, + pub description: Option, + pub created_by: String, + pub created_at: DateTime, + pub expires_at: DateTime, + pub max_uses: i32, + pub use_count: i32, + pub revoked: bool, + pub revoked_at: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/control-plane/shared/entity/src/entities/mod.rs b/control-plane/shared/entity/src/entities/mod.rs index 75c1eeb..e5257da 100644 --- a/control-plane/shared/entity/src/entities/mod.rs +++ b/control-plane/shared/entity/src/entities/mod.rs @@ -1,4 +1,5 @@ pub mod agent_api_keys; +pub mod bootstrap_tokens; pub mod failover_events; pub mod agent_certificates; pub mod agent_metrics; @@ -22,6 +23,7 @@ pub mod volumes; pub mod workloads; pub use agent_api_keys::Entity as AgentApiKeys; +pub use bootstrap_tokens::Entity as BootstrapTokens; pub use failover_events::Entity as FailoverEvents; pub use agent_certificates::Entity as AgentCertificates; pub use agent_metrics::Entity as AgentMetrics; diff --git a/control-plane/shared/migration/src/lib.rs b/control-plane/shared/migration/src/lib.rs index 5d444ed..06c9ccb 100644 --- a/control-plane/shared/migration/src/lib.rs +++ b/control-plane/shared/migration/src/lib.rs @@ -16,6 +16,7 @@ mod m20260306_000000_add_volumes; mod m20260306_120000_add_failover_events; mod m20260307_000000_add_networks; mod m20260308_000000_add_org_scoping; +mod m20260309_000000_add_bootstrap_tokens; pub struct Migrator; @@ -39,6 +40,7 @@ impl MigratorTrait for Migrator { Box::new(m20260306_120000_add_failover_events::Migration), Box::new(m20260307_000000_add_networks::Migration), Box::new(m20260308_000000_add_org_scoping::Migration), + Box::new(m20260309_000000_add_bootstrap_tokens::Migration), ] } } diff --git a/control-plane/shared/migration/src/m20260309_000000_add_bootstrap_tokens.rs b/control-plane/shared/migration/src/m20260309_000000_add_bootstrap_tokens.rs new file mode 100644 index 0000000..0c695eb --- /dev/null +++ b/control-plane/shared/migration/src/m20260309_000000_add_bootstrap_tokens.rs @@ -0,0 +1,49 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(BootstrapTokens::Table) + .if_not_exists() + .col(ColumnDef::new(BootstrapTokens::Id).uuid().not_null().primary_key()) + .col(ColumnDef::new(BootstrapTokens::Token).string().not_null().unique_key()) + .col(ColumnDef::new(BootstrapTokens::Description).string().null()) + .col(ColumnDef::new(BootstrapTokens::CreatedBy).string().not_null()) + .col(ColumnDef::new(BootstrapTokens::CreatedAt).date_time().not_null()) + .col(ColumnDef::new(BootstrapTokens::ExpiresAt).date_time().not_null()) + .col(ColumnDef::new(BootstrapTokens::MaxUses).integer().not_null()) + .col(ColumnDef::new(BootstrapTokens::UseCount).integer().not_null().default(0)) + .col(ColumnDef::new(BootstrapTokens::Revoked).boolean().not_null().default(false)) + .col(ColumnDef::new(BootstrapTokens::RevokedAt).date_time().null()) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(BootstrapTokens::Table).to_owned()) + .await + } +} + +#[derive(Iden)] +enum BootstrapTokens { + Table, + Id, + Token, + Description, + CreatedBy, + CreatedAt, + ExpiresAt, + MaxUses, + UseCount, + Revoked, + RevokedAt, +} diff --git a/nixos-node/modules/csf-daemon.nix b/nixos-node/modules/csf-daemon.nix index aabec50..ea86b10 100644 --- a/nixos-node/modules/csf-daemon.nix +++ b/nixos-node/modules/csf-daemon.nix @@ -2,7 +2,6 @@ let cfg = config.services.csf-daemon; - tokenFile = "/var/lib/csf-daemon/bootstrap-token"; credentialsFile = "/var/lib/csf-daemon/credentials"; in { @@ -23,7 +22,7 @@ in registrationToken = lib.mkOption { type = lib.types.str; default = ""; - description = "Static one-time registration token. Leave empty when masterNode.enable = true."; + description = "Cluster-wide bootstrap token (csf-bootstrap.*) or node-specific pre-register token (reg_*). Ignored once the agent is registered."; }; heartbeatInterval = lib.mkOption { @@ -37,21 +36,6 @@ in default = "info"; description = "Log level for the daemon."; }; - - masterNode = { - enable = lib.mkEnableOption "automatic self-registration for the master control-plane node"; - - adminUsername = lib.mkOption { - type = lib.types.str; - default = "admin"; - description = "Admin username used to obtain a registration token."; - }; - - adminPassword = lib.mkOption { - type = lib.types.str; - description = "Admin password used to obtain a registration token."; - }; - }; }; config = lib.mkIf cfg.enable { @@ -69,76 +53,9 @@ in "d /var/lib/csf-daemon 0700 csf-daemon csf-daemon -" ]; - systemd.services.csf-bootstrap = lib.mkIf cfg.masterNode.enable { - description = "CSF Master Node Bootstrap (one-time self-registration)"; - after = [ "csf-control-plane.service" "network-online.target" ]; - wants = [ "network-online.target" ]; - wantedBy = [ "multi-user.target" ]; - - serviceConfig = { - Type = "oneshot"; - RemainAfterExit = true; - User = "root"; - }; - - script = '' - set -euo pipefail - - if [ -f ${credentialsFile} ]; then - echo "csf-bootstrap: agent already registered, skipping" - exit 0 - fi - - if [ -f ${tokenFile} ]; then - echo "csf-bootstrap: token already exists, skipping pre-register" - exit 0 - fi - - GATEWAY="${cfg.apiGateway}" - - echo "csf-bootstrap: waiting for api-gateway at $GATEWAY" - for i in $(seq 1 60); do - if ${pkgs.curl}/bin/curl -sf "$GATEWAY/api/system/health" > /dev/null 2>&1; then - break - fi - sleep 5 - done - - echo "csf-bootstrap: logging in as ${cfg.masterNode.adminUsername}" - JWT=$(${pkgs.curl}/bin/curl -sf -X POST "$GATEWAY/api/login" \ - -H "Content-Type: application/json" \ - -d '{"username":"${cfg.masterNode.adminUsername}","password":"${cfg.masterNode.adminPassword}"}' \ - | ${pkgs.jq}/bin/jq -r '.token') - - if [ -z "$JWT" ] || [ "$JWT" = "null" ]; then - echo "csf-bootstrap: login failed" - exit 1 - fi - - HOSTNAME=$(${pkgs.inetutils}/bin/hostname) - - echo "csf-bootstrap: pre-registering node $HOSTNAME" - TOKEN=$(${pkgs.curl}/bin/curl -sf -X POST "$GATEWAY/api/registry/admin/agents/pre-register" \ - -H "Authorization: Bearer $JWT" \ - -H "Content-Type: application/json" \ - -d "{\"name\":\"$HOSTNAME\",\"hostname\":\"$HOSTNAME\"}" \ - | ${pkgs.jq}/bin/jq -r '.token') - - if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then - echo "csf-bootstrap: pre-registration failed" - exit 1 - fi - - install -m 600 -o csf-daemon -g csf-daemon /dev/null ${tokenFile} - echo -n "$TOKEN" > ${tokenFile} - echo "csf-bootstrap: token written to ${tokenFile}" - ''; - }; - systemd.services.csf-daemon = { description = "CSF Local Daemon Agent"; - after = [ "network-online.target" ] - ++ lib.optionals cfg.masterNode.enable [ "csf-bootstrap.service" ]; + after = [ "network-online.target" ]; wants = [ "network-online.target" ]; wantedBy = [ "multi-user.target" ]; @@ -151,18 +68,6 @@ in }; serviceConfig = { - ExecStartPre = lib.mkIf cfg.masterNode.enable ( - "+${pkgs.writeShellScript "csf-daemon-prestart" '' - set -euo pipefail - TOKEN_ENV=/var/lib/csf-daemon/token.env - if [ -f ${tokenFile} ] && [ ! -f ${credentialsFile} ]; then - echo "CSF_REGISTRATION_TOKEN=$(cat ${tokenFile})" > "$TOKEN_ENV" - chmod 600 "$TOKEN_ENV" - chown csf-daemon:csf-daemon "$TOKEN_ENV" - fi - ''}" - ); - EnvironmentFile = lib.mkIf cfg.masterNode.enable "-/var/lib/csf-daemon/token.env"; ExecStart = "${cfg.package}/bin/csf-agent"; Restart = "always"; RestartSec = "5s"; diff --git a/nixos-node/modules/node-configuration.nix b/nixos-node/modules/node-configuration.nix index fe07c7f..021f9d3 100644 --- a/nixos-node/modules/node-configuration.nix +++ b/nixos-node/modules/node-configuration.nix @@ -26,7 +26,7 @@ enable = true; package = csf.agentPackage; apiGateway = "http://gateway.csf.local:8000"; - registrationToken = ""; + registrationToken = "csf-bootstrap.change_me"; heartbeatInterval = 60; logLevel = "info"; }; diff --git a/nixos-node/modules/server-configuration.nix b/nixos-node/modules/server-configuration.nix index 366cbbe..9b9d1a0 100644 --- a/nixos-node/modules/server-configuration.nix +++ b/nixos-node/modules/server-configuration.nix @@ -63,13 +63,9 @@ in enable = true; package = csf.agentPackage; apiGateway = "http://localhost:8000"; + registrationToken = "csf-bootstrap.change_me"; heartbeatInterval = 60; logLevel = "info"; - masterNode = { - enable = true; - adminUsername = "admin"; - adminPassword = "change_me_in_production"; - }; }; environment.systemPackages = with pkgs; [