Compare commits
10 Commits
8f1944ba15
...
dad5586339
Author | SHA1 | Date | |
---|---|---|---|
dad5586339 | |||
e4a19a6bb8 | |||
8bdccdc8c7 | |||
bf80b65873 | |||
f1f2b8f9ef | |||
ce6f2ce29d | |||
b33127bd34 | |||
c07f389996 | |||
4f7c2d6a66 | |||
af6a584628 |
6
.gitignore
vendored
6
.gitignore
vendored
@ -29,3 +29,9 @@ go.work.sum
|
||||
|
||||
|
||||
.local
|
||||
|
||||
*.csr
|
||||
*.crt
|
||||
*.key
|
||||
*.srl
|
||||
.kat/
|
8
Makefile
8
Makefile
@ -18,24 +18,24 @@ clean:
|
||||
# Run all tests
|
||||
test: generate
|
||||
@echo "Running all tests..."
|
||||
@go test -count=1 ./...
|
||||
@go test -v -count=1 ./... --coverprofile=coverage.out
|
||||
|
||||
# Run unit tests only (faster, no integration tests)
|
||||
test-unit:
|
||||
@echo "Running unit tests..."
|
||||
@go test -count=1 -short ./...
|
||||
@go test -v -count=1 ./...
|
||||
|
||||
# Run integration tests only
|
||||
test-integration:
|
||||
@echo "Running integration tests..."
|
||||
@go test -count=1 -run Integration ./...
|
||||
@go test -v -count=1 -run Integration ./...
|
||||
|
||||
# Run tests for a specific package
|
||||
test-package:
|
||||
@echo "Running tests for package $(PACKAGE)..."
|
||||
@go test -v ./$(PACKAGE)
|
||||
|
||||
kat-agent:
|
||||
kat-agent: $(shell find ./cmd/kat-agent -name '*.go') $(shell find . -name 'go.mod' -o -name 'go.sum')
|
||||
@echo "Building kat-agent..."
|
||||
@go build -o kat-agent ./cmd/kat-agent/main.go
|
||||
|
||||
|
@ -12,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.dws.rip/dubey/kat/internal/api"
|
||||
"git.dws.rip/dubey/kat/internal/cli"
|
||||
"git.dws.rip/dubey/kat/internal/config"
|
||||
"git.dws.rip/dubey/kat/internal/leader"
|
||||
"git.dws.rip/dubey/kat/internal/pki"
|
||||
@ -37,9 +38,34 @@ campaigns for leadership, and stores initial cluster configuration.`,
|
||||
Run: runInit,
|
||||
}
|
||||
|
||||
joinCmd = &cobra.Command{
|
||||
Use: "join",
|
||||
Short: "Joins an existing KAT cluster.",
|
||||
Long: `Connects to an existing KAT leader, submits a certificate signing request,
|
||||
and obtains the necessary credentials to participate in the cluster.`,
|
||||
Run: runJoin,
|
||||
}
|
||||
|
||||
verifyCmd = &cobra.Command{
|
||||
Use: "verify",
|
||||
Short: "Verifies node registration in etcd.",
|
||||
Long: `Connects to etcd and verifies that a node is properly registered.
|
||||
This is useful for testing and debugging.`,
|
||||
Run: runVerify,
|
||||
}
|
||||
|
||||
// Global flags / config paths
|
||||
clusterConfigPath string
|
||||
nodeName string
|
||||
|
||||
// Join command flags
|
||||
leaderAPI string
|
||||
advertiseAddr string
|
||||
leaderCACert string
|
||||
etcdPeer bool
|
||||
|
||||
// Verify command flags
|
||||
etcdEndpoint string
|
||||
)
|
||||
|
||||
const (
|
||||
@ -58,7 +84,24 @@ func init() {
|
||||
}
|
||||
initCmd.Flags().StringVar(&nodeName, "node-name", defaultHostName, "Name of this node, used as leader ID if elected.")
|
||||
|
||||
// Join command flags
|
||||
joinCmd.Flags().StringVar(&leaderAPI, "leader-api", "", "Address of the leader API (required, format: host:port)")
|
||||
joinCmd.Flags().StringVar(&advertiseAddr, "advertise-address", "", "IP address or interface name to advertise to other nodes (required)")
|
||||
joinCmd.Flags().StringVar(&nodeName, "node-name", defaultHostName, "Name for this node in the cluster")
|
||||
joinCmd.Flags().StringVar(&leaderCACert, "leader-ca-cert", "", "Path to the leader's CA certificate (optional, insecure if not provided)")
|
||||
joinCmd.Flags().BoolVar(&etcdPeer, "etcd-peer", false, "Request to join the etcd quorum (optional)")
|
||||
|
||||
// Mark required flags
|
||||
joinCmd.MarkFlagRequired("leader-api")
|
||||
joinCmd.MarkFlagRequired("advertise-address")
|
||||
|
||||
// Verify command flags
|
||||
verifyCmd.Flags().StringVar(&etcdEndpoint, "etcd-endpoint", "http://localhost:2379", "Etcd endpoint to connect to")
|
||||
verifyCmd.Flags().StringVar(&nodeName, "node-name", defaultHostName, "Name of the node to verify")
|
||||
|
||||
rootCmd.AddCommand(initCmd)
|
||||
rootCmd.AddCommand(joinCmd)
|
||||
rootCmd.AddCommand(verifyCmd)
|
||||
}
|
||||
|
||||
func runInit(cmd *cobra.Command, args []string) {
|
||||
@ -219,11 +262,9 @@ func runInit(cmd *cobra.Command, args []string) {
|
||||
log.Printf("Failed to create API server: %v", err)
|
||||
} else {
|
||||
// Register the join handler
|
||||
apiServer.RegisterJoinHandler(func(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("Received join request from %s", r.RemoteAddr)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("Join endpoint is operational"))
|
||||
})
|
||||
joinHandler := api.NewJoinHandler(etcdStore, caKeyPath, caCertPath)
|
||||
apiServer.RegisterJoinHandler(joinHandler)
|
||||
log.Printf("Registered join handler with CA key: %s, CA cert: %s", caKeyPath, caCertPath)
|
||||
|
||||
// Start the server in a goroutine
|
||||
go func() {
|
||||
@ -245,7 +286,7 @@ func runInit(cmd *cobra.Command, args []string) {
|
||||
|
||||
log.Printf("API server started on port %d with mTLS", parsedClusterConfig.Spec.ApiPort)
|
||||
log.Printf("Verification: API server requires client certificates signed by the cluster CA")
|
||||
log.Printf("Test with: curl --cacert %s --cert <client_cert> --key <client_key> https://localhost:%d/internal/v1alpha1/join",
|
||||
log.Printf("Test with: curl --cacert %s --cert <client_cert> --key <client_key> https://localhost:%d/internal/v1alpha1/join",
|
||||
caCertPath, parsedClusterConfig.Spec.ApiPort)
|
||||
}
|
||||
|
||||
@ -283,6 +324,60 @@ func runInit(cmd *cobra.Command, args []string) {
|
||||
log.Println("KAT Agent init shutdown complete.")
|
||||
}
|
||||
|
||||
func runJoin(cmd *cobra.Command, args []string) {
|
||||
log.Printf("Starting KAT Agent in join mode for node: %s", nodeName)
|
||||
log.Printf("Attempting to join cluster via leader API: %s", leaderAPI)
|
||||
|
||||
// Determine PKI directory
|
||||
// For simplicity, we'll use a default location
|
||||
pkiDir := filepath.Join(os.Getenv("HOME"), ".kat-agent", nodeName, "pki")
|
||||
|
||||
// Join the cluster
|
||||
if err := cli.JoinCluster(leaderAPI, advertiseAddr, nodeName, leaderCACert, pkiDir); err != nil {
|
||||
log.Fatalf("Failed to join cluster: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("Successfully joined cluster. Node is ready.")
|
||||
|
||||
// Setup signal handling for graceful shutdown
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
// Stay up in an idle loop until interrupted
|
||||
log.Printf("Node %s is now running. Press Ctrl+C to exit.", nodeName)
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Println("Received shutdown signal. Exiting...")
|
||||
return
|
||||
case <-ticker.C:
|
||||
log.Printf("Node %s is still running...", nodeName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runVerify(cmd *cobra.Command, args []string) {
|
||||
log.Printf("Verifying node registration for node: %s", nodeName)
|
||||
log.Printf("Connecting to etcd at: %s", etcdEndpoint)
|
||||
|
||||
// Create etcd client
|
||||
etcdStore, err := store.NewEtcdStore([]string{etcdEndpoint}, nil)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create etcd store client: %v", err)
|
||||
}
|
||||
defer etcdStore.Close()
|
||||
|
||||
// Verify node registration
|
||||
if err := cli.VerifyNodeRegistration(etcdStore, nodeName); err != nil {
|
||||
log.Fatalf("Failed to verify node registration: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("Node registration verification complete.")
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
|
@ -1,9 +1,11 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@ -11,33 +13,37 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"kat-system/internal/pki"
|
||||
"kat-system/internal/store"
|
||||
"git.dws.rip/dubey/kat/internal/pki"
|
||||
"git.dws.rip/dubey/kat/internal/store"
|
||||
)
|
||||
|
||||
// JoinRequest represents the data sent by an agent when joining
|
||||
type JoinRequest struct {
|
||||
CSR []byte `json:"csr"`
|
||||
CSRData string `json:"csrData"` // base64 encoded CSR
|
||||
AdvertiseAddr string `json:"advertiseAddr"`
|
||||
NodeName string `json:"nodeName,omitempty"` // Optional, leader can generate
|
||||
WireguardPubKey string `json:"wireguardPubKey"` // Placeholder for now
|
||||
WireGuardPubKey string `json:"wireguardPubKey"` // Placeholder for now
|
||||
}
|
||||
|
||||
// JoinResponse represents the data sent back to the agent
|
||||
type JoinResponse struct {
|
||||
NodeName string `json:"nodeName"`
|
||||
NodeUID string `json:"nodeUID"`
|
||||
SignedCert []byte `json:"signedCert"`
|
||||
CACert []byte `json:"caCert"`
|
||||
JoinTimestamp int64 `json:"joinTimestamp"`
|
||||
NodeName string `json:"nodeName"`
|
||||
NodeUID string `json:"nodeUID"`
|
||||
SignedCertificate string `json:"signedCertificate"` // base64 encoded certificate
|
||||
CACertificate string `json:"caCertificate"` // base64 encoded CA certificate
|
||||
AssignedSubnet string `json:"assignedSubnet"` // Placeholder for now
|
||||
EtcdJoinInstructions string `json:"etcdJoinInstructions,omitempty"`
|
||||
}
|
||||
|
||||
// NewJoinHandler creates a handler for agent join requests
|
||||
func NewJoinHandler(stateStore store.StateStore, caKeyPath, caCertPath string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("Received join request from %s", r.RemoteAddr)
|
||||
|
||||
// Read and parse the request body
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
log.Printf("Failed to read request body: %v", err)
|
||||
http.Error(w, fmt.Sprintf("Failed to read request body: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
@ -45,16 +51,19 @@ func NewJoinHandler(stateStore store.StateStore, caKeyPath, caCertPath string) h
|
||||
|
||||
var joinReq JoinRequest
|
||||
if err := json.Unmarshal(body, &joinReq); err != nil {
|
||||
log.Printf("Failed to parse request: %v", err)
|
||||
http.Error(w, fmt.Sprintf("Failed to parse request: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate request
|
||||
if len(joinReq.CSR) == 0 {
|
||||
http.Error(w, "Missing CSR", http.StatusBadRequest)
|
||||
if joinReq.CSRData == "" {
|
||||
log.Printf("Missing CSR data")
|
||||
http.Error(w, "Missing CSR data", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if joinReq.AdvertiseAddr == "" {
|
||||
log.Printf("Missing advertise address")
|
||||
http.Error(w, "Missing advertise address", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
@ -63,16 +72,26 @@ func NewJoinHandler(stateStore store.StateStore, caKeyPath, caCertPath string) h
|
||||
nodeName := joinReq.NodeName
|
||||
if nodeName == "" {
|
||||
nodeName = fmt.Sprintf("node-%s", uuid.New().String()[:8])
|
||||
log.Printf("Generated node name: %s", nodeName)
|
||||
}
|
||||
|
||||
// Generate a unique node ID
|
||||
nodeUID := uuid.New().String()
|
||||
log.Printf("Generated node UID: %s", nodeUID)
|
||||
|
||||
// Decode CSR data
|
||||
csrData, err := base64.StdEncoding.DecodeString(joinReq.CSRData)
|
||||
if err != nil {
|
||||
log.Printf("Failed to decode CSR data: %v", err)
|
||||
http.Error(w, fmt.Sprintf("Failed to decode CSR data: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Sign the CSR
|
||||
// Create a temporary file for the CSR
|
||||
tempDir := os.TempDir()
|
||||
csrPath := filepath.Join(tempDir, fmt.Sprintf("%s.csr", nodeUID))
|
||||
if err := os.WriteFile(csrPath, joinReq.CSR, 0600); err != nil {
|
||||
if err := os.WriteFile(csrPath, csrData, 0600); err != nil {
|
||||
log.Printf("Failed to save CSR: %v", err)
|
||||
http.Error(w, fmt.Sprintf("Failed to save CSR: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@ -81,6 +100,7 @@ func NewJoinHandler(stateStore store.StateStore, caKeyPath, caCertPath string) h
|
||||
// Sign the CSR
|
||||
certPath := filepath.Join(tempDir, fmt.Sprintf("%s.crt", nodeUID))
|
||||
if err := pki.SignCertificateRequest(caKeyPath, caCertPath, csrPath, certPath, 365*24*time.Hour); err != nil {
|
||||
log.Printf("Failed to sign CSR: %v", err)
|
||||
http.Error(w, fmt.Sprintf("Failed to sign CSR: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@ -89,6 +109,7 @@ func NewJoinHandler(stateStore store.StateStore, caKeyPath, caCertPath string) h
|
||||
// Read the signed certificate
|
||||
signedCert, err := os.ReadFile(certPath)
|
||||
if err != nil {
|
||||
log.Printf("Failed to read signed certificate: %v", err)
|
||||
http.Error(w, fmt.Sprintf("Failed to read signed certificate: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@ -96,6 +117,7 @@ func NewJoinHandler(stateStore store.StateStore, caKeyPath, caCertPath string) h
|
||||
// Read the CA certificate
|
||||
caCert, err := os.ReadFile(caCertPath)
|
||||
if err != nil {
|
||||
log.Printf("Failed to read CA certificate: %v", err)
|
||||
http.Error(w, fmt.Sprintf("Failed to read CA certificate: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@ -103,33 +125,38 @@ func NewJoinHandler(stateStore store.StateStore, caKeyPath, caCertPath string) h
|
||||
// Store node registration in etcd
|
||||
nodeRegKey := fmt.Sprintf("/kat/nodes/registration/%s", nodeName)
|
||||
nodeReg := map[string]interface{}{
|
||||
"uid": nodeUID,
|
||||
"advertiseAddr": joinReq.AdvertiseAddr,
|
||||
"wireguardPubKey": joinReq.WireguardPubKey,
|
||||
"joinTimestamp": time.Now().Unix(),
|
||||
"uid": nodeUID,
|
||||
"advertiseAddr": joinReq.AdvertiseAddr,
|
||||
"wireguardPubKey": joinReq.WireGuardPubKey,
|
||||
"joinTimestamp": time.Now().Unix(),
|
||||
}
|
||||
nodeRegData, err := json.Marshal(nodeReg)
|
||||
if err != nil {
|
||||
log.Printf("Failed to marshal node registration: %v", err)
|
||||
http.Error(w, fmt.Sprintf("Failed to marshal node registration: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Storing node registration in etcd at key: %s", nodeRegKey)
|
||||
if err := stateStore.Put(r.Context(), nodeRegKey, nodeRegData); err != nil {
|
||||
log.Printf("Failed to store node registration: %v", err)
|
||||
http.Error(w, fmt.Sprintf("Failed to store node registration: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
log.Printf("Successfully stored node registration in etcd")
|
||||
|
||||
// Prepare and send response
|
||||
joinResp := JoinResponse{
|
||||
NodeName: nodeName,
|
||||
NodeUID: nodeUID,
|
||||
SignedCert: signedCert,
|
||||
CACert: caCert,
|
||||
JoinTimestamp: time.Now().Unix(),
|
||||
NodeName: nodeName,
|
||||
NodeUID: nodeUID,
|
||||
SignedCertificate: base64.StdEncoding.EncodeToString(signedCert),
|
||||
CACertificate: base64.StdEncoding.EncodeToString(caCert),
|
||||
AssignedSubnet: "10.100.0.0/24", // Placeholder for now, will be implemented in network phase
|
||||
}
|
||||
|
||||
respData, err := json.Marshal(joinResp)
|
||||
if err != nil {
|
||||
log.Printf("Failed to marshal response: %v", err)
|
||||
http.Error(w, fmt.Sprintf("Failed to marshal response: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@ -137,5 +164,6 @@ func NewJoinHandler(stateStore store.StateStore, caKeyPath, caCertPath string) h
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(respData)
|
||||
log.Printf("Successfully processed join request for node: %s", nodeName)
|
||||
}
|
||||
}
|
||||
|
168
internal/api/join_handler_test.go
Normal file
168
internal/api/join_handler_test.go
Normal file
@ -0,0 +1,168 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.dws.rip/dubey/kat/internal/pki"
|
||||
"git.dws.rip/dubey/kat/internal/store"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
// MockStateStore for testing
|
||||
type MockStateStore struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *MockStateStore) Put(ctx context.Context, key string, value []byte) error {
|
||||
args := m.Called(ctx, key, value)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockStateStore) Get(ctx context.Context, key string) (*store.KV, error) {
|
||||
args := m.Called(ctx, key)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(*store.KV), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockStateStore) Delete(ctx context.Context, key string) error {
|
||||
args := m.Called(ctx, key)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockStateStore) List(ctx context.Context, prefix string) ([]store.KV, error) {
|
||||
args := m.Called(ctx, prefix)
|
||||
return args.Get(0).([]store.KV), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockStateStore) Watch(ctx context.Context, keyOrPrefix string, startRevision int64) (<-chan store.WatchEvent, error) {
|
||||
args := m.Called(ctx, keyOrPrefix, startRevision)
|
||||
return args.Get(0).(chan store.WatchEvent), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockStateStore) Close() error {
|
||||
args := m.Called()
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockStateStore) Campaign(ctx context.Context, leaderID string, leaseTTLSeconds int64) (context.Context, error) {
|
||||
args := m.Called(ctx, leaderID, leaseTTLSeconds)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(context.Context), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockStateStore) Resign(ctx context.Context) error {
|
||||
args := m.Called(ctx)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockStateStore) GetLeader(ctx context.Context) (string, error) {
|
||||
args := m.Called(ctx)
|
||||
return args.String(0), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockStateStore) DoTransaction(ctx context.Context, checks []store.Compare, onSuccess []store.Op, onFailure []store.Op) (bool, error) {
|
||||
args := m.Called(ctx, checks, onSuccess, onFailure)
|
||||
return args.Bool(0), args.Error(1)
|
||||
}
|
||||
|
||||
func TestJoinHandler(t *testing.T) {
|
||||
// Create temporary directory for test PKI files
|
||||
tempDir, err := os.MkdirTemp("", "kat-test-pki-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp directory: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// Generate CA for testing
|
||||
caKeyPath := filepath.Join(tempDir, "ca.key")
|
||||
caCertPath := filepath.Join(tempDir, "ca.crt")
|
||||
err = pki.GenerateCA(tempDir, caKeyPath, caCertPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate test CA: %v", err)
|
||||
}
|
||||
|
||||
// Generate a test CSR
|
||||
nodeKeyPath := filepath.Join(tempDir, "node.key")
|
||||
nodeCSRPath := filepath.Join(tempDir, "node.csr")
|
||||
err = pki.GenerateCertificateRequest("test-node", nodeKeyPath, nodeCSRPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate test CSR: %v", err)
|
||||
}
|
||||
|
||||
// Read the CSR file
|
||||
csrData, err := os.ReadFile(nodeCSRPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read CSR file: %v", err)
|
||||
}
|
||||
|
||||
// Create mock state store
|
||||
mockStore := new(MockStateStore)
|
||||
mockStore.On("Put", mock.Anything, mock.MatchedBy(func(key string) bool {
|
||||
return key == "/kat/nodes/registration/test-node"
|
||||
}), mock.Anything).Return(nil)
|
||||
|
||||
// Create join handler
|
||||
handler := NewJoinHandler(mockStore, caKeyPath, caCertPath)
|
||||
|
||||
// Create test request
|
||||
joinReq := JoinRequest{
|
||||
NodeName: "test-node",
|
||||
AdvertiseAddr: "192.168.1.100",
|
||||
CSRData: base64.StdEncoding.EncodeToString(csrData),
|
||||
WireGuardPubKey: "test-pubkey",
|
||||
}
|
||||
reqBody, err := json.Marshal(joinReq)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal join request: %v", err)
|
||||
}
|
||||
|
||||
// Create HTTP request
|
||||
req := httptest.NewRequest("POST", "/internal/v1alpha1/join", bytes.NewBuffer(reqBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
// Call handler
|
||||
handler(w, req)
|
||||
|
||||
// Check response
|
||||
resp := w.Result()
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
// Read response body
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read response body: %v", err)
|
||||
}
|
||||
|
||||
// Parse response
|
||||
var joinResp JoinResponse
|
||||
err = json.Unmarshal(respBody, &joinResp)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse response: %v", err)
|
||||
}
|
||||
|
||||
// Verify response fields
|
||||
assert.Equal(t, "test-node", joinResp.NodeName)
|
||||
assert.NotEmpty(t, joinResp.NodeUID)
|
||||
assert.NotEmpty(t, joinResp.SignedCertificate)
|
||||
assert.NotEmpty(t, joinResp.CACertificate)
|
||||
assert.Equal(t, "10.100.0.0/24", joinResp.AssignedSubnet) // Placeholder value
|
||||
|
||||
// Verify mock was called
|
||||
mockStore.AssertExpectations(t)
|
||||
}
|
@ -5,11 +5,53 @@ import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
// loggingResponseWriter is a wrapper for http.ResponseWriter to capture status code
|
||||
type loggingResponseWriter struct {
|
||||
http.ResponseWriter
|
||||
statusCode int
|
||||
}
|
||||
|
||||
// WriteHeader captures the status code before passing to the underlying ResponseWriter
|
||||
func (lrw *loggingResponseWriter) WriteHeader(code int) {
|
||||
lrw.statusCode = code
|
||||
lrw.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
// LoggingMiddleware logs information about each request
|
||||
func LoggingMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
|
||||
// Create a response writer wrapper to capture status code
|
||||
lrw := &loggingResponseWriter{
|
||||
ResponseWriter: w,
|
||||
statusCode: http.StatusOK, // Default status
|
||||
}
|
||||
|
||||
// Process the request
|
||||
next.ServeHTTP(lrw, r)
|
||||
|
||||
// Calculate duration
|
||||
duration := time.Since(start)
|
||||
|
||||
// Log the request details
|
||||
log.Printf("REQUEST: %s %s - %d %s - %s - %v",
|
||||
r.Method,
|
||||
r.URL.Path,
|
||||
lrw.statusCode,
|
||||
http.StatusText(lrw.statusCode),
|
||||
r.RemoteAddr,
|
||||
duration,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// Server represents the API server for KAT
|
||||
type Server struct {
|
||||
httpServer *http.Server
|
||||
@ -22,7 +64,7 @@ type Server struct {
|
||||
// NewServer creates a new API server instance
|
||||
func NewServer(addr string, certFile, keyFile, caFile string) (*Server, error) {
|
||||
router := NewRouter()
|
||||
|
||||
|
||||
server := &Server{
|
||||
router: router,
|
||||
certFile: certFile,
|
||||
@ -33,7 +75,7 @@ func NewServer(addr string, certFile, keyFile, caFile string) (*Server, error) {
|
||||
// Create the HTTP server with TLS config
|
||||
server.httpServer = &http.Server{
|
||||
Addr: addr,
|
||||
Handler: router,
|
||||
Handler: LoggingMiddleware(router), // Add logging middleware
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
@ -44,6 +86,8 @@ func NewServer(addr string, certFile, keyFile, caFile string) (*Server, error) {
|
||||
|
||||
// Start begins listening for requests
|
||||
func (s *Server) Start() error {
|
||||
log.Printf("Starting server on %s", s.httpServer.Addr)
|
||||
|
||||
// Load server certificate and key
|
||||
cert, err := tls.LoadX509KeyPair(s.certFile, s.keyFile)
|
||||
if err != nil {
|
||||
@ -61,24 +105,41 @@ func (s *Server) Start() error {
|
||||
return fmt.Errorf("failed to append CA certificate to pool")
|
||||
}
|
||||
|
||||
// Configure TLS
|
||||
// For Phase 2, we'll use a simpler approach - don't require client certs at all
|
||||
// This is a temporary solution until we implement proper authentication
|
||||
s.httpServer.TLSConfig = &tls.Config{
|
||||
Certificates: []tls.Certificate{cert},
|
||||
ClientAuth: tls.RequireAndVerifyClientCert,
|
||||
ClientCAs: caCertPool,
|
||||
ClientAuth: tls.NoClientCert, // Don't require client certs for now
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}
|
||||
|
||||
log.Printf("WARNING: TLS configured without client certificate verification for Phase 2")
|
||||
log.Printf("This is a temporary development configuration and should be secured in production")
|
||||
|
||||
log.Printf("Server configured with TLS, starting to listen for requests")
|
||||
// Start the server
|
||||
return s.httpServer.ListenAndServeTLS("", "")
|
||||
}
|
||||
|
||||
// Stop gracefully shuts down the server
|
||||
func (s *Server) Stop(ctx context.Context) error {
|
||||
return s.httpServer.Shutdown(ctx)
|
||||
log.Printf("Shutting down server on %s", s.httpServer.Addr)
|
||||
err := s.httpServer.Shutdown(ctx)
|
||||
if err != nil {
|
||||
log.Printf("Error during server shutdown: %v", err)
|
||||
return err
|
||||
}
|
||||
log.Printf("Server shutdown complete")
|
||||
return nil
|
||||
}
|
||||
|
||||
// RegisterJoinHandler registers the handler for agent join requests
|
||||
func (s *Server) RegisterJoinHandler(handler http.HandlerFunc) {
|
||||
s.router.HandleFunc("POST", "/internal/v1alpha1/join", handler)
|
||||
log.Printf("Registered join handler at /internal/v1alpha1/join")
|
||||
}
|
||||
|
||||
// RegisterNodeStatusHandler registers the handler for node status updates
|
||||
func (s *Server) RegisterNodeStatusHandler(handler http.HandlerFunc) {
|
||||
s.router.HandleFunc("POST", "/v1alpha1/nodes/{nodeName}/status", handler)
|
||||
}
|
||||
|
@ -12,9 +12,12 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"kat-system/internal/pki"
|
||||
"git.dws.rip/dubey/kat/internal/pki"
|
||||
)
|
||||
|
||||
// TestServerWithMTLS tests the server with TLS configuration
|
||||
// Note: In Phase 2, we've temporarily disabled client certificate verification
|
||||
// to simplify the initial join process. This test has been updated to reflect that.
|
||||
func TestServerWithMTLS(t *testing.T) {
|
||||
// Skip in short mode
|
||||
if testing.Short() {
|
||||
@ -31,7 +34,7 @@ func TestServerWithMTLS(t *testing.T) {
|
||||
// Generate CA
|
||||
caKeyPath := filepath.Join(tempDir, "ca.key")
|
||||
caCertPath := filepath.Join(tempDir, "ca.crt")
|
||||
if err := pki.GenerateCA(caKeyPath, caCertPath, "KAT Test CA", 24*time.Hour); err != nil {
|
||||
if err := pki.GenerateCA(tempDir, caKeyPath, caCertPath); err != nil {
|
||||
t.Fatalf("Failed to generate CA: %v", err)
|
||||
}
|
||||
|
||||
@ -39,7 +42,7 @@ func TestServerWithMTLS(t *testing.T) {
|
||||
serverKeyPath := filepath.Join(tempDir, "server.key")
|
||||
serverCSRPath := filepath.Join(tempDir, "server.csr")
|
||||
serverCertPath := filepath.Join(tempDir, "server.crt")
|
||||
if err := pki.GenerateCertificateRequest("server.test", serverKeyPath, serverCSRPath); err != nil {
|
||||
if err := pki.GenerateCertificateRequest("localhost", serverKeyPath, serverCSRPath); err != nil {
|
||||
t.Fatalf("Failed to generate server CSR: %v", err)
|
||||
}
|
||||
if err := pki.SignCertificateRequest(caKeyPath, caCertPath, serverCSRPath, serverCertPath, 24*time.Hour); err != nil {
|
||||
@ -58,7 +61,7 @@ func TestServerWithMTLS(t *testing.T) {
|
||||
}
|
||||
|
||||
// Create and start server
|
||||
server, err := NewServer("localhost:0", serverCertPath, serverKeyPath, caCertPath)
|
||||
server, err := NewServer("localhost:8443", serverCertPath, serverKeyPath, caCertPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create server: %v", err)
|
||||
}
|
||||
@ -76,7 +79,7 @@ func TestServerWithMTLS(t *testing.T) {
|
||||
}()
|
||||
|
||||
// Wait for server to start
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
|
||||
// Load CA cert
|
||||
caCert, err := os.ReadFile(caCertPath)
|
||||
@ -118,7 +121,7 @@ func TestServerWithMTLS(t *testing.T) {
|
||||
t.Errorf("Unexpected response: %s", body)
|
||||
}
|
||||
|
||||
// Test with no client cert (should fail)
|
||||
// Test with no client cert (should succeed in Phase 2)
|
||||
clientWithoutCert := &http.Client{
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
@ -127,9 +130,18 @@ func TestServerWithMTLS(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
_, err = clientWithoutCert.Get("https://localhost:8443/test")
|
||||
if err == nil {
|
||||
t.Error("Request without client cert should fail")
|
||||
resp, err = clientWithoutCert.Get("https://localhost:8443/test")
|
||||
if err != nil {
|
||||
t.Errorf("Request without client cert should succeed in Phase 2: %v", err)
|
||||
} else {
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Errorf("Failed to read response: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(body), "test successful") {
|
||||
t.Errorf("Unexpected response: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown server
|
||||
|
168
internal/cli/join.go
Normal file
168
internal/cli/join.go
Normal file
@ -0,0 +1,168 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"git.dws.rip/dubey/kat/internal/pki"
|
||||
)
|
||||
|
||||
// JoinRequest represents the data sent to the leader when joining
|
||||
type JoinRequest struct {
|
||||
NodeName string `json:"nodeName"`
|
||||
AdvertiseAddr string `json:"advertiseAddr"`
|
||||
CSRData string `json:"csrData"` // base64 encoded CSR
|
||||
WireGuardPubKey string `json:"wireguardPubKey"`
|
||||
}
|
||||
|
||||
// JoinResponse represents the data received from the leader after a successful join
|
||||
type JoinResponse struct {
|
||||
NodeName string `json:"nodeName"`
|
||||
NodeUID string `json:"nodeUID"`
|
||||
SignedCertificate string `json:"signedCertificate"` // base64 encoded certificate
|
||||
CACertificate string `json:"caCertificate"` // base64 encoded CA certificate
|
||||
AssignedSubnet string `json:"assignedSubnet"`
|
||||
EtcdJoinInstructions string `json:"etcdJoinInstructions,omitempty"`
|
||||
}
|
||||
|
||||
// JoinCluster sends a join request to the leader and processes the response
|
||||
func JoinCluster(leaderAPI, advertiseAddr, nodeName, leaderCACert string, pkiDir string) error {
|
||||
// Create PKI directory if it doesn't exist
|
||||
if err := os.MkdirAll(pkiDir, 0700); err != nil {
|
||||
return fmt.Errorf("failed to create PKI directory: %w", err)
|
||||
}
|
||||
|
||||
// Generate key and CSR
|
||||
nodeKeyPath := filepath.Join(pkiDir, "node.key")
|
||||
nodeCSRPath := filepath.Join(pkiDir, "node.csr")
|
||||
nodeCertPath := filepath.Join(pkiDir, "node.crt")
|
||||
caCertPath := filepath.Join(pkiDir, "ca.crt")
|
||||
|
||||
log.Printf("Generating node key and CSR...")
|
||||
if err := pki.GenerateCertificateRequest(nodeName, nodeKeyPath, nodeCSRPath); err != nil {
|
||||
return fmt.Errorf("failed to generate key and CSR: %w", err)
|
||||
}
|
||||
|
||||
// Read the CSR file
|
||||
csrData, err := os.ReadFile(nodeCSRPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read CSR file: %w", err)
|
||||
}
|
||||
|
||||
// Create join request
|
||||
joinReq := JoinRequest{
|
||||
NodeName: nodeName,
|
||||
AdvertiseAddr: advertiseAddr,
|
||||
CSRData: base64.StdEncoding.EncodeToString(csrData),
|
||||
WireGuardPubKey: "placeholder", // Will be implemented in a future phase
|
||||
}
|
||||
|
||||
// Marshal request to JSON
|
||||
reqBody, err := json.Marshal(joinReq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal join request: %w", err)
|
||||
}
|
||||
|
||||
// Create HTTP client with TLS configuration
|
||||
client := &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
|
||||
// If leader CA cert is provided, configure TLS to trust it
|
||||
if leaderCACert != "" {
|
||||
// Read the CA cert file
|
||||
caCert, err := os.ReadFile(leaderCACert)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read leader CA certificate: %w", err)
|
||||
}
|
||||
|
||||
// Create a cert pool and add the CA cert
|
||||
caCertPool := x509.NewCertPool()
|
||||
if !caCertPool.AppendCertsFromPEM(caCert) {
|
||||
return fmt.Errorf("failed to parse leader CA certificate")
|
||||
}
|
||||
|
||||
// Configure TLS
|
||||
client.Transport = &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
RootCAs: caCertPool,
|
||||
},
|
||||
}
|
||||
} else {
|
||||
// For Phase 2 development, allow insecure connections
|
||||
// This should be removed in production
|
||||
log.Println("WARNING: No leader CA certificate provided. TLS verification disabled (Phase 2 development mode).")
|
||||
log.Println("This is expected for the initial join process in Phase 2.")
|
||||
client.Transport = &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
InsecureSkipVerify: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Send join request to leader
|
||||
joinURL := fmt.Sprintf("https://%s/internal/v1alpha1/join", leaderAPI)
|
||||
log.Printf("Sending join request to %s...", joinURL)
|
||||
resp, err := client.Post(joinURL, "application/json", bytes.NewBuffer(reqBody))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send join request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read response body
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
// Check response status
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("join request failed with status %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
// Parse response
|
||||
var joinResp JoinResponse
|
||||
if err := json.Unmarshal(respBody, &joinResp); err != nil {
|
||||
return fmt.Errorf("failed to parse join response: %w", err)
|
||||
}
|
||||
|
||||
// Save signed certificate
|
||||
certData, err := base64.StdEncoding.DecodeString(joinResp.SignedCertificate)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decode signed certificate: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(nodeCertPath, certData, 0600); err != nil {
|
||||
return fmt.Errorf("failed to save signed certificate: %w", err)
|
||||
}
|
||||
log.Printf("Saved signed certificate to %s", nodeCertPath)
|
||||
|
||||
// Save CA certificate
|
||||
caCertData, err := base64.StdEncoding.DecodeString(joinResp.CACertificate)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decode CA certificate: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(caCertPath, caCertData, 0600); err != nil {
|
||||
return fmt.Errorf("failed to save CA certificate: %w", err)
|
||||
}
|
||||
log.Printf("Saved CA certificate to %s", caCertPath)
|
||||
|
||||
log.Printf("Successfully joined cluster as node: %s", joinResp.NodeName)
|
||||
if joinResp.AssignedSubnet != "" {
|
||||
log.Printf("Assigned subnet: %s", joinResp.AssignedSubnet)
|
||||
}
|
||||
if joinResp.EtcdJoinInstructions != "" {
|
||||
log.Printf("Etcd join instructions: %s", joinResp.EtcdJoinInstructions)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
53
internal/cli/verify_registration.go
Normal file
53
internal/cli/verify_registration.go
Normal file
@ -0,0 +1,53 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"git.dws.rip/dubey/kat/internal/store"
|
||||
)
|
||||
|
||||
// NodeRegistration represents the data stored in etcd for a node
|
||||
type NodeRegistration struct {
|
||||
UID string `json:"uid"`
|
||||
AdvertiseAddr string `json:"advertiseAddr"`
|
||||
WireguardPubKey string `json:"wireguardPubKey"`
|
||||
JoinTimestamp int64 `json:"joinTimestamp"`
|
||||
}
|
||||
|
||||
// VerifyNodeRegistration checks if a node is registered in etcd
|
||||
func VerifyNodeRegistration(etcdStore store.StateStore, nodeName string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Construct the key for the node registration
|
||||
nodeRegKey := fmt.Sprintf("/kat/nodes/registration/%s", nodeName)
|
||||
|
||||
// Get the node registration from etcd
|
||||
kv, err := etcdStore.Get(ctx, nodeRegKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get node registration from etcd: %w", err)
|
||||
}
|
||||
|
||||
// Parse the node registration
|
||||
var nodeReg NodeRegistration
|
||||
if err := json.Unmarshal(kv.Value, &nodeReg); err != nil {
|
||||
return fmt.Errorf("failed to parse node registration: %w", err)
|
||||
}
|
||||
|
||||
// Print the node registration details
|
||||
log.Printf("Node Registration Details:")
|
||||
log.Printf(" Node Name: %s", nodeName)
|
||||
log.Printf(" Node UID: %s", nodeReg.UID)
|
||||
log.Printf(" Advertise Address: %s", nodeReg.AdvertiseAddr)
|
||||
log.Printf(" WireGuard Public Key: %s", nodeReg.WireguardPubKey)
|
||||
|
||||
// Convert timestamp to human-readable format
|
||||
joinTime := time.Unix(nodeReg.JoinTimestamp, 0)
|
||||
log.Printf(" Join Timestamp: %s (%d)", joinTime.Format(time.RFC3339), nodeReg.JoinTimestamp)
|
||||
|
||||
return nil
|
||||
}
|
@ -201,8 +201,8 @@ func TestValidateClusterConfiguration_InvalidValues(t *testing.T) {
|
||||
ApiPort: 10251,
|
||||
EtcdPeerPort: 2380,
|
||||
EtcdClientPort: 2379,
|
||||
VolumeBasePath: "~/.kat/volumes",
|
||||
BackupPath: "~/.kat/backups",
|
||||
VolumeBasePath: ".kat/volumes",
|
||||
BackupPath: ".kat/backups",
|
||||
BackupIntervalMinutes: 30,
|
||||
AgentTickSeconds: 15,
|
||||
NodeLossTimeoutSeconds: 60,
|
||||
|
@ -11,8 +11,8 @@ const (
|
||||
DefaultApiPort = 9115
|
||||
DefaultEtcdPeerPort = 2380
|
||||
DefaultEtcdClientPort = 2379
|
||||
DefaultVolumeBasePath = "~/.kat/volumes"
|
||||
DefaultBackupPath = "~/.kat/backups"
|
||||
DefaultVolumeBasePath = ".kat/volumes"
|
||||
DefaultBackupPath = ".kat/backups"
|
||||
DefaultBackupIntervalMins = 30
|
||||
DefaultAgentTickSeconds = 15
|
||||
DefaultNodeLossTimeoutSec = 60 // DefaultNodeLossTimeoutSeconds = DefaultAgentTickSeconds * 4 (example logic)
|
||||
|
@ -22,7 +22,7 @@ const (
|
||||
// Default certificate validity period
|
||||
DefaultCertValidityDays = 365 // 1 year
|
||||
// Default PKI directory
|
||||
DefaultPKIDir = "~/.kat/pki"
|
||||
DefaultPKIDir = ".kat/pki"
|
||||
)
|
||||
|
||||
// GenerateCA creates a new Certificate Authority key pair and certificate.
|
||||
|
@ -51,8 +51,8 @@ spec:
|
||||
apiPort: 9115
|
||||
etcdPeerPort: 2380
|
||||
etcdClientPort: 2379
|
||||
volumeBasePath: "~/.kat/volumes"
|
||||
backupPath: "~/.kat/backups"
|
||||
volumeBasePath: ".kat/volumes"
|
||||
backupPath: ".kat/backups"
|
||||
backupIntervalMinutes: 30
|
||||
agentTickSeconds: 15
|
||||
nodeLossTimeoutSeconds: 60
|
||||
|
Reference in New Issue
Block a user